Skip to main content

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}