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. A clip
202    /// to an arbitrary outline is [`Command::PushClipPath`].
203    PushClipRounded {
204        rect: Rect,
205        /// Per-corner radii, in the pre-transform coordinate space.
206        radii: CornerRadii,
207        transform: Affine,
208    },
209    /// Push a clip to an arbitrary closed outline onto the render backend's
210    /// clip stack, under a transform. Subsequent draws are clipped to the
211    /// path's interior (nonzero winding rule) until the matching
212    /// [`Command::PopClip`].
213    ///
214    /// Popped by the *same* [`Command::PopClip`] every other clip uses — one
215    /// clip stack. `path` is positioned in the same coordinate space as every
216    /// other command (the caller has translated it to the widget's origin
217    /// before recording), exactly as [`Command::Path`]'s is; open subpaths are
218    /// closed implicitly, as a fill closes them.
219    ///
220    /// Lowered as a **coverage mask, no layer page**: the outline is
221    /// rasterized once into coverage the draws inside it are generated
222    /// against, the way a rounded clip is — never an offscreen layer. Because
223    /// it is a mask, **a retained body under it is never retained**: a
224    /// [`Command::PushRetained`] bracket opened inside a path clip always
225    /// paints inline, as one inside a rounded clip does.
226    PushClipPath { path: BezPath, transform: Affine },
227    /// Pop the most recently pushed clip, whichever of [`Command::PushClip`],
228    /// [`Command::PushClipRounded`] or [`Command::PushClipPath`] pushed it.
229    PopClip,
230    /// Draw a decoded image (natural pixel size `data.width`x`data.height`),
231    /// scaled to fill `dest`, under a transform.
232    ///
233    /// `data` is cloned from the widget's cached `ImageSource` each frame;
234    /// `peniko::ImageData`'s `Blob<u8>` is reference-counted internally, so
235    /// this is a cheap handle clone, never a pixel copy or re-decode.
236    Image {
237        data: ImageData,
238        dest: Rect,
239        transform: Affine,
240    },
241    /// Draw a rounded rectangle with a gaussian-blurred elevation shadow (an
242    /// approximation of a CSS `box-shadow`), under a transform.
243    ///
244    /// `radii` are the rectangle's corner radii, `std_dev` the blur's standard
245    /// deviation, both in the pre-transform coordinate space. Maps onto vello
246    /// 0.9's `Scene::draw_blurred_rounded_rect`, whose `brush` parameter is a
247    /// concrete `peniko::Color` (not a `Brush`) — a blurred shadow has no
248    /// gradient support in this vello version — and which takes a *single*
249    /// radius, as does the CPU tier's `fill_blurred_rounded_rect`. A per-corner
250    /// shadow therefore lowers through [`CornerRadii::largest`] at encode time
251    /// (see `frust-render::convert`); the field carries all four corners so
252    /// this command shares one radii vocabulary with its rounded siblings and
253    /// gains per-corner blur for free if a backend ever grows it.
254    BlurredRoundedRect {
255        rect: Rect,
256        radii: CornerRadii,
257        std_dev: f64,
258        color: Color,
259        transform: Affine,
260    },
261    /// Push a translucent layer onto the render backend's layer stack, under
262    /// a transform. Subsequent draws are composited at `alpha` until the
263    /// matching [`Command::PopLayer`].
264    ///
265    /// Semantically a generalization of [`Command::PushClip`] (which is
266    /// `PushLayer` with `alpha: 1.0`) — kept as a distinct variant rather than
267    /// folded into it so existing `PushClip`/`PopClip` consumers are
268    /// unaffected (see `frust-render::convert`).
269    PushLayer {
270        rect: Rect,
271        alpha: f32,
272        transform: Affine,
273    },
274    /// Pop the most recently pushed layer.
275    PopLayer,
276    /// Clear an axis-aligned rectangle to full transparency (alpha 0) under a
277    /// transform, erasing everything already drawn beneath it in this scene —
278    /// a real destination-clearing composite, not a skipped paint.
279    ///
280    /// The platform-view hole-punch is the sole v1 producer (see
281    /// `frust-core`'s `PaintScene::clear_rect`): a translucent-surface (Mode B)
282    /// slot punches its rect so an opaque app backdrop painted below it (the
283    /// catalog's `AppBackground`) doesn't seal the hole the hosted native view
284    /// shows through. `frust-render::convert` lowers this to a destination-out
285    /// composite (an opaque fill erasing color and alpha wherever it covers),
286    /// hoisted to the scene root past any enclosing clip/opacity group so a
287    /// nested slot's punch isn't confined to its own group's content — the
288    /// exact composite mode and hoist mechanics are `frust-render`'s to name
289    /// (scene-layer purity: no vello types here). The clear only becomes
290    /// visible on a surface that actually carries an alpha channel; on an
291    /// opaque surface the transparency is disregarded (vello's surface
292    /// contract), which is why the producer gates it on the translucent flag
293    /// rather than punching always.
294    ClearRect { rect: Rect, transform: Affine },
295    /// Fill or stroke an arbitrary vector path (e.g. an arc), under a
296    /// transform.
297    ///
298    /// `path` is a `kurbo::BezPath` already positioned in the same
299    /// coordinate space as every other command (the caller has translated it
300    /// to the widget's origin before recording); `style` selects fill vs.
301    /// stroke (see [`PathStyle`]).
302    Path {
303        path: BezPath,
304        style: PathStyle,
305        brush: Brush,
306        transform: Affine,
307    },
308    /// Draw a fragment-shader-filled rectangle, scaled to fill `dest`, under
309    /// a transform.
310    ///
311    /// `program` is compiled (and cache-keyed on [`ShaderProgram::id`]) by
312    /// `frust-engine`'s effects module. Output is treated as premultiplied
313    /// alpha. `time` is seconds, app-supplied (from `PaintCtx::frame_time` at
314    /// the widget layer), threaded into the shader's uniform buffer. This
315    /// command lives at the scene layer to preserve purity: the render backend
316    /// interprets the compiled shader output.
317    ShaderQuad {
318        program: ShaderProgram,
319        dest: Rect,
320        transform: Affine,
321        time: f32,
322    },
323    /// Marks the start of a retained-layer bracket, under a transform.
324    ///
325    /// The commands between this and the matching [`Command::PopRetained`] are
326    /// the BODY, recorded in the ordinary composed transform space (i.e. not
327    /// pre-multiplied by `scale`). `rect` is the body's bounds in the local
328    /// space of `transform` — the same convention [`Command::PushLayer`]'s
329    /// `rect`/`alpha`/`transform` use.
330    ///
331    /// `alpha` (`0.0..=1.0`) and `scale` (uniform, about `rect`'s center) are
332    /// PRESENTATION parameters applied to the body as a whole — deliberately
333    /// NOT baked into the body's own commands, so a renderer can reuse a
334    /// rasterized body while they animate.
335    ///
336    /// A renderer that retains MAY rasterize the body once under `key` and
337    /// later honour a [`Command::DrawRetained`] with the same key from that
338    /// page, drawing it with `transform * scale_about(rect.center(), scale)`
339    /// inside an alpha layer. A renderer that does not MUST paint the body
340    /// inline wrapped exactly as if the recorder had emitted
341    /// `push_transform(scale_about(rect.center(), scale))` (when `scale !=
342    /// 1.0`) then `push_layer(rect, alpha)` (when `alpha < 1.0`) — pops
343    /// reversed (see `frust-render::convert`'s miss path).
344    ///
345    /// Syntactic nesting is allowed, but only the OUTERMOST bracket needs
346    /// honouring — an inner bracket's own `alpha`/`scale` may be ignored by a
347    /// non-implementing renderer. An unbalanced [`Command::PopRetained`] is
348    /// ignored, the same policy [`Command::PopLayer`] follows.
349    PushRetained {
350        /// Key identifying this bracket's body across frames.
351        key: u64,
352        rect: Rect,
353        alpha: f32,
354        /// Uniform scale, applied about `rect`'s center.
355        scale: f64,
356        transform: Affine,
357    },
358    /// Pop the most recently pushed retained bracket (see
359    /// [`Command::PushRetained`]).
360    PopRetained,
361    /// Draw a previously retained body from its cached page, with the same
362    /// `key`, `rect`, `transform`, `alpha` and `scale` meanings as
363    /// [`Command::PushRetained`].
364    ///
365    /// A `DrawRetained` only appears in a scene when the resident-key ledger
366    /// said the key is resident, so a renderer that does not retain never sees
367    /// one: it MUST treat it as a renderer error (the CPU oracle panics in
368    /// tests; the engine logs once, skips the frame and clears its store).
369    DrawRetained {
370        key: u64,
371        rect: Rect,
372        transform: Affine,
373        alpha: f32,
374        scale: f64,
375    },
376    /// Draw an externally owned GPU texture scaled to fill `dest` under
377    /// `transform`.
378    ///
379    /// `id` is opaque scene-layer data (precedent: [`ShaderProgram`]'s opaque
380    /// id, `shader.rs`) — only the render backend resolves it against
381    /// textures registered with the GPU context; an unregistered id draws
382    /// nothing.
383    SceneTexture {
384        id: u64,
385        dest: Rect,
386        transform: Affine,
387    },
388    /// Blur whatever is already painted beneath `rect`, under a transform —
389    /// a frosted-glass backdrop (CSS `backdrop-filter: blur(radius)`).
390    ///
391    /// Everything the scene recorded *before* this command, in painter order,
392    /// is the backdrop: the pixels under `rect` are replaced by a Gaussian
393    /// blur of standard deviation `radius` (in the pre-transform coordinate
394    /// space, scaled into device space by `transform` the way a blurred
395    /// rounded rect's `std_dev` is) of what was painted there, drawn through
396    /// any clip open around the command — a rounded or path clip masks the
397    /// blurred region exactly as it masks a fill. Commands recorded after
398    /// this one paint over the blurred region as usual. The blur reads
399    /// transparent black past the edge of the frame. A `radius` at or below
400    /// zero blurs nothing and draws nothing.
401    ///
402    /// The backdrop is the *frame's own* target: a backdrop blur recorded
403    /// inside an isolated (translucent) layer has no frame beneath it to
404    /// sample and draws nothing, and a [`Command::PushRetained`] body that
405    /// carries one is never retained — a page rasterized offscreen cannot
406    /// sample what is painted beneath it. A renderer bounds how many it serves
407    /// per frame (`frust-engine` refuses a frame recording more than four).
408    BackdropBlur {
409        rect: Rect,
410        /// The blur's standard deviation, in the pre-transform coordinate
411        /// space.
412        radius: f64,
413        transform: Affine,
414    },
415}
416
417impl Command {
418    /// Whether this command puts pixels on the target (a *drawing* command)
419    /// rather than only opening or closing a group around other commands (a
420    /// *structural* one).
421    ///
422    /// Drawing: [`Command::FillRect`], [`Command::RoundedRect`],
423    /// [`Command::Line`], [`Command::GlyphRun`], [`Command::Image`],
424    /// [`Command::BlurredRoundedRect`], [`Command::ClearRect`],
425    /// [`Command::Path`], [`Command::ShaderQuad`], [`Command::DrawRetained`],
426    /// [`Command::SceneTexture`] and [`Command::BackdropBlur`]. Structural:
427    /// the clip pushes ([`Command::PushClip`], [`Command::PushClipRounded`],
428    /// [`Command::PushClipPath`]) and [`Command::PopClip`], the layer bracket
429    /// ([`Command::PushLayer`]/[`Command::PopLayer`]) and the retained bracket
430    /// ([`Command::PushRetained`]/[`Command::PopRetained`]) — the same split
431    /// the render backend's compile step applies implicitly (pushes open
432    /// groups, pops close them, everything else draws).
433    ///
434    /// The platform-view differ uses it to decide whether a run of commands
435    /// painted above a native slot carries any frust content worth an
436    /// overlay. The match is exhaustive on purpose: a new variant must be
437    /// classified here before it compiles.
438    pub fn is_drawing(&self) -> bool {
439        match self {
440            Command::FillRect { .. }
441            | Command::RoundedRect { .. }
442            | Command::Line { .. }
443            | Command::GlyphRun(_)
444            | Command::Image { .. }
445            | Command::BlurredRoundedRect { .. }
446            | Command::ClearRect { .. }
447            | Command::Path { .. }
448            | Command::ShaderQuad { .. }
449            | Command::DrawRetained { .. }
450            | Command::SceneTexture { .. }
451            | Command::BackdropBlur { .. } => true,
452            Command::PushClip { .. }
453            | Command::PushClipRounded { .. }
454            | Command::PushClipPath { .. }
455            | Command::PopClip
456            | Command::PushLayer { .. }
457            | Command::PopLayer
458            | Command::PushRetained { .. }
459            | Command::PopRetained => false,
460        }
461    }
462}
463
464/// Renderer-agnostic, immediate-mode display list.
465///
466/// Widgets paint into a `Scene` (via [`crate::SceneBuilder`]) each frame; the
467/// render crate consumes [`Scene::commands`] to draw. Rebuilt per frame — call
468/// [`Scene::reset`] before re-recording rather than allocating a new `Scene`.
469#[derive(Clone, Debug, Default)]
470pub struct Scene {
471    commands: Vec<Command>,
472    /// [`crate::SceneBuilder`]'s transform stack, parked here between frames
473    /// so its backing `Vec` allocation is
474    /// reused across every `SceneBuilder::new` call instead of reallocating
475    /// per frame — a `SceneBuilder` borrows it via `&mut Scene` and resets it
476    /// to `[Affine::IDENTITY]` on construction, so behavior is unchanged.
477    /// Not part of the stable widget-facing API.
478    pub(crate) transform_stack: Vec<Affine>,
479    /// Depth counter for open [`Command::PushRetained`] brackets, incremented
480    /// by [`crate::SceneBuilder::push_retained`] and decremented — only when
481    /// greater than zero — by [`crate::SceneBuilder::pop_retained`], the same
482    /// unbalanced-pop policy [`Command::PopClip`]/[`Command::PopLayer`]
483    /// follow. Reset alongside `commands` in [`Scene::reset`]; NOT reset by
484    /// [`crate::SceneBuilder::new`] (unlike `transform_stack`), since it
485    /// tracks bracket balance across the whole scene, not a builder session.
486    /// Not part of the stable widget-facing API.
487    pub(crate) retained_depth: usize,
488}
489
490impl Scene {
491    /// Creates an empty scene.
492    pub fn new() -> Self {
493        Self::default()
494    }
495
496    /// Clears all recorded commands so the scene can be reused for the next frame.
497    pub fn reset(&mut self) {
498        self.commands.clear();
499        self.retained_depth = 0;
500    }
501
502    /// The recorded commands for this frame, in paint order.
503    pub fn commands(&self) -> &[Command] {
504        &self.commands
505    }
506
507    /// Records a command. Used by [`crate::SceneBuilder`]; not part of the
508    /// stable widget-facing API.
509    pub(crate) fn push(&mut self, command: Command) {
510        self.commands.push(command);
511    }
512
513    /// How many [`Command::PushRetained`] brackets are still open.
514    ///
515    /// `#[doc(hidden)]`, and deliberately not part of the stable widget-facing
516    /// API: a widget has no business reading the bracket depth mid-recording,
517    /// and nothing in the framework branches on it. It exists so a property
518    /// test outside this crate can assert the balance contract the field's own
519    /// docs state — that a balanced sequence leaves the depth at zero and an
520    /// unmatched [`crate::SceneBuilder::pop_retained`] never drives it below
521    /// zero — which is otherwise unobservable from the command stream alone,
522    /// since an ignored pop records nothing to observe.
523    #[doc(hidden)]
524    pub fn retained_depth(&self) -> usize {
525        self.retained_depth
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    #[test]
534    fn new_scene_has_no_commands() {
535        let scene = Scene::new();
536        assert!(scene.commands().is_empty());
537    }
538
539    #[test]
540    fn reset_clears_commands() {
541        let mut scene = Scene::new();
542        scene.push(Command::PushClip {
543            rect: Rect::new(0.0, 0.0, 1.0, 1.0),
544            transform: Affine::IDENTITY,
545        });
546        assert_eq!(scene.commands().len(), 1);
547
548        scene.reset();
549        assert!(scene.commands().is_empty());
550    }
551
552    #[test]
553    fn reset_clears_retained_depth() {
554        let mut scene = Scene::new();
555        scene.retained_depth = 2;
556        scene.reset();
557        assert_eq!(scene.retained_depth, 0);
558    }
559
560    #[test]
561    fn is_drawing_classifies_every_variant() {
562        use crate::glyph::FontHandle;
563        use peniko::FontData;
564
565        let rect = Rect::new(0.0, 0.0, 4.0, 4.0);
566        let t = Affine::IDENTITY;
567        let brush = Brush::Solid(Color::from_rgba8(1, 2, 3, 255));
568        let mut path = BezPath::new();
569        path.move_to((0.0, 0.0));
570        path.line_to((4.0, 4.0));
571        let image = ImageData {
572            data: peniko::Blob::from(vec![0u8; 4]),
573            format: peniko::ImageFormat::Rgba8,
574            alpha_type: peniko::ImageAlphaType::Alpha,
575            width: 1,
576            height: 1,
577        };
578        let glyphs = GlyphRun {
579            font: FontHandle::new(FontData::new(peniko::Blob::from(Vec::<u8>::new()), 0)),
580            font_size: 12.0,
581            brush: brush.clone(),
582            transform: t,
583            glyphs: Vec::new(),
584        };
585
586        let drawing = [
587            Command::FillRect {
588                rect,
589                brush: brush.clone(),
590                transform: t,
591            },
592            Command::RoundedRect {
593                rect,
594                radii: CornerRadii::uniform(2.0),
595                brush: brush.clone(),
596                transform: t,
597            },
598            Command::Line {
599                p0: Point::ORIGIN,
600                p1: Point::new(4.0, 4.0),
601                width: 1.0,
602                brush: brush.clone(),
603                transform: t,
604            },
605            Command::GlyphRun(glyphs),
606            Command::Image {
607                data: image,
608                dest: rect,
609                transform: t,
610            },
611            Command::BlurredRoundedRect {
612                rect,
613                radii: CornerRadii::uniform(2.0),
614                std_dev: 3.0,
615                color: Color::from_rgba8(0, 0, 0, 255),
616                transform: t,
617            },
618            Command::ClearRect { rect, transform: t },
619            Command::Path {
620                path: path.clone(),
621                style: PathStyle::Fill,
622                brush: brush.clone(),
623                transform: t,
624            },
625            Command::ShaderQuad {
626                program: ShaderProgram::new("// test"),
627                dest: rect,
628                transform: t,
629                time: 0.0,
630            },
631            Command::DrawRetained {
632                key: 1,
633                rect,
634                transform: t,
635                alpha: 1.0,
636                scale: 1.0,
637            },
638            Command::SceneTexture {
639                id: 7,
640                dest: rect,
641                transform: t,
642            },
643            Command::BackdropBlur {
644                rect,
645                radius: 4.0,
646                transform: t,
647            },
648        ];
649        let structural = [
650            Command::PushClip { rect, transform: t },
651            Command::PushClipRounded {
652                rect,
653                radii: CornerRadii::uniform(2.0),
654                transform: t,
655            },
656            Command::PushClipPath { path, transform: t },
657            Command::PopClip,
658            Command::PushLayer {
659                rect,
660                alpha: 0.5,
661                transform: t,
662            },
663            Command::PopLayer,
664            Command::PushRetained {
665                key: 1,
666                rect,
667                alpha: 1.0,
668                scale: 1.0,
669                transform: t,
670            },
671            Command::PopRetained,
672        ];
673
674        // 12 drawing + 8 structural = all 20 variants.
675        assert_eq!(drawing.len() + structural.len(), 20);
676        for command in &drawing {
677            assert!(command.is_drawing(), "{command:?} should be drawing");
678        }
679        for command in &structural {
680            assert!(!command.is_drawing(), "{command:?} should be structural");
681        }
682    }
683
684    #[test]
685    fn corner_radii_from_f64_is_uniform() {
686        assert_eq!(CornerRadii::from(6.0), CornerRadii::new(6.0, 6.0, 6.0, 6.0));
687        assert_eq!(CornerRadii::from(6.0), CornerRadii::uniform(6.0));
688    }
689
690    #[test]
691    fn corner_radii_new_orders_corners_clockwise_from_top_left() {
692        // The argument order is load-bearing: it must match
693        // `kurbo::RoundedRectRadii::new`, which `frust-render` converts into.
694        let radii = CornerRadii::new(1.0, 2.0, 3.0, 4.0);
695        assert_eq!(radii.top_left, 1.0);
696        assert_eq!(radii.top_right, 2.0);
697        assert_eq!(radii.bottom_right, 3.0);
698        assert_eq!(radii.bottom_left, 4.0);
699    }
700
701    #[test]
702    fn corner_radii_largest_picks_the_biggest_corner() {
703        assert_eq!(CornerRadii::new(1.0, 9.0, 3.0, 4.0).largest(), 9.0);
704        assert_eq!(CornerRadii::uniform(2.0).largest(), 2.0);
705        assert_eq!(CornerRadii::default().largest(), 0.0);
706    }
707
708    #[test]
709    fn dash_pattern_defaults_to_zero_phase_and_carries_with_phase() {
710        let dash = DashPattern::new(4.0, 2.0);
711        assert_eq!(dash.phase, 0.0);
712        assert_eq!(dash.with_phase(1.5).phase, 1.5);
713        // `with_phase` leaves the cycle itself alone.
714        assert_eq!(dash.with_phase(1.5).on, 4.0);
715        assert_eq!(dash.with_phase(1.5).off, 2.0);
716    }
717
718    #[test]
719    fn degenerate_dash_patterns_are_not_effective() {
720        assert!(DashPattern::new(4.0, 2.0).is_effective());
721        assert!(!DashPattern::new(0.0, 2.0).is_effective());
722        assert!(!DashPattern::new(4.0, 0.0).is_effective());
723        assert!(!DashPattern::new(-4.0, 2.0).is_effective());
724        assert!(!DashPattern::new(f64::NAN, 2.0).is_effective());
725        assert!(!DashPattern::new(f64::INFINITY, 2.0).is_effective());
726        assert!(
727            !DashPattern::new(4.0, 2.0)
728                .with_phase(f64::NAN)
729                .is_effective()
730        );
731    }
732
733    #[test]
734    fn tiny_period_dash_patterns_are_not_effective() {
735        // A pattern with a vanishingly small period (on + off) below the
736        // epsilon is not effective, falling back to solid stroke. This prevents
737        // the dash iterator from expanding into astronomically many segments.
738        assert!(!DashPattern::new(1e-9, 1e-9).is_effective());
739        assert!(!DashPattern::new(0.01, 0.01).is_effective()); // period 0.02 < 0.1
740        assert!(!DashPattern::new(0.04, 0.05).is_effective()); // period 0.09 < 0.1
741    }
742
743    #[test]
744    fn dash_pattern_at_epsilon_boundary_is_effective() {
745        // At the epsilon boundary, the pattern is exactly effective (not strict <).
746        assert!(DashPattern::new(0.05, 0.05).is_effective()); // period exactly 0.1
747        assert!(DashPattern::new(0.03, 0.07).is_effective()); // period exactly 0.1
748    }
749
750    #[test]
751    fn normal_dash_patterns_remain_effective() {
752        // Normal-sized patterns well above epsilon remain effective.
753        assert!(DashPattern::new(1.0, 0.5).is_effective());
754        assert!(DashPattern::new(4.0, 2.0).is_effective());
755        assert!(DashPattern::new(10.0, 10.0).is_effective());
756    }
757}