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;