Skip to main content

stipple/render/
scene.rs

1//! A retained list of draw primitives, lowered to an `oxideav-core`
2//! [`VectorFrame`] for rasterization.
3//!
4//! `stipple::core` rebuilds a `Scene` from the widget tree each time the visible
5//! state changes; `stipple::render` then rasterizes and presents it. The scene
6//! works entirely in **logical pixels** — DPI scaling is applied at raster
7//! time via the frame's view box (see [`Scene::into_vector_frame`]).
8
9use crate::geometry::{Affine, Point, Rect, Size};
10use crate::render::convert::{to_ox_point, to_transform2d};
11use crate::render::{Color, Pixmap};
12use oxideav_core::{
13    Group, ImageRef, Node, Paint, Path, PathNode, Point as OxPoint, Rect as OxRect, Rgba, Stroke,
14    Transform2D, VectorFrame, ViewBox,
15};
16use std::sync::Arc;
17
18/// Control-point offset for approximating a quarter circle with a cubic
19/// Bézier (the standard `4/3·(√2 − 1)` "kappa" constant).
20const KAPPA: f64 = 0.552_284_749_830_793_4;
21
22/// A structured record of a scene primitive, kept alongside the lowered oxideav
23/// nodes so a GPU backend can consume the scene without re-deriving primitives
24/// from vector paths (the CPU rasterizer uses the nodes; the GPU path uses
25/// these). See [`Scene::commands`].
26#[derive(Clone, Debug, PartialEq)]
27pub enum DrawCmd {
28    /// A box: `radius` rounds the corners (0 = sharp); `border` > 0 strokes the
29    /// outline at that width instead of filling.
30    Rect {
31        rect: Rect,
32        color: Color,
33        radius: f64,
34        border: f64,
35    },
36    /// A single line of text at `origin` (top-left).
37    Text {
38        text: String,
39        origin: Point,
40        size: f64,
41        color: Color,
42    },
43    /// Begin clipping subsequent primitives to `rect` (nests until [`PopClip`]).
44    /// GPU backends map this to a scissor rectangle.
45    ///
46    /// [`PopClip`]: DrawCmd::PopClip
47    PushClip(Rect),
48    /// End the innermost clip region opened by [`PushClip`](DrawCmd::PushClip).
49    PopClip,
50    /// Begin a layer: subsequent primitives (until the matching [`PopLayer`])
51    /// are drawn through `transform` (logical pixels in, logical pixels out,
52    /// composed with any enclosing layer's) and composited **as a group** at
53    /// `opacity` — rendered together at full opacity, then blended once, so
54    /// overlapping primitives inside don't show through each other.
55    ///
56    /// [`PopLayer`]: DrawCmd::PopLayer
57    PushLayer { transform: Affine, opacity: f32 },
58    /// End the innermost layer opened by [`PushLayer`](DrawCmd::PushLayer).
59    PopLayer,
60    /// A reserved area for **embedded content** (a browser page, video, or any
61    /// externally-rendered surface), identified by `id`. The compositor draws
62    /// the content here: the CPU path blits the registered content [`Pixmap`]
63    /// over the placeholder fill after rasterizing; a GPU backend samples the
64    /// imported (dma-buf / `IOSurface` / shared-handle) texture into this rect.
65    /// See [`Scene::fill_viewport`].
66    Viewport { rect: Rect, id: u64 },
67    /// A bitmap drawn into `rect`: an OS-drawn control under the native theme.
68    Image { rect: Rect, image: Arc<Pixmap> },
69}
70
71/// A clip region or layer under construction: the primitives emitted while it
72/// is open, plus the clip path, transform and opacity applied to them as a
73/// group when it closes. The scene keeps a stack of these so
74/// [`Scene::push_clip`]/[`Scene::pop_clip`] and
75/// [`Scene::push_layer`]/[`Scene::pop_layer`] can nest.
76#[derive(Clone, Debug)]
77struct ClipFrame {
78    clip: Option<Path>,
79    /// `true` for a [`Scene::push_layer`] frame, `false` for a clip (or the base).
80    layer: bool,
81    transform: Transform2D,
82    opacity: f32,
83    nodes: Vec<Node>,
84}
85
86impl ClipFrame {
87    fn base() -> Self {
88        Self {
89            clip: None,
90            layer: false,
91            transform: Transform2D::identity(),
92            opacity: 1.0,
93            nodes: Vec::new(),
94        }
95    }
96}
97
98/// A builder of vector draw primitives in logical-pixel space.
99#[derive(Clone, Debug)]
100pub struct Scene {
101    logical_size: Size,
102    // A stack of clip frames; the base (index 0) is the unclipped root. Every
103    // primitive is emitted into the top frame; `pop_clip` wraps a frame's nodes
104    // in a clipped `Group` and folds it into the frame below.
105    stack: Vec<ClipFrame>,
106    commands: Vec<DrawCmd>,
107    /// Physical pixels per logical pixel of the frame this scene is for, so
108    /// bitmaps can be produced at the resolution they'll be shown at.
109    scale: f64,
110}
111
112impl Scene {
113    /// Create an empty scene covering `logical_size`.
114    pub fn new(logical_size: Size) -> Self {
115        Self {
116            logical_size,
117            stack: vec![ClipFrame::base()],
118            commands: Vec::new(),
119            scale: 1.0,
120        }
121    }
122
123    /// Set the physical-per-logical pixel scale this scene will be shown at.
124    pub fn set_scale(&mut self, scale: f64) {
125        self.scale = scale;
126    }
127
128    /// The physical-per-logical pixel scale this scene will be shown at.
129    pub fn scale(&self) -> f64 {
130        self.scale
131    }
132
133    /// Draw `image` (straight-alpha RGBA) into `rect`.
134    pub fn draw_image(&mut self, rect: Rect, image: Arc<Pixmap>) {
135        self.emit(Node::Image(ImageRef {
136            frame: Box::new(image.to_video_frame()),
137            bounds: OxRect {
138                x: rect.min_x() as f32,
139                y: rect.min_y() as f32,
140                width: rect.width() as f32,
141                height: rect.height() as f32,
142            },
143            transform: Transform2D::identity(),
144        }));
145        self.commands.push(DrawCmd::Image { rect, image });
146    }
147
148    /// Emit a node into the current (innermost) clip frame.
149    fn emit(&mut self, node: Node) {
150        // The stack always has at least the base frame.
151        self.stack.last_mut().unwrap().nodes.push(node);
152    }
153
154    /// Begin clipping subsequently-emitted primitives to `rect`; nests until the
155    /// matching [`pop_clip`](Scene::pop_clip). Used by scroll containers to mask
156    /// overflowing content to the viewport.
157    pub fn push_clip(&mut self, rect: Rect) {
158        self.stack.push(ClipFrame {
159            clip: Some(rect_path(rect)),
160            ..ClipFrame::base()
161        });
162        self.commands.push(DrawCmd::PushClip(rect));
163    }
164
165    /// End the innermost clip region opened by [`push_clip`](Scene::push_clip),
166    /// folding its primitives into a clipped group. A no-op if none is open
167    /// (or the innermost open region is a layer).
168    pub fn pop_clip(&mut self) {
169        if self.stack.len() <= 1 || self.stack.last().is_some_and(|f| f.layer) {
170            return; // unbalanced pop — ignore rather than drop the base frame
171        }
172        self.close_frame();
173        self.commands.push(DrawCmd::PopClip);
174    }
175
176    /// Begin a layer: primitives emitted until the matching
177    /// [`pop_layer`](Scene::pop_layer) are drawn through `transform` (mapping
178    /// logical pixels to logical pixels, inside any enclosing layer's) and
179    /// composited as a group at `opacity` (`0.0..=1.0`): the layer's content
180    /// is rendered at full opacity on its own, then blended once, so
181    /// overlapping primitives inside it don't show through each other. Layers
182    /// and clips nest freely; a clip opened inside a layer is transformed with
183    /// it.
184    pub fn push_layer(&mut self, transform: Affine, opacity: f32) {
185        let opacity = if opacity.is_nan() {
186            1.0
187        } else {
188            opacity.clamp(0.0, 1.0)
189        };
190        self.stack.push(ClipFrame {
191            layer: true,
192            transform: to_transform2d(transform),
193            opacity,
194            ..ClipFrame::base()
195        });
196        self.commands
197            .push(DrawCmd::PushLayer { transform, opacity });
198    }
199
200    /// End the innermost layer opened by [`push_layer`](Scene::push_layer). A
201    /// no-op if none is open (or the innermost open region is a clip).
202    pub fn pop_layer(&mut self) {
203        if self.stack.len() <= 1 || self.stack.last().is_some_and(|f| !f.layer) {
204            return;
205        }
206        self.close_frame();
207        self.commands.push(DrawCmd::PopLayer);
208    }
209
210    /// Whether any layer pushed so far moves things or makes them translucent
211    /// (a [`DrawCmd::PushLayer`] with a non-identity transform or an opacity
212    /// below 1). Backends that can't draw layers natively fall back on this.
213    pub fn has_effects(&self) -> bool {
214        self.commands.iter().any(|c| {
215            matches!(c, DrawCmd::PushLayer { transform, opacity }
216                if !transform.is_identity() || *opacity < 1.0)
217        })
218    }
219
220    /// Pop the innermost frame and fold its nodes, as one group carrying its
221    /// clip, transform and opacity, into the frame below.
222    fn close_frame(&mut self) {
223        let frame = self.stack.pop().unwrap();
224        let group = Group {
225            children: frame.nodes,
226            clip: frame.clip,
227            transform: frame.transform,
228            opacity: frame.opacity,
229            ..Group::new()
230        };
231        self.emit(Node::Group(group));
232    }
233
234    /// The structured draw commands recorded by the typed helpers (for a GPU
235    /// backend; the CPU rasterizer uses the lowered nodes instead).
236    pub fn commands(&self) -> &[DrawCmd] {
237        &self.commands
238    }
239
240    /// The scene's extent in logical pixels.
241    #[inline]
242    pub fn logical_size(&self) -> Size {
243        self.logical_size
244    }
245
246    /// Number of top-level primitives queued (at the root clip level).
247    #[inline]
248    pub fn len(&self) -> usize {
249        self.stack[0].nodes.len()
250    }
251
252    #[inline]
253    pub fn is_empty(&self) -> bool {
254        self.stack.iter().all(|f| f.nodes.is_empty())
255    }
256
257    /// Stroke the outline of a rectangle with rounded corners.
258    pub fn stroke_round_rect(&mut self, rect: Rect, radius: f64, color: Color, width: f64) {
259        let path = round_rect_path(rect, radius);
260        let stroke = Stroke::solid(width as f32, Rgba::from(color));
261        self.emit(Node::Path(PathNode::new(path).with_stroke(stroke)));
262        self.commands.push(DrawCmd::Rect {
263            rect,
264            color,
265            radius,
266            border: width,
267        });
268    }
269
270    /// Fill an axis-aligned rectangle with a solid color.
271    pub fn fill_rect(&mut self, rect: Rect, color: Color) {
272        let path = rect_path(rect);
273        self.emit(Node::Path(
274            PathNode::new(path).with_fill(Paint::Solid(color.into())),
275        ));
276        self.commands.push(DrawCmd::Rect {
277            rect,
278            color,
279            radius: 0.0,
280            border: 0.0,
281        });
282    }
283
284    /// Fill a rectangle with rounded corners (corner `radius` in logical
285    /// pixels, clamped to half the shorter side).
286    pub fn fill_round_rect(&mut self, rect: Rect, radius: f64, color: Color) {
287        let path = round_rect_path(rect, radius);
288        self.emit(Node::Path(
289            PathNode::new(path).with_fill(Paint::Solid(color.into())),
290        ));
291        self.commands.push(DrawCmd::Rect {
292            rect,
293            color,
294            radius,
295            border: 0.0,
296        });
297    }
298
299    /// Stroke the outline of a rectangle with the given line `width`.
300    pub fn stroke_rect(&mut self, rect: Rect, color: Color, width: f64) {
301        let path = rect_path(rect);
302        let stroke = Stroke::solid(width as f32, Rgba::from(color));
303        self.emit(Node::Path(PathNode::new(path).with_stroke(stroke)));
304        self.commands.push(DrawCmd::Rect {
305            rect,
306            color,
307            radius: 0.0,
308            border: width,
309        });
310    }
311
312    /// Reserve an embedded-content area `rect` for the viewport `id`, painting a
313    /// solid `placeholder` so the region is visible before any content is
314    /// composited in. Records a [`DrawCmd::Viewport`] so a GPU backend can
315    /// texture the rect from an imported content surface instead, and so the app
316    /// can locate the rect to blit registered CPU content over the placeholder.
317    pub fn fill_viewport(&mut self, rect: Rect, id: u64, placeholder: Color) {
318        let path = rect_path(rect);
319        self.emit(Node::Path(
320            PathNode::new(path).with_fill(Paint::Solid(placeholder.into())),
321        ));
322        self.commands.push(DrawCmd::Viewport { rect, id });
323    }
324
325    /// Record a text draw command (the glyph nodes are pushed separately by
326    /// [`Scene::fill_text`](crate::render::Scene::fill_text)).
327    pub(crate) fn record_text(&mut self, text: &str, origin: Point, size: f64, color: Color) {
328        self.commands.push(DrawCmd::Text {
329            text: text.to_string(),
330            origin,
331            size,
332            color,
333        });
334    }
335
336    /// Escape hatch: push a pre-built oxideav scene-graph node. Lets callers
337    /// emit text runs, images, gradients, or clips that the typed helpers
338    /// don't yet cover.
339    pub fn push_node(&mut self, node: Node) {
340        self.emit(node);
341    }
342
343    /// Lower the scene to an `oxideav-core` [`VectorFrame`].
344    ///
345    /// The frame carries a view box equal to the logical size, so the
346    /// rasterizer maps logical pixels onto whatever physical canvas size the
347    /// renderer was constructed with — that mapping *is* the DPI scale.
348    pub fn into_vector_frame(self) -> VectorFrame {
349        let size = self.logical_size;
350        self.into_vector_frame_view(Rect::from_xywh(0.0, 0.0, size.width, size.height))
351    }
352
353    /// Lower to a frame whose view box is `view` — a logical-pixel sub-rect of
354    /// the scene. A renderer sized to that region's physical pixels then
355    /// rasterizes only the region; content outside it is clipped by the canvas
356    /// bounds. Used for area-limited repaints (see `SoftwareRenderer::render_region`).
357    pub fn into_vector_frame_region(self, view: Rect) -> VectorFrame {
358        self.into_vector_frame_view(view)
359    }
360
361    /// Shared lowering: close any open clips, wrap the root nodes in a group,
362    /// and attach `view` as the frame's view box.
363    fn into_vector_frame_view(mut self, view: Rect) -> VectorFrame {
364        // Defensively close any clip regions or layers the caller left open.
365        while self.stack.len() > 1 {
366            if self.stack.last().is_some_and(|f| f.layer) {
367                self.pop_layer();
368            } else {
369                self.pop_clip();
370            }
371        }
372        let (w, h) = (view.width() as f32, view.height() as f32);
373        let root = Group {
374            children: self.stack.pop().unwrap().nodes,
375            ..Group::new()
376        };
377        VectorFrame::new(w, h)
378            .with_view_box(ViewBox {
379                min_x: view.min_x() as f32,
380                min_y: view.min_y() as f32,
381                width: w,
382                height: h,
383            })
384            .with_root(root)
385    }
386}
387
388fn rect_path(rect: Rect) -> Path {
389    let (x0, y0, x1, y1) = (rect.min_x(), rect.min_y(), rect.max_x(), rect.max_y());
390    let mut p = Path::new();
391    p.move_to(OxPoint {
392        x: x0 as f32,
393        y: y0 as f32,
394    });
395    p.line_to(OxPoint {
396        x: x1 as f32,
397        y: y0 as f32,
398    });
399    p.line_to(OxPoint {
400        x: x1 as f32,
401        y: y1 as f32,
402    });
403    p.line_to(OxPoint {
404        x: x0 as f32,
405        y: y1 as f32,
406    });
407    p.close();
408    p
409}
410
411fn round_rect_path(rect: Rect, radius: f64) -> Path {
412    let r = radius.max(0.0).min(rect.width().min(rect.height()) / 2.0);
413    if r <= 0.0 {
414        return rect_path(rect);
415    }
416    let (x0, y0, x1, y1) = (rect.min_x(), rect.min_y(), rect.max_x(), rect.max_y());
417    let k = r * KAPPA;
418    use crate::geometry::Point as P;
419    let mut p = Path::new();
420    // Start at the top edge just right of the top-left corner, go clockwise.
421    p.move_to(to_ox_point(P::new(x0 + r, y0)));
422    p.line_to(to_ox_point(P::new(x1 - r, y0)));
423    p.cubic_to(
424        to_ox_point(P::new(x1 - r + k, y0)),
425        to_ox_point(P::new(x1, y0 + r - k)),
426        to_ox_point(P::new(x1, y0 + r)),
427    );
428    p.line_to(to_ox_point(P::new(x1, y1 - r)));
429    p.cubic_to(
430        to_ox_point(P::new(x1, y1 - r + k)),
431        to_ox_point(P::new(x1 - r + k, y1)),
432        to_ox_point(P::new(x1 - r, y1)),
433    );
434    p.line_to(to_ox_point(P::new(x0 + r, y1)));
435    p.cubic_to(
436        to_ox_point(P::new(x0 + r - k, y1)),
437        to_ox_point(P::new(x0, y1 - r + k)),
438        to_ox_point(P::new(x0, y1 - r)),
439    );
440    p.line_to(to_ox_point(P::new(x0, y0 + r)));
441    p.cubic_to(
442        to_ox_point(P::new(x0, y0 + r - k)),
443        to_ox_point(P::new(x0 + r - k, y0)),
444        to_ox_point(P::new(x0 + r, y0)),
445    );
446    p.close();
447    p
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use crate::geometry::Point;
454
455    /// An image lands where it's placed, keeps its colors, and blends its alpha
456    /// over what's underneath (the path OS-drawn controls take).
457    #[test]
458    fn draw_image_composites_straight_alpha() {
459        use crate::geometry::{PhysicalSize, ScaleFactor};
460        use crate::render::software::SoftwareRenderer;
461
462        let size = PhysicalSize::new(4, 4);
463        let mut data = Vec::new();
464        for _ in 0..8 {
465            data.extend_from_slice(&[200, 100, 50, 255]); // top half: opaque
466        }
467        for _ in 0..8 {
468            data.extend_from_slice(&[200, 100, 50, 0]); // bottom half: clear
469        }
470        let image = Arc::new(Pixmap::from_rgba8(size, data));
471        let mut scene = Scene::new(Size::new(8.0, 8.0));
472        scene.fill_rect(Rect::from_xywh(0.0, 0.0, 8.0, 8.0), Color::rgb(0, 0, 255));
473        scene.draw_image(Rect::from_xywh(2.0, 2.0, 4.0, 4.0), image);
474        assert!(matches!(
475            scene.commands().last(),
476            Some(DrawCmd::Image { .. })
477        ));
478
479        let out = SoftwareRenderer::new().render(scene, ScaleFactor::IDENTITY);
480        assert_eq!(out.pixel(0, 0), Some([0, 0, 255, 255]), "outside the image");
481        assert_eq!(out.pixel(3, 2), Some([200, 100, 50, 255]), "opaque part");
482        assert_eq!(out.pixel(3, 5), Some([0, 0, 255, 255]), "clear part");
483    }
484
485    #[test]
486    fn scene_lowers_to_frame_with_viewbox() {
487        let mut scene = Scene::new(Size::new(200.0, 100.0));
488        scene.fill_rect(Rect::from_xywh(10.0, 10.0, 50.0, 50.0), Color::WHITE);
489        assert_eq!(scene.len(), 1);
490        let frame = scene.into_vector_frame();
491        assert_eq!((frame.width, frame.height), (200.0, 100.0));
492        let vb = frame.view_box.expect("view box set");
493        assert_eq!((vb.width, vb.height), (200.0, 100.0));
494        assert_eq!(frame.root.children.len(), 1);
495    }
496
497    #[test]
498    fn records_structured_draw_commands() {
499        let mut scene = Scene::new(Size::new(100.0, 100.0));
500        scene.fill_rect(Rect::from_xywh(0.0, 0.0, 10.0, 10.0), Color::WHITE);
501        scene.fill_round_rect(Rect::from_xywh(0.0, 0.0, 10.0, 10.0), 4.0, Color::BLACK);
502        scene.stroke_rect(Rect::from_xywh(0.0, 0.0, 10.0, 10.0), Color::WHITE, 2.0);
503        let cmds = scene.commands();
504        assert_eq!(cmds.len(), 3);
505        assert!(
506            matches!(cmds[0], DrawCmd::Rect { radius, border, .. } if radius == 0.0 && border == 0.0)
507        );
508        assert!(matches!(cmds[1], DrawCmd::Rect { radius, .. } if radius == 4.0));
509        assert!(matches!(cmds[2], DrawCmd::Rect { border, .. } if border == 2.0));
510    }
511
512    #[test]
513    fn push_pop_clip_nests_a_clipped_group() {
514        let mut scene = Scene::new(Size::new(200.0, 200.0));
515        scene.fill_rect(Rect::from_xywh(0.0, 0.0, 10.0, 10.0), Color::WHITE); // root level
516        scene.push_clip(Rect::from_xywh(20.0, 20.0, 50.0, 50.0));
517        scene.fill_rect(Rect::from_xywh(25.0, 25.0, 100.0, 100.0), Color::BLACK); // clipped
518        scene.fill_rect(Rect::from_xywh(30.0, 30.0, 5.0, 5.0), Color::BLACK); // clipped
519        scene.pop_clip();
520        // Root has the first rect plus one clipped group.
521        assert_eq!(scene.len(), 2);
522        // Commands record the clip bracket around the two inner rects.
523        let cmds = scene.commands();
524        assert!(matches!(cmds[1], DrawCmd::PushClip(_)));
525        assert!(matches!(cmds[4], DrawCmd::PopClip));
526        let frame = scene.into_vector_frame();
527        // Root group: rect + clipped group.
528        assert_eq!(frame.root.children.len(), 2);
529        let clipped = match &frame.root.children[1] {
530            Node::Group(g) => g,
531            _ => panic!("expected a clipped group as the second child"),
532        };
533        assert!(clipped.clip.is_some(), "nested group carries the clip path");
534        assert_eq!(clipped.children.len(), 2, "two clipped rects");
535    }
536
537    /// Render `scene` at 1x over an opaque black background.
538    fn raster(scene: Scene) -> crate::render::Pixmap {
539        use crate::geometry::ScaleFactor;
540        use crate::render::software::SoftwareRenderer;
541        SoftwareRenderer::new()
542            .with_background(Color::BLACK)
543            .render(scene, ScaleFactor::IDENTITY)
544    }
545
546    const RED: Color = Color::rgb(255, 0, 0);
547
548    #[test]
549    fn a_layer_translates_what_it_holds() {
550        use crate::geometry::Vec2;
551        let mut scene = Scene::new(Size::new(40.0, 40.0));
552        scene.push_layer(Affine::translate(Vec2::new(20.0, 20.0)), 1.0);
553        scene.fill_rect(Rect::from_xywh(0.0, 0.0, 10.0, 10.0), RED);
554        scene.pop_layer();
555        assert!(scene.has_effects());
556        let out = raster(scene);
557        assert_eq!(out.pixel(25, 25), Some([255, 0, 0, 255]));
558        assert_eq!(out.pixel(5, 5), Some([0, 0, 0, 255]));
559    }
560
561    #[test]
562    fn a_layer_rotates_about_a_point() {
563        use crate::geometry::Vec2;
564        // A horizontal bar through the middle, turned 90° about the center,
565        // stands upright.
566        let about = Affine::translate(Vec2::new(-20.0, -20.0))
567            .then(Affine::rotate(std::f64::consts::FRAC_PI_2))
568            .then(Affine::translate(Vec2::new(20.0, 20.0)));
569        let mut scene = Scene::new(Size::new(40.0, 40.0));
570        scene.push_layer(about, 1.0);
571        scene.fill_rect(Rect::from_xywh(10.0, 18.0, 20.0, 4.0), RED);
572        scene.pop_layer();
573        let out = raster(scene);
574        assert_eq!(out.pixel(20, 12), Some([255, 0, 0, 255]), "now vertical");
575        assert_eq!(out.pixel(20, 27), Some([255, 0, 0, 255]), "now vertical");
576        assert_eq!(
577            out.pixel(12, 20),
578            Some([0, 0, 0, 255]),
579            "no longer horizontal"
580        );
581    }
582
583    #[test]
584    fn nested_layers_compose_inner_first() {
585        use crate::geometry::Vec2;
586        let mut scene = Scene::new(Size::new(40.0, 40.0));
587        scene.push_layer(Affine::translate(Vec2::new(20.0, 0.0)), 1.0);
588        scene.push_layer(Affine::scale(2.0, 2.0), 1.0);
589        // (1, 1, 2, 2) → scaled to (2, 2, 4, 4) → moved to (22, 2, 4, 4).
590        scene.fill_rect(Rect::from_xywh(1.0, 1.0, 2.0, 2.0), RED);
591        scene.pop_layer();
592        scene.pop_layer();
593        let out = raster(scene);
594        assert_eq!(out.pixel(23, 3), Some([255, 0, 0, 255]));
595        assert_eq!(out.pixel(25, 5), Some([255, 0, 0, 255]));
596        assert_eq!(out.pixel(1, 1), Some([0, 0, 0, 255]));
597        assert_eq!(out.pixel(27, 7), Some([0, 0, 0, 255]));
598    }
599
600    /// Two overlapping opaque children at 50% group opacity read as one
601    /// half-transparent shape: the overlap is no darker than the rest.
602    #[test]
603    fn layer_opacity_composites_as_a_group() {
604        let mut scene = Scene::new(Size::new(40.0, 20.0));
605        scene.push_layer(Affine::IDENTITY, 0.5);
606        scene.fill_rect(Rect::from_xywh(0.0, 0.0, 25.0, 20.0), RED);
607        scene.fill_rect(Rect::from_xywh(15.0, 0.0, 25.0, 20.0), RED);
608        scene.pop_layer();
609        assert!(scene.has_effects());
610        let out = raster(scene);
611        let alone = out.pixel(5, 10).unwrap();
612        let overlap = out.pixel(20, 10).unwrap();
613        assert_eq!(alone, overlap, "no seam where the children overlap");
614        assert!(
615            (120..=136).contains(&alone[0]),
616            "half red over black: {alone:?}"
617        );
618    }
619
620    #[test]
621    fn a_clip_inside_a_layer_moves_with_it() {
622        use crate::geometry::Vec2;
623        let mut scene = Scene::new(Size::new(40.0, 40.0));
624        scene.push_layer(Affine::translate(Vec2::new(20.0, 0.0)), 1.0);
625        scene.push_clip(Rect::from_xywh(0.0, 0.0, 10.0, 10.0));
626        scene.fill_rect(Rect::from_xywh(0.0, 0.0, 40.0, 40.0), RED);
627        scene.pop_clip();
628        scene.pop_layer();
629        let out = raster(scene);
630        assert_eq!(
631            out.pixel(25, 5),
632            Some([255, 0, 0, 255]),
633            "inside the moved clip"
634        );
635        assert_eq!(out.pixel(5, 5), Some([0, 0, 0, 255]), "where the clip was");
636        assert_eq!(out.pixel(25, 15), Some([0, 0, 0, 255]), "below the clip");
637    }
638
639    #[test]
640    fn mismatched_pops_are_ignored_and_open_layers_close() {
641        let mut scene = Scene::new(Size::new(10.0, 10.0));
642        scene.push_layer(Affine::IDENTITY, 0.5);
643        scene.pop_clip(); // not a clip: ignored
644        scene.fill_rect(Rect::from_xywh(0.0, 0.0, 10.0, 10.0), RED);
645        assert_eq!(scene.commands().len(), 2, "the stray pop records nothing");
646        let frame = scene.into_vector_frame();
647        let Node::Group(g) = &frame.root.children[0] else {
648            panic!("the open layer closes into a group");
649        };
650        assert_eq!(g.opacity, 0.5);
651        assert_eq!(g.children.len(), 1);
652    }
653
654    #[test]
655    fn fill_viewport_paints_placeholder_and_records_command() {
656        let mut scene = Scene::new(Size::new(200.0, 100.0));
657        let r = Rect::from_xywh(10.0, 10.0, 80.0, 60.0);
658        scene.fill_viewport(r, 7, Color::rgb(0x20, 0x24, 0x2c));
659        // A placeholder fill node is emitted so the CPU raster shows the area.
660        assert_eq!(scene.len(), 1);
661        // The structured command carries the rect + id for the compositor.
662        let cmds = scene.commands();
663        assert_eq!(cmds.len(), 1);
664        assert!(matches!(cmds[0], DrawCmd::Viewport { id, rect } if id == 7 && rect == r));
665    }
666
667    #[test]
668    fn zero_radius_round_rect_is_plain_rect() {
669        let path = round_rect_path(Rect::from_xywh(0.0, 0.0, 10.0, 10.0), 0.0);
670        // Plain rect: move + 3 lines + close == 5 commands.
671        assert_eq!(path.commands.len(), 5);
672    }
673
674    #[test]
675    fn round_rect_has_corner_curves() {
676        let path = round_rect_path(Rect::from_xywh(0.0, 0.0, 20.0, 20.0), 4.0);
677        let cubics = path
678            .commands
679            .iter()
680            .filter(|c| matches!(c, oxideav_core::PathCommand::CubicCurveTo { .. }))
681            .count();
682        assert_eq!(cubics, 4);
683        let _ = Point::ORIGIN;
684    }
685}