mirror of
https://github.com/penpot/penpot.git
synced 2026-02-17 01:54:43 -05:00
Compare commits
2 Commits
develop
...
elenatorro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af6cf17435 | ||
|
|
f2d09a6140 |
@@ -8,6 +8,7 @@
|
||||
"A WASM based render API"
|
||||
(:require
|
||||
["react-dom/server" :as rds]
|
||||
[app.common.buffer :as buf]
|
||||
[app.common.data :as d]
|
||||
[app.common.data.macros :as dm]
|
||||
[app.common.exceptions :as ex]
|
||||
@@ -495,7 +496,23 @@
|
||||
(mapcat get-fill-images)
|
||||
(map #(process-fill-image shape-id % thumbnail?))))))
|
||||
|
||||
(defn- pending-fill-images
|
||||
"Return pending image fetches for fill image-ids without re-sending data to WASM."
|
||||
[shape-id image-ids thumbnail?]
|
||||
(keep (fn [id]
|
||||
(let [buffer (uuid/get-u32 id)
|
||||
cached-image? (h/call wasm/internal-module "_is_image_cached"
|
||||
(aget buffer 0)
|
||||
(aget buffer 1)
|
||||
(aget buffer 2)
|
||||
(aget buffer 3)
|
||||
thumbnail?)]
|
||||
(when (zero? cached-image?)
|
||||
(fetch-image shape-id id thumbnail?))))
|
||||
image-ids))
|
||||
|
||||
(defn set-shape-fills
|
||||
"Write fills to WASM and return pending image fetches."
|
||||
[shape-id fills thumbnail?]
|
||||
(if (empty? fills)
|
||||
(h/call wasm/internal-module "_clear_shape_fills")
|
||||
@@ -510,24 +527,73 @@
|
||||
(h/call wasm/internal-module "_set_shape_fills")
|
||||
|
||||
;; load images for image fills if not cached
|
||||
(keep (fn [id]
|
||||
(let [buffer (uuid/get-u32 id)
|
||||
cached-image? (h/call wasm/internal-module "_is_image_cached"
|
||||
(aget buffer 0)
|
||||
(aget buffer 1)
|
||||
(aget buffer 2)
|
||||
(aget buffer 3)
|
||||
thumbnail?)]
|
||||
(when (zero? cached-image?)
|
||||
(fetch-image shape-id id thumbnail?))))
|
||||
(pending-fill-images shape-id (types.fills/get-image-ids fills) thumbnail?))))
|
||||
|
||||
(types.fills/get-image-ids fills)))))
|
||||
(defn set-shape-fills-data
|
||||
"Write fills data to WASM (no image fetching). Returns the coerced fills for later image checks."
|
||||
[fills]
|
||||
(if (empty? fills)
|
||||
(do (h/call wasm/internal-module "_clear_shape_fills") nil)
|
||||
(let [fills (types.fills/coerce fills)
|
||||
offset (mem/alloc->offset-32 (types.fills/get-byte-size fills))
|
||||
heap (mem/get-heap-u32)]
|
||||
(types.fills/write-to fills heap offset)
|
||||
(h/call wasm/internal-module "_set_shape_fills")
|
||||
fills)))
|
||||
|
||||
(def ^:const STROKE-ENTRY-U8-SIZE
|
||||
"Per-stroke binary entry: 4 bytes header (kind, style, cap-start, cap-end)
|
||||
+ 4 bytes width (f32) + FILL-U8-SIZE bytes fill data."
|
||||
(+ 8 types.fills.impl/FILL-U8-SIZE))
|
||||
|
||||
(defn- translate-stroke-kind
|
||||
"Translate stroke alignment keyword to binary kind value.
|
||||
Must match RawStrokeKind repr(u8) in strokes.rs."
|
||||
[align]
|
||||
(case align
|
||||
:inner 1
|
||||
:outer 2
|
||||
0)) ;; center (default)
|
||||
|
||||
(defn- pending-stroke-images
|
||||
"Return pending image fetches for stroke images without re-sending data to WASM."
|
||||
[shape-id strokes thumbnail?]
|
||||
(into []
|
||||
(keep (fn [stroke]
|
||||
(when-let [image (:stroke-image stroke)]
|
||||
(let [image-id (get image :id)
|
||||
buffer (uuid/get-u32 image-id)
|
||||
cached-image? (h/call wasm/internal-module "_is_image_cached"
|
||||
(aget buffer 0) (aget buffer 1)
|
||||
(aget buffer 2) (aget buffer 3)
|
||||
thumbnail?)]
|
||||
(when (zero? cached-image?)
|
||||
(fetch-image shape-id image-id thumbnail?))))))
|
||||
strokes))
|
||||
|
||||
(defn set-shape-strokes
|
||||
"Write strokes to WASM and return pending image fetches."
|
||||
[shape-id strokes thumbnail?]
|
||||
(h/call wasm/internal-module "_clear_shape_strokes")
|
||||
(keep (fn [stroke]
|
||||
(let [opacity (or (:stroke-opacity stroke) 1.0)
|
||||
(if (empty? strokes)
|
||||
(h/call wasm/internal-module "_clear_shape_strokes")
|
||||
(let [num-strokes (count strokes)
|
||||
buf-size (+ 4 (* num-strokes STROKE-ENTRY-U8-SIZE))
|
||||
base-ptr (mem/alloc buf-size)
|
||||
heap (mem/get-heap-u8)
|
||||
dview (js/DataView. (.-buffer heap))]
|
||||
|
||||
;; Header: stroke count at byte 0
|
||||
(buf/write-byte dview base-ptr num-strokes)
|
||||
|
||||
;; Write each stroke entry into the shared buffer
|
||||
(loop [i 0
|
||||
remaining (seq strokes)]
|
||||
(when remaining
|
||||
(let [stroke (first remaining)
|
||||
entry-offset (+ base-ptr 4 (* i STROKE-ENTRY-U8-SIZE))
|
||||
fill-offset (+ entry-offset 8)
|
||||
|
||||
opacity (or (:stroke-opacity stroke) 1.0)
|
||||
color (:stroke-color stroke)
|
||||
gradient (:stroke-color-gradient stroke)
|
||||
image (:stroke-image stroke)
|
||||
@@ -536,37 +602,89 @@
|
||||
style (-> stroke :stroke-style sr/translate-stroke-style)
|
||||
cap-start (-> stroke :stroke-cap-start sr/translate-stroke-cap)
|
||||
cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap)
|
||||
offset (mem/alloc types.fills.impl/FILL-U8-SIZE)
|
||||
heap (mem/get-heap-u8)
|
||||
dview (js/DataView. (.-buffer heap))]
|
||||
(case align
|
||||
:inner (h/call wasm/internal-module "_add_shape_inner_stroke" width style cap-start cap-end)
|
||||
:outer (h/call wasm/internal-module "_add_shape_outer_stroke" width style cap-start cap-end)
|
||||
(h/call wasm/internal-module "_add_shape_center_stroke" width style cap-start cap-end))
|
||||
kind (translate-stroke-kind align)]
|
||||
|
||||
;; Stroke header: kind(u8), style(u8), cap-start(u8), cap-end(u8), width(f32)
|
||||
(buf/write-byte dview (+ entry-offset 0) kind)
|
||||
(buf/write-byte dview (+ entry-offset 1) style)
|
||||
(buf/write-byte dview (+ entry-offset 2) cap-start)
|
||||
(buf/write-byte dview (+ entry-offset 3) cap-end)
|
||||
(buf/write-float dview (+ entry-offset 4) width)
|
||||
|
||||
;; Fill data (written at fill-offset inside the same buffer)
|
||||
(cond
|
||||
(some? gradient)
|
||||
(do
|
||||
(types.fills.impl/write-gradient-fill offset dview opacity gradient)
|
||||
(h/call wasm/internal-module "_add_shape_stroke_fill"))
|
||||
(types.fills.impl/write-gradient-fill fill-offset dview opacity gradient)
|
||||
|
||||
(some? image)
|
||||
(let [image-id (get image :id)
|
||||
buffer (uuid/get-u32 image-id)
|
||||
cached-image? (h/call wasm/internal-module "_is_image_cached"
|
||||
(aget buffer 0) (aget buffer 1)
|
||||
(aget buffer 2) (aget buffer 3)
|
||||
thumbnail?)]
|
||||
(types.fills.impl/write-image-fill offset dview opacity image)
|
||||
(h/call wasm/internal-module "_add_shape_stroke_fill")
|
||||
(when (== cached-image? 0)
|
||||
(fetch-image shape-id image-id thumbnail?)))
|
||||
(types.fills.impl/write-image-fill fill-offset dview opacity image)
|
||||
|
||||
(some? color)
|
||||
(do
|
||||
(types.fills.impl/write-solid-fill offset dview opacity color)
|
||||
(h/call wasm/internal-module "_add_shape_stroke_fill")))))
|
||||
strokes))
|
||||
(types.fills.impl/write-solid-fill fill-offset dview opacity color))
|
||||
|
||||
(recur (inc i) (next remaining)))))
|
||||
|
||||
;; Single WASM call to set all strokes at once
|
||||
(h/call wasm/internal-module "_set_shape_strokes")
|
||||
|
||||
;; Check image cache and fetch uncached images
|
||||
(pending-stroke-images shape-id strokes thumbnail?))))
|
||||
|
||||
(defn set-shape-strokes-data
|
||||
"Write strokes data to WASM (no image fetching)."
|
||||
[strokes]
|
||||
(if (empty? strokes)
|
||||
(h/call wasm/internal-module "_clear_shape_strokes")
|
||||
(let [num-strokes (count strokes)
|
||||
buf-size (+ 4 (* num-strokes STROKE-ENTRY-U8-SIZE))
|
||||
base-ptr (mem/alloc buf-size)
|
||||
heap (mem/get-heap-u8)
|
||||
dview (js/DataView. (.-buffer heap))]
|
||||
|
||||
;; Header: stroke count at byte 0
|
||||
(buf/write-byte dview base-ptr num-strokes)
|
||||
|
||||
;; Write each stroke entry into the shared buffer
|
||||
(loop [i 0
|
||||
remaining (seq strokes)]
|
||||
(when remaining
|
||||
(let [stroke (first remaining)
|
||||
entry-offset (+ base-ptr 4 (* i STROKE-ENTRY-U8-SIZE))
|
||||
fill-offset (+ entry-offset 8)
|
||||
|
||||
opacity (or (:stroke-opacity stroke) 1.0)
|
||||
color (:stroke-color stroke)
|
||||
gradient (:stroke-color-gradient stroke)
|
||||
image (:stroke-image stroke)
|
||||
width (:stroke-width stroke)
|
||||
align (:stroke-alignment stroke)
|
||||
style (-> stroke :stroke-style sr/translate-stroke-style)
|
||||
cap-start (-> stroke :stroke-cap-start sr/translate-stroke-cap)
|
||||
cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap)
|
||||
kind (translate-stroke-kind align)]
|
||||
|
||||
;; Stroke header
|
||||
(buf/write-byte dview (+ entry-offset 0) kind)
|
||||
(buf/write-byte dview (+ entry-offset 1) style)
|
||||
(buf/write-byte dview (+ entry-offset 2) cap-start)
|
||||
(buf/write-byte dview (+ entry-offset 3) cap-end)
|
||||
(buf/write-float dview (+ entry-offset 4) width)
|
||||
|
||||
;; Fill data
|
||||
(cond
|
||||
(some? gradient)
|
||||
(types.fills.impl/write-gradient-fill fill-offset dview opacity gradient)
|
||||
|
||||
(some? image)
|
||||
(types.fills.impl/write-image-fill fill-offset dview opacity image)
|
||||
|
||||
(some? color)
|
||||
(types.fills.impl/write-solid-fill fill-offset dview opacity color))
|
||||
|
||||
(recur (inc i) (next remaining)))))
|
||||
|
||||
;; Single WASM call to set all strokes at once
|
||||
(h/call wasm/internal-module "_set_shape_strokes"))))
|
||||
|
||||
(defn set-shape-svg-attrs
|
||||
[attrs]
|
||||
@@ -863,29 +981,39 @@
|
||||
(when (ctl/grid-layout? shape)
|
||||
(set-grid-layout shape)))
|
||||
|
||||
;; Shadow binary layout constants (must match Rust RawShadowData):
|
||||
;; 24 bytes per shadow: color(u32) + blur(f32) + spread(f32) + x(f32) + y(f32) + style(u8) + hidden(u8) + padding(2)
|
||||
(def ^:const SHADOW-ENTRY-SIZE 24)
|
||||
(def ^:const SHADOW-HEADER-SIZE 4)
|
||||
|
||||
(defn set-shape-shadows
|
||||
[shadows]
|
||||
(h/call wasm/internal-module "_clear_shape_shadows")
|
||||
|
||||
(run! (fn [shadow]
|
||||
(let [color (get shadow :color)
|
||||
blur (get shadow :blur)
|
||||
(if (or (nil? shadows) (empty? shadows))
|
||||
(h/call wasm/internal-module "_clear_shape_shadows")
|
||||
(let [n (count shadows)
|
||||
size (+ SHADOW-HEADER-SIZE (* n SHADOW-ENTRY-SIZE))
|
||||
offset (mem/alloc size)
|
||||
heap (mem/get-heap-u8)
|
||||
dview (js/DataView. (.-buffer heap))]
|
||||
;; Header: shadow count in first byte
|
||||
(.setUint8 dview offset n)
|
||||
;; Write each shadow entry
|
||||
(loop [i 0, shadows (seq shadows)]
|
||||
(when shadows
|
||||
(let [shadow (first shadows)
|
||||
base (+ offset SHADOW-HEADER-SIZE (* i SHADOW-ENTRY-SIZE))
|
||||
color (get shadow :color)
|
||||
rgba (sr-clr/hex->u32argb (get color :color)
|
||||
(get color :opacity))
|
||||
hidden (get shadow :hidden)
|
||||
x (get shadow :offset-x)
|
||||
y (get shadow :offset-y)
|
||||
spread (get shadow :spread)
|
||||
style (get shadow :style)]
|
||||
(h/call wasm/internal-module "_add_shape_shadow"
|
||||
rgba
|
||||
blur
|
||||
spread
|
||||
x
|
||||
y
|
||||
(sr/translate-shadow-style style)
|
||||
hidden)))
|
||||
shadows))
|
||||
(get color :opacity))]
|
||||
(.setUint32 dview base rgba true)
|
||||
(.setFloat32 dview (+ base 4) (get shadow :blur) true)
|
||||
(.setFloat32 dview (+ base 8) (get shadow :spread) true)
|
||||
(.setFloat32 dview (+ base 12) (get shadow :offset-x) true)
|
||||
(.setFloat32 dview (+ base 16) (get shadow :offset-y) true)
|
||||
(.setUint8 dview (+ base 20) (sr/translate-shadow-style (get shadow :style)))
|
||||
(.setUint8 dview (+ base 21) (if (get shadow :hidden) 1 0))
|
||||
(recur (inc i) (next shadows)))))
|
||||
(h/call wasm/internal-module "_set_shape_shadows"))))
|
||||
|
||||
(defn fonts-from-text-content [content fallback-fonts-only?]
|
||||
(let [paragraph-set (first (get content :children))
|
||||
@@ -962,6 +1090,12 @@
|
||||
;; to prevent errors when navigating quickly
|
||||
(when wasm/context-initialized?
|
||||
(perf/begin-measure "render-finish")
|
||||
;; set_view_end clears fast mode, enters settling mode,
|
||||
;; rebuilds tiles and syncs the viewbox. The settling render
|
||||
;; uses reduced blur quality so visible tiles appear quickly.
|
||||
;; When all settling tiles finish, the Rust side automatically
|
||||
;; transitions to full quality (clears settling, invalidates
|
||||
;; caches, starts a new render loop) — no JS round-trip needed.
|
||||
(h/call wasm/internal-module "_set_view_end")
|
||||
(render ts)
|
||||
(perf/end-measure "render-finish")))]
|
||||
@@ -1043,15 +1177,21 @@
|
||||
(set-shape-layout shape)
|
||||
(set-layout-data shape)
|
||||
|
||||
(let [pending_thumbnails (into [] (concat
|
||||
;; Write fills & strokes data to WASM once (identical for both resolutions)
|
||||
(let [coerced-fills (set-shape-fills-data fills)
|
||||
_ (set-shape-strokes-data strokes)
|
||||
fill-image-ids (when coerced-fills (types.fills/get-image-ids coerced-fills))
|
||||
|
||||
;; Collect pending image fetches per resolution (only the image cache check differs)
|
||||
pending_thumbnails (into [] (concat
|
||||
(set-shape-text-content id content)
|
||||
(set-shape-text-images id content true)
|
||||
(set-shape-fills id fills true)
|
||||
(set-shape-strokes id strokes true)))
|
||||
(pending-fill-images id fill-image-ids true)
|
||||
(pending-stroke-images id strokes true)))
|
||||
pending_full (into [] (concat
|
||||
(set-shape-text-images id content false)
|
||||
(set-shape-fills id fills false)
|
||||
(set-shape-strokes id strokes false)))]
|
||||
(pending-fill-images id fill-image-ids false)
|
||||
(pending-stroke-images id strokes false)))]
|
||||
(perf/end-measure "set-object")
|
||||
{:thumbnails pending_thumbnails
|
||||
:full pending_full})))
|
||||
|
||||
@@ -36,10 +36,14 @@
|
||||
;; | 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) |
|
||||
;; | 104 | 1 | blur_hidden | u8 (0 = visible, !0 = hidden) |
|
||||
;; | 105 | 1 | blur_type | u8 (0 = layer-blur) |
|
||||
;; | 106 | 2 | blur_pad | - |
|
||||
;; | 108 | 4 | blur_value | f32 LE (0.0 = no blur) |
|
||||
;; |--------|------|--------------|-----------------------------------|
|
||||
;; | Total | 104 | | |
|
||||
;; | Total | 112 | | |
|
||||
|
||||
(def ^:const BASE-PROPS-SIZE 104)
|
||||
(def ^:const BASE-PROPS-SIZE 112)
|
||||
(def ^:const FLAG-CLIP-CONTENT 0x01)
|
||||
(def ^:const FLAG-HIDDEN 0x02)
|
||||
(def ^:const CONSTRAINT-NONE 0xFF)
|
||||
@@ -188,6 +192,18 @@
|
||||
(.setFloat32 dview (+ offset 96) r3 true)
|
||||
(.setFloat32 dview (+ offset 100) r4 true)
|
||||
|
||||
;; Write blur fields (offset 104..111)
|
||||
;; Layout in Rust: u8 hidden, u8 blur_type, 2 bytes padding, f32 value
|
||||
(let [blur (get shape :blur)]
|
||||
(when (some? blur)
|
||||
(let [bt (-> blur :type sr/translate-blur-type)
|
||||
hidden (if (:hidden blur) 1 0)
|
||||
value (d/nilv (:value blur) 0.0)]
|
||||
(.setUint8 dview (+ offset 104) hidden)
|
||||
(.setUint8 dview (+ offset 105) bt)
|
||||
;; padding bytes at 106,107 left as zero
|
||||
(.setFloat32 dview (+ offset 108) value true))))
|
||||
|
||||
(h/call wasm/internal-module "_set_shape_base_props")
|
||||
|
||||
nil)))
|
||||
|
||||
@@ -642,13 +642,15 @@ export class SelectionController extends EventTarget {
|
||||
} else {
|
||||
this.#anchorNode = anchorNode;
|
||||
this.#anchorOffset = anchorOffset;
|
||||
if (anchorNode === focusNode) {
|
||||
this.#focusNode = this.#anchorNode;
|
||||
this.#focusOffset = this.#anchorOffset;
|
||||
this.#focusNode = focusNode;
|
||||
this.#focusOffset = focusOffset;
|
||||
// setPosition() collapses the selection to a single caret. We must only use it
|
||||
// when anchorOffset === focusOffset. When both points are in the same node but
|
||||
// offsets differ (e.g. selecting "hola" in "hola adios"), we need setBaseAndExtent()
|
||||
// to preserve the range so we don't incorrectly collapse ranges and lose the selection.
|
||||
if (anchorNode === focusNode && anchorOffset === focusOffset) {
|
||||
this.#selection.setPosition(anchorNode, anchorOffset);
|
||||
} else {
|
||||
this.#focusNode = focusNode;
|
||||
this.#focusOffset = focusOffset;
|
||||
this.#selection.setBaseAndExtent(
|
||||
anchorNode,
|
||||
anchorOffset,
|
||||
|
||||
@@ -263,6 +263,9 @@ pub extern "C" fn set_view_start() {
|
||||
}
|
||||
performance::begin_measure!("set_view_start");
|
||||
state.render_state.options.set_fast_mode(true);
|
||||
// Clear settling mode if a new pan/zoom starts before the
|
||||
// settling→full quality transition completes.
|
||||
state.render_state.options.set_settling_mode(false);
|
||||
performance::end_measure!("set_view_start");
|
||||
});
|
||||
}
|
||||
@@ -273,6 +276,10 @@ pub extern "C" fn set_view_end() {
|
||||
let _end_start = performance::begin_timed_log!("set_view_end");
|
||||
performance::begin_measure!("set_view_end");
|
||||
state.render_state.options.set_fast_mode(false);
|
||||
// Enter settling mode: the first render pass will use reduced blur
|
||||
// quality so visible tiles appear quickly. The frontend should call
|
||||
// `settle_view_end` after this render to schedule a full-quality pass.
|
||||
state.render_state.options.set_settling_mode(true);
|
||||
state.render_state.cancel_animation_frame();
|
||||
|
||||
// Update tile_viewbox first so that get_tiles_for_shape uses the correct interest area
|
||||
@@ -306,6 +313,24 @@ pub extern "C" fn set_view_end() {
|
||||
});
|
||||
}
|
||||
|
||||
/// Called by the frontend after the settling render pass completes.
|
||||
/// Turns off settling mode, invalidates all tile caches so that the
|
||||
/// next render produces full-quality output.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn settle_view_end() {
|
||||
with_state_mut!(state, {
|
||||
if state.render_state.options.is_settling_mode() {
|
||||
performance::begin_measure!("settle_view_end");
|
||||
state.render_state.options.set_settling_mode(false);
|
||||
// Soft-invalidate cached tiles so the next render pass
|
||||
// re-draws them at full quality while keeping settling
|
||||
// textures as visual fallback to avoid popping.
|
||||
state.render_state.surfaces.soft_clear_tiles();
|
||||
performance::end_measure!("settle_view_end");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn clear_focus_mode() {
|
||||
with_state_mut!(state, {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
pub const DEBUG_VISIBLE: u32 = 0x01;
|
||||
pub const PROFILE_REBUILD_TILES: u32 = 0x02;
|
||||
pub const FAST_MODE: u32 = 0x04;
|
||||
/// Settling mode: transitional state between fast mode and full quality.
|
||||
/// Renders blur at reduced quality for a fast first pass after pan/zoom ends,
|
||||
/// then a full-quality re-render is scheduled automatically.
|
||||
pub const SETTLING_MODE: u32 = 0x08;
|
||||
|
||||
@@ -272,6 +272,10 @@ pub(crate) struct RenderState {
|
||||
pub render_request_id: Option<i32>,
|
||||
// Indicates whether the rendering process has pending frames.
|
||||
pub render_in_progress: bool,
|
||||
/// When true, visible tiles are allowed to yield across frames (progressive
|
||||
/// rendering). This is set when the Target surface has been primed with
|
||||
/// cached content so that un-rendered tiles still show something.
|
||||
progressive_render: bool,
|
||||
// Stack of nodes pending to be rendered.
|
||||
pending_nodes: Vec<NodeRenderState>,
|
||||
pub current_tile: Option<tiles::Tile>,
|
||||
@@ -299,6 +303,12 @@ pub(crate) struct RenderState {
|
||||
pub preview_mode: bool,
|
||||
}
|
||||
|
||||
/// Maximum cache surface dimension in pixels.
|
||||
/// Prevents GPU memory exhaustion (and WebGL context loss) at high zoom
|
||||
/// levels where the tile grid can grow very large.
|
||||
/// 8192 × 8192 × 4 bytes ≈ 256 MB, which is safe for most GPUs.
|
||||
const MAX_CACHE_DIMENSION: i32 = 8192;
|
||||
|
||||
pub fn get_cache_size(viewbox: Viewbox, scale: f32) -> skia::ISize {
|
||||
// First we retrieve the extended area of the viewport that we could render.
|
||||
let TileRect(isx, isy, iex, iey) = tiles::get_tiles_for_viewbox_with_interest(
|
||||
@@ -311,11 +321,9 @@ pub fn get_cache_size(viewbox: Viewbox, scale: f32) -> skia::ISize {
|
||||
let dy = if isy.signum() != iey.signum() { 1 } else { 0 };
|
||||
|
||||
let tile_size = tiles::TILE_SIZE;
|
||||
(
|
||||
((iex - isx).abs() + dx) * tile_size as i32,
|
||||
((iey - isy).abs() + dy) * tile_size as i32,
|
||||
)
|
||||
.into()
|
||||
let w = (((iex - isx).abs() + dx) * tile_size as i32).min(MAX_CACHE_DIMENSION);
|
||||
let h = (((iey - isy).abs() + dy) * tile_size as i32).min(MAX_CACHE_DIMENSION);
|
||||
(w, h).into()
|
||||
}
|
||||
|
||||
impl RenderState {
|
||||
@@ -350,6 +358,7 @@ impl RenderState {
|
||||
background_color: skia::Color::TRANSPARENT,
|
||||
render_request_id: None,
|
||||
render_in_progress: false,
|
||||
progressive_render: false,
|
||||
pending_nodes: vec![],
|
||||
current_tile: None,
|
||||
sampling_options,
|
||||
@@ -664,16 +673,20 @@ impl RenderState {
|
||||
.nested_fills
|
||||
.last()
|
||||
.is_some_and(|fills| !fills.is_empty());
|
||||
let has_inherited_blur = !self.ignore_nested_blurs
|
||||
let has_inherited_blur = !fast_mode
|
||||
&& !self.ignore_nested_blurs
|
||||
&& self.nested_blurs.iter().flatten().any(|blur| {
|
||||
!blur.hidden && blur.blur_type == BlurType::LayerBlur && blur.value > 0.0
|
||||
});
|
||||
// In fast mode blur is stripped from shapes, so treat blur as absent
|
||||
// for the direct-render eligibility check.
|
||||
let effective_blur_none = shape.blur.is_none() || fast_mode;
|
||||
let can_render_directly = apply_to_current_surface
|
||||
&& clip_bounds.is_none()
|
||||
&& offset.is_none()
|
||||
&& parent_shadows.is_none()
|
||||
&& !shape.needs_layer()
|
||||
&& shape.blur.is_none()
|
||||
&& effective_blur_none
|
||||
&& !has_inherited_blur
|
||||
&& shape.shadows.is_empty()
|
||||
&& shape.transform.is_identity()
|
||||
@@ -784,7 +797,29 @@ impl RenderState {
|
||||
shape.to_mut().set_blur(None);
|
||||
}
|
||||
if fast_mode {
|
||||
shape.to_mut().set_blur(None);
|
||||
// In fast mode (pan/zoom), strip blur entirely for individual shapes.
|
||||
// Each blur requires an expensive offscreen filter-surface pass
|
||||
// (clear → render → composite), so with many blurred shapes (e.g. 100)
|
||||
// even clamped blur values cause significant thread blocking.
|
||||
// The tile cache already shows the previously-blurred content, so
|
||||
// removing blur during interaction is visually acceptable. The
|
||||
// settling pass will restore blur at reduced quality, followed by
|
||||
// a full-quality re-render.
|
||||
if shape.blur.is_some() {
|
||||
shape.to_mut().set_blur(None);
|
||||
}
|
||||
} else if self.options.is_settling_mode() {
|
||||
// In settling mode (first render after pan/zoom ends) we allow
|
||||
// blur but clamp it to a small value so the visible tiles
|
||||
// render quickly. A full-quality re-render is scheduled after.
|
||||
const SETTLING_MAX_BLUR: f32 = 4.0;
|
||||
if let Some(ref blur) = shape.blur {
|
||||
if !blur.hidden && blur.value > SETTLING_MAX_BLUR {
|
||||
let mut clamped = *blur;
|
||||
clamped.value = SETTLING_MAX_BLUR;
|
||||
shape.to_mut().set_blur(Some(clamped));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let center = shape.center();
|
||||
@@ -869,13 +904,18 @@ impl RenderState {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let mut drop_shadows = shape.drop_shadow_paints();
|
||||
let max_shadow_blur = if self.options.is_settling_mode() {
|
||||
Some(4.0_f32)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut drop_shadows = shape.drop_shadow_paints(max_shadow_blur);
|
||||
|
||||
if let Some(inherited_shadows) = self.get_inherited_drop_shadows() {
|
||||
drop_shadows.extend(inherited_shadows);
|
||||
}
|
||||
|
||||
let inner_shadows = shape.inner_shadow_paints();
|
||||
let inner_shadows = shape.inner_shadow_paints(max_shadow_blur);
|
||||
let blur_filter = shape.image_filter(1.);
|
||||
let mut paragraphs_with_shadows =
|
||||
text_content.paragraph_builder_group_from_text(Some(true));
|
||||
@@ -1032,7 +1072,12 @@ impl RenderState {
|
||||
Some(strokes_surface_id),
|
||||
antialias,
|
||||
);
|
||||
if !fast_mode {
|
||||
if !self.options.is_fast_mode() {
|
||||
let max_shadow_blur = if self.options.is_settling_mode() {
|
||||
Some(4.0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
for stroke in &visible_strokes {
|
||||
shadows::render_stroke_inner_shadows(
|
||||
self,
|
||||
@@ -1040,17 +1085,24 @@ impl RenderState {
|
||||
stroke,
|
||||
antialias,
|
||||
innershadows_surface_id,
|
||||
max_shadow_blur,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !fast_mode {
|
||||
if !self.options.is_fast_mode() {
|
||||
let max_shadow_blur = if self.options.is_settling_mode() {
|
||||
Some(4.0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
shadows::render_fill_inner_shadows(
|
||||
self,
|
||||
shape,
|
||||
antialias,
|
||||
innershadows_surface_id,
|
||||
max_shadow_blur,
|
||||
);
|
||||
}
|
||||
// bools::debug_render_bool_paths(self, shape, shapes, modifiers, structure);
|
||||
@@ -1175,6 +1227,37 @@ impl RenderState {
|
||||
performance::begin_measure!("start_render_loop");
|
||||
|
||||
self.reset_canvas();
|
||||
|
||||
// When we have valid cached content (from a previous render or
|
||||
// settling pass), prime the Target surface with it. This lets
|
||||
// visible tiles yield across frames without showing empty
|
||||
// background — un-rendered tiles display the cached content until
|
||||
// they are individually re-rendered at the current quality level.
|
||||
let has_valid_cache = self.cached_viewbox.area.width() > 0.0;
|
||||
self.progressive_render = has_valid_cache;
|
||||
if has_valid_cache {
|
||||
let cached_scale = self.get_cached_scale();
|
||||
let navigate_zoom = self.viewbox.zoom / self.cached_viewbox.zoom;
|
||||
let TileRect(start_tile_x, start_tile_y, _, _) =
|
||||
tiles::get_tiles_for_viewbox_with_interest(
|
||||
self.cached_viewbox,
|
||||
VIEWPORT_INTEREST_AREA_THRESHOLD,
|
||||
cached_scale,
|
||||
);
|
||||
let offset_x = self.viewbox.area.left * self.cached_viewbox.zoom * self.options.dpr();
|
||||
let offset_y = self.viewbox.area.top * self.cached_viewbox.zoom * self.options.dpr();
|
||||
let translate_x = (start_tile_x as f32 * tiles::TILE_SIZE) - offset_x;
|
||||
let translate_y = (start_tile_y as f32 * tiles::TILE_SIZE) - offset_y;
|
||||
{
|
||||
let canvas = self.surfaces.canvas(SurfaceId::Target);
|
||||
canvas.save();
|
||||
canvas.scale((navigate_zoom, navigate_zoom));
|
||||
canvas.translate((translate_x, translate_y));
|
||||
}
|
||||
self.surfaces.draw_cache_to_target();
|
||||
self.surfaces.canvas(SurfaceId::Target).restore();
|
||||
}
|
||||
|
||||
let surface_ids = SurfaceId::Strokes as u32
|
||||
| SurfaceId::Fills as u32
|
||||
| SurfaceId::InnerShadows as u32
|
||||
@@ -1185,11 +1268,15 @@ impl RenderState {
|
||||
|
||||
let viewbox_cache_size = get_cache_size(self.viewbox, scale);
|
||||
let cached_viewbox_cache_size = get_cache_size(self.cached_viewbox, scale);
|
||||
// Only resize cache if the new size is larger than the cached size
|
||||
// This avoids unnecessary surface recreations when the cache size decreases
|
||||
if viewbox_cache_size.width > cached_viewbox_cache_size.width
|
||||
|| viewbox_cache_size.height > cached_viewbox_cache_size.height
|
||||
{
|
||||
// Resize cache when the new size is larger, or when the current cache
|
||||
// is significantly oversized (more than 2× the needed area). This
|
||||
// prevents the cache from staying at its peak size after zooming out,
|
||||
// which would waste GPU memory.
|
||||
let needs_grow = viewbox_cache_size.width > cached_viewbox_cache_size.width
|
||||
|| viewbox_cache_size.height > cached_viewbox_cache_size.height;
|
||||
let needs_shrink = cached_viewbox_cache_size.width > viewbox_cache_size.width * 2
|
||||
|| cached_viewbox_cache_size.height > viewbox_cache_size.height * 2;
|
||||
if needs_grow || needs_shrink {
|
||||
self.surfaces
|
||||
.resize_cache(viewbox_cache_size, VIEWPORT_INTEREST_AREA_THRESHOLD);
|
||||
}
|
||||
@@ -1247,6 +1334,26 @@ impl RenderState {
|
||||
if self.render_in_progress {
|
||||
self.cancel_animation_frame();
|
||||
self.render_request_id = Some(wapi::request_animation_frame!());
|
||||
} else if self.options.is_settling_mode() {
|
||||
// The settling render (reduced-quality) has finished all tiles.
|
||||
// Automatically transition to full quality: clear settling mode,
|
||||
// invalidate tile caches, and start a fresh render loop so that
|
||||
// tiles are re-drawn at full blur quality. This keeps the rAF
|
||||
// chain going without needing a round-trip through JS.
|
||||
//
|
||||
// Use soft_clear so settling-quality tile textures remain
|
||||
// available as visual fallback. This prevents the "bumpy" pop
|
||||
// where plain (unblurred) shapes flash before full-quality
|
||||
// tiles replace them.
|
||||
performance::begin_measure!("settling_to_full_quality");
|
||||
self.options.set_settling_mode(false);
|
||||
self.surfaces.soft_clear_tiles();
|
||||
performance::end_measure!("settling_to_full_quality");
|
||||
// Use a fresh timestamp so the full-quality pass gets its own
|
||||
// time budget instead of inheriting the exhausted budget from
|
||||
// the settling pass.
|
||||
let fresh_ts = performance::get_time();
|
||||
self.start_render_loop(base_object, tree, fresh_ts, false)?;
|
||||
} else {
|
||||
performance::end_measure!("render");
|
||||
}
|
||||
@@ -1324,7 +1431,15 @@ impl RenderState {
|
||||
|
||||
if let Some(frame_blur) = Self::frame_clip_layer_blur(element) {
|
||||
let scale = self.get_scale();
|
||||
let sigma = frame_blur.value * scale;
|
||||
let mut sigma = frame_blur.value * scale;
|
||||
// In fast mode skip frame-level blur entirely for better
|
||||
// responsiveness; in settling mode clamp to a small value.
|
||||
if self.options.is_fast_mode() {
|
||||
sigma = 0.0;
|
||||
} else if self.options.is_settling_mode() {
|
||||
const SETTLING_MAX_SIGMA: f32 = 4.0;
|
||||
sigma = sigma.min(SETTLING_MAX_SIGMA * scale);
|
||||
}
|
||||
if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) {
|
||||
paint.set_image_filter(filter);
|
||||
}
|
||||
@@ -1508,6 +1623,15 @@ impl RenderState {
|
||||
transformed_shadow.to_mut().offset = (0.0, 0.0);
|
||||
transformed_shadow.to_mut().color = skia::Color::BLACK;
|
||||
|
||||
// Clamp shadow blur during settling mode for faster rendering.
|
||||
// The filter surface is also at 0.5x resolution (see FAST_MODE_FILTER_DOWNSCALE).
|
||||
if self.options.is_settling_mode() {
|
||||
const SETTLING_MAX_SHADOW_BLUR: f32 = 4.0;
|
||||
if transformed_shadow.blur > SETTLING_MAX_SHADOW_BLUR {
|
||||
transformed_shadow.to_mut().blur = SETTLING_MAX_SHADOW_BLUR;
|
||||
}
|
||||
}
|
||||
|
||||
let mut plain_shape = Cow::Borrowed(shape);
|
||||
let combined_blur =
|
||||
Self::combine_blur_values(self.combined_layer_blur(shape.blur), extra_layer_blur);
|
||||
@@ -1738,7 +1862,11 @@ impl RenderState {
|
||||
|
||||
let mut transformed_shadow: Cow<Shadow> = Cow::Borrowed(shadow);
|
||||
transformed_shadow.to_mut().color = skia::Color::BLACK;
|
||||
transformed_shadow.to_mut().blur = transformed_shadow.blur * scale;
|
||||
let mut blur = transformed_shadow.blur;
|
||||
if self.options.is_settling_mode() {
|
||||
blur = blur.min(4.0);
|
||||
}
|
||||
transformed_shadow.to_mut().blur = blur * scale;
|
||||
transformed_shadow.to_mut().spread = transformed_shadow.spread * scale;
|
||||
|
||||
let mut new_shadow_paint = skia::Paint::default();
|
||||
@@ -1879,11 +2007,14 @@ impl RenderState {
|
||||
let element_extrect =
|
||||
extrect.get_or_insert_with(|| transformed_element.extrect(tree, scale));
|
||||
element_extrect.intersects(self.render_area)
|
||||
&& !transformed_element.visually_insignificant(scale, tree)
|
||||
&& !transformed_element
|
||||
.visually_insignificant_with_extrect(scale, element_extrect)
|
||||
} else {
|
||||
// For simple shapes without effects, use selrect for both intersection
|
||||
// and size check — avoids expensive extrect computation entirely.
|
||||
let selrect = transformed_element.selrect();
|
||||
selrect.intersects(self.render_area)
|
||||
&& !transformed_element.visually_insignificant(scale, tree)
|
||||
&& !transformed_element.visually_insignificant_with_extrect(scale, &selrect)
|
||||
};
|
||||
|
||||
if self.options.is_debug_visible() {
|
||||
@@ -1936,6 +2067,9 @@ impl RenderState {
|
||||
.get_render_context_translation(self.render_area, scale);
|
||||
|
||||
// Skip expensive drop shadow rendering in fast mode (during pan/zoom)
|
||||
// and settling mode (first post-interaction pass). Shadow rendering
|
||||
// is the most expensive operation per shape; deferring it to the
|
||||
// progressive full-quality pass prevents the UI from freezing.
|
||||
let skip_shadows = self.options.is_fast_mode();
|
||||
|
||||
// Skip shadow block when already rendered before the layer (frame_clip_layer_blur)
|
||||
@@ -2097,11 +2231,18 @@ impl RenderState {
|
||||
}
|
||||
} else {
|
||||
performance::begin_measure!("render_shape_tree::uncached");
|
||||
// Only allow stopping (yielding) if the current tile is NOT visible.
|
||||
// This ensures all visible tiles render synchronously before showing,
|
||||
// eliminating empty squares during zoom. Interest-area tiles can still yield.
|
||||
// Decide whether this tile can yield (stop mid-render to
|
||||
// avoid blocking the main thread for too long).
|
||||
//
|
||||
// Normally, visible tiles render synchronously to prevent
|
||||
// empty squares on screen. However, when progressive_render
|
||||
// is active (the Target was primed with cached content),
|
||||
// visible tiles can also yield because the cached content
|
||||
// fills in for tiles that haven't been re-rendered yet.
|
||||
// This keeps each frame short and responsive even when
|
||||
// tiles contain heavy blurs.
|
||||
let tile_is_visible = self.tile_viewbox.is_visible(¤t_tile);
|
||||
let can_stop = allow_stop && !tile_is_visible;
|
||||
let can_stop = allow_stop && (!tile_is_visible || self.progressive_render);
|
||||
let (is_empty, early_return) =
|
||||
self.render_shape_tree_partial_uncached(tree, timestamp, can_stop)?;
|
||||
|
||||
@@ -2122,11 +2263,20 @@ impl RenderState {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.surfaces.apply_mut(SurfaceId::Target as u32, |s| {
|
||||
let mut paint = skia::Paint::default();
|
||||
paint.set_color(self.background_color);
|
||||
s.canvas().draw_rect(tile_rect, &paint);
|
||||
});
|
||||
// Tile is empty — try to show a soft-cleared fallback tile
|
||||
// (e.g. from a settling pass) instead of plain background.
|
||||
let tile = self.current_tile.unwrap();
|
||||
if !self.surfaces.draw_cached_tile_fallback(
|
||||
tile,
|
||||
tile_rect,
|
||||
self.background_color,
|
||||
) {
|
||||
self.surfaces.apply_mut(SurfaceId::Target as u32, |s| {
|
||||
let mut paint = skia::Paint::default();
|
||||
paint.set_color(self.background_color);
|
||||
s.canvas().draw_rect(tile_rect, &paint);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2167,6 +2317,7 @@ impl RenderState {
|
||||
}
|
||||
|
||||
self.render_in_progress = false;
|
||||
self.progressive_render = false;
|
||||
|
||||
self.surfaces.gc();
|
||||
|
||||
@@ -2360,8 +2511,10 @@ impl RenderState {
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate changed tiles - old content stays visible until new tiles render
|
||||
self.surfaces.remove_cached_tiles(self.background_color);
|
||||
if zoom_changed {
|
||||
// Invalidate changed tiles - old content stays visible until new tiles render
|
||||
self.surfaces.remove_cached_tiles(self.background_color);
|
||||
}
|
||||
|
||||
performance::end_measure!("rebuild_tiles_shallow");
|
||||
}
|
||||
@@ -2393,11 +2546,10 @@ impl RenderState {
|
||||
}
|
||||
}
|
||||
|
||||
// Invalidate changed tiles - old content stays visible until new tiles render
|
||||
// Invalidate all cached tiles - content will be re-rendered at the new state.
|
||||
// remove_cached_tiles already clears the entire tile cache, so no need
|
||||
// to also call remove_cached_tile per tile individually.
|
||||
self.surfaces.remove_cached_tiles(self.background_color);
|
||||
for tile in all_tiles {
|
||||
self.remove_cached_tile(tile);
|
||||
}
|
||||
performance::end_measure!("rebuild_tiles");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@ use skia_safe::{self as skia, ImageFilter, Rect};
|
||||
|
||||
use super::{RenderState, SurfaceId};
|
||||
|
||||
/// Extra downscale factor applied to the filter surface during fast mode
|
||||
/// (pan/zoom interactions). Rendering blurred content at half resolution
|
||||
/// is virtually indistinguishable while the viewport is moving and cuts
|
||||
/// GPU fill-rate / shader work significantly.
|
||||
const FAST_MODE_FILTER_DOWNSCALE: f32 = 0.5;
|
||||
|
||||
/// Composes two image filters, returning a combined filter if both are present,
|
||||
/// or the individual filter if only one is present, or None if neither is present.
|
||||
///
|
||||
@@ -87,7 +93,7 @@ where
|
||||
let bounds_height = bounds.height().ceil().max(1.0) as i32;
|
||||
|
||||
// Calculate scale factor if bounds exceed filter surface size
|
||||
let scale = if bounds_width > filter_width || bounds_height > filter_height {
|
||||
let mut scale = if bounds_width > filter_width || bounds_height > filter_height {
|
||||
let scale_x = filter_width as f32 / bounds_width as f32;
|
||||
let scale_y = filter_height as f32 / bounds_height as f32;
|
||||
// Use the smaller scale to ensure everything fits
|
||||
@@ -96,6 +102,14 @@ where
|
||||
1.0
|
||||
};
|
||||
|
||||
// In fast mode or settling mode (pan/zoom or post-interaction first pass)
|
||||
// apply an additional downscale so that blur filter surfaces are rendered at
|
||||
// lower resolution. The compositing step scales the result back up, trading
|
||||
// a small amount of fidelity for a large reduction in GPU work.
|
||||
if render_state.options.is_reduced_quality() {
|
||||
scale = (scale * FAST_MODE_FILTER_DOWNSCALE).max(0.1);
|
||||
}
|
||||
|
||||
{
|
||||
let canvas = render_state.surfaces.canvas(filter_id);
|
||||
canvas.clear(skia::Color::TRANSPARENT);
|
||||
|
||||
@@ -28,6 +28,27 @@ impl RenderOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// Settling mode: transitional reduced-quality render after pan/zoom ends.
|
||||
/// Blur is kept but rendered at reduced resolution/sigma so the first
|
||||
/// post-interaction frame appears quickly. A follow-up full-quality render
|
||||
/// is then scheduled.
|
||||
pub fn is_settling_mode(&self) -> bool {
|
||||
self.flags & options::SETTLING_MODE == options::SETTLING_MODE
|
||||
}
|
||||
|
||||
pub fn set_settling_mode(&mut self, enabled: bool) {
|
||||
if enabled {
|
||||
self.flags |= options::SETTLING_MODE;
|
||||
} else {
|
||||
self.flags &= !options::SETTLING_MODE;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if blur quality should be reduced (either fast mode or settling mode).
|
||||
pub fn is_reduced_quality(&self) -> bool {
|
||||
self.is_fast_mode() || self.is_settling_mode()
|
||||
}
|
||||
|
||||
pub fn dpr(&self) -> f32 {
|
||||
self.dpr.unwrap_or(1.0)
|
||||
}
|
||||
|
||||
@@ -11,10 +11,12 @@ pub fn render_fill_inner_shadows(
|
||||
shape: &Shape,
|
||||
antialias: bool,
|
||||
surface_id: SurfaceId,
|
||||
max_blur: Option<f32>,
|
||||
) {
|
||||
if shape.has_fills() {
|
||||
for shadow in shape.inner_shadows_visible() {
|
||||
render_fill_inner_shadow(render_state, shape, shadow, antialias, surface_id);
|
||||
let shadow = clamp_shadow_blur(shadow, max_blur);
|
||||
render_fill_inner_shadow(render_state, shape, &shadow, antialias, surface_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,9 +38,11 @@ pub fn render_stroke_inner_shadows(
|
||||
stroke: &Stroke,
|
||||
antialias: bool,
|
||||
surface_id: SurfaceId,
|
||||
max_blur: Option<f32>,
|
||||
) {
|
||||
if !shape.has_fills() {
|
||||
for shadow in shape.inner_shadows_visible() {
|
||||
let shadow = clamp_shadow_blur(shadow, max_blur);
|
||||
let filter = shadow.get_inner_shadow_filter();
|
||||
strokes::render_single(
|
||||
render_state,
|
||||
@@ -166,3 +170,14 @@ pub fn render_text_shadows(
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
|
||||
fn clamp_shadow_blur(shadow: &Shadow, max_blur: Option<f32>) -> Shadow {
|
||||
match max_blur {
|
||||
Some(max) if shadow.blur > max => {
|
||||
let mut clamped = *shadow;
|
||||
clamped.blur = max;
|
||||
clamped
|
||||
}
|
||||
_ => *shadow,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,13 @@ impl Surfaces {
|
||||
self.tiles.clear();
|
||||
}
|
||||
|
||||
/// Soft-clear tiles: marks all tiles as needing re-render but keeps their
|
||||
/// textures available for visual fallback via `draw_cached_tile_surface`.
|
||||
/// This avoids the visual "pop" when transitioning between quality levels.
|
||||
pub fn soft_clear_tiles(&mut self) {
|
||||
self.tiles.soft_clear();
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, gpu_state: &mut GpuState, new_width: i32, new_height: i32) {
|
||||
self.reset_from_target(gpu_state.create_target_surface(new_width, new_height));
|
||||
}
|
||||
@@ -461,6 +468,29 @@ impl Surfaces {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws a cached tile even if it was soft-removed, for visual fallback
|
||||
/// during quality transitions. Returns true if a fallback tile was drawn.
|
||||
pub fn draw_cached_tile_fallback(
|
||||
&mut self,
|
||||
tile: Tile,
|
||||
rect: skia::Rect,
|
||||
color: skia::Color,
|
||||
) -> bool {
|
||||
if let Some(image) = self.tiles.get_even_if_removed(tile) {
|
||||
let mut paint = skia::Paint::default();
|
||||
paint.set_color(color);
|
||||
|
||||
self.target.canvas().draw_rect(rect, &paint);
|
||||
|
||||
self.target
|
||||
.canvas()
|
||||
.draw_image_rect(&image, None, rect, &skia::Paint::default());
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the current tile directly to the target and cache surfaces without
|
||||
/// creating a snapshot. This avoids GPU stalls from ReadPixels but doesn't
|
||||
/// populate the tile texture cache (suitable for one-shot renders like tests).
|
||||
@@ -534,6 +564,7 @@ impl TileTextureCache {
|
||||
for tile in self.removed.iter() {
|
||||
self.grid.remove(tile);
|
||||
}
|
||||
self.removed.clear();
|
||||
}
|
||||
|
||||
fn free_tiles(&mut self, tile_viewbox: &TileViewbox) {
|
||||
@@ -579,13 +610,29 @@ impl TileTextureCache {
|
||||
self.grid.get_mut(&tile)
|
||||
}
|
||||
|
||||
/// Returns the tile texture even if it was soft-removed.
|
||||
/// Used for visual fallback during quality transitions.
|
||||
pub fn get_even_if_removed(&mut self, tile: Tile) -> Option<&mut skia::Image> {
|
||||
self.grid.get_mut(&tile)
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, tile: Tile) {
|
||||
self.removed.insert(tile);
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
/// Soft-clear marks every tile as removed (so `has()` returns false and
|
||||
/// tiles will be re-rendered) but keeps the textures in `grid` so that
|
||||
/// `get()` can still return them when used explicitly for visual fallback.
|
||||
/// Use this when transitioning between quality levels so that old tiles
|
||||
/// remain visible until replaced by higher-quality versions.
|
||||
pub fn soft_clear(&mut self) {
|
||||
for k in self.grid.keys() {
|
||||
self.removed.insert(*k);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.grid.clear();
|
||||
self.removed.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +196,10 @@ pub struct Shape {
|
||||
pub extrect_cache: RefCell<Option<(math::Rect, u32)>>,
|
||||
pub svg_transform: Option<Matrix>,
|
||||
pub ignore_constraints: bool,
|
||||
/// Cached `ImageFilter` for the unit-scale blur (`scale = 1.0`).
|
||||
/// Invalidated whenever `set_blur` is called. Keyed by the blur
|
||||
/// parameters so a stale entry is never returned.
|
||||
cached_image_filter: RefCell<Option<(Blur, skia::ImageFilter)>>,
|
||||
}
|
||||
|
||||
// Returns all ancestor shapes of this shape, traversing up the parent hierarchy
|
||||
@@ -284,6 +288,7 @@ impl Shape {
|
||||
extrect_cache: RefCell::new(None),
|
||||
svg_transform: None,
|
||||
ignore_constraints: false,
|
||||
cached_image_filter: RefCell::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,6 +301,8 @@ impl Shape {
|
||||
|
||||
if let Some(blur) = self.blur.as_mut() {
|
||||
blur.scale_content(value);
|
||||
// Invalidate cached filter since blur value changed
|
||||
*self.cached_image_filter.borrow_mut() = None;
|
||||
}
|
||||
|
||||
self.layout_item
|
||||
@@ -604,6 +611,8 @@ impl Shape {
|
||||
|
||||
pub fn set_blur(&mut self, blur: Option<Blur>) {
|
||||
self.invalidate_extrect();
|
||||
// Invalidate the cached ImageFilter when blur parameters change
|
||||
*self.cached_image_filter.borrow_mut() = None;
|
||||
self.blur = blur;
|
||||
}
|
||||
|
||||
@@ -654,6 +663,11 @@ impl Shape {
|
||||
self.strokes.push(s)
|
||||
}
|
||||
|
||||
pub fn set_strokes(&mut self, strokes: Vec<Stroke>) {
|
||||
self.invalidate_extrect();
|
||||
self.strokes = strokes;
|
||||
}
|
||||
|
||||
pub fn set_stroke_fill(&mut self, f: Fill) -> Result<(), String> {
|
||||
let stroke = self.strokes.last_mut().ok_or("Shape has no strokes")?;
|
||||
stroke.fill = f;
|
||||
@@ -740,8 +754,17 @@ impl Shape {
|
||||
self.calculate_extrect(shapes_pool, scale)
|
||||
}
|
||||
|
||||
/// Check if shape is too small to be visually relevant at the given scale.
|
||||
/// Prefer `visually_insignificant_with_extrect` if you already have a computed extrect.
|
||||
#[allow(dead_code)]
|
||||
pub fn visually_insignificant(&self, scale: f32, shapes_pool: ShapesPoolRef) -> bool {
|
||||
let extrect = self.extrect(shapes_pool, scale);
|
||||
self.visually_insignificant_with_extrect(scale, &extrect)
|
||||
}
|
||||
|
||||
/// Check if shape is too small to be visually relevant, using a precomputed extrect.
|
||||
/// This avoids redundant extrect computation when the caller already has one.
|
||||
pub fn visually_insignificant_with_extrect(&self, scale: f32, extrect: &math::Rect) -> bool {
|
||||
extrect.width() * scale < MIN_VISIBLE_SIZE && extrect.height() * scale < MIN_VISIBLE_SIZE
|
||||
}
|
||||
|
||||
@@ -872,6 +895,11 @@ impl Shape {
|
||||
}
|
||||
|
||||
fn apply_shadow_bounds(&self, bounds: Bounds) -> Bounds {
|
||||
// Fast path: skip when no shadows exist
|
||||
if self.shadows.is_empty() {
|
||||
return bounds;
|
||||
}
|
||||
|
||||
let mut rect = bounds.to_rect();
|
||||
for shadow in self.shadows_visible() {
|
||||
if !shadow.hidden() {
|
||||
@@ -885,6 +913,12 @@ impl Shape {
|
||||
}
|
||||
|
||||
fn apply_blur_bounds(&self, bounds: Bounds) -> Bounds {
|
||||
// Fast path: skip when no active blur (most common case)
|
||||
match self.blur {
|
||||
Some(b) if !b.hidden => {}
|
||||
_ => return bounds,
|
||||
}
|
||||
|
||||
let mut rect = bounds.to_rect();
|
||||
let image_filter = self.image_filter(1.);
|
||||
if let Some(image_filter) = image_filter {
|
||||
@@ -956,7 +990,6 @@ impl Shape {
|
||||
}
|
||||
|
||||
pub fn apply_children_blur(&self, bounds: Bounds, tree: ShapesPoolRef) -> Bounds {
|
||||
let mut rect = bounds.to_rect();
|
||||
let mut children_blur = 0.0;
|
||||
let mut current_parent_id = self.parent_id;
|
||||
|
||||
@@ -983,6 +1016,12 @@ impl Shape {
|
||||
}
|
||||
}
|
||||
|
||||
// Short-circuit: no parent has a layer blur, skip Skia filter creation entirely
|
||||
if children_blur == 0.0 {
|
||||
return bounds;
|
||||
}
|
||||
|
||||
let mut rect = bounds.to_rect();
|
||||
let blur = skia::image_filters::blur((children_blur, children_blur), None, None, None);
|
||||
if let Some(image_filter) = blur {
|
||||
let blur_bounds = image_filter.compute_fast_bounds(rect);
|
||||
@@ -1212,16 +1251,39 @@ impl Shape {
|
||||
}
|
||||
|
||||
pub fn image_filter(&self, scale: f32) -> Option<skia::ImageFilter> {
|
||||
self.blur
|
||||
.filter(|blur| !blur.hidden)
|
||||
.and_then(|blur| match blur.blur_type {
|
||||
BlurType::LayerBlur => skia::image_filters::blur(
|
||||
(blur.value * scale, blur.value * scale),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
})
|
||||
let blur = self.blur.filter(|b| !b.hidden)?;
|
||||
|
||||
// Fast path: for the most common case (scale == 1.0) use the cached filter.
|
||||
if scale == 1.0 {
|
||||
let cache = self.cached_image_filter.borrow();
|
||||
if let Some((cached_blur, ref filter)) = *cache {
|
||||
if cached_blur == blur {
|
||||
return Some(filter.clone());
|
||||
}
|
||||
}
|
||||
drop(cache);
|
||||
|
||||
// Compute and cache
|
||||
let filter = match blur.blur_type {
|
||||
BlurType::LayerBlur => {
|
||||
skia::image_filters::blur((blur.value, blur.value), None, None, None)
|
||||
}
|
||||
};
|
||||
if let Some(ref f) = filter {
|
||||
*self.cached_image_filter.borrow_mut() = Some((blur, f.clone()));
|
||||
}
|
||||
return filter;
|
||||
}
|
||||
|
||||
// Non-unit scale: compute directly (rare path)
|
||||
match blur.blur_type {
|
||||
BlurType::LayerBlur => skia::image_filters::blur(
|
||||
(blur.value * scale, blur.value * scale),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -1251,6 +1313,11 @@ impl Shape {
|
||||
self.shadows.push(shadow);
|
||||
}
|
||||
|
||||
pub fn set_shadows(&mut self, shadows: Vec<Shadow>) {
|
||||
self.invalidate_extrect();
|
||||
self.shadows = shadows;
|
||||
}
|
||||
|
||||
pub fn clear_shadows(&mut self) {
|
||||
self.invalidate_extrect();
|
||||
self.shadows.clear();
|
||||
@@ -1578,12 +1645,20 @@ impl Shape {
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn drop_shadow_paints(&self) -> Vec<skia_safe::Paint> {
|
||||
pub fn drop_shadow_paints(&self, max_blur: Option<f32>) -> Vec<skia_safe::Paint> {
|
||||
let drop_shadows: Vec<&Shadow> = self.drop_shadows_visible().collect();
|
||||
|
||||
drop_shadows
|
||||
.into_iter()
|
||||
.map(|shadow| {
|
||||
let shadow = match max_blur {
|
||||
Some(max) if shadow.blur > max => {
|
||||
let mut c = *shadow;
|
||||
c.blur = max;
|
||||
c
|
||||
}
|
||||
_ => *shadow,
|
||||
};
|
||||
let mut paint = skia_safe::Paint::default();
|
||||
let filter = shadow.get_drop_shadow_filter();
|
||||
paint.set_image_filter(filter);
|
||||
@@ -1592,12 +1667,20 @@ impl Shape {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn inner_shadow_paints(&self) -> Vec<skia_safe::Paint> {
|
||||
pub fn inner_shadow_paints(&self, max_blur: Option<f32>) -> Vec<skia_safe::Paint> {
|
||||
let inner_shadows: Vec<&Shadow> = self.inner_shadows_visible().collect();
|
||||
|
||||
inner_shadows
|
||||
.into_iter()
|
||||
.map(|shadow| {
|
||||
let shadow = match max_blur {
|
||||
Some(max) if shadow.blur > max => {
|
||||
let mut c = *shadow;
|
||||
c.blur = max;
|
||||
c
|
||||
}
|
||||
_ => *shadow,
|
||||
};
|
||||
let mut paint = skia_safe::Paint::default();
|
||||
let filter = shadow.get_inner_shadow_filter();
|
||||
paint.set_image_filter(filter);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
use macros::ToJs;
|
||||
use skia_safe as skia;
|
||||
|
||||
use crate::mem;
|
||||
use crate::shapes::{Shadow, ShadowStyle};
|
||||
use crate::{with_current_shape_mut, STATE};
|
||||
|
||||
const RAW_SHADOW_DATA_SIZE: usize = std::mem::size_of::<RawShadowData>();
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, ToJs)]
|
||||
#[repr(u8)]
|
||||
#[allow(dead_code)]
|
||||
@@ -28,6 +31,93 @@ impl From<RawShadowStyle> for ShadowStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// Binary layout for a single shadow entry in the batched buffer.
|
||||
///
|
||||
/// | Offset | Size | Field | Type |
|
||||
/// |--------|------|---------|------|
|
||||
/// | 0 | 4 | color | u32 |
|
||||
/// | 4 | 4 | blur | f32 |
|
||||
/// | 8 | 4 | spread | f32 |
|
||||
/// | 12 | 4 | x | f32 |
|
||||
/// | 16 | 4 | y | f32 |
|
||||
/// | 20 | 1 | style | u8 |
|
||||
/// | 21 | 1 | hidden | u8 |
|
||||
/// | 22 | 2 | padding | - |
|
||||
/// | Total | 24 | | |
|
||||
#[repr(C, align(4))]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct RawShadowData {
|
||||
color: u32,
|
||||
blur: f32,
|
||||
spread: f32,
|
||||
x: f32,
|
||||
y: f32,
|
||||
style: u8,
|
||||
hidden: u8,
|
||||
_padding: [u8; 2],
|
||||
}
|
||||
|
||||
impl From<RawShadowData> for Shadow {
|
||||
fn from(raw: RawShadowData) -> Self {
|
||||
let color = skia::Color::new(raw.color);
|
||||
let style = RawShadowStyle::from(raw.style).into();
|
||||
Shadow::new(
|
||||
color,
|
||||
raw.blur,
|
||||
raw.spread,
|
||||
(raw.x, raw.y),
|
||||
style,
|
||||
raw.hidden != 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<[u8; RAW_SHADOW_DATA_SIZE]> for RawShadowData {
|
||||
fn from(bytes: [u8; RAW_SHADOW_DATA_SIZE]) -> Self {
|
||||
unsafe { std::mem::transmute(bytes) }
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&[u8]> for RawShadowData {
|
||||
type Error = String;
|
||||
fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
|
||||
let data: [u8; RAW_SHADOW_DATA_SIZE] = bytes
|
||||
.get(0..RAW_SHADOW_DATA_SIZE)
|
||||
.and_then(|slice| slice.try_into().ok())
|
||||
.ok_or("Invalid shadow data".to_string())?;
|
||||
Ok(RawShadowData::from(data))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_shadows_from_bytes(buffer: &[u8], num_shadows: usize) -> Vec<Shadow> {
|
||||
buffer
|
||||
.chunks_exact(RAW_SHADOW_DATA_SIZE)
|
||||
.take(num_shadows)
|
||||
.map(|bytes| {
|
||||
RawShadowData::try_from(bytes)
|
||||
.expect("Invalid shadow data")
|
||||
.into()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Batched shadow setter: reads all shadows from the shared memory buffer.
|
||||
/// Buffer layout: [u8 count][3 bytes padding][N × 24-byte RawShadowData]
|
||||
#[no_mangle]
|
||||
pub extern "C" fn set_shape_shadows() {
|
||||
with_current_shape_mut!(state, |shape: &mut Shape| {
|
||||
let bytes = mem::bytes();
|
||||
let num_shadows = bytes.first().copied().unwrap_or(0) as usize;
|
||||
let shadows = if num_shadows == 0 {
|
||||
vec![]
|
||||
} else {
|
||||
parse_shadows_from_bytes(&bytes[4..], num_shadows)
|
||||
};
|
||||
shape.set_shadows(shadows);
|
||||
mem::free_bytes();
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn add_shape_shadow(
|
||||
raw_color: u32,
|
||||
@@ -52,3 +142,41 @@ pub extern "C" fn clear_shape_shadows() {
|
||||
shape.clear_shadows();
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_raw_shadow_data_layout() {
|
||||
assert_eq!(RAW_SHADOW_DATA_SIZE, 24);
|
||||
assert_eq!(std::mem::align_of::<RawShadowData>(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_raw_shadow_data_from_bytes() {
|
||||
let mut bytes = [0u8; RAW_SHADOW_DATA_SIZE];
|
||||
// color = 0xFF112233
|
||||
bytes[0..4].copy_from_slice(&0xFF112233_u32.to_le_bytes());
|
||||
// blur = 5.0
|
||||
bytes[4..8].copy_from_slice(&5.0_f32.to_le_bytes());
|
||||
// spread = 2.0
|
||||
bytes[8..12].copy_from_slice(&2.0_f32.to_le_bytes());
|
||||
// x = 10.0
|
||||
bytes[12..16].copy_from_slice(&10.0_f32.to_le_bytes());
|
||||
// y = 15.0
|
||||
bytes[16..20].copy_from_slice(&15.0_f32.to_le_bytes());
|
||||
// style = 0 (DropShadow)
|
||||
bytes[20] = 0;
|
||||
// hidden = 0 (false)
|
||||
bytes[21] = 0;
|
||||
|
||||
let raw = RawShadowData::from(bytes);
|
||||
let shadow: Shadow = raw.into();
|
||||
|
||||
assert_eq!(shadow.blur, 5.0);
|
||||
assert_eq!(shadow.spread, 2.0);
|
||||
assert_eq!(shadow.offset, (10.0, 15.0));
|
||||
assert!(!shadow.hidden());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ use crate::wasm::layouts::constraints::{RawConstraintH, RawConstraintV};
|
||||
use crate::{with_state_mut, STATE};
|
||||
|
||||
use super::RawShapeType;
|
||||
use crate::shapes::Blur;
|
||||
use crate::wasm::blurs::RawBlurType;
|
||||
|
||||
const FLAG_CLIP_CONTENT: u8 = 0b0000_0001;
|
||||
const FLAG_HIDDEN: u8 = 0b0000_0010;
|
||||
@@ -59,6 +61,12 @@ pub struct RawBasePropsData {
|
||||
corner_r2: f32,
|
||||
corner_r3: f32,
|
||||
corner_r4: f32,
|
||||
// Blur fields (8 bytes)
|
||||
// hidden (u8), blur_type (u8), padding (2 bytes), value (f32)
|
||||
blur_hidden: u8,
|
||||
blur_type: u8,
|
||||
blur_padding: [u8; 2],
|
||||
blur_value: f32,
|
||||
}
|
||||
|
||||
impl RawBasePropsData {
|
||||
@@ -97,6 +105,17 @@ impl RawBasePropsData {
|
||||
Some(RawConstraintV::from(self.constraint_v).into())
|
||||
}
|
||||
}
|
||||
|
||||
fn blur(&self) -> Option<Blur> {
|
||||
// Only LayerBlur is currently supported; hidden indicated by blur_hidden != 0
|
||||
if self.blur_value > 0.0 {
|
||||
let hidden = self.blur_hidden != 0;
|
||||
let blur_type = RawBlurType::from(self.blur_type).into();
|
||||
Some(Blur::new(blur_type, hidden, self.blur_value))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<[u8; RAW_BASE_PROPS_SIZE]> for RawBasePropsData {
|
||||
@@ -149,6 +168,8 @@ pub extern "C" fn set_shape_base_props() {
|
||||
raw.selrect_y2,
|
||||
);
|
||||
shape.set_corners((raw.corner_r1, raw.corner_r2, raw.corner_r3, raw.corner_r4));
|
||||
// Apply blur if present in the batched data
|
||||
shape.set_blur(raw.blur());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -169,7 +190,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_raw_base_props_layout() {
|
||||
assert_eq!(RAW_BASE_PROPS_SIZE, 104);
|
||||
assert_eq!(RAW_BASE_PROPS_SIZE, 112);
|
||||
assert_eq!(std::mem::align_of::<RawBasePropsData>(), 4);
|
||||
}
|
||||
|
||||
@@ -189,6 +210,8 @@ mod tests {
|
||||
assert_eq!(std::mem::offset_of!(RawBasePropsData, transform_a), 48);
|
||||
assert_eq!(std::mem::offset_of!(RawBasePropsData, selrect_x1), 72);
|
||||
assert_eq!(std::mem::offset_of!(RawBasePropsData, corner_r1), 88);
|
||||
assert_eq!(std::mem::offset_of!(RawBasePropsData, blur_hidden), 104);
|
||||
assert_eq!(std::mem::offset_of!(RawBasePropsData, blur_value), 108);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
use macros::ToJs;
|
||||
|
||||
use crate::mem;
|
||||
use crate::shapes::{self, StrokeCap, StrokeStyle};
|
||||
use crate::shapes::{self, Fill, StrokeCap, StrokeKind, StrokeStyle};
|
||||
use crate::with_current_shape_mut;
|
||||
use crate::STATE;
|
||||
|
||||
use super::fills::RawFillData;
|
||||
|
||||
const RAW_FILL_DATA_SIZE: usize = std::mem::size_of::<RawFillData>();
|
||||
|
||||
/// Binary layout per stroke entry (matches STROKE-ENTRY-U8-SIZE on CLJS side):
|
||||
/// byte 0: kind (0=center, 1=inner, 2=outer)
|
||||
/// byte 1: style (0=solid, 1=dotted, 2=dashed, 3=mixed)
|
||||
/// byte 2: cap_start
|
||||
/// byte 3: cap_end
|
||||
/// bytes 4-7: width (f32, little-endian)
|
||||
/// bytes 8..: fill data (RAW_FILL_DATA_SIZE bytes)
|
||||
const STROKE_ENTRY_SIZE: usize = 8 + RAW_FILL_DATA_SIZE;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Copy, ToJs)]
|
||||
#[repr(u8)]
|
||||
#[allow(dead_code)]
|
||||
@@ -69,6 +82,76 @@ impl TryFrom<RawStrokeCap> for StrokeCap {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
#[repr(u8)]
|
||||
#[allow(dead_code)]
|
||||
pub enum RawStrokeKind {
|
||||
Center = 0,
|
||||
Inner = 1,
|
||||
Outer = 2,
|
||||
}
|
||||
|
||||
impl From<u8> for RawStrokeKind {
|
||||
fn from(value: u8) -> Self {
|
||||
unsafe { std::mem::transmute(value) }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RawStrokeKind> for StrokeKind {
|
||||
fn from(value: RawStrokeKind) -> Self {
|
||||
match value {
|
||||
RawStrokeKind::Center => StrokeKind::Center,
|
||||
RawStrokeKind::Inner => StrokeKind::Inner,
|
||||
RawStrokeKind::Outer => StrokeKind::Outer,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_stroke_from_bytes(bytes: &[u8]) -> shapes::Stroke {
|
||||
let kind = RawStrokeKind::from(bytes[0]);
|
||||
let style = RawStrokeStyle::from(bytes[1]);
|
||||
let cap_start = RawStrokeCap::from(bytes[2]);
|
||||
let cap_end = RawStrokeCap::from(bytes[3]);
|
||||
let width = f32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
|
||||
let fill_bytes = &bytes[8..8 + RAW_FILL_DATA_SIZE];
|
||||
let raw_fill = RawFillData::try_from(fill_bytes).expect("Invalid stroke fill data");
|
||||
let fill: Fill = raw_fill.into();
|
||||
|
||||
shapes::Stroke {
|
||||
fill,
|
||||
width,
|
||||
style: style.into(),
|
||||
cap_start: cap_start.try_into().ok(),
|
||||
cap_end: cap_end.try_into().ok(),
|
||||
kind: kind.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_strokes_from_bytes(buffer: &[u8], num_strokes: usize) -> Vec<shapes::Stroke> {
|
||||
buffer
|
||||
.chunks_exact(STROKE_ENTRY_SIZE)
|
||||
.take(num_strokes)
|
||||
.map(parse_stroke_from_bytes)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Batched stroke setter: reads all strokes from a single shared-memory buffer.
|
||||
///
|
||||
/// Buffer layout:
|
||||
/// byte 0: number of strokes (u8)
|
||||
/// bytes 1-3: padding (reserved)
|
||||
/// bytes 4..: N × STROKE_ENTRY_SIZE stroke entries
|
||||
#[no_mangle]
|
||||
pub extern "C" fn set_shape_strokes() {
|
||||
with_current_shape_mut!(state, |shape: &mut Shape| {
|
||||
let bytes = mem::bytes();
|
||||
let num_strokes = bytes.first().copied().unwrap_or(0) as usize;
|
||||
let strokes = parse_strokes_from_bytes(&bytes[4..], num_strokes);
|
||||
shape.set_strokes(strokes);
|
||||
mem::free_bytes();
|
||||
});
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn add_shape_center_stroke(width: f32, style: u8, cap_start: u8, cap_end: u8) {
|
||||
let stroke_style = RawStrokeStyle::from(style);
|
||||
@@ -134,3 +217,95 @@ pub extern "C" fn clear_shape_strokes() {
|
||||
shape.clear_strokes();
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_stroke_entry_size() {
|
||||
// STROKE_ENTRY_SIZE = 8 bytes header + RAW_FILL_DATA_SIZE bytes fill
|
||||
assert_eq!(STROKE_ENTRY_SIZE, 8 + RAW_FILL_DATA_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_stroke_center_solid() {
|
||||
let mut entry = vec![0u8; STROKE_ENTRY_SIZE];
|
||||
// kind = Center (0)
|
||||
entry[0] = 0;
|
||||
// style = Solid (0)
|
||||
entry[1] = 0;
|
||||
// cap_start = None (0)
|
||||
entry[2] = 0;
|
||||
// cap_end = None (0)
|
||||
entry[3] = 0;
|
||||
// width = 5.0 (f32 LE)
|
||||
entry[4..8].copy_from_slice(&5.0f32.to_le_bytes());
|
||||
// fill: solid (tag byte 0x00 at offset 8, color at offset 12)
|
||||
entry[8] = 0x00;
|
||||
entry[12..16].copy_from_slice(&0xff00ff00_u32.to_le_bytes());
|
||||
|
||||
let stroke = parse_stroke_from_bytes(&entry);
|
||||
|
||||
assert_eq!(stroke.kind, StrokeKind::Center);
|
||||
assert_eq!(stroke.style, StrokeStyle::Solid);
|
||||
assert_eq!(stroke.cap_start, None);
|
||||
assert_eq!(stroke.cap_end, None);
|
||||
assert_eq!(stroke.width, 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_stroke_inner_dashed_with_caps() {
|
||||
let mut entry = vec![0u8; STROKE_ENTRY_SIZE];
|
||||
// kind = Inner (1)
|
||||
entry[0] = 1;
|
||||
// style = Dashed (2)
|
||||
entry[1] = 2;
|
||||
// cap_start = LineArrow (1)
|
||||
entry[2] = 1;
|
||||
// cap_end = Round (6)
|
||||
entry[3] = 6;
|
||||
// width = 3.5 (f32 LE)
|
||||
entry[4..8].copy_from_slice(&3.5f32.to_le_bytes());
|
||||
// fill: solid
|
||||
entry[8] = 0x00;
|
||||
entry[12..16].copy_from_slice(&0xffaabbcc_u32.to_le_bytes());
|
||||
|
||||
let stroke = parse_stroke_from_bytes(&entry);
|
||||
|
||||
assert_eq!(stroke.kind, StrokeKind::Inner);
|
||||
assert_eq!(stroke.style, StrokeStyle::Dashed);
|
||||
assert_eq!(stroke.cap_start, Some(StrokeCap::LineArrow));
|
||||
assert_eq!(stroke.cap_end, Some(StrokeCap::Round));
|
||||
assert_eq!(stroke.width, 3.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_multiple_strokes() {
|
||||
let mut buffer = vec![0u8; 2 * STROKE_ENTRY_SIZE];
|
||||
|
||||
// First stroke: center solid
|
||||
buffer[0] = 0; // center
|
||||
buffer[1] = 0; // solid
|
||||
buffer[4..8].copy_from_slice(&2.0f32.to_le_bytes());
|
||||
buffer[8] = 0x00; // solid fill
|
||||
buffer[12..16].copy_from_slice(&0xff000000_u32.to_le_bytes());
|
||||
|
||||
// Second stroke: outer dotted
|
||||
let off = STROKE_ENTRY_SIZE;
|
||||
buffer[off] = 2; // outer
|
||||
buffer[off + 1] = 1; // dotted
|
||||
buffer[off + 4..off + 8].copy_from_slice(&4.0f32.to_le_bytes());
|
||||
buffer[off + 8] = 0x00; // solid fill
|
||||
buffer[off + 12..off + 16].copy_from_slice(&0xff0000ff_u32.to_le_bytes());
|
||||
|
||||
let strokes = parse_strokes_from_bytes(&buffer, 2);
|
||||
|
||||
assert_eq!(strokes.len(), 2);
|
||||
assert_eq!(strokes[0].kind, StrokeKind::Center);
|
||||
assert_eq!(strokes[0].width, 2.0);
|
||||
assert_eq!(strokes[1].kind, StrokeKind::Outer);
|
||||
assert_eq!(strokes[1].style, StrokeStyle::Dotted);
|
||||
assert_eq!(strokes[1].width, 4.0);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user