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}