Skip to main content

bevy_react/svg/
raster.rs

1//! Rasterize svg surfaces into element-owned textures at laid-out size —
2//! [`SvgDocument`]s for file mode, the ECS [`SvgShape`] tree for JSX mode —
3//! and keep the file-mode intrinsic-size measure stamped across `bevy_ui`'s
4//! clears.
5//!
6//! [`update_svg_surfaces`] mirrors the `<canvas>` update discipline
7//! ([`crate::canvas::update_canvas_surfaces`]): CPU-side raster into the
8//! node's [`ImageNode`] image, `contains` before `get_mut` so an idle surface
9//! never re-uploads, a [`LayerContentDirt`](crate::layer::LayerContentDirt)
10//! tap before every pixel write, and zero mutable derefs on a clean entity.
11//! JSX repaint dirt is **derived, not flagged**: shape-attr and child-list
12//! changes tick `Changed<SvgShape>`/`Changed<Children>` (the reconcile writes
13//! are compare-before-write, so the ticks are real changes) — plus
14//! `RemovedComponents<Children>` for a container emptied of its last child —
15//! and this system climbs each to its enclosing `<svg>` root. Only `viewBox`
16//! changes and the mount state ride the explicit `SvgSurface::dirty` flag.
17
18use bevy::asset::{AssetEvent, AssetId, Assets};
19use bevy::ecs::change_detection::Ref;
20use bevy::ecs::entity::EntityHashSet;
21use bevy::prelude::*;
22use bevy::render::render_resource::Extent3d;
23use bevy::ui::widget::ImageNode;
24use bevy::ui::{ComputedNode, ComputedUiRenderTargetInfo, ContentSize};
25
26use super::walk::{ShapeQuery, climb_to_svg_root, walk_shapes};
27use super::{SvgDocument, SvgShape, SvgSurface, stamp_intrinsic_measure};
28
29/// Rasterize `doc` into a `w`×`h` pixmap with the web's `<img>` behavior for
30/// SVG sources: uniform scale, `xMidYMid meet` centering — the document's
31/// intrinsic aspect is letterboxed into the target box, never stretched.
32/// `None` on a zero-sized target.
33pub fn rasterize_document(doc: &SvgDocument, w: u32, h: u32) -> Option<tiny_skia::Pixmap> {
34    let mut pixmap = tiny_skia::Pixmap::new(w, h)?;
35    if doc.size.x <= 0.0 || doc.size.y <= 0.0 {
36        return Some(pixmap); // nothing to draw (usvg guarantees non-zero)
37    }
38    // Same `xMidYMid meet` math as the JSX painter's viewBox fit — one shared
39    // helper, two callers (see `paint::view_box_transform`).
40    let transform = super::paint::meet_transform(Vec2::ZERO, doc.size, w, h);
41    resvg::render(&doc.tree, transform, &mut pixmap.as_mut());
42    Some(pixmap)
43}
44
45/// Drain the frame's [`SvgDocument`] asset events into the set of documents
46/// that finished loading or hot-reloaded — each forces a re-raster (and a
47/// measure re-stamp) of every node displaying it.
48fn touched_docs(events: &mut MessageReader<AssetEvent<SvgDocument>>) -> Vec<AssetId<SvgDocument>> {
49    let mut touched = Vec::new();
50    for event in events.read() {
51        if let AssetEvent::LoadedWithDependencies { id } | AssetEvent::Modified { id } = event {
52            touched.push(*id);
53        }
54    }
55    touched
56}
57
58/// Repaint every svg surface whose raster is stale — a repaint request
59/// (`dirty`), a layout resize, a document load/hot-reload (file mode), or a
60/// shape/child-list change (JSX mode, derived below) — and upload the result
61/// into the backing image. Reads the node's size from [`ComputedNode`]
62/// (already physical px, so HiDPI rasters crisp); a freshly-mounted node has
63/// no size yet and rasters next frame, once layout ran.
64#[allow(clippy::type_complexity, clippy::too_many_arguments)]
65pub fn update_svg_surfaces(
66    mut images: ResMut<Assets<Image>>,
67    docs: Res<Assets<SvgDocument>>,
68    mut doc_events: MessageReader<AssetEvent<SvgDocument>>,
69    mut dirt: ResMut<crate::layer::LayerContentDirt>,
70    mut query: Query<(
71        Entity,
72        &ComputedNode,
73        &ImageNode,
74        Option<&Children>,
75        &mut SvgSurface,
76        &mut ContentSize,
77    )>,
78    shapes: ShapeQuery,
79    changed_shapes: Query<Entity, Changed<SvgShape>>,
80    changed_children: Query<Entity, (Changed<Children>, Or<(With<SvgShape>, With<SvgSurface>)>)>,
81    mut removed_children: RemovedComponents<Children>,
82    parents: Query<&ChildOf>,
83    svg_roots: Query<(), With<SvgSurface>>,
84) {
85    // JSX dirt derivation, inline rather than a separate system: the signals
86    // are `Changed<…>` filters relative to THIS system's last run, which is
87    // exactly the "what changed since I last rastered" question — a prelude
88    // system would need a handoff resource and its own tick bookkeeping for
89    // zero ordering benefit. The reconcile writes are queued commands flushed
90    // in `apply_js_ops`'s sync point, so they are visible here same-frame
91    // (pinned by `shape_delta_rerasters_same_frame`).
92    let mut jsx_dirty = EntityHashSet::default();
93    for entity in &changed_shapes {
94        if let Some(root) = climb_to_svg_root(entity, &parents, &svg_roots) {
95            jsx_dirty.insert(root);
96        }
97    }
98    for entity in &changed_children {
99        // The root's own child list changed (shape attach/remove/reorder), or
100        // a `<g>`'s did — the latter climbs like any shape change.
101        if svg_roots.contains(entity) {
102            jsx_dirty.insert(entity);
103        } else if let Some(root) = climb_to_svg_root(entity, &parents, &svg_roots) {
104            jsx_dirty.insert(root);
105        }
106    }
107    // Removing the LAST child is invisible to `Changed<Children>`: bevy's
108    // `ChildOf` on_remove hook removes the now-empty `Children` component
109    // outright (bevy_ecs relationship/mod.rs), and a filter can't match a
110    // component that is gone. Catch it via removal events — only for a
111    // still-alive svg root or shape group (a despawned entity's own removal
112    // is covered by its parent's `Changed`/removed signal).
113    for entity in removed_children.read() {
114        if svg_roots.contains(entity) {
115            jsx_dirty.insert(entity);
116        } else if shapes.contains(entity)
117            && let Some(root) = climb_to_svg_root(entity, &parents, &svg_roots)
118        {
119            jsx_dirty.insert(root);
120        }
121    }
122
123    let touched = touched_docs(&mut doc_events);
124    for (entity, node, image_node, children, mut surface, mut content_size) in &mut query {
125        // `None` is a JSX `<svg>` root — its picture is the `SvgShape`
126        // children, not an asset.
127        let Some(doc_handle) = surface.doc.as_ref() else {
128            raster_jsx_surface(
129                entity,
130                node,
131                image_node,
132                children,
133                &mut surface,
134                jsx_dirty.contains(&entity),
135                &shapes,
136                &mut images,
137                &mut dirt,
138            );
139            continue;
140        };
141        let Some(doc) = docs.get(doc_handle) else {
142            continue; // not loaded yet; `dirty` stays set, rasters on load
143        };
144        let doc_touched = touched.contains(&doc_handle.id());
145        let (w, h) = crate::canvas::clamp_physical_size(node.size);
146        if w == 0 || h == 0 {
147            // Not laid out (fresh node) or hidden (`display: none`). A doc
148            // hot-reload seen now would otherwise be lost with the drained
149            // event — persist it into `dirty` so a re-show at the *same* size
150            // still re-rasters. Compare-before-write keeps the plain
151            // zero-size skip deref-free.
152            if doc_touched && !surface.dirty {
153                surface.dirty = true;
154            }
155            continue;
156        }
157        let size = UVec2::new(w, h);
158        if !surface.dirty && surface.last_size == size && !doc_touched {
159            continue; // clean: not a single mutable deref taken
160        }
161        // `contains` (not `get_mut`) so a skipped raster below never flags the
162        // asset changed — and thus re-uploaded — for nothing.
163        if !images.contains(&image_node.image) {
164            continue;
165        }
166        let Some(pixmap) = rasterize_document(doc, w, h) else {
167            continue;
168        };
169        upload_pixmap(entity, image_node, &mut images, &mut dirt, w, h, &pixmap);
170        // First successful raster for this node: stamp the intrinsic measure.
171        // This covers docs that arrive without a load event (e.g. parked
172        // directly into `Assets` — only `Added` fires); an event-carrying
173        // load/hot-reload is `stamp_svg_measures`'s job — it reads the same
174        // event this same frame, in PostUpdate — so `doc_touched` deliberately
175        // does not re-stamp here (no double stamp).
176        if surface.last_size == UVec2::ZERO {
177            let scale_factor = if node.inverse_scale_factor > 0.0 {
178                node.inverse_scale_factor.recip()
179            } else {
180                1.0
181            };
182            stamp_intrinsic_measure(
183                &mut content_size,
184                doc.size,
185                scale_factor,
186                image_node.visual_box,
187            );
188        }
189        // Compare-before-write: any `deref_mut` ticks `Changed<SvgSurface>`,
190        // so touch only the fields that are actually stale.
191        if surface.last_size != size {
192            surface.last_size = size;
193        }
194        if surface.dirty {
195            surface.dirty = false;
196        }
197    }
198}
199
200/// The JSX branch of [`update_svg_surfaces`]: paint the root's [`SvgShape`]
201/// children (depth-first, groups composed — see [`walk_shapes`]) through the
202/// viewBox fit into a fresh pixmap and upload it. `derived_dirty` is the
203/// walked `Changed<SvgShape>`/`Changed<Children>` signal for this root;
204/// `surface.dirty` covers the mount state and `viewBox` writes. Same
205/// discipline as the file branch: clean roots take zero mutable derefs, and
206/// derived-dirt repaints never touch `SvgSurface` at all.
207#[allow(clippy::too_many_arguments)] // a private per-entity slice of the system's params
208fn raster_jsx_surface(
209    entity: Entity,
210    node: &ComputedNode,
211    image_node: &ImageNode,
212    children: Option<&Children>,
213    surface: &mut Mut<SvgSurface>,
214    derived_dirty: bool,
215    shapes: &ShapeQuery,
216    images: &mut Assets<Image>,
217    dirt: &mut crate::layer::LayerContentDirt,
218) {
219    let (w, h) = crate::canvas::clamp_physical_size(node.size);
220    if w == 0 || h == 0 {
221        // Not laid out (fresh node) or hidden (`display: none`). Derived dirt
222        // is a one-shot `Changed<…>` signal — persist it into `dirty` so a
223        // re-show at the *same* size still repaints. Compare-before-write
224        // keeps the plain zero-size skip deref-free.
225        if derived_dirty && !surface.dirty {
226            surface.dirty = true;
227        }
228        return;
229    }
230    let size = UVec2::new(w, h);
231    if !surface.dirty && surface.last_size == size && !derived_dirty {
232        return; // clean: not a single mutable deref taken
233    }
234    // `contains` (not `get_mut`) so a skipped raster below never flags the
235    // asset changed — and thus re-uploaded — for nothing.
236    if !images.contains(&image_node.image) {
237        return;
238    }
239    let Some(mut pixmap) = tiny_skia::Pixmap::new(w, h) else {
240        return;
241    };
242    let scale_factor = if node.inverse_scale_factor > 0.0 {
243        node.inverse_scale_factor.recip()
244    } else {
245        1.0
246    };
247    let transform = super::paint::view_box_transform(surface.view_box.as_ref(), w, h, scale_factor);
248    if let Some(children) = children {
249        walk_shapes(children, shapes, transform, 1.0, &mut |_, shape, t, o| {
250            super::paint::paint_shape(&mut pixmap, shape.kind, &shape.attrs, t, o);
251        });
252    }
253    upload_pixmap(entity, image_node, images, dirt, w, h, &pixmap);
254    // Compare-before-write: any `deref_mut` ticks `Changed<SvgSurface>`, so
255    // touch only the fields that are actually stale.
256    if surface.last_size != size {
257        surface.last_size = size;
258    }
259    if surface.dirty {
260        surface.dirty = false;
261    }
262}
263
264/// Upload freshly-painted pixels into the node's element-owned image: the
265/// [`LayerContentDirt`](crate::layer::LayerContentDirt) tap first (a real
266/// pixel write stales the owning layer's capture), then resize-if-needed and
267/// the straight-alpha write. Shared by the file and JSX branches — callers
268/// verified `images.contains` before painting.
269fn upload_pixmap(
270    entity: Entity,
271    image_node: &ImageNode,
272    images: &mut Assets<Image>,
273    dirt: &mut crate::layer::LayerContentDirt,
274    w: u32,
275    h: u32,
276    pixmap: &tiny_skia::Pixmap,
277) {
278    dirt.nodes.push(entity);
279    let Some(mut image) = images.get_mut(&image_node.image) else {
280        return;
281    };
282    let extent = Extent3d {
283        width: w,
284        height: h,
285        depth_or_array_layers: 1,
286    };
287    if image.texture_descriptor.size != extent {
288        image.resize(extent);
289    }
290    image.data = Some(crate::canvas::to_straight_alpha(pixmap));
291}
292
293/// Re-stamp the svg intrinsic measure after `bevy_ui` may have cleared it.
294///
295/// `bevy_ui`'s `update_image_content_size_system` (`PostUpdate`,
296/// `UiSystems::Content`) **clears** the `ContentSize` measure of any
297/// non-`Auto`-mode `ImageNode` that changed this frame — and every svg-mode
298/// prop rebuild re-inserts the `ImageNode` (svg mode is `Stretch`), so the
299/// measure would vanish on each delta. Registered after that system / before
300/// `UiSystems::Layout`, this re-stamps only when a trigger fired: the
301/// `ImageNode` changed (exactly the clear condition), the render-target scale
302/// factor changed (`bevy_ui`'s own re-measure trigger), or the document
303/// finished loading / hot-reloaded. No trigger → no `ContentSize` deref → no
304/// spurious relayout.
305pub fn stamp_svg_measures(
306    docs: Res<Assets<SvgDocument>>,
307    mut doc_events: MessageReader<AssetEvent<SvgDocument>>,
308    mut query: Query<(
309        Ref<ImageNode>,
310        &SvgSurface,
311        &mut ContentSize,
312        Ref<ComputedUiRenderTargetInfo>,
313    )>,
314) {
315    let touched = touched_docs(&mut doc_events);
316    for (image, surface, mut content_size, target) in &mut query {
317        let Some(doc_handle) = surface.doc.as_ref() else {
318            continue; // future JSX mode: no asset-backed intrinsic size
319        };
320        if !image.is_changed() && !target.is_changed() && !touched.contains(&doc_handle.id()) {
321            continue;
322        }
323        let Some(doc) = docs.get(doc_handle) else {
324            continue; // not loaded; the load event re-triggers this later
325        };
326        let sf = target.scale_factor();
327        let scale_factor = if sf > 0.0 { sf } else { 1.0 };
328        stamp_intrinsic_measure(&mut content_size, doc.size, scale_factor, image.visual_box);
329    }
330}
331
332#[cfg(test)]
333mod tests;