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

View File

@ -17,6 +17,7 @@
[app.common.logging :as log] [app.common.logging :as log]
[app.common.math :as mth] [app.common.math :as mth]
[app.common.render-wasm.api.props :as props] [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.helpers :as h]
[app.common.render-wasm.mem :as mem] [app.common.render-wasm.mem :as mem]
[app.common.render-wasm.mem.heap32 :as mem.h32] [app.common.render-wasm.mem.heap32 :as mem.h32]
@ -1030,37 +1031,56 @@
(map #(process-fill-image shape-id % thumbnail?)))))) (map #(process-fill-image shape-id % thumbnail?))))))
(defn set-shape-fills (defn set-shape-fills
[shape-id fills thumbnail?] "Writes fill records (unless `write?` is false) and returns pending image
;; Record write is shared with the headless exporter; the image fetch below is fetches. When fills were already uploaded in `_set_shapes_batch`, pass
;; browser-only (WebGL textures). `write?` false so only image fetches remain."
(when-let [fills (props/write-shape-fills! fills)] ([shape-id fills thumbnail?]
(keep (fn [id] (set-shape-fills shape-id fills thumbnail? true))
(let [buffer (uuid/get-u32 id) ([shape-id fills thumbnail? write?]
cached-image? (h/call wasm/internal-module "_is_image_cached" (let [fills (if write?
(aget buffer 0) (props/write-shape-fills! fills)
(aget buffer 1) (when (seq fills)
(aget buffer 2) (types.fills/coerce fills)))]
(aget buffer 3) (when fills
thumbnail?)] (keep (fn [id]
(when (zero? cached-image?) (let [buffer (uuid/get-u32 id)
(fetch-image shape-id id thumbnail?)))) cached-image? (h/call wasm/internal-module "_is_image_cached"
(types.fills/get-image-ids fills)))) (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 (defn set-shape-strokes
[shape-id strokes thumbnail?] "Writes stroke records (unless `write?` is false) and returns pending image
;; Record write is shared with the headless exporter; the image fetch below is fetches for stroke image fills."
;; browser-only (WebGL textures). ([shape-id strokes thumbnail?]
(keep (fn [image-id] (set-shape-strokes shape-id strokes thumbnail? true))
(let [buffer (uuid/get-u32 image-id) ([shape-id strokes thumbnail? write?]
cached-image? (h/call wasm/internal-module "_is_image_cached" (let [image-ids (if write?
(aget buffer 0) (props/write-shape-strokes! strokes)
(aget buffer 1) (stroke-image-ids strokes))]
(aget buffer 2) (keep (fn [image-id]
(aget buffer 3) (let [buffer (uuid/get-u32 image-id)
thumbnail?)] cached-image? (h/call wasm/internal-module "_is_image_cached"
(when (zero? cached-image?) (aget buffer 0)
(fetch-image shape-id image-id thumbnail?)))) (aget buffer 1)
(props/write-shape-strokes! strokes))) (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 (defn set-shape-svg-attrs
[attrs] [attrs]
@ -1545,47 +1565,70 @@
[content] [content]
(or content (tc/v2-default-text-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 (defn set-object
[shape] [shape]
(if-not (and shape (wasm/live?)) (if-not (and shape (wasm/live?))
{:thumbnails [] :full [] :font-pending-ids []} {:thumbnails [] :full [] :font-pending-ids []}
(do (do
(perf/begin-measure "set-object") (perf/begin-measure "set-object")
(let [shape (svg-filters/apply-svg-derived shape) (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))]
(serialize-shape/serialize-shape! shape) (serialize-shape/serialize-shape! shape)
(let [result (set-object-host-attrs shape false)]
;; 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)))]
(perf/end-measure "set-object") (perf/end-measure "set-object")
{:thumbnails pending-thumbnails result)))))
:full pending-full
:font-pending-ids (if (some :callback text-content-pending) [id] [])})))))
(defn- update-text-layouts (defn- update-text-layouts
"Synchronously update text layouts for all shapes and send rect updates "Synchronously update text layouts for all shapes and send rect updates
to the worker index." to the worker index."
@ -1697,30 +1740,54 @@
:font-pending-ids (persistent! font-acc)}))] :font-pending-ids (persistent! font-acc)}))]
(process-pending shapes thumbnails full font-pending-ids noop-fn))) (process-pending shapes thumbnails full font-pending-ids noop-fn)))
(def ^:private ^:const BATCH_MAX_SHAPES 512)
(defn- process-shapes-chunk (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}" Returns {:thumbnails [...] :full [...] :font-pending-ids [...] :next-index n}"
[shapes start-index thumbnails-acc full-acc font-pending-acc] [shapes start-index thumbnails-acc full-acc font-pending-acc]
(let [total (count shapes) (let [total (count shapes)
deadline (+ (js/performance.now) CHUNK_TIME_BUDGET_MS)] end-index (min total (+ start-index BATCH_MAX_SHAPES))
(loop [index start-index 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) t-acc (transient thumbnails-acc)
f-acc (transient full-acc) f-acc (transient full-acc)
fp-acc (transient font-pending-acc)] fp-acc (transient font-pending-acc)]
(if (and (< index total) (if-let [shape (first xs)]
;; Check performance.now every 8 shapes to reduce overhead (let [{:keys [thumbnails full font-pending-ids]}
(or (pos? (bit-and (- index start-index) 7)) (set-object-host-attrs shape true :skip-fills-strokes? true)]
(<= (js/performance.now) deadline))) (recur (next xs)
(let [shape (nth shapes index)
{:keys [thumbnails full font-pending-ids]} (set-object shape)]
(recur (inc index)
(reduce conj! t-acc thumbnails) (reduce conj! t-acc thumbnails)
(reduce conj! f-acc full) (reduce conj! f-acc full)
(reduce conj! fp-acc font-pending-ids))) (reduce conj! fp-acc font-pending-ids)))
{:thumbnails (persistent! t-acc) {:thumbnails (persistent! t-acc)
:full (persistent! f-acc) :full (persistent! f-acc)
:font-pending-ids (persistent! fp-acc) :font-pending-ids (persistent! fp-acc)
:next-index index})))) :next-index end-index}))))
(defn- set-objects-async (defn- set-objects-async
"Asynchronously process shapes in time-budgeted chunks, yielding to the "Asynchronously process shapes in time-budgeted chunks, yielding to the
@ -1847,32 +1914,50 @@
(defn- set-objects-sync (defn- set-objects-sync
"Synchronously process all shapes (for small shape counts)." "Synchronously process all shapes (for small shape counts)."
[shapes render-callback on-shapes-ready] [shapes render-callback on-shapes-ready]
(let [total-shapes (count shapes) (let [prepared (mapv svg-filters/apply-svg-derived shapes)]
{:keys [thumbnails full font-pending-ids]} (when (seq prepared)
(loop [index 0 thumbnails-acc (transient []) full-acc (transient []) font-acc (transient [])] (upload/flush-shapes-batch! prepared {:include-layout? true
(if (< index total-shapes) :include-fills-strokes? true}))
(let [shape (nth shapes index) (doseq [shape prepared]
{:keys [thumbnails full font-pending-ids]} (set-object shape)] (let [id (dm/get-prop shape :id)
(recur (inc index) type (dm/get-prop shape :type)]
(reduce conj! thumbnails-acc thumbnails) (when (or (some? (get shape :svg-attrs))
(reduce conj! full-acc full) (and (contains? #{:path :bool} type) (some? (get shape :content))))
(reduce conj! font-acc font-pending-ids))) (use-shape id)
{:thumbnails (persistent! thumbnails-acc) (when (some? (get shape :svg-attrs))
:full (persistent! full-acc) (props/set-shape-svg-attrs (get shape :svg-attrs)))
:font-pending-ids (persistent! font-acc)}))] (when (and (contains? #{:path :bool} type) (some? (get shape :content)))
(perf/end-measure "set-objects") (props/set-shape-path-content (get shape :content))))))
(when on-shapes-ready (on-shapes-ready)) (let [total-shapes (count prepared)
(when (wasm/live?) {:keys [thumbnails full font-pending-ids]}
;; Rebuild the tile index so _render knows which shapes (loop [index 0
;; map to which tiles after a page switch. thumbnails-acc (transient [])
(h/call wasm/internal-module "_set_view_end") full-acc (transient [])
(reset! view-interaction-active? false) font-acc (transient [])]
(process-pending shapes thumbnails full font-pending-ids (if (< index total-shapes)
(fn [] (let [shape (nth prepared index)
(if render-callback {:keys [thumbnails full font-pending-ids]}
(render-callback) (set-object-host-attrs shape true :skip-fills-strokes? true)]
(request-render "set-objects-sync-complete")) (recur (inc index)
(ug/dispatch! (ug/event "penpot:wasm:set-objects"))))))) (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 (defn- shapes-in-tree-order
"Returns shapes sorted in tree order (parents before children). "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] #[no_mangle]
#[wasm_error] #[wasm_error]
pub extern "C" fn clean_up() -> Result<()> { pub extern "C" fn clean_up() -> Result<()> {
crate::performance::page_trace_reset("clean_up");
// Cancel the current animation frame if it exists so // Cancel the current animation frame if it exists so
// it won't try to render without context // it won't try to render without context
let render_state = get_render_state(); let render_state = get_render_state();

View File

@ -274,6 +274,7 @@ pub extern "C" fn render_preview() -> Result<()> {
#[no_mangle] #[no_mangle]
#[wasm_error] #[wasm_error]
pub extern "C" fn begin_loading() -> Result<()> { pub extern "C" fn begin_loading() -> Result<()> {
performance::page_trace("begin_loading");
with_state!(state, { with_state!(state, {
state.loading = true; state.loading = true;
}); });
@ -288,6 +289,7 @@ pub extern "C" fn end_loading() -> Result<()> {
with_state!(state, { with_state!(state, {
state.loading = false; state.loading = false;
}); });
performance::page_trace_end_loading();
Ok(()) Ok(())
} }
@ -307,6 +309,7 @@ pub extern "C" fn render_loading_overlay() -> Result<()> {
#[no_mangle] #[no_mangle]
#[wasm_error] #[wasm_error]
pub extern "C" fn reset_canvas() -> Result<()> { pub extern "C" fn reset_canvas() -> Result<()> {
performance::page_trace("reset_canvas");
get_render_state().reset_canvas(); get_render_state().reset_canvas();
Ok(()) Ok(())
} }
@ -321,10 +324,12 @@ pub extern "C" fn resize_viewbox(width: i32, height: i32) -> Result<()> {
#[no_mangle] #[no_mangle]
#[wasm_error] #[wasm_error]
pub extern "C" fn set_view(zoom: f32, x: f32, y: f32) -> Result<()> { pub extern "C" fn set_view(zoom: f32, x: f32, y: f32) -> Result<()> {
performance::page_trace("set_view:begin");
performance::begin_measure!("set_view"); performance::begin_measure!("set_view");
let render_state = get_render_state(); let render_state = get_render_state();
render_state.set_view(zoom, x, y); render_state.set_view(zoom, x, y);
performance::end_measure!("set_view"); performance::end_measure!("set_view");
performance::page_trace("set_view:end");
Ok(()) Ok(())
} }
@ -352,6 +357,7 @@ pub extern "C" fn set_view_start() -> Result<()> {
#[no_mangle] #[no_mangle]
#[wasm_error] #[wasm_error]
pub extern "C" fn set_view_end() -> Result<()> { pub extern "C" fn set_view_end() -> Result<()> {
performance::page_trace("set_view_end:begin");
with_state!(state, { with_state!(state, {
performance::begin_measure!("set_view_end"); performance::begin_measure!("set_view_end");
let render_state = get_render_state(); 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::end_measure!("set_view_end");
}); });
performance::page_trace("set_view_end:end");
Ok(()) Ok(())
} }
@ -444,15 +451,23 @@ pub extern "C" fn set_focus_mode() -> Result<()> {
#[no_mangle] #[no_mangle]
#[wasm_error] #[wasm_error]
pub extern "C" fn init_shapes_pool(capacity: usize) -> Result<()> { 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, { with_state!(state, {
state.init_shapes_pool(capacity); state.init_shapes_pool(capacity);
}); });
performance::page_trace("init_shapes_pool:done");
Ok(()) Ok(())
} }
#[no_mangle] #[no_mangle]
#[wasm_error] #[wasm_error]
pub extern "C" fn use_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> { pub extern "C" fn use_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> {
performance::page_trace_use_shape();
with_state!(state, { with_state!(state, {
let id = uuid_from_u32_quartet(a, b, c, d); let id = uuid_from_u32_quartet(a, b, c, d);
state.use_shape(id); 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<()> { 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, { with_state!(state, {
let Some(parent_id) = parent_id else { state.set_current_shape_children(entries)?;
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);
}
}); });
Ok(()) Ok(())
} }

View File

@ -1,3 +1,5 @@
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicUsize, Ordering};
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub fn get_time() -> i32 { pub fn get_time() -> i32 {
crate::get_now!() as i32 crate::get_now!() as i32
@ -9,6 +11,62 @@ pub fn get_time() -> i32 {
now.elapsed().as_millis() as 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) /// Log a message to the browser console (only when profile-macros feature is enabled)
#[macro_export] #[macro_export]
macro_rules! console_log { macro_rules! console_log {

View File

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

View File

@ -340,6 +340,36 @@ impl State {
self.shapes.set_modifiers(modifiers); 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) { pub fn touch_current(&mut self) {
// `mark_touched` only drives incremental on-screen tile invalidation; // `mark_touched` only drives incremental on-screen tile invalidation;
// the headless export path has no render state, so skip it there. // 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) { pub fn initialize(&mut self, capacity: usize) {
performance::page_trace("shapes_pool_initialize:begin");
performance::begin_measure!("shapes_pool_initialize"); performance::begin_measure!("shapes_pool_initialize");
self.counter = 0; self.counter = 0;
self.uuid_to_idx = HashMap::with_capacity(capacity); self.uuid_to_idx = HashMap::with_capacity(capacity);
let additional = capacity as i32 - self.shapes.len() as i32; let additional = capacity as i32 - self.shapes.len() as i32;
if additional <= 0 { if additional <= 0 {
performance::end_measure!("shapes_pool_initialize");
performance::page_trace("shapes_pool_initialize:done (reuse)");
return; return;
} }
@ -99,6 +102,7 @@ impl ShapesPoolImpl {
self.shapes self.shapes
.extend(iter::repeat_with(|| Shape::new(Uuid::nil())).take(additional as usize)); .extend(iter::repeat_with(|| Shape::new(Uuid::nil())).take(additional as usize));
performance::end_measure!("shapes_pool_initialize"); performance::end_measure!("shapes_pool_initialize");
performance::page_trace("shapes_pool_initialize:done");
} }
pub fn add_shape(&mut self, id: Uuid) -> &mut Shape { pub fn add_shape(&mut self, id: Uuid) -> &mut Shape {

View File

@ -8,7 +8,7 @@ mod gradient;
mod image; mod image;
mod solid; 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))] #[repr(C, u8, align(4))]
#[derive(Debug, PartialEq, Clone, Copy, ToJs)] #[derive(Debug, PartialEq, Clone, Copy, ToJs)]

View File

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

View File

@ -12,10 +12,10 @@ use macros::wasm_error;
use super::RawShapeType; use super::RawShapeType;
const FLAG_CLIP_CONTENT: u8 = 0b0000_0001; pub const FLAG_CLIP_CONTENT: u8 = 0b0000_0001;
const FLAG_HIDDEN: u8 = 0b0000_0010; 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. /// 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)] #[derive(Debug, Clone, Copy)]
pub struct RawBasePropsData { pub struct RawBasePropsData {
// UUID id (16 bytes) // UUID id (16 bytes)
id_a: u32, pub id_a: u32,
id_b: u32, pub id_b: u32,
id_c: u32, pub id_c: u32,
id_d: u32, pub id_d: u32,
// UUID parent_id (16 bytes) // UUID parent_id (16 bytes)
parent_a: u32, pub parent_a: u32,
parent_b: u32, pub parent_b: u32,
parent_c: u32, pub parent_c: u32,
parent_d: u32, pub parent_d: u32,
// Single-byte fields // Single-byte fields
shape_type: u8, pub shape_type: u8,
flags: u8, pub flags: u8,
blend_mode: u8, pub blend_mode: u8,
constraint_h: u8, pub constraint_h: u8,
constraint_v: u8, pub constraint_v: u8,
padding: [u8; 3], pub padding: [u8; 3],
// f32 fields // f32 fields
opacity: f32, pub opacity: f32,
rotation: f32, pub rotation: f32,
// Transform matrix (a, b, c, d, e, f) // Transform matrix (a, b, c, d, e, f)
transform_a: f32, pub transform_a: f32,
transform_b: f32, pub transform_b: f32,
transform_c: f32, pub transform_c: f32,
transform_d: f32, pub transform_d: f32,
transform_e: f32, pub transform_e: f32,
transform_f: f32, pub transform_f: f32,
// Selrect (x1, y1, x2, y2) // Selrect (x1, y1, x2, y2)
selrect_x1: f32, pub selrect_x1: f32,
selrect_y1: f32, pub selrect_y1: f32,
selrect_x2: f32, pub selrect_x2: f32,
selrect_y2: f32, pub selrect_y2: f32,
// Corners (r1, r2, r3, r4) // Corners (r1, r2, r3, r4)
corner_r1: f32, pub corner_r1: f32,
corner_r2: f32, pub corner_r2: f32,
corner_r3: f32, pub corner_r3: f32,
corner_r4: f32, pub corner_r4: f32,
} }
impl RawBasePropsData { 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) 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) 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 (self.flags & FLAG_CLIP_CONTENT) != 0
} }
fn hidden(&self) -> bool { pub fn hidden(&self) -> bool {
(self.flags & FLAG_HIDDEN) != 0 (self.flags & FLAG_HIDDEN) != 0
} }
fn blend_mode(&self) -> BlendMode { pub fn blend_mode(&self) -> BlendMode {
RawBlendMode::from(self.blend_mode).into() 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() 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() RawConstraintV::from(self.constraint_v).into()
} }
} }
@ -100,21 +100,8 @@ impl From<[u8; RAW_BASE_PROPS_SIZE]> for RawBasePropsData {
} }
} }
#[no_mangle] /// Apply base props from a parsed record (selects the shape and sets core attrs).
#[wasm_error] pub fn apply_base_props(raw: &RawBasePropsData) -> Result<()> {
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);
let id = raw.id(); let id = raw.id();
let parent_id = raw.parent_id(); let parent_id = raw.parent_id();
let shape_type = RawShapeType::from(raw.shape_type); let shape_type = RawShapeType::from(raw.shape_type);
@ -153,6 +140,24 @@ pub extern "C" fn set_shape_base_props() -> Result<()> {
Ok(()) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@ -1,8 +1,9 @@
mod base_props; mod base_props;
mod upload_batch;
use macros::ToJs; 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; use crate::with_current_shape_mut;
#[derive(Debug, Clone, PartialEq, ToJs)] #[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(())
}