lumen_core/node_ir.rs
1//! Retained Node IR - typed scene-graph tree produced from the flat `Extracted*` bag.
2//!
3//! ## Why a tree?
4//!
5//! The render side previously consumed a flat ECS of one-component-per-drawable, painter-sorted at submit
6//! ([`crate::render_world::ExtractedRect`] / [`ExtractedText`] / [`ExtractedShadow`] / [`ExtractedOutline`]).
7//! The flat shape lost the parent-driven invariants every modern scenegraph relies on - opacity composition,
8//! transform stacks, and clip regions all have to be reconstructed at submit. This module replaces that with
9//! a typed [`Node`] tree owned per-frame by the render world.
10//!
11//! ## 1:1 mapping to Qt SceneGraph + GTK GSK
12//!
13//! The variants below were chosen to map directly onto the two reference scenegraphs - Qt 6.8 Scene Graph
14//! (`QSG*`) and GTK 4 / GSK (`gtk_snapshot_*` / `GskRenderNode`). The renderer back-end only needs to know how
15//! to translate each variant to its native equivalent.
16//!
17//! | Lumen [`Node`] | Qt SceneGraph | GTK 4 / GSK |
18//! |-------------------|--------------------------------------------------------|----------------------------------------------------------------------|
19//! | [`Node::Container`] | `QSGNode` (parent of children) | implicit container via `gtk_snapshot_push_*` / `pop` bracket |
20//! | [`Node::Transform`] | `QSGTransformNode::setMatrix` | `gtk_snapshot_push_transform` -> `GskTransformNode` |
21//! | [`Node::Opacity`] | `QSGOpacityNode::setOpacity` | `gtk_snapshot_push_opacity` -> `GskOpacityNode` |
22//! | [`Node::Clip`] (rect) | `QSGClipNode { isRectangular = true }` (scissor) | `gtk_snapshot_push_clip` -> `GskClipNode` |
23//! | [`Node::Clip`] (radii) | `QSGClipNode` + custom geometry | `gtk_snapshot_push_rounded_clip` -> `GskRoundedClipNode` |
24//! | [`Node::Rect`] (solid) | `QSGSimpleRectNode` | `gtk_snapshot_append_color` -> `GskColorNode` |
25//! | [`Node::Rect`] (gradient) | `QSGGeometryNode` + gradient `QSGMaterial` | `GskLinearGradientNode` / `GskRadialGradientNode` / `GskConicGradientNode` |
26//! | [`Node::Shadow`] (outer) | `QSGGeometryNode` + blur material | `gtk_snapshot_append_outset_shadow` -> `GskOutsetShadowNode` |
27//! | [`Node::Shadow`] (inner) | (custom material) | `gtk_snapshot_append_inset_shadow` -> `GskInsetShadowNode` |
28//! | [`Node::Outline`] | `QSGGeometryNode` (line list) | `GskBorderNode` (4-side uniform) or composed `GskColorNode`s |
29//! | [`Node::Text`] | `QSGTextNode` (via `QSGRendererInterface::createTextNode`) | `gtk_snapshot_append_layout` -> `GskTextNode` |
30//! | [`Node::Image`] | `QSGSimpleTextureNode::setTexture` + `setSourceRect`| `gtk_snapshot_append_texture` -> `GskTextureNode` |
31//! | [`Node::Native`] | `QSGRenderNode` | `GskGLShaderNode` / `gtk_snapshot_push_gl_shader` |
32//!
33//! ## Content sharing
34//!
35//! Children are held in `Arc<Node>` so identical subtrees can share storage across frames - the diff can
36//! short-circuit via `Arc::ptr_eq` and the leaf-encoding [`crate::render_world::SceneFragmentCache`] becomes a
37//! content-addressed layer on top.
38//!
39//! ## Wave 2 status
40//!
41//! - W2.1 ships the types + a `transform_extracted_to_nodes` system that walks the existing extract output and
42//! produces a [`RetainedScene`] each frame. The legacy `Extracted*` components stay in place so the existing
43//! render systems keep compiling during the migration.
44//! - W2.2 wires the renderer walker (`lumen_render_wgpu::walk_node`) to consume [`RetainedScene`].
45//! - W2.3 puts overflow clipping back on the rails via the [`Node::Clip`] variant - see the [`Clip`] doc-comment.
46//! - W2.4 lets the offscreen render path reuse the same walker (and hence the [`crate::render_world::SceneFragmentCache`]).
47
48use crate::components::{Color, ImageBlob, SvgPayload};
49use crate::render_world::{
50 Brush, ExtractedBorder, ExtractedClipBox, ExtractedImage, ExtractedOutline, ExtractedRect,
51 ExtractedScrollbar, ExtractedShadow, ExtractedText, PaintOrder, Rect,
52};
53use bevy_ecs::prelude::Resource;
54use glam::Vec2;
55use std::any::Any;
56use std::sync::Arc;
57
58/// A 2D affine transform stored as `[a, b, c, d, e, f]` in column-major order - same convention as
59/// `vello::kurbo::Affine` so back-ends can construct without conversion glue. The default is the identity.
60#[derive(Clone, Copy, Debug, PartialEq)]
61pub struct Affine2 {
62 /// Row-major 2x3 coefficients: `[m11, m12, m21, m22, tx, ty]`. The identity is `[1, 0, 0, 1, 0, 0]`.
63 pub coeffs: [f64; 6],
64}
65
66impl Affine2 {
67 /// Identity transform.
68 pub const IDENTITY: Affine2 = Affine2 {
69 coeffs: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
70 };
71
72 /// Pure translation `(tx, ty)`.
73 pub const fn translate(tx: f64, ty: f64) -> Self {
74 Self {
75 coeffs: [1.0, 0.0, 0.0, 1.0, tx, ty],
76 }
77 }
78}
79
80impl Default for Affine2 {
81 fn default() -> Self {
82 Self::IDENTITY
83 }
84}
85
86/// Clip-region shape for [`Node::Clip`]. Rectangular clips map onto scissor on backends that support it
87/// (Qt `QSGClipNode { isRectangular = true }`, GSK `GskClipNode`); rounded clips require stencil / mask
88/// (Qt `QSGClipNode` + geometry, GSK `GskRoundedClipNode`).
89#[derive(Clone, Copy, Debug, PartialEq)]
90pub enum ClipShape {
91 /// Axis-aligned rectangle.
92 Rect(Rect),
93 /// Rounded rectangle with per-corner radii in `[top_left, top_right, bottom_right, bottom_left]` order
94 /// (CSS shorthand). A single uniform radius is represented by all four entries equal.
95 RoundedRect {
96 /// Bounding rect.
97 rect: Rect,
98 /// Per-corner radii.
99 radii: [f32; 4],
100 },
101}
102
103impl ClipShape {
104 /// Builds a [`ClipShape::RoundedRect`] when `radius > 0.0`, otherwise [`ClipShape::Rect`].
105 pub fn from_rect_radius(rect: Rect, radius: f32) -> Self {
106 if radius > 0.0 {
107 Self::RoundedRect {
108 rect,
109 radii: [radius; 4],
110 }
111 } else {
112 Self::Rect(rect)
113 }
114 }
115}
116
117impl From<&ExtractedClipBox> for ClipShape {
118 fn from(c: &ExtractedClipBox) -> Self {
119 let rect = Rect::new(c.origin, c.size);
120 Self::from_rect_radius(rect, c.radius)
121 }
122}
123
124/// One node in the retained scene-graph tree. The tree is produced each tick by
125/// [`transform_extracted_to_nodes`] and rendered by the back-end walker.
126///
127/// Children - wherever they appear - are held as `Arc<Node>` so identical subtrees share storage and the
128/// inter-frame diff can short-circuit on `Arc::ptr_eq`. Leaf variants own their parameters by value (cheap
129/// copies, the inner brush already shares its stop array via `Arc<[...]>`).
130#[derive(Clone)]
131pub enum Node {
132 /// Ordered list of children painted back-to-front. The tree root is always a [`Container`].
133 ///
134 /// [`Container`]: Node::Container
135 Container {
136 /// Ordered children (paint order = vec order).
137 children: Vec<Arc<Node>>,
138 },
139 /// Affine transform pushed onto the back-end's transform stack. Wraps a single child subtree.
140 ///
141 /// Maps to `QSGTransformNode::setMatrix` / `gtk_snapshot_push_transform`.
142 Transform {
143 /// Affine matrix.
144 matrix: Affine2,
145 /// Child subtree.
146 child: Arc<Node>,
147 },
148 /// Opacity multiplier pushed onto the back-end's compositing stack. Multiplies into the alpha of every
149 /// descendant - including nested [`Opacity`] groups. Wraps a single child subtree.
150 ///
151 /// Maps to `QSGOpacityNode::setOpacity` / `gtk_snapshot_push_opacity`.
152 ///
153 /// [`Opacity`]: Node::Opacity
154 Opacity {
155 /// `[0.0, 1.0]` multiplier applied to descendant alpha.
156 alpha: f32,
157 /// Child subtree.
158 child: Arc<Node>,
159 },
160 /// Clip region pushed onto the back-end's clip stack. Descendants are masked to `shape`. Wraps a single
161 /// child subtree.
162 ///
163 /// Maps to `QSGClipNode` (scissor when rectangular, stencil when rounded) /
164 /// `gtk_snapshot_push_clip` / `gtk_snapshot_push_rounded_clip`. Authored from
165 /// `overflow: hidden` containers and `<scroll>` viewports.
166 Clip {
167 /// Clip-region shape.
168 shape: ClipShape,
169 /// Child subtree.
170 child: Arc<Node>,
171 },
172 /// Filled rectangle leaf - solid or gradient.
173 ///
174 /// Maps to `QSGSimpleRectNode` / `GskColorNode` / gradient nodes.
175 Rect {
176 /// Bounding rect in window coordinates.
177 bounds: Rect,
178 /// Fill brush.
179 brush: Brush,
180 /// Uniform corner radius. `0.0` = sharp.
181 corner: f32,
182 /// Per-corner radii `[tl, tr, br, bl]`; `None` = uniform `corner`.
183 corners: Option<[f32; 4]>,
184 },
185 /// Shadow leaf - outer drop shadow or inset shadow.
186 ///
187 /// Outer maps to `gtk_snapshot_append_outset_shadow` / `QSGGeometryNode` + blur material;
188 /// inner maps to `gtk_snapshot_append_inset_shadow`.
189 Shadow {
190 /// Top-left in window coordinates.
191 origin: Vec2,
192 /// Source rect size.
193 size: Vec2,
194 /// Corner radius.
195 radius: f32,
196 /// CSS spread radius (inflates / deflates the rect pre-blur).
197 spread: f32,
198 /// Gaussian blur std-dev.
199 blur: f32,
200 /// Shadow color.
201 color: Color,
202 /// `true` for an inset shadow (clipped to the source rect, drawn at the negated offset).
203 inner: bool,
204 /// Source rect top-left without the per-shadow offset. Used by inset shadows for the clip rect and
205 /// to flip the offset; ignored for outer shadows.
206 rect_origin: Vec2,
207 },
208 /// CSS border leaf - the ring between the border box and the padding
209 /// box, filled with one solid color. Per-side widths supported;
210 /// distinct from [`Node::Outline`], which strokes centered on the box
211 /// edge and never affects layout.
212 ///
213 /// Maps to `GskBorderNode` / a `QSGGeometryNode` ring.
214 Border {
215 /// Border-box top-left in window coordinates.
216 origin: Vec2,
217 /// Border-box size.
218 size: Vec2,
219 /// Per-side widths `[top, right, bottom, left]`.
220 widths: [f32; 4],
221 /// Solid border color.
222 color: Color,
223 /// Per-side color overrides `[top, right, bottom, left]`.
224 side_colors: Option<[Color; 4]>,
225 /// Outer corner radius.
226 radius: f32,
227 /// Per-corner outer radii `[tl, tr, br, bl]`; `None` = uniform.
228 corners: Option<[f32; 4]>,
229 },
230 /// Stroked outline leaf - typically a focus ring.
231 ///
232 /// Maps to `QSGGeometryNode` (line list) / `GskBorderNode`.
233 Outline {
234 /// Top-left in window coordinates.
235 origin: Vec2,
236 /// Box size being outlined.
237 size: Vec2,
238 /// Stroke color.
239 stroke: Color,
240 /// Stroke width.
241 width: f32,
242 /// Uniform corner radius (matches the outlined box).
243 radius: f32,
244 },
245 /// Text leaf - one shaped run for now. Future BiDi rewrite (wave 5) lifts this to a `Vec<ShapedRunRef>`.
246 ///
247 /// Maps to `QSGTextNode` / `GskTextNode`. The leaf carries the *unshaped* string + style; the renderer
248 /// drives the shaper because text shaping is `&mut TextShaper`-bound and can't sit in an immutable IR.
249 Text {
250 /// Wrapped legacy [`ExtractedText`] - keeps the field set stable while wave 2 ships the IR.
251 /// Wave 5 BiDi rewrites this to a `Vec<ShapedRunRef>` + baseline.
252 run: ExtractedText,
253 },
254 /// Raster image leaf.
255 ///
256 /// Maps to `QSGSimpleTextureNode` / `GskTextureNode`. Wraps the legacy [`ExtractedImage`] payload so the
257 /// existing pipeline keeps its blob-identity GPU upload cache.
258 Image {
259 /// Wrapped legacy [`ExtractedImage`].
260 image: ExtractedImage,
261 /// Opaque blob carrier - typically `Arc<lumen_assets::ExtractedImageBlob>`. `None` when the renderer
262 /// doesn't need a separate blob (e.g. headless).
263 blob: Option<Arc<dyn Any + Send + Sync>>,
264 },
265 /// Vector image leaf (SVG pre-rendered into a vello sub-scene).
266 ///
267 /// Maps to `QQuickSvgItem` -> SG subtree / `GskCairoNode` (or a pre-baked `GskTextureNode`).
268 /// Stored as an opaque `Arc<dyn Any + Send + Sync>` so `lumen-core` doesn't need to depend on `vello`.
269 Svg {
270 /// Type-erased SVG payload. The concrete type is typically `Arc<lumen_assets::ExtractedSvg>`.
271 payload: Arc<dyn Any + Send + Sync>,
272 },
273 /// Native back-end escape hatch - apps record custom RHI/wgpu commands.
274 ///
275 /// Maps to `QSGRenderNode` / `GskGLShaderNode`. The renderer downcasts `payload` based on `extension_id`.
276 Native {
277 /// String identifier for the native extension (e.g. `"lumen.native.wgpu"`).
278 extension_id: Arc<str>,
279 /// Opaque payload the back-end downcasts.
280 payload: Arc<dyn Any + Send + Sync>,
281 },
282}
283
284impl std::fmt::Debug for Node {
285 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286 match self {
287 Node::Container { children } => f
288 .debug_struct("Container")
289 .field("children", &children.len())
290 .finish(),
291 Node::Transform { matrix, .. } => {
292 f.debug_struct("Transform").field("matrix", matrix).finish()
293 }
294 Node::Opacity { alpha, .. } => f.debug_struct("Opacity").field("alpha", alpha).finish(),
295 Node::Clip { shape, .. } => f.debug_struct("Clip").field("shape", shape).finish(),
296 Node::Rect { bounds, corner, .. } => f
297 .debug_struct("Rect")
298 .field("bounds", bounds)
299 .field("corner", corner)
300 .finish(),
301 Node::Shadow {
302 origin,
303 size,
304 inner,
305 ..
306 } => f
307 .debug_struct("Shadow")
308 .field("origin", origin)
309 .field("size", size)
310 .field("inner", inner)
311 .finish(),
312 Node::Border {
313 origin,
314 size,
315 widths,
316 ..
317 } => f
318 .debug_struct("Border")
319 .field("origin", origin)
320 .field("size", size)
321 .field("widths", widths)
322 .finish(),
323 Node::Outline { origin, size, .. } => f
324 .debug_struct("Outline")
325 .field("origin", origin)
326 .field("size", size)
327 .finish(),
328 Node::Text { run } => f
329 .debug_struct("Text")
330 .field("len", &run.text.len())
331 .finish(),
332 Node::Image { image, .. } => f
333 .debug_struct("Image")
334 .field("origin", &image.origin)
335 .field("size", &image.size)
336 .finish(),
337 Node::Svg { .. } => f.debug_struct("Svg").finish(),
338 Node::Native { extension_id, .. } => {
339 f.debug_struct("Native").field("ext", extension_id).finish()
340 }
341 }
342 }
343}
344
345impl From<&ExtractedRect> for Node {
346 fn from(r: &ExtractedRect) -> Self {
347 Node::Rect {
348 bounds: Rect::new(r.origin, r.size),
349 brush: r.brush.clone(),
350 corner: r.radius,
351 corners: r.corner_radii,
352 }
353 }
354}
355
356impl From<&ExtractedShadow> for Node {
357 fn from(s: &ExtractedShadow) -> Self {
358 Node::Shadow {
359 origin: s.origin,
360 size: s.size,
361 radius: s.radius,
362 spread: s.spread,
363 blur: s.blur,
364 color: s.color,
365 inner: s.inner,
366 rect_origin: s.rect_origin,
367 }
368 }
369}
370
371impl From<&ExtractedBorder> for Node {
372 fn from(b: &ExtractedBorder) -> Self {
373 Node::Border {
374 origin: b.origin,
375 size: b.size,
376 widths: b.widths,
377 color: b.color,
378 side_colors: b.side_colors,
379 radius: b.radius,
380 corners: b.corner_radii,
381 }
382 }
383}
384
385impl From<&ExtractedOutline> for Node {
386 fn from(o: &ExtractedOutline) -> Self {
387 Node::Outline {
388 origin: o.origin,
389 size: o.size,
390 stroke: o.stroke,
391 width: o.width,
392 radius: o.radius,
393 }
394 }
395}
396
397impl From<&ExtractedText> for Node {
398 fn from(t: &ExtractedText) -> Self {
399 Node::Text { run: t.clone() }
400 }
401}
402
403impl From<&ExtractedImage> for Node {
404 fn from(i: &ExtractedImage) -> Self {
405 Node::Image {
406 image: i.clone(),
407 blob: None,
408 }
409 }
410}
411
412impl From<(&ExtractedImage, &ImageBlob)> for Node {
413 fn from((i, b): (&ExtractedImage, &ImageBlob)) -> Self {
414 Node::Image {
415 image: i.clone(),
416 blob: Some(b.0.clone()),
417 }
418 }
419}
420
421impl From<&SvgPayload> for Node {
422 fn from(s: &SvgPayload) -> Self {
423 Node::Svg {
424 payload: s.payload.clone(),
425 }
426 }
427}
428
429/// One drawable entry produced during extract, sorted by [`PaintOrder`] before tree assembly.
430#[derive(Clone)]
431pub enum DrawEntry {
432 /// Rect leaf.
433 Rect(Arc<Node>),
434 /// Shadow leaf.
435 Shadow(Arc<Node>),
436 /// Border leaf.
437 Border(Arc<Node>),
438 /// Outline leaf.
439 Outline(Arc<Node>),
440 /// Text leaf.
441 Text(Arc<Node>),
442 /// Image leaf.
443 Image(Arc<Node>),
444 /// Svg leaf.
445 Svg(Arc<Node>),
446 /// Push-clip marker - pairs with a later [`DrawEntry::PopClip`] at the same logical depth.
447 PushClip(ClipShape),
448 /// Pop-clip marker - matches the most recent [`DrawEntry::PushClip`].
449 PopClip,
450}
451
452/// The retained scene-graph root for the current frame.
453///
454/// Holds the root [`Node`] (always a [`Node::Container`]) plus an opportunistic content-sharing pool keyed
455/// by appearance - wave 2 only wires this loosely; later waves add proper cache lookup on the producer side.
456#[derive(Resource, Default)]
457pub struct RetainedScene {
458 /// Root container - `None` until the first [`transform_extracted_to_nodes`] tick.
459 pub root: Option<Arc<Node>>,
460}
461
462impl std::fmt::Debug for RetainedScene {
463 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
464 f.debug_struct("RetainedScene")
465 .field("has_root", &self.root.is_some())
466 .finish()
467 }
468}
469
470/// Snapshot of the previous tick's [`RetainedScene`]. Stored on the render world so the renderer can diff
471/// `Arc::ptr_eq` between corresponding subtrees and emit damage rects into [`crate::render_world::FrameDamage`].
472///
473/// Wave 2 stores the root only; the depth-first diff lives in the back-end walker.
474#[derive(Resource, Default)]
475pub struct PreviousScene {
476 /// Root of the prior tick's tree. `None` for the first frame.
477 pub root: Option<Arc<Node>>,
478}
479
480impl std::fmt::Debug for PreviousScene {
481 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
482 f.debug_struct("PreviousScene")
483 .field("has_root", &self.root.is_some())
484 .finish()
485 }
486}
487
488/// Sort key for ordering [`DrawEntry`] before tree assembly.
489type EntryOrder = PaintOrder;
490
491/// Builds a [`RetainedScene`] from the flat `Extracted*` components in the render world.
492///
493/// Walks every `ExtractedRect`/`Shadow`/`Outline`/`Text`/`Image`/`Svg`/`ClipBox` once, sorts the leaves by
494/// [`PaintOrder`], folds the clip pairs around the leaves they enclose, and produces an `Arc<Node::Container>`
495/// containing the painter-ordered children. The previous frame's root is moved into [`PreviousScene`] so the
496/// walker can diff against it.
497///
498/// Runs in [`crate::render_world::RenderStage::Prepare`] (registered by `App::new`).
499#[allow(clippy::too_many_arguments)]
500pub fn transform_extracted_to_nodes(
501 mut retained: bevy_ecs::system::ResMut<RetainedScene>,
502 mut previous: bevy_ecs::system::ResMut<PreviousScene>,
503 rects: bevy_ecs::system::Query<&ExtractedRect>,
504 borders: bevy_ecs::system::Query<&ExtractedBorder>,
505 shadows: bevy_ecs::system::Query<&ExtractedShadow>,
506 outlines: bevy_ecs::system::Query<&ExtractedOutline>,
507 texts: bevy_ecs::system::Query<&ExtractedText>,
508 images: bevy_ecs::system::Query<(&ExtractedImage, Option<&ImageBlob>)>,
509 svgs: bevy_ecs::system::Query<&SvgPayload>,
510 clips: bevy_ecs::system::Query<&ExtractedClipBox>,
511 scrollbars: bevy_ecs::system::Query<&ExtractedScrollbar>,
512) {
513 // Park the prior frame's root in PreviousScene so the walker / damage diff can compare ptr-equal subtrees.
514 previous.root = retained.root.take();
515
516 // Collect leaves sorted by PaintOrder. Each leaf is wrapped in an Arc<Node>.
517 let mut entries: Vec<(EntryOrder, Arc<Node>)> = Vec::with_capacity(
518 rects.iter().len()
519 + borders.iter().len()
520 + shadows.iter().len()
521 + outlines.iter().len()
522 + texts.iter().len()
523 + images.iter().len()
524 + svgs.iter().len(),
525 );
526 for r in &rects {
527 entries.push((r.order, Arc::new(Node::from(r))));
528 }
529 // Borders share the entity's own order key with its background rect;
530 // pushing them after rects keeps `background -> border` paint order
531 // through the stable sort below.
532 for b in &borders {
533 entries.push((b.order, Arc::new(Node::from(b))));
534 }
535 for s in &shadows {
536 entries.push((s.order, Arc::new(Node::from(s))));
537 }
538 for o in &outlines {
539 entries.push((o.order, Arc::new(Node::from(o))));
540 }
541 for t in &texts {
542 entries.push((t.order, Arc::new(Node::from(t))));
543 }
544 for (i, maybe_blob) in &images {
545 // Splice the type-erased blob payload (set by lumen-assets in its extract pass) directly
546 // into Node::Image.blob - the renderer walker downcasts it back. Closes the loop from the
547 // round-4 W36 / W39 deferral note: the on-screen path previously spliced blobs via a
548 // window-winit auxiliary loop because lumen-core couldn't see lumen-assets' blob type.
549 let node = match maybe_blob {
550 Some(blob) => Node::from((i, blob)),
551 None => Node::from(i),
552 };
553 entries.push((i.order, Arc::new(node)));
554 }
555 for s in &svgs {
556 entries.push((s.order, Arc::new(Node::from(s))));
557 }
558 // Overlay scrollbars: pushed LAST so the stable sort keeps them
559 // after any other leaf sharing their paint-order key, and `draws`
560 // order (track -> thumb) is preserved within the bar.
561 for sb in &scrollbars {
562 for d in &sb.draws {
563 entries.push((
564 sb.order,
565 Arc::new(Node::Rect {
566 bounds: Rect::new(d.origin, d.size),
567 brush: Brush::Solid(d.color),
568 corner: d.radius,
569 corners: None,
570 }),
571 ));
572 }
573 }
574 entries.sort_by_key(|(k, _)| *k);
575
576 // Collect clip ranges sorted by start_order so the assembly loop can bracket leaves with the right
577 // push/pop sequence. A clip wraps every leaf whose order is in `[start_order, end_order]`.
578 let mut clip_ranges: Vec<(PaintOrder, PaintOrder, ClipShape)> = clips
579 .iter()
580 .map(|c| (c.start_order, c.end_order, ClipShape::from(c)))
581 .collect();
582 clip_ranges.sort_by_key(|(s, _, _)| *s);
583
584 // Single pass: at each leaf, close any open clips whose end has passed, then open any clips whose
585 // start matches the leaf's order. The result is a flat children Vec carrying the painter-ordered leaves
586 // wrapped in Clip subtrees.
587 let mut next_clip = 0usize;
588 let mut open_clips: Vec<(PaintOrder, ClipShape, Vec<Arc<Node>>)> = Vec::new();
589 let mut roots: Vec<Arc<Node>> = Vec::new();
590
591 fn flush_open(
592 open_clips: &mut Vec<(PaintOrder, ClipShape, Vec<Arc<Node>>)>,
593 roots: &mut Vec<Arc<Node>>,
594 until_order: PaintOrder,
595 ) {
596 while let Some((end, _, _)) = open_clips.last() {
597 if *end >= until_order {
598 break;
599 }
600 let (_, shape, children) = open_clips.pop().expect("checked above");
601 let container = Arc::new(Node::Container { children });
602 let clip_node = Arc::new(Node::Clip {
603 shape,
604 child: container,
605 });
606 push_into(open_clips, roots, clip_node);
607 }
608 }
609
610 fn push_into(
611 open_clips: &mut [(PaintOrder, ClipShape, Vec<Arc<Node>>)],
612 roots: &mut Vec<Arc<Node>>,
613 child: Arc<Node>,
614 ) {
615 if let Some(top) = open_clips.last_mut() {
616 top.2.push(child);
617 } else {
618 roots.push(child);
619 }
620 }
621
622 for (order, leaf) in entries {
623 // Close finished clips first.
624 flush_open(&mut open_clips, &mut roots, order);
625 // Open any new clips that start at/before this leaf.
626 while next_clip < clip_ranges.len() && clip_ranges[next_clip].0 <= order {
627 let (_, end, shape) = clip_ranges[next_clip];
628 open_clips.push((end, shape, Vec::new()));
629 next_clip += 1;
630 }
631 push_into(&mut open_clips, &mut roots, leaf);
632 }
633 // Drain any remaining clips.
634 while let Some((_, shape, children)) = open_clips.pop() {
635 let container = Arc::new(Node::Container { children });
636 let clip_node = Arc::new(Node::Clip {
637 shape,
638 child: container,
639 });
640 if let Some(top) = open_clips.last_mut() {
641 top.2.push(clip_node);
642 } else {
643 roots.push(clip_node);
644 }
645 }
646
647 retained.root = Some(Arc::new(Node::Container { children: roots }));
648}
649
650#[cfg(test)]
651mod tests {
652 use super::*;
653 use crate::components::Color;
654
655 fn solid_rect(order: PaintOrder, origin: Vec2, size: Vec2) -> ExtractedRect {
656 ExtractedRect {
657 origin,
658 size,
659 brush: Brush::Solid(Color::rgba(1.0, 0.0, 0.0, 1.0)),
660 radius: 0.0,
661 corner_radii: None,
662 order,
663 }
664 }
665
666 #[test]
667 fn node_from_rect_round_trips_fields() {
668 let r = solid_rect(0, Vec2::new(1.0, 2.0), Vec2::new(10.0, 20.0));
669 let node = Node::from(&r);
670 match node {
671 Node::Rect { bounds, corner, .. } => {
672 assert_eq!(bounds.origin, Vec2::new(1.0, 2.0));
673 assert_eq!(bounds.size, Vec2::new(10.0, 20.0));
674 assert_eq!(corner, 0.0);
675 }
676 _ => panic!("expected Rect"),
677 }
678 }
679
680 /// R-css-flex: at the shared paint-order key, the background rect
681 /// assembles before the border leaf (CSS background -> border), and
682 /// both come before higher-order (descendant) leaves.
683 #[test]
684 fn border_assembles_after_rect_at_same_order() {
685 let mut retained = RetainedScene::default();
686 let mut previous = PreviousScene::default();
687 let mut world = bevy_ecs::world::World::new();
688 world.spawn(solid_rect(4, Vec2::ZERO, Vec2::new(10.0, 10.0)));
689 world.spawn(ExtractedBorder {
690 origin: Vec2::ZERO,
691 size: Vec2::new(10.0, 10.0),
692 widths: [1.0; 4],
693 color: Color::rgba(0.0, 0.0, 1.0, 1.0),
694 side_colors: None,
695 radius: 0.0,
696 corner_radii: None,
697 order: 4,
698 });
699 world.spawn(solid_rect(6, Vec2::ZERO, Vec2::new(4.0, 4.0)));
700
701 let mut schedule = bevy_ecs::schedule::Schedule::default();
702 schedule.add_systems(transform_extracted_to_nodes);
703 world.insert_resource(std::mem::take(&mut retained));
704 world.insert_resource(std::mem::take(&mut previous));
705 schedule.run(&mut world);
706
707 let retained = world.resource::<RetainedScene>();
708 let root = retained.root.as_ref().expect("root");
709 let Node::Container { children } = root.as_ref() else {
710 panic!("root is a container");
711 };
712 assert_eq!(children.len(), 3);
713 assert!(matches!(children[0].as_ref(), Node::Rect { .. }));
714 assert!(matches!(children[1].as_ref(), Node::Border { .. }));
715 assert!(matches!(children[2].as_ref(), Node::Rect { .. }));
716 }
717
718 #[test]
719 fn clipshape_from_extracted_clipbox_routes_radius() {
720 let sharp = ExtractedClipBox {
721 origin: Vec2::ZERO,
722 size: Vec2::new(10.0, 10.0),
723 radius: 0.0,
724 start_order: 0,
725 end_order: 1,
726 };
727 let rounded = ExtractedClipBox {
728 origin: Vec2::ZERO,
729 size: Vec2::new(10.0, 10.0),
730 radius: 5.0,
731 start_order: 0,
732 end_order: 1,
733 };
734 assert!(matches!(ClipShape::from(&sharp), ClipShape::Rect(_)));
735 assert!(matches!(
736 ClipShape::from(&rounded),
737 ClipShape::RoundedRect { .. }
738 ));
739 }
740}