Skip to main content

frust_scene/
scene.rs

1//! The [`Scene`] display list and the [`Command`]s it holds.
2
3use kurbo::{Affine, BezPath, Point, Rect};
4use peniko::{Brush, Color, ImageData};
5
6use crate::glyph::GlyphRun;
7use crate::shader::ShaderProgram;
8
9/// Per-corner radii for a rounded rectangle, in the pre-transform coordinate
10/// space.
11///
12/// Corners are named clockwise from the top-left, matching
13/// `kurbo::RoundedRectRadii` — the concrete shape `frust-render` builds at
14/// encode time (scene-layer purity keeps the shape type itself out of this
15/// crate's commands).
16///
17/// `From<f64>` covers the uniform case, so the one-radius callers that predate
18/// this type keep passing a bare `f64` and the builder converts:
19/// `CornerRadii::from(8.0)` == `CornerRadii::new(8.0, 8.0, 8.0, 8.0)`.
20#[derive(Clone, Copy, Debug, Default, PartialEq)]
21pub struct CornerRadii {
22    /// Top-left corner radius.
23    pub top_left: f64,
24    /// Top-right corner radius.
25    pub top_right: f64,
26    /// Bottom-right corner radius.
27    pub bottom_right: f64,
28    /// Bottom-left corner radius.
29    pub bottom_left: f64,
30}
31
32impl CornerRadii {
33    /// Radii for each corner, clockwise from the top-left (the
34    /// `kurbo::RoundedRectRadii::new` argument order).
35    pub const fn new(top_left: f64, top_right: f64, bottom_right: f64, bottom_left: f64) -> Self {
36        Self {
37            top_left,
38            top_right,
39            bottom_right,
40            bottom_left,
41        }
42    }
43
44    /// The same `radius` on all four corners — what [`From<f64>`] builds.
45    pub const fn uniform(radius: f64) -> Self {
46        Self::new(radius, radius, radius, radius)
47    }
48
49    /// The largest of the four corner radii.
50    ///
51    /// A backend that can only express a single radius (both blurred-shadow
52    /// primitives take one) lowers through this rather than through the
53    /// smallest: a shadow rounded *more* than its caster only lightens a square
54    /// corner, while one rounded less pushes a hard shadow wedge out through a
55    /// rounded corner's notch.
56    pub fn largest(&self) -> f64 {
57        self.top_left
58            .max(self.top_right)
59            .max(self.bottom_right)
60            .max(self.bottom_left)
61    }
62}
63
64impl From<f64> for CornerRadii {
65    fn from(radius: f64) -> Self {
66        Self::uniform(radius)
67    }
68}
69
70/// A stroke's dash pattern: an `on` run, an `off` gap, and a `phase` offset
71/// into that repeating cycle — all lengths in the pre-transform coordinate
72/// space.
73///
74/// One on/off pair rather than an arbitrary-length array: it covers the dashed
75/// dividers/outlines/focus rings widgets ask for, stays `Copy` (so
76/// [`PathStyle`] and every command holding one stay `Copy`/allocation-free),
77/// and the render crate expands it into a `[on, off]` slice at encode time.
78#[derive(Clone, Copy, Debug, PartialEq)]
79pub struct DashPattern {
80    /// Length of each painted dash.
81    pub on: f64,
82    /// Length of each gap between dashes.
83    pub off: f64,
84    /// Distance into the on/off cycle the pattern starts at — animate this to
85    /// march the dashes along the path.
86    pub phase: f64,
87}
88
89/// Minimum total dash period (on + off) in logical pixels for a pattern to be
90/// rendered as dashed. Below this, the dash segments would be too small to see
91/// and the period becomes too dense to efficiently expand at encode time, so
92/// the pattern falls back to a solid stroke. This is the threshold below which
93/// a dash pattern is visually indistinguishable from a solid stroke anyway.
94const DASH_PERIOD_EPSILON: f64 = 0.1;
95
96impl DashPattern {
97    /// An `on`/`off` cycle starting at phase 0.
98    pub const fn new(on: f64, off: f64) -> Self {
99        Self {
100            on,
101            off,
102            phase: 0.0,
103        }
104    }
105
106    /// The same pattern offset by `phase` into its cycle.
107    pub const fn with_phase(mut self, phase: f64) -> Self {
108        self.phase = phase;
109        self
110    }
111
112    /// Whether this pattern actually breaks a stroke into dashes.
113    ///
114    /// A non-positive or non-finite length has no dashed interpretation (a zero
115    /// `off` is a solid line; a zero `on` paints nothing, which a caller never
116    /// means by "dashed"), so `frust-render` strokes such a path solid rather
117    /// than feeding a degenerate cycle to the dash iterator. Additionally, a
118    /// period (on + off) smaller than [`DASH_PERIOD_EPSILON`] is too dense to
119    /// efficiently expand at encode time and is visually indistinguishable from
120    /// a solid stroke anyway, so the pattern falls back to solid.
121    pub fn is_effective(&self) -> bool {
122        self.on.is_finite()
123            && self.off.is_finite()
124            && self.phase.is_finite()
125            && self.on > 0.0
126            && self.off > 0.0
127            && (self.on + self.off) >= DASH_PERIOD_EPSILON
128    }
129}
130
131/// How a [`Command::Path`] is rendered: filled or stroked.
132///
133/// Kept intentionally minimal: the fill/stroke shape
134/// widgets need for arcs (circular progress, activity indicators) — a
135/// nonzero-fill, or a stroke with a fixed width, round caps/joins, and an
136/// optional [`DashPattern`]. No miter limit or even-odd fill rule yet; extend
137/// here (and in `frust-render::convert`) if a later widget needs one.
138#[derive(Clone, Copy, Debug, PartialEq)]
139pub enum PathStyle {
140    /// Fill using the nonzero winding rule.
141    Fill,
142    /// Stroke with the given width (pre-transform coordinate space) and
143    /// round caps/joins.
144    Stroke {
145        /// Stroke width, in the pre-transform coordinate space.
146        width: f64,
147        /// Dash pattern, or `None` for a solid stroke — what every stroke that
148        /// predates dashing records, and what the render crate falls back to
149        /// for a degenerate pattern (see [`DashPattern::is_effective`]).
150        dash: Option<DashPattern>,
151    },
152}
153
154/// A single paint operation recorded into a [`Scene`].
155///
156/// This is the renderer-agnostic vocabulary the render crate (layer 4)
157/// translates into backend draw calls. No `vello`/`wgpu` types appear here.
158#[derive(Clone, Debug)]
159pub enum Command {
160    /// Fill an axis-aligned rectangle with a brush, under a transform.
161    FillRect {
162        rect: Rect,
163        brush: Brush,
164        transform: Affine,
165    },
166    /// Fill an axis-aligned rectangle with rounded corners.
167    RoundedRect {
168        rect: Rect,
169        /// Per-corner radii, in the pre-transform coordinate space; a uniform
170        /// radius arrives here as [`CornerRadii::uniform`].
171        radii: CornerRadii,
172        brush: Brush,
173        transform: Affine,
174    },
175    /// Stroke a straight line segment from `p0` to `p1`.
176    Line {
177        p0: Point,
178        p1: Point,
179        /// Stroke width, in the pre-transform coordinate space.
180        width: f64,
181        brush: Brush,
182        transform: Affine,
183    },
184    /// Draw a positioned run of glyphs.
185    GlyphRun(GlyphRun),
186    /// Push a rectangular clip onto the render backend's clip stack, under a
187    /// transform. Subsequent draws are clipped to it until the matching
188    /// [`Command::PopClip`].
189    PushClip { rect: Rect, transform: Affine },
190    /// Push a clip with rounded corners onto the render backend's clip stack,
191    /// under a transform. Subsequent draws are clipped to the rounded shape
192    /// until the matching [`Command::PopClip`].
193    ///
194    /// Popped by the *same* [`Command::PopClip`] a [`Command::PushClip`] uses —
195    /// there is one clip stack, not a separate rounded one. The motivating
196    /// consumer is a radiused mask over a bitmap (an avatar/thumbnail), which a
197    /// rectangular clip cannot express.
198    ///
199    /// Carried as a `Rect` + [`CornerRadii`], mirroring
200    /// [`Command::RoundedRect`] rather than naming a `kurbo::RoundedRect`; the
201    /// render crate reconstitutes the concrete shape at encode time. An
202    /// arbitrary-path clip is deliberately not modelled yet — extend here (and
203    /// in `frust-render::convert`) if one is ever needed.
204    PushClipRounded {
205        rect: Rect,
206        /// Per-corner radii, in the pre-transform coordinate space.
207        radii: CornerRadii,
208        transform: Affine,
209    },
210    /// Pop the most recently pushed clip.
211    PopClip,
212    /// Draw a decoded image (natural pixel size `data.width`x`data.height`),
213    /// scaled to fill `dest`, under a transform.
214    ///
215    /// `data` is cloned from the widget's cached `ImageSource` each frame;
216    /// `peniko::ImageData`'s `Blob<u8>` is reference-counted internally, so
217    /// this is a cheap handle clone, never a pixel copy or re-decode.
218    Image {
219        data: ImageData,
220        dest: Rect,
221        transform: Affine,
222    },
223    /// Draw a rounded rectangle with a gaussian-blurred elevation shadow (an
224    /// approximation of a CSS `box-shadow`), under a transform.
225    ///
226    /// `radii` are the rectangle's corner radii, `std_dev` the blur's standard
227    /// deviation, both in the pre-transform coordinate space. Maps onto vello
228    /// 0.9's `Scene::draw_blurred_rounded_rect`, whose `brush` parameter is a
229    /// concrete `peniko::Color` (not a `Brush`) — a blurred shadow has no
230    /// gradient support in this vello version — and which takes a *single*
231    /// radius, as does the CPU tier's `fill_blurred_rounded_rect`. A per-corner
232    /// shadow therefore lowers through [`CornerRadii::largest`] at encode time
233    /// (see `frust-render::convert`); the field carries all four corners so
234    /// this command shares one radii vocabulary with its rounded siblings and
235    /// gains per-corner blur for free if a backend ever grows it.
236    BlurredRoundedRect {
237        rect: Rect,
238        radii: CornerRadii,
239        std_dev: f64,
240        color: Color,
241        transform: Affine,
242    },
243    /// Push a translucent layer onto the render backend's layer stack, under
244    /// a transform. Subsequent draws are composited at `alpha` until the
245    /// matching [`Command::PopLayer`].
246    ///
247    /// Semantically a generalization of [`Command::PushClip`] (which is
248    /// `PushLayer` with `alpha: 1.0`) — kept as a distinct variant rather than
249    /// folded into it so existing `PushClip`/`PopClip` consumers are
250    /// unaffected (see `frust-render::convert`).
251    PushLayer {
252        rect: Rect,
253        alpha: f32,
254        transform: Affine,
255    },
256    /// Pop the most recently pushed layer.
257    PopLayer,
258    /// Clear an axis-aligned rectangle to full transparency (alpha 0) under a
259    /// transform, erasing everything already drawn beneath it in this scene —
260    /// a real destination-clearing composite, not a skipped paint.
261    ///
262    /// The platform-view hole-punch is the sole v1 producer (see
263    /// `frust-core`'s `PaintScene::clear_rect`): a translucent-surface (Mode B)
264    /// slot punches its rect so an opaque app backdrop painted below it (the
265    /// catalog's `AppBackground`) doesn't seal the hole the hosted native view
266    /// shows through. `frust-render::convert` lowers this to a destination-out
267    /// composite (an opaque fill erasing color and alpha wherever it covers),
268    /// hoisted to the scene root past any enclosing clip/opacity group so a
269    /// nested slot's punch isn't confined to its own group's content — the
270    /// exact composite mode and hoist mechanics are `frust-render`'s to name
271    /// (scene-layer purity: no vello types here). The clear only becomes
272    /// visible on a surface that actually carries an alpha channel; on an
273    /// opaque surface the transparency is disregarded (vello's surface
274    /// contract), which is why the producer gates it on the translucent flag
275    /// rather than punching always.
276    ClearRect { rect: Rect, transform: Affine },
277    /// Fill or stroke an arbitrary vector path (e.g. an arc), under a
278    /// transform.
279    ///
280    /// `path` is a `kurbo::BezPath` already positioned in the same
281    /// coordinate space as every other command (the caller has translated it
282    /// to the widget's origin before recording); `style` selects fill vs.
283    /// stroke (see [`PathStyle`]).
284    Path {
285        path: BezPath,
286        style: PathStyle,
287        brush: Brush,
288        transform: Affine,
289    },
290    /// Draw a fragment-shader-filled rectangle, scaled to fill `dest`, under
291    /// a transform.
292    ///
293    /// `program` is compiled (and cache-keyed on [`ShaderProgram::id`]) by
294    /// `frust-engine`'s effects module. Output is treated as premultiplied
295    /// alpha. `time` is seconds, app-supplied (from `PaintCtx::frame_time` at
296    /// the widget layer), threaded into the shader's uniform buffer. This
297    /// command lives at the scene layer to preserve purity: the render backend
298    /// interprets the compiled shader output.
299    ShaderQuad {
300        program: ShaderProgram,
301        dest: Rect,
302        transform: Affine,
303        time: f32,
304    },
305    /// Marks the start of a cacheable "snapshot" bracket, under a transform.
306    ///
307    /// The commands between this and the matching [`Command::PopSnapshot`] are
308    /// the BODY, recorded in the ordinary composed transform space (i.e. not
309    /// pre-multiplied by `scale`). `rect` is the body's bounds in the local
310    /// space of `transform` — the same convention [`Command::PushLayer`]'s
311    /// `rect`/`alpha`/`transform` use.
312    ///
313    /// `alpha` (`0.0..=1.0`) and `scale` (uniform, about `rect`'s center) are
314    /// PRESENTATION parameters applied to the body as a whole — deliberately
315    /// NOT baked into the body's own commands, so a renderer can reuse a
316    /// rasterized body while they animate.
317    ///
318    /// A renderer that implements snapshots MAY rasterize the body once and
319    /// draw it as an image with `transform * scale_about(rect.center(),
320    /// scale)` inside an alpha layer. A renderer that does not MUST paint the
321    /// body inline wrapped exactly as if the recorder had emitted
322    /// `push_transform(scale_about(rect.center(), scale))` (when `scale !=
323    /// 1.0`) then `push_layer(rect, alpha)` (when `alpha < 1.0`) — pops
324    /// reversed (see `frust-render::convert`'s miss path).
325    ///
326    /// Syntactic nesting is allowed, but only the OUTERMOST bracket needs
327    /// honouring — an inner bracket's own `alpha`/`scale` may be ignored by a
328    /// non-implementing renderer. An unbalanced [`Command::PopSnapshot`] is
329    /// ignored, the same policy [`Command::PopLayer`] follows.
330    PushSnapshot {
331        /// Cache key identifying this bracket's body across frames.
332        key: u64,
333        rect: Rect,
334        alpha: f32,
335        /// Uniform scale, applied about `rect`'s center.
336        scale: f64,
337        transform: Affine,
338    },
339    /// Pop the most recently pushed snapshot bracket (see
340    /// [`Command::PushSnapshot`]).
341    PopSnapshot,
342    /// Draw an externally owned GPU texture scaled to fill `dest` under
343    /// `transform`.
344    ///
345    /// `id` is opaque scene-layer data (precedent: [`ShaderProgram`]'s opaque
346    /// id, `shader.rs`) — only the render backend resolves it against
347    /// textures registered with the GPU context; an unregistered id draws
348    /// nothing.
349    SceneTexture {
350        id: u64,
351        dest: Rect,
352        transform: Affine,
353    },
354}
355
356/// Renderer-agnostic, immediate-mode display list.
357///
358/// Widgets paint into a `Scene` (via [`crate::SceneBuilder`]) each frame; the
359/// render crate consumes [`Scene::commands`] to draw. Rebuilt per frame — call
360/// [`Scene::reset`] before re-recording rather than allocating a new `Scene`.
361#[derive(Clone, Debug, Default)]
362pub struct Scene {
363    commands: Vec<Command>,
364    /// [`crate::SceneBuilder`]'s transform stack, parked here between frames
365    /// so its backing `Vec` allocation is
366    /// reused across every `SceneBuilder::new` call instead of reallocating
367    /// per frame — a `SceneBuilder` borrows it via `&mut Scene` and resets it
368    /// to `[Affine::IDENTITY]` on construction, so behavior is unchanged.
369    /// Not part of the stable widget-facing API.
370    pub(crate) transform_stack: Vec<Affine>,
371    /// Depth counter for open [`Command::PushSnapshot`] brackets, incremented
372    /// by [`crate::SceneBuilder::push_snapshot`] and decremented — only when
373    /// greater than zero — by [`crate::SceneBuilder::pop_snapshot`], the same
374    /// unbalanced-pop policy [`Command::PopClip`]/[`Command::PopLayer`]
375    /// follow. Reset alongside `commands` in [`Scene::reset`]; NOT reset by
376    /// [`crate::SceneBuilder::new`] (unlike `transform_stack`), since it
377    /// tracks bracket balance across the whole scene, not a builder session.
378    /// Not part of the stable widget-facing API.
379    pub(crate) snapshot_depth: usize,
380}
381
382impl Scene {
383    /// Creates an empty scene.
384    pub fn new() -> Self {
385        Self::default()
386    }
387
388    /// Clears all recorded commands so the scene can be reused for the next frame.
389    pub fn reset(&mut self) {
390        self.commands.clear();
391        self.snapshot_depth = 0;
392    }
393
394    /// The recorded commands for this frame, in paint order.
395    pub fn commands(&self) -> &[Command] {
396        &self.commands
397    }
398
399    /// Records a command. Used by [`crate::SceneBuilder`]; not part of the
400    /// stable widget-facing API.
401    pub(crate) fn push(&mut self, command: Command) {
402        self.commands.push(command);
403    }
404
405    /// How many [`Command::PushSnapshot`] brackets are still open.
406    ///
407    /// `#[doc(hidden)]`, and deliberately not part of the stable widget-facing
408    /// API: a widget has no business reading the bracket depth mid-recording,
409    /// and nothing in the framework branches on it. It exists so a property
410    /// test outside this crate can assert the balance contract the field's own
411    /// docs state — that a balanced sequence leaves the depth at zero and an
412    /// unmatched [`crate::SceneBuilder::pop_snapshot`] never drives it below
413    /// zero — which is otherwise unobservable from the command stream alone,
414    /// since an ignored pop records nothing to observe.
415    #[doc(hidden)]
416    pub fn snapshot_depth(&self) -> usize {
417        self.snapshot_depth
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[test]
426    fn new_scene_has_no_commands() {
427        let scene = Scene::new();
428        assert!(scene.commands().is_empty());
429    }
430
431    #[test]
432    fn reset_clears_commands() {
433        let mut scene = Scene::new();
434        scene.push(Command::PushClip {
435            rect: Rect::new(0.0, 0.0, 1.0, 1.0),
436            transform: Affine::IDENTITY,
437        });
438        assert_eq!(scene.commands().len(), 1);
439
440        scene.reset();
441        assert!(scene.commands().is_empty());
442    }
443
444    #[test]
445    fn reset_clears_snapshot_depth() {
446        let mut scene = Scene::new();
447        scene.snapshot_depth = 2;
448        scene.reset();
449        assert_eq!(scene.snapshot_depth, 0);
450    }
451
452    #[test]
453    fn corner_radii_from_f64_is_uniform() {
454        assert_eq!(CornerRadii::from(6.0), CornerRadii::new(6.0, 6.0, 6.0, 6.0));
455        assert_eq!(CornerRadii::from(6.0), CornerRadii::uniform(6.0));
456    }
457
458    #[test]
459    fn corner_radii_new_orders_corners_clockwise_from_top_left() {
460        // The argument order is load-bearing: it must match
461        // `kurbo::RoundedRectRadii::new`, which `frust-render` converts into.
462        let radii = CornerRadii::new(1.0, 2.0, 3.0, 4.0);
463        assert_eq!(radii.top_left, 1.0);
464        assert_eq!(radii.top_right, 2.0);
465        assert_eq!(radii.bottom_right, 3.0);
466        assert_eq!(radii.bottom_left, 4.0);
467    }
468
469    #[test]
470    fn corner_radii_largest_picks_the_biggest_corner() {
471        assert_eq!(CornerRadii::new(1.0, 9.0, 3.0, 4.0).largest(), 9.0);
472        assert_eq!(CornerRadii::uniform(2.0).largest(), 2.0);
473        assert_eq!(CornerRadii::default().largest(), 0.0);
474    }
475
476    #[test]
477    fn dash_pattern_defaults_to_zero_phase_and_carries_with_phase() {
478        let dash = DashPattern::new(4.0, 2.0);
479        assert_eq!(dash.phase, 0.0);
480        assert_eq!(dash.with_phase(1.5).phase, 1.5);
481        // `with_phase` leaves the cycle itself alone.
482        assert_eq!(dash.with_phase(1.5).on, 4.0);
483        assert_eq!(dash.with_phase(1.5).off, 2.0);
484    }
485
486    #[test]
487    fn degenerate_dash_patterns_are_not_effective() {
488        assert!(DashPattern::new(4.0, 2.0).is_effective());
489        assert!(!DashPattern::new(0.0, 2.0).is_effective());
490        assert!(!DashPattern::new(4.0, 0.0).is_effective());
491        assert!(!DashPattern::new(-4.0, 2.0).is_effective());
492        assert!(!DashPattern::new(f64::NAN, 2.0).is_effective());
493        assert!(!DashPattern::new(f64::INFINITY, 2.0).is_effective());
494        assert!(
495            !DashPattern::new(4.0, 2.0)
496                .with_phase(f64::NAN)
497                .is_effective()
498        );
499    }
500
501    #[test]
502    fn tiny_period_dash_patterns_are_not_effective() {
503        // A pattern with a vanishingly small period (on + off) below the
504        // epsilon is not effective, falling back to solid stroke. This prevents
505        // the dash iterator from expanding into astronomically many segments.
506        assert!(!DashPattern::new(1e-9, 1e-9).is_effective());
507        assert!(!DashPattern::new(0.01, 0.01).is_effective()); // period 0.02 < 0.1
508        assert!(!DashPattern::new(0.04, 0.05).is_effective()); // period 0.09 < 0.1
509    }
510
511    #[test]
512    fn dash_pattern_at_epsilon_boundary_is_effective() {
513        // At the epsilon boundary, the pattern is exactly effective (not strict <).
514        assert!(DashPattern::new(0.05, 0.05).is_effective()); // period exactly 0.1
515        assert!(DashPattern::new(0.03, 0.07).is_effective()); // period exactly 0.1
516    }
517
518    #[test]
519    fn normal_dash_patterns_remain_effective() {
520        // Normal-sized patterns well above epsilon remain effective.
521        assert!(DashPattern::new(1.0, 0.5).is_effective());
522        assert!(DashPattern::new(4.0, 2.0).is_effective());
523        assert!(DashPattern::new(10.0, 10.0).is_effective());
524    }
525}