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::Color;
10use crate::convert::to_ox_point;
11use oxideav_core::{
12    Group, Node, Paint, Path, PathNode, Point as OxPoint, Rgba, Stroke, VectorFrame, ViewBox,
13};
14use stipple_geometry::{Point, Rect, Size};
15
16/// Control-point offset for approximating a quarter circle with a cubic
17/// Bézier (the standard `4/3·(√2 − 1)` "kappa" constant).
18const KAPPA: f64 = 0.552_284_749_830_793_4;
19
20/// A structured record of a scene primitive, kept alongside the lowered oxideav
21/// nodes so a GPU backend can consume the scene without re-deriving primitives
22/// from vector paths (the CPU rasterizer uses the nodes; the GPU path uses
23/// these). See [`Scene::commands`].
24#[derive(Clone, Debug, PartialEq)]
25pub enum DrawCmd {
26    /// A box: `radius` rounds the corners (0 = sharp); `border` > 0 strokes the
27    /// outline at that width instead of filling.
28    Rect {
29        rect: Rect,
30        color: Color,
31        radius: f64,
32        border: f64,
33    },
34    /// A single line of text at `origin` (top-left).
35    Text {
36        text: String,
37        origin: Point,
38        size: f64,
39        color: Color,
40    },
41    /// Begin clipping subsequent primitives to `rect` (nests until [`PopClip`]).
42    /// GPU backends map this to a scissor rectangle.
43    ///
44    /// [`PopClip`]: DrawCmd::PopClip
45    PushClip(Rect),
46    /// End the innermost clip region opened by [`PushClip`](DrawCmd::PushClip).
47    PopClip,
48    /// A reserved area for **embedded content** (a browser page, video, or any
49    /// externally-rendered surface), identified by `id`. The compositor draws
50    /// the content here: the CPU path blits the registered content [`Pixmap`]
51    /// over the placeholder fill after rasterizing; a GPU backend samples the
52    /// imported (dma-buf / `IOSurface` / shared-handle) texture into this rect.
53    /// See [`Scene::fill_viewport`].
54    Viewport { rect: Rect, id: u64 },
55}
56
57/// A clip region under construction: the primitives emitted while it is open,
58/// plus the optional clip path applied to them when it closes. The scene keeps a
59/// stack of these so [`Scene::push_clip`]/[`Scene::pop_clip`] can nest.
60#[derive(Clone, Debug)]
61struct ClipFrame {
62    clip: Option<Path>,
63    nodes: Vec<Node>,
64}
65
66/// A builder of vector draw primitives in logical-pixel space.
67#[derive(Clone, Debug)]
68pub struct Scene {
69    logical_size: Size,
70    // A stack of clip frames; the base (index 0) is the unclipped root. Every
71    // primitive is emitted into the top frame; `pop_clip` wraps a frame's nodes
72    // in a clipped `Group` and folds it into the frame below.
73    stack: Vec<ClipFrame>,
74    commands: Vec<DrawCmd>,
75}
76
77impl Scene {
78    /// Create an empty scene covering `logical_size`.
79    pub fn new(logical_size: Size) -> Self {
80        Self {
81            logical_size,
82            stack: vec![ClipFrame {
83                clip: None,
84                nodes: Vec::new(),
85            }],
86            commands: Vec::new(),
87        }
88    }
89
90    /// Emit a node into the current (innermost) clip frame.
91    fn emit(&mut self, node: Node) {
92        // The stack always has at least the base frame.
93        self.stack.last_mut().unwrap().nodes.push(node);
94    }
95
96    /// Begin clipping subsequently-emitted primitives to `rect`; nests until the
97    /// matching [`pop_clip`](Scene::pop_clip). Used by scroll containers to mask
98    /// overflowing content to the viewport.
99    pub fn push_clip(&mut self, rect: Rect) {
100        self.stack.push(ClipFrame {
101            clip: Some(rect_path(rect)),
102            nodes: Vec::new(),
103        });
104        self.commands.push(DrawCmd::PushClip(rect));
105    }
106
107    /// End the innermost clip region opened by [`push_clip`](Scene::push_clip),
108    /// folding its primitives into a clipped group. A no-op if none is open.
109    pub fn pop_clip(&mut self) {
110        if self.stack.len() <= 1 {
111            return; // unbalanced pop — ignore rather than drop the base frame
112        }
113        let frame = self.stack.pop().unwrap();
114        let group = Group {
115            children: frame.nodes,
116            clip: frame.clip,
117            ..Group::new()
118        };
119        self.emit(Node::Group(group));
120        self.commands.push(DrawCmd::PopClip);
121    }
122
123    /// The structured draw commands recorded by the typed helpers (for a GPU
124    /// backend; the CPU rasterizer uses the lowered nodes instead).
125    pub fn commands(&self) -> &[DrawCmd] {
126        &self.commands
127    }
128
129    /// The scene's extent in logical pixels.
130    #[inline]
131    pub fn logical_size(&self) -> Size {
132        self.logical_size
133    }
134
135    /// Number of top-level primitives queued (at the root clip level).
136    #[inline]
137    pub fn len(&self) -> usize {
138        self.stack[0].nodes.len()
139    }
140
141    #[inline]
142    pub fn is_empty(&self) -> bool {
143        self.stack.iter().all(|f| f.nodes.is_empty())
144    }
145
146    /// Fill an axis-aligned rectangle with a solid color.
147    pub fn fill_rect(&mut self, rect: Rect, color: Color) {
148        let path = rect_path(rect);
149        self.emit(Node::Path(
150            PathNode::new(path).with_fill(Paint::Solid(color.into())),
151        ));
152        self.commands.push(DrawCmd::Rect {
153            rect,
154            color,
155            radius: 0.0,
156            border: 0.0,
157        });
158    }
159
160    /// Fill a rectangle with rounded corners (corner `radius` in logical
161    /// pixels, clamped to half the shorter side).
162    pub fn fill_round_rect(&mut self, rect: Rect, radius: f64, color: Color) {
163        let path = round_rect_path(rect, radius);
164        self.emit(Node::Path(
165            PathNode::new(path).with_fill(Paint::Solid(color.into())),
166        ));
167        self.commands.push(DrawCmd::Rect {
168            rect,
169            color,
170            radius,
171            border: 0.0,
172        });
173    }
174
175    /// Stroke the outline of a rectangle with the given line `width`.
176    pub fn stroke_rect(&mut self, rect: Rect, color: Color, width: f64) {
177        let path = rect_path(rect);
178        let stroke = Stroke::solid(width as f32, Rgba::from(color));
179        self.emit(Node::Path(PathNode::new(path).with_stroke(stroke)));
180        self.commands.push(DrawCmd::Rect {
181            rect,
182            color,
183            radius: 0.0,
184            border: width,
185        });
186    }
187
188    /// Reserve an embedded-content area `rect` for the viewport `id`, painting a
189    /// solid `placeholder` so the region is visible before any content is
190    /// composited in. Records a [`DrawCmd::Viewport`] so a GPU backend can
191    /// texture the rect from an imported content surface instead, and so the app
192    /// can locate the rect to blit registered CPU content over the placeholder.
193    pub fn fill_viewport(&mut self, rect: Rect, id: u64, placeholder: Color) {
194        let path = rect_path(rect);
195        self.emit(Node::Path(
196            PathNode::new(path).with_fill(Paint::Solid(placeholder.into())),
197        ));
198        self.commands.push(DrawCmd::Viewport { rect, id });
199    }
200
201    /// Record a text draw command (the glyph nodes are pushed separately by
202    /// [`Scene::fill_text`](crate::Scene::fill_text)).
203    pub(crate) fn record_text(&mut self, text: &str, origin: Point, size: f64, color: Color) {
204        self.commands.push(DrawCmd::Text {
205            text: text.to_string(),
206            origin,
207            size,
208            color,
209        });
210    }
211
212    /// Escape hatch: push a pre-built oxideav scene-graph node. Lets callers
213    /// emit text runs, images, gradients, or clips that the typed helpers
214    /// don't yet cover.
215    pub fn push_node(&mut self, node: Node) {
216        self.emit(node);
217    }
218
219    /// Lower the scene to an `oxideav-core` [`VectorFrame`].
220    ///
221    /// The frame carries a view box equal to the logical size, so the
222    /// rasterizer maps logical pixels onto whatever physical canvas size the
223    /// renderer was constructed with — that mapping *is* the DPI scale.
224    pub fn into_vector_frame(self) -> VectorFrame {
225        let size = self.logical_size;
226        self.into_vector_frame_view(Rect::from_xywh(0.0, 0.0, size.width, size.height))
227    }
228
229    /// Lower to a frame whose view box is `view` — a logical-pixel sub-rect of
230    /// the scene. A renderer sized to that region's physical pixels then
231    /// rasterizes only the region; content outside it is clipped by the canvas
232    /// bounds. Used for area-limited repaints (see `SoftwareRenderer::render_region`).
233    pub fn into_vector_frame_region(self, view: Rect) -> VectorFrame {
234        self.into_vector_frame_view(view)
235    }
236
237    /// Shared lowering: close any open clips, wrap the root nodes in a group,
238    /// and attach `view` as the frame's view box.
239    fn into_vector_frame_view(mut self, view: Rect) -> VectorFrame {
240        // Defensively close any clip regions the caller left open.
241        while self.stack.len() > 1 {
242            self.pop_clip();
243        }
244        let (w, h) = (view.width() as f32, view.height() as f32);
245        let root = Group {
246            children: self.stack.pop().unwrap().nodes,
247            ..Group::new()
248        };
249        VectorFrame::new(w, h)
250            .with_view_box(ViewBox {
251                min_x: view.min_x() as f32,
252                min_y: view.min_y() as f32,
253                width: w,
254                height: h,
255            })
256            .with_root(root)
257    }
258}
259
260fn rect_path(rect: Rect) -> Path {
261    let (x0, y0, x1, y1) = (rect.min_x(), rect.min_y(), rect.max_x(), rect.max_y());
262    let mut p = Path::new();
263    p.move_to(OxPoint {
264        x: x0 as f32,
265        y: y0 as f32,
266    });
267    p.line_to(OxPoint {
268        x: x1 as f32,
269        y: y0 as f32,
270    });
271    p.line_to(OxPoint {
272        x: x1 as f32,
273        y: y1 as f32,
274    });
275    p.line_to(OxPoint {
276        x: x0 as f32,
277        y: y1 as f32,
278    });
279    p.close();
280    p
281}
282
283fn round_rect_path(rect: Rect, radius: f64) -> Path {
284    let r = radius.max(0.0).min(rect.width().min(rect.height()) / 2.0);
285    if r <= 0.0 {
286        return rect_path(rect);
287    }
288    let (x0, y0, x1, y1) = (rect.min_x(), rect.min_y(), rect.max_x(), rect.max_y());
289    let k = r * KAPPA;
290    use stipple_geometry::Point as P;
291    let mut p = Path::new();
292    // Start at the top edge just right of the top-left corner, go clockwise.
293    p.move_to(to_ox_point(P::new(x0 + r, y0)));
294    p.line_to(to_ox_point(P::new(x1 - r, y0)));
295    p.cubic_to(
296        to_ox_point(P::new(x1 - r + k, y0)),
297        to_ox_point(P::new(x1, y0 + r - k)),
298        to_ox_point(P::new(x1, y0 + r)),
299    );
300    p.line_to(to_ox_point(P::new(x1, y1 - r)));
301    p.cubic_to(
302        to_ox_point(P::new(x1, y1 - r + k)),
303        to_ox_point(P::new(x1 - r + k, y1)),
304        to_ox_point(P::new(x1 - r, y1)),
305    );
306    p.line_to(to_ox_point(P::new(x0 + r, y1)));
307    p.cubic_to(
308        to_ox_point(P::new(x0 + r - k, y1)),
309        to_ox_point(P::new(x0, y1 - r + k)),
310        to_ox_point(P::new(x0, y1 - r)),
311    );
312    p.line_to(to_ox_point(P::new(x0, y0 + r)));
313    p.cubic_to(
314        to_ox_point(P::new(x0, y0 + r - k)),
315        to_ox_point(P::new(x0 + r - k, y0)),
316        to_ox_point(P::new(x0 + r, y0)),
317    );
318    p.close();
319    p
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use stipple_geometry::Point;
326
327    #[test]
328    fn scene_lowers_to_frame_with_viewbox() {
329        let mut scene = Scene::new(Size::new(200.0, 100.0));
330        scene.fill_rect(Rect::from_xywh(10.0, 10.0, 50.0, 50.0), Color::WHITE);
331        assert_eq!(scene.len(), 1);
332        let frame = scene.into_vector_frame();
333        assert_eq!((frame.width, frame.height), (200.0, 100.0));
334        let vb = frame.view_box.expect("view box set");
335        assert_eq!((vb.width, vb.height), (200.0, 100.0));
336        assert_eq!(frame.root.children.len(), 1);
337    }
338
339    #[test]
340    fn records_structured_draw_commands() {
341        let mut scene = Scene::new(Size::new(100.0, 100.0));
342        scene.fill_rect(Rect::from_xywh(0.0, 0.0, 10.0, 10.0), Color::WHITE);
343        scene.fill_round_rect(Rect::from_xywh(0.0, 0.0, 10.0, 10.0), 4.0, Color::BLACK);
344        scene.stroke_rect(Rect::from_xywh(0.0, 0.0, 10.0, 10.0), Color::WHITE, 2.0);
345        let cmds = scene.commands();
346        assert_eq!(cmds.len(), 3);
347        assert!(
348            matches!(cmds[0], DrawCmd::Rect { radius, border, .. } if radius == 0.0 && border == 0.0)
349        );
350        assert!(matches!(cmds[1], DrawCmd::Rect { radius, .. } if radius == 4.0));
351        assert!(matches!(cmds[2], DrawCmd::Rect { border, .. } if border == 2.0));
352    }
353
354    #[test]
355    fn push_pop_clip_nests_a_clipped_group() {
356        let mut scene = Scene::new(Size::new(200.0, 200.0));
357        scene.fill_rect(Rect::from_xywh(0.0, 0.0, 10.0, 10.0), Color::WHITE); // root level
358        scene.push_clip(Rect::from_xywh(20.0, 20.0, 50.0, 50.0));
359        scene.fill_rect(Rect::from_xywh(25.0, 25.0, 100.0, 100.0), Color::BLACK); // clipped
360        scene.fill_rect(Rect::from_xywh(30.0, 30.0, 5.0, 5.0), Color::BLACK); // clipped
361        scene.pop_clip();
362        // Root has the first rect plus one clipped group.
363        assert_eq!(scene.len(), 2);
364        // Commands record the clip bracket around the two inner rects.
365        let cmds = scene.commands();
366        assert!(matches!(cmds[1], DrawCmd::PushClip(_)));
367        assert!(matches!(cmds[4], DrawCmd::PopClip));
368        let frame = scene.into_vector_frame();
369        // Root group: rect + clipped group.
370        assert_eq!(frame.root.children.len(), 2);
371        let clipped = match &frame.root.children[1] {
372            Node::Group(g) => g,
373            _ => panic!("expected a clipped group as the second child"),
374        };
375        assert!(clipped.clip.is_some(), "nested group carries the clip path");
376        assert_eq!(clipped.children.len(), 2, "two clipped rects");
377    }
378
379    #[test]
380    fn fill_viewport_paints_placeholder_and_records_command() {
381        let mut scene = Scene::new(Size::new(200.0, 100.0));
382        let r = Rect::from_xywh(10.0, 10.0, 80.0, 60.0);
383        scene.fill_viewport(r, 7, Color::rgb(0x20, 0x24, 0x2c));
384        // A placeholder fill node is emitted so the CPU raster shows the area.
385        assert_eq!(scene.len(), 1);
386        // The structured command carries the rect + id for the compositor.
387        let cmds = scene.commands();
388        assert_eq!(cmds.len(), 1);
389        assert!(matches!(cmds[0], DrawCmd::Viewport { id, rect } if id == 7 && rect == r));
390    }
391
392    #[test]
393    fn zero_radius_round_rect_is_plain_rect() {
394        let path = round_rect_path(Rect::from_xywh(0.0, 0.0, 10.0, 10.0), 0.0);
395        // Plain rect: move + 3 lines + close == 5 commands.
396        assert_eq!(path.commands.len(), 5);
397    }
398
399    #[test]
400    fn round_rect_has_corner_curves() {
401        let path = round_rect_path(Rect::from_xywh(0.0, 0.0, 20.0, 20.0), 4.0);
402        let cubics = path
403            .commands
404            .iter()
405            .filter(|c| matches!(c, oxideav_core::PathCommand::CubicCurveTo { .. }))
406            .count();
407        assert_eq!(cubics, 4);
408        let _ = Point::ORIGIN;
409    }
410}