diff --git a/frontend/src/app/main/data/changes.cljs b/frontend/src/app/main/data/changes.cljs index ef18c15554..74a2d97659 100644 --- a/frontend/src/app/main/data/changes.cljs +++ b/frontend/src/app/main/data/changes.cljs @@ -20,7 +20,6 @@ [app.main.worker :as mw] [app.render-wasm.api :as wasm.api] [app.render-wasm.shape :as wasm.shape] - [app.render-wasm.wasm :as wasm] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) @@ -90,8 +89,7 @@ (ptk/reify ::sync-wasm-structural-changes ptk/EffectEvent (effect [_ state _] - (when (and wasm/context-initialized? - (not @wasm/context-lost?)) + (when (wasm.api/initialized?) (let [objects (dsh/lookup-page-objects state) shapes (into [] diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 689ce07ecd..cedfad1d96 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -355,8 +355,7 @@ features (features/get-enabled-features state team-id) render-wasm-enabled? (features/active-feature? state "render-wasm/v1") render-wasm-ready? #(and render-wasm-enabled? - wasm-state/context-initialized? - (not @wasm-state/context-lost?))] + (wasm-state/ready?))] (log/debug :hint "initialize-workspace" :team-id (dm/str team-id) diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index 7886881f5b..cb8be131cd 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -683,7 +683,7 @@ ty (.-f first-matrix)] (if-let [base @cache] (translate-selrect base tx ty) - (let [computed (wasm.api/get-selection-rect ids)] + (when-let [computed (wasm.api/get-selection-rect ids)] (vreset! cache (translate-selrect computed (- tx) (- ty))) computed)))) @@ -729,15 +729,27 @@ (vreset! wasm-structure-modifiers-active? true))) (let [geometry-entries (parse-geometry-modifiers modif-tree) root-modifiers (into [] (map (fn [[id data]] [id (:transform data)])) geometry-entries) + wasm-ready? (wasm.api/initialized?) + ;; While the GL context is down (lost / mid-reload), keep the + ;; root transforms so SVG selection/preview can still move. + ;; `propagate-modifiers` returns [] when not ready, do not + ;; treat that as "no modifiers". modifiers - (if (and translation? (not snap-pixel?)) + (cond + (or (not wasm-ready?) + (and translation? (not snap-pixel?))) root-modifiers - (wasm.api/propagate-modifiers geometry-entries snap-pixel?))] - (wasm.api/set-modifiers modifiers) + + :else + (let [propagated (wasm.api/propagate-modifiers geometry-entries snap-pixel?)] + (if (seq propagated) propagated root-modifiers)))] + (when wasm-ready? + (wasm.api/set-modifiers modifiers)) (let [ids (into [] xf:map-key geometry-entries) - selrect (if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers)) - (cached-translation-selrect ids (second (first modifiers)) selection-rect-cache) - (wasm.api/get-selection-rect ids))] + selrect (when wasm-ready? + (if (and translation? (not snap-pixel?) selection-rect-cache (seq modifiers)) + (cached-translation-selrect ids (second (first modifiers)) selection-rect-cache) + (wasm.api/get-selection-rect ids)))] (rx/of (set-temporary-selrect selrect) (set-temporary-modifiers modifiers)))))))) @@ -795,7 +807,8 @@ (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) transforms - (if (and translation? (not snap-pixel?)) + (cond + (and translation? (not snap-pixel?)) ;; Mirror WASM `propagate_modifiers` in CLJS: splat the ;; translation matrix onto every descendant. Without ;; this step the commit would only touch the dragged @@ -815,6 +828,26 @@ (reduce (fn [a sid] (assoc a sid t)) acc subtree-ids))) {} geometry-entries) + + ;; Context lost / mid-reload: do not call into WASM. Use + ;; root transforms (and splat translation onto descendants + ;; when we can) so the commit still lands in file data. + (not (wasm.api/initialized?)) + (if translation? + (reduce + (fn [acc [id data]] + (let [t (:transform data) + subtree-ids + (or (get subtree-ids-by-id id) + (cfh/get-children-ids-with-self objects id))] + (reduce (fn [a sid] (assoc a sid t)) acc subtree-ids))) + {} + geometry-entries) + (into {} + (map (fn [[id data]] [id (:transform data)])) + geometry-entries)) + + :else (into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?))) ignore-tree diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 7e4e4c2734..45da1db348 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -93,11 +93,14 @@ (defn initialized? - "True when the WASM render context is ready to receive design-state - operations. Use it to skip WASM work during transient states (e.g. while - switching renderer with a text shape being edited)." + "True when the WASM render context is safe for normal application calls. + False while missing/lost or while `reload-renderer!` is reconstructing it. + + App/data code and tests should use this (tests can `set!` it). Prefer + `(initialized?)` over `(wasm/ready?)` inside this namespace so readiness + stays mockable; use `wasm/live?` for reload-internal ops." [] - (and wasm/context-initialized? (not @wasm/context-lost?))) + (wasm/ready?)) (defn set-transition-image-from-background! @@ -416,9 +419,10 @@ (defn free-gpu-resources [] - ;; check if the context has not been lost already or we will get warnings about - ;; removing objects from a non-current context - (when (initialized?) + ;; Do not use `wasm/live?` here: after `webglcontextrestored` we keep + ;; `context-lost?` true until re-init, but the browser context is usable again + ;; and must release GPU objects before `_clean_up` / context deletion. + (when (and wasm/context-initialized? (wasm/module-ready?)) (h/call wasm/internal-module "_free_gpu_resources"))) ;; When set, the next render keeps the previously presented frame on screen @@ -450,7 +454,7 @@ ;; This should never be called from the outside. (defn- render [timestamp] - (when (and wasm/context-initialized? (not @wasm/context-lost?)) + (when (wasm/live?) ;; SYNC-TILES makes WASM keep the last presented frame while the new tiles ;; are rasterized, rather than clearing to the background first. The flag is ;; one-shot on both sides: WASM clears it when the render loop starts, so @@ -488,7 +492,7 @@ rebuilding shape tiles. Fast synchronous call used to show the viewport frame immediately before a potentially slow tile rebuild." [] - (when (and wasm/context-initialized? (not @wasm/context-lost?)) + (when (wasm/live?) (h/call wasm/internal-module "_render_ui_only"))) (defn render-from-backbuffer! @@ -504,7 +508,7 @@ any zoom. Only valid on a stable viewbox; pan/zoom must keep using the atlas blit, whose preview tracks the moving viewport." [] - (when (and wasm/context-initialized? (not @wasm/context-lost?)) + (when (wasm/live?) (h/call wasm/internal-module "_render_from_backbuffer"))) (defn render-text-editor-overlay! @@ -523,8 +527,7 @@ Pending editor events are still drained so the style toolbar stays in sync; a full render is only requested when content or layout actually changed." [] - (when (and wasm/context-initialized? - (not @wasm/context-lost?) + (when (and (wasm/live?) ;; Skip when a render is already pending: `text-editor-render-caret` ;; composes from the Backbuffer, but a progressive render (multiple ;; rAF frames at high zoom) is still rebuilding it. Compositing then @@ -548,7 +551,7 @@ "Blurs the current page into the canvas so a following `capture-canvas-snapshot` grabs an already-blurred transition frame." [] - (when (and wasm/context-initialized? (not @wasm/context-lost?)) + (when (wasm/live?) (h/call wasm/internal-module "_render_blurred_snapshot" TRANSITION_BLUR_RADIUS))) (defn render-sync @@ -592,7 +595,9 @@ (defn request-render [_requester] - (when (and (initialized?) (not @wasm/disable-request-render?)) + ;; Use `wasm/live?` (not `wasm/ready?`) so reload-internal + ;; renders can run while `reloading?` still blocks external mutations. + (when (and (wasm/live?) (not @wasm/disable-request-render?)) (if @shapes-loading? (register-deferred-render!) (when-not @pending-render @@ -1300,7 +1305,8 @@ (defn set-shape-grow-type [grow-type] - (props/set-shape-grow-type grow-type)) + (when (initialized?) + (props/set-shape-grow-type grow-type))) (defn get-text-dimensions ([id] @@ -1340,13 +1346,13 @@ (defn view-interaction-start! [] - (when-not @view-interaction-active? + (when (and (initialized?) (not @view-interaction-active?)) (h/call wasm/internal-module "_set_view_start") (reset! view-interaction-active? true))) (defn view-interaction-end! [] - (when @view-interaction-active? + (when (and (initialized?) @view-interaction-active?) (perf/begin-measure "render-finish") (h/call wasm/internal-module "_set_view_end") (perf/end-measure "render-finish") @@ -1383,22 +1389,23 @@ (defn finalize-view-interaction! "Ends the view interaction and triggers a full-quality render." [] - (view-interaction-end!) - ;; Preserve the last presented frame while the new one renders. A plain render - ;; goes through WASM `reset_canvas`, which clears to the background and - ;; re-rasterizes the viewport tile by tile — a visible flash on zoomed-in - ;; views. The content is unchanged across a view-interaction end (only the - ;; view moved), so there is nothing to clear; SYNC-TILES sets `preserve_target` - ;; and lets the new tiles replace the old frame in place. Zoom-end already did - ;; this implicitly (`zoom_changed`); this extends it to pan/resize-triggered - ;; ends (e.g. selecting a shape opens the options panel and resizes the - ;; viewport), which previously blanked. - (internal-render 0 RENDER-FLAG-SYNC-TILES) - ;; The direct render above bypasses the rAF `render` loop, so repaint the - ;; editor overlay explicitly. Only when this was a full frame: a progressive - ;; render keeps painting through the rAF loop and its partial frames must not - ;; be over-stamped (see `render-text-editor-overlay-after-frame!`). - (render-text-editor-overlay-after-frame!)) + (when (initialized?) + (view-interaction-end!) + ;; Preserve the last presented frame while the new one renders. A plain render + ;; goes through WASM `reset_canvas`, which clears to the background and + ;; re-rasterizes the viewport tile by tile — a visible flash on zoomed-in + ;; views. The content is unchanged across a view-interaction end (only the + ;; view moved), so there is nothing to clear; SYNC-TILES sets `preserve_target` + ;; and lets the new tiles replace the old frame in place. Zoom-end already did + ;; this implicitly (`zoom_changed`); this extends it to pan/resize-triggered + ;; ends (e.g. selecting a shape opens the options panel and resizes the + ;; viewport), which previously blanked. + (internal-render 0 RENDER-FLAG-SYNC-TILES) + ;; The direct render above bypasses the rAF `render` loop, so repaint the + ;; editor overlay explicitly. Only when this was a full frame: a progressive + ;; render keeps painting through the rAF loop and its partial frames must not + ;; be over-stamped (see `render-text-editor-overlay-after-frame!`). + (render-text-editor-overlay-after-frame!))) (def render-finish (letfn [(do-render [] @@ -1415,24 +1422,25 @@ (defn set-view-box [zoom vbox] - (perf/begin-measure "set-view-box") - (view-interaction-start!) - (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) - (perf/end-measure "set-view-box") + (when (initialized?) + (perf/begin-measure "set-view-box") + (view-interaction-start!) + (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) + (perf/end-measure "set-view-box") - (perf/begin-measure "render-from-cache") - (h/call wasm/internal-module "_render_from_cache" 0) - ;; Keep the text-editor caret/selection glued to the shapes while the view - ;; changes. `_render_from_cache` re-composites shapes + UI at the new viewbox - ;; but omits the editor overlay, so without this the selection would vanish for - ;; the whole pan/zoom gesture and only flash back when the debounced full - ;; render lands — the blink seen when zooming in/out over a selection at high - ;; zoom (gh-10709). `_text_editor_render_overlay` draws straight onto the - ;; freshly composited Target (no Backbuffer re-compose) and no-ops when no - ;; editor is active. - (render-text-editor-overlay-if-active!) - (render-finish) - (perf/end-measure "render-from-cache")) + (perf/begin-measure "render-from-cache") + (h/call wasm/internal-module "_render_from_cache" 0) + ;; Keep the text-editor caret/selection glued to the shapes while the view + ;; changes. `_render_from_cache` re-composites shapes + UI at the new viewbox + ;; but omits the editor overlay, so without this the selection would vanish for + ;; the whole pan/zoom gesture and only flash back when the debounced full + ;; render lands — the blink seen when zooming in/out over a selection at high + ;; zoom (gh-10709). `_text_editor_render_overlay` draws straight onto the + ;; freshly composited Target (no Backbuffer re-compose) and no-ops when no + ;; editor is active. + (render-text-editor-overlay-if-active!) + (render-finish) + (perf/end-measure "render-from-cache"))) (defn sync-workspace-local-viewport! "Pushes `[:workspace-local :zoom]` and `:vbox` into WASM." @@ -1452,42 +1460,44 @@ (defn set-object [shape] - (perf/begin-measure "set-object") - (when shape - (let [shape (svg-filters/apply-svg-derived shape) - id (dm/get-prop shape :id) - type (dm/get-prop shape :type) + (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))] + fills (get shape :fills) + strokes (if (= type :group) + [] (get shape :strokes)) + content (let [content (get shape :content)] + (if (= type :text) + (ensure-text-content content) + content))] - (serialize-shape/serialize-shape! shape) + (serialize-shape/serialize-shape! shape) - ;; Browser-only: svg-raw markup (needs React) + workspace layout. - (when (and (some? content) (= type :svg-raw)) - (set-shape-svg-raw-content (get-static-markup shape))) - (set-shape-layout shape) - (set-layout-data shape) - (let [is-text? (= type :text) - text-content-pending (when is-text? (set-shape-text-content id content)) - pending-thumbnails (into [] (concat - text-content-pending - (when is-text? (set-shape-text-images id content true)) - (set-shape-fills id fills true) - (set-shape-strokes id strokes true))) - pending-full (into [] (concat - (when is-text? (set-shape-text-images id content false)) - (set-shape-fills id fills false) - (set-shape-strokes id strokes false)))] - (perf/end-measure "set-object") - {:thumbnails pending-thumbnails - :full pending-full - :font-pending-ids (if (some :callback text-content-pending) [id] [])})))) + ;; Browser-only: svg-raw markup (needs React) + workspace layout. + (when (and (some? content) (= type :svg-raw)) + (set-shape-svg-raw-content (get-static-markup shape))) + (set-shape-layout shape) + (set-layout-data shape) + (let [is-text? (= type :text) + text-content-pending (when is-text? (set-shape-text-content id content)) + pending-thumbnails (into [] (concat + text-content-pending + (when is-text? (set-shape-text-images id content true)) + (set-shape-fills id fills true) + (set-shape-strokes id strokes true))) + pending-full (into [] (concat + (when is-text? (set-shape-text-images id content false)) + (set-shape-fills id fills false) + (set-shape-strokes id strokes false)))] + (perf/end-measure "set-object") + {:thumbnails pending-thumbnails + :full pending-full + :font-pending-ids (if (some :callback text-content-pending) [id] [])}))))) (defn- update-text-layouts "Synchronously update text layouts for all shapes and send rect updates @@ -1649,50 +1659,55 @@ ;; Notify that shapes are loaded and tiles rebuilt (when on-shapes-ready (on-shapes-ready)) - ;; Show shapes immediately: end loading overlay + unblock rendering - (h/call wasm/internal-module "_end_loading") - (end-shapes-loading!) + (if-not (wasm/live?) + (do + (end-shapes-loading!) + (resolve nil)) + (do + ;; Show shapes immediately: end loading overlay + unblock rendering + (h/call wasm/internal-module "_end_loading") + (end-shapes-loading!) - ;; 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) + ;; 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) - ;; Text layouts must run after _end_loading (they - ;; depend on state that is only correct when loading - ;; is false). Each call touch_shape → touched_ids. - (let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)] - (when (seq text-ids) - (update-text-layouts text-ids))) - (if render-callback - (render-callback) - (request-render "set-objects-complete")) - (ug/dispatch! (ug/event "penpot:wasm:set-objects")) - (resolve nil) + ;; Text layouts must run after _end_loading (they + ;; depend on state that is only correct when loading + ;; is false). Each call touch_shape → touched_ids. + (let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)] + (when (seq text-ids) + (update-text-layouts text-ids))) + (if render-callback + (render-callback) + (request-render "set-objects-complete")) + (ug/dispatch! (ug/event "penpot:wasm:set-objects")) + (resolve nil) - ;; Kick off image fetches in the background. - ;; The promise is already resolved so these don't - ;; block the caller. - (let [pending-thumbnails (d/index-by :key :callback thumbnails-acc) - pending-full (d/index-by :key :callback full-acc)] - (when (or (seq pending-thumbnails) (seq pending-full)) - (->> (rx/concat - (->> (rx/from (vals pending-thumbnails)) - (rx/merge-map - (fn [callback] - (if (fn? callback) (callback) (rx/empty)))) - (rx/reduce conj [])) - (->> (rx/from (vals pending-full)) - (rx/mapcat - (fn [callback] - (if (fn? callback) (callback) (rx/empty)))) - (rx/reduce conj []))) - (rx/subs! - (fn [_] - (relayout-after-fonts! shapes font-pending-acc) - (request-render "images-loaded")) - noop-fn - noop-fn)))))))] + ;; Kick off image fetches in the background. + ;; The promise is already resolved so these don't + ;; block the caller. + (let [pending-thumbnails (d/index-by :key :callback thumbnails-acc) + pending-full (d/index-by :key :callback full-acc)] + (when (or (seq pending-thumbnails) (seq pending-full)) + (->> (rx/concat + (->> (rx/from (vals pending-thumbnails)) + (rx/merge-map + (fn [callback] + (if (fn? callback) (callback) (rx/empty)))) + (rx/reduce conj [])) + (->> (rx/from (vals pending-full)) + (rx/mapcat + (fn [callback] + (if (fn? callback) (callback) (rx/empty)))) + (rx/reduce conj []))) + (rx/subs! + (fn [_] + (relayout-after-fonts! shapes font-pending-acc) + (request-render "images-loaded")) + noop-fn + noop-fn)))))))))] (process-next-chunk 0 [] [] [])))))) @@ -1760,16 +1775,17 @@ :font-pending-ids (persistent! font-acc)}))] (perf/end-measure "set-objects") (when on-shapes-ready (on-shapes-ready)) - ;; 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")))))) + (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). @@ -1815,38 +1831,40 @@ ([objects render-callback] (set-objects objects render-callback nil false)) ([objects render-callback on-shapes-ready force-sync] - (perf/begin-measure "set-objects") - (let [shapes (shapes-in-tree-order objects) - total-shapes (count shapes)] - (if (or force-sync (< total-shapes ASYNC_THRESHOLD)) - (set-objects-sync shapes render-callback on-shapes-ready) - (do - (begin-shapes-loading!) - (h/call wasm/internal-module "_begin_loading") - ;; NOTE: to render a loading overlay in the future - ;; (when-not on-shapes-ready - ;; (h/call wasm/internal-module "_render_loading_overlay")) - (try - (-> (set-objects-async shapes render-callback on-shapes-ready) - (p/catch (fn [error] - (h/call wasm/internal-module "_end_loading") - (end-shapes-loading!) - (js/console.error "Async WASM shape loading failed" error)))) - (catch :default error - (h/call wasm/internal-module "_end_loading") - (end-shapes-loading!) - (js/console.error "Async WASM shape loading failed" error) - (throw error))) - nil))))) + (when (wasm/live?) + (perf/begin-measure "set-objects") + (let [shapes (shapes-in-tree-order objects) + total-shapes (count shapes)] + (if (or force-sync (< total-shapes ASYNC_THRESHOLD)) + (set-objects-sync shapes render-callback on-shapes-ready) + (do + (begin-shapes-loading!) + (h/call wasm/internal-module "_begin_loading") + ;; NOTE: to render a loading overlay in the future + ;; (when-not on-shapes-ready + ;; (h/call wasm/internal-module "_render_loading_overlay")) + (try + (-> (set-objects-async shapes render-callback on-shapes-ready) + (p/catch (fn [error] + (h/call wasm/internal-module "_end_loading") + (end-shapes-loading!) + (js/console.error "Async WASM shape loading failed" error)))) + (catch :default error + (h/call wasm/internal-module "_end_loading") + (end-shapes-loading!) + (js/console.error "Async WASM shape loading failed" error) + (throw error))) + nil)))))) (defn clear-focus-mode [] - (h/call wasm/internal-module "_clear_focus_mode") - (request-render "clear-focus-mode")) + (when (initialized?) + (h/call wasm/internal-module "_clear_focus_mode") + (request-render "clear-focus-mode"))) (defn set-focus-mode [entries] - (when-not ^boolean (empty? entries) + (when (and (initialized?) (not ^boolean (empty? entries))) (let [size (mem/get-alloc-size entries UUID-U8-SIZE) heap (mem/get-heap-u32) offset (mem/alloc->offset-32 size)] @@ -1882,7 +1900,7 @@ (defn set-structure-modifiers [entries] - (when-not ^boolean (empty? entries) + (when (and (initialized?) (not ^boolean (empty? entries))) (let [size (mem/get-alloc-size entries 44) offset (mem/alloc->offset-32 size) heapu32 (mem/get-heap-u32) @@ -1902,8 +1920,14 @@ (h/call wasm/internal-module "_set_structure_modifiers")))) (defn propagate-modifiers + "Propagates geometry modifiers through the WASM shape tree. + + Always returns a vector. When the context is not ready (lost / mid-reload) + or `entries` is empty, returns `[]` so callers never receive `nil` (which + would trip `set-modifiers`' vector assert)." [entries pixel-precision] - (when-not ^boolean (empty? entries) + (if-not (and (initialized?) (not ^boolean (empty? entries))) + [] (let [heapf32 (mem/get-heap-f32) heapu32 (mem/get-heap-u32) size (mem/get-alloc-size entries INPUT-MODIFIER-U8-SIZE) @@ -1937,7 +1961,7 @@ (defn get-selection-rect [entries] - (when-not ^boolean (empty? entries) + (when (and (initialized?) (not ^boolean (empty? entries))) (let [size (mem/get-alloc-size entries UUID-U8-SIZE) offset (mem/alloc->offset-32 size) heapu32 (mem/get-heap-u32) @@ -1956,9 +1980,10 @@ (defn set-canvas-background [background] - (let [rgba (sr-clr/hex->u32argb background 1)] - (h/call wasm/internal-module "_set_canvas_background" rgba) - (request-render "set-canvas-background"))) + (when (initialized?) + (let [rgba (sr-clr/hex->u32argb background 1)] + (h/call wasm/internal-module "_set_canvas_background" rgba) + (request-render "set-canvas-background")))) (defn clean-modifiers [] @@ -1984,38 +2009,40 @@ (defn set-modifiers [modifiers] + (when (initialized?) + ;; Assert only when we would touch WASM; callers may still build + ;; modifiers while the context is unavailable. + (assert (vector? modifiers) "expected a vector for `set-modifiers`") - ;; We need to ensure efficient operations - (assert (vector? modifiers) "expected a vector for `set-modifiers`") + (let [length (count modifiers)] + (when (pos? length) + (let [offset (mem/alloc->offset-32 (* MODIFIER-U8-SIZE length)) + heapu32 (mem/get-heap-u32) + heapf32 (mem/get-heap-f32)] - (let [length (count modifiers)] - (when (pos? length) - (let [offset (mem/alloc->offset-32 (* MODIFIER-U8-SIZE length)) - heapu32 (mem/get-heap-u32) - heapf32 (mem/get-heap-f32)] + (reduce (fn [offset [id transform]] + (-> offset + (mem.h32/write-uuid heapu32 id) + (mem.h32/write-matrix heapf32 transform))) + offset + modifiers) - (reduce (fn [offset [id transform]] - (-> offset - (mem.h32/write-uuid heapu32 id) - (mem.h32/write-matrix heapf32 transform))) - offset - modifiers) + (h/call wasm/internal-module "_set_modifiers") - (h/call wasm/internal-module "_set_modifiers") - - (request-render "set-modifiers"))))) + (request-render "set-modifiers")))))) (defn initialize-viewport [base-objects zoom vbox & {:keys [background background-opacity on-render on-shapes-ready force-sync] :or {background-opacity 1}}] - (let [rgba (when background (sr-clr/hex->u32argb background background-opacity)) - total-shapes (count (vals base-objects))] + (when (wasm/live?) + (let [rgba (when background (sr-clr/hex->u32argb background background-opacity)) + total-shapes (count (vals base-objects))] - (when rgba (h/call wasm/internal-module "_set_canvas_background" rgba)) - (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) - (h/call wasm/internal-module "_init_shapes_pool" total-shapes) - (set-objects base-objects on-render on-shapes-ready force-sync))) + (when rgba (h/call wasm/internal-module "_set_canvas_background" rgba)) + (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) + (h/call wasm/internal-module "_init_shapes_pool" total-shapes) + (set-objects base-objects on-render on-shapes-ready force-sync)))) (defn- run-resource-callbacks! [entries] @@ -2076,15 +2103,20 @@ "preserveDrawingBuffer" true}) (defn resize-viewbox + "Resizes the WASM viewbox. No-ops unless the GL context is live + (`wasm/live?`), so callers cannot hit `_resize_viewbox` during + teardown / pre-init after WebGL context loss. Allowed during reload + once re-init has succeeded." [width height] - (h/call wasm/internal-module "_resize_viewbox" width height)) + (when (wasm/live?) + (h/call wasm/internal-module "_resize_viewbox" width height))) (defn set-viewer-viewport! "Update viewer zoom/pan and rebuild the tile index (frame hops in the viewer). `vbox` must have at least `:x` and `:y` keys (design-space top-left corner)." [zoom vbox] - (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) (when (initialized?) + (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) (h/call wasm/internal-module "_set_view_end") (reset! view-interaction-active? false))) @@ -2101,18 +2133,20 @@ (defn set-render-options! "Updates WASM render options with a new DPR value." [new-dpr] - (h/call wasm/internal-module "_set_render_options" (debug-flags) new-dpr)) + (when (wasm/live?) + (h/call wasm/internal-module "_set_render_options" (debug-flags) new-dpr))) (defn resize-offscreen-canvas! "Resize a persistent OffscreenCanvas to new physical-pixel dimensions and update the WASM render surfaces accordingly (via `_resize_viewbox`). The design state (shape pool) is preserved so `set-objects` is not needed again." [canvas new-physical-w new-physical-h] - (let [dpr (get-dpr)] - (set! (.-width canvas) new-physical-w) - (set! (.-height canvas) new-physical-h) - (set-render-options! dpr) - (resize-viewbox (/ new-physical-w dpr) (/ new-physical-h dpr)))) + (when (wasm/live?) + (let [dpr (get-dpr)] + (set! (.-width canvas) new-physical-w) + (set! (.-height canvas) new-physical-h) + (set-render-options! dpr) + (resize-viewbox (/ new-physical-w dpr) (/ new-physical-h dpr))))) (defn- wasm-get-numeric-value [name] @@ -2152,11 +2186,12 @@ ([canvas] (resize-canvas! canvas (get-dpr))) ([canvas new-dpr] - (let [[css-w css-h] (canvas-css-size canvas new-dpr)] - (set! (.-width ^js canvas) (* new-dpr css-w)) - (set! (.-height ^js canvas) (* new-dpr css-h)) - (set-render-options! new-dpr) - (resize-viewbox css-w css-h)))) + (when (wasm/live?) + (let [[css-w css-h] (canvas-css-size canvas new-dpr)] + (set! (.-width ^js canvas) (* new-dpr css-w)) + (set! (.-height ^js canvas) (* new-dpr css-h)) + (set-render-options! new-dpr) + (resize-viewbox css-w css-h))))) (defn- on-webgl-context-lost [event] @@ -2179,11 +2214,18 @@ (defn- on-webgl-context-restored [event] (dom/prevent-default event) - (reset! wasm/context-lost? false) - (st/emit! (drw/context-restored)) + ;; Keep `context-lost?` / `:render-state :lost` until reload finishes. + ;; Emitting `context-restored` early flips the UI out of inspect/read-only, + ;; which can call into WASM while `reload-renderer!` is still between + ;; `clear-canvas` and re-init (panic). `reloading?` is owned by + ;; `reload-renderer!`. (let [payload (build-reload-payload)] (-> (reload-renderer! payload) (p/then (fn [_] + ;; `init-canvas-context` already cleared the lost atom; emit + ;; only after the full reload so layout/viewport sync runs + ;; against a live renderer with `wasm/ready?` true. + (st/emit! (drw/context-restored)) (listen-tiles-render-complete-once! end-context-loss-overlay!) (st/async-emit! (ntf/show {:content (tr "webgl.webgl-context-recovered.toast") @@ -2232,14 +2274,10 @@ (wasm-set-param-from-route-params-if-present :node_batch_threshold) (wasm-set-param-from-route-params-if-present :blur_downscale_threshold) - ;; Set browser and canvas size only after initialization + ;; Set browser after `_init`; mark live before sizing so the + ;; guarded `resize-*` helpers can run (they require `wasm/live?`, + ;; which is true here even while `reloading?` still blocks app callers). (h/call wasm/internal-module "_set_browser" browser) - ;; DOM canvas: keep drawing buffer synced to CSS size. - ;; OffscreenCanvas: no CSS size, so only sync WASM viewbox. - (if (and (number? (.-clientWidth ^js canvas)) - (pos? (.-clientWidth ^js canvas))) - (resize-canvas! canvas dpr) - (resize-viewbox css-w css-h)) ;; Add event listeners for WebGL context lost (set! wasm/canvas canvas) @@ -2248,7 +2286,14 @@ (.addEventListener canvas "webglcontextrestored" on-webgl-context-restored)) (start-canvas-snapshot-listener!) (reset! wasm/context-lost? false) - (set! wasm/context-initialized? true))) + (set! wasm/context-initialized? true) + + ;; DOM canvas: keep drawing buffer synced to CSS size. + ;; OffscreenCanvas: no CSS size, so only sync WASM viewbox. + (if (and (number? (.-clientWidth ^js canvas)) + (pos? (.-clientWidth ^js canvas))) + (resize-canvas! canvas dpr) + (resize-viewbox css-w css-h)))) context-init?))) @@ -2258,6 +2303,9 @@ ([{:keys [lose-browser-context?] :or {lose-browser-context? true}}] (try + ;; Release GPU objects while the context is still current. + (free-gpu-resources) + (set! wasm/context-initialized? false) ;; Cancel any pending animation frame to prevent race conditions. @@ -2268,6 +2316,7 @@ (reset! pending-render false) (reset! shapes-loading? false) (reset! deferred-render? false) + (reset! view-interaction-active? false) ;; Remove listener before losing/deleting context. (when wasm/canvas @@ -2276,7 +2325,6 @@ (stop-canvas-snapshot-listener!) (when (wasm/module-ready?) - (free-gpu-resources) (h/call wasm/internal-module "_clean_up")) ;; Ensure the WebGL context is properly disposed so browsers do not keep @@ -2325,6 +2373,7 @@ force-sync false} :as payload}] (ug/dispatch! (ug/event "penpot:wasm:reload-start")) + (reset! wasm/reloading? true) (let [fonts (derive-font-resources base-objects fonts)] (-> (p/resolved nil) ;; Keep teardown strict (`_clean_up` + deleteContext) but do not @@ -2361,58 +2410,68 @@ (sync-rulers-to-wasm! (rulers-state/from-store @st/state)) (request-render "reload-renderer") (ug/dispatch! (ug/event "penpot:wasm:reload-complete")) + (reset! wasm/reloading? false) payload)) (p/catch (fn [cause] (ug/dispatch! (ug/event "penpot:wasm:reload-failed")) + (reset! wasm/reloading? false) + (reset! wasm/context-lost? true) (clear-canvas) (p/rejected cause)))))) (defn show-grid [id] - (let [buffer (uuid/get-u32 id)] - (h/call wasm/internal-module "_show_grid" - (aget buffer 0) - (aget buffer 1) - (aget buffer 2) - (aget buffer 3))) - (request-render "show-grid")) + (when (initialized?) + (let [buffer (uuid/get-u32 id)] + (h/call wasm/internal-module "_show_grid" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3))) + (request-render "show-grid"))) (defn set-rulers-visible! [visible?] - (h/call wasm/internal-module "_set_rulers_visible" (if visible? 1 0))) + (when (wasm/live?) + (h/call wasm/internal-module "_set_rulers_visible" (if visible? 1 0)))) (defn set-rulers-frame-visible! [visible?] - (h/call wasm/internal-module "_set_rulers_frame_visible" (if visible? 1 0))) + (when (wasm/live?) + (h/call wasm/internal-module "_set_rulers_frame_visible" (if visible? 1 0)))) (defn set-rulers-offsets! [offset-x offset-y] - (h/call wasm/internal-module "_set_rulers_offsets" - (or offset-x 0) (or offset-y 0))) + (when (wasm/live?) + (h/call wasm/internal-module "_set_rulers_offsets" + (or offset-x 0) (or offset-y 0)))) (defn set-rulers-selection! [rect] - (if (some? rect) - (h/call wasm/internal-module "_set_rulers_selection" 1 - (or (:x rect) 0) (or (:y rect) 0) - (or (:width rect) 0) (or (:height rect) 0)) - (h/call wasm/internal-module "_set_rulers_selection" 0 0 0 0 0))) + (when (wasm/live?) + (if (some? rect) + (h/call wasm/internal-module "_set_rulers_selection" 1 + (or (:x rect) 0) (or (:y rect) 0) + (or (:width rect) 0) (or (:height rect) 0)) + (h/call wasm/internal-module "_set_rulers_selection" 0 0 0 0 0)))) (defn set-rulers-colors! "Push ruler chrome / accent colors as ARGB u32. Inputs are hex strings (e.g. \"#181818\"); call once on theme change." [bg-hex border-hex label-hex accent-hex] - (h/call wasm/internal-module "_set_rulers_colors" - (sr-clr/hex->u32argb bg-hex 1) - (sr-clr/hex->u32argb border-hex 1) - (sr-clr/hex->u32argb label-hex 1) - (sr-clr/hex->u32argb accent-hex 1))) + (when (wasm/live?) + (h/call wasm/internal-module "_set_rulers_colors" + (sr-clr/hex->u32argb bg-hex 1) + (sr-clr/hex->u32argb border-hex 1) + (sr-clr/hex->u32argb label-hex 1) + (sr-clr/hex->u32argb accent-hex 1)))) (defn clear-grid [] - (h/call wasm/internal-module "_hide_grid") - (request-render "clear-grid")) + (when (initialized?) + (h/call wasm/internal-module "_hide_grid") + (request-render "clear-grid"))) ;; Ruler guides ---------------------------------------------------------------- @@ -2421,13 +2480,14 @@ `guides` is the page `:guides` map (id -> guide); `objects` is the page objects map, used to resolve each guide's board clip range." [guides objects] - (let [size (sr/get-guides-byte-size guides) - offset (mem/alloc->offset-32 size) - heapu32 (mem/get-heap-u32) - heapf32 (mem/get-heap-f32)] - (sr/write-guides guides objects heapu32 heapf32 offset) - (h/call wasm/internal-module "_set_guides") - (request-render "set-guides"))) + (when (initialized?) + (let [size (sr/get-guides-byte-size guides) + offset (mem/alloc->offset-32 size) + heapu32 (mem/get-heap-u32) + heapf32 (mem/get-heap-f32)] + (sr/write-guides guides objects heapu32 heapf32 offset) + (h/call wasm/internal-module "_set_guides") + (request-render "set-guides")))) ;; Screen-space hit tolerance for ruler guides. Must match ;; `guide-active-area` in `app.main.ui.workspace.viewport.guides`. @@ -2437,41 +2497,45 @@ "Returns the serialized guide index at `position` (viewport coordinates), or -1 when no guide is within the hit tolerance." [position zoom] - (h/call wasm/internal-module "_find_guide_at" - (:x position) - (:y position) - zoom - guide-active-area)) + (if (initialized?) + (h/call wasm/internal-module "_find_guide_at" + (:x position) + (:y position) + zoom + guide-active-area) + -1)) (defn get-grid-coords [position] - (let [offset (h/call wasm/internal-module - "_get_grid_coords" - (get position :x) - (get position :y)) - heapi32 (mem/get-heap-i32) - row (aget heapi32 (mem/->offset-32 (+ offset 0))) - column (aget heapi32 (mem/->offset-32 (+ offset 4)))] - (mem/free) - [row column])) + (when (initialized?) + (let [offset (h/call wasm/internal-module + "_get_grid_coords" + (get position :x) + (get position :y)) + heapi32 (mem/get-heap-i32) + row (aget heapi32 (mem/->offset-32 (+ offset 0))) + column (aget heapi32 (mem/->offset-32 (+ offset 4)))] + (mem/free) + [row column]))) (defn shape-to-path [id] - (use-shape id) - (try - (let [offset (-> (h/call wasm/internal-module "_current_to_path") - (mem/->offset-32)) - heap (mem/get-heap-u32) - length (aget heap offset) - data (mem/slice heap - (+ offset 1) - (* length path.impl/SEGMENT-U32-SIZE)) - content (path/from-bytes data)] - (mem/free) - content) - (catch :default cause - (mem/free) - (throw cause)))) + (when (initialized?) + (use-shape id) + (try + (let [offset (-> (h/call wasm/internal-module "_current_to_path") + (mem/->offset-32)) + heap (mem/get-heap-u32) + length (aget heap offset) + data (mem/slice heap + (+ offset 1) + (* length path.impl/SEGMENT-U32-SIZE)) + content (path/from-bytes data)] + (mem/free) + content) + (catch :default cause + (mem/free) + (throw cause))))) (defn stroke-to-path "Converts a shape's stroke at the given index into a filled path. @@ -2480,25 +2544,26 @@ the segments: [even-odd flag][length] (the flat segment list can't encode the fill rule itself)." [id stroke-index] - (use-shape id) - (try - (let [offset (-> (h/call wasm/internal-module "_convert_stroke_to_path" stroke-index) - (mem/->offset-32)) - heap (mem/get-heap-u32) - even-odd? (not (zero? (aget heap offset))) - length (aget heap (inc offset))] - (if (pos? length) - (let [data (mem/slice heap - (+ offset 2) - (* length path.impl/SEGMENT-U32-SIZE)) - content (path/from-bytes data)] - (mem/free) - {:content content :even-odd? even-odd?}) - (do (mem/free) - nil))) - (catch :default cause - (mem/free) - (throw cause)))) + (when (initialized?) + (use-shape id) + (try + (let [offset (-> (h/call wasm/internal-module "_convert_stroke_to_path" stroke-index) + (mem/->offset-32)) + heap (mem/get-heap-u32) + even-odd? (not (zero? (aget heap offset))) + length (aget heap (inc offset))] + (if (pos? length) + (let [data (mem/slice heap + (+ offset 2) + (* length path.impl/SEGMENT-U32-SIZE)) + content (path/from-bytes data)] + (mem/free) + {:content content :even-odd? even-odd?}) + (do (mem/free) + nil))) + (catch :default cause + (mem/free) + (throw cause))))) (defn calculate-bool* [bool-type ids] @@ -2528,6 +2593,12 @@ (throw cause))))) (defn calculate-bool + "WASM implementation of `path/calc-bool-content`. + + It must always return content: the result is stored as the bool shape's `:content` + and persisted as a file change, so returning `nil` would destroy the path. + When the render context is unusable, we fall back to the pure CLJS calculation, + which needs no render context." [shape objects] ;; We need to be able to calculate the boolean data but we cannot @@ -2536,24 +2607,27 @@ ;; temporary and then we serialize the objects needed to calculate the ;; boolean object. ;; After the content is returned we discard that temporary context - (h/call wasm/internal-module "_start_temp_objects") + (if-not (initialized?) + (path/calc-bool-content shape objects) + (do + (h/call wasm/internal-module "_start_temp_objects") - (try - (let [bool-type (get shape :bool-type) - ids (get shape :shapes) - all-children - (->> ids - (mapcat #(cfh/get-children-with-self objects %)))] + (try + (let [bool-type (get shape :bool-type) + ids (get shape :shapes) + all-children + (->> ids + (mapcat #(cfh/get-children-with-self objects %)))] - (h/call wasm/internal-module "_init_shapes_pool" (count all-children)) - (run! set-object all-children) + (h/call wasm/internal-module "_init_shapes_pool" (count all-children)) + (run! set-object all-children) - (-> (calculate-bool* bool-type ids) - (path.impl/path-data))) - (finally - ;; Always restore the main shapes pool: leaving the temp pool - ;; active would make the next `_start_temp_objects` panic. - (h/call wasm/internal-module "_end_temp_objects")))) + (-> (calculate-bool* bool-type ids) + (path.impl/path-data))) + (finally + ;; Always restore the main shapes pool: leaving the temp pool + ;; active would make the next `_start_temp_objects` panic. + (h/call wasm/internal-module "_end_temp_objects")))))) (def POSITION-DATA-U8-SIZE 36) (def POSITION-DATA-U32-SIZE (/ POSITION-DATA-U8-SIZE 4)) @@ -2639,60 +2713,63 @@ :webp; jpeg is flattened onto white on the Rust side, since it has no alpha channel." [shape-id scale format] - (let [buffer (uuid/get-u32 shape-id) + (when (initialized?) + (let [buffer (uuid/get-u32 shape-id) - offset - (h/call wasm/internal-module "_render_shape_pixels" - (aget buffer 0) - (aget buffer 1) - (aget buffer 2) - (aget buffer 3) - scale - (sr/translate-raster-format format)) + offset + (h/call wasm/internal-module "_render_shape_pixels" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3) + scale + (sr/translate-raster-format format)) - heap (mem/get-heap-u8) - heapu32 (mem/get-heap-u32) - length (aget heapu32 (mem/->offset-32 offset)) - result (dr/read-image-bytes heap (+ offset 12) length)] - (mem/free) - result)) + heap (mem/get-heap-u8) + heapu32 (mem/get-heap-u32) + length (aget heapu32 (mem/->offset-32 offset)) + result (dr/read-image-bytes heap (+ offset 12) length)] + (mem/free) + result))) (defn get-shape-extrect [shape-id] - (let [buffer (uuid/get-u32 shape-id) - offset (h/call wasm/internal-module "_get_shape_extrect" - (aget buffer 0) - (aget buffer 1) - (aget buffer 2) - (aget buffer 3))] - (when (and (number? offset) (pos? offset)) - (let [heapf32 (mem/get-heap-f32) - base (mem/->offset-32 offset) - x (aget heapf32 base) - y (aget heapf32 (+ base 1)) - w (aget heapf32 (+ base 2)) - h (aget heapf32 (+ base 3))] - (mem/free) - {:x x :y y :width w :height h})))) + (when (initialized?) + (let [buffer (uuid/get-u32 shape-id) + offset (h/call wasm/internal-module "_get_shape_extrect" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3))] + (when (and (number? offset) (pos? offset)) + (let [heapf32 (mem/get-heap-f32) + base (mem/->offset-32 offset) + x (aget heapf32 base) + y (aget heapf32 (+ base 1)) + w (aget heapf32 (+ base 2)) + h (aget heapf32 (+ base 3))] + (mem/free) + {:x x :y y :width w :height h}))))) (defn render-shape-pdf [shape-id scale] - (let [buffer (uuid/get-u32 shape-id) + (when (initialized?) + (let [buffer (uuid/get-u32 shape-id) - offset - (h/call wasm/internal-module "_render_shape_pdf" - (aget buffer 0) - (aget buffer 1) - (aget buffer 2) - (aget buffer 3) - scale) + offset + (h/call wasm/internal-module "_render_shape_pdf" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3) + scale) - heap (mem/get-heap-u8) - heapu32 (mem/get-heap-u32) - length (aget heapu32 (mem/->offset-32 offset)) - result (dr/read-image-bytes heap (+ offset 4) length)] - (mem/free) - result)) + heap (mem/get-heap-u8) + heapu32 (mem/get-heap-u32) + length (aget heapu32 (mem/->offset-32 offset)) + result (dr/read-image-bytes heap (+ offset 4) length)] + (mem/free) + result))) (defn init-wasm-module [module] diff --git a/frontend/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs index d0b6c39f0f..c3d5a32a35 100644 --- a/frontend/src/app/render_wasm/api/fonts.cljs +++ b/frontend/src/app/render_wasm/api/fonts.cljs @@ -119,7 +119,7 @@ (defn update-text-layout [id] - (when wasm/context-initialized? + (when (wasm/live?) (let [shape-id-buffer (uuid/get-u32 id)] (h/call wasm/internal-module "_update_shape_text_layout_for" (aget shape-id-buffer 0) @@ -129,7 +129,7 @@ (defn force-update-text-layout [id] - (when wasm/context-initialized? + (when (wasm/live?) (let [shape-id-buffer (uuid/get-u32 id)] (h/call wasm/internal-module "_force_update_shape_text_layout_for" (aget shape-id-buffer 0) @@ -140,7 +140,7 @@ ;; IMPORTANT: Only TTF fonts can be stored. (defn- store-font-buffer [font-data font-array-buffer emoji? fallback?] - (when wasm/context-initialized? + (when (wasm/live?) (let [font-id-buffer (:family-id-buffer font-data) size (.-byteLength font-array-buffer) ptr (h/call wasm/internal-module "_alloc_bytes" size) diff --git a/frontend/src/app/render_wasm/api/shapes.cljs b/frontend/src/app/render_wasm/api/shapes.cljs index aa5e4fe9c6..02c2c91ee2 100644 --- a/frontend/src/app/render_wasm/api/shapes.cljs +++ b/frontend/src/app/render_wasm/api/shapes.cljs @@ -94,7 +94,7 @@ Returns nil." [shape] - (when wasm/context-initialized? + (when (wasm/live?) (let [id (dm/get-prop shape :id) parent-id (get shape :parent-id) shape-type (dm/get-prop shape :type) diff --git a/frontend/src/app/render_wasm/api/webgl.cljs b/frontend/src/app/render_wasm/api/webgl.cljs index 23b8d57810..fc7a3fe37f 100644 --- a/frontend/src/app/render_wasm/api/webgl.cljs +++ b/frontend/src/app/render_wasm/api/webgl.cljs @@ -14,7 +14,7 @@ (defn get-webgl-context "Gets the WebGL context from the WASM module" [] - (when wasm/context-initialized? + (when (wasm/live?) (let [gl-obj (unchecked-get wasm/internal-module "GL")] (when gl-obj ;; Get the current WebGL context from Emscripten diff --git a/frontend/src/app/render_wasm/shape.cljs b/frontend/src/app/render_wasm/shape.cljs index 403a1314ee..19e43e8eea 100644 --- a/frontend/src/app/render_wasm/shape.cljs +++ b/frontend/src/app/render_wasm/shape.cljs @@ -15,7 +15,6 @@ [app.main.refs :as refs] [app.render-wasm.api :as api] [app.render-wasm.svg-filters :as svg-filters] - [app.render-wasm.wasm :as wasm] [beicon.v2.core :as rx] [cljs.core :as c] [cuerdas.core :as str])) @@ -129,7 +128,7 @@ ;; The `set-wasm-attr!` can return a list of callbacks to be executed in a second pass. (defn- set-wasm-attr! [shape k] - (when wasm/context-initialized? + (when (api/initialized?) (let [shape (case k :svg-attrs (svg-filters/apply-svg-derived (assoc shape :svg-attrs (get shape :svg-attrs))) (:fills :blur :shadow) (svg-filters/apply-svg-derived shape) @@ -334,14 +333,15 @@ (defn process-shape-changes! [objects shape-changes] - (let [shape-changes - (->> shape-changes - ;; We don't need to update the model for shapes not in the current page - (filter (fn [[shape-id _]] (shape-in-current-page? shape-id))))] - (when (d/not-empty? shape-changes) - (->> (rx/from shape-changes) - (rx/mapcat (fn [[shape-id props]] (process-shape! (get objects shape-id) props))) - (rx/subs! #(api/request-render "set-wasm-attrs")))))) + (when (api/initialized?) + (let [shape-changes + (->> shape-changes + ;; We don't need to update the model for shapes not in the current page + (filter (fn [[shape-id _]] (shape-in-current-page? shape-id))))] + (when (d/not-empty? shape-changes) + (->> (rx/from shape-changes) + (rx/mapcat (fn [[shape-id props]] (process-shape! (get objects shape-id) props))) + (rx/subs! #(api/request-render "set-wasm-attrs"))))))) ;; `conj` empty set initialization (def conj* (fnil conj (d/ordered-set))) diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 0b346b4dd4..f434b024b9 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -190,7 +190,7 @@ (defn text-editor-focus [id] - (when wasm/context-initialized? + (when (wasm/ready?) (let [buffer (uuid/get-u32 id)] (when-not (h/call wasm/internal-module "_text_editor_focus" (aget buffer 0) @@ -202,44 +202,44 @@ (defn text-editor-set-cursor-from-offset "Sets caret position from shape relative coordinates" [{:keys [x y]}] - (when wasm/context-initialized? + (when (wasm/ready?) (h/call wasm/internal-module "_text_editor_set_cursor_from_offset" x y))) (defn text-editor-set-cursor-from-point "Sets caret position from screen (canvas) coordinates" [{:keys [x y]}] - (when wasm/context-initialized? + (when (wasm/ready?) (h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y))) (defn text-editor-toggle-overtype-mode "Toggles overtype mode" [] - (when wasm/context-initialized? + (when (wasm/ready?) (h/call wasm/internal-module "_text_editor_toggle_overtype_mode"))) (defn text-editor-pointer-down [{:keys [x y]}] - (when wasm/context-initialized? + (when (wasm/ready?) (h/call wasm/internal-module "_text_editor_pointer_down" x y))) (defn text-editor-pointer-move [{:keys [x y]}] - (when wasm/context-initialized? + (when (wasm/ready?) (h/call wasm/internal-module "_text_editor_pointer_move" x y))) (defn text-editor-pointer-up [{:keys [x y]}] - (when wasm/context-initialized? + (when (wasm/ready?) (h/call wasm/internal-module "_text_editor_pointer_up" x y))) (defn text-editor-update-blink [timestamp-ms] - (when wasm/context-initialized? + (when (wasm/ready?) (h/call wasm/internal-module "_text_editor_update_blink" timestamp-ms))) (defn text-editor-render-overlay [] - (when wasm/context-initialized? + (when (wasm/ready?) (h/call wasm/internal-module "_text_editor_render_overlay"))) (defn text-editor-render-caret @@ -252,7 +252,7 @@ (defn text-editor-poll-event [] - (when wasm/context-initialized? + (when (wasm/ready?) (let [res (h/call wasm/internal-module "_text_editor_poll_event")] res))) @@ -323,7 +323,7 @@ (defn text-editor-get-current-styles [] - (when wasm/context-initialized? + (when (wasm/ready?) (let [ptr (h/call wasm/internal-module "_text_editor_get_current_styles")] (when (and ptr (not (zero? ptr))) (let [heap-u8 (mem/get-heap-u8) @@ -411,7 +411,7 @@ (defn text-editor-encode-text-pre [text] (when (and (not (empty? text)) - wasm/context-initialized?) + (wasm/ready?)) (let [encoder (js/TextEncoder.) buf (.encode encoder text) heapu8 (mem/get-heap-u8) @@ -422,31 +422,31 @@ (defn text-editor-encode-text-post [text] (when (and (not (empty? text)) - wasm/context-initialized?) + (wasm/ready?)) (mem/free))) (defn text-editor-composition-start [] - (when wasm/context-initialized? + (when (wasm/ready?) (h/call wasm/internal-module "_text_editor_composition_start"))) (defn text-editor-composition-update [text] - (when wasm/context-initialized? + (when (wasm/ready?) (text-editor-encode-text-pre text) (h/call wasm/internal-module "_text_editor_composition_update") (text-editor-encode-text-post text))) (defn text-editor-composition-end [text] - (when wasm/context-initialized? + (when (wasm/ready?) (text-editor-encode-text-pre text) (h/call wasm/internal-module "_text_editor_composition_end") (text-editor-encode-text-post text))) (defn text-editor-insert-text [text] - (when wasm/context-initialized? + (when (wasm/ready?) (text-editor-encode-text-pre text) (h/call wasm/internal-module "_text_editor_insert_text") (text-editor-encode-text-post text))) @@ -455,62 +455,62 @@ ([] (text-editor-delete-backward false)) ([word-boundary] - (when wasm/context-initialized? + (when (wasm/ready?) (h/call wasm/internal-module "_text_editor_delete_backward" word-boundary)))) (defn text-editor-delete-forward ([] (text-editor-delete-forward false)) ([word-boundary] - (when wasm/context-initialized? + (when (wasm/ready?) (h/call wasm/internal-module "_text_editor_delete_forward" word-boundary)))) (defn text-editor-insert-paragraph [] - (when wasm/context-initialized? + (when (wasm/ready?) (h/call wasm/internal-module "_text_editor_insert_paragraph"))) (defn text-editor-move-cursor [direction word-boundary extend-selection] - (when wasm/context-initialized? + (when (wasm/ready?) (h/call wasm/internal-module "_text_editor_move_cursor" direction word-boundary (if extend-selection 1 0)))) (defn text-editor-select-all [] - (when wasm/context-initialized? + (when (wasm/ready?) (h/call wasm/internal-module "_text_editor_select_all"))) (defn text-editor-select-word-boundary [{:keys [x y]}] - (when wasm/context-initialized? + (when (wasm/ready?) (h/call wasm/internal-module "_text_editor_select_word_boundary" x y))) (defn text-editor-blur [] - (when wasm/context-initialized? + (when (wasm/ready?) (when-not (h/call wasm/internal-module "_text_editor_blur") (throw (js/Error. "TextEditor blur failed"))))) (defn text-editor-dispose [] - (when wasm/context-initialized? + (when (wasm/ready?) (h/call wasm/internal-module "_text_editor_dispose"))) (defn text-editor-has-focus? ([id] - (when wasm/context-initialized? + (when (wasm/ready?) (not (zero? (h/call wasm/internal-module "_text_editor_has_focus_with_id" id))))) ([] - (when wasm/context-initialized? + (when (wasm/ready?) (not (zero? (h/call wasm/internal-module "_text_editor_has_focus")))))) (defn text-editor-has-selection? ([] - (when wasm/context-initialized? + (when (wasm/ready?) (not (zero? (h/call wasm/internal-module "_text_editor_has_selection")))))) (defn text-editor-export-content [] - (when wasm/context-initialized? + (when (wasm/ready?) (let [ptr (h/call wasm/internal-module "_text_editor_export_content")] (when (and ptr (not (zero? ptr))) (let [json-str (mem/read-string ptr)] @@ -520,7 +520,7 @@ (defn text-editor-export-selection "Export only the currently selected text as plain text from the WASM editor. Requires WASM support (_text_editor_export_selection)." [] - (when wasm/context-initialized? + (when (wasm/ready?) (let [ptr (h/call wasm/internal-module "_text_editor_export_selection")] (when (and ptr (not (zero? ptr))) (let [text (mem/read-string ptr)] @@ -529,7 +529,7 @@ (defn text-editor-get-active-shape-id [] - (when wasm/context-initialized? + (when (wasm/ready?) (try (let [byte-offset (mem/alloc 16) u32-offset (mem/->offset-32 byte-offset) @@ -549,7 +549,7 @@ (defn text-editor-get-selection [] - (when wasm/context-initialized? + (when (wasm/ready?) (let [byte-offset (mem/alloc 16) u32-offset (mem/->offset-32 byte-offset) heap (mem/get-heap-u32) @@ -643,7 +643,7 @@ shape-id and the fully merged content map ready for v2-update-text-shape-content." [] - (when (and wasm/context-initialized? (text-editor-has-focus?)) + (when (and (wasm/ready?) (text-editor-has-focus?)) (let [shape-id (text-editor-get-active-shape-id) new-texts (text-editor-export-content)] (when (and shape-id new-texts) @@ -707,7 +707,7 @@ (defn apply-styles-to-selection [attrs use-shape-fn set-shape-text-content-fn] - (when wasm/context-initialized? + (when (wasm/ready?) (let [shape-id (text-editor-get-active-shape-id) selection (text-editor-get-selection)] diff --git a/frontend/src/app/render_wasm/wasm.cljs b/frontend/src/app/render_wasm/wasm.cljs index dee660408a..933b200530 100644 --- a/frontend/src/app/render_wasm/wasm.cljs +++ b/frontend/src/app/render_wasm/wasm.cljs @@ -28,6 +28,11 @@ (defonce context-initialized? false) (defonce context-lost? (atom false)) +;; True while `reload-renderer!` is reconstructing the GL/WASM pipeline after a +;; WebGL context restore (or an explicit reload). External app callers must not +;; touch WASM until this clears (`ready?`); reload-internal ops use `live?`. +(defonce reloading? (atom false)) + ;; When we're rendering in a sync way we want to stop the asynchrous `request-render` (defonce disable-request-render? (atom false)) @@ -35,15 +40,30 @@ [] (and internal-module (fn? (unchecked-get internal-module "_init")))) +(defn live? + "GL/WASM context exists and is not marked lost. True during reload after re-init." + [] + (and context-initialized? (not @context-lost?))) + +(defn ready? + "Safe for normal application WASM calls (not lost, not mid-reload)." + [] + (and (live?) (not @reloading?))) + (defn reset-context-state! + "Clears canvas/GL handles and marks the context uninitialized. + + Intentionally does **not** clear `context-lost?` or `reloading?`. During a + context-restore reload, `clear-canvas` runs while recovery is still in + progress; clearing those flags here would reopen a window where callers think + WebGL is ready before re-init finishes." [] (set! internal-frame-id nil) (set! canvas nil) (set! canvas-snapshot nil) (set! gl-context-handle nil) (set! gl-context nil) - (set! context-initialized? false) - (reset! context-lost? false)) + (set! context-initialized? false)) (defonce serializers #js {:raster-format shared/RasterFormat