Batch WASM shape upload to speed up page switches

Upload structural shape attrs (base, children, blur, shadows,
flex, layout-item) via multi-shape `_set_shapes_batch` FFI in
chunks of 512, then apply host attrs with use-shape selection.
This commit is contained in:
Alejandro Alonso 2026-08-27 13:05:26 +02:00
parent 03cd3fa70f
commit 5c5eb22c9a
14 changed files with 1316 additions and 209 deletions

View File

@ -0,0 +1,456 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.render-wasm.api.upload
"Enlarged per-shape + multi-shape structural upload for WASM cold load.
Writes a binary batch consumed by `_set_shapes_batch`. Remaining
host-specific attrs (image bytes, text, path, grid tracks) are applied
afterwards via the existing per-shape setters."
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.mem :as mem]
[app.common.render-wasm.serializers :as sr]
[app.common.render-wasm.serializers.color :as sr-clr]
[app.common.render-wasm.wasm :as wasm]
[app.common.types.fills :as types.fills]
[app.common.types.fills.impl :as types.fills.impl]
[app.common.types.shape.layout :as ctl]
[app.common.uuid :as uuid]))
(def ^:const BASE-PROPS-SIZE 104)
(def ^:const FLAG-CLIP-CONTENT 0x01)
(def ^:const FLAG-HIDDEN 0x02)
(def ^:const SECTION-CHILDREN 0x01)
(def ^:const SECTION-BLUR-LAYER 0x02)
(def ^:const SECTION-BLUR-BG 0x04)
(def ^:const SECTION-SHADOWS 0x08)
(def ^:const SECTION-MASKED 0x10)
(def ^:const SECTION-BOOL-TYPE 0x20)
(def ^:const SECTION-GROW-TYPE 0x40)
(def ^:const SECTION-LAYOUT-ITEM 0x80)
(def ^:const SECTION-FLEX 0x100)
(def ^:const SECTION-FILLS 0x200)
(def ^:const SECTION-STROKES 0x400)
;; Stroke header before RawFillData (must match upload_batch.rs).
(def ^:const STROKE-HEADER-U8-SIZE 36)
(def ^:const STROKE-ALIGN-CENTER 0)
(def ^:const STROKE-ALIGN-INNER 1)
(def ^:const STROKE-ALIGN-OUTER 2)
(defn- write-uuid!
[dview offset id]
(let [buffer (uuid/get-u32 id)]
(.setUint32 dview offset (aget buffer 0) true)
(.setUint32 dview (+ offset 4) (aget buffer 1) true)
(.setUint32 dview (+ offset 8) (aget buffer 2) true)
(.setUint32 dview (+ offset 12) (aget buffer 3) true)
(+ offset 16)))
(defn- write-base-props!
"Write the 104-byte RawBasePropsData at `offset`. Returns next offset."
[dview offset shape]
(let [id (dm/get-prop shape :id)
parent-id (get shape :parent-id)
shape-type (dm/get-prop shape :type)
clip-content (if (= shape-type :frame)
(not (get shape :show-content))
false)
hidden (get shape :hidden false)
flags (cond-> 0
clip-content (bit-or FLAG-CLIP-CONTENT)
hidden (bit-or FLAG-HIDDEN))
blend-mode (sr/translate-blend-mode (get shape :blend-mode))
constraint-h (sr/translate-constraint-h (or (get shape :constraints-h) :none))
constraint-v (sr/translate-constraint-v (or (get shape :constraints-v) :none))
opacity (d/nilv (get shape :opacity) 1.0)
rotation (d/nilv (get shape :rotation) 0.0)
transform (get shape :transform)
[ta tb tc td te tf]
(if (some? transform)
[(dm/get-prop transform :a)
(dm/get-prop transform :b)
(dm/get-prop transform :c)
(dm/get-prop transform :d)
(dm/get-prop transform :e)
(dm/get-prop transform :f)]
[1.0 0.0 0.0 1.0 0.0 0.0])
selrect (get shape :selrect)
[sx1 sy1 sx2 sy2]
(if (some? selrect)
[(dm/get-prop selrect :x1)
(dm/get-prop selrect :y1)
(dm/get-prop selrect :x2)
(dm/get-prop selrect :y2)]
[0.0 0.0 0.0 0.0])
r1 (d/nilv (get shape :r1) 0.0)
r2 (d/nilv (get shape :r2) 0.0)
r3 (d/nilv (get shape :r3) 0.0)
r4 (d/nilv (get shape :r4) 0.0)]
(write-uuid! dview offset id)
(write-uuid! dview (+ offset 16) (d/nilv parent-id uuid/zero))
(.setUint8 dview (+ offset 32) (sr/translate-shape-type shape-type))
(.setUint8 dview (+ offset 33) flags)
(.setUint8 dview (+ offset 34) blend-mode)
(.setUint8 dview (+ offset 35) constraint-h)
(.setUint8 dview (+ offset 36) constraint-v)
(.setFloat32 dview (+ offset 40) opacity true)
(.setFloat32 dview (+ offset 44) rotation true)
(.setFloat32 dview (+ offset 48) ta true)
(.setFloat32 dview (+ offset 52) tb true)
(.setFloat32 dview (+ offset 56) tc true)
(.setFloat32 dview (+ offset 60) td true)
(.setFloat32 dview (+ offset 64) te true)
(.setFloat32 dview (+ offset 68) tf true)
(.setFloat32 dview (+ offset 72) sx1 true)
(.setFloat32 dview (+ offset 76) sy1 true)
(.setFloat32 dview (+ offset 80) sx2 true)
(.setFloat32 dview (+ offset 84) sy2 true)
(.setFloat32 dview (+ offset 88) r1 true)
(.setFloat32 dview (+ offset 92) r2 true)
(.setFloat32 dview (+ offset 96) r3 true)
(.setFloat32 dview (+ offset 100) r4 true)
(+ offset BASE-PROPS-SIZE)))
(defn- write-blur!
[dview offset blur]
(.setUint8 dview offset (if (get blur :hidden) 1 0))
(.setFloat32 dview (+ offset 4) (get blur :value 0) true)
(+ offset 8))
(defn- write-shadow!
[dview offset shadow]
(let [color (get shadow :color)
rgba (sr-clr/hex->u32argb (get color :color)
(get color :opacity))]
(.setUint32 dview offset rgba true)
(.setFloat32 dview (+ offset 4) (get shadow :blur 0) true)
(.setFloat32 dview (+ offset 8) (get shadow :spread 0) true)
(.setFloat32 dview (+ offset 12) (get shadow :offset-x 0) true)
(.setFloat32 dview (+ offset 16) (get shadow :offset-y 0) true)
(.setUint8 dview (+ offset 20) (sr/translate-shadow-style (get shadow :style)))
(.setUint8 dview (+ offset 21) (if (get shadow :hidden) 1 0))
(+ offset 24)))
(defn- write-flex!
[dview offset shape]
(let [dir (-> (get shape :layout-flex-dir :row)
(sr/translate-layout-flex-dir))
gap (get shape :layout-gap)
row-gap (get gap :row-gap 0)
column-gap (get gap :column-gap 0)
align-items (-> (get shape :layout-align-items) sr/translate-layout-align-items)
align-content (-> (get shape :layout-align-content) sr/translate-layout-align-content)
justify-items (-> (get shape :layout-justify-items) sr/translate-layout-justify-items)
justify-content (-> (get shape :layout-justify-content) sr/translate-layout-justify-content)
wrap-type (-> (get shape :layout-wrap-type) sr/translate-layout-wrap-type)
padding (get shape :layout-padding)
padding-top (get padding :p1 0)
padding-right (get padding :p2 0)
padding-bottom (get padding :p3 0)
padding-left (get padding :p4 0)]
(.setUint8 dview offset dir)
(.setUint8 dview (+ offset 1) align-items)
(.setUint8 dview (+ offset 2) align-content)
(.setUint8 dview (+ offset 3) justify-items)
(.setUint8 dview (+ offset 4) justify-content)
(.setUint8 dview (+ offset 5) wrap-type)
(.setFloat32 dview (+ offset 8) row-gap true)
(.setFloat32 dview (+ offset 12) column-gap true)
(.setFloat32 dview (+ offset 16) padding-top true)
(.setFloat32 dview (+ offset 20) padding-right true)
(.setFloat32 dview (+ offset 24) padding-bottom true)
(.setFloat32 dview (+ offset 28) padding-left true)
(+ offset 32)))
(defn- write-layout-item!
[dview offset shape]
(let [margins (get shape :layout-item-margin)
margin-top (get margins :m1 0)
margin-right (get margins :m2 0)
margin-bottom (get margins :m3 0)
margin-left (get margins :m4 0)
h-sizing (-> (get shape :layout-item-h-sizing) sr/translate-layout-sizing)
v-sizing (-> (get shape :layout-item-v-sizing) sr/translate-layout-sizing)
align-self (-> (get shape :layout-item-align-self) sr/translate-align-self)
max-h (get shape :layout-item-max-h)
min-h (get shape :layout-item-min-h)
max-w (get shape :layout-item-max-w)
min-w (get shape :layout-item-min-w)
is-absolute (boolean (get shape :layout-item-absolute))
z-index (get shape :layout-item-z-index)
flags (cond-> 0
(some? max-h) (bit-or 0x01)
(some? min-h) (bit-or 0x02)
(some? max-w) (bit-or 0x04)
(some? min-w) (bit-or 0x08)
is-absolute (bit-or 0x10))]
(.setFloat32 dview offset margin-top true)
(.setFloat32 dview (+ offset 4) margin-right true)
(.setFloat32 dview (+ offset 8) margin-bottom true)
(.setFloat32 dview (+ offset 12) margin-left true)
(.setUint8 dview (+ offset 16) (d/nilv h-sizing 0))
(.setUint8 dview (+ offset 17) (d/nilv v-sizing 0))
(.setUint8 dview (+ offset 18) flags)
(.setUint8 dview (+ offset 19) (d/nilv align-self 0))
(.setFloat32 dview (+ offset 20) (d/nilv max-h 0) true)
(.setFloat32 dview (+ offset 24) (d/nilv min-h 0) true)
(.setFloat32 dview (+ offset 28) (d/nilv max-w 0) true)
(.setFloat32 dview (+ offset 32) (d/nilv min-w 0) true)
(.setInt32 dview (+ offset 36) (d/nilv z-index 0) true)
(+ offset 40)))
(defn- write-fills-section!
"Write fills in the same layout as `_set_shape_fills`:
[u8 n][u8;3 pad][n × FILL-U8-SIZE]. Returns next offset."
[dview offset fills]
(let [fills (types.fills/coerce (or fills []))
byte-size (types.fills/get-byte-size fills)
;; write-to expects a Uint32Array heap + u32 element offset
heap-u32 (js/Uint32Array. (.-buffer dview))
u32-off (quot offset 4)]
(types.fills/write-to fills heap-u32 u32-off)
(+ offset byte-size)))
(defn- write-stroke-fill!
[dview offset stroke]
(let [opacity (or (:stroke-opacity stroke) 1.0)
color (:stroke-color stroke)
gradient (:stroke-color-gradient stroke)
image (:stroke-image stroke)]
(cond
(some? gradient)
(types.fills.impl/write-gradient-fill offset dview opacity gradient)
(some? image)
(types.fills.impl/write-image-fill offset dview opacity image)
(some? color)
(types.fills.impl/write-solid-fill offset dview opacity color)
:else
(types.fills.impl/write-solid-fill offset dview 0.0 "#000000"))))
(defn- write-stroke!
[dview offset stroke]
(let [width (or (:stroke-width stroke) 1.0)
style (-> stroke :stroke-style sr/translate-stroke-style)
align (case (:stroke-alignment stroke)
:inner STROKE-ALIGN-INNER
:outer STROKE-ALIGN-OUTER
STROKE-ALIGN-CENTER)
cap-start (-> stroke :stroke-cap-start sr/translate-stroke-cap)
cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap)
dash (or (:stroke-dash stroke) -1)
gap (or (:stroke-gap stroke) -1)
per-side? (boolean (:stroke-per-side stroke))
top (or (:stroke-width-top stroke) width)
right (or (:stroke-width-right stroke) width)
bottom (or (:stroke-width-bottom stroke) width)
left (or (:stroke-width-left stroke) width)
has-sides? (and per-side? (not= top right bottom left))]
(.setFloat32 dview offset width true)
(.setUint8 dview (+ offset 4) style)
(.setUint8 dview (+ offset 5) align)
(.setUint8 dview (+ offset 6) (d/nilv cap-start 0))
(.setUint8 dview (+ offset 7) (d/nilv cap-end 0))
(.setFloat32 dview (+ offset 8) dash true)
(.setFloat32 dview (+ offset 12) gap true)
(.setUint8 dview (+ offset 16) (if has-sides? 1 0))
(.setFloat32 dview (+ offset 20) top true)
(.setFloat32 dview (+ offset 24) right true)
(.setFloat32 dview (+ offset 28) bottom true)
(.setFloat32 dview (+ offset 32) left true)
(write-stroke-fill! dview (+ offset STROKE-HEADER-U8-SIZE) stroke)
(+ offset STROKE-HEADER-U8-SIZE types.fills.impl/FILL-U8-SIZE)))
(defn- visible-strokes
[shape]
(let [type (dm/get-prop shape :type)]
(if (= type :group)
[]
(into [] (remove :hidden) (or (get shape :strokes) [])))))
(defn- write-strokes-section!
[dview offset strokes]
(.setUint32 dview offset (count strokes) true)
(reduce (fn [o s] (write-stroke! dview o s))
(+ offset 4)
strokes))
(defn write-shape-payload!
"Serialize one shape's structural payload into `dview` starting at `offset`
(payload only — no length prefix). Returns the offset after the payload.
Options:
- `:include-layout?` — when true, emit FLEX + LAYOUT-ITEM (workspace cold load).
- `:include-fills-strokes?` — when true, emit FILLS + STROKES sections."
[dview offset shape {:keys [include-layout? include-fills-strokes?]
:or {include-layout? false
include-fills-strokes? false}}]
(let [shape-type (dm/get-prop shape :type)
children (into [] (filter uuid?) (get shape :shapes))
blur (get shape :blur)
bg-blur (get shape :background-blur)
shadows (or (get shape :shadow) [])
masked? (and (= shape-type :group) (boolean (get shape :masked-group)))
bool-type (when (= shape-type :bool) (get shape :bool-type))
grow-type (when (= shape-type :text) (get shape :grow-type))
flex? (and include-layout? (ctl/flex-layout? shape))
layout-item? include-layout?
strokes (when include-fills-strokes? (visible-strokes shape))
mask (cond-> 0
true (bit-or SECTION-CHILDREN)
(some? blur) (bit-or SECTION-BLUR-LAYER)
(some? bg-blur) (bit-or SECTION-BLUR-BG)
(seq shadows) (bit-or SECTION-SHADOWS)
(= shape-type :group) (bit-or SECTION-MASKED)
(some? bool-type) (bit-or SECTION-BOOL-TYPE)
(some? grow-type) (bit-or SECTION-GROW-TYPE)
flex? (bit-or SECTION-FLEX)
layout-item? (bit-or SECTION-LAYOUT-ITEM)
include-fills-strokes? (bit-or SECTION-FILLS)
include-fills-strokes? (bit-or SECTION-STROKES))
offset (write-base-props! dview offset shape)
_ (.setUint32 dview offset mask true)
offset (+ offset 4)
offset (let [o offset]
(.setUint32 dview o (count children) true)
(reduce (fn [o id] (write-uuid! dview o id))
(+ o 4)
children))
offset (cond-> offset
(some? blur)
(as-> o (write-blur! dview o blur)))
offset (cond-> offset
(some? bg-blur)
(as-> o (write-blur! dview o bg-blur)))
offset (cond-> offset
(seq shadows)
(as-> o
(do
(.setUint32 dview o (count shadows) true)
(reduce (fn [o s] (write-shadow! dview o s))
(+ o 4)
shadows))))
offset (cond-> offset
(= shape-type :group)
(as-> o
(do (.setUint8 dview o (if masked? 1 0))
(+ o 4))))
offset (cond-> offset
(some? bool-type)
(as-> o
(do (.setUint8 dview o (sr/translate-bool-type bool-type))
(+ o 4))))
offset (cond-> offset
(some? grow-type)
(as-> o
(do (.setUint8 dview o (sr/translate-grow-type grow-type))
(+ o 4))))
;; FLEX before LAYOUT-ITEM (Rust clears layout on flex)
offset (cond-> offset
flex?
(as-> o (write-flex! dview o shape)))
offset (cond-> offset
layout-item?
(as-> o (write-layout-item! dview o shape)))
offset (cond-> offset
include-fills-strokes?
(as-> o (write-fills-section! dview o (get shape :fills))))
offset (cond-> offset
include-fills-strokes?
(as-> o (write-strokes-section! dview o strokes)))]
offset))
(defn- payload-byte-size
[shape {:keys [include-layout? include-fills-strokes?]
:or {include-layout? false include-fills-strokes? false}}]
(let [children (into [] (filter uuid?) (get shape :shapes))
shadows (or (get shape :shadow) [])
shape-type (dm/get-prop shape :type)
blur (get shape :blur)
bg-blur (get shape :background-blur)
flex? (and include-layout? (ctl/flex-layout? shape))
fills-size (if include-fills-strokes?
(types.fills/get-byte-size (types.fills/coerce (or (get shape :fills) [])))
0)
strokes (when include-fills-strokes? (visible-strokes shape))
strokes-size (if include-fills-strokes?
(+ 4 (* (count strokes)
(+ STROKE-HEADER-U8-SIZE types.fills.impl/FILL-U8-SIZE)))
0)]
(+ BASE-PROPS-SIZE
4 ;; mask
(+ 4 (* 16 (count children)))
(if (some? blur) 8 0)
(if (some? bg-blur) 8 0)
(if (seq shadows) (+ 4 (* 24 (count shadows))) 0)
(if (= shape-type :group) 4 0)
(if (and (= shape-type :bool) (some? (get shape :bool-type))) 4 0)
(if (and (= shape-type :text) (some? (get shape :grow-type))) 4 0)
(if flex? 32 0)
(if include-layout? 40 0)
fills-size
strokes-size)))
(defn- encode-shape-record
"Returns a Uint8Array: [u32 payload_len][payload]."
[shape opts]
(let [capacity (+ 4 (payload-byte-size shape opts))
buffer (js/ArrayBuffer. capacity)
dview (js/DataView. buffer)
end (write-shape-payload! dview 4 shape opts)
payload-len (- end 4)]
(assert (= end capacity)
(str "upload record size mismatch: wrote " end " expected " capacity))
(.setUint32 dview 0 payload-len true)
(js/Uint8Array. buffer 0 end)))
(defn flush-shapes-batch!
"Upload `shapes` as one `_set_shapes_batch` call.
`opts` passed to each record writer (`:include-layout?`,
`:include-fills-strokes?`)."
[shapes opts]
(when (and (wasm/live?) (seq shapes))
(let [records (mapv #(encode-shape-record % opts) shapes)
total (reduce (fn [acc ^js u8] (+ acc (.-byteLength u8))) 4 records)
offset (mem/alloc total)
heap (mem/get-heap-u8)
dview (js/DataView. (.-buffer heap))]
(.setUint32 dview offset (count records) true)
(reduce (fn [o ^js u8]
(.set heap u8 o)
(+ o (.-byteLength u8)))
(+ offset 4)
records)
(h/call wasm/internal-module "_set_shapes_batch")
nil)))
(defn set-shape-upload!
"Single-shape structural upload (enlarged blob, one FFI)."
([shape]
(set-shape-upload! shape {:include-layout? false}))
([shape opts]
(flush-shapes-batch! [shape] opts)))

View File

@ -12,43 +12,29 @@
- the workspace `app.render-wasm.api/set-object` (browser), and
- the headless exporter `app.wasm.serialize/set-shape!` (Node).
It applies only the properties that need no host-specific resources or driver:
base props, children, blur, background blur, shadows, svg attrs, group mask,
bool type, path/bool geometry and text grow type. The parts that DO differ by
host are handled by each caller AFTER this runs:
- fills / strokes (image bytes are fetched + uploaded differently),
Structural attrs (base, children, blur, shadows, masked, bool, grow) go
through the enlarged `_set_shapes_batch` upload. Path geometry stays on the
chunked path FFI. Host-specific parts remain in each caller AFTER this runs:
- fills / strokes image bytes (records may already be in cold-load batch),
- text content (fonts),
- svg-raw markup (browser renders it via React),
- layout (grid/flex — workspace only).
- svg-raw markup (browser React),
- layout (grid/flex — workspace cold-load batches flex+item via upload;
incremental edits still use `set-shape-layout` / `set-layout-data`).
The incremental workspace edit path (`set-wasm-attr!`) is unaffected; it keeps
dispatching per changed key through the same underlying `props` setters."
(:require
[app.common.render-wasm.api.props :as props]
[app.common.render-wasm.api.shapes :as shapes]))
[app.common.render-wasm.api.upload :as upload]))
(defn serialize-shape!
"Applies every host-independent WASM property of `shape`. `set-shape-base-props`
runs first because it selects the current shape (`use_shape`) the rest mutate."
"Applies every host-independent WASM property of `shape`."
[shape]
(let [type (get shape :type)]
(shapes/set-shape-base-props shape)
(props/set-shape-children (get shape :shapes))
(props/set-shape-blur (get shape :blur))
(props/set-shape-background-blur (get shape :background-blur))
(props/set-shape-shadows (get shape :shadow))
(upload/set-shape-upload! shape {:include-layout? false})
(when (some? (get shape :svg-attrs))
(props/set-shape-svg-attrs (get shape :svg-attrs)))
(when (= type :group)
(props/set-masked (boolean (get shape :masked-group))))
(when (= type :bool)
(props/set-shape-bool-type (get shape :bool-type)))
(when (and (contains? #{:path :bool} type) (some? (get shape :content)))
(props/set-shape-path-content (get shape :content)))
(when (= type :text)
(props/set-shape-grow-type (get shape :grow-type)))))
(props/set-shape-path-content (get shape :content)))))

View File

@ -17,6 +17,7 @@
[app.common.logging :as log]
[app.common.math :as mth]
[app.common.render-wasm.api.props :as props]
[app.common.render-wasm.api.upload :as upload]
[app.common.render-wasm.helpers :as h]
[app.common.render-wasm.mem :as mem]
[app.common.render-wasm.mem.heap32 :as mem.h32]
@ -1030,37 +1031,56 @@
(map #(process-fill-image shape-id % thumbnail?))))))
(defn set-shape-fills
[shape-id fills thumbnail?]
;; Record write is shared with the headless exporter; the image fetch below is
;; browser-only (WebGL textures).
(when-let [fills (props/write-shape-fills! fills)]
(keep (fn [id]
(let [buffer (uuid/get-u32 id)
cached-image? (h/call wasm/internal-module "_is_image_cached"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3)
thumbnail?)]
(when (zero? cached-image?)
(fetch-image shape-id id thumbnail?))))
(types.fills/get-image-ids fills))))
"Writes fill records (unless `write?` is false) and returns pending image
fetches. When fills were already uploaded in `_set_shapes_batch`, pass
`write?` false so only image fetches remain."
([shape-id fills thumbnail?]
(set-shape-fills shape-id fills thumbnail? true))
([shape-id fills thumbnail? write?]
(let [fills (if write?
(props/write-shape-fills! fills)
(when (seq fills)
(types.fills/coerce fills)))]
(when fills
(keep (fn [id]
(let [buffer (uuid/get-u32 id)
cached-image? (h/call wasm/internal-module "_is_image_cached"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3)
thumbnail?)]
(when (zero? cached-image?)
(fetch-image shape-id id thumbnail?))))
(types.fills/get-image-ids fills))))))
(defn- stroke-image-ids
[strokes]
(into []
(comp (remove :hidden)
(keep #(get-in % [:stroke-image :id])))
(or strokes [])))
(defn set-shape-strokes
[shape-id strokes thumbnail?]
;; Record write is shared with the headless exporter; the image fetch below is
;; browser-only (WebGL textures).
(keep (fn [image-id]
(let [buffer (uuid/get-u32 image-id)
cached-image? (h/call wasm/internal-module "_is_image_cached"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3)
thumbnail?)]
(when (zero? cached-image?)
(fetch-image shape-id image-id thumbnail?))))
(props/write-shape-strokes! strokes)))
"Writes stroke records (unless `write?` is false) and returns pending image
fetches for stroke image fills."
([shape-id strokes thumbnail?]
(set-shape-strokes shape-id strokes thumbnail? true))
([shape-id strokes thumbnail? write?]
(let [image-ids (if write?
(props/write-shape-strokes! strokes)
(stroke-image-ids strokes))]
(keep (fn [image-id]
(let [buffer (uuid/get-u32 image-id)
cached-image? (h/call wasm/internal-module "_is_image_cached"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3)
thumbnail?)]
(when (zero? cached-image?)
(fetch-image shape-id image-id thumbnail?))))
image-ids))))
(defn set-shape-svg-attrs
[attrs]
@ -1545,47 +1565,70 @@
[content]
(or content (tc/v2-default-text-content)))
(defn- set-object-host-attrs
"Host-specific attrs after structural upload (text/svg-raw/grid; optionally
fills/strokes). When `skip-layout?` is true, flex+layout-item were already in
the batch; only grid tracks/cells are applied here. When
`skip-fills-strokes?` is true, fill/stroke records were already in the batch;
only image fetches remain.
Always `use-shape` first: after a multi-shape batch the WASM current shape is
the last record in the chunk, not this shape."
[shape skip-layout? & {:keys [skip-fills-strokes?] :or {skip-fills-strokes? false}}]
(let [id (dm/get-prop shape :id)
type (dm/get-prop shape :type)
fills (get shape :fills)
strokes (if (= type :group) [] (get shape :strokes))
content (let [content (get shape :content)]
(if (= type :text)
(ensure-text-content content)
content))
write-fills-strokes? (not skip-fills-strokes?)
needs-current? (or write-fills-strokes?
(= type :text)
(and (some? content) (= type :svg-raw))
(if skip-layout?
(ctl/grid-layout? shape)
true))]
(when needs-current?
(use-shape id))
(when (and (some? content) (= type :svg-raw))
(set-shape-svg-raw-content (get-static-markup shape)))
(if skip-layout?
(when (ctl/grid-layout? shape)
(set-grid-layout shape))
(do (set-shape-layout shape)
(set-layout-data shape)))
(let [is-text? (= type :text)
text-content-pending (when is-text? (set-shape-text-content id content))
pending-thumbnails (into [] (concat
text-content-pending
(when is-text? (set-shape-text-images id content true))
(set-shape-fills id fills true write-fills-strokes?)
(set-shape-strokes id strokes true write-fills-strokes?)))
pending-full (into [] (concat
(when is-text? (set-shape-text-images id content false))
(set-shape-fills id fills false write-fills-strokes?)
(set-shape-strokes id strokes false write-fills-strokes?)))]
{:thumbnails pending-thumbnails
:full pending-full
:font-pending-ids (if (some :callback text-content-pending) [id] [])})))
(defn set-object
[shape]
(if-not (and shape (wasm/live?))
{:thumbnails [] :full [] :font-pending-ids []}
(do
(perf/begin-measure "set-object")
(let [shape (svg-filters/apply-svg-derived shape)
id (dm/get-prop shape :id)
type (dm/get-prop shape :type)
fills (get shape :fills)
strokes (if (= type :group)
[] (get shape :strokes))
content (let [content (get shape :content)]
(if (= type :text)
(ensure-text-content content)
content))]
(let [shape (svg-filters/apply-svg-derived shape)]
(serialize-shape/serialize-shape! shape)
;; Browser-only: svg-raw markup (needs React) + workspace layout.
(when (and (some? content) (= type :svg-raw))
(set-shape-svg-raw-content (get-static-markup shape)))
(set-shape-layout shape)
(set-layout-data shape)
(let [is-text? (= type :text)
text-content-pending (when is-text? (set-shape-text-content id content))
pending-thumbnails (into [] (concat
text-content-pending
(when is-text? (set-shape-text-images id content true))
(set-shape-fills id fills true)
(set-shape-strokes id strokes true)))
pending-full (into [] (concat
(when is-text? (set-shape-text-images id content false))
(set-shape-fills id fills false)
(set-shape-strokes id strokes false)))]
(let [result (set-object-host-attrs shape false)]
(perf/end-measure "set-object")
{:thumbnails pending-thumbnails
:full pending-full
:font-pending-ids (if (some :callback text-content-pending) [id] [])})))))
result)))))
(defn- update-text-layouts
"Synchronously update text layouts for all shapes and send rect updates
to the worker index."
@ -1697,30 +1740,54 @@
:font-pending-ids (persistent! font-acc)}))]
(process-pending shapes thumbnails full font-pending-ids noop-fn)))
(def ^:private ^:const BATCH_MAX_SHAPES 512)
(defn- process-shapes-chunk
"Process shapes starting at `start-index` until the time budget is exhausted.
"Process up to `BATCH_MAX_SHAPES` shapes starting at `start-index`.
Structural attrs are uploaded in one `_set_shapes_batch` FFI per chunk;
host-specific attrs (fills/strokes/text/grid/path) stay per-shape.
Returns {:thumbnails [...] :full [...] :font-pending-ids [...] :next-index n}"
[shapes start-index thumbnails-acc full-acc font-pending-acc]
(let [total (count shapes)
deadline (+ (js/performance.now) CHUNK_TIME_BUDGET_MS)]
(loop [index start-index
(let [total (count shapes)
end-index (min total (+ start-index BATCH_MAX_SHAPES))
chunk (into [] (subvec (if (vector? shapes) shapes (vec shapes))
start-index end-index))
prepared (mapv svg-filters/apply-svg-derived chunk)]
;; One multi-shape structural upload (base+children+blur+shadows+flex+item+fills+strokes).
(when (seq prepared)
(upload/flush-shapes-batch! prepared {:include-layout? true
:include-fills-strokes? true}))
;; Path + svg-attrs still need the legacy per-shape path (variable/large).
(doseq [shape prepared]
(let [id (dm/get-prop shape :id)
type (dm/get-prop shape :type)]
(when (or (some? (get shape :svg-attrs))
(and (contains? #{:path :bool} type) (some? (get shape :content))))
(use-shape id)
(when (some? (get shape :svg-attrs))
(props/set-shape-svg-attrs (get shape :svg-attrs)))
(when (and (contains? #{:path :bool} type) (some? (get shape :content)))
(props/set-shape-path-content (get shape :content))))))
(loop [xs prepared
t-acc (transient thumbnails-acc)
f-acc (transient full-acc)
fp-acc (transient font-pending-acc)]
(if (and (< index total)
;; Check performance.now every 8 shapes to reduce overhead
(or (pos? (bit-and (- index start-index) 7))
(<= (js/performance.now) deadline)))
(let [shape (nth shapes index)
{:keys [thumbnails full font-pending-ids]} (set-object shape)]
(recur (inc index)
(if-let [shape (first xs)]
(let [{:keys [thumbnails full font-pending-ids]}
(set-object-host-attrs shape true :skip-fills-strokes? true)]
(recur (next xs)
(reduce conj! t-acc thumbnails)
(reduce conj! f-acc full)
(reduce conj! fp-acc font-pending-ids)))
{:thumbnails (persistent! t-acc)
:full (persistent! f-acc)
:font-pending-ids (persistent! fp-acc)
:next-index index}))))
:next-index end-index}))))
(defn- set-objects-async
"Asynchronously process shapes in time-budgeted chunks, yielding to the
@ -1847,32 +1914,50 @@
(defn- set-objects-sync
"Synchronously process all shapes (for small shape counts)."
[shapes render-callback on-shapes-ready]
(let [total-shapes (count shapes)
{:keys [thumbnails full font-pending-ids]}
(loop [index 0 thumbnails-acc (transient []) full-acc (transient []) font-acc (transient [])]
(if (< index total-shapes)
(let [shape (nth shapes index)
{:keys [thumbnails full font-pending-ids]} (set-object shape)]
(recur (inc index)
(reduce conj! thumbnails-acc thumbnails)
(reduce conj! full-acc full)
(reduce conj! font-acc font-pending-ids)))
{:thumbnails (persistent! thumbnails-acc)
:full (persistent! full-acc)
:font-pending-ids (persistent! font-acc)}))]
(perf/end-measure "set-objects")
(when on-shapes-ready (on-shapes-ready))
(when (wasm/live?)
;; Rebuild the tile index so _render knows which shapes
;; map to which tiles after a page switch.
(h/call wasm/internal-module "_set_view_end")
(reset! view-interaction-active? false)
(process-pending shapes thumbnails full font-pending-ids
(fn []
(if render-callback
(render-callback)
(request-render "set-objects-sync-complete"))
(ug/dispatch! (ug/event "penpot:wasm:set-objects")))))))
(let [prepared (mapv svg-filters/apply-svg-derived shapes)]
(when (seq prepared)
(upload/flush-shapes-batch! prepared {:include-layout? true
:include-fills-strokes? true}))
(doseq [shape prepared]
(let [id (dm/get-prop shape :id)
type (dm/get-prop shape :type)]
(when (or (some? (get shape :svg-attrs))
(and (contains? #{:path :bool} type) (some? (get shape :content))))
(use-shape id)
(when (some? (get shape :svg-attrs))
(props/set-shape-svg-attrs (get shape :svg-attrs)))
(when (and (contains? #{:path :bool} type) (some? (get shape :content)))
(props/set-shape-path-content (get shape :content))))))
(let [total-shapes (count prepared)
{:keys [thumbnails full font-pending-ids]}
(loop [index 0
thumbnails-acc (transient [])
full-acc (transient [])
font-acc (transient [])]
(if (< index total-shapes)
(let [shape (nth prepared index)
{:keys [thumbnails full font-pending-ids]}
(set-object-host-attrs shape true :skip-fills-strokes? true)]
(recur (inc index)
(reduce conj! thumbnails-acc thumbnails)
(reduce conj! full-acc full)
(reduce conj! font-acc font-pending-ids)))
{:thumbnails (persistent! thumbnails-acc)
:full (persistent! full-acc)
:font-pending-ids (persistent! font-acc)}))]
(perf/end-measure "set-objects")
(when on-shapes-ready (on-shapes-ready))
(when (wasm/live?)
;; Rebuild the tile index so _render knows which shapes
;; map to which tiles after a page switch.
(h/call wasm/internal-module "_set_view_end")
(reset! view-interaction-active? false)
(process-pending shapes thumbnails full font-pending-ids
(fn []
(if render-callback
(render-callback)
(request-render "set-objects-sync-complete"))
(ug/dispatch! (ug/event "penpot:wasm:set-objects"))))))))
(defn- shapes-in-tree-order
"Returns shapes sorted in tree order (parents before children).

View File

@ -203,6 +203,7 @@ pub extern "C" fn init_headless(_width: i32, _height: i32) -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn clean_up() -> Result<()> {
crate::performance::page_trace_reset("clean_up");
// Cancel the current animation frame if it exists so
// it won't try to render without context
let render_state = get_render_state();

View File

@ -274,6 +274,7 @@ pub extern "C" fn render_preview() -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn begin_loading() -> Result<()> {
performance::page_trace("begin_loading");
with_state!(state, {
state.loading = true;
});
@ -288,6 +289,7 @@ pub extern "C" fn end_loading() -> Result<()> {
with_state!(state, {
state.loading = false;
});
performance::page_trace_end_loading();
Ok(())
}
@ -307,6 +309,7 @@ pub extern "C" fn render_loading_overlay() -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn reset_canvas() -> Result<()> {
performance::page_trace("reset_canvas");
get_render_state().reset_canvas();
Ok(())
}
@ -321,10 +324,12 @@ pub extern "C" fn resize_viewbox(width: i32, height: i32) -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_view(zoom: f32, x: f32, y: f32) -> Result<()> {
performance::page_trace("set_view:begin");
performance::begin_measure!("set_view");
let render_state = get_render_state();
render_state.set_view(zoom, x, y);
performance::end_measure!("set_view");
performance::page_trace("set_view:end");
Ok(())
}
@ -352,6 +357,7 @@ pub extern "C" fn set_view_start() -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_view_end() -> Result<()> {
performance::page_trace("set_view_end:begin");
with_state!(state, {
performance::begin_measure!("set_view_end");
let render_state = get_render_state();
@ -382,6 +388,7 @@ pub extern "C" fn set_view_end() -> Result<()> {
}
performance::end_measure!("set_view_end");
});
performance::page_trace("set_view_end:end");
Ok(())
}
@ -444,15 +451,23 @@ pub extern "C" fn set_focus_mode() -> Result<()> {
#[no_mangle]
#[wasm_error]
pub extern "C" fn init_shapes_pool(capacity: usize) -> Result<()> {
// First open has no preceding clean_up; page switch already reset there.
if !performance::page_trace_active() {
performance::page_trace_reset(&format!("init_shapes_pool capacity={capacity}"));
} else {
performance::page_trace(&format!("init_shapes_pool capacity={capacity}"));
}
with_state!(state, {
state.init_shapes_pool(capacity);
});
performance::page_trace("init_shapes_pool:done");
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn use_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> {
performance::page_trace_use_shape();
with_state!(state, {
let id = uuid_from_u32_quartet(a, b, c, d);
state.use_shape(id);
@ -552,33 +567,8 @@ pub extern "C" fn add_shape_child(a: u32, b: u32, c: u32, d: u32) -> Result<()>
}
fn set_children_set(entries: Vec<Uuid>) -> Result<()> {
let mut deleted = Vec::new();
let mut parent_id = None;
with_current_shape_mut!(state, |shape: &mut Shape| {
parent_id = Some(shape.id);
(_, deleted) = shape.compute_children_differences(&entries);
shape.children = entries.clone();
for id in entries {
state.touch_shape(id);
if let Some(children_shape) = state.shapes.get_mut(&id) {
children_shape.set_deleted(false);
}
}
});
with_state!(state, {
let Some(parent_id) = parent_id else {
return Err(Error::RecoverableError(
"set_children_set: Parent ID not found".to_string(),
));
};
for id in deleted {
state.delete_shape_children(parent_id, id);
state.touch_shape(id);
}
state.set_current_shape_children(entries)?;
});
Ok(())
}

View File

@ -1,3 +1,5 @@
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicUsize, Ordering};
#[cfg(target_arch = "wasm32")]
pub fn get_time() -> i32 {
crate::get_now!() as i32
@ -9,6 +11,62 @@ pub fn get_time() -> i32 {
now.elapsed().as_millis() as i32
}
/// Always-on page-load / page-switch tracing (temporary profiling).
/// Prints to stdout (browser console under Emscripten). No feature gates.
/// Active only between `page_trace_reset` and first tiles-complete to avoid
/// flooding the console on pan/zoom.
static PAGE_TRACE_ACTIVE: AtomicBool = AtomicBool::new(false);
static PAGE_TRACE_EPOCH: AtomicI32 = AtomicI32::new(0);
static PAGE_TRACE_LAST: AtomicI32 = AtomicI32::new(0);
static PAGE_TRACE_USE_SHAPE: AtomicUsize = AtomicUsize::new(0);
pub fn page_trace_active() -> bool {
PAGE_TRACE_ACTIVE.load(Ordering::Relaxed)
}
/// Start a new page-load/switch session. Resets epoch and counters.
pub fn page_trace_reset(label: &str) {
let now = get_time();
PAGE_TRACE_ACTIVE.store(true, Ordering::Relaxed);
PAGE_TRACE_EPOCH.store(now, Ordering::Relaxed);
PAGE_TRACE_LAST.store(now, Ordering::Relaxed);
PAGE_TRACE_USE_SHAPE.store(0, Ordering::Relaxed);
println!("[wasm-page] === {label} === t={now}ms");
}
/// Log a milestone with delta since previous mark and since session start.
pub fn page_trace(label: &str) {
if !PAGE_TRACE_ACTIVE.load(Ordering::Relaxed) {
return;
}
let now = get_time();
let epoch = PAGE_TRACE_EPOCH.load(Ordering::Relaxed);
let last = PAGE_TRACE_LAST.load(Ordering::Relaxed);
let since_epoch = if epoch == 0 { 0 } else { now - epoch };
let since_last = if last == 0 { 0 } else { now - last };
PAGE_TRACE_LAST.store(now, Ordering::Relaxed);
println!("[wasm-page] {label} +{since_last}ms (session +{since_epoch}ms) t={now}ms");
}
/// Count a `use_shape` during the active page-load session.
pub fn page_trace_use_shape() {
if PAGE_TRACE_ACTIVE.load(Ordering::Relaxed) {
PAGE_TRACE_USE_SHAPE.fetch_add(1, Ordering::Relaxed);
}
}
/// Log end of bulk loading with shape count.
pub fn page_trace_end_loading() {
let n = PAGE_TRACE_USE_SHAPE.load(Ordering::Relaxed);
page_trace(&format!("end_loading shapes={n}"));
}
/// Final milestone of the page session (first tiles-complete), then silence.
pub fn page_trace_done(label: &str) {
page_trace(label);
PAGE_TRACE_ACTIVE.store(false, Ordering::Relaxed);
}
/// Log a message to the browser console (only when profile-macros feature is enabled)
#[macro_export]
macro_rules! console_log {

View File

@ -2332,6 +2332,7 @@ impl RenderState {
timestamp: i32,
sync_render: bool,
) -> Result<FrameType> {
performance::page_trace("start_render_loop:begin");
self.clear(tree);
let _start = performance::begin_timed_log!("start_render_loop");
@ -2536,6 +2537,7 @@ impl RenderState {
// HiDPI viewports and is not needed until the next drag.
self.present_frame(tree);
self.viewport_presented = true;
performance::page_trace_done("render:ViewportReady (tiles-complete)");
wapi::notify_tiles_render_complete!();
Self::drain_partial_gpu_soft();
}
@ -2547,11 +2549,13 @@ impl RenderState {
self.rebuild_backbuffer_crop_cache(tree);
}
self.present_frame(tree);
performance::page_trace_done("render:Full (tiles-complete)");
wapi::notify_tiles_render_complete!();
} else if !self.options.is_fast_mode() && !self.options.is_interactive_transform() {
// Interest fill finished after ViewportReady. Backbuffer
// still holds the viewport compose; rebuild crop cache
// off the sharp-snap frame.
performance::page_trace("render:Full (interest fill done)");
self.rebuild_backbuffer_crop_cache(tree);
}
performance::end_measure!("render");
@ -4277,6 +4281,7 @@ impl RenderState {
/// until the post-gesture full render replaces them.
pub fn rebuild_tile_index(&mut self, tree: ShapesPoolRef) {
let zoom_changed = self.zoom_changed();
performance::page_trace("rebuild_tile_index:begin");
performance::begin_measure!("rebuild_tile_index");
let mut nodes = Vec::<Uuid>::with_capacity(64);
nodes.push(Uuid::nil());
@ -4297,6 +4302,7 @@ impl RenderState {
}
}
performance::end_measure!("rebuild_tile_index");
performance::page_trace("rebuild_tile_index:end");
}
pub fn rebuild_tiles_shallow(&mut self, tree: ShapesPoolRef) {

View File

@ -340,6 +340,36 @@ impl State {
self.shapes.set_modifiers(modifiers);
}
/// Replace the current shape's children list (same semantics as `_set_children`).
pub fn set_current_shape_children(&mut self, entries: Vec<Uuid>) -> Result<()> {
let mut deleted = Vec::new();
let parent_id = {
let Some(shape) = self.current_shape_mut() else {
return Err(Error::RecoverableError(
"set_current_shape_children: no current shape".to_string(),
));
};
let id = shape.id;
(_, deleted) = shape.compute_children_differences(&entries);
shape.children = entries.clone();
id
};
for id in &entries {
self.touch_shape(*id);
if let Some(children_shape) = self.shapes.get_mut(id) {
children_shape.set_deleted(false);
}
}
for id in deleted {
self.delete_shape_children(parent_id, id);
self.touch_shape(id);
}
Ok(())
}
pub fn touch_current(&mut self) {
// `mark_touched` only drives incremental on-screen tile invalidation;
// the headless export path has no render state, so skip it there.

View File

@ -82,12 +82,15 @@ impl ShapesPoolImpl {
}
pub fn initialize(&mut self, capacity: usize) {
performance::page_trace("shapes_pool_initialize:begin");
performance::begin_measure!("shapes_pool_initialize");
self.counter = 0;
self.uuid_to_idx = HashMap::with_capacity(capacity);
let additional = capacity as i32 - self.shapes.len() as i32;
if additional <= 0 {
performance::end_measure!("shapes_pool_initialize");
performance::page_trace("shapes_pool_initialize:done (reuse)");
return;
}
@ -99,6 +102,7 @@ impl ShapesPoolImpl {
self.shapes
.extend(iter::repeat_with(|| Shape::new(Uuid::nil())).take(additional as usize));
performance::end_measure!("shapes_pool_initialize");
performance::page_trace("shapes_pool_initialize:done");
}
pub fn add_shape(&mut self, id: Uuid) -> &mut Shape {

View File

@ -8,7 +8,7 @@ mod gradient;
mod image;
mod solid;
const RAW_FILL_DATA_SIZE: usize = std::mem::size_of::<RawFillData>();
pub const RAW_FILL_DATA_SIZE: usize = std::mem::size_of::<RawFillData>();
#[repr(C, u8, align(4))]
#[derive(Debug, PartialEq, Clone, Copy, ToJs)]

View File

@ -4,9 +4,14 @@ use macros::ToJs;
mod align;
pub mod constraints;
mod flex;
pub mod flex;
mod grid;
pub use align::{
RawAlignContent, RawAlignItems, RawAlignSelf, RawJustifyContent, RawJustifyItems,
};
pub use flex::{RawFlexDirection, RawWrapType};
#[derive(Debug, Clone, PartialEq, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]

View File

@ -12,10 +12,10 @@ use macros::wasm_error;
use super::RawShapeType;
const FLAG_CLIP_CONTENT: u8 = 0b0000_0001;
const FLAG_HIDDEN: u8 = 0b0000_0010;
pub const FLAG_CLIP_CONTENT: u8 = 0b0000_0001;
pub const FLAG_HIDDEN: u8 = 0b0000_0010;
const RAW_BASE_PROPS_SIZE: usize = std::mem::size_of::<RawBasePropsData>();
pub const RAW_BASE_PROPS_SIZE: usize = std::mem::size_of::<RawBasePropsData>();
/// Binary layout for batched shape base properties.
///
@ -26,70 +26,70 @@ const RAW_BASE_PROPS_SIZE: usize = std::mem::size_of::<RawBasePropsData>();
#[derive(Debug, Clone, Copy)]
pub struct RawBasePropsData {
// UUID id (16 bytes)
id_a: u32,
id_b: u32,
id_c: u32,
id_d: u32,
pub id_a: u32,
pub id_b: u32,
pub id_c: u32,
pub id_d: u32,
// UUID parent_id (16 bytes)
parent_a: u32,
parent_b: u32,
parent_c: u32,
parent_d: u32,
pub parent_a: u32,
pub parent_b: u32,
pub parent_c: u32,
pub parent_d: u32,
// Single-byte fields
shape_type: u8,
flags: u8,
blend_mode: u8,
constraint_h: u8,
constraint_v: u8,
padding: [u8; 3],
pub shape_type: u8,
pub flags: u8,
pub blend_mode: u8,
pub constraint_h: u8,
pub constraint_v: u8,
pub padding: [u8; 3],
// f32 fields
opacity: f32,
rotation: f32,
pub opacity: f32,
pub rotation: f32,
// Transform matrix (a, b, c, d, e, f)
transform_a: f32,
transform_b: f32,
transform_c: f32,
transform_d: f32,
transform_e: f32,
transform_f: f32,
pub transform_a: f32,
pub transform_b: f32,
pub transform_c: f32,
pub transform_d: f32,
pub transform_e: f32,
pub transform_f: f32,
// Selrect (x1, y1, x2, y2)
selrect_x1: f32,
selrect_y1: f32,
selrect_x2: f32,
selrect_y2: f32,
pub selrect_x1: f32,
pub selrect_y1: f32,
pub selrect_x2: f32,
pub selrect_y2: f32,
// Corners (r1, r2, r3, r4)
corner_r1: f32,
corner_r2: f32,
corner_r3: f32,
corner_r4: f32,
pub corner_r1: f32,
pub corner_r2: f32,
pub corner_r3: f32,
pub corner_r4: f32,
}
impl RawBasePropsData {
fn id(&self) -> Uuid {
pub fn id(&self) -> Uuid {
uuid_from_u32_quartet(self.id_a, self.id_b, self.id_c, self.id_d)
}
fn parent_id(&self) -> Uuid {
pub fn parent_id(&self) -> Uuid {
uuid_from_u32_quartet(self.parent_a, self.parent_b, self.parent_c, self.parent_d)
}
fn clip_content(&self) -> bool {
pub fn clip_content(&self) -> bool {
(self.flags & FLAG_CLIP_CONTENT) != 0
}
fn hidden(&self) -> bool {
pub fn hidden(&self) -> bool {
(self.flags & FLAG_HIDDEN) != 0
}
fn blend_mode(&self) -> BlendMode {
pub fn blend_mode(&self) -> BlendMode {
RawBlendMode::from(self.blend_mode).into()
}
fn constraint_h(&self) -> Option<ConstraintH> {
pub fn constraint_h(&self) -> Option<ConstraintH> {
RawConstraintH::from(self.constraint_h).into()
}
fn constraint_v(&self) -> Option<ConstraintV> {
pub fn constraint_v(&self) -> Option<ConstraintV> {
RawConstraintV::from(self.constraint_v).into()
}
}
@ -100,21 +100,8 @@ impl From<[u8; RAW_BASE_PROPS_SIZE]> for RawBasePropsData {
}
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_shape_base_props() -> Result<()> {
let bytes = mem::bytes();
if bytes.len() < RAW_BASE_PROPS_SIZE {
return Ok(());
}
// FIXME: this should just be a try_from
let data: [u8; RAW_BASE_PROPS_SIZE] = bytes[..RAW_BASE_PROPS_SIZE]
.try_into()
.map_err(|_| Error::CriticalError("Invalid bytes for base props".to_string()))?;
let raw = RawBasePropsData::from(data);
/// Apply base props from a parsed record (selects the shape and sets core attrs).
pub fn apply_base_props(raw: &RawBasePropsData) -> Result<()> {
let id = raw.id();
let parent_id = raw.parent_id();
let shape_type = RawShapeType::from(raw.shape_type);
@ -153,6 +140,24 @@ pub extern "C" fn set_shape_base_props() -> Result<()> {
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_shape_base_props() -> Result<()> {
let bytes = mem::bytes();
if bytes.len() < RAW_BASE_PROPS_SIZE {
return Ok(());
}
// FIXME: this should just be a try_from
let data: [u8; RAW_BASE_PROPS_SIZE] = bytes[..RAW_BASE_PROPS_SIZE]
.try_into()
.map_err(|_| Error::CriticalError("Invalid bytes for base props".to_string()))?;
let raw = RawBasePropsData::from(data);
apply_base_props(&raw)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -1,8 +1,9 @@
mod base_props;
mod upload_batch;
use macros::ToJs;
use crate::shapes::{Bool, Frame, Group, Path, Rect, SVGRaw, TextContent, Type};
use crate::shapes::{Bool, Frame, Group, Path, Rect, Shape, SVGRaw, TextContent, Type};
use crate::with_current_shape_mut;
#[derive(Debug, Clone, PartialEq, ToJs)]

View File

@ -0,0 +1,480 @@
//! Multi-shape / enlarged cold-upload batch protocol.
//!
//! Buffer layout:
//! ```text
//! [u32 shape_count]
//! repeat shape_count times:
//! [u32 payload_len] // bytes after this u32
//! [104 base props] // RawBasePropsData
//! [u32 section_mask]
//! optional sections (presence via mask; order is FIXED, not bit-numeric):
//! CHILDREN: [u32 n][n × 16 uuid]
//! BLUR_LAYER: [u8 hidden][u8;3 pad][f32 value]
//! BLUR_BG: same
//! SHADOWS: [u32 n][n × 24]
//! MASKED: [u8 value][u8;3 pad]
//! BOOL_TYPE: [u8 value][u8;3 pad]
//! GROW_TYPE: [u8 value][u8;3 pad]
//! FLEX: 32 bytes (clears container layout, then sets flex)
//! LAYOUT_ITEM: 40 bytes (must follow FLEX so clear_layout does not wipe it)
//! FILLS: [u8 n][u8;3][n × RawFillData] (same as set_shape_fills)
//! STROKES: [u32 n][n × (36-byte header + RawFillData)]
//! ```
//!
//! Text, path geometry, and grid tracks/cells stay on the legacy
//! per-shape FFI path after the batch flush.
use skia_safe as skia;
use crate::mem;
use crate::performance;
use crate::shapes::{
Blur, BlurType, BoolType, GrowType, Shadow, ShadowStyle, Shape, Stroke, Type,
};
use crate::utils::uuid_from_u32_quartet;
use crate::uuid::Uuid;
use crate::wasm::fills::{read_fills_from_bytes, RawFillData, RAW_FILL_DATA_SIZE};
use crate::wasm::layouts::{
RawAlignContent, RawAlignItems, RawAlignSelf, RawFlexDirection, RawJustifyContent,
RawJustifyItems, RawSizing, RawWrapType,
};
use crate::wasm::shadows::RawShadowStyle;
use crate::wasm::shapes::base_props::{apply_base_props, RawBasePropsData, RAW_BASE_PROPS_SIZE};
use crate::wasm::strokes::{RawStrokeCap, RawStrokeStyle};
use crate::with_current_shape_mut;
use crate::with_state;
#[allow(unused_imports)]
use crate::error::{Error, Result};
use macros::wasm_error;
pub const SECTION_CHILDREN: u32 = 1 << 0;
pub const SECTION_BLUR_LAYER: u32 = 1 << 1;
pub const SECTION_BLUR_BG: u32 = 1 << 2;
pub const SECTION_SHADOWS: u32 = 1 << 3;
pub const SECTION_MASKED: u32 = 1 << 4;
pub const SECTION_BOOL_TYPE: u32 = 1 << 5;
pub const SECTION_GROW_TYPE: u32 = 1 << 6;
pub const SECTION_LAYOUT_ITEM: u32 = 1 << 7;
pub const SECTION_FLEX: u32 = 1 << 8;
pub const SECTION_FILLS: u32 = 1 << 9;
pub const SECTION_STROKES: u32 = 1 << 10;
const STROKE_ALIGN_CENTER: u8 = 0;
const STROKE_ALIGN_INNER: u8 = 1;
const STROKE_ALIGN_OUTER: u8 = 2;
fn decode_optional_f32(value: f32) -> Option<f32> {
if value.is_finite() && value >= 0.0 {
Some(value)
} else {
None
}
}
struct Cursor<'a> {
data: &'a [u8],
pos: usize,
}
impl<'a> Cursor<'a> {
fn new(data: &'a [u8]) -> Self {
Self { data, pos: 0 }
}
fn remaining(&self) -> usize {
self.data.len().saturating_sub(self.pos)
}
fn take(&mut self, n: usize) -> Result<&'a [u8]> {
if self.remaining() < n {
return Err(Error::CriticalError(
"upload_batch: truncated buffer".to_string(),
));
}
let slice = &self.data[self.pos..self.pos + n];
self.pos += n;
Ok(slice)
}
fn u32(&mut self) -> Result<u32> {
let b = self.take(4)?;
Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
}
fn u8(&mut self) -> Result<u8> {
Ok(self.take(1)?[0])
}
fn f32(&mut self) -> Result<f32> {
let b = self.take(4)?;
Ok(f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
}
fn i32(&mut self) -> Result<i32> {
let b = self.take(4)?;
Ok(i32::from_le_bytes([b[0], b[1], b[2], b[3]]))
}
fn uuid(&mut self) -> Result<Uuid> {
let a = self.u32()?;
let b = self.u32()?;
let c = self.u32()?;
let d = self.u32()?;
Ok(uuid_from_u32_quartet(a, b, c, d))
}
}
fn read_base_props(cur: &mut Cursor<'_>) -> Result<RawBasePropsData> {
let bytes = cur.take(RAW_BASE_PROPS_SIZE)?;
let arr: [u8; RAW_BASE_PROPS_SIZE] = bytes
.try_into()
.map_err(|_| Error::CriticalError("upload_batch: bad base props".to_string()))?;
Ok(RawBasePropsData::from(arr))
}
fn apply_blur(layer: bool, hidden: bool, value: f32) {
with_current_shape_mut!(state, |shape: &mut Shape| {
let blur_type = if layer {
BlurType::LayerBlur
} else {
BlurType::BackgroundBlur
};
let blur = Some(Blur::new(blur_type, hidden, value));
if layer {
shape.set_blur(blur);
} else {
shape.set_background_blur(blur);
}
});
}
fn clear_blur(layer: bool) {
with_current_shape_mut!(state, |shape: &mut Shape| {
if layer {
shape.set_blur(None);
} else {
shape.set_background_blur(None);
}
});
}
fn apply_shadows(cur: &mut Cursor<'_>) -> Result<()> {
with_current_shape_mut!(state, |shape: &mut Shape| {
shape.clear_shadows();
});
let n = cur.u32()? as usize;
for _ in 0..n {
let rgba = cur.u32()?;
let blur = cur.f32()?;
let spread = cur.f32()?;
let x = cur.f32()?;
let y = cur.f32()?;
let style = cur.u8()?;
let hidden = cur.u8()? != 0;
let _pad = cur.take(2)?;
with_current_shape_mut!(state, |shape: &mut Shape| {
let color = skia::Color::new(rgba);
let style: ShadowStyle = RawShadowStyle::from(style).into();
shape.add_shadow(Shadow::new(color, blur, spread, (x, y), style, hidden));
});
}
Ok(())
}
fn apply_layout_item(cur: &mut Cursor<'_>) -> Result<()> {
let margin_top = cur.f32()?;
let margin_right = cur.f32()?;
let margin_bottom = cur.f32()?;
let margin_left = cur.f32()?;
let h_sizing = cur.u8()?;
let v_sizing = cur.u8()?;
let flags = cur.u8()?;
let align_self = cur.u8()?;
let max_h = cur.f32()?;
let min_h = cur.f32()?;
let max_w = cur.f32()?;
let min_w = cur.f32()?;
let z_index = cur.i32()?;
let has_max_h = (flags & 0x01) != 0;
let has_min_h = (flags & 0x02) != 0;
let has_max_w = (flags & 0x04) != 0;
let has_min_w = (flags & 0x08) != 0;
let is_absolute = (flags & 0x10) != 0;
let h_sizing = RawSizing::from(h_sizing);
let v_sizing = RawSizing::from(v_sizing);
let max_h = has_max_h.then(|| max_h.max(0.01));
let min_h = has_min_h.then(|| min_h.clamp(0.01, max_h.unwrap_or(f32::INFINITY)));
let max_w = has_max_w.then(|| max_w.max(0.01));
let min_w = has_min_w.then(|| min_w.clamp(0.01, max_w.unwrap_or(f32::INFINITY)));
let z_index = if z_index != 0 { Some(z_index) } else { None };
let align_self = RawAlignSelf::from(align_self).try_into().ok();
with_current_shape_mut!(state, |shape: &mut Shape| {
shape.set_flex_layout_child_data(
margin_top,
margin_right,
margin_bottom,
margin_left,
h_sizing.into(),
v_sizing.into(),
max_h,
min_h,
max_w,
min_w,
align_self,
is_absolute,
z_index,
);
});
Ok(())
}
fn apply_flex(cur: &mut Cursor<'_>) -> Result<()> {
let dir = cur.u8()?;
let align_items = cur.u8()?;
let align_content = cur.u8()?;
let justify_items = cur.u8()?;
let justify_content = cur.u8()?;
let wrap_type = cur.u8()?;
let _pad = cur.take(2)?;
let row_gap = cur.f32()?;
let column_gap = cur.f32()?;
let padding_top = cur.f32()?;
let padding_right = cur.f32()?;
let padding_bottom = cur.f32()?;
let padding_left = cur.f32()?;
with_current_shape_mut!(state, |shape: &mut Shape| {
shape.clear_layout();
shape.set_flex_layout_data(
RawFlexDirection::from(dir).into(),
row_gap,
column_gap,
RawAlignItems::from(align_items).into(),
RawAlignContent::from(align_content).into(),
RawJustifyItems::from(justify_items).into(),
RawJustifyContent::from(justify_content).into(),
RawWrapType::from(wrap_type).into(),
padding_top,
padding_right,
padding_bottom,
padding_left,
);
});
Ok(())
}
fn apply_shape_payload(payload: &[u8]) -> Result<()> {
let mut cur = Cursor::new(payload);
let raw = read_base_props(&mut cur)?;
let mask = cur.u32()?;
apply_base_props(&raw)?;
if mask & SECTION_CHILDREN != 0 {
let n = cur.u32()? as usize;
let mut entries = Vec::with_capacity(n);
for _ in 0..n {
entries.push(cur.uuid()?);
}
with_state!(state, {
state.set_current_shape_children(entries)?;
});
}
if mask & SECTION_BLUR_LAYER != 0 {
let hidden = cur.u8()? != 0;
let _ = cur.take(3)?;
let value = cur.f32()?;
apply_blur(true, hidden, value);
} else {
clear_blur(true);
}
if mask & SECTION_BLUR_BG != 0 {
let hidden = cur.u8()? != 0;
let _ = cur.take(3)?;
let value = cur.f32()?;
apply_blur(false, hidden, value);
} else {
clear_blur(false);
}
if mask & SECTION_SHADOWS != 0 {
apply_shadows(&mut cur)?;
} else {
with_current_shape_mut!(state, |shape: &mut Shape| {
shape.clear_shadows();
});
}
if mask & SECTION_MASKED != 0 {
let masked = cur.u8()? != 0;
let _ = cur.take(3)?;
with_current_shape_mut!(state, |shape: &mut Shape| {
shape.set_masked(masked);
});
}
if mask & SECTION_BOOL_TYPE != 0 {
let raw_bool = cur.u8()?;
let _ = cur.take(3)?;
let bool_type = match raw_bool {
0 => BoolType::Union,
1 => BoolType::Difference,
2 => BoolType::Intersection,
_ => BoolType::Exclusion,
};
with_current_shape_mut!(state, |shape: &mut Shape| {
shape.set_bool_type(bool_type);
});
}
if mask & SECTION_GROW_TYPE != 0 {
let raw_grow = cur.u8()?;
let _ = cur.take(3)?;
let grow_type = match raw_grow {
1 => GrowType::AutoWidth,
2 => GrowType::AutoHeight,
_ => GrowType::Fixed,
};
with_current_shape_mut!(state, |shape: &mut Shape| {
if let Type::Text(text_content) = &mut shape.shape_type {
text_content.set_grow_type(grow_type);
}
});
}
// FLEX before LAYOUT_ITEM: clear_layout must not wipe the item we just set.
// Only clear when this payload owns layout (workspace cold-load). Exporter /
// serialize-shape! omit both bits and must not clobber existing layout.
if mask & SECTION_FLEX != 0 {
apply_flex(&mut cur)?;
} else if mask & SECTION_LAYOUT_ITEM != 0 {
with_current_shape_mut!(state, |shape: &mut Shape| {
shape.clear_layout();
});
}
if mask & SECTION_LAYOUT_ITEM != 0 {
apply_layout_item(&mut cur)?;
}
if mask & SECTION_FILLS != 0 {
apply_fills(&mut cur)?;
}
if mask & SECTION_STROKES != 0 {
apply_strokes(&mut cur)?;
}
Ok(())
}
fn apply_fills(cur: &mut Cursor<'_>) -> Result<()> {
let header = cur.take(4)?;
let n = header[0] as usize;
let bytes = if n == 0 {
&[][..]
} else {
cur.take(n * RAW_FILL_DATA_SIZE)?
};
let fills = read_fills_from_bytes(bytes, n);
with_current_shape_mut!(state, |shape: &mut Shape| {
shape.set_fills(fills);
});
Ok(())
}
fn apply_strokes(cur: &mut Cursor<'_>) -> Result<()> {
with_current_shape_mut!(state, |shape: &mut Shape| {
shape.clear_strokes();
});
let n = cur.u32()? as usize;
for _ in 0..n {
let width = cur.f32()?;
let style = cur.u8()?;
let align = cur.u8()?;
let cap_start = cur.u8()?;
let cap_end = cur.u8()?;
let dash = cur.f32()?;
let gap = cur.f32()?;
let has_sides = cur.u8()? != 0;
let _pad = cur.take(3)?;
let top = cur.f32()?;
let right = cur.f32()?;
let bottom = cur.f32()?;
let left = cur.f32()?;
let fill_bytes = cur.take(RAW_FILL_DATA_SIZE)?;
let fill = RawFillData::try_from(fill_bytes)
.map_err(|e| Error::CriticalError(format!("upload_batch stroke fill: {e}")))?;
let stroke_style = RawStrokeStyle::from(style);
let cap_start = RawStrokeCap::from(cap_start);
let cap_end = RawStrokeCap::from(cap_end);
let dash = decode_optional_f32(dash);
let gap = decode_optional_f32(gap);
with_current_shape_mut!(state, |shape: &mut Shape| {
let stroke = match align {
STROKE_ALIGN_INNER => Stroke::new_inner_stroke(
width,
stroke_style.into(),
cap_start.try_into().ok(),
cap_end.try_into().ok(),
dash,
gap,
),
STROKE_ALIGN_OUTER => Stroke::new_outer_stroke(
width,
stroke_style.into(),
cap_start.try_into().ok(),
cap_end.try_into().ok(),
dash,
gap,
),
STROKE_ALIGN_CENTER | _ => Stroke::new_center_stroke(
width,
stroke_style.into(),
cap_start.try_into().ok(),
cap_end.try_into().ok(),
dash,
gap,
),
};
shape.add_stroke(stroke);
if has_sides {
let _ = shape.set_last_stroke_widths([top, right, bottom, left]);
}
let _ = shape.set_stroke_fill(fill.into());
});
}
Ok(())
}
/// Apply a multi-shape upload buffer previously written via `_alloc_bytes`.
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_shapes_batch() -> Result<()> {
let bytes = mem::bytes();
if bytes.len() < 4 {
return Ok(());
}
let mut cur = Cursor::new(&bytes);
let count = cur.u32()? as usize;
performance::page_trace(&format!("set_shapes_batch:begin count={count}"));
for _ in 0..count {
let payload_len = cur.u32()? as usize;
let payload = cur.take(payload_len)?;
apply_shape_payload(payload)?;
performance::page_trace_use_shape();
}
performance::page_trace(&format!("set_shapes_batch:end count={count}"));
Ok(())
}