Skip to main content

frus_core/
path.rs

1//! Vector paths: **arbitrary** 2D geometry (straight segments and Bézier curves),
2//! filled and/or stroked. This is the building block for icons and for custom
3//! drawing (`CustomPaint`), beyond what rectangles can express.
4//!
5//! A [`Path`] is purely declarative — a sequence of [`PathVerb`]. It knows nothing
6//! about the GPU: `frus-gpu` tessellates it into triangles at render time.
7//! Coordinates are in logical pixels, with the same conventions as [`crate::Rect`]
8//! (top-left origin, Y pointing down).
9
10use crate::{Color, Point, Rect};
11
12/// A single drawing command.
13#[derive(Clone, Copy, Debug, PartialEq)]
14pub enum PathVerb {
15    /// Lift the pen and put it down again: starts a new **sub-path** at this point.
16    MoveTo(Point),
17    /// A straight segment from the current point to `to`.
18    LineTo(Point),
19    /// A **quadratic** Bézier curve (one control point).
20    QuadTo {
21        /// The curve's single control point.
22        ctrl: Point,
23        /// The curve's end point.
24        to: Point,
25    },
26    /// A **cubic** Bézier curve (two control points).
27    CubicTo {
28        /// The first control point.
29        c1: Point,
30        /// The second control point.
31        c2: Point,
32        /// The curve's end point.
33        to: Point,
34    },
35    /// Closes the current sub-path, joining it back to its starting point.
36    Close,
37}
38
39impl PathVerb {
40    fn scaled(self, f: f32) -> PathVerb {
41        let s = |p: Point| Point::new(p.x * f, p.y * f);
42        match self {
43            PathVerb::MoveTo(p) => PathVerb::MoveTo(s(p)),
44            PathVerb::LineTo(p) => PathVerb::LineTo(s(p)),
45            PathVerb::QuadTo { ctrl, to } => PathVerb::QuadTo {
46                ctrl: s(ctrl),
47                to: s(to),
48            },
49            PathVerb::CubicTo { c1, c2, to } => PathVerb::CubicTo {
50                c1: s(c1),
51                c2: s(c2),
52                to: s(to),
53            },
54            PathVerb::Close => PathVerb::Close,
55        }
56    }
57
58    fn rotated(self, center: Point, sin: f32, cos: f32) -> PathVerb {
59        let r = |p: Point| {
60            let (dx, dy) = (p.x - center.x, p.y - center.y);
61            Point::new(
62                center.x + dx * cos - dy * sin,
63                center.y + dx * sin + dy * cos,
64            )
65        };
66        match self {
67            PathVerb::MoveTo(p) => PathVerb::MoveTo(r(p)),
68            PathVerb::LineTo(p) => PathVerb::LineTo(r(p)),
69            PathVerb::QuadTo { ctrl, to } => PathVerb::QuadTo {
70                ctrl: r(ctrl),
71                to: r(to),
72            },
73            PathVerb::CubicTo { c1, c2, to } => PathVerb::CubicTo {
74                c1: r(c1),
75                c2: r(c2),
76                to: r(to),
77            },
78            PathVerb::Close => PathVerb::Close,
79        }
80    }
81
82    fn mirrored_x(self, axis: f32) -> PathVerb {
83        let m = |p: Point| Point::new(2.0 * axis - p.x, p.y);
84        match self {
85            PathVerb::MoveTo(p) => PathVerb::MoveTo(m(p)),
86            PathVerb::LineTo(p) => PathVerb::LineTo(m(p)),
87            PathVerb::QuadTo { ctrl, to } => PathVerb::QuadTo {
88                ctrl: m(ctrl),
89                to: m(to),
90            },
91            PathVerb::CubicTo { c1, c2, to } => PathVerb::CubicTo {
92                c1: m(c1),
93                c2: m(c2),
94                to: m(to),
95            },
96            PathVerb::Close => PathVerb::Close,
97        }
98    }
99
100    fn translated(self, dx: f32, dy: f32) -> PathVerb {
101        let t = |p: Point| Point::new(p.x + dx, p.y + dy);
102        match self {
103            PathVerb::MoveTo(p) => PathVerb::MoveTo(t(p)),
104            PathVerb::LineTo(p) => PathVerb::LineTo(t(p)),
105            PathVerb::QuadTo { ctrl, to } => PathVerb::QuadTo {
106                ctrl: t(ctrl),
107                to: t(to),
108            },
109            PathVerb::CubicTo { c1, c2, to } => PathVerb::CubicTo {
110                c1: t(c1),
111                c2: t(c2),
112                to: t(to),
113            },
114            PathVerb::Close => PathVerb::Close,
115        }
116    }
117}
118
119/// **How an open line ends** — the reference's `StrokeCap` (milestone 630).
120#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
121pub enum StrokeCap {
122    /// Flat, exactly at the end point.
123    #[default]
124    Butt,
125    /// Flat, half the line's width past the end point.
126    Square,
127    /// A half circle past the end point.
128    Round,
129}
130
131/// An **outline** (the line a path is drawn with): colour, width, in pixels, and how its
132/// ends are drawn.
133#[derive(Clone, Copy, Debug, PartialEq)]
134pub struct Stroke {
135    /// The line's colour.
136    pub color: Color,
137    /// The line's width, in logical pixels.
138    pub width: f32,
139    /// How an open line ends. [`StrokeCap::Butt`] unless said otherwise.
140    pub cap: StrokeCap,
141}
142
143impl Stroke {
144    /// A stroke of the given colour and width, its ends flat.
145    pub const fn new(color: Color, width: f32) -> Self {
146        Self {
147            color,
148            width,
149            cap: StrokeCap::Butt,
150        }
151    }
152
153    /// The same stroke, its ends drawn as `cap`.
154    #[must_use]
155    pub const fn with_cap(mut self, cap: StrokeCap) -> Self {
156        self.cap = cap;
157        self
158    }
159}
160
161/// **Which points a filled path covers**, where its contours cross or nest.
162///
163/// A point is inside when a ray from it crosses the outline an odd number of times
164/// ([`EvenOdd`](FillRule::EvenOdd)), or when the outline winds round it on balance
165/// ([`NonZero`](FillRule::NonZero)). The two agree on a simple shape and differ on a
166/// self-crossing one or on nested contours drawn the same way round: even-odd makes a hole
167/// of every second one, non-zero fills them unless they turn the other way.
168///
169/// Even-odd is the default because it is what every path here has always been filled
170/// with. Vector artwork (SVG, Lottie) says which it wants, and is mostly non-zero.
171#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
172pub enum FillRule {
173    /// Inside where a ray from the point crosses the outline an odd number of times.
174    #[default]
175    EvenOdd,
176    /// Inside where the outline winds round the point on balance.
177    NonZero,
178}
179
180/// A vector path: a sequence of [`PathVerb`]. Built in *builder* style (chainable),
181/// and consumed at render time.
182///
183/// ```
184/// use frus_core::{Path, Point};
185/// let triangle = Path::new()
186///     .move_to(Point::new(0.0, 0.0))
187///     .line_to(Point::new(10.0, 0.0))
188///     .line_to(Point::new(5.0, 8.0))
189///     .close();
190/// assert_eq!(triangle.verbs().len(), 4);
191/// ```
192#[derive(Clone, Debug, Default, PartialEq)]
193pub struct Path {
194    verbs: Vec<PathVerb>,
195    fill_rule: FillRule,
196}
197
198impl Path {
199    /// The same path, filled by `rule`.
200    pub fn with_fill_rule(mut self, rule: FillRule) -> Self {
201        self.fill_rule = rule;
202        self
203    }
204
205    /// How the path is filled where its contours cross or nest.
206    pub fn fill_rule(&self) -> FillRule {
207        self.fill_rule
208    }
209
210    /// An empty path.
211    pub fn new() -> Self {
212        Self::default()
213    }
214
215    /// Starts a sub-path at `p`.
216    pub fn move_to(mut self, p: Point) -> Self {
217        self.verbs.push(PathVerb::MoveTo(p));
218        self
219    }
220
221    /// A straight segment to `p`.
222    pub fn line_to(mut self, p: Point) -> Self {
223        self.verbs.push(PathVerb::LineTo(p));
224        self
225    }
226
227    /// A quadratic curve (one control point, `ctrl`) to `to`.
228    pub fn quad_to(mut self, ctrl: Point, to: Point) -> Self {
229        self.verbs.push(PathVerb::QuadTo { ctrl, to });
230        self
231    }
232
233    /// A cubic curve (two control points, `c1` and `c2`) to `to`.
234    pub fn cubic_to(mut self, c1: Point, c2: Point, to: Point) -> Self {
235        self.verbs.push(PathVerb::CubicTo { c1, c2, to });
236        self
237    }
238
239    /// Closes the current sub-path.
240    pub fn close(mut self) -> Self {
241        self.verbs.push(PathVerb::Close);
242        self
243    }
244
245    /// This path and then `other`, as one: under the even-odd rule a path inside another
246    /// is a hole in it, which is how a band is drawn.
247    #[must_use]
248    pub fn append(mut self, other: Path) -> Self {
249        self.verbs.extend(other.verbs);
250        self
251    }
252
253    /// The path's commands, in order.
254    pub fn verbs(&self) -> &[PathVerb] {
255        &self.verbs
256    }
257
258    /// `true` when the path holds no commands at all.
259    pub fn is_empty(&self) -> bool {
260        self.verbs.is_empty()
261    }
262
263    /// A filled rectangle (a closed outline, clockwise).
264    pub fn rect(r: Rect) -> Self {
265        Self::new()
266            .move_to(Point::new(r.x, r.y))
267            .line_to(Point::new(r.x + r.width, r.y))
268            .line_to(Point::new(r.x + r.width, r.y + r.height))
269            .line_to(Point::new(r.x, r.y + r.height))
270            .close()
271    }
272
273    /// Continues the path along a **circular arc**, from `start` to `end` (radians,
274    /// measured from the positive x axis, y downwards), around `center`.
275    ///
276    /// The current point is assumed to be the arc's start; nothing moves it there,
277    /// because an arc that jumped to its own beginning could not be part of an
278    /// outline. Approximated by cubics, split so that no piece spans more than a
279    /// quarter turn — which is where the constant used by [`Path::circle`] stops
280    /// being accurate enough.
281    pub fn arc_to(mut self, center: Point, radius: f32, start: f32, end: f32) -> Self {
282        let sweep = end - start;
283        if sweep.abs() < 1e-6 || radius <= 0.0 {
284            return self;
285        }
286        let pieces = (sweep.abs() / std::f32::consts::FRAC_PI_2).ceil().max(1.0);
287        let step = sweep / pieces;
288        // The control-point distance for a cubic that fits a `step` arc exactly at
289        // its ends and its middle. At a quarter turn this is the familiar 0.5523.
290        let k = 4.0 / 3.0 * (step / 4.0).tan();
291        let mut angle = start;
292        for _ in 0..pieces as usize {
293            let next = angle + step;
294            let (s0, c0) = angle.sin_cos();
295            let (s1, c1) = next.sin_cos();
296            let p0 = Point::new(center.x + radius * c0, center.y + radius * s0);
297            let p1 = Point::new(center.x + radius * c1, center.y + radius * s1);
298            self = self.cubic_to(
299                Point::new(p0.x - k * radius * s0, p0.y + k * radius * c0),
300                Point::new(p1.x + k * radius * s1, p1.y - k * radius * c1),
301                p1,
302            );
303            angle = next;
304        }
305        self
306    }
307
308    /// A circle, approximated by **four cubic arcs** (the Bézier constant `0.5523`,
309    /// which is exact at the nodes).
310    pub fn circle(center: Point, radius: f32) -> Self {
311        const K: f32 = 0.552_284_8;
312        let (cx, cy, r) = (center.x, center.y, radius);
313        let k = r * K;
314        Self::new()
315            .move_to(Point::new(cx, cy - r))
316            .cubic_to(
317                Point::new(cx + k, cy - r),
318                Point::new(cx + r, cy - k),
319                Point::new(cx + r, cy),
320            )
321            .cubic_to(
322                Point::new(cx + r, cy + k),
323                Point::new(cx + k, cy + r),
324                Point::new(cx, cy + r),
325            )
326            .cubic_to(
327                Point::new(cx - k, cy + r),
328                Point::new(cx - r, cy + k),
329                Point::new(cx - r, cy),
330            )
331            .cubic_to(
332                Point::new(cx - r, cy - k),
333                Point::new(cx - k, cy - r),
334                Point::new(cx, cy - r),
335            )
336            .close()
337    }
338
339    /// An **ellipse** inscribed in `r`, by the same four-cubic approximation as
340    /// [`Path::circle`] — which it generalises: a square `r` gives that circle back.
341    ///
342    /// A circle scaled along one axis, in other words, without needing a transform
343    /// to say so: it is how a wide, shallow arc (an overscroll glow, a soft
344    /// highlight) is drawn as a plain filled path.
345    pub fn oval(r: Rect) -> Self {
346        const K: f32 = 0.552_284_8;
347        let (rx, ry) = (r.width * 0.5, r.height * 0.5);
348        let (cx, cy) = (r.x + rx, r.y + ry);
349        let (kx, ky) = (rx * K, ry * K);
350        Self::new()
351            .move_to(Point::new(cx, cy - ry))
352            .cubic_to(
353                Point::new(cx + kx, cy - ry),
354                Point::new(cx + rx, cy - ky),
355                Point::new(cx + rx, cy),
356            )
357            .cubic_to(
358                Point::new(cx + rx, cy + ky),
359                Point::new(cx + kx, cy + ry),
360                Point::new(cx, cy + ry),
361            )
362            .cubic_to(
363                Point::new(cx - kx, cy + ry),
364                Point::new(cx - rx, cy + ky),
365                Point::new(cx - rx, cy),
366            )
367            .cubic_to(
368                Point::new(cx - rx, cy - ky),
369                Point::new(cx - kx, cy - ry),
370                Point::new(cx, cy - ry),
371            )
372            .close()
373    }
374
375    /// A copy scaled by `factor` about the origin — used for logical-to-physical
376    /// conversion, or to fit a `24×24` icon to its real size.
377    pub fn scaled(&self, factor: f32) -> Path {
378        Path {
379            verbs: self.verbs.iter().map(|v| v.scaled(factor)).collect(),
380            fill_rule: self.fill_rule,
381        }
382    }
383
384    /// A copy translated by `(dx, dy)` — used to place an icon inside its box.
385    pub fn translated(&self, dx: f32, dy: f32) -> Path {
386        Path {
387            verbs: self.verbs.iter().map(|v| v.translated(dx, dy)).collect(),
388            fill_rule: self.fill_rule,
389        }
390    }
391
392    /// A copy reflected in the **vertical line** `x = axis` — how an icon that carries a
393    /// direction (an arrow, an indent, a reply) is turned round for a right-to-left
394    /// reading order.
395    ///
396    /// The reflection reverses each contour's winding, and reverses **every** contour, so
397    /// a shape's holes stay holes: the non-zero rule cares about the relative direction
398    /// of the contours, not about their absolute one.
399    pub fn mirrored_x(&self, axis: f32) -> Path {
400        Path {
401            verbs: self.verbs.iter().map(|v| v.mirrored_x(axis)).collect(),
402            fill_rule: self.fill_rule,
403        }
404    }
405
406    /// A copy turned by `radians` about `center`, clockwise — y points down here, so a
407    /// positive angle turns the way a clock's hand does.
408    ///
409    /// Unlike a reflection, a rotation **keeps** each contour's winding, so a shape's
410    /// holes stay holes without anything else being done about it. Bézier control points
411    /// turn with their curve, which is exact: an affine map of a Bézier's control polygon
412    /// is the same Bézier mapped.
413    ///
414    /// The sine and cosine are taken once for the whole path rather than per point.
415    pub fn rotated(&self, center: Point, radians: f32) -> Path {
416        let (sin, cos) = radians.sin_cos();
417        Path {
418            verbs: self
419                .verbs
420                .iter()
421                .map(|v| v.rotated(center, sin, cos))
422                .collect(),
423            fill_rule: self.fill_rule,
424        }
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    /// The fill rule is the path's, and every copy of it keeps it.
433    #[test]
434    fn a_fill_rule_survives_every_transform() {
435        let p = Path::rect(Rect::new(0.0, 0.0, 4.0, 4.0)).with_fill_rule(FillRule::NonZero);
436        assert_eq!(
437            Path::new().fill_rule(),
438            FillRule::EvenOdd,
439            "even-odd by default"
440        );
441        assert_eq!(p.scaled(2.0).fill_rule(), FillRule::NonZero);
442        assert_eq!(p.translated(1.0, 1.0).fill_rule(), FillRule::NonZero);
443        assert_eq!(p.mirrored_x(2.0).fill_rule(), FillRule::NonZero);
444        assert_eq!(
445            p.rotated(Point::new(0.0, 0.0), 1.0).fill_rule(),
446            FillRule::NonZero
447        );
448    }
449
450    /// A reflection is its own inverse, moves nothing on the axis, and leaves y alone.
451    /// Those three together are the whole definition, and each one is a mistake somebody
452    /// makes: reflecting about the origin instead of the axis, or about the wrong one.
453    #[test]
454    fn mirroring_reflects_x_about_the_axis_and_nothing_else() {
455        let path = Path::new()
456            .move_to(Point::new(2.0, 5.0))
457            .line_to(Point::new(8.0, 5.0))
458            .quad_to(Point::new(10.0, 7.0), Point::new(6.0, 9.0))
459            .cubic_to(
460                Point::new(4.0, 9.0),
461                Point::new(3.0, 8.0),
462                Point::new(2.0, 5.0),
463            )
464            .close();
465        let flipped = path.mirrored_x(6.0);
466        assert_eq!(
467            flipped.verbs(),
468            &[
469                PathVerb::MoveTo(Point::new(10.0, 5.0)),
470                PathVerb::LineTo(Point::new(4.0, 5.0)),
471                PathVerb::QuadTo {
472                    ctrl: Point::new(2.0, 7.0),
473                    to: Point::new(6.0, 9.0),
474                },
475                PathVerb::CubicTo {
476                    c1: Point::new(8.0, 9.0),
477                    c2: Point::new(9.0, 8.0),
478                    to: Point::new(10.0, 5.0),
479                },
480                PathVerb::Close,
481            ]
482        );
483        // A point on the axis does not move, and mirroring twice is the identity.
484        assert_eq!(path.mirrored_x(6.0).mirrored_x(6.0).verbs(), path.verbs());
485    }
486
487    /// A quarter turn, in the direction the screen reads it. Y points **down** here, so
488    /// a positive angle has to turn clockwise on screen — the opposite of the convention
489    /// in a maths textbook, and the thing that is silently backwards if the sign is taken
490    /// from one.
491    ///
492    /// A point at the centre stays put, and four quarter turns come back where they
493    /// started. Those two together rule out rotating about the origin instead of the
494    /// centre, which looks like a translation and is the mistake worth catching.
495    #[test]
496    fn rotating_turns_clockwise_about_the_centre() {
497        let centre = Point::new(10.0, 10.0);
498        let quarter = std::f32::consts::FRAC_PI_2;
499        // Directly above the centre. A clockwise quarter turn puts it to the right.
500        let up = Path::new()
501            .move_to(centre)
502            .line_to(Point::new(10.0, 4.0))
503            .close();
504        let turned = up.rotated(centre, quarter);
505        let PathVerb::LineTo(tip) = turned.verbs()[1] else {
506            panic!("the second verb is still a line");
507        };
508        assert!(
509            (tip.x - 16.0).abs() < 1e-4 && (tip.y - 10.0).abs() < 1e-4,
510            "a quarter turn puts the tip to the right of the centre: {tip:?}"
511        );
512        let PathVerb::MoveTo(fixed) = turned.verbs()[0] else {
513            panic!("the first verb is still a move");
514        };
515        assert_eq!(fixed, centre, "the centre does not move");
516
517        // Four quarter turns are the identity, up to the arithmetic.
518        let round_trip = up
519            .rotated(centre, quarter)
520            .rotated(centre, quarter)
521            .rotated(centre, quarter)
522            .rotated(centre, quarter);
523        for (a, b) in round_trip.verbs().iter().zip(up.verbs()) {
524            match (a, b) {
525                (PathVerb::MoveTo(a), PathVerb::MoveTo(b))
526                | (PathVerb::LineTo(a), PathVerb::LineTo(b)) => {
527                    assert!(
528                        (a.x - b.x).abs() < 1e-3 && (a.y - b.y).abs() < 1e-3,
529                        "{a:?}"
530                    );
531                }
532                (PathVerb::Close, PathVerb::Close) => {}
533                _ => panic!("the verbs changed kind"),
534            }
535        }
536    }
537
538    /// A curve's control points turn **with** it. An affine map of a Bézier's control
539    /// polygon is the same Bézier mapped, so a rotation that moved only the on-curve
540    /// points would bend every curve in the icon and pass any test that looked at
541    /// endpoints alone.
542    #[test]
543    fn rotating_carries_the_control_points() {
544        let centre = Point::new(0.0, 0.0);
545        let half = std::f32::consts::PI;
546        let curved = Path::new()
547            .move_to(Point::new(1.0, 0.0))
548            .quad_to(Point::new(2.0, 3.0), Point::new(4.0, 0.0))
549            .cubic_to(
550                Point::new(5.0, 1.0),
551                Point::new(6.0, 2.0),
552                Point::new(7.0, 0.0),
553            );
554        let turned = curved.rotated(centre, half);
555        // A half turn about the origin is a negation of both coordinates.
556        let near = |a: Point, x: f32, y: f32| (a.x - x).abs() < 1e-4 && (a.y - y).abs() < 1e-4;
557        match turned.verbs()[1] {
558            PathVerb::QuadTo { ctrl, to } => {
559                assert!(near(ctrl, -2.0, -3.0), "the control point turned: {ctrl:?}");
560                assert!(near(to, -4.0, 0.0), "and so did the end: {to:?}");
561            }
562            ref other => panic!("expected a quad, got {other:?}"),
563        }
564        match turned.verbs()[2] {
565            PathVerb::CubicTo { c1, c2, to } => {
566                assert!(
567                    near(c1, -5.0, -1.0) && near(c2, -6.0, -2.0),
568                    "both controls"
569                );
570                assert!(near(to, -7.0, 0.0), "and the end: {to:?}");
571            }
572            ref other => panic!("expected a cubic, got {other:?}"),
573        }
574    }
575
576    #[test]
577    fn builder_records_verbs_in_order() {
578        let p = Path::new()
579            .move_to(Point::new(1.0, 2.0))
580            .line_to(Point::new(3.0, 4.0))
581            .quad_to(Point::new(5.0, 6.0), Point::new(7.0, 8.0))
582            .close();
583        assert_eq!(
584            p.verbs(),
585            &[
586                PathVerb::MoveTo(Point::new(1.0, 2.0)),
587                PathVerb::LineTo(Point::new(3.0, 4.0)),
588                PathVerb::QuadTo {
589                    ctrl: Point::new(5.0, 6.0),
590                    to: Point::new(7.0, 8.0)
591                },
592                PathVerb::Close,
593            ]
594        );
595    }
596
597    /// An arc lands where it was told to and stays on the circle in between — the
598    /// property that matters, since the arc is one segment of a larger outline.
599    #[test]
600    fn an_arc_ends_on_the_circle_it_was_given() {
601        use std::f32::consts::PI;
602        let centre = Point::new(50.0, 40.0);
603        let radius = 12.0;
604        for (start, end) in [
605            (0.0, PI),        // half a turn
606            (PI, 0.0),        // and back the other way
607            (0.3, 0.5),       // a sliver
608            (-PI, PI * 0.75), // more than a half turn: several pieces
609        ] {
610            let on = |a: f32| Point::new(centre.x + radius * a.cos(), centre.y + radius * a.sin());
611            let path = Path::new()
612                .move_to(on(start))
613                .arc_to(centre, radius, start, end);
614            let last = match path.verbs().last() {
615                Some(PathVerb::CubicTo { to, .. }) => *to,
616                other => panic!("an arc ends on a cubic, got {other:?}"),
617            };
618            let want = on(end);
619            assert!(
620                (last.x - want.x).abs() < 0.01 && (last.y - want.y).abs() < 0.01,
621                "arc {start}..{end} ended at {last:?}, wanted {want:?}"
622            );
623            // Every node the arc puts down sits on the circle.
624            for verb in path.verbs() {
625                if let PathVerb::CubicTo { to, .. } = verb {
626                    let d = ((to.x - centre.x).powi(2) + (to.y - centre.y).powi(2)).sqrt();
627                    assert!((d - radius).abs() < 0.01, "node off the circle: {d}");
628                }
629            }
630        }
631    }
632
633    #[test]
634    fn rect_is_a_closed_quad() {
635        let p = Path::rect(Rect::new(0.0, 0.0, 10.0, 20.0));
636        // move + 3× line + close = 5 commands (`close` supplies the fourth side).
637        assert_eq!(p.verbs().len(), 5);
638        assert!(matches!(p.verbs().last(), Some(PathVerb::Close)));
639    }
640
641    #[test]
642    fn scaled_and_translated_transform_points() {
643        let p = Path::new()
644            .move_to(Point::new(2.0, 3.0))
645            .line_to(Point::new(4.0, 5.0));
646        let s = p.scaled(2.0);
647        assert_eq!(s.verbs()[0], PathVerb::MoveTo(Point::new(4.0, 6.0)));
648        let t = p.translated(10.0, 100.0);
649        assert_eq!(t.verbs()[1], PathVerb::LineTo(Point::new(14.0, 105.0)));
650    }
651
652    #[test]
653    fn circle_starts_at_top_and_closes() {
654        let c = Path::circle(Point::new(12.0, 12.0), 10.0);
655        assert_eq!(c.verbs()[0], PathVerb::MoveTo(Point::new(12.0, 2.0)));
656        assert!(matches!(c.verbs().last(), Some(PathVerb::Close)));
657    }
658}