From b9ddfc1596b7033909f9772404853d5d8c564475 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 2 Sep 2026 10:32:52 +0200 Subject: [PATCH] :zap: 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. --- .../app/common/render_wasm/api/shapes.cljs | 189 ------- .../app/common/render_wasm/api/upload.cljs | 453 +++++++++++++++++ .../common/render_wasm/serialize_shape.cljs | 36 +- frontend/src/app/render_wasm/api.cljs | 307 ++++++++---- render-wasm/src/main.rs | 27 +- render-wasm/src/shapes.rs | 28 ++ render-wasm/src/state.rs | 30 ++ render-wasm/src/utils.rs | 9 + render-wasm/src/wasm/fills.rs | 2 +- render-wasm/src/wasm/layouts.rs | 3 + render-wasm/src/wasm/shapes/base_props.rs | 26 +- render-wasm/src/wasm/shapes/mod.rs | 1 + render-wasm/src/wasm/shapes/upload_batch.rs | 473 ++++++++++++++++++ render-wasm/src/wasm/strokes.rs | 24 +- render-wasm/src/wasm/text.rs | 1 + 15 files changed, 1224 insertions(+), 385 deletions(-) delete mode 100644 common/src/app/common/render_wasm/api/shapes.cljs create mode 100644 common/src/app/common/render_wasm/api/upload.cljs create mode 100644 render-wasm/src/wasm/shapes/upload_batch.rs diff --git a/common/src/app/common/render_wasm/api/shapes.cljs b/common/src/app/common/render_wasm/api/shapes.cljs deleted file mode 100644 index e507c1245e..0000000000 --- a/common/src/app/common/render_wasm/api/shapes.cljs +++ /dev/null @@ -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))) diff --git a/common/src/app/common/render_wasm/api/upload.cljs b/common/src/app/common/render_wasm/api/upload.cljs new file mode 100644 index 0000000000..eb2e42f57c --- /dev/null +++ b/common/src/app/common/render_wasm/api/upload.cljs @@ -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))) diff --git a/common/src/app/common/render_wasm/serialize_shape.cljs b/common/src/app/common/render_wasm/serialize_shape.cljs index 3f4111bb60..868c0321f5 100644 --- a/common/src/app/common/render_wasm/serialize_shape.cljs +++ b/common/src/app/common/render_wasm/serialize_shape.cljs @@ -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))))) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index aaf8112c7f..e50d3c15e9 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -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). diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index f58ddd9492..433a68c94d 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -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) -> 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(()) } diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index e93fd35e24..9b9989a271 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -200,6 +200,10 @@ pub struct Shape { pub svg_transform: Option, 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>, + /// Strokes from a cold-load batch, applied together with deferred fills. + deferred_batch_strokes: Option>, } // 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) { + 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) { + self.deferred_batch_fills = Some(fills); + } + + pub fn set_deferred_batch_strokes(&mut self, strokes: Vec) { + 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) { match &mut self.shape_type { Type::Bool(Bool { bool_type, .. }) => { diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 09220283cf..5b2684ecb4 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -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) -> 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); diff --git a/render-wasm/src/utils.rs b/render-wasm/src/utils.rs index e153e03b97..12c9c51f90 100644 --- a/render-wasm/src/utils.rs +++ b/render-wasm/src/utils.rs @@ -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 { + if value.is_finite() && value >= 0.0 { + Some(value) + } else { + None + } +} + #[derive(Debug, Clone, Copy)] #[repr(u8)] pub enum Browser { diff --git a/render-wasm/src/wasm/fills.rs b/render-wasm/src/wasm/fills.rs index c0a8d6850d..c4084ea983 100644 --- a/render-wasm/src/wasm/fills.rs +++ b/render-wasm/src/wasm/fills.rs @@ -8,7 +8,7 @@ mod gradient; mod image; mod solid; -const RAW_FILL_DATA_SIZE: usize = std::mem::size_of::(); +pub(crate) const RAW_FILL_DATA_SIZE: usize = std::mem::size_of::(); #[repr(C, u8, align(4))] #[derive(Debug, PartialEq, Clone, Copy, ToJs)] diff --git a/render-wasm/src/wasm/layouts.rs b/render-wasm/src/wasm/layouts.rs index d179188e51..364ef49cbd 100644 --- a/render-wasm/src/wasm/layouts.rs +++ b/render-wasm/src/wasm/layouts.rs @@ -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)] diff --git a/render-wasm/src/wasm/shapes/base_props.rs b/render-wasm/src/wasm/shapes/base_props.rs index ab204a1f44..9ab65f78fe 100644 --- a/render-wasm/src/wasm/shapes/base_props.rs +++ b/render-wasm/src/wasm/shapes/base_props.rs @@ -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::(); +pub(crate) const RAW_BASE_PROPS_SIZE: usize = std::mem::size_of::(); /// Binary layout for batched shape base properties. /// @@ -24,7 +21,7 @@ const RAW_BASE_PROPS_SIZE: usize = std::mem::size_of::(); #[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); diff --git a/render-wasm/src/wasm/shapes/mod.rs b/render-wasm/src/wasm/shapes/mod.rs index 901feb57f8..11175d3780 100644 --- a/render-wasm/src/wasm/shapes/mod.rs +++ b/render-wasm/src/wasm/shapes/mod.rs @@ -1,4 +1,5 @@ mod base_props; +mod upload_batch; use macros::ToJs; diff --git a/render-wasm/src/wasm/shapes/upload_batch.rs b/render-wasm/src/wasm/shapes/upload_batch.rs new file mode 100644 index 0000000000..99051842a4 --- /dev/null +++ b/render-wasm/src/wasm/shapes/upload_batch.rs @@ -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 { + let b = self.take(4)?; + Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]])) + } + + fn u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + + fn f32(&mut self) -> Result { + let b = self.take(4)?; + Ok(f32::from_le_bytes([b[0], b[1], b[2], b[3]])) + } + + fn i32(&mut self) -> Result { + let b = self.take(4)?; + Ok(i32::from_le_bytes([b[0], b[1], b[2], b[3]])) + } + + fn uuid(&mut self) -> Result { + 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 { + 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> { + 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> { + 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(()) +} diff --git a/render-wasm/src/wasm/strokes.rs b/render-wasm/src/wasm/strokes.rs index 647510ff0b..02f9bf1d6e 100644 --- a/render-wasm/src/wasm/strokes.rs +++ b/render-wasm/src/wasm/strokes.rs @@ -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 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 { - 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), )); }); } diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs index 6e2575741f..c154afbf89 100644 --- a/render-wasm/src/wasm/text.rs +++ b/render-wasm/src/wasm/text.rs @@ -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(); } }