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 a renderer's leaf-encoding fragment cache (`lumen_paint::FragmentCache`)
37//! becomes a content-addressed layer on top.
38//!
39//! ## Producer and consumer
40//!
41//! `transform_extracted_to_nodes` walks the extract output and produces a [`RetainedScene`] each frame; the
42//! walker in `lumen-paint` consumes it for every renderer and every kind of target. Overflow clipping rides the
43//! [`Node::Clip`] variant - see the [`Clip`] doc-comment.
44
45use crate::components::{Color, ImageBlob, SvgPayload};
46use crate::native::ExtractedNative;
47use crate::render_world::{
48    BackgroundClip, Brush, ExtractedBorder, ExtractedClipBox, ExtractedImage,
49    ExtractedOpacityGroup, ExtractedOutline, ExtractedRect, ExtractedScrollbar, ExtractedShadow,
50    ExtractedText, PaintOrder, Rect,
51};
52use bevy_ecs::entity::Entity;
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    /// Uniform scale by `s` about the origin.
80    pub const fn scale(s: f64) -> Self {
81        Self {
82            coeffs: [s, 0.0, 0.0, s, 0.0, 0.0],
83        }
84    }
85}
86
87/// Composition: `outer * inner` applies `inner` first, matching `vello::kurbo::Affine`.
88impl std::ops::Mul for Affine2 {
89    type Output = Affine2;
90
91    fn mul(self, rhs: Affine2) -> Affine2 {
92        let a = self.coeffs;
93        let b = rhs.coeffs;
94        Affine2 {
95            coeffs: [
96                a[0] * b[0] + a[2] * b[1],
97                a[1] * b[0] + a[3] * b[1],
98                a[0] * b[2] + a[2] * b[3],
99                a[1] * b[2] + a[3] * b[3],
100                a[0] * b[4] + a[2] * b[5] + a[4],
101                a[1] * b[4] + a[3] * b[5] + a[5],
102            ],
103        }
104    }
105}
106
107impl Default for Affine2 {
108    fn default() -> Self {
109        Self::IDENTITY
110    }
111}
112
113/// Clip-region shape for [`Node::Clip`]. Rectangular clips map onto scissor on backends that support it
114/// (Qt `QSGClipNode { isRectangular = true }`, GSK `GskClipNode`); rounded clips require stencil / mask
115/// (Qt `QSGClipNode` + geometry, GSK `GskRoundedClipNode`).
116#[derive(Clone, Copy, Debug, PartialEq)]
117pub enum ClipShape {
118    /// Axis-aligned rectangle.
119    Rect(Rect),
120    /// Rounded rectangle with per-corner radii in `[top_left, top_right, bottom_right, bottom_left]` order
121    /// (CSS shorthand). A single uniform radius is represented by all four entries equal.
122    RoundedRect {
123        /// Bounding rect.
124        rect: Rect,
125        /// Per-corner radii.
126        radii: [f32; 4],
127    },
128}
129
130impl ClipShape {
131    /// Builds a [`ClipShape::RoundedRect`] when `radius > 0.0`, otherwise [`ClipShape::Rect`].
132    pub fn from_rect_radius(rect: Rect, radius: f32) -> Self {
133        if radius > 0.0 {
134            Self::RoundedRect {
135                rect,
136                radii: [radius; 4],
137            }
138        } else {
139            Self::Rect(rect)
140        }
141    }
142}
143
144impl From<&ExtractedClipBox> for ClipShape {
145    fn from(c: &ExtractedClipBox) -> Self {
146        let rect = Rect::new(c.origin, c.size);
147        Self::from_rect_radius(rect, c.radius)
148    }
149}
150
151/// One node in the retained scene-graph tree. The tree is produced each tick by
152/// [`transform_extracted_to_nodes`] and rendered by the back-end walker.
153///
154/// Children - wherever they appear - are held as `Arc<Node>` so identical subtrees share storage and the
155/// inter-frame diff can short-circuit on `Arc::ptr_eq`. Leaf variants own their parameters by value (cheap
156/// copies, the inner brush already shares its stop array via `Arc<[...]>`).
157#[derive(Clone)]
158pub enum Node {
159    /// Ordered list of children painted back-to-front. The tree root is always a [`Container`].
160    ///
161    /// [`Container`]: Node::Container
162    Container {
163        /// Ordered children (paint order = vec order).
164        children: Vec<Arc<Node>>,
165    },
166    /// Affine transform pushed onto the back-end's transform stack. Wraps a single child subtree.
167    ///
168    /// Maps to `QSGTransformNode::setMatrix` / `gtk_snapshot_push_transform`.
169    Transform {
170        /// Affine matrix.
171        matrix: Affine2,
172        /// Child subtree.
173        child: Arc<Node>,
174    },
175    /// Group opacity: the child subtree paints into a layer of its own at full alpha, and the layer
176    /// composites at `alpha` when it closes, so the subtree fades once, as a whole, the way CSS
177    /// `opacity` does. A descendant over an ancestor hides it inside the group just as it would at
178    /// full opacity. Nested [`Opacity`] groups composite into the enclosing layer. Wraps a single
179    /// child subtree; authored from [`ExtractedOpacityGroup`].
180    ///
181    /// Maps to `gtk_snapshot_push_opacity` / a layered `QQuickItem` (`layer.enabled` with `opacity`).
182    ///
183    /// [`Opacity`]: Node::Opacity
184    Opacity {
185        /// `[0.0, 1.0]` alpha the layer composites at.
186        alpha: f32,
187        /// Child subtree.
188        child: Arc<Node>,
189        /// Names the group across frames, so a backend can keep the group's layer and composite it
190        /// again while the child paints the same, whatever `alpha` does. `None` for a group with no
191        /// such identity, which paints its layer afresh every frame.
192        key: Option<u64>,
193    },
194    /// Clip region pushed onto the back-end's clip stack. Descendants are masked to `shape`. Wraps a single
195    /// child subtree.
196    ///
197    /// Maps to `QSGClipNode` (scissor when rectangular, stencil when rounded) /
198    /// `gtk_snapshot_push_clip` / `gtk_snapshot_push_rounded_clip`. Authored from
199    /// `overflow: hidden` containers and `<scroll>` viewports.
200    Clip {
201        /// Clip-region shape.
202        shape: ClipShape,
203        /// Child subtree.
204        child: Arc<Node>,
205    },
206    /// Filled rectangle leaf - solid or gradient.
207    ///
208    /// Maps to `QSGSimpleRectNode` / `GskColorNode` / gradient nodes.
209    Rect {
210        /// Bounding rect in window coordinates.
211        bounds: Rect,
212        /// Fill brush.
213        brush: Brush,
214        /// Uniform corner radius. `0.0` = sharp.
215        corner: f32,
216        /// Per-corner radii `[tl, tr, br, bl]`; `None` = uniform `corner`.
217        corners: Option<[f32; 4]>,
218    },
219    /// Shadow leaf - outer drop shadow or inset shadow.
220    ///
221    /// Outer maps to `gtk_snapshot_append_outset_shadow` / `QSGGeometryNode` + blur material;
222    /// inner maps to `gtk_snapshot_append_inset_shadow`.
223    Shadow {
224        /// Top-left in window coordinates.
225        origin: Vec2,
226        /// Source rect size.
227        size: Vec2,
228        /// Corner radius.
229        radius: f32,
230        /// CSS spread radius (inflates / deflates the rect pre-blur).
231        spread: f32,
232        /// Gaussian blur std-dev.
233        blur: f32,
234        /// Shadow color.
235        color: Color,
236        /// `true` for an inset shadow (the source rect minus a blurred copy of it moved by the offset).
237        inner: bool,
238        /// Source rect top-left without the per-shadow offset. Used by inset shadows for the clip rect and
239        /// to flip the offset; ignored for outer shadows.
240        rect_origin: Vec2,
241    },
242    /// CSS border leaf - the ring between the border box and the padding
243    /// box, filled with one solid color. Per-side widths supported;
244    /// distinct from [`Node::Outline`], which strokes centered on the box
245    /// edge and never affects layout.
246    ///
247    /// Maps to `GskBorderNode` / a `QSGGeometryNode` ring.
248    Border {
249        /// Border-box top-left in window coordinates.
250        origin: Vec2,
251        /// Border-box size.
252        size: Vec2,
253        /// Per-side widths `[top, right, bottom, left]`.
254        widths: [f32; 4],
255        /// Solid border color.
256        color: Color,
257        /// Per-side color overrides `[top, right, bottom, left]`.
258        side_colors: Option<[Color; 4]>,
259        /// Outer corner radius.
260        radius: f32,
261        /// Per-corner outer radii `[tl, tr, br, bl]`; `None` = uniform.
262        corners: Option<[f32; 4]>,
263    },
264    /// Stroked outline leaf - typically a focus ring.
265    ///
266    /// Maps to `QSGGeometryNode` (line list) / `GskBorderNode`.
267    Outline {
268        /// Top-left in window coordinates.
269        origin: Vec2,
270        /// Box size being outlined.
271        size: Vec2,
272        /// Stroke color.
273        stroke: Color,
274        /// Stroke width.
275        width: f32,
276        /// Uniform corner radius (matches the outlined box).
277        radius: f32,
278    },
279    /// Text leaf - one shaped run for now. Future BiDi rewrite (wave 5) lifts this to a `Vec<ShapedRunRef>`.
280    ///
281    /// Maps to `QSGTextNode` / `GskTextNode`. The leaf carries the *unshaped* string + style; the renderer
282    /// drives the shaper because text shaping is `&mut TextShaper`-bound and can't sit in an immutable IR.
283    Text {
284        /// Wrapped legacy [`ExtractedText`] - keeps the field set stable while wave 2 ships the IR.
285        /// Wave 5 BiDi rewrites this to a `Vec<ShapedRunRef>` + baseline.
286        run: ExtractedText,
287    },
288    /// Raster image leaf.
289    ///
290    /// Maps to `QSGSimpleTextureNode` / `GskTextureNode`. Wraps the legacy [`ExtractedImage`] payload so the
291    /// existing pipeline keeps its blob-identity GPU upload cache.
292    Image {
293        /// Wrapped legacy [`ExtractedImage`].
294        image: ExtractedImage,
295        /// Opaque blob carrier - typically `Arc<lumen_assets::ExtractedImageBlob>`. `None` when the renderer
296        /// doesn't need a separate blob (e.g. headless).
297        blob: Option<Arc<dyn Any + Send + Sync>>,
298    },
299    /// Vector image leaf (SVG pre-rendered into a vello sub-scene).
300    ///
301    /// Maps to `QQuickSvgItem` -> SG subtree / `GskCairoNode` (or a pre-baked `GskTextureNode`).
302    /// Stored as an opaque `Arc<dyn Any + Send + Sync>` so `lumen-core` doesn't need to depend on `vello`.
303    Svg {
304        /// Type-erased SVG payload. The concrete type is typically `Arc<lumen_assets::ExtractedSvg>`.
305        payload: Arc<dyn Any + Send + Sync>,
306    },
307    /// Backend-painted leaf contributed by a plugin - see [`crate::native`] for the seam.
308    ///
309    /// Maps to `QSGRenderNode` / `GskGLShaderNode`. The backend looks up a
310    /// [`crate::native::NativePainter`] by `extension_id` and hands it the payload plus its own
311    /// draw target; an unregistered id paints nothing.
312    Native {
313        /// String identifier for the native extension (e.g. `"lumen.native.wgpu"`).
314        extension_id: Arc<str>,
315        /// Opaque payload the back-end downcasts.
316        payload: Arc<dyn Any + Send + Sync>,
317        /// Bounding rect in window coordinates - encloses every pixel the painter touches.
318        bounds: Rect,
319        /// Content stamp; equal revisions mean identical pixels.
320        revision: u64,
321        /// Clip the painter to `bounds`.
322        clip_to_bounds: bool,
323    },
324}
325
326impl std::fmt::Debug for Node {
327    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
328        match self {
329            Node::Container { children } => f
330                .debug_struct("Container")
331                .field("children", &children.len())
332                .finish(),
333            Node::Transform { matrix, .. } => {
334                f.debug_struct("Transform").field("matrix", matrix).finish()
335            }
336            Node::Opacity { alpha, .. } => f.debug_struct("Opacity").field("alpha", alpha).finish(),
337            Node::Clip { shape, .. } => f.debug_struct("Clip").field("shape", shape).finish(),
338            Node::Rect { bounds, corner, .. } => f
339                .debug_struct("Rect")
340                .field("bounds", bounds)
341                .field("corner", corner)
342                .finish(),
343            Node::Shadow {
344                origin,
345                size,
346                inner,
347                ..
348            } => f
349                .debug_struct("Shadow")
350                .field("origin", origin)
351                .field("size", size)
352                .field("inner", inner)
353                .finish(),
354            Node::Border {
355                origin,
356                size,
357                widths,
358                ..
359            } => f
360                .debug_struct("Border")
361                .field("origin", origin)
362                .field("size", size)
363                .field("widths", widths)
364                .finish(),
365            Node::Outline { origin, size, .. } => f
366                .debug_struct("Outline")
367                .field("origin", origin)
368                .field("size", size)
369                .finish(),
370            Node::Text { run } => f
371                .debug_struct("Text")
372                .field("len", &run.text.len())
373                .finish(),
374            Node::Image { image, .. } => f
375                .debug_struct("Image")
376                .field("origin", &image.origin)
377                .field("size", &image.size)
378                .finish(),
379            Node::Svg { .. } => f.debug_struct("Svg").finish(),
380            Node::Native {
381                extension_id,
382                bounds,
383                revision,
384                ..
385            } => f
386                .debug_struct("Native")
387                .field("ext", extension_id)
388                .field("bounds", bounds)
389                .field("revision", revision)
390                .finish(),
391        }
392    }
393}
394
395impl From<&ExtractedRect> for Node {
396    fn from(r: &ExtractedRect) -> Self {
397        Node::Rect {
398            bounds: Rect::new(r.origin, r.size),
399            brush: r.brush.clone(),
400            corner: r.radius,
401            corners: r.corner_radii,
402        }
403    }
404}
405
406impl From<&ExtractedShadow> for Node {
407    fn from(s: &ExtractedShadow) -> Self {
408        Node::Shadow {
409            origin: s.origin,
410            size: s.size,
411            radius: s.radius,
412            spread: s.spread,
413            blur: s.blur,
414            color: s.color,
415            inner: s.inner,
416            rect_origin: s.rect_origin,
417        }
418    }
419}
420
421impl From<&ExtractedBorder> for Node {
422    fn from(b: &ExtractedBorder) -> Self {
423        Node::Border {
424            origin: b.origin,
425            size: b.size,
426            widths: b.widths,
427            color: b.color,
428            side_colors: b.side_colors,
429            radius: b.radius,
430            corners: b.corner_radii,
431        }
432    }
433}
434
435impl From<&ExtractedOutline> for Node {
436    fn from(o: &ExtractedOutline) -> Self {
437        Node::Outline {
438            origin: o.origin,
439            size: o.size,
440            stroke: o.stroke,
441            width: o.width,
442            radius: o.radius,
443        }
444    }
445}
446
447impl From<&ExtractedText> for Node {
448    fn from(t: &ExtractedText) -> Self {
449        Node::Text { run: t.clone() }
450    }
451}
452
453impl From<&ExtractedImage> for Node {
454    fn from(i: &ExtractedImage) -> Self {
455        Node::Image {
456            image: i.clone(),
457            blob: None,
458        }
459    }
460}
461
462impl From<(&ExtractedImage, &ImageBlob)> for Node {
463    fn from((i, b): (&ExtractedImage, &ImageBlob)) -> Self {
464        Node::Image {
465            image: i.clone(),
466            blob: Some(b.0.clone()),
467        }
468    }
469}
470
471impl From<&SvgPayload> for Node {
472    fn from(s: &SvgPayload) -> Self {
473        Node::Svg {
474            payload: s.payload.clone(),
475        }
476    }
477}
478
479impl From<&ExtractedNative> for Node {
480    fn from(n: &ExtractedNative) -> Self {
481        Node::Native {
482            extension_id: n.extension_id.clone(),
483            payload: n.payload.clone(),
484            bounds: n.bounds,
485            revision: n.revision,
486            clip_to_bounds: n.clip_to_bounds,
487        }
488    }
489}
490
491/// One drawable entry produced during extract, sorted by [`PaintOrder`] before tree assembly.
492#[derive(Clone)]
493pub enum DrawEntry {
494    /// Rect leaf.
495    Rect(Arc<Node>),
496    /// Shadow leaf.
497    Shadow(Arc<Node>),
498    /// Border leaf.
499    Border(Arc<Node>),
500    /// Outline leaf.
501    Outline(Arc<Node>),
502    /// Text leaf.
503    Text(Arc<Node>),
504    /// Image leaf.
505    Image(Arc<Node>),
506    /// Svg leaf.
507    Svg(Arc<Node>),
508    /// Push-clip marker - pairs with a later [`DrawEntry::PopClip`] at the same logical depth.
509    PushClip(ClipShape),
510    /// Pop-clip marker - matches the most recent [`DrawEntry::PushClip`].
511    PopClip,
512}
513
514/// The retained scene-graph root for the current frame.
515///
516/// Holds the root [`Node`] (always a [`Node::Container`]) plus an opportunistic content-sharing pool keyed
517/// by appearance - wave 2 only wires this loosely; later waves add proper cache lookup on the producer side.
518#[derive(Resource, Default)]
519pub struct RetainedScene {
520    /// Root container - `None` until the first [`transform_extracted_to_nodes`] tick.
521    pub root: Option<Arc<Node>>,
522}
523
524impl std::fmt::Debug for RetainedScene {
525    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
526        f.debug_struct("RetainedScene")
527            .field("has_root", &self.root.is_some())
528            .finish()
529    }
530}
531
532/// Snapshot of the previous tick's [`RetainedScene`]. Stored on the render world so the renderer can diff
533/// corresponding subtrees (short-circuiting on `Arc::ptr_eq`) and skip a frame whose tree did not change.
534///
535/// Wave 2 stores the root only; the depth-first diff lives in the back-end walker.
536#[derive(Resource, Default)]
537pub struct PreviousScene {
538    /// Root of the prior tick's tree. `None` for the first frame.
539    pub root: Option<Arc<Node>>,
540}
541
542impl std::fmt::Debug for PreviousScene {
543    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
544        f.debug_struct("PreviousScene")
545            .field("has_root", &self.root.is_some())
546            .finish()
547    }
548}
549
550/// Wraps a background image leaf in the clip of the box it fills, so it takes
551/// the element's corner radii the way the element's colour does.
552fn background_clip(clip: BackgroundClip, leaf: Node) -> Arc<Node> {
553    let rect = Rect::new(clip.origin, clip.size);
554    let shape = if clip.radii.iter().any(|r| *r > 0.0) {
555        ClipShape::RoundedRect {
556            rect,
557            radii: clip.radii,
558        }
559    } else {
560        ClipShape::Rect(rect)
561    };
562    Arc::new(Node::Clip {
563        shape,
564        child: Arc::new(Node::Container {
565            children: vec![Arc::new(leaf)],
566        }),
567    })
568}
569
570/// Sort key for ordering leaves before tree assembly: the [`PaintOrder`] doubled, plus one for every
571/// leaf but an overlay scrollbar. A scroll container's bars sit at its last descendant's order plus
572/// one, which is the slot the next element's drop shadows share; keyed under them, the bars stay out
573/// of an [`ExtractedOpacityGroup`] the next element opens at that slot, and paint under its shadows,
574/// as a later sibling paints over an earlier one.
575type EntryOrder = u64;
576
577/// The [`EntryOrder`] of an ordinary leaf at `order`.
578fn leaf_key(order: PaintOrder) -> EntryOrder {
579    u64::from(order) * 2 + 1
580}
581
582/// The [`EntryOrder`] of an overlay scrollbar at `order`.
583fn scrollbar_key(order: PaintOrder) -> EntryOrder {
584    u64::from(order) * 2
585}
586
587/// What a bracketed range of leaves is wrapped in.
588#[derive(Clone, Copy)]
589enum Bracket {
590    /// A [`Node::Clip`].
591    Clip(ClipShape),
592    /// A [`Node::Opacity`] group.
593    Opacity(f32, u64),
594}
595
596impl Bracket {
597    fn wrap(self, children: Vec<Arc<Node>>) -> Arc<Node> {
598        let child = Arc::new(Node::Container { children });
599        Arc::new(match self {
600            Bracket::Clip(shape) => Node::Clip { shape, child },
601            Bracket::Opacity(alpha, key) => Node::Opacity {
602                alpha,
603                child,
604                key: Some(key),
605            },
606        })
607    }
608}
609
610/// Builds a [`RetainedScene`] from the flat `Extracted*` components in the render world.
611///
612/// Walks every `ExtractedRect`/`Shadow`/`Outline`/`Text`/`Image`/`Svg`/`Native`/`ClipBox` once, sorts the leaves by
613/// [`PaintOrder`], folds the clip pairs around the leaves they enclose, and produces an `Arc<Node::Container>`
614/// containing the painter-ordered children. The previous frame's root is moved into [`PreviousScene`] so the
615/// walker can diff against it.
616///
617/// Runs in [`crate::render_world::RenderStage::Prepare`] (registered by `App::new`).
618#[allow(clippy::too_many_arguments)]
619pub fn transform_extracted_to_nodes(
620    mut retained: bevy_ecs::system::ResMut<RetainedScene>,
621    mut previous: bevy_ecs::system::ResMut<PreviousScene>,
622    rects: bevy_ecs::system::Query<&ExtractedRect>,
623    borders: bevy_ecs::system::Query<&ExtractedBorder>,
624    shadows: bevy_ecs::system::Query<&ExtractedShadow>,
625    outlines: bevy_ecs::system::Query<&ExtractedOutline>,
626    texts: bevy_ecs::system::Query<&ExtractedText>,
627    images: bevy_ecs::system::Query<(&ExtractedImage, Option<&ImageBlob>)>,
628    svgs: bevy_ecs::system::Query<&SvgPayload>,
629    natives: bevy_ecs::system::Query<(bevy_ecs::entity::Entity, &ExtractedNative)>,
630    clips: bevy_ecs::system::Query<&ExtractedClipBox>,
631    groups: bevy_ecs::system::Query<&ExtractedOpacityGroup>,
632    scrollbars: bevy_ecs::system::Query<&ExtractedScrollbar>,
633) {
634    // Park the prior frame's root in PreviousScene so the walker / damage diff can compare ptr-equal subtrees.
635    previous.root = retained.root.take();
636
637    // Collect leaves sorted by PaintOrder. Each leaf is wrapped in an Arc<Node>.
638    let mut entries: Vec<(EntryOrder, Arc<Node>)> = Vec::with_capacity(
639        rects.iter().len()
640            + borders.iter().len()
641            + shadows.iter().len()
642            + outlines.iter().len()
643            + texts.iter().len()
644            + images.iter().len()
645            + svgs.iter().len()
646            + natives.iter().len(),
647    );
648    for r in &rects {
649        entries.push((leaf_key(r.order), Arc::new(Node::from(r))));
650    }
651    // Background images (`bg: url(...)`) share the entity's order key too.
652    // Pushed after the fills and before the borders, they paint over the
653    // element's colour and under its border, clipped to its rounded box.
654    for (i, maybe_blob) in &images {
655        if let Some(clip) = i.background {
656            let leaf = match maybe_blob {
657                Some(blob) => Node::from((i, blob)),
658                None => Node::from(i),
659            };
660            entries.push((leaf_key(i.order), background_clip(clip, leaf)));
661        }
662    }
663    for s in &svgs {
664        if let Some(clip) = s.background {
665            entries.push((leaf_key(s.order), background_clip(clip, Node::from(s))));
666        }
667    }
668    // An inset shadow shares the entity's order key too, and sits between
669    // the background and the border, as CSS paints it.
670    for s in shadows.iter().filter(|s| s.inner) {
671        entries.push((leaf_key(s.order), Arc::new(Node::from(s))));
672    }
673    // Borders share the entity's own order key with its background rect;
674    // pushing them after rects keeps `background -> border` paint order
675    // through the stable sort below.
676    for b in &borders {
677        entries.push((leaf_key(b.order), Arc::new(Node::from(b))));
678    }
679    for s in shadows.iter().filter(|s| !s.inner) {
680        entries.push((leaf_key(s.order), Arc::new(Node::from(s))));
681    }
682    for o in &outlines {
683        entries.push((leaf_key(o.order), Arc::new(Node::from(o))));
684    }
685    for t in &texts {
686        entries.push((leaf_key(t.order), Arc::new(Node::from(t))));
687    }
688    for (i, maybe_blob) in images.iter().filter(|(i, _)| i.background.is_none()) {
689        // Splice the type-erased blob payload (set by lumen-assets in its extract pass) directly
690        // into Node::Image.blob - the renderer walker downcasts it back. Closes the loop from the
691        // round-4 W36 / W39 deferral note: the on-screen path previously spliced blobs via a
692        // window-winit auxiliary loop because lumen-core couldn't see lumen-assets' blob type.
693        let node = match maybe_blob {
694            Some(blob) => Node::from((i, blob)),
695            None => Node::from(i),
696        };
697        entries.push((leaf_key(i.order), Arc::new(node)));
698    }
699    for s in svgs.iter().filter(|s| s.background.is_none()) {
700        entries.push((leaf_key(s.order), Arc::new(Node::from(s))));
701    }
702    // Plugin-painted leaves. Query iteration follows archetype order, which
703    // shifts as plugins add and drop components, so sort on a key that is
704    // total: paint order, then extension id, then the render entity that
705    // carries the leaf. The entity is stable across frames (the extract
706    // helper reuses it), so one extension's several leaves at a shared order
707    // also keep their relative order from frame to frame.
708    let mut native_leaves: Vec<(Entity, &ExtractedNative)> = natives.iter().collect();
709    native_leaves.sort_by(|(ae, a), (be, b)| {
710        a.order
711            .cmp(&b.order)
712            .then_with(|| a.extension_id.cmp(&b.extension_id))
713            .then_with(|| ae.cmp(be))
714    });
715    for (_, n) in native_leaves {
716        entries.push((leaf_key(n.order), Arc::new(Node::from(n))));
717    }
718    // Overlay scrollbars, keyed by `scrollbar_key` under the leaves that
719    // share their paint order; the stable sort keeps `draws` order
720    // (track -> thumb) within the bar.
721    for sb in &scrollbars {
722        for d in &sb.draws {
723            entries.push((
724                scrollbar_key(sb.order),
725                Arc::new(Node::Rect {
726                    bounds: Rect::new(d.origin, d.size),
727                    brush: Brush::Solid(d.color),
728                    corner: d.radius,
729                    corners: None,
730                }),
731            ));
732        }
733    }
734    entries.sort_by_key(|(k, _)| *k);
735
736    // Collect the clip and opacity-group ranges sorted by where they open, so the assembly loop can
737    // bracket leaves with the right push/pop sequence. A range wraps every leaf whose key is in
738    // `[start, end]`. An opacity group opens at the drop-shadow slot under its element and a clip at
739    // the element itself, so an element that does both fades its clipped content; ranges that open
740    // together nest the longer one outside.
741    let mut ranges: Vec<(EntryOrder, EntryOrder, Bracket)> = clips
742        .iter()
743        .map(|c| {
744            (
745                leaf_key(c.start_order),
746                leaf_key(c.end_order),
747                Bracket::Clip(ClipShape::from(c)),
748            )
749        })
750        .chain(groups.iter().map(|g| {
751            (
752                leaf_key(g.start_order),
753                leaf_key(g.end_order),
754                Bracket::Opacity(g.alpha, g.key),
755            )
756        }))
757        .collect();
758    ranges.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)));
759
760    // Single pass: at each leaf, close any open ranges whose end has passed, then open any ranges whose
761    // start the leaf has reached. The result is a flat children Vec carrying the painter-ordered leaves
762    // wrapped in Clip and Opacity subtrees.
763    let mut next_range = 0usize;
764    let mut open: Vec<(EntryOrder, Bracket, Vec<Arc<Node>>)> = Vec::new();
765    let mut roots: Vec<Arc<Node>> = Vec::new();
766
767    fn flush_open(
768        open: &mut Vec<(EntryOrder, Bracket, Vec<Arc<Node>>)>,
769        roots: &mut Vec<Arc<Node>>,
770        until: EntryOrder,
771    ) {
772        while let Some((end, _, _)) = open.last() {
773            if *end >= until {
774                break;
775            }
776            let (_, bracket, children) = open.pop().expect("checked above");
777            push_into(open, roots, bracket.wrap(children));
778        }
779    }
780
781    fn push_into(
782        open: &mut [(EntryOrder, Bracket, Vec<Arc<Node>>)],
783        roots: &mut Vec<Arc<Node>>,
784        child: Arc<Node>,
785    ) {
786        if let Some(top) = open.last_mut() {
787            top.2.push(child);
788        } else {
789            roots.push(child);
790        }
791    }
792
793    for (key, leaf) in entries {
794        // Close finished ranges first.
795        flush_open(&mut open, &mut roots, key);
796        // Open any new ranges that start at/before this leaf.
797        while next_range < ranges.len() && ranges[next_range].0 <= key {
798            let (_, end, bracket) = ranges[next_range];
799            next_range += 1;
800            // A range that already ended holds no leaf of its own: an empty
801            // container clips or fades nothing. Opening it here would wrap this
802            // leaf, which sits past the range, in a bracket it is not inside.
803            if end < key {
804                continue;
805            }
806            open.push((end, bracket, Vec::new()));
807        }
808        push_into(&mut open, &mut roots, leaf);
809    }
810    // Drain any remaining ranges.
811    while let Some((_, bracket, children)) = open.pop() {
812        let node = bracket.wrap(children);
813        if let Some(top) = open.last_mut() {
814            top.2.push(node);
815        } else {
816            roots.push(node);
817        }
818    }
819
820    retained.root = Some(Arc::new(Node::Container { children: roots }));
821}
822
823#[cfg(test)]
824mod tests {
825    use super::*;
826    use crate::components::Color;
827    use crate::render_world::{OVERLAY_ORDER_BASE, ScrollbarDrawRect};
828
829    fn solid_rect(order: PaintOrder, origin: Vec2, size: Vec2) -> ExtractedRect {
830        ExtractedRect {
831            origin,
832            size,
833            brush: Brush::Solid(Color::rgba(1.0, 0.0, 0.0, 1.0)),
834            radius: 0.0,
835            corner_radii: None,
836            order,
837        }
838    }
839
840    #[test]
841    fn node_from_rect_round_trips_fields() {
842        let r = solid_rect(0, Vec2::new(1.0, 2.0), Vec2::new(10.0, 20.0));
843        let node = Node::from(&r);
844        match node {
845            Node::Rect { bounds, corner, .. } => {
846                assert_eq!(bounds.origin, Vec2::new(1.0, 2.0));
847                assert_eq!(bounds.size, Vec2::new(10.0, 20.0));
848                assert_eq!(corner, 0.0);
849            }
850            _ => panic!("expected Rect"),
851        }
852    }
853
854    /// R-css-flex: at the shared paint-order key, the background rect
855    /// assembles before the border leaf (CSS background -> border), and
856    /// both come before higher-order (descendant) leaves.
857    #[test]
858    fn border_assembles_after_rect_at_same_order() {
859        let mut retained = RetainedScene::default();
860        let mut previous = PreviousScene::default();
861        let mut world = bevy_ecs::world::World::new();
862        world.spawn(solid_rect(4, Vec2::ZERO, Vec2::new(10.0, 10.0)));
863        world.spawn(ExtractedBorder {
864            origin: Vec2::ZERO,
865            size: Vec2::new(10.0, 10.0),
866            widths: [1.0; 4],
867            color: Color::rgba(0.0, 0.0, 1.0, 1.0),
868            side_colors: None,
869            radius: 0.0,
870            corner_radii: None,
871            order: 4,
872        });
873        world.spawn(solid_rect(6, Vec2::ZERO, Vec2::new(4.0, 4.0)));
874
875        let mut schedule = bevy_ecs::schedule::Schedule::default();
876        schedule.add_systems(transform_extracted_to_nodes);
877        world.insert_resource(std::mem::take(&mut retained));
878        world.insert_resource(std::mem::take(&mut previous));
879        schedule.run(&mut world);
880
881        let retained = world.resource::<RetainedScene>();
882        let root = retained.root.as_ref().expect("root");
883        let Node::Container { children } = root.as_ref() else {
884            panic!("root is a container");
885        };
886        assert_eq!(children.len(), 3);
887        assert!(matches!(children[0].as_ref(), Node::Rect { .. }));
888        assert!(matches!(children[1].as_ref(), Node::Border { .. }));
889        assert!(matches!(children[2].as_ref(), Node::Rect { .. }));
890    }
891
892    /// An inset shadow paints over its element's background and under its
893    /// border, as CSS draws it, while a drop shadow stays below the
894    /// background. The inset shadow once painted under the background, so
895    /// any element with a `bg` hid it.
896    #[test]
897    fn inset_shadow_sits_between_fill_and_border() {
898        let shadow = |order: PaintOrder, inner: bool| ExtractedShadow {
899            origin: Vec2::ZERO,
900            size: Vec2::new(10.0, 10.0),
901            radius: 0.0,
902            spread: 0.0,
903            blur: 2.0,
904            color: Color::rgba(1.0, 0.0, 0.0, 1.0),
905            order,
906            inner,
907            rect_origin: Vec2::ZERO,
908        };
909        let mut world = bevy_ecs::world::World::new();
910        world.spawn(ExtractedBorder {
911            origin: Vec2::ZERO,
912            size: Vec2::new(10.0, 10.0),
913            widths: [1.0; 4],
914            color: Color::rgba(0.0, 0.0, 1.0, 1.0),
915            side_colors: None,
916            radius: 0.0,
917            corner_radii: None,
918            order: 4,
919        });
920        world.spawn(shadow(4, true));
921        world.spawn(shadow(3, false));
922        world.spawn(solid_rect(4, Vec2::ZERO, Vec2::new(10.0, 10.0)));
923        world.insert_resource(RetainedScene::default());
924        world.insert_resource(PreviousScene::default());
925        let mut schedule = bevy_ecs::schedule::Schedule::default();
926        schedule.add_systems(transform_extracted_to_nodes);
927        schedule.run(&mut world);
928
929        let root = world
930            .resource::<RetainedScene>()
931            .root
932            .clone()
933            .expect("root");
934        let Node::Container { children } = root.as_ref() else {
935            panic!("root is a container");
936        };
937        let kinds: Vec<&str> = children
938            .iter()
939            .map(|n| match n.as_ref() {
940                Node::Shadow { inner: true, .. } => "inset",
941                Node::Shadow { .. } => "drop",
942                Node::Rect { .. } => "fill",
943                Node::Border { .. } => "border",
944                _ => "other",
945            })
946            .collect();
947        assert_eq!(kinds, ["drop", "fill", "inset", "border"]);
948    }
949
950    fn image(order: PaintOrder, background: Option<BackgroundClip>) -> ExtractedImage {
951        ExtractedImage {
952            origin: Vec2::ZERO,
953            size: Vec2::new(10.0, 10.0),
954            width: 1,
955            height: 1,
956            rgba: Arc::from(vec![255u8; 4]),
957            fit: crate::components::ImageFit::Cover,
958            order,
959            alpha: 1.0,
960            background,
961        }
962    }
963
964    /// A `bg: url(...)` image paints at its element's own order key over the
965    /// element's colour and under its border, clipped to the element's rounded
966    /// box, and a child still paints over it. An `<image>` at the same key
967    /// keeps its place after the border.
968    #[test]
969    fn a_background_image_sits_between_fill_and_border_inside_its_clip() {
970        let mut world = bevy_ecs::world::World::new();
971        world.spawn(solid_rect(4, Vec2::ZERO, Vec2::new(10.0, 10.0)));
972        world.spawn(ExtractedBorder {
973            origin: Vec2::ZERO,
974            size: Vec2::new(10.0, 10.0),
975            widths: [1.0; 4],
976            color: Color::rgba(0.0, 0.0, 1.0, 1.0),
977            side_colors: None,
978            radius: 3.0,
979            corner_radii: None,
980            order: 4,
981        });
982        world.spawn(image(
983            4,
984            Some(BackgroundClip {
985                origin: Vec2::ZERO,
986                size: Vec2::new(10.0, 10.0),
987                radii: [3.0; 4],
988            }),
989        ));
990        world.spawn(image(4, None));
991        world.spawn(solid_rect(6, Vec2::ZERO, Vec2::new(4.0, 4.0)));
992
993        let children = children_of(&assemble(&mut world));
994        assert_eq!(children.len(), 5);
995        assert!(matches!(children[0].as_ref(), Node::Rect { .. }));
996        let Node::Clip { shape, child } = children[1].as_ref() else {
997            panic!("the background is clipped, got {:?}", children[1]);
998        };
999        assert_eq!(
1000            *shape,
1001            ClipShape::RoundedRect {
1002                rect: Rect::new(Vec2::ZERO, Vec2::new(10.0, 10.0)),
1003                radii: [3.0; 4],
1004            }
1005        );
1006        assert!(matches!(
1007            children_of(child)[0].as_ref(),
1008            Node::Image { image, .. } if image.background.is_some()
1009        ));
1010        assert!(matches!(children[2].as_ref(), Node::Border { .. }));
1011        assert!(matches!(
1012            children[3].as_ref(),
1013            Node::Image { image, .. } if image.background.is_none()
1014        ));
1015        assert!(matches!(children[4].as_ref(), Node::Rect { .. }));
1016    }
1017
1018    fn native(
1019        order: PaintOrder,
1020        extension_id: &str,
1021        origin: Vec2,
1022        revision: u64,
1023    ) -> ExtractedNative {
1024        ExtractedNative {
1025            extension_id: extension_id.into(),
1026            payload: Arc::new(revision),
1027            bounds: Rect::new(origin, Vec2::new(30.0, 30.0)),
1028            order,
1029            revision,
1030            clip_to_bounds: false,
1031        }
1032    }
1033
1034    fn assemble(world: &mut bevy_ecs::world::World) -> Arc<Node> {
1035        world.insert_resource(RetainedScene::default());
1036        world.insert_resource(PreviousScene::default());
1037        let mut schedule = bevy_ecs::schedule::Schedule::default();
1038        schedule.add_systems(transform_extracted_to_nodes);
1039        schedule.run(world);
1040        world
1041            .resource::<RetainedScene>()
1042            .root
1043            .as_ref()
1044            .expect("root")
1045            .clone()
1046    }
1047
1048    fn children_of(node: &Arc<Node>) -> Vec<Arc<Node>> {
1049        match node.as_ref() {
1050            Node::Container { children } => children.clone(),
1051            other => panic!("expected a container, got {other:?}"),
1052        }
1053    }
1054
1055    /// A plugin's leaf reaches the tree with the geometry and content stamp it
1056    /// was extracted with: the renderer positions and repaints it from those,
1057    /// not from anything inside the opaque payload.
1058    #[test]
1059    fn a_native_leaf_carries_its_bounds_and_revision_into_the_tree() {
1060        let mut world = bevy_ecs::world::World::new();
1061        let mut leaf = native(4, "demo.chart", Vec2::new(12.0, 8.0), 77);
1062        leaf.clip_to_bounds = true;
1063        world.spawn(leaf);
1064
1065        let children = children_of(&assemble(&mut world));
1066        assert_eq!(children.len(), 1);
1067        match children[0].as_ref() {
1068            Node::Native {
1069                extension_id,
1070                bounds,
1071                revision,
1072                clip_to_bounds,
1073                ..
1074            } => {
1075                assert_eq!(&**extension_id, "demo.chart");
1076                assert_eq!(bounds.origin, Vec2::new(12.0, 8.0));
1077                assert_eq!(*revision, 77);
1078                assert!(clip_to_bounds);
1079            }
1080            other => panic!("expected Native, got {other:?}"),
1081        }
1082    }
1083
1084    /// At one entity's paint-order key the leaf paints over that entity's own
1085    /// background and border, the way a canvas sits inside the box that styles
1086    /// it.
1087    #[test]
1088    fn a_native_leaf_paints_over_the_box_it_sits_in() {
1089        let mut world = bevy_ecs::world::World::new();
1090        world.spawn(solid_rect(4, Vec2::ZERO, Vec2::new(40.0, 40.0)));
1091        world.spawn(ExtractedBorder {
1092            origin: Vec2::ZERO,
1093            size: Vec2::new(40.0, 40.0),
1094            widths: [1.0; 4],
1095            color: Color::rgba(0.0, 0.0, 1.0, 1.0),
1096            side_colors: None,
1097            radius: 0.0,
1098            corner_radii: None,
1099            order: 4,
1100        });
1101        world.spawn(native(4, "demo.chart", Vec2::ZERO, 1));
1102
1103        let children = children_of(&assemble(&mut world));
1104        assert_eq!(children.len(), 3);
1105        assert!(matches!(children[0].as_ref(), Node::Rect { .. }));
1106        assert!(matches!(children[1].as_ref(), Node::Border { .. }));
1107        assert!(matches!(children[2].as_ref(), Node::Native { .. }));
1108    }
1109
1110    /// Two extensions contributing at the same paint-order key stack by
1111    /// `extension_id`, so the frame does not reorder itself as archetypes churn.
1112    #[test]
1113    fn native_leaves_at_one_order_stack_by_extension_id() {
1114        let mut world = bevy_ecs::world::World::new();
1115        world.spawn(native(4, "zeta.overlay", Vec2::ZERO, 1));
1116        world.spawn(native(4, "alpha.grid", Vec2::ZERO, 1));
1117
1118        let children = children_of(&assemble(&mut world));
1119        let ids: Vec<String> = children
1120            .iter()
1121            .map(|c| match c.as_ref() {
1122                Node::Native { extension_id, .. } => extension_id.to_string(),
1123                other => panic!("expected Native, got {other:?}"),
1124            })
1125            .collect();
1126        assert_eq!(ids, ["alpha.grid", "zeta.overlay"]);
1127    }
1128
1129    /// An enclosing `overflow: hidden` container clips a plugin's leaf the same
1130    /// way it clips a rect, so a scrolled-out chart does not paint over its
1131    /// container's edge.
1132    #[test]
1133    fn an_enclosing_clip_brackets_a_native_leaf() {
1134        let mut world = bevy_ecs::world::World::new();
1135        world.spawn(ExtractedClipBox {
1136            origin: Vec2::new(5.0, 5.0),
1137            size: Vec2::new(50.0, 50.0),
1138            radius: 0.0,
1139            start_order: 4,
1140            end_order: 8,
1141        });
1142        world.spawn(native(6, "demo.chart", Vec2::ZERO, 1));
1143
1144        let children = children_of(&assemble(&mut world));
1145        assert_eq!(children.len(), 1);
1146        let Node::Clip { child, .. } = children[0].as_ref() else {
1147            panic!("expected the leaf wrapped in a clip, got {:?}", children[0]);
1148        };
1149        let inner = children_of(child);
1150        assert!(matches!(inner[0].as_ref(), Node::Native { .. }));
1151    }
1152
1153    /// An empty container's clip range covers no leaf, so the next leaf after
1154    /// it stays outside: a hidden or childless `overflow: hidden` box must not
1155    /// clip the sibling that paints after it.
1156    #[test]
1157    fn an_empty_clip_range_leaves_the_following_leaf_alone() {
1158        let mut world = bevy_ecs::world::World::new();
1159        world.spawn(ExtractedClipBox {
1160            origin: Vec2::new(5.0, 5.0),
1161            size: Vec2::new(10.0, 10.0),
1162            radius: 0.0,
1163            start_order: 4,
1164            end_order: 4,
1165        });
1166        world.spawn(solid_rect(6, Vec2::ZERO, Vec2::new(40.0, 40.0)));
1167
1168        let children = children_of(&assemble(&mut world));
1169        assert_eq!(children.len(), 1);
1170        assert!(
1171            matches!(children[0].as_ref(), Node::Rect { .. }),
1172            "expected an unclipped rect, got {:?}",
1173            children[0]
1174        );
1175    }
1176
1177    /// The same holds for a range nested inside a live one: the leaf keeps the
1178    /// enclosing clip and picks up nothing from the empty range beside it.
1179    #[test]
1180    fn an_empty_clip_range_nested_in_a_live_one_wraps_nothing() {
1181        let mut world = bevy_ecs::world::World::new();
1182        world.spawn(ExtractedClipBox {
1183            origin: Vec2::ZERO,
1184            size: Vec2::new(80.0, 80.0),
1185            radius: 0.0,
1186            start_order: 1,
1187            end_order: 10,
1188        });
1189        world.spawn(ExtractedClipBox {
1190            origin: Vec2::new(2.0, 2.0),
1191            size: Vec2::new(4.0, 4.0),
1192            radius: 0.0,
1193            start_order: 3,
1194            end_order: 4,
1195        });
1196        world.spawn(solid_rect(5, Vec2::ZERO, Vec2::new(40.0, 40.0)));
1197
1198        let children = children_of(&assemble(&mut world));
1199        assert_eq!(children.len(), 1);
1200        let Node::Clip { child, .. } = children[0].as_ref() else {
1201            panic!("expected the outer clip, got {:?}", children[0]);
1202        };
1203        let inner = children_of(child);
1204        assert_eq!(inner.len(), 1);
1205        assert!(
1206            matches!(inner[0].as_ref(), Node::Rect { .. }),
1207            "expected the rect directly under the outer clip, got {:?}",
1208            inner[0]
1209        );
1210    }
1211
1212    /// The top-layer band lifts a plugin's leaf over all normal content, and
1213    /// the overlay scrollbars of a container in that band paint after its
1214    /// content.
1215    #[test]
1216    fn the_overlay_band_and_scrollbars_keep_their_places_around_a_native_leaf() {
1217        let mut world = bevy_ecs::world::World::new();
1218        world.spawn(solid_rect(6, Vec2::ZERO, Vec2::new(40.0, 40.0)));
1219        world.spawn(native(
1220            OVERLAY_ORDER_BASE + 2,
1221            "demo.tooltip",
1222            Vec2::ZERO,
1223            1,
1224        ));
1225        world.spawn(ExtractedScrollbar {
1226            draws: vec![ScrollbarDrawRect {
1227                origin: Vec2::new(90.0, 0.0),
1228                size: Vec2::new(6.0, 40.0),
1229                color: Color::rgba(0.0, 0.0, 0.0, 0.4),
1230                radius: 3.0,
1231            }],
1232            order: OVERLAY_ORDER_BASE + 3,
1233        });
1234
1235        let children = children_of(&assemble(&mut world));
1236        assert_eq!(children.len(), 3);
1237        assert!(matches!(children[0].as_ref(), Node::Rect { .. }));
1238        assert!(matches!(children[1].as_ref(), Node::Native { .. }));
1239        assert!(matches!(children[2].as_ref(), Node::Rect { .. }));
1240    }
1241
1242    /// An opacity group wraps its element's drop-shadow slot and its subtree in one `Opacity` node,
1243    /// and leaves out a preceding container's scrollbar that shares the shadow slot.
1244    #[test]
1245    fn an_opacity_group_wraps_the_faded_subtree_and_nothing_else() {
1246        let mut world = bevy_ecs::world::World::new();
1247        // A scroll container at 2 with content up to 4; its bar sits at 5.
1248        world.spawn(solid_rect(2, Vec2::ZERO, Vec2::new(40.0, 40.0)));
1249        world.spawn(solid_rect(4, Vec2::ZERO, Vec2::new(40.0, 40.0)));
1250        world.spawn(ExtractedScrollbar {
1251            draws: vec![ScrollbarDrawRect {
1252                origin: Vec2::new(34.0, 0.0),
1253                size: Vec2::new(6.0, 40.0),
1254                color: Color::rgba(0.0, 0.0, 0.0, 0.4),
1255                radius: 3.0,
1256            }],
1257            order: 5,
1258        });
1259        // The faded sibling at 6 with a child at 8, and one more element after it.
1260        world.spawn(solid_rect(6, Vec2::ZERO, Vec2::new(40.0, 40.0)));
1261        world.spawn(solid_rect(8, Vec2::ZERO, Vec2::new(20.0, 20.0)));
1262        world.spawn(solid_rect(10, Vec2::ZERO, Vec2::new(10.0, 10.0)));
1263        world.spawn(ExtractedOpacityGroup {
1264            alpha: 0.3,
1265            start_order: 5,
1266            end_order: 8,
1267            key: 1,
1268        });
1269
1270        let children = children_of(&assemble(&mut world));
1271        assert_eq!(
1272            children.len(),
1273            5,
1274            "two rects, the bar, the group, the last rect"
1275        );
1276        assert!(
1277            matches!(children[2].as_ref(), Node::Rect { .. }),
1278            "the bar stays outside"
1279        );
1280        let Node::Opacity { alpha, child, .. } = children[3].as_ref() else {
1281            panic!("expected the group, got {:?}", children[3]);
1282        };
1283        assert_eq!(*alpha, 0.3);
1284        assert_eq!(children_of(child).len(), 2, "the element and its child");
1285        assert!(matches!(children[4].as_ref(), Node::Rect { .. }));
1286    }
1287
1288    #[test]
1289    fn clipshape_from_extracted_clipbox_routes_radius() {
1290        let sharp = ExtractedClipBox {
1291            origin: Vec2::ZERO,
1292            size: Vec2::new(10.0, 10.0),
1293            radius: 0.0,
1294            start_order: 0,
1295            end_order: 1,
1296        };
1297        let rounded = ExtractedClipBox {
1298            origin: Vec2::ZERO,
1299            size: Vec2::new(10.0, 10.0),
1300            radius: 5.0,
1301            start_order: 0,
1302            end_order: 1,
1303        };
1304        assert!(matches!(ClipShape::from(&sharp), ClipShape::Rect(_)));
1305        assert!(matches!(
1306            ClipShape::from(&rounded),
1307            ClipShape::RoundedRect { .. }
1308        ));
1309    }
1310}