diff --git a/CHANGES.md b/CHANGES.md index 86fd60f1c7..df712f7212 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -65,6 +65,11 @@ - Fix unhandled exception tokens creation dialog [Github #8110](https://github.com/penpot/penpot/issues/8110) - Fix allow negative spread values on shadow token creation [Taiga #13167](https://tree.taiga.io/project/penpot/issue/13167) - Fix spanish translations on import export token modal [Taiga #13171](https://tree.taiga.io/project/penpot/issue/13171) +- Remove whitespaces from asset export filename [Github #8133](https://github.com/penpot/penpot/pull/8133) +- Fix exception on uploading large fonts [Github #8135](https://github.com/penpot/penpot/pull/8135) +- Fix unhandled exception on open-new-window helper [Github #7787](https://github.com/penpot/penpot/issues/7787) +- Fix incorrect handling of input values on layout gap and padding inputs [Github #8113](https://github.com/penpot/penpot/issues/8113) + ## 2.12.1 diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 44495c0a55..b5c7923698 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -135,6 +135,8 @@ :subscriptions :subscriptions-old :inspect-styles + ;; Enable performance logs in devconsole (disabled by default) + :perf-logs ;; Security layer middleware that filters request by fetch ;; metadata headers diff --git a/frontend/src/app/main/data/event.cljs b/frontend/src/app/main/data/event.cljs index 41daafdda6..7ee1d63225 100644 --- a/frontend/src/app/main/data/event.cljs +++ b/frontend/src/app/main/data/event.cljs @@ -476,23 +476,24 @@ (when (and (some? (.-PerformanceObserver js/window)) (nil? @longtask-observer*)) (let [observer (js/PerformanceObserver. (fn [list _] - (doseq [entry (.getEntries list)] - (let [dur (.-duration entry) - start (.-startTime entry) - attrib (.-attribution entry) - attrib-count (when attrib (.-length attrib)) - first-attrib (when (and attrib-count (> attrib-count 0)) (aget attrib 0)) - attrib-name (when first-attrib (.-name first-attrib)) - attrib-ctype (when first-attrib (.-containerType first-attrib)) - attrib-cid (when first-attrib (.-containerId first-attrib)) - attrib-csrc (when first-attrib (.-containerSrc first-attrib))] + (when (contains? cf/flags :perf-logs) + (doseq [entry (.getEntries list)] + (let [dur (.-duration entry) + start (.-startTime entry) + attrib (.-attribution entry) + attrib-count (when attrib (.-length attrib)) + first-attrib (when (and attrib-count (> attrib-count 0)) (aget attrib 0)) + attrib-name (when first-attrib (.-name first-attrib)) + attrib-ctype (when first-attrib (.-containerType first-attrib)) + attrib-cid (when first-attrib (.-containerId first-attrib)) + attrib-csrc (when first-attrib (.-containerSrc first-attrib))] - (.warn js/console (str "[perf] long task " (Math/round dur) "ms at " (Math/round start) "ms" - (when first-attrib - (str " attrib:name=" attrib-name - " ctype=" attrib-ctype - " cid=" attrib-cid - " csrc=" attrib-csrc))))))))] + (.warn js/console (str "[perf] long task " (Math/round dur) "ms at " (Math/round start) "ms" + (when first-attrib + (str " attrib:name=" attrib-name + " ctype=" attrib-ctype + " cid=" attrib-cid + " csrc=" attrib-csrc)))))))))] (.observe observer #js{:entryTypes #js["longtask"]}) (reset! longtask-observer* observer)))) @@ -505,28 +506,30 @@ (let [last (atom (.now js/performance)) id (js/setInterval (fn [] - (let [now (.now js/performance) - expected (+ @last interval-ms) - drift (- now expected) - current-op @current-op* - measures (.getEntriesByType js/performance "measure") - mlen (.-length measures) - last-measure (when (> mlen 0) (aget measures (dec mlen))) - meas-name (when last-measure (.-name last-measure)) - meas-detail (when last-measure (.-detail last-measure)) - meas-count (when meas-detail (unchecked-get meas-detail "count"))] - (reset! last now) - (when (> drift threshold-ms) - (.warn js/console - (str "[perf] event loop stall: " (Math/round drift) "ms" - (when current-op (str " op=" current-op)) - (when meas-name (str " last=" meas-name)) - (when meas-count (str " count=" meas-count))))))) + (when (contains? cf/flags :perf-logs) + (let [now (.now js/performance) + expected (+ @last interval-ms) + drift (- now expected) + current-op @current-op* + measures (.getEntriesByType js/performance "measure") + mlen (.-length measures) + last-measure (when (> mlen 0) (aget measures (dec mlen))) + meas-name (when last-measure (.-name last-measure)) + meas-detail (when last-measure (.-detail last-measure)) + meas-count (when meas-detail (unchecked-get meas-detail "count"))] + (reset! last now) + (when (> drift threshold-ms) + (.warn js/console + (str "[perf] event loop stall: " (Math/round drift) "ms" + (when current-op (str " op=" current-op)) + (when meas-name (str " last=" meas-name)) + (when meas-count (str " count=" meas-count)))))))) interval-ms)] (reset! stall-timer* id)))) (defn init! - "Install perf observers in dev builds. Safe to call multiple times." + "Install perf observers in dev builds. Safe to call multiple times. + Perf logs are disabled by default. Enable them with the :perf-logs flag in config." [] (when ^boolean js/goog.DEBUG (install-long-task-observer!) diff --git a/frontend/src/app/main/ui/dashboard/team.scss b/frontend/src/app/main/ui/dashboard/team.scss index 90e33f5cca..259fdeb565 100644 --- a/frontend/src/app/main/ui/dashboard/team.scss +++ b/frontend/src/app/main/ui/dashboard/team.scss @@ -628,6 +628,7 @@ width: $sz-400; padding: var(--sp-xxxl); background-color: var(--color-background-primary); + z-index: var(--z-index-set); &.hero { top: px2rem(216); diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs index ac44b9720b..909ba2eca7 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs @@ -151,7 +151,6 @@ (st/emit! (dw/set-clipboard-style style))))] (.addEventListener ^js global/document "keyup" on-key-up) - (.addEventListener ^js instance "blur" on-blur) (.addEventListener ^js instance "focus" on-focus) (.addEventListener ^js instance "needslayout" on-needs-layout) (.addEventListener ^js instance "stylechange" on-style-change) @@ -166,8 +165,12 @@ ;; This function is called when the component is unmounted (fn [] + ;; Explicitly call on-blur here instead of relying on browser blur events, + ;; because in Firefox blur is not reliably fired when leaving the text editor + ;; by clicking elsewhere. The component does unmount when the shape is + ;; deselected, so we can safely call the blur handler here to finalize the editor. + (on-blur) (.removeEventListener ^js global/document "keyup" on-key-up) - (.removeEventListener ^js instance "blur" on-blur) (.removeEventListener ^js instance "focus" on-focus) (.removeEventListener ^js instance "needslayout" on-needs-layout) (.removeEventListener ^js instance "stylechange" on-style-change) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 2aa0dd4ff1..5d1ddbd731 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -28,6 +28,7 @@ [app.main.ui.shapes.text] [app.main.worker :as mw] [app.render-wasm.api.fonts :as f] + [app.render-wasm.api.shapes :as shapes] [app.render-wasm.api.texts :as t] [app.render-wasm.api.webgl :as webgl] [app.render-wasm.deserializers :as dr] @@ -68,12 +69,25 @@ (def ^:const DEBOUNCE_DELAY_MS 100) (def ^:const THROTTLE_DELAY_MS 10) +;; Number of shapes to process before yielding to browser +(def ^:const SHAPES_CHUNK_SIZE 100) +;; Threshold below which we use synchronous processing (no chunking overhead) +(def ^:const ASYNC_THRESHOLD 100) + (def dpr (if use-dpr? (if (exists? js/window) js/window.devicePixelRatio 1.0) 1.0)) (def noop-fn (constantly nil)) +(defn- yield-to-browser + "Returns a promise that resolves after yielding to the browser's event loop. + Uses requestAnimationFrame for smooth visual updates during loading." + [] + (p/create + (fn [resolve _reject] + (js/requestAnimationFrame (fn [_] (resolve nil)))))) + ;; Based on app.main.render/object-svg (mf/defc object-svg {::mf/props :obj} @@ -120,17 +134,56 @@ (aget buffer 3)) (set! wasm/internal-frame-id nil)))) +(defn render-preview! + "Render a lightweight preview without tile caching. + Used during progressive loading for fast feedback." + [] + (when (and wasm/context-initialized? (not @wasm/context-lost?)) + (h/call wasm/internal-module "_render_preview"))) + (defonce pending-render (atom false)) +(defonce shapes-loading? (atom false)) +(defonce deferred-render? (atom false)) + +(defn- register-deferred-render! + [] + (reset! deferred-render? true)) (defn request-render [_requester] - (when (and wasm/context-initialized? (not @pending-render) (not @wasm/context-lost?)) - (reset! pending-render true) - (js/requestAnimationFrame - (fn [ts] - (reset! pending-render false) - (render ts))))) + (when (and wasm/context-initialized? (not @wasm/context-lost?)) + (if @shapes-loading? + (register-deferred-render!) + (when-not @pending-render + (reset! pending-render true) + (let [frame-id + (js/requestAnimationFrame + (fn [ts] + (reset! pending-render false) + (set! wasm/internal-frame-id nil) + (render ts)))] + (set! wasm/internal-frame-id frame-id)))))) + +(defn- begin-shapes-loading! + [] + (reset! shapes-loading? true) + (let [frame-id wasm/internal-frame-id + was-pending @pending-render] + (when frame-id + (js/cancelAnimationFrame frame-id) + (set! wasm/internal-frame-id nil)) + (reset! pending-render false) + (reset! deferred-render? was-pending))) + +(defn- end-shapes-loading! + [] + (let [was-loading (compare-and-set! shapes-loading? true false)] + (reset! deferred-render? false) + ;; Always trigger a render after loading completes + ;; This ensures shapes are displayed even if no deferred render was requested + (when was-loading + (request-render "set-objects:flush")))) (declare get-text-dimensions) @@ -895,24 +948,12 @@ id (dm/get-prop shape :id) type (dm/get-prop shape :type) - parent-id (get shape :parent-id) masked (get shape :masked-group) - selrect (get shape :selrect) - constraint-h (get shape :constraints-h) - constraint-v (get shape :constraints-v) - clip-content (if (= type :frame) - (not (get shape :show-content)) - false) - rotation (get shape :rotation) - transform (get shape :transform) fills (get shape :fills) strokes (if (= type :group) [] (get shape :strokes)) children (get shape :shapes) - blend-mode (get shape :blend-mode) - opacity (get shape :opacity) - hidden (get shape :hidden) content (let [content (get shape :content)] (if (= type :text) (ensure-text-content content) @@ -921,22 +962,12 @@ grow-type (get shape :grow-type) blur (get shape :blur) svg-attrs (get shape :svg-attrs) - shadows (get shape :shadow) - corners (map #(get shape %) [:r1 :r2 :r3 :r4])] + shadows (get shape :shadow)] - (use-shape id) - (set-parent-id parent-id) - (set-shape-type type) - (set-shape-clip-content clip-content) - (set-shape-constraints constraint-h constraint-v) + (shapes/set-shape-base-props shape) - (set-shape-rotation rotation) - (set-shape-transform transform) - (set-shape-blend-mode blend-mode) - (set-shape-opacity opacity) - (set-shape-hidden hidden) + ;; Remaining properties that need separate calls (variable-length or conditional) (set-shape-children children) - (set-shape-corners corners) (set-shape-blur blur) (when (= type :group) (set-masked (boolean masked))) @@ -956,7 +987,6 @@ (set-shape-layout shape) (set-layout-data shape) - (set-shape-selrect selrect) (let [pending_thumbnails (into [] (concat (set-shape-text-content id content) @@ -1012,30 +1042,143 @@ (let [{:keys [thumbnails full]} (set-object shape)] (process-pending [shape] thumbnails full noop-fn))) +(defn- process-shapes-chunk + "Process a chunk of shapes synchronously, returning accumulated pending operations. + Returns {:thumbnails [...] :full [...] :next-index n}" + [shapes start-index chunk-size thumbnails-acc full-acc] + (let [total (count shapes) + end-index (min total (+ start-index chunk-size))] + (loop [index start-index + t-acc thumbnails-acc + f-acc full-acc] + (if (< index end-index) + (let [shape (nth shapes index) + {:keys [thumbnails full]} (set-object shape)] + (recur (inc index) + (into t-acc thumbnails) + (into f-acc full))) + {:thumbnails t-acc + :full f-acc + :next-index end-index})))) + +(defn- set-objects-async + "Asynchronously process shapes in chunks, yielding to the browser between chunks. + Returns a promise that resolves when all shapes are processed. + + Renders a preview only periodically during loading to show progress, + then does a full tile-based render at the end." + [shapes render-callback] + (let [total-shapes (count shapes) + total-chunks (mth/ceil (/ total-shapes SHAPES_CHUNK_SIZE)) + ;; Render at 25%, 50%, 75% of loading + render-at-chunks (set [(mth/floor (* total-chunks 0.25)) + (mth/floor (* total-chunks 0.5)) + (mth/floor (* total-chunks 0.75))])] + (p/create + (fn [resolve _reject] + (letfn [(process-next-chunk [index thumbnails-acc full-acc chunk-count] + (if (< index total-shapes) + ;; Process one chunk + (let [{:keys [thumbnails full next-index]} + (process-shapes-chunk shapes index SHAPES_CHUNK_SIZE + thumbnails-acc full-acc) + new-chunk-count (inc chunk-count)] + ;; Only render at specific progress milestones + (when (contains? render-at-chunks new-chunk-count) + (render-preview!)) + + ;; Yield to browser, then continue with next chunk + (-> (yield-to-browser) + (p/then (fn [_] + (process-next-chunk next-index thumbnails full new-chunk-count))))) + ;; All chunks done - finalize + (do + (perf/end-measure "set-objects") + (process-pending shapes thumbnails-acc full-acc noop-fn + (fn [] + (end-shapes-loading!) + (if render-callback + (render-callback) + (render-finish)) + (ug/dispatch! (ug/event "penpot:wasm:set-objects")) + (resolve nil))))))] + (process-next-chunk 0 [] [] 0)))))) + +(defn- set-objects-sync + "Synchronously process all shapes (for small shape counts)." + [shapes render-callback] + (let [total-shapes (count shapes) + {:keys [thumbnails full]} + (loop [index 0 thumbnails-acc [] full-acc []] + (if (< index total-shapes) + (let [shape (nth shapes index) + {:keys [thumbnails full]} (set-object shape)] + (recur (inc index) + (into thumbnails-acc thumbnails) + (into full-acc full))) + {:thumbnails thumbnails-acc :full full-acc}))] + (perf/end-measure "set-objects") + (process-pending shapes thumbnails full noop-fn + (fn [] + (if render-callback + (render-callback) + (render-finish)) + (ug/dispatch! (ug/event "penpot:wasm:set-objects")))))) + +(defn- shapes-in-tree-order + "Returns shapes sorted in tree order (parents before children). + This ensures parent shapes are processed before their children, + maintaining proper shape reference consistency in WASM." + [objects] + ;; Get IDs in tree order starting from root (uuid/zero) + ;; If root doesn't exist (e.g., filtered thumbnail data), fall back to + ;; finding top-level shapes (those without a parent in objects) and + ;; traversing from there. + (if (contains? objects uuid/zero) + ;; Normal case: traverse from root + (let [ordered-ids (cfh/get-children-ids-with-self objects uuid/zero)] + (into [] + (keep #(get objects %)) + ordered-ids)) + ;; Fallback for filtered data (thumbnails): find top-level shapes and traverse + (let [;; Find shapes whose parent is not in the objects map (top-level in this subset) + top-level-ids (->> (vals objects) + (filter (fn [shape] + (not (contains? objects (:parent-id shape))))) + (map :id)) + ;; Get all children in order for each top-level shape + all-ordered-ids (into [] + (mapcat #(cfh/get-children-ids-with-self objects %)) + top-level-ids)] + (into [] + (keep #(get objects %)) + all-ordered-ids)))) + (defn set-objects + "Set all shape objects for rendering. + + Shapes are processed in tree order (parents before children) + to maintain proper shape reference consistency in WASM." ([objects] (set-objects objects nil)) ([objects render-callback] (perf/begin-measure "set-objects") - (let [shapes (into [] (vals objects)) - total-shapes (count shapes) - ;; Collect pending operations - set-object returns {:thumbnails [...] :full [...]} - {:keys [thumbnails full]} - (loop [index 0 thumbnails-acc [] full-acc []] - (if (< index total-shapes) - (let [shape (nth shapes index) - {:keys [thumbnails full]} (set-object shape)] - (recur (inc index) - (into thumbnails-acc thumbnails) - (into full-acc full))) - {:thumbnails thumbnails-acc :full full-acc}))] - (perf/end-measure "set-objects") - (process-pending shapes thumbnails full noop-fn - (fn [] - (if render-callback - (render-callback) - (render-finish)) - (ug/dispatch! (ug/event "penpot:wasm:set-objects"))))))) + (let [shapes (shapes-in-tree-order objects) + total-shapes (count shapes)] + (if (< total-shapes ASYNC_THRESHOLD) + (set-objects-sync shapes render-callback) + (do + (begin-shapes-loading!) + (try + (-> (set-objects-async shapes render-callback) + (p/catch (fn [error] + (end-shapes-loading!) + (js/console.error "Async WASM shape loading failed" error)))) + (catch :default error + (end-shapes-loading!) + (js/console.error "Async WASM shape loading failed" error) + (throw error))) + nil))))) (defn clear-focus-mode [] diff --git a/frontend/src/app/render_wasm/api/shapes.cljs b/frontend/src/app/render_wasm/api/shapes.cljs new file mode 100644 index 0000000000..c498c5e922 --- /dev/null +++ b/frontend/src/app/render_wasm/api/shapes.cljs @@ -0,0 +1,193 @@ +;; 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 INC + +(ns app.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.uuid :as uuid] + [app.render-wasm.helpers :as h] + [app.render-wasm.mem :as mem] + [app.render-wasm.serializers :as sr] + [app.render-wasm.wasm :as wasm])) + +;; 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/context-initialized? + (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)] + (if (some? c) + (sr/translate-constraint-h c) + CONSTRAINT-NONE)) + constraint-v (let [c (get shape :constraints-v)] + (if (some? c) + (sr/translate-constraint-v c) + CONSTRAINT-NONE)) + + 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/render-wasm/src/main.rs b/render-wasm/src/main.rs index 162732d551..c23ce7a07c 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -23,7 +23,7 @@ use std::collections::HashMap; use utils::uuid_from_u32_quartet; use uuid::Uuid; -pub(crate) static mut STATE: Option>> = None; +pub(crate) static mut STATE: Option> = None; #[macro_export] macro_rules! with_state_mut { @@ -191,6 +191,20 @@ pub extern "C" fn render_from_cache(_: i32) { }); } +#[no_mangle] +pub extern "C" fn set_preview_mode(enabled: bool) { + with_state_mut!(state, { + state.render_state.set_preview_mode(enabled); + }); +} + +#[no_mangle] +pub extern "C" fn render_preview() { + with_state_mut!(state, { + state.render_preview(performance::get_time()); + }); +} + #[no_mangle] pub extern "C" fn process_animation_frame(timestamp: i32) { let result = std::panic::catch_unwind(|| { diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index f0619a9e37..f009946a21 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -294,6 +294,8 @@ pub(crate) struct RenderState { /// where we must render shapes without inheriting ancestor layer blurs. Toggle it through /// `with_nested_blurs_suppressed` to ensure it's always restored. pub ignore_nested_blurs: bool, + /// Preview render mode - when true, uses simplified rendering for progressive loading + pub preview_mode: bool, } pub fn get_cache_size(viewbox: Viewbox, scale: f32) -> skia::ISize { @@ -366,6 +368,7 @@ impl RenderState { focus_mode: FocusMode::new(), touched_ids: HashSet::default(), ignore_nested_blurs: false, + preview_mode: false, } } @@ -486,6 +489,10 @@ impl RenderState { self.background_color = color; } + pub fn set_preview_mode(&mut self, enabled: bool) { + self.preview_mode = enabled; + } + pub fn resize(&mut self, width: i32, height: i32) { let dpr_width = (width as f32 * self.options.dpr()).floor() as i32; let dpr_height = (height as f32 * self.options.dpr()).floor() as i32; @@ -1127,6 +1134,25 @@ impl RenderState { performance::end_timed_log!("render_from_cache", _start); } + /// Render a preview of the shapes during loading. + /// This rebuilds tiles for touched shapes and renders synchronously. + pub fn render_preview(&mut self, tree: ShapesPoolRef, timestamp: i32) -> Result<(), String> { + let _start = performance::begin_timed_log!("render_preview"); + performance::begin_measure!("render_preview"); + + // Skip tile rebuilding during preview - we'll do it at the end + // Just rebuild tiles for touched shapes and render synchronously + self.rebuild_touched_tiles(tree); + + // Use the sync render path + self.start_render_loop(None, tree, timestamp, true)?; + + performance::end_measure!("render_preview"); + performance::end_timed_log!("render_preview", _start); + + Ok(()) + } + pub fn start_render_loop( &mut self, base_object: Option<&Uuid>, @@ -1622,10 +1648,11 @@ impl RenderState { is_empty = false; - let element = tree.get(&node_id).ok_or(format!( - "Error: Element with root_id {} not found in the tree.", - node_render_state.id - ))?; + let Some(element) = tree.get(&node_id) else { + // The shape isn't available yet (likely still streaming in from WASM). + // Skip it for this pass; a subsequent render will pick it up once present. + continue; + }; let scale = self.get_scale(); let mut extrect: Option = None; @@ -1743,7 +1770,9 @@ impl RenderState { if !matches!(element.shape_type, Type::Bool(_)) { // Nested shapes shadowing - apply black shadow to child shapes too for shadow_shape_id in element.children.iter() { - let shadow_shape = tree.get(shadow_shape_id).unwrap(); + let Some(shadow_shape) = tree.get(shadow_shape_id) else { + continue; + }; if shadow_shape.hidden { continue; } @@ -2141,9 +2170,7 @@ impl RenderState { } pub fn remove_cached_tile(&mut self, tile: tiles::Tile) { - let rect = self.get_aligned_tile_bounds(tile); - self.surfaces - .remove_cached_tile_surface(tile, rect, self.background_color); + self.surfaces.remove_cached_tile_surface(tile); } pub fn rebuild_tiles_shallow(&mut self, tree: ShapesPoolRef) { @@ -2164,7 +2191,7 @@ impl RenderState { } } - // Update the changed tiles + // Invalidate changed tiles - old content stays visible until new tiles render self.surfaces.remove_cached_tiles(self.background_color); for tile in all_tiles { self.remove_cached_tile(tile); @@ -2211,7 +2238,7 @@ impl RenderState { } } - // Update the changed tiles + // Invalidate changed tiles - old content stays visible until new tiles render self.surfaces.remove_cached_tiles(self.background_color); for tile in all_tiles { self.remove_cached_tile(tile); diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 00792109d8..8719b0373a 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -401,11 +401,10 @@ impl Surfaces { self.tiles.has(tile) } - pub fn remove_cached_tile_surface(&mut self, tile: Tile, rect: skia::Rect, color: skia::Color) { - // Clear the specific tile area in the cache surface with color - let mut paint = skia::Paint::default(); - paint.set_color(color); - self.cache.canvas().draw_rect(rect, &paint); + pub fn remove_cached_tile_surface(&mut self, tile: Tile) { + // Mark tile as invalid + // Old content stays visible until new tile overwrites it atomically, + // preventing flickering during tile re-renders. self.tiles.remove(tile); } diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index f178ed4477..385408d89f 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -18,16 +18,16 @@ use crate::shapes::modifiers::grid_layout::grid_cell_data; /// It is created by [init] and passed to the other exported functions. /// Note that rust-skia data structures are not thread safe, so a state /// must not be shared between different Web Workers. -pub(crate) struct State<'a> { +pub(crate) struct State { pub render_state: RenderState, pub text_editor_state: TextEditorState, pub current_id: Option, pub current_browser: u8, - pub shapes: ShapesPool<'a>, - pub saved_shapes: Option>, + pub shapes: ShapesPool, + pub saved_shapes: Option, } -impl<'a> State<'a> { +impl State { pub fn new(width: i32, height: i32) -> Self { State { render_state: RenderState::new(width, height), @@ -223,17 +223,14 @@ impl<'a> State<'a> { self.render_state.rebuild_touched_tiles(&self.shapes); } + pub fn render_preview(&mut self, timestamp: i32) { + let _ = self.render_state.render_preview(&self.shapes, timestamp); + } + pub fn rebuild_modifier_tiles(&mut self, ids: Vec) { - // SAFETY: We're extending the lifetime of the mutable borrow to 'a. - // This is safe because: - // 1. shapes has lifetime 'a in the struct - // 2. The reference won't outlive the struct - // 3. No other references to shapes exist during this call - unsafe { - let shapes_ptr = &mut self.shapes as *mut ShapesPool<'a>; - self.render_state - .rebuild_modifier_tiles(&mut *shapes_ptr, ids); - } + // Index-based storage is safe + self.render_state + .rebuild_modifier_tiles(&mut self.shapes, ids); } pub fn font_collection(&self) -> &FontCollection { diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index cdbc8c3caa..6587a23de2 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -28,29 +28,44 @@ const SHAPES_POOL_ALLOC_MULTIPLIER: f32 = 1.3; /// Shapes are stored in a `Vec`, which keeps the `Shape` instances /// in a contiguous memory block. /// -pub struct ShapesPoolImpl<'a> { +/// # Index-based Design +/// +/// All auxiliary HashMaps (modifiers, structure, scale_content, modified_shape_cache) +/// use `usize` indices instead of `&'a Uuid` references. This eliminates: +/// - Unsafe lifetime extensions +/// - The need for `rebuild_references()` after Vec reallocation +/// - Complex lifetime annotations +/// +/// The `uuid_to_idx` HashMap maps `Uuid` (owned) to indices, avoiding lifetime issues. +/// +pub struct ShapesPoolImpl { shapes: Vec, counter: usize, - shapes_uuid_to_idx: HashMap<&'a Uuid, usize>, + /// Maps UUID to index in the shapes Vec. Uses owned Uuid, no lifetime needed. + uuid_to_idx: HashMap, - modified_shape_cache: HashMap<&'a Uuid, OnceCell>, - modifiers: HashMap<&'a Uuid, skia::Matrix>, - structure: HashMap<&'a Uuid, Vec>, - scale_content: HashMap<&'a Uuid, f32>, + /// Cache for modified shapes, keyed by index + modified_shape_cache: HashMap>, + /// Transform modifiers, keyed by index + modifiers: HashMap, + /// Structure entries, keyed by index + structure: HashMap>, + /// Scale content values, keyed by index + scale_content: HashMap, } -// Type aliases to avoid writing lifetimes everywhere -pub type ShapesPool<'a> = ShapesPoolImpl<'a>; -pub type ShapesPoolRef<'a> = &'a ShapesPoolImpl<'a>; -pub type ShapesPoolMutRef<'a> = &'a mut ShapesPoolImpl<'a>; +// Type aliases - no longer need lifetimes! +pub type ShapesPool = ShapesPoolImpl; +pub type ShapesPoolRef<'a> = &'a ShapesPoolImpl; +pub type ShapesPoolMutRef<'a> = &'a mut ShapesPoolImpl; -impl<'a> ShapesPoolImpl<'a> { +impl ShapesPoolImpl { pub fn new() -> Self { ShapesPoolImpl { shapes: vec![], counter: 0, - shapes_uuid_to_idx: HashMap::default(), + uuid_to_idx: HashMap::default(), modified_shape_cache: HashMap::default(), modifiers: HashMap::default(), @@ -62,15 +77,14 @@ impl<'a> ShapesPoolImpl<'a> { pub fn initialize(&mut self, capacity: usize) { performance::begin_measure!("shapes_pool_initialize"); self.counter = 0; - self.shapes_uuid_to_idx = HashMap::with_capacity(capacity); + self.uuid_to_idx = HashMap::with_capacity(capacity); let additional = capacity as i32 - self.shapes.len() as i32; if additional <= 0 { return; } - // Reserve exact capacity to avoid any future reallocations - // This is critical because we store &'a Uuid references that would be invalidated + // Reserve extra capacity to avoid future reallocations let target_capacity = (capacity as f32 * SHAPES_POOL_ALLOC_MULTIPLIER) as usize; self.shapes .reserve_exact(target_capacity.saturating_sub(self.shapes.len())); @@ -81,15 +95,15 @@ impl<'a> ShapesPoolImpl<'a> { } pub fn add_shape(&mut self, id: Uuid) -> &mut Shape { - let did_reallocate = if self.counter >= self.shapes.len() { - // We need more space. Check if we'll need to reallocate the Vec. + if self.counter >= self.shapes.len() { + // We need more space let current_capacity = self.shapes.capacity(); - let additional = (self.shapes.len() as f32 * SHAPES_POOL_ALLOC_MULTIPLIER) as usize; + // Ensure we add at least 1 shape when the pool is empty + let additional = + ((self.shapes.len() as f32 * SHAPES_POOL_ALLOC_MULTIPLIER) as usize).max(1); let needed_capacity = self.shapes.len() + additional; - let will_reallocate = needed_capacity > current_capacity; - - if will_reallocate { + if needed_capacity > current_capacity { // Reserve extra space to minimize future reallocations let extra_reserve = (needed_capacity as f32 * 0.5) as usize; self.shapes @@ -98,165 +112,68 @@ impl<'a> ShapesPoolImpl<'a> { self.shapes .extend(iter::repeat_with(|| Shape::new(Uuid::nil())).take(additional)); - - will_reallocate - } else { - false - }; + } let idx = self.counter; let new_shape = &mut self.shapes[idx]; new_shape.id = id; - // Get a reference to the id field in the shape with lifetime 'a - // SAFETY: This is safe because: - // 1. We pre-allocate enough capacity to avoid Vec reallocation - // 2. The shape and its id field won't move within the Vec - // 3. The reference won't outlive the ShapesPoolImpl - let id_ref: &'a Uuid = unsafe { &*(&self.shapes[idx].id as *const Uuid) }; - - self.shapes_uuid_to_idx.insert(id_ref, idx); + // Simply store the UUID -> index mapping. No unsafe lifetime tricks needed! + self.uuid_to_idx.insert(id, idx); self.counter += 1; - // If the Vec reallocated, we need to rebuild all references in the HashMaps - // because the old references point to deallocated memory - if did_reallocate { - self.rebuild_references(); - } - &mut self.shapes[idx] } - - /// Rebuilds all &'a Uuid references in the HashMaps after a Vec reallocation. - /// This is necessary because Vec reallocation invalidates all existing references. - fn rebuild_references(&mut self) { - // Rebuild shapes_uuid_to_idx with fresh references - let mut new_map = HashMap::with_capacity(self.shapes_uuid_to_idx.len()); - for (_, idx) in self.shapes_uuid_to_idx.drain() { - let id_ref: &'a Uuid = unsafe { &*(&self.shapes[idx].id as *const Uuid) }; - new_map.insert(id_ref, idx); - } - self.shapes_uuid_to_idx = new_map; - - // Rebuild modifiers with fresh references - if !self.modifiers.is_empty() { - let old_modifiers: Vec<(Uuid, skia::Matrix)> = self - .modifiers - .drain() - .map(|(uuid_ref, matrix)| (*uuid_ref, matrix)) - .collect(); - - for (uuid, matrix) in old_modifiers { - if let Some(uuid_ref) = self.get_uuid_ref(&uuid) { - self.modifiers.insert(uuid_ref, matrix); - } - } - } - - // Rebuild structure with fresh references - if !self.structure.is_empty() { - let old_structure: Vec<(Uuid, Vec)> = self - .structure - .drain() - .map(|(uuid_ref, entries)| (*uuid_ref, entries)) - .collect(); - - for (uuid, entries) in old_structure { - if let Some(uuid_ref) = self.get_uuid_ref(&uuid) { - self.structure.insert(uuid_ref, entries); - } - } - } - - // Rebuild scale_content with fresh references - if !self.scale_content.is_empty() { - let old_scale_content: Vec<(Uuid, f32)> = self - .scale_content - .drain() - .map(|(uuid_ref, scale)| (*uuid_ref, scale)) - .collect(); - - for (uuid, scale) in old_scale_content { - if let Some(uuid_ref) = self.get_uuid_ref(&uuid) { - self.scale_content.insert(uuid_ref, scale); - } - } - } - // Rebuild modified_shape_cache with fresh references - if !self.modified_shape_cache.is_empty() { - let old_cache: Vec<(Uuid, OnceCell)> = self - .modified_shape_cache - .drain() - .map(|(uuid_ref, cell)| (*uuid_ref, cell)) - .collect(); - - for (uuid, cell) in old_cache { - if let Some(uuid_ref) = self.get_uuid_ref(&uuid) { - self.modified_shape_cache.insert(uuid_ref, cell); - } - } - } - } + // No longer needed! Index-based storage means no references to rebuild. + // The old rebuild_references() function has been removed entirely. pub fn len(&self) -> usize { - self.shapes_uuid_to_idx.len() + self.uuid_to_idx.len() } pub fn has(&self, id: &Uuid) -> bool { - self.shapes_uuid_to_idx.contains_key(&id) + self.uuid_to_idx.contains_key(id) } pub fn get_mut(&mut self, id: &Uuid) -> Option<&mut Shape> { - let idx = *self.shapes_uuid_to_idx.get(&id)?; + let idx = *self.uuid_to_idx.get(id)?; Some(&mut self.shapes[idx]) } - pub fn get(&self, id: &Uuid) -> Option<&'a Shape> { - let idx = *self.shapes_uuid_to_idx.get(&id)?; + /// Get a shape by UUID. Returns the modified shape if modifiers/structure + /// are applied, otherwise returns the base shape. + pub fn get(&self, id: &Uuid) -> Option<&Shape> { + let idx = *self.uuid_to_idx.get(id)?; - // SAFETY: We're extending the lifetimes to 'a. - // This is safe because: - // 1. All internal HashMaps and the shapes Vec have fields with lifetime 'a - // 2. The shape at idx won't be moved or reallocated (pre-allocated Vec) - // 3. The id is stored in shapes[idx].id which has lifetime 'a - // 4. The references won't outlive the ShapesPoolImpl - unsafe { - let shape_ptr = &self.shapes[idx] as *const Shape; - let modifiers_ptr = &self.modifiers as *const HashMap<&'a Uuid, skia::Matrix>; - let structure_ptr = &self.structure as *const HashMap<&'a Uuid, Vec>; - let scale_content_ptr = &self.scale_content as *const HashMap<&'a Uuid, f32>; - let cache_ptr = &self.modified_shape_cache as *const HashMap<&'a Uuid, OnceCell>; + let shape = &self.shapes[idx]; - // Extend the lifetime of id to 'a - safe because it's the same Uuid stored in shapes[idx].id - let id_ref: &'a Uuid = &*(id as *const Uuid); + // Check if this shape needs modification (has modifiers, structure changes, or is a bool) + let needs_modification = shape.is_bool() + || self.modifiers.contains_key(&idx) + || self.structure.contains_key(&idx) + || self.scale_content.contains_key(&idx); - if (*shape_ptr).is_bool() - || (*modifiers_ptr).contains_key(&id_ref) - || (*structure_ptr).contains_key(&id_ref) - || (*scale_content_ptr).contains_key(&id_ref) - { - if let Some(cell) = (*cache_ptr).get(&id_ref) { - Some(cell.get_or_init(|| { - let mut shape = (*shape_ptr).transformed( - (*modifiers_ptr).get(&id_ref), - (*structure_ptr).get(&id_ref), - ); + if needs_modification { + // Check if we have a cached modified version + if let Some(cell) = self.modified_shape_cache.get(&idx) { + Some(cell.get_or_init(|| { + let mut modified_shape = + shape.transformed(self.modifiers.get(&idx), self.structure.get(&idx)); - if self.to_update_bool(&shape) { - math_bools::update_bool_to_path(&mut shape, self); - } + if self.to_update_bool(&modified_shape) { + math_bools::update_bool_to_path(&mut modified_shape, self); + } - if let Some(scale) = (*scale_content_ptr).get(&id_ref) { - shape.scale_content(*scale); - } - shape - })) - } else { - Some(&*shape_ptr) - } + if let Some(scale) = self.scale_content.get(&idx) { + modified_shape.scale_content(*scale); + } + modified_shape + })) } else { - Some(&*shape_ptr) + Some(shape) } + } else { + Some(shape) } } @@ -275,69 +192,68 @@ impl<'a> ShapesPoolImpl<'a> { } pub fn set_modifiers(&mut self, modifiers: HashMap) { - // Convert HashMap to HashMap<&'a Uuid, V> using references from shapes and - // Initialize the cache cells because later we don't want to have the mutable pointer + // Convert HashMap to HashMap using indices + // Initialize the cache cells for affected shapes let mut ids = Vec::::new(); + let mut modifiers_with_idx = HashMap::with_capacity(modifiers.len()); - let mut modifiers_with_refs = HashMap::with_capacity(modifiers.len()); for (uuid, matrix) in modifiers { - if let Some(uuid_ref) = self.get_uuid_ref(&uuid) { - // self.modified_shape_cache.insert(uuid_ref, OnceCell::new()); - modifiers_with_refs.insert(uuid_ref, matrix); - ids.push(*uuid_ref); + if let Some(idx) = self.uuid_to_idx.get(&uuid).copied() { + modifiers_with_idx.insert(idx, matrix); + ids.push(uuid); } } - self.modifiers = modifiers_with_refs; + self.modifiers = modifiers_with_idx; let all_ids = shapes::all_with_ancestors(&ids, self, true); for uuid in all_ids { - if let Some(uuid_ref) = self.get_uuid_ref(&uuid) { - self.modified_shape_cache.insert(uuid_ref, OnceCell::new()); + if let Some(idx) = self.uuid_to_idx.get(&uuid).copied() { + self.modified_shape_cache.insert(idx, OnceCell::new()); } } } pub fn set_structure(&mut self, structure: HashMap>) { - // Convert HashMap to HashMap<&'a Uuid, V> using references from shapes and - // Initialize the cache cells because later we don't want to have the mutable pointer - let mut structure_with_refs = HashMap::with_capacity(structure.len()); + // Convert HashMap to HashMap using indices + // Initialize the cache cells for affected shapes + let mut structure_with_idx = HashMap::with_capacity(structure.len()); let mut ids = Vec::::new(); for (uuid, entries) in structure { - if let Some(uuid_ref) = self.get_uuid_ref(&uuid) { - structure_with_refs.insert(uuid_ref, entries); - ids.push(*uuid_ref); + if let Some(idx) = self.uuid_to_idx.get(&uuid).copied() { + structure_with_idx.insert(idx, entries); + ids.push(uuid); } } - self.structure = structure_with_refs; + self.structure = structure_with_idx; let all_ids = shapes::all_with_ancestors(&ids, self, true); for uuid in all_ids { - if let Some(uuid_ref) = self.get_uuid_ref(&uuid) { - self.modified_shape_cache.insert(uuid_ref, OnceCell::new()); + if let Some(idx) = self.uuid_to_idx.get(&uuid).copied() { + self.modified_shape_cache.insert(idx, OnceCell::new()); } } } pub fn set_scale_content(&mut self, scale_content: HashMap) { - // Convert HashMap to HashMap<&'a Uuid, V> using references from shapes and - // Initialize the cache cells because later we don't want to have the mutable pointer - let mut scale_content_with_refs = HashMap::with_capacity(scale_content.len()); + // Convert HashMap to HashMap using indices + // Initialize the cache cells for affected shapes + let mut scale_content_with_idx = HashMap::with_capacity(scale_content.len()); let mut ids = Vec::::new(); for (uuid, value) in scale_content { - if let Some(uuid_ref) = self.get_uuid_ref(&uuid) { - scale_content_with_refs.insert(uuid_ref, value); - ids.push(*uuid_ref); + if let Some(idx) = self.uuid_to_idx.get(&uuid).copied() { + scale_content_with_idx.insert(idx, value); + ids.push(uuid); } } - self.scale_content = scale_content_with_refs; + self.scale_content = scale_content_with_idx; let all_ids = shapes::all_with_ancestors(&ids, self, true); for uuid in all_ids { - if let Some(uuid_ref) = self.get_uuid_ref(&uuid) { - self.modified_shape_cache.insert(uuid_ref, OnceCell::new()); + if let Some(idx) = self.uuid_to_idx.get(&uuid).copied() { + self.modified_shape_cache.insert(idx, OnceCell::new()); } } } @@ -349,47 +265,33 @@ impl<'a> ShapesPoolImpl<'a> { self.scale_content = HashMap::default(); } - /// Get a reference to the Uuid stored in a shape, if it exists - pub fn get_uuid_ref(&self, id: &Uuid) -> Option<&'a Uuid> { - let idx = *self.shapes_uuid_to_idx.get(&id)?; - // SAFETY: We're returning a reference with lifetime 'a to a Uuid stored - // in the shapes Vec. This is safe because the Vec is stable (pre-allocated) - // and won't be reallocated. - unsafe { Some(&*(&self.shapes[idx].id as *const Uuid)) } - } - - pub fn subtree(&self, id: &Uuid) -> ShapesPoolImpl<'a> { + pub fn subtree(&self, id: &Uuid) -> ShapesPoolImpl { let Some(shape) = self.get(id) else { panic!("Subtree not found"); }; let mut shapes = vec![]; - let mut idx = 0; - let mut shapes_uuid_to_idx = HashMap::default(); + let mut new_idx = 0; + let mut uuid_to_idx = HashMap::default(); - for id in shape.all_children_iter(self, true, true) { - let Some(shape) = self.get(&id) else { + for child_id in shape.all_children_iter(self, true, true) { + let Some(child_shape) = self.get(&child_id) else { panic!("Not found"); }; - shapes.push(shape.clone()); - - let id_ref: &'a Uuid = unsafe { &*(&self.shapes[idx].id as *const Uuid) }; - shapes_uuid_to_idx.insert(id_ref, idx); - idx += 1; + shapes.push(child_shape.clone()); + uuid_to_idx.insert(child_id, new_idx); + new_idx += 1; } - let mut result = ShapesPoolImpl { + ShapesPoolImpl { shapes, - counter: idx, - shapes_uuid_to_idx, + counter: new_idx, + uuid_to_idx, modified_shape_cache: HashMap::default(), modifiers: HashMap::default(), structure: HashMap::default(), scale_content: HashMap::default(), - }; - result.rebuild_references(); - - result + } } fn to_update_bool(&self, shape: &Shape) -> bool { @@ -398,11 +300,21 @@ impl<'a> ShapesPoolImpl<'a> { } let default = &Matrix::default(); - let parent_modifier = self.modifiers.get(&shape.id).unwrap_or(default); + + // Get parent modifier by index + let parent_idx = self.uuid_to_idx.get(&shape.id); + let parent_modifier = parent_idx + .and_then(|idx| self.modifiers.get(idx)) + .unwrap_or(default); // Returns true if the transform of any child is different to the parent's - shape.all_children_iter(self, true, false).any(|id| { - !math::is_close_matrix(parent_modifier, self.modifiers.get(&id).unwrap_or(default)) + shape.all_children_iter(self, true, false).any(|child_id| { + let child_modifier = self + .uuid_to_idx + .get(&child_id) + .and_then(|idx| self.modifiers.get(idx)) + .unwrap_or(default); + !math::is_close_matrix(parent_modifier, child_modifier) }) } } diff --git a/render-wasm/src/wasm/layouts.rs b/render-wasm/src/wasm/layouts.rs index 903edaa0e6..9f77a21429 100644 --- a/render-wasm/src/wasm/layouts.rs +++ b/render-wasm/src/wasm/layouts.rs @@ -3,7 +3,7 @@ use crate::{with_current_shape_mut, STATE}; use macros::ToJs; mod align; -mod constraints; +pub mod constraints; mod flex; mod grid; diff --git a/render-wasm/src/wasm/shapes/base_props.rs b/render-wasm/src/wasm/shapes/base_props.rs new file mode 100644 index 0000000000..428c5e18fa --- /dev/null +++ b/render-wasm/src/wasm/shapes/base_props.rs @@ -0,0 +1,173 @@ +use crate::mem; +use crate::shapes::{BlendMode, ConstraintH, ConstraintV}; +use crate::utils::uuid_from_u32_quartet; +use crate::uuid::Uuid; +use crate::wasm::blend::RawBlendMode; +use crate::wasm::layouts::constraints::{RawConstraintH, RawConstraintV}; +use crate::{with_state_mut, STATE}; + +use super::RawShapeType; + +/// Binary layout for batched shape base properties: +/// +/// | 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 | | | +pub const BASE_PROPS_SIZE: usize = 104; + +const FLAG_CLIP_CONTENT: u8 = 0b0000_0001; +const FLAG_HIDDEN: u8 = 0b0000_0010; +const CONSTRAINT_NONE: u8 = 0xFF; + +/// Reads a f32 from a byte slice at the given offset (little-endian) +#[inline] +fn read_f32_le(bytes: &[u8], offset: usize) -> f32 { + f32::from_le_bytes([ + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + ]) +} + +/// Reads a u32 from a byte slice at the given offset (little-endian) +#[inline] +fn read_u32_le(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes([ + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + ]) +} + +/// Parses UUID from bytes at given offset +#[inline] +fn read_uuid(bytes: &[u8], offset: usize) -> Uuid { + uuid_from_u32_quartet( + read_u32_le(bytes, offset), + read_u32_le(bytes, offset + 4), + read_u32_le(bytes, offset + 8), + read_u32_le(bytes, offset + 12), + ) +} + +#[no_mangle] +pub extern "C" fn set_shape_base_props() { + let bytes = mem::bytes(); + + if bytes.len() < BASE_PROPS_SIZE { + return; + } + + // Parse all fields from the buffer + let id = read_uuid(&bytes, 0); + let parent_id = read_uuid(&bytes, 16); + let shape_type = bytes[32]; + let flags = bytes[33]; + let blend_mode = bytes[34]; + let constraint_h = bytes[35]; + let constraint_v = bytes[36]; + // bytes[37..40] are padding + + let opacity = read_f32_le(&bytes, 40); + let rotation = read_f32_le(&bytes, 44); + + // Transform matrix (a, b, c, d, e, f) + let transform_a = read_f32_le(&bytes, 48); + let transform_b = read_f32_le(&bytes, 52); + let transform_c = read_f32_le(&bytes, 56); + let transform_d = read_f32_le(&bytes, 60); + let transform_e = read_f32_le(&bytes, 64); + let transform_f = read_f32_le(&bytes, 68); + + // Selrect (x1, y1, x2, y2) + let selrect_x1 = read_f32_le(&bytes, 72); + let selrect_y1 = read_f32_le(&bytes, 76); + let selrect_x2 = read_f32_le(&bytes, 80); + let selrect_y2 = read_f32_le(&bytes, 84); + + // Corners (r1, r2, r3, r4) + let corner_r1 = read_f32_le(&bytes, 88); + let corner_r2 = read_f32_le(&bytes, 92); + let corner_r3 = read_f32_le(&bytes, 96); + let corner_r4 = read_f32_le(&bytes, 100); + + // Decode flags + let clip_content = (flags & FLAG_CLIP_CONTENT) != 0; + let hidden = (flags & FLAG_HIDDEN) != 0; + + // Convert raw enum values + let shape_type_enum = RawShapeType::from(shape_type); + let blend_mode_enum: BlendMode = RawBlendMode::from(blend_mode).into(); + + let constraint_h_opt: Option = if constraint_h == CONSTRAINT_NONE { + None + } else { + Some(RawConstraintH::from(constraint_h).into()) + }; + + let constraint_v_opt: Option = if constraint_v == CONSTRAINT_NONE { + None + } else { + Some(RawConstraintV::from(constraint_v).into()) + }; + + with_state_mut!(state, { + // Select/create the shape + state.use_shape(id); + + // Set parent relationship + state.set_parent_for_current_shape(parent_id); + + // Mark shape as touched + state.touch_current(); + + // Apply all properties to the current shape + if let Some(shape) = state.current_shape_mut() { + // Type + shape.set_shape_type(shape_type_enum.into()); + + // Boolean flags + shape.set_clip(clip_content); + shape.set_hidden(hidden); + + // Blend mode and opacity + shape.set_blend_mode(blend_mode_enum); + shape.set_opacity(opacity); + + // Constraints + shape.set_constraint_h(constraint_h_opt); + shape.set_constraint_v(constraint_v_opt); + + // Transform + shape.set_rotation(rotation); + shape.set_transform( + transform_a, + transform_b, + transform_c, + transform_d, + transform_e, + transform_f, + ); + + // Geometry + shape.set_selrect(selrect_x1, selrect_y1, selrect_x2, selrect_y2); + shape.set_corners((corner_r1, corner_r2, corner_r3, corner_r4)); + } + }); +} diff --git a/render-wasm/src/wasm/shapes.rs b/render-wasm/src/wasm/shapes/mod.rs similarity index 98% rename from render-wasm/src/wasm/shapes.rs rename to render-wasm/src/wasm/shapes/mod.rs index c2ff3b4931..3f32e824c3 100644 --- a/render-wasm/src/wasm/shapes.rs +++ b/render-wasm/src/wasm/shapes/mod.rs @@ -1,3 +1,5 @@ +mod base_props; + use macros::ToJs; use crate::shapes::{Bool, Frame, Group, Path, Rect, SVGRaw, TextContent, Type};