Batch WASM shape upload to speed up page switches (#11443)

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-09-02 10:32:52 +02:00 committed by GitHub
parent d1a54134bb
commit b9ddfc1596
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1224 additions and 385 deletions

View File

@ -1,189 +0,0 @@
;; 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.shapes
"Batched shape property serialization for improved WASM performance.
This module provides a single WASM call to set all base shape properties,
replacing multiple individual calls (use_shape, set_parent, set_shape_type,
etc.) with one batched operation."
(: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.wasm :as wasm]
[app.common.uuid :as uuid]))
;; Binary layout constants matching Rust implementation:
;;
;; | Offset | Size | Field | Type |
;; |--------|------|--------------|-----------------------------------|
;; | 0 | 16 | id | UUID (4 × u32 LE) |
;; | 16 | 16 | parent_id | UUID (4 × u32 LE) |
;; | 32 | 1 | shape_type | u8 |
;; | 33 | 1 | flags | u8 (bit0: clip, bit1: hidden) |
;; | 34 | 1 | blend_mode | u8 |
;; | 35 | 1 | constraint_h | u8 (0xFF = None) |
;; | 36 | 1 | constraint_v | u8 (0xFF = None) |
;; | 37 | 3 | padding | - |
;; | 40 | 4 | opacity | f32 LE |
;; | 44 | 4 | rotation | f32 LE |
;; | 48 | 24 | transform | 6 × f32 LE (a,b,c,d,e,f) |
;; | 72 | 16 | selrect | 4 × f32 LE (x1,y1,x2,y2) |
;; | 88 | 16 | corners | 4 × f32 LE (r1,r2,r3,r4) |
;; |--------|------|--------------|-----------------------------------|
;; | Total | 104 | | |
(def ^:const BASE-PROPS-SIZE 104)
(def ^:const FLAG-CLIP-CONTENT 0x01)
(def ^:const FLAG-HIDDEN 0x02)
(def ^:const CONSTRAINT-NONE 0xFF)
(defn- write-uuid-to-heap
"Write a UUID to the heap at the given byte offset using DataView."
[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)))
(defn- serialize-transform
"Extract transform matrix values, defaulting to identity matrix."
[transform]
(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])) ; identity matrix
(defn- serialize-selrect
"Extract selrect values."
[selrect]
(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]))
(defn set-shape-base-props
"Set all base shape properties in a single WASM call.
This replaces the following individual calls:
- use-shape
- set-parent-id
- set-shape-type
- set-shape-clip-content
- set-shape-rotation
- set-shape-transform
- set-shape-blend-mode
- set-shape-opacity
- set-shape-hidden
- set-shape-selrect
- set-shape-corners
- set-shape-constraints (clear + h + v)
Returns nil."
[shape]
(when (wasm/live?)
(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 (let [c (get shape :constraints-h)]
(sr/translate-constraint-h c))
constraint-v (let [c (get shape :constraints-v)]
(sr/translate-constraint-v c))
opacity (d/nilv (get shape :opacity) 1.0)
rotation (d/nilv (get shape :rotation) 0.0)
;; Transform matrix
[ta tb tc td te tf] (serialize-transform (get shape :transform))
;; Selrect
selrect (get shape :selrect)
[sx1 sy1 sx2 sy2] (serialize-selrect selrect)
;; Corners
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)
;; Allocate buffer and get DataView
offset (mem/alloc BASE-PROPS-SIZE)
heap (mem/get-heap-u8)
dview (js/DataView. (.-buffer heap))]
;; Write id (offset 0, 16 bytes)
(write-uuid-to-heap dview offset id)
;; Write parent_id (offset 16, 16 bytes)
(write-uuid-to-heap dview (+ offset 16) (d/nilv parent-id uuid/zero))
;; Write shape_type (offset 32, 1 byte)
(.setUint8 dview (+ offset 32) (sr/translate-shape-type shape-type))
;; Write flags (offset 33, 1 byte)
(.setUint8 dview (+ offset 33) flags)
;; Write blend_mode (offset 34, 1 byte)
(.setUint8 dview (+ offset 34) blend-mode)
;; Write constraint_h (offset 35, 1 byte)
(.setUint8 dview (+ offset 35) constraint-h)
;; Write constraint_v (offset 36, 1 byte)
(.setUint8 dview (+ offset 36) constraint-v)
;; Padding at offset 37-39 (already zero from alloc)
;; Write opacity (offset 40, f32)
(.setFloat32 dview (+ offset 40) opacity true)
;; Write rotation (offset 44, f32)
(.setFloat32 dview (+ offset 44) rotation true)
;; Write transform matrix (offset 48, 6 × f32)
(.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)
;; Write selrect (offset 72, 4 × f32)
(.setFloat32 dview (+ offset 72) sx1 true)
(.setFloat32 dview (+ offset 76) sy1 true)
(.setFloat32 dview (+ offset 80) sx2 true)
(.setFloat32 dview (+ offset 84) sy2 true)
;; Write corners (offset 88, 4 × f32)
(.setFloat32 dview (+ offset 88) r1 true)
(.setFloat32 dview (+ offset 92) r2 true)
(.setFloat32 dview (+ offset 96) r3 true)
(.setFloat32 dview (+ offset 100) r4 true)
(h/call wasm/internal-module "_set_shape_base_props")
nil)))

View File

@ -0,0 +1,453 @@
;; 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.buffer :as buf]
[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]
(buf/write-uuid dview offset id)
(+ 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))
(buf/write-u8 dview (+ offset 32) (sr/translate-shape-type shape-type))
(buf/write-u8 dview (+ offset 33) flags)
(buf/write-u8 dview (+ offset 34) blend-mode)
(buf/write-u8 dview (+ offset 35) constraint-h)
(buf/write-u8 dview (+ offset 36) constraint-v)
(buf/write-f32 dview (+ offset 40) opacity)
(buf/write-f32 dview (+ offset 44) rotation)
(buf/write-f32 dview (+ offset 48) ta)
(buf/write-f32 dview (+ offset 52) tb)
(buf/write-f32 dview (+ offset 56) tc)
(buf/write-f32 dview (+ offset 60) td)
(buf/write-f32 dview (+ offset 64) te)
(buf/write-f32 dview (+ offset 68) tf)
(buf/write-f32 dview (+ offset 72) sx1)
(buf/write-f32 dview (+ offset 76) sy1)
(buf/write-f32 dview (+ offset 80) sx2)
(buf/write-f32 dview (+ offset 84) sy2)
(buf/write-f32 dview (+ offset 88) r1)
(buf/write-f32 dview (+ offset 92) r2)
(buf/write-f32 dview (+ offset 96) r3)
(buf/write-f32 dview (+ offset 100) r4)
(+ offset BASE-PROPS-SIZE)))
(defn- write-blur!
[dview offset blur]
(buf/write-u8 dview offset (if (get blur :hidden) 1 0))
(buf/write-f32 dview (+ offset 4) (get blur :value 0))
(+ offset 8))
(defn- write-shadow!
[dview offset shadow]
(let [color (get shadow :color)
rgba (sr-clr/hex->u32argb (get color :color)
(get color :opacity))]
(buf/write-u32 dview offset rgba)
(buf/write-f32 dview (+ offset 4) (get shadow :blur 0))
(buf/write-f32 dview (+ offset 8) (get shadow :spread 0))
(buf/write-f32 dview (+ offset 12) (get shadow :offset-x 0))
(buf/write-f32 dview (+ offset 16) (get shadow :offset-y 0))
(buf/write-u8 dview (+ offset 20) (sr/translate-shadow-style (get shadow :style)))
(buf/write-u8 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)]
(buf/write-u8 dview offset dir)
(buf/write-u8 dview (+ offset 1) align-items)
(buf/write-u8 dview (+ offset 2) align-content)
(buf/write-u8 dview (+ offset 3) justify-items)
(buf/write-u8 dview (+ offset 4) justify-content)
(buf/write-u8 dview (+ offset 5) wrap-type)
(buf/write-f32 dview (+ offset 8) row-gap)
(buf/write-f32 dview (+ offset 12) column-gap)
(buf/write-f32 dview (+ offset 16) padding-top)
(buf/write-f32 dview (+ offset 20) padding-right)
(buf/write-f32 dview (+ offset 24) padding-bottom)
(buf/write-f32 dview (+ offset 28) padding-left)
(+ 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))]
(buf/write-f32 dview offset margin-top)
(buf/write-f32 dview (+ offset 4) margin-right)
(buf/write-f32 dview (+ offset 8) margin-bottom)
(buf/write-f32 dview (+ offset 12) margin-left)
(buf/write-u8 dview (+ offset 16) (d/nilv h-sizing 0))
(buf/write-u8 dview (+ offset 17) (d/nilv v-sizing 0))
(buf/write-u8 dview (+ offset 18) flags)
(buf/write-u8 dview (+ offset 19) (d/nilv align-self 0))
(buf/write-f32 dview (+ offset 20) (d/nilv max-h 0))
(buf/write-f32 dview (+ offset 24) (d/nilv min-h 0))
(buf/write-f32 dview (+ offset 28) (d/nilv max-w 0))
(buf/write-f32 dview (+ offset 32) (d/nilv min-w 0))
(buf/write-i32 dview (+ offset 36) (d/nilv z-index 0))
(+ 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))]
(buf/write-f32 dview offset width)
(buf/write-u8 dview (+ offset 4) style)
(buf/write-u8 dview (+ offset 5) align)
(buf/write-u8 dview (+ offset 6) (d/nilv cap-start 0))
(buf/write-u8 dview (+ offset 7) (d/nilv cap-end 0))
(buf/write-f32 dview (+ offset 8) dash)
(buf/write-f32 dview (+ offset 12) gap)
(buf/write-u8 dview (+ offset 16) (if has-sides? 1 0))
(buf/write-f32 dview (+ offset 20) top)
(buf/write-f32 dview (+ offset 24) right)
(buf/write-f32 dview (+ offset 28) bottom)
(buf/write-f32 dview (+ offset 32) left)
(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]
(buf/write-u32 dview offset (count strokes))
(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)
_ (buf/write-u32 dview offset mask)
offset (+ offset 4)
offset (let [o offset]
(buf/write-u32 dview o (count children))
(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
(buf/write-u32 dview o (count shadows))
(reduce (fn [o s] (write-shadow! dview o s))
(+ o 4)
shadows))))
offset (cond-> offset
(= shape-type :group)
(as-> o
(do (buf/write-u8 dview o (if masked? 1 0))
(+ o 4))))
offset (cond-> offset
(some? bool-type)
(as-> o
(do (buf/write-u8 dview o (sr/translate-bool-type bool-type))
(+ o 4))))
offset (cond-> offset
(some? grow-type)
(as-> o
(do (buf/write-u8 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))
(buf/write-u32 dview 0 payload-len)
(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))]
(buf/write-u32 dview offset (count records))
(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]
@ -280,8 +281,6 @@
(defonce ^:private view-interaction-active? (atom false))
;; Time budget (ms) per chunk of shape processing before yielding to browser
(def ^:private ^:const CHUNK_TIME_BUDGET_MS 8)
;; Threshold below which we use synchronous processing (no chunking overhead)
(def ^:const ASYNC_THRESHOLD 100)
@ -736,6 +735,17 @@
(aget buffer 3))]
(= result 1))))
(defn- write-text-content!
"Push every paragraph of `content` to the current WASM text shape."
[content]
(let [paragraph-set (first (get content :children))
paragraphs (get paragraph-set :children)]
(doseq [paragraph paragraphs
:let [spans (get paragraph :children)]
:when (seq spans)]
(let [text (apply str (map :text spans))]
(t/write-shape-text spans paragraph text)))))
(defn set-shape-text-content
"This function sets shape text content and returns a stream that loads the needed fonts asynchronously"
[shape-id content]
@ -750,10 +760,11 @@
(set-shape-vertical-align (get content :vertical-align))
(let [fonts (f/get-content-fonts content)
fallback-fonts (fonts-from-text-content content true)
all-fonts (concat fonts fallback-fonts)
result (f/store-fonts all-fonts)]
(let [fonts (f/get-content-fonts content)
fallback-fonts (fonts-from-text-content content false)
all-fonts (concat fonts fallback-fonts)
result (f/store-fonts all-fonts)]
(write-text-content! content)
(f/load-fallback-fonts-for-editor! fallback-fonts)
(h/call wasm/internal-module "_update_shape_text_layout")
result)))
@ -1030,37 +1041,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 +1575,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 +1750,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 +1924,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

@ -552,33 +552,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

@ -200,6 +200,10 @@ pub struct Shape {
pub svg_transform: Option<Matrix>,
pub ignore_constraints: bool,
deleted: bool,
/// Fills from a cold-load batch, held until text content is uploaded and laid out.
deferred_batch_fills: Option<Vec<Fill>>,
/// Strokes from a cold-load batch, applied together with deferred fills.
deferred_batch_strokes: Option<Vec<Stroke>>,
}
// Returns all ancestor shapes of this shape, traversing up the parent hierarchy
@ -302,6 +306,8 @@ impl Shape {
svg_transform: None,
ignore_constraints: false,
deleted: false,
deferred_batch_fills: None,
deferred_batch_strokes: None,
}
}
@ -665,6 +671,7 @@ impl Shape {
}
pub fn set_fills(&mut self, fills: Vec<Fill>) {
self.deferred_batch_fills = None;
self.fills = fills;
}
@ -707,10 +714,31 @@ impl Shape {
}
pub fn clear_strokes(&mut self) {
self.deferred_batch_strokes = None;
self.invalidate_extrect();
self.strokes.clear();
}
pub fn set_deferred_batch_fills(&mut self, fills: Vec<Fill>) {
self.deferred_batch_fills = Some(fills);
}
pub fn set_deferred_batch_strokes(&mut self, strokes: Vec<Stroke>) {
self.deferred_batch_strokes = Some(strokes);
}
/// Apply fill/stroke records that were parsed from a batch upload but held
/// back until text content exists and has been laid out.
pub fn apply_deferred_batch_paint(&mut self) {
if let Some(fills) = self.deferred_batch_fills.take() {
self.fills = fills;
}
if let Some(strokes) = self.deferred_batch_strokes.take() {
self.strokes = strokes;
self.invalidate_extrect();
}
}
pub fn set_path_segments(&mut self, segments: Vec<Segment>) {
match &mut self.shape_type {
Type::Bool(Bool { bool_type, .. }) => {

View File

@ -344,6 +344,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 (parent_id, deleted) = {
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;
let (_, deleted) = shape.compute_children_differences(&entries);
shape.children = entries.clone();
(id, deleted)
};
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) {
if let Some(current_id) = self.current_id {
self.touch_shape(current_id);

View File

@ -41,6 +41,15 @@ pub fn get_font_collection() -> &'static FontCollection {
}
}
/// A negative f32 means "unset" — the renderer falls back to its default.
pub fn decode_optional_f32(value: f32) -> Option<f32> {
if value.is_finite() && value >= 0.0 {
Some(value)
} else {
None
}
}
#[derive(Debug, Clone, Copy)]
#[repr(u8)]
pub enum Browser {

View File

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

View File

@ -7,6 +7,9 @@ pub mod constraints;
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

@ -1,4 +1,3 @@
use crate::mem;
use crate::shapes::{BlendMode, ConstraintH, ConstraintV};
use crate::utils::uuid_from_u32_quartet;
use crate::uuid::Uuid;
@ -6,16 +5,14 @@ use crate::wasm::blend::RawBlendMode;
use crate::wasm::layouts::constraints::{RawConstraintH, RawConstraintV};
use crate::with_state;
#[allow(unused_imports)]
use crate::error::{Error, Result};
use macros::wasm_error;
use crate::error::Result;
use super::RawShapeType;
const FLAG_CLIP_CONTENT: u8 = 0b0000_0001;
const FLAG_HIDDEN: u8 = 0b0000_0010;
const RAW_BASE_PROPS_SIZE: usize = std::mem::size_of::<RawBasePropsData>();
pub(crate) const RAW_BASE_PROPS_SIZE: usize = std::mem::size_of::<RawBasePropsData>();
/// Binary layout for batched shape base properties.
///
@ -24,7 +21,7 @@ const RAW_BASE_PROPS_SIZE: usize = std::mem::size_of::<RawBasePropsData>();
#[repr(C)]
#[repr(align(4))]
#[derive(Debug, Clone, Copy)]
pub struct RawBasePropsData {
pub(crate) struct RawBasePropsData {
// UUID id (16 bytes)
id_a: u32,
id_b: u32,
@ -100,21 +97,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(crate) 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);

View File

@ -1,4 +1,5 @@
mod base_props;
mod upload_batch;
use macros::ToJs;

View File

@ -0,0 +1,473 @@
//! 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::shapes::{Blur, BlurType, Shadow, ShadowStyle, Stroke, Type};
use crate::utils::{decode_optional_f32, 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::paths::bools::RawBoolType;
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::wasm::text::RawGrowType;
use crate::with_current_shape_mut;
use crate::with_state;
#[allow(unused_imports)]
use crate::error::{Error, Result};
use macros::wasm_error;
const SECTION_CHILDREN: u32 = 1 << 0;
const SECTION_BLUR_LAYER: u32 = 1 << 1;
const SECTION_BLUR_BG: u32 = 1 << 2;
const SECTION_SHADOWS: u32 = 1 << 3;
const SECTION_MASKED: u32 = 1 << 4;
const SECTION_BOOL_TYPE: u32 = 1 << 5;
const SECTION_GROW_TYPE: u32 = 1 << 6;
const SECTION_LAYOUT_ITEM: u32 = 1 << 7;
const SECTION_FLEX: u32 = 1 << 8;
const SECTION_FILLS: u32 = 1 << 9;
const SECTION_STROKES: u32 = 1 << 10;
const STROKE_ALIGN_INNER: u8 = 1;
const STROKE_ALIGN_OUTER: u8 = 2;
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)?;
with_current_shape_mut!(state, |shape: &mut Shape| {
shape.set_bool_type(RawBoolType::from(raw_bool).into());
});
}
if mask & SECTION_GROW_TYPE != 0 {
let raw_grow = cur.u8()?;
let _ = cur.take(3)?;
with_current_shape_mut!(state, |shape: &mut Shape| {
if let Type::Text(text_content) = &mut shape.shape_type {
text_content.set_grow_type(RawGrowType::from(raw_grow).into());
}
});
}
// 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)?;
}
let is_text_shape = with_state!(state, {
state
.current_shape()
.is_some_and(|shape| matches!(shape.shape_type, Type::Text(_)))
});
if mask & SECTION_FILLS != 0 {
let fills = parse_fills(&mut cur)?;
if is_text_shape {
with_current_shape_mut!(state, |shape: &mut Shape| {
shape.set_deferred_batch_fills(fills);
});
} else {
with_current_shape_mut!(state, |shape: &mut Shape| {
shape.set_fills(fills);
});
}
}
if mask & SECTION_STROKES != 0 {
let strokes = parse_strokes(&mut cur)?;
if is_text_shape {
with_current_shape_mut!(state, |shape: &mut Shape| {
shape.set_deferred_batch_strokes(strokes);
});
} else {
with_current_shape_mut!(state, |shape: &mut Shape| {
shape.clear_strokes();
for stroke in strokes {
shape.add_stroke(stroke);
}
});
}
}
Ok(())
}
fn parse_fills(cur: &mut Cursor<'_>) -> Result<Vec<crate::shapes::Fill>> {
let header = cur.take(4)?;
let n = header[0] as usize;
let bytes = if n == 0 {
&[][..]
} else {
cur.take(n * RAW_FILL_DATA_SIZE)?
};
Ok(read_fills_from_bytes(bytes, n))
}
fn parse_strokes(cur: &mut Cursor<'_>) -> Result<Vec<Stroke>> {
let n = cur.u32()? as usize;
let mut strokes = Vec::with_capacity(n);
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);
let mut 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::new_center_stroke(
width,
stroke_style.into(),
cap_start.try_into().ok(),
cap_end.try_into().ok(),
dash,
gap,
),
};
if has_sides {
stroke.widths = Some([top, right, bottom, left]);
}
stroke.fill = fill.into();
strokes.push(stroke);
}
Ok(strokes)
}
/// 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;
for _ in 0..count {
let payload_len = cur.u32()? as usize;
let payload = cur.take(payload_len)?;
apply_shape_payload(payload)?;
}
Ok(())
}

View File

@ -2,6 +2,7 @@ use macros::ToJs;
use crate::mem;
use crate::shapes::{self, StrokeCap, StrokeStyle};
use crate::utils::decode_optional_f32;
use crate::with_current_shape_mut;
#[derive(Debug, Clone, PartialEq, Copy, ToJs)]
@ -68,17 +69,6 @@ impl TryFrom<RawStrokeCap> for StrokeCap {
}
}
// A negative value means "unset" — the renderer falls back to its default
// dash pattern. We use a sentinel instead of passing a bool because adding
// two f32 params keeps the FFI signature flat and allocation-free.
fn decode_optional(value: f32) -> Option<f32> {
if value.is_finite() && value >= 0.0 {
Some(value)
} else {
None
}
}
#[no_mangle]
pub extern "C" fn add_shape_center_stroke(
width: f32,
@ -98,8 +88,8 @@ pub extern "C" fn add_shape_center_stroke(
stroke_style.into(),
cap_start.try_into().ok(),
cap_end.try_into().ok(),
decode_optional(dash),
decode_optional(gap),
decode_optional_f32(dash),
decode_optional_f32(gap),
));
});
}
@ -123,8 +113,8 @@ pub extern "C" fn add_shape_inner_stroke(
stroke_style.into(),
cap_start.try_into().ok(),
cap_end.try_into().ok(),
decode_optional(dash),
decode_optional(gap),
decode_optional_f32(dash),
decode_optional_f32(gap),
));
});
}
@ -148,8 +138,8 @@ pub extern "C" fn add_shape_outer_stroke(
stroke_style.into(),
cap_start.try_into().ok(),
cap_end.try_into().ok(),
decode_optional(dash),
decode_optional(gap),
decode_optional_f32(dash),
decode_optional_f32(gap),
));
});
}

View File

@ -376,6 +376,7 @@ fn update_text_layout(shape: &mut Shape, force: bool) {
text_content.force_next_layout_update();
}
text_content.update_layout(shape.selrect);
shape.apply_deferred_batch_paint();
shape.invalidate_extrect();
}
}