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..3162f5aee9 --- /dev/null +++ b/common/src/app/common/render_wasm/api/upload.cljs @@ -0,0 +1,456 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.common.render-wasm.api.upload + "Enlarged per-shape + multi-shape structural upload for WASM cold load. + + Writes a binary batch consumed by `_set_shapes_batch`. Remaining + host-specific attrs (image bytes, text, path, grid tracks) are applied + afterwards via the existing per-shape setters." + (:require + [app.common.data :as d] + [app.common.data.macros :as dm] + [app.common.render-wasm.helpers :as h] + [app.common.render-wasm.mem :as mem] + [app.common.render-wasm.serializers :as sr] + [app.common.render-wasm.serializers.color :as sr-clr] + [app.common.render-wasm.wasm :as wasm] + [app.common.types.fills :as types.fills] + [app.common.types.fills.impl :as types.fills.impl] + [app.common.types.shape.layout :as ctl] + [app.common.uuid :as uuid])) + +(def ^:const BASE-PROPS-SIZE 104) +(def ^:const FLAG-CLIP-CONTENT 0x01) +(def ^:const FLAG-HIDDEN 0x02) + +(def ^:const SECTION-CHILDREN 0x01) +(def ^:const SECTION-BLUR-LAYER 0x02) +(def ^:const SECTION-BLUR-BG 0x04) +(def ^:const SECTION-SHADOWS 0x08) +(def ^:const SECTION-MASKED 0x10) +(def ^:const SECTION-BOOL-TYPE 0x20) +(def ^:const SECTION-GROW-TYPE 0x40) +(def ^:const SECTION-LAYOUT-ITEM 0x80) +(def ^:const SECTION-FLEX 0x100) +(def ^:const SECTION-FILLS 0x200) +(def ^:const SECTION-STROKES 0x400) + +;; Stroke header before RawFillData (must match upload_batch.rs). +(def ^:const STROKE-HEADER-U8-SIZE 36) +(def ^:const STROKE-ALIGN-CENTER 0) +(def ^:const STROKE-ALIGN-INNER 1) +(def ^:const STROKE-ALIGN-OUTER 2) + +(defn- write-uuid! + [dview offset id] + (let [buffer (uuid/get-u32 id)] + (.setUint32 dview offset (aget buffer 0) true) + (.setUint32 dview (+ offset 4) (aget buffer 1) true) + (.setUint32 dview (+ offset 8) (aget buffer 2) true) + (.setUint32 dview (+ offset 12) (aget buffer 3) true) + (+ offset 16))) + +(defn- write-base-props! + "Write the 104-byte RawBasePropsData at `offset`. Returns next offset." + [dview offset shape] + (let [id (dm/get-prop shape :id) + parent-id (get shape :parent-id) + shape-type (dm/get-prop shape :type) + clip-content (if (= shape-type :frame) + (not (get shape :show-content)) + false) + hidden (get shape :hidden false) + flags (cond-> 0 + clip-content (bit-or FLAG-CLIP-CONTENT) + hidden (bit-or FLAG-HIDDEN)) + blend-mode (sr/translate-blend-mode (get shape :blend-mode)) + constraint-h (sr/translate-constraint-h (or (get shape :constraints-h) :none)) + constraint-v (sr/translate-constraint-v (or (get shape :constraints-v) :none)) + opacity (d/nilv (get shape :opacity) 1.0) + rotation (d/nilv (get shape :rotation) 0.0) + transform (get shape :transform) + [ta tb tc td te tf] + (if (some? transform) + [(dm/get-prop transform :a) + (dm/get-prop transform :b) + (dm/get-prop transform :c) + (dm/get-prop transform :d) + (dm/get-prop transform :e) + (dm/get-prop transform :f)] + [1.0 0.0 0.0 1.0 0.0 0.0]) + selrect (get shape :selrect) + [sx1 sy1 sx2 sy2] + (if (some? selrect) + [(dm/get-prop selrect :x1) + (dm/get-prop selrect :y1) + (dm/get-prop selrect :x2) + (dm/get-prop selrect :y2)] + [0.0 0.0 0.0 0.0]) + r1 (d/nilv (get shape :r1) 0.0) + r2 (d/nilv (get shape :r2) 0.0) + r3 (d/nilv (get shape :r3) 0.0) + r4 (d/nilv (get shape :r4) 0.0)] + + (write-uuid! dview offset id) + (write-uuid! dview (+ offset 16) (d/nilv parent-id uuid/zero)) + (.setUint8 dview (+ offset 32) (sr/translate-shape-type shape-type)) + (.setUint8 dview (+ offset 33) flags) + (.setUint8 dview (+ offset 34) blend-mode) + (.setUint8 dview (+ offset 35) constraint-h) + (.setUint8 dview (+ offset 36) constraint-v) + (.setFloat32 dview (+ offset 40) opacity true) + (.setFloat32 dview (+ offset 44) rotation true) + (.setFloat32 dview (+ offset 48) ta true) + (.setFloat32 dview (+ offset 52) tb true) + (.setFloat32 dview (+ offset 56) tc true) + (.setFloat32 dview (+ offset 60) td true) + (.setFloat32 dview (+ offset 64) te true) + (.setFloat32 dview (+ offset 68) tf true) + (.setFloat32 dview (+ offset 72) sx1 true) + (.setFloat32 dview (+ offset 76) sy1 true) + (.setFloat32 dview (+ offset 80) sx2 true) + (.setFloat32 dview (+ offset 84) sy2 true) + (.setFloat32 dview (+ offset 88) r1 true) + (.setFloat32 dview (+ offset 92) r2 true) + (.setFloat32 dview (+ offset 96) r3 true) + (.setFloat32 dview (+ offset 100) r4 true) + (+ offset BASE-PROPS-SIZE))) + +(defn- write-blur! + [dview offset blur] + (.setUint8 dview offset (if (get blur :hidden) 1 0)) + (.setFloat32 dview (+ offset 4) (get blur :value 0) true) + (+ offset 8)) + +(defn- write-shadow! + [dview offset shadow] + (let [color (get shadow :color) + rgba (sr-clr/hex->u32argb (get color :color) + (get color :opacity))] + (.setUint32 dview offset rgba true) + (.setFloat32 dview (+ offset 4) (get shadow :blur 0) true) + (.setFloat32 dview (+ offset 8) (get shadow :spread 0) true) + (.setFloat32 dview (+ offset 12) (get shadow :offset-x 0) true) + (.setFloat32 dview (+ offset 16) (get shadow :offset-y 0) true) + (.setUint8 dview (+ offset 20) (sr/translate-shadow-style (get shadow :style))) + (.setUint8 dview (+ offset 21) (if (get shadow :hidden) 1 0)) + (+ offset 24))) + +(defn- write-flex! + [dview offset shape] + (let [dir (-> (get shape :layout-flex-dir :row) + (sr/translate-layout-flex-dir)) + gap (get shape :layout-gap) + row-gap (get gap :row-gap 0) + column-gap (get gap :column-gap 0) + align-items (-> (get shape :layout-align-items) sr/translate-layout-align-items) + align-content (-> (get shape :layout-align-content) sr/translate-layout-align-content) + justify-items (-> (get shape :layout-justify-items) sr/translate-layout-justify-items) + justify-content (-> (get shape :layout-justify-content) sr/translate-layout-justify-content) + wrap-type (-> (get shape :layout-wrap-type) sr/translate-layout-wrap-type) + padding (get shape :layout-padding) + padding-top (get padding :p1 0) + padding-right (get padding :p2 0) + padding-bottom (get padding :p3 0) + padding-left (get padding :p4 0)] + (.setUint8 dview offset dir) + (.setUint8 dview (+ offset 1) align-items) + (.setUint8 dview (+ offset 2) align-content) + (.setUint8 dview (+ offset 3) justify-items) + (.setUint8 dview (+ offset 4) justify-content) + (.setUint8 dview (+ offset 5) wrap-type) + (.setFloat32 dview (+ offset 8) row-gap true) + (.setFloat32 dview (+ offset 12) column-gap true) + (.setFloat32 dview (+ offset 16) padding-top true) + (.setFloat32 dview (+ offset 20) padding-right true) + (.setFloat32 dview (+ offset 24) padding-bottom true) + (.setFloat32 dview (+ offset 28) padding-left true) + (+ offset 32))) + +(defn- write-layout-item! + [dview offset shape] + (let [margins (get shape :layout-item-margin) + margin-top (get margins :m1 0) + margin-right (get margins :m2 0) + margin-bottom (get margins :m3 0) + margin-left (get margins :m4 0) + h-sizing (-> (get shape :layout-item-h-sizing) sr/translate-layout-sizing) + v-sizing (-> (get shape :layout-item-v-sizing) sr/translate-layout-sizing) + align-self (-> (get shape :layout-item-align-self) sr/translate-align-self) + max-h (get shape :layout-item-max-h) + min-h (get shape :layout-item-min-h) + max-w (get shape :layout-item-max-w) + min-w (get shape :layout-item-min-w) + is-absolute (boolean (get shape :layout-item-absolute)) + z-index (get shape :layout-item-z-index) + flags (cond-> 0 + (some? max-h) (bit-or 0x01) + (some? min-h) (bit-or 0x02) + (some? max-w) (bit-or 0x04) + (some? min-w) (bit-or 0x08) + is-absolute (bit-or 0x10))] + (.setFloat32 dview offset margin-top true) + (.setFloat32 dview (+ offset 4) margin-right true) + (.setFloat32 dview (+ offset 8) margin-bottom true) + (.setFloat32 dview (+ offset 12) margin-left true) + (.setUint8 dview (+ offset 16) (d/nilv h-sizing 0)) + (.setUint8 dview (+ offset 17) (d/nilv v-sizing 0)) + (.setUint8 dview (+ offset 18) flags) + (.setUint8 dview (+ offset 19) (d/nilv align-self 0)) + (.setFloat32 dview (+ offset 20) (d/nilv max-h 0) true) + (.setFloat32 dview (+ offset 24) (d/nilv min-h 0) true) + (.setFloat32 dview (+ offset 28) (d/nilv max-w 0) true) + (.setFloat32 dview (+ offset 32) (d/nilv min-w 0) true) + (.setInt32 dview (+ offset 36) (d/nilv z-index 0) true) + (+ offset 40))) + +(defn- write-fills-section! + "Write fills in the same layout as `_set_shape_fills`: + [u8 n][u8;3 pad][n × FILL-U8-SIZE]. Returns next offset." + [dview offset fills] + (let [fills (types.fills/coerce (or fills [])) + byte-size (types.fills/get-byte-size fills) + ;; write-to expects a Uint32Array heap + u32 element offset + heap-u32 (js/Uint32Array. (.-buffer dview)) + u32-off (quot offset 4)] + (types.fills/write-to fills heap-u32 u32-off) + (+ offset byte-size))) + +(defn- write-stroke-fill! + [dview offset stroke] + (let [opacity (or (:stroke-opacity stroke) 1.0) + color (:stroke-color stroke) + gradient (:stroke-color-gradient stroke) + image (:stroke-image stroke)] + (cond + (some? gradient) + (types.fills.impl/write-gradient-fill offset dview opacity gradient) + + (some? image) + (types.fills.impl/write-image-fill offset dview opacity image) + + (some? color) + (types.fills.impl/write-solid-fill offset dview opacity color) + + :else + (types.fills.impl/write-solid-fill offset dview 0.0 "#000000")))) + +(defn- write-stroke! + [dview offset stroke] + (let [width (or (:stroke-width stroke) 1.0) + style (-> stroke :stroke-style sr/translate-stroke-style) + align (case (:stroke-alignment stroke) + :inner STROKE-ALIGN-INNER + :outer STROKE-ALIGN-OUTER + STROKE-ALIGN-CENTER) + cap-start (-> stroke :stroke-cap-start sr/translate-stroke-cap) + cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap) + dash (or (:stroke-dash stroke) -1) + gap (or (:stroke-gap stroke) -1) + per-side? (boolean (:stroke-per-side stroke)) + top (or (:stroke-width-top stroke) width) + right (or (:stroke-width-right stroke) width) + bottom (or (:stroke-width-bottom stroke) width) + left (or (:stroke-width-left stroke) width) + has-sides? (and per-side? (not= top right bottom left))] + (.setFloat32 dview offset width true) + (.setUint8 dview (+ offset 4) style) + (.setUint8 dview (+ offset 5) align) + (.setUint8 dview (+ offset 6) (d/nilv cap-start 0)) + (.setUint8 dview (+ offset 7) (d/nilv cap-end 0)) + (.setFloat32 dview (+ offset 8) dash true) + (.setFloat32 dview (+ offset 12) gap true) + (.setUint8 dview (+ offset 16) (if has-sides? 1 0)) + (.setFloat32 dview (+ offset 20) top true) + (.setFloat32 dview (+ offset 24) right true) + (.setFloat32 dview (+ offset 28) bottom true) + (.setFloat32 dview (+ offset 32) left true) + (write-stroke-fill! dview (+ offset STROKE-HEADER-U8-SIZE) stroke) + (+ offset STROKE-HEADER-U8-SIZE types.fills.impl/FILL-U8-SIZE))) + +(defn- visible-strokes + [shape] + (let [type (dm/get-prop shape :type)] + (if (= type :group) + [] + (into [] (remove :hidden) (or (get shape :strokes) []))))) + +(defn- write-strokes-section! + [dview offset strokes] + (.setUint32 dview offset (count strokes) true) + (reduce (fn [o s] (write-stroke! dview o s)) + (+ offset 4) + strokes)) + +(defn write-shape-payload! + "Serialize one shape's structural payload into `dview` starting at `offset` + (payload only — no length prefix). Returns the offset after the payload. + + Options: + - `:include-layout?` — when true, emit FLEX + LAYOUT-ITEM (workspace cold load). + - `:include-fills-strokes?` — when true, emit FILLS + STROKES sections." + [dview offset shape {:keys [include-layout? include-fills-strokes?] + :or {include-layout? false + include-fills-strokes? false}}] + (let [shape-type (dm/get-prop shape :type) + children (into [] (filter uuid?) (get shape :shapes)) + blur (get shape :blur) + bg-blur (get shape :background-blur) + shadows (or (get shape :shadow) []) + masked? (and (= shape-type :group) (boolean (get shape :masked-group))) + bool-type (when (= shape-type :bool) (get shape :bool-type)) + grow-type (when (= shape-type :text) (get shape :grow-type)) + flex? (and include-layout? (ctl/flex-layout? shape)) + layout-item? include-layout? + strokes (when include-fills-strokes? (visible-strokes shape)) + + mask (cond-> 0 + true (bit-or SECTION-CHILDREN) + (some? blur) (bit-or SECTION-BLUR-LAYER) + (some? bg-blur) (bit-or SECTION-BLUR-BG) + (seq shadows) (bit-or SECTION-SHADOWS) + (= shape-type :group) (bit-or SECTION-MASKED) + (some? bool-type) (bit-or SECTION-BOOL-TYPE) + (some? grow-type) (bit-or SECTION-GROW-TYPE) + flex? (bit-or SECTION-FLEX) + layout-item? (bit-or SECTION-LAYOUT-ITEM) + include-fills-strokes? (bit-or SECTION-FILLS) + include-fills-strokes? (bit-or SECTION-STROKES)) + + offset (write-base-props! dview offset shape) + _ (.setUint32 dview offset mask true) + offset (+ offset 4) + + offset (let [o offset] + (.setUint32 dview o (count children) true) + (reduce (fn [o id] (write-uuid! dview o id)) + (+ o 4) + children)) + + offset (cond-> offset + (some? blur) + (as-> o (write-blur! dview o blur))) + + offset (cond-> offset + (some? bg-blur) + (as-> o (write-blur! dview o bg-blur))) + + offset (cond-> offset + (seq shadows) + (as-> o + (do + (.setUint32 dview o (count shadows) true) + (reduce (fn [o s] (write-shadow! dview o s)) + (+ o 4) + shadows)))) + + offset (cond-> offset + (= shape-type :group) + (as-> o + (do (.setUint8 dview o (if masked? 1 0)) + (+ o 4)))) + + offset (cond-> offset + (some? bool-type) + (as-> o + (do (.setUint8 dview o (sr/translate-bool-type bool-type)) + (+ o 4)))) + + offset (cond-> offset + (some? grow-type) + (as-> o + (do (.setUint8 dview o (sr/translate-grow-type grow-type)) + (+ o 4)))) + + ;; FLEX before LAYOUT-ITEM (Rust clears layout on flex) + offset (cond-> offset + flex? + (as-> o (write-flex! dview o shape))) + + offset (cond-> offset + layout-item? + (as-> o (write-layout-item! dview o shape))) + + offset (cond-> offset + include-fills-strokes? + (as-> o (write-fills-section! dview o (get shape :fills)))) + + offset (cond-> offset + include-fills-strokes? + (as-> o (write-strokes-section! dview o strokes)))] + offset)) + +(defn- payload-byte-size + [shape {:keys [include-layout? include-fills-strokes?] + :or {include-layout? false include-fills-strokes? false}}] + (let [children (into [] (filter uuid?) (get shape :shapes)) + shadows (or (get shape :shadow) []) + shape-type (dm/get-prop shape :type) + blur (get shape :blur) + bg-blur (get shape :background-blur) + flex? (and include-layout? (ctl/flex-layout? shape)) + fills-size (if include-fills-strokes? + (types.fills/get-byte-size (types.fills/coerce (or (get shape :fills) []))) + 0) + strokes (when include-fills-strokes? (visible-strokes shape)) + strokes-size (if include-fills-strokes? + (+ 4 (* (count strokes) + (+ STROKE-HEADER-U8-SIZE types.fills.impl/FILL-U8-SIZE))) + 0)] + (+ BASE-PROPS-SIZE + 4 ;; mask + (+ 4 (* 16 (count children))) + (if (some? blur) 8 0) + (if (some? bg-blur) 8 0) + (if (seq shadows) (+ 4 (* 24 (count shadows))) 0) + (if (= shape-type :group) 4 0) + (if (and (= shape-type :bool) (some? (get shape :bool-type))) 4 0) + (if (and (= shape-type :text) (some? (get shape :grow-type))) 4 0) + (if flex? 32 0) + (if include-layout? 40 0) + fills-size + strokes-size))) + +(defn- encode-shape-record + "Returns a Uint8Array: [u32 payload_len][payload]." + [shape opts] + (let [capacity (+ 4 (payload-byte-size shape opts)) + buffer (js/ArrayBuffer. capacity) + dview (js/DataView. buffer) + end (write-shape-payload! dview 4 shape opts) + payload-len (- end 4)] + (assert (= end capacity) + (str "upload record size mismatch: wrote " end " expected " capacity)) + (.setUint32 dview 0 payload-len true) + (js/Uint8Array. buffer 0 end))) + +(defn flush-shapes-batch! + "Upload `shapes` as one `_set_shapes_batch` call. + `opts` passed to each record writer (`:include-layout?`, + `:include-fills-strokes?`)." + [shapes opts] + (when (and (wasm/live?) (seq shapes)) + (let [records (mapv #(encode-shape-record % opts) shapes) + total (reduce (fn [acc ^js u8] (+ acc (.-byteLength u8))) 4 records) + offset (mem/alloc total) + heap (mem/get-heap-u8) + dview (js/DataView. (.-buffer heap))] + (.setUint32 dview offset (count records) true) + (reduce (fn [o ^js u8] + (.set heap u8 o) + (+ o (.-byteLength u8))) + (+ offset 4) + records) + (h/call wasm/internal-module "_set_shapes_batch") + nil))) + +(defn set-shape-upload! + "Single-shape structural upload (enlarged blob, one FFI)." + ([shape] + (set-shape-upload! shape {:include-layout? false})) + ([shape opts] + (flush-shapes-batch! [shape] opts))) 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 a1fc87a83d..35775f3693 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] @@ -1030,37 +1031,56 @@ (map #(process-fill-image shape-id % thumbnail?)))))) (defn set-shape-fills - [shape-id fills thumbnail?] - ;; Record write is shared with the headless exporter; the image fetch below is - ;; browser-only (WebGL textures). - (when-let [fills (props/write-shape-fills! fills)] - (keep (fn [id] - (let [buffer (uuid/get-u32 id) - cached-image? (h/call wasm/internal-module "_is_image_cached" - (aget buffer 0) - (aget buffer 1) - (aget buffer 2) - (aget buffer 3) - thumbnail?)] - (when (zero? cached-image?) - (fetch-image shape-id id thumbnail?)))) - (types.fills/get-image-ids fills)))) + "Writes fill records (unless `write?` is false) and returns pending image + fetches. When fills were already uploaded in `_set_shapes_batch`, pass + `write?` false so only image fetches remain." + ([shape-id fills thumbnail?] + (set-shape-fills shape-id fills thumbnail? true)) + ([shape-id fills thumbnail? write?] + (let [fills (if write? + (props/write-shape-fills! fills) + (when (seq fills) + (types.fills/coerce fills)))] + (when fills + (keep (fn [id] + (let [buffer (uuid/get-u32 id) + cached-image? (h/call wasm/internal-module "_is_image_cached" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3) + thumbnail?)] + (when (zero? cached-image?) + (fetch-image shape-id id thumbnail?)))) + (types.fills/get-image-ids fills)))))) + +(defn- stroke-image-ids + [strokes] + (into [] + (comp (remove :hidden) + (keep #(get-in % [:stroke-image :id]))) + (or strokes []))) (defn set-shape-strokes - [shape-id strokes thumbnail?] - ;; Record write is shared with the headless exporter; the image fetch below is - ;; browser-only (WebGL textures). - (keep (fn [image-id] - (let [buffer (uuid/get-u32 image-id) - cached-image? (h/call wasm/internal-module "_is_image_cached" - (aget buffer 0) - (aget buffer 1) - (aget buffer 2) - (aget buffer 3) - thumbnail?)] - (when (zero? cached-image?) - (fetch-image shape-id image-id thumbnail?)))) - (props/write-shape-strokes! strokes))) + "Writes stroke records (unless `write?` is false) and returns pending image + fetches for stroke image fills." + ([shape-id strokes thumbnail?] + (set-shape-strokes shape-id strokes thumbnail? true)) + ([shape-id strokes thumbnail? write?] + (let [image-ids (if write? + (props/write-shape-strokes! strokes) + (stroke-image-ids strokes))] + (keep (fn [image-id] + (let [buffer (uuid/get-u32 image-id) + cached-image? (h/call wasm/internal-module "_is_image_cached" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3) + thumbnail?)] + (when (zero? cached-image?) + (fetch-image shape-id image-id thumbnail?)))) + image-ids)))) (defn set-shape-svg-attrs [attrs] @@ -1545,47 +1565,70 @@ [content] (or content (tc/v2-default-text-content))) +(defn- set-object-host-attrs + "Host-specific attrs after structural upload (text/svg-raw/grid; optionally + fills/strokes). When `skip-layout?` is true, flex+layout-item were already in + the batch; only grid tracks/cells are applied here. When + `skip-fills-strokes?` is true, fill/stroke records were already in the batch; + only image fetches remain. + + Always `use-shape` first: after a multi-shape batch the WASM current shape is + the last record in the chunk, not this shape." + [shape skip-layout? & {:keys [skip-fills-strokes?] :or {skip-fills-strokes? false}}] + (let [id (dm/get-prop shape :id) + type (dm/get-prop shape :type) + fills (get shape :fills) + strokes (if (= type :group) [] (get shape :strokes)) + content (let [content (get shape :content)] + (if (= type :text) + (ensure-text-content content) + content)) + write-fills-strokes? (not skip-fills-strokes?) + needs-current? (or write-fills-strokes? + (= type :text) + (and (some? content) (= type :svg-raw)) + (if skip-layout? + (ctl/grid-layout? shape) + true))] + + (when needs-current? + (use-shape id)) + + (when (and (some? content) (= type :svg-raw)) + (set-shape-svg-raw-content (get-static-markup shape))) + + (if skip-layout? + (when (ctl/grid-layout? shape) + (set-grid-layout shape)) + (do (set-shape-layout shape) + (set-layout-data shape))) + + (let [is-text? (= type :text) + text-content-pending (when is-text? (set-shape-text-content id content)) + pending-thumbnails (into [] (concat + text-content-pending + (when is-text? (set-shape-text-images id content true)) + (set-shape-fills id fills true write-fills-strokes?) + (set-shape-strokes id strokes true write-fills-strokes?))) + pending-full (into [] (concat + (when is-text? (set-shape-text-images id content false)) + (set-shape-fills id fills false write-fills-strokes?) + (set-shape-strokes id strokes false write-fills-strokes?)))] + {:thumbnails pending-thumbnails + :full pending-full + :font-pending-ids (if (some :callback text-content-pending) [id] [])}))) + (defn set-object [shape] (if-not (and shape (wasm/live?)) {:thumbnails [] :full [] :font-pending-ids []} (do (perf/begin-measure "set-object") - (let [shape (svg-filters/apply-svg-derived shape) - id (dm/get-prop shape :id) - type (dm/get-prop shape :type) - - fills (get shape :fills) - strokes (if (= type :group) - [] (get shape :strokes)) - content (let [content (get shape :content)] - (if (= type :text) - (ensure-text-content content) - content))] - + (let [shape (svg-filters/apply-svg-derived shape)] (serialize-shape/serialize-shape! shape) - - ;; Browser-only: svg-raw markup (needs React) + workspace layout. - (when (and (some? content) (= type :svg-raw)) - (set-shape-svg-raw-content (get-static-markup shape))) - (set-shape-layout shape) - (set-layout-data shape) - (let [is-text? (= type :text) - text-content-pending (when is-text? (set-shape-text-content id content)) - pending-thumbnails (into [] (concat - text-content-pending - (when is-text? (set-shape-text-images id content true)) - (set-shape-fills id fills true) - (set-shape-strokes id strokes true))) - pending-full (into [] (concat - (when is-text? (set-shape-text-images id content false)) - (set-shape-fills id fills false) - (set-shape-strokes id strokes false)))] + (let [result (set-object-host-attrs shape false)] (perf/end-measure "set-object") - {:thumbnails pending-thumbnails - :full pending-full - :font-pending-ids (if (some :callback text-content-pending) [id] [])}))))) - + result))))) (defn- update-text-layouts "Synchronously update text layouts for all shapes and send rect updates to the worker index." @@ -1697,30 +1740,54 @@ :font-pending-ids (persistent! font-acc)}))] (process-pending shapes thumbnails full font-pending-ids noop-fn))) +(def ^:private ^:const BATCH_MAX_SHAPES 512) + (defn- process-shapes-chunk - "Process shapes starting at `start-index` until the time budget is exhausted. + "Process up to `BATCH_MAX_SHAPES` shapes starting at `start-index`. + + Structural attrs are uploaded in one `_set_shapes_batch` FFI per chunk; + host-specific attrs (fills/strokes/text/grid/path) stay per-shape. + Returns {:thumbnails [...] :full [...] :font-pending-ids [...] :next-index n}" [shapes start-index thumbnails-acc full-acc font-pending-acc] - (let [total (count shapes) - deadline (+ (js/performance.now) CHUNK_TIME_BUDGET_MS)] - (loop [index start-index + (let [total (count shapes) + end-index (min total (+ start-index BATCH_MAX_SHAPES)) + chunk (into [] (subvec (if (vector? shapes) shapes (vec shapes)) + start-index end-index)) + prepared (mapv svg-filters/apply-svg-derived chunk)] + + ;; One multi-shape structural upload (base+children+blur+shadows+flex+item+fills+strokes). + (when (seq prepared) + (upload/flush-shapes-batch! prepared {:include-layout? true + :include-fills-strokes? true})) + + ;; Path + svg-attrs still need the legacy per-shape path (variable/large). + (doseq [shape prepared] + (let [id (dm/get-prop shape :id) + type (dm/get-prop shape :type)] + (when (or (some? (get shape :svg-attrs)) + (and (contains? #{:path :bool} type) (some? (get shape :content)))) + (use-shape id) + (when (some? (get shape :svg-attrs)) + (props/set-shape-svg-attrs (get shape :svg-attrs))) + (when (and (contains? #{:path :bool} type) (some? (get shape :content))) + (props/set-shape-path-content (get shape :content)))))) + + (loop [xs prepared t-acc (transient thumbnails-acc) f-acc (transient full-acc) fp-acc (transient font-pending-acc)] - (if (and (< index total) - ;; Check performance.now every 8 shapes to reduce overhead - (or (pos? (bit-and (- index start-index) 7)) - (<= (js/performance.now) deadline))) - (let [shape (nth shapes index) - {:keys [thumbnails full font-pending-ids]} (set-object shape)] - (recur (inc index) + (if-let [shape (first xs)] + (let [{:keys [thumbnails full font-pending-ids]} + (set-object-host-attrs shape true :skip-fills-strokes? true)] + (recur (next xs) (reduce conj! t-acc thumbnails) (reduce conj! f-acc full) (reduce conj! fp-acc font-pending-ids))) {:thumbnails (persistent! t-acc) :full (persistent! f-acc) :font-pending-ids (persistent! fp-acc) - :next-index index})))) + :next-index end-index})))) (defn- set-objects-async "Asynchronously process shapes in time-budgeted chunks, yielding to the @@ -1847,32 +1914,50 @@ (defn- set-objects-sync "Synchronously process all shapes (for small shape counts)." [shapes render-callback on-shapes-ready] - (let [total-shapes (count shapes) - {:keys [thumbnails full font-pending-ids]} - (loop [index 0 thumbnails-acc (transient []) full-acc (transient []) font-acc (transient [])] - (if (< index total-shapes) - (let [shape (nth shapes index) - {:keys [thumbnails full font-pending-ids]} (set-object shape)] - (recur (inc index) - (reduce conj! thumbnails-acc thumbnails) - (reduce conj! full-acc full) - (reduce conj! font-acc font-pending-ids))) - {:thumbnails (persistent! thumbnails-acc) - :full (persistent! full-acc) - :font-pending-ids (persistent! font-acc)}))] - (perf/end-measure "set-objects") - (when on-shapes-ready (on-shapes-ready)) - (when (wasm/live?) - ;; Rebuild the tile index so _render knows which shapes - ;; map to which tiles after a page switch. - (h/call wasm/internal-module "_set_view_end") - (reset! view-interaction-active? false) - (process-pending shapes thumbnails full font-pending-ids - (fn [] - (if render-callback - (render-callback) - (request-render "set-objects-sync-complete")) - (ug/dispatch! (ug/event "penpot:wasm:set-objects"))))))) + (let [prepared (mapv svg-filters/apply-svg-derived shapes)] + (when (seq prepared) + (upload/flush-shapes-batch! prepared {:include-layout? true + :include-fills-strokes? true})) + (doseq [shape prepared] + (let [id (dm/get-prop shape :id) + type (dm/get-prop shape :type)] + (when (or (some? (get shape :svg-attrs)) + (and (contains? #{:path :bool} type) (some? (get shape :content)))) + (use-shape id) + (when (some? (get shape :svg-attrs)) + (props/set-shape-svg-attrs (get shape :svg-attrs))) + (when (and (contains? #{:path :bool} type) (some? (get shape :content))) + (props/set-shape-path-content (get shape :content)))))) + (let [total-shapes (count prepared) + {:keys [thumbnails full font-pending-ids]} + (loop [index 0 + thumbnails-acc (transient []) + full-acc (transient []) + font-acc (transient [])] + (if (< index total-shapes) + (let [shape (nth prepared index) + {:keys [thumbnails full font-pending-ids]} + (set-object-host-attrs shape true :skip-fills-strokes? true)] + (recur (inc index) + (reduce conj! thumbnails-acc thumbnails) + (reduce conj! full-acc full) + (reduce conj! font-acc font-pending-ids))) + {:thumbnails (persistent! thumbnails-acc) + :full (persistent! full-acc) + :font-pending-ids (persistent! font-acc)}))] + (perf/end-measure "set-objects") + (when on-shapes-ready (on-shapes-ready)) + (when (wasm/live?) + ;; Rebuild the tile index so _render knows which shapes + ;; map to which tiles after a page switch. + (h/call wasm/internal-module "_set_view_end") + (reset! view-interaction-active? false) + (process-pending shapes thumbnails full font-pending-ids + (fn [] + (if render-callback + (render-callback) + (request-render "set-objects-sync-complete")) + (ug/dispatch! (ug/event "penpot:wasm:set-objects")))))))) (defn- shapes-in-tree-order "Returns shapes sorted in tree order (parents before children). diff --git a/render-wasm/src/globals.rs b/render-wasm/src/globals.rs index b1de8c9271..acf53a9a65 100644 --- a/render-wasm/src/globals.rs +++ b/render-wasm/src/globals.rs @@ -203,6 +203,7 @@ pub extern "C" fn init_headless(_width: i32, _height: i32) -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn clean_up() -> Result<()> { + crate::performance::page_trace_reset("clean_up"); // Cancel the current animation frame if it exists so // it won't try to render without context let render_state = get_render_state(); diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index c5bff2105f..f00d43eec4 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -274,6 +274,7 @@ pub extern "C" fn render_preview() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn begin_loading() -> Result<()> { + performance::page_trace("begin_loading"); with_state!(state, { state.loading = true; }); @@ -288,6 +289,7 @@ pub extern "C" fn end_loading() -> Result<()> { with_state!(state, { state.loading = false; }); + performance::page_trace_end_loading(); Ok(()) } @@ -307,6 +309,7 @@ pub extern "C" fn render_loading_overlay() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn reset_canvas() -> Result<()> { + performance::page_trace("reset_canvas"); get_render_state().reset_canvas(); Ok(()) } @@ -321,10 +324,12 @@ pub extern "C" fn resize_viewbox(width: i32, height: i32) -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn set_view(zoom: f32, x: f32, y: f32) -> Result<()> { + performance::page_trace("set_view:begin"); performance::begin_measure!("set_view"); let render_state = get_render_state(); render_state.set_view(zoom, x, y); performance::end_measure!("set_view"); + performance::page_trace("set_view:end"); Ok(()) } @@ -352,6 +357,7 @@ pub extern "C" fn set_view_start() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn set_view_end() -> Result<()> { + performance::page_trace("set_view_end:begin"); with_state!(state, { performance::begin_measure!("set_view_end"); let render_state = get_render_state(); @@ -382,6 +388,7 @@ pub extern "C" fn set_view_end() -> Result<()> { } performance::end_measure!("set_view_end"); }); + performance::page_trace("set_view_end:end"); Ok(()) } @@ -444,15 +451,23 @@ pub extern "C" fn set_focus_mode() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn init_shapes_pool(capacity: usize) -> Result<()> { + // First open has no preceding clean_up; page switch already reset there. + if !performance::page_trace_active() { + performance::page_trace_reset(&format!("init_shapes_pool capacity={capacity}")); + } else { + performance::page_trace(&format!("init_shapes_pool capacity={capacity}")); + } with_state!(state, { state.init_shapes_pool(capacity); }); + performance::page_trace("init_shapes_pool:done"); Ok(()) } #[no_mangle] #[wasm_error] pub extern "C" fn use_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> { + performance::page_trace_use_shape(); with_state!(state, { let id = uuid_from_u32_quartet(a, b, c, d); state.use_shape(id); @@ -552,33 +567,8 @@ pub extern "C" fn add_shape_child(a: u32, b: u32, c: u32, d: u32) -> Result<()> } fn set_children_set(entries: Vec) -> 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/performance.rs b/render-wasm/src/performance.rs index c27cd0efa7..e59a101908 100644 --- a/render-wasm/src/performance.rs +++ b/render-wasm/src/performance.rs @@ -1,3 +1,5 @@ +use std::sync::atomic::{AtomicBool, AtomicI32, AtomicUsize, Ordering}; + #[cfg(target_arch = "wasm32")] pub fn get_time() -> i32 { crate::get_now!() as i32 @@ -9,6 +11,62 @@ pub fn get_time() -> i32 { now.elapsed().as_millis() as i32 } +/// Always-on page-load / page-switch tracing (temporary profiling). +/// Prints to stdout (browser console under Emscripten). No feature gates. +/// Active only between `page_trace_reset` and first tiles-complete to avoid +/// flooding the console on pan/zoom. +static PAGE_TRACE_ACTIVE: AtomicBool = AtomicBool::new(false); +static PAGE_TRACE_EPOCH: AtomicI32 = AtomicI32::new(0); +static PAGE_TRACE_LAST: AtomicI32 = AtomicI32::new(0); +static PAGE_TRACE_USE_SHAPE: AtomicUsize = AtomicUsize::new(0); + +pub fn page_trace_active() -> bool { + PAGE_TRACE_ACTIVE.load(Ordering::Relaxed) +} + +/// Start a new page-load/switch session. Resets epoch and counters. +pub fn page_trace_reset(label: &str) { + let now = get_time(); + PAGE_TRACE_ACTIVE.store(true, Ordering::Relaxed); + PAGE_TRACE_EPOCH.store(now, Ordering::Relaxed); + PAGE_TRACE_LAST.store(now, Ordering::Relaxed); + PAGE_TRACE_USE_SHAPE.store(0, Ordering::Relaxed); + println!("[wasm-page] === {label} === t={now}ms"); +} + +/// Log a milestone with delta since previous mark and since session start. +pub fn page_trace(label: &str) { + if !PAGE_TRACE_ACTIVE.load(Ordering::Relaxed) { + return; + } + let now = get_time(); + let epoch = PAGE_TRACE_EPOCH.load(Ordering::Relaxed); + let last = PAGE_TRACE_LAST.load(Ordering::Relaxed); + let since_epoch = if epoch == 0 { 0 } else { now - epoch }; + let since_last = if last == 0 { 0 } else { now - last }; + PAGE_TRACE_LAST.store(now, Ordering::Relaxed); + println!("[wasm-page] {label} +{since_last}ms (session +{since_epoch}ms) t={now}ms"); +} + +/// Count a `use_shape` during the active page-load session. +pub fn page_trace_use_shape() { + if PAGE_TRACE_ACTIVE.load(Ordering::Relaxed) { + PAGE_TRACE_USE_SHAPE.fetch_add(1, Ordering::Relaxed); + } +} + +/// Log end of bulk loading with shape count. +pub fn page_trace_end_loading() { + let n = PAGE_TRACE_USE_SHAPE.load(Ordering::Relaxed); + page_trace(&format!("end_loading shapes={n}")); +} + +/// Final milestone of the page session (first tiles-complete), then silence. +pub fn page_trace_done(label: &str) { + page_trace(label); + PAGE_TRACE_ACTIVE.store(false, Ordering::Relaxed); +} + /// Log a message to the browser console (only when profile-macros feature is enabled) #[macro_export] macro_rules! console_log { diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 985a2ca57a..6d41c85439 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -2332,6 +2332,7 @@ impl RenderState { timestamp: i32, sync_render: bool, ) -> Result { + performance::page_trace("start_render_loop:begin"); self.clear(tree); let _start = performance::begin_timed_log!("start_render_loop"); @@ -2536,6 +2537,7 @@ impl RenderState { // HiDPI viewports and is not needed until the next drag. self.present_frame(tree); self.viewport_presented = true; + performance::page_trace_done("render:ViewportReady (tiles-complete)"); wapi::notify_tiles_render_complete!(); Self::drain_partial_gpu_soft(); } @@ -2547,11 +2549,13 @@ impl RenderState { self.rebuild_backbuffer_crop_cache(tree); } self.present_frame(tree); + performance::page_trace_done("render:Full (tiles-complete)"); wapi::notify_tiles_render_complete!(); } else if !self.options.is_fast_mode() && !self.options.is_interactive_transform() { // Interest fill finished after ViewportReady. Backbuffer // still holds the viewport compose; rebuild crop cache // off the sharp-snap frame. + performance::page_trace("render:Full (interest fill done)"); self.rebuild_backbuffer_crop_cache(tree); } performance::end_measure!("render"); @@ -4277,6 +4281,7 @@ impl RenderState { /// until the post-gesture full render replaces them. pub fn rebuild_tile_index(&mut self, tree: ShapesPoolRef) { let zoom_changed = self.zoom_changed(); + performance::page_trace("rebuild_tile_index:begin"); performance::begin_measure!("rebuild_tile_index"); let mut nodes = Vec::::with_capacity(64); nodes.push(Uuid::nil()); @@ -4297,6 +4302,7 @@ impl RenderState { } } performance::end_measure!("rebuild_tile_index"); + performance::page_trace("rebuild_tile_index:end"); } pub fn rebuild_tiles_shallow(&mut self, tree: ShapesPoolRef) { diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 8e9be844d7..ea0c4dde07 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -340,6 +340,36 @@ impl State { self.shapes.set_modifiers(modifiers); } + /// Replace the current shape's children list (same semantics as `_set_children`). + pub fn set_current_shape_children(&mut self, entries: Vec) -> Result<()> { + let mut deleted = Vec::new(); + let parent_id = { + let Some(shape) = self.current_shape_mut() else { + return Err(Error::RecoverableError( + "set_current_shape_children: no current shape".to_string(), + )); + }; + let id = shape.id; + (_, deleted) = shape.compute_children_differences(&entries); + shape.children = entries.clone(); + id + }; + + for id in &entries { + self.touch_shape(*id); + if let Some(children_shape) = self.shapes.get_mut(id) { + children_shape.set_deleted(false); + } + } + + for id in deleted { + self.delete_shape_children(parent_id, id); + self.touch_shape(id); + } + + Ok(()) + } + pub fn touch_current(&mut self) { // `mark_touched` only drives incremental on-screen tile invalidation; // the headless export path has no render state, so skip it there. diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index f57bbcb51b..e9171c7d95 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -82,12 +82,15 @@ impl ShapesPoolImpl { } pub fn initialize(&mut self, capacity: usize) { + performance::page_trace("shapes_pool_initialize:begin"); performance::begin_measure!("shapes_pool_initialize"); self.counter = 0; self.uuid_to_idx = HashMap::with_capacity(capacity); let additional = capacity as i32 - self.shapes.len() as i32; if additional <= 0 { + performance::end_measure!("shapes_pool_initialize"); + performance::page_trace("shapes_pool_initialize:done (reuse)"); return; } @@ -99,6 +102,7 @@ impl ShapesPoolImpl { self.shapes .extend(iter::repeat_with(|| Shape::new(Uuid::nil())).take(additional as usize)); performance::end_measure!("shapes_pool_initialize"); + performance::page_trace("shapes_pool_initialize:done"); } pub fn add_shape(&mut self, id: Uuid) -> &mut Shape { diff --git a/render-wasm/src/wasm/fills.rs b/render-wasm/src/wasm/fills.rs index c0a8d6850d..2926990c9f 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 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..46a428a411 100644 --- a/render-wasm/src/wasm/layouts.rs +++ b/render-wasm/src/wasm/layouts.rs @@ -4,9 +4,14 @@ use macros::ToJs; mod align; pub mod constraints; -mod flex; +pub mod flex; mod grid; +pub use align::{ + RawAlignContent, RawAlignItems, RawAlignSelf, RawJustifyContent, RawJustifyItems, +}; +pub use flex::{RawFlexDirection, RawWrapType}; + #[derive(Debug, Clone, PartialEq, Copy, ToJs)] #[repr(u8)] #[allow(dead_code)] diff --git a/render-wasm/src/wasm/shapes/base_props.rs b/render-wasm/src/wasm/shapes/base_props.rs index ab204a1f44..0f666e1004 100644 --- a/render-wasm/src/wasm/shapes/base_props.rs +++ b/render-wasm/src/wasm/shapes/base_props.rs @@ -12,10 +12,10 @@ use macros::wasm_error; use super::RawShapeType; -const FLAG_CLIP_CONTENT: u8 = 0b0000_0001; -const FLAG_HIDDEN: u8 = 0b0000_0010; +pub const FLAG_CLIP_CONTENT: u8 = 0b0000_0001; +pub const FLAG_HIDDEN: u8 = 0b0000_0010; -const RAW_BASE_PROPS_SIZE: usize = std::mem::size_of::(); +pub const RAW_BASE_PROPS_SIZE: usize = std::mem::size_of::(); /// Binary layout for batched shape base properties. /// @@ -26,70 +26,70 @@ const RAW_BASE_PROPS_SIZE: usize = std::mem::size_of::(); #[derive(Debug, Clone, Copy)] pub struct RawBasePropsData { // UUID id (16 bytes) - id_a: u32, - id_b: u32, - id_c: u32, - id_d: u32, + pub id_a: u32, + pub id_b: u32, + pub id_c: u32, + pub id_d: u32, // UUID parent_id (16 bytes) - parent_a: u32, - parent_b: u32, - parent_c: u32, - parent_d: u32, + pub parent_a: u32, + pub parent_b: u32, + pub parent_c: u32, + pub parent_d: u32, // Single-byte fields - shape_type: u8, - flags: u8, - blend_mode: u8, - constraint_h: u8, - constraint_v: u8, - padding: [u8; 3], + pub shape_type: u8, + pub flags: u8, + pub blend_mode: u8, + pub constraint_h: u8, + pub constraint_v: u8, + pub padding: [u8; 3], // f32 fields - opacity: f32, - rotation: f32, + pub opacity: f32, + pub rotation: f32, // Transform matrix (a, b, c, d, e, f) - transform_a: f32, - transform_b: f32, - transform_c: f32, - transform_d: f32, - transform_e: f32, - transform_f: f32, + pub transform_a: f32, + pub transform_b: f32, + pub transform_c: f32, + pub transform_d: f32, + pub transform_e: f32, + pub transform_f: f32, // Selrect (x1, y1, x2, y2) - selrect_x1: f32, - selrect_y1: f32, - selrect_x2: f32, - selrect_y2: f32, + pub selrect_x1: f32, + pub selrect_y1: f32, + pub selrect_x2: f32, + pub selrect_y2: f32, // Corners (r1, r2, r3, r4) - corner_r1: f32, - corner_r2: f32, - corner_r3: f32, - corner_r4: f32, + pub corner_r1: f32, + pub corner_r2: f32, + pub corner_r3: f32, + pub corner_r4: f32, } impl RawBasePropsData { - fn id(&self) -> Uuid { + pub fn id(&self) -> Uuid { uuid_from_u32_quartet(self.id_a, self.id_b, self.id_c, self.id_d) } - fn parent_id(&self) -> Uuid { + pub fn parent_id(&self) -> Uuid { uuid_from_u32_quartet(self.parent_a, self.parent_b, self.parent_c, self.parent_d) } - fn clip_content(&self) -> bool { + pub fn clip_content(&self) -> bool { (self.flags & FLAG_CLIP_CONTENT) != 0 } - fn hidden(&self) -> bool { + pub fn hidden(&self) -> bool { (self.flags & FLAG_HIDDEN) != 0 } - fn blend_mode(&self) -> BlendMode { + pub fn blend_mode(&self) -> BlendMode { RawBlendMode::from(self.blend_mode).into() } - fn constraint_h(&self) -> Option { + pub fn constraint_h(&self) -> Option { RawConstraintH::from(self.constraint_h).into() } - fn constraint_v(&self) -> Option { + pub fn constraint_v(&self) -> Option { RawConstraintV::from(self.constraint_v).into() } } @@ -100,21 +100,8 @@ impl From<[u8; RAW_BASE_PROPS_SIZE]> for RawBasePropsData { } } -#[no_mangle] -#[wasm_error] -pub extern "C" fn set_shape_base_props() -> Result<()> { - let bytes = mem::bytes(); - - if bytes.len() < RAW_BASE_PROPS_SIZE { - return Ok(()); - } - - // FIXME: this should just be a try_from - let data: [u8; RAW_BASE_PROPS_SIZE] = bytes[..RAW_BASE_PROPS_SIZE] - .try_into() - .map_err(|_| Error::CriticalError("Invalid bytes for base props".to_string()))?; - let raw = RawBasePropsData::from(data); - +/// Apply base props from a parsed record (selects the shape and sets core attrs). +pub fn apply_base_props(raw: &RawBasePropsData) -> Result<()> { let id = raw.id(); let parent_id = raw.parent_id(); let shape_type = RawShapeType::from(raw.shape_type); @@ -153,6 +140,24 @@ pub extern "C" fn set_shape_base_props() -> Result<()> { Ok(()) } +#[no_mangle] +#[wasm_error] +pub extern "C" fn set_shape_base_props() -> Result<()> { + let bytes = mem::bytes(); + + if bytes.len() < RAW_BASE_PROPS_SIZE { + return Ok(()); + } + + // FIXME: this should just be a try_from + let data: [u8; RAW_BASE_PROPS_SIZE] = bytes[..RAW_BASE_PROPS_SIZE] + .try_into() + .map_err(|_| Error::CriticalError("Invalid bytes for base props".to_string()))?; + let raw = RawBasePropsData::from(data); + apply_base_props(&raw)?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/render-wasm/src/wasm/shapes/mod.rs b/render-wasm/src/wasm/shapes/mod.rs index 901feb57f8..3e8f50afa5 100644 --- a/render-wasm/src/wasm/shapes/mod.rs +++ b/render-wasm/src/wasm/shapes/mod.rs @@ -1,8 +1,9 @@ mod base_props; +mod upload_batch; use macros::ToJs; -use crate::shapes::{Bool, Frame, Group, Path, Rect, SVGRaw, TextContent, Type}; +use crate::shapes::{Bool, Frame, Group, Path, Rect, Shape, SVGRaw, TextContent, Type}; use crate::with_current_shape_mut; #[derive(Debug, Clone, PartialEq, ToJs)] 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..e1cfaa29d3 --- /dev/null +++ b/render-wasm/src/wasm/shapes/upload_batch.rs @@ -0,0 +1,480 @@ +//! Multi-shape / enlarged cold-upload batch protocol. +//! +//! Buffer layout: +//! ```text +//! [u32 shape_count] +//! repeat shape_count times: +//! [u32 payload_len] // bytes after this u32 +//! [104 base props] // RawBasePropsData +//! [u32 section_mask] +//! optional sections (presence via mask; order is FIXED, not bit-numeric): +//! CHILDREN: [u32 n][n × 16 uuid] +//! BLUR_LAYER: [u8 hidden][u8;3 pad][f32 value] +//! BLUR_BG: same +//! SHADOWS: [u32 n][n × 24] +//! MASKED: [u8 value][u8;3 pad] +//! BOOL_TYPE: [u8 value][u8;3 pad] +//! GROW_TYPE: [u8 value][u8;3 pad] +//! FLEX: 32 bytes (clears container layout, then sets flex) +//! LAYOUT_ITEM: 40 bytes (must follow FLEX so clear_layout does not wipe it) +//! FILLS: [u8 n][u8;3][n × RawFillData] (same as set_shape_fills) +//! STROKES: [u32 n][n × (36-byte header + RawFillData)] +//! ``` +//! +//! Text, path geometry, and grid tracks/cells stay on the legacy +//! per-shape FFI path after the batch flush. + +use skia_safe as skia; + +use crate::mem; +use crate::performance; +use crate::shapes::{ + Blur, BlurType, BoolType, GrowType, Shadow, ShadowStyle, Shape, Stroke, Type, +}; +use crate::utils::uuid_from_u32_quartet; +use crate::uuid::Uuid; +use crate::wasm::fills::{read_fills_from_bytes, RawFillData, RAW_FILL_DATA_SIZE}; +use crate::wasm::layouts::{ + RawAlignContent, RawAlignItems, RawAlignSelf, RawFlexDirection, RawJustifyContent, + RawJustifyItems, RawSizing, RawWrapType, +}; +use crate::wasm::shadows::RawShadowStyle; +use crate::wasm::shapes::base_props::{apply_base_props, RawBasePropsData, RAW_BASE_PROPS_SIZE}; +use crate::wasm::strokes::{RawStrokeCap, RawStrokeStyle}; +use crate::with_current_shape_mut; +use crate::with_state; + +#[allow(unused_imports)] +use crate::error::{Error, Result}; +use macros::wasm_error; + +pub const SECTION_CHILDREN: u32 = 1 << 0; +pub const SECTION_BLUR_LAYER: u32 = 1 << 1; +pub const SECTION_BLUR_BG: u32 = 1 << 2; +pub const SECTION_SHADOWS: u32 = 1 << 3; +pub const SECTION_MASKED: u32 = 1 << 4; +pub const SECTION_BOOL_TYPE: u32 = 1 << 5; +pub const SECTION_GROW_TYPE: u32 = 1 << 6; +pub const SECTION_LAYOUT_ITEM: u32 = 1 << 7; +pub const SECTION_FLEX: u32 = 1 << 8; +pub const SECTION_FILLS: u32 = 1 << 9; +pub const SECTION_STROKES: u32 = 1 << 10; + +const STROKE_ALIGN_CENTER: u8 = 0; +const STROKE_ALIGN_INNER: u8 = 1; +const STROKE_ALIGN_OUTER: u8 = 2; + +fn decode_optional_f32(value: f32) -> Option { + if value.is_finite() && value >= 0.0 { + Some(value) + } else { + None + } +} + +struct Cursor<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Cursor<'a> { + fn new(data: &'a [u8]) -> Self { + Self { data, pos: 0 } + } + + fn remaining(&self) -> usize { + self.data.len().saturating_sub(self.pos) + } + + fn take(&mut self, n: usize) -> Result<&'a [u8]> { + if self.remaining() < n { + return Err(Error::CriticalError( + "upload_batch: truncated buffer".to_string(), + )); + } + let slice = &self.data[self.pos..self.pos + n]; + self.pos += n; + Ok(slice) + } + + fn u32(&mut self) -> Result { + 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)?; + let bool_type = match raw_bool { + 0 => BoolType::Union, + 1 => BoolType::Difference, + 2 => BoolType::Intersection, + _ => BoolType::Exclusion, + }; + with_current_shape_mut!(state, |shape: &mut Shape| { + shape.set_bool_type(bool_type); + }); + } + + if mask & SECTION_GROW_TYPE != 0 { + let raw_grow = cur.u8()?; + let _ = cur.take(3)?; + let grow_type = match raw_grow { + 1 => GrowType::AutoWidth, + 2 => GrowType::AutoHeight, + _ => GrowType::Fixed, + }; + with_current_shape_mut!(state, |shape: &mut Shape| { + if let Type::Text(text_content) = &mut shape.shape_type { + text_content.set_grow_type(grow_type); + } + }); + } + + // FLEX before LAYOUT_ITEM: clear_layout must not wipe the item we just set. + // Only clear when this payload owns layout (workspace cold-load). Exporter / + // serialize-shape! omit both bits and must not clobber existing layout. + if mask & SECTION_FLEX != 0 { + apply_flex(&mut cur)?; + } else if mask & SECTION_LAYOUT_ITEM != 0 { + with_current_shape_mut!(state, |shape: &mut Shape| { + shape.clear_layout(); + }); + } + + if mask & SECTION_LAYOUT_ITEM != 0 { + apply_layout_item(&mut cur)?; + } + + if mask & SECTION_FILLS != 0 { + apply_fills(&mut cur)?; + } + + if mask & SECTION_STROKES != 0 { + apply_strokes(&mut cur)?; + } + + Ok(()) +} + +fn apply_fills(cur: &mut Cursor<'_>) -> Result<()> { + let header = cur.take(4)?; + let n = header[0] as usize; + let bytes = if n == 0 { + &[][..] + } else { + cur.take(n * RAW_FILL_DATA_SIZE)? + }; + let fills = read_fills_from_bytes(bytes, n); + with_current_shape_mut!(state, |shape: &mut Shape| { + shape.set_fills(fills); + }); + Ok(()) +} + +fn apply_strokes(cur: &mut Cursor<'_>) -> Result<()> { + with_current_shape_mut!(state, |shape: &mut Shape| { + shape.clear_strokes(); + }); + + let n = cur.u32()? as usize; + for _ in 0..n { + let width = cur.f32()?; + let style = cur.u8()?; + let align = cur.u8()?; + let cap_start = cur.u8()?; + let cap_end = cur.u8()?; + let dash = cur.f32()?; + let gap = cur.f32()?; + let has_sides = cur.u8()? != 0; + let _pad = cur.take(3)?; + let top = cur.f32()?; + let right = cur.f32()?; + let bottom = cur.f32()?; + let left = cur.f32()?; + let fill_bytes = cur.take(RAW_FILL_DATA_SIZE)?; + let fill = RawFillData::try_from(fill_bytes) + .map_err(|e| Error::CriticalError(format!("upload_batch stroke fill: {e}")))?; + + let stroke_style = RawStrokeStyle::from(style); + let cap_start = RawStrokeCap::from(cap_start); + let cap_end = RawStrokeCap::from(cap_end); + let dash = decode_optional_f32(dash); + let gap = decode_optional_f32(gap); + + with_current_shape_mut!(state, |shape: &mut Shape| { + let stroke = match align { + STROKE_ALIGN_INNER => Stroke::new_inner_stroke( + width, + stroke_style.into(), + cap_start.try_into().ok(), + cap_end.try_into().ok(), + dash, + gap, + ), + STROKE_ALIGN_OUTER => Stroke::new_outer_stroke( + width, + stroke_style.into(), + cap_start.try_into().ok(), + cap_end.try_into().ok(), + dash, + gap, + ), + STROKE_ALIGN_CENTER | _ => Stroke::new_center_stroke( + width, + stroke_style.into(), + cap_start.try_into().ok(), + cap_end.try_into().ok(), + dash, + gap, + ), + }; + shape.add_stroke(stroke); + if has_sides { + let _ = shape.set_last_stroke_widths([top, right, bottom, left]); + } + let _ = shape.set_stroke_fill(fill.into()); + }); + } + Ok(()) +} + +/// Apply a multi-shape upload buffer previously written via `_alloc_bytes`. +#[no_mangle] +#[wasm_error] +pub extern "C" fn set_shapes_batch() -> Result<()> { + let bytes = mem::bytes(); + if bytes.len() < 4 { + return Ok(()); + } + + let mut cur = Cursor::new(&bytes); + let count = cur.u32()? as usize; + performance::page_trace(&format!("set_shapes_batch:begin count={count}")); + + for _ in 0..count { + let payload_len = cur.u32()? as usize; + let payload = cur.take(payload_len)?; + apply_shape_payload(payload)?; + performance::page_trace_use_shape(); + } + + performance::page_trace(&format!("set_shapes_batch:end count={count}")); + Ok(()) +}