frus-core 0.5.0

frus — shared foundation types (geometry, colour)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
//! Vector paths: **arbitrary** 2D geometry (straight segments and Bézier curves),
//! filled and/or stroked. This is the building block for icons and for custom
//! drawing (`CustomPaint`), beyond what rectangles can express.
//!
//! A [`Path`] is purely declarative — a sequence of [`PathVerb`]. It knows nothing
//! about the GPU: `frus-gpu` tessellates it into triangles at render time.
//! Coordinates are in logical pixels, with the same conventions as [`crate::Rect`]
//! (top-left origin, Y pointing down).

use crate::{Color, Point, Rect};

/// A single drawing command.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum PathVerb {
    /// Lift the pen and put it down again: starts a new **sub-path** at this point.
    MoveTo(Point),
    /// A straight segment from the current point to `to`.
    LineTo(Point),
    /// A **quadratic** Bézier curve (one control point).
    QuadTo {
        /// The curve's single control point.
        ctrl: Point,
        /// The curve's end point.
        to: Point,
    },
    /// A **cubic** Bézier curve (two control points).
    CubicTo {
        /// The first control point.
        c1: Point,
        /// The second control point.
        c2: Point,
        /// The curve's end point.
        to: Point,
    },
    /// Closes the current sub-path, joining it back to its starting point.
    Close,
}

impl PathVerb {
    fn scaled(self, f: f32) -> PathVerb {
        let s = |p: Point| Point::new(p.x * f, p.y * f);
        match self {
            PathVerb::MoveTo(p) => PathVerb::MoveTo(s(p)),
            PathVerb::LineTo(p) => PathVerb::LineTo(s(p)),
            PathVerb::QuadTo { ctrl, to } => PathVerb::QuadTo {
                ctrl: s(ctrl),
                to: s(to),
            },
            PathVerb::CubicTo { c1, c2, to } => PathVerb::CubicTo {
                c1: s(c1),
                c2: s(c2),
                to: s(to),
            },
            PathVerb::Close => PathVerb::Close,
        }
    }

    fn rotated(self, center: Point, sin: f32, cos: f32) -> PathVerb {
        let r = |p: Point| {
            let (dx, dy) = (p.x - center.x, p.y - center.y);
            Point::new(
                center.x + dx * cos - dy * sin,
                center.y + dx * sin + dy * cos,
            )
        };
        match self {
            PathVerb::MoveTo(p) => PathVerb::MoveTo(r(p)),
            PathVerb::LineTo(p) => PathVerb::LineTo(r(p)),
            PathVerb::QuadTo { ctrl, to } => PathVerb::QuadTo {
                ctrl: r(ctrl),
                to: r(to),
            },
            PathVerb::CubicTo { c1, c2, to } => PathVerb::CubicTo {
                c1: r(c1),
                c2: r(c2),
                to: r(to),
            },
            PathVerb::Close => PathVerb::Close,
        }
    }

    fn mirrored_x(self, axis: f32) -> PathVerb {
        let m = |p: Point| Point::new(2.0 * axis - p.x, p.y);
        match self {
            PathVerb::MoveTo(p) => PathVerb::MoveTo(m(p)),
            PathVerb::LineTo(p) => PathVerb::LineTo(m(p)),
            PathVerb::QuadTo { ctrl, to } => PathVerb::QuadTo {
                ctrl: m(ctrl),
                to: m(to),
            },
            PathVerb::CubicTo { c1, c2, to } => PathVerb::CubicTo {
                c1: m(c1),
                c2: m(c2),
                to: m(to),
            },
            PathVerb::Close => PathVerb::Close,
        }
    }

    fn translated(self, dx: f32, dy: f32) -> PathVerb {
        let t = |p: Point| Point::new(p.x + dx, p.y + dy);
        match self {
            PathVerb::MoveTo(p) => PathVerb::MoveTo(t(p)),
            PathVerb::LineTo(p) => PathVerb::LineTo(t(p)),
            PathVerb::QuadTo { ctrl, to } => PathVerb::QuadTo {
                ctrl: t(ctrl),
                to: t(to),
            },
            PathVerb::CubicTo { c1, c2, to } => PathVerb::CubicTo {
                c1: t(c1),
                c2: t(c2),
                to: t(to),
            },
            PathVerb::Close => PathVerb::Close,
        }
    }
}

/// **How an open line ends** — the reference's `StrokeCap` (milestone 630).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum StrokeCap {
    /// Flat, exactly at the end point.
    #[default]
    Butt,
    /// Flat, half the line's width past the end point.
    Square,
    /// A half circle past the end point.
    Round,
}

/// An **outline** (the line a path is drawn with): colour, width, in pixels, and how its
/// ends are drawn.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Stroke {
    /// The line's colour.
    pub color: Color,
    /// The line's width, in logical pixels.
    pub width: f32,
    /// How an open line ends. [`StrokeCap::Butt`] unless said otherwise.
    pub cap: StrokeCap,
}

impl Stroke {
    /// A stroke of the given colour and width, its ends flat.
    pub const fn new(color: Color, width: f32) -> Self {
        Self {
            color,
            width,
            cap: StrokeCap::Butt,
        }
    }

    /// The same stroke, its ends drawn as `cap`.
    #[must_use]
    pub const fn with_cap(mut self, cap: StrokeCap) -> Self {
        self.cap = cap;
        self
    }
}

/// **Which points a filled path covers**, where its contours cross or nest.
///
/// A point is inside when a ray from it crosses the outline an odd number of times
/// ([`EvenOdd`](FillRule::EvenOdd)), or when the outline winds round it on balance
/// ([`NonZero`](FillRule::NonZero)). The two agree on a simple shape and differ on a
/// self-crossing one or on nested contours drawn the same way round: even-odd makes a hole
/// of every second one, non-zero fills them unless they turn the other way.
///
/// Even-odd is the default because it is what every path here has always been filled
/// with. Vector artwork (SVG, Lottie) says which it wants, and is mostly non-zero.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum FillRule {
    /// Inside where a ray from the point crosses the outline an odd number of times.
    #[default]
    EvenOdd,
    /// Inside where the outline winds round the point on balance.
    NonZero,
}

/// A vector path: a sequence of [`PathVerb`]. Built in *builder* style (chainable),
/// and consumed at render time.
///
/// ```
/// use frus_core::{Path, Point};
/// let triangle = Path::new()
///     .move_to(Point::new(0.0, 0.0))
///     .line_to(Point::new(10.0, 0.0))
///     .line_to(Point::new(5.0, 8.0))
///     .close();
/// assert_eq!(triangle.verbs().len(), 4);
/// ```
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Path {
    verbs: Vec<PathVerb>,
    fill_rule: FillRule,
}

impl Path {
    /// The same path, filled by `rule`.
    pub fn with_fill_rule(mut self, rule: FillRule) -> Self {
        self.fill_rule = rule;
        self
    }

    /// How the path is filled where its contours cross or nest.
    pub fn fill_rule(&self) -> FillRule {
        self.fill_rule
    }

    /// An empty path.
    pub fn new() -> Self {
        Self::default()
    }

    /// Starts a sub-path at `p`.
    pub fn move_to(mut self, p: Point) -> Self {
        self.verbs.push(PathVerb::MoveTo(p));
        self
    }

    /// A straight segment to `p`.
    pub fn line_to(mut self, p: Point) -> Self {
        self.verbs.push(PathVerb::LineTo(p));
        self
    }

    /// A quadratic curve (one control point, `ctrl`) to `to`.
    pub fn quad_to(mut self, ctrl: Point, to: Point) -> Self {
        self.verbs.push(PathVerb::QuadTo { ctrl, to });
        self
    }

    /// A cubic curve (two control points, `c1` and `c2`) to `to`.
    pub fn cubic_to(mut self, c1: Point, c2: Point, to: Point) -> Self {
        self.verbs.push(PathVerb::CubicTo { c1, c2, to });
        self
    }

    /// Closes the current sub-path.
    pub fn close(mut self) -> Self {
        self.verbs.push(PathVerb::Close);
        self
    }

    /// This path and then `other`, as one: under the even-odd rule a path inside another
    /// is a hole in it, which is how a band is drawn.
    #[must_use]
    pub fn append(mut self, other: Path) -> Self {
        self.verbs.extend(other.verbs);
        self
    }

    /// The path's commands, in order.
    pub fn verbs(&self) -> &[PathVerb] {
        &self.verbs
    }

    /// `true` when the path holds no commands at all.
    pub fn is_empty(&self) -> bool {
        self.verbs.is_empty()
    }

    /// A filled rectangle (a closed outline, clockwise).
    pub fn rect(r: Rect) -> Self {
        Self::new()
            .move_to(Point::new(r.x, r.y))
            .line_to(Point::new(r.x + r.width, r.y))
            .line_to(Point::new(r.x + r.width, r.y + r.height))
            .line_to(Point::new(r.x, r.y + r.height))
            .close()
    }

    /// Continues the path along a **circular arc**, from `start` to `end` (radians,
    /// measured from the positive x axis, y downwards), around `center`.
    ///
    /// The current point is assumed to be the arc's start; nothing moves it there,
    /// because an arc that jumped to its own beginning could not be part of an
    /// outline. Approximated by cubics, split so that no piece spans more than a
    /// quarter turn — which is where the constant used by [`Path::circle`] stops
    /// being accurate enough.
    pub fn arc_to(mut self, center: Point, radius: f32, start: f32, end: f32) -> Self {
        let sweep = end - start;
        if sweep.abs() < 1e-6 || radius <= 0.0 {
            return self;
        }
        let pieces = (sweep.abs() / std::f32::consts::FRAC_PI_2).ceil().max(1.0);
        let step = sweep / pieces;
        // The control-point distance for a cubic that fits a `step` arc exactly at
        // its ends and its middle. At a quarter turn this is the familiar 0.5523.
        let k = 4.0 / 3.0 * (step / 4.0).tan();
        let mut angle = start;
        for _ in 0..pieces as usize {
            let next = angle + step;
            let (s0, c0) = angle.sin_cos();
            let (s1, c1) = next.sin_cos();
            let p0 = Point::new(center.x + radius * c0, center.y + radius * s0);
            let p1 = Point::new(center.x + radius * c1, center.y + radius * s1);
            self = self.cubic_to(
                Point::new(p0.x - k * radius * s0, p0.y + k * radius * c0),
                Point::new(p1.x + k * radius * s1, p1.y - k * radius * c1),
                p1,
            );
            angle = next;
        }
        self
    }

    /// A circle, approximated by **four cubic arcs** (the Bézier constant `0.5523`,
    /// which is exact at the nodes).
    pub fn circle(center: Point, radius: f32) -> Self {
        const K: f32 = 0.552_284_8;
        let (cx, cy, r) = (center.x, center.y, radius);
        let k = r * K;
        Self::new()
            .move_to(Point::new(cx, cy - r))
            .cubic_to(
                Point::new(cx + k, cy - r),
                Point::new(cx + r, cy - k),
                Point::new(cx + r, cy),
            )
            .cubic_to(
                Point::new(cx + r, cy + k),
                Point::new(cx + k, cy + r),
                Point::new(cx, cy + r),
            )
            .cubic_to(
                Point::new(cx - k, cy + r),
                Point::new(cx - r, cy + k),
                Point::new(cx - r, cy),
            )
            .cubic_to(
                Point::new(cx - r, cy - k),
                Point::new(cx - k, cy - r),
                Point::new(cx, cy - r),
            )
            .close()
    }

    /// An **ellipse** inscribed in `r`, by the same four-cubic approximation as
    /// [`Path::circle`] — which it generalises: a square `r` gives that circle back.
    ///
    /// A circle scaled along one axis, in other words, without needing a transform
    /// to say so: it is how a wide, shallow arc (an overscroll glow, a soft
    /// highlight) is drawn as a plain filled path.
    pub fn oval(r: Rect) -> Self {
        const K: f32 = 0.552_284_8;
        let (rx, ry) = (r.width * 0.5, r.height * 0.5);
        let (cx, cy) = (r.x + rx, r.y + ry);
        let (kx, ky) = (rx * K, ry * K);
        Self::new()
            .move_to(Point::new(cx, cy - ry))
            .cubic_to(
                Point::new(cx + kx, cy - ry),
                Point::new(cx + rx, cy - ky),
                Point::new(cx + rx, cy),
            )
            .cubic_to(
                Point::new(cx + rx, cy + ky),
                Point::new(cx + kx, cy + ry),
                Point::new(cx, cy + ry),
            )
            .cubic_to(
                Point::new(cx - kx, cy + ry),
                Point::new(cx - rx, cy + ky),
                Point::new(cx - rx, cy),
            )
            .cubic_to(
                Point::new(cx - rx, cy - ky),
                Point::new(cx - kx, cy - ry),
                Point::new(cx, cy - ry),
            )
            .close()
    }

    /// A copy scaled by `factor` about the origin — used for logical-to-physical
    /// conversion, or to fit a `24×24` icon to its real size.
    pub fn scaled(&self, factor: f32) -> Path {
        Path {
            verbs: self.verbs.iter().map(|v| v.scaled(factor)).collect(),
            fill_rule: self.fill_rule,
        }
    }

    /// A copy translated by `(dx, dy)` — used to place an icon inside its box.
    pub fn translated(&self, dx: f32, dy: f32) -> Path {
        Path {
            verbs: self.verbs.iter().map(|v| v.translated(dx, dy)).collect(),
            fill_rule: self.fill_rule,
        }
    }

    /// A copy reflected in the **vertical line** `x = axis` — how an icon that carries a
    /// direction (an arrow, an indent, a reply) is turned round for a right-to-left
    /// reading order.
    ///
    /// The reflection reverses each contour's winding, and reverses **every** contour, so
    /// a shape's holes stay holes: the non-zero rule cares about the relative direction
    /// of the contours, not about their absolute one.
    pub fn mirrored_x(&self, axis: f32) -> Path {
        Path {
            verbs: self.verbs.iter().map(|v| v.mirrored_x(axis)).collect(),
            fill_rule: self.fill_rule,
        }
    }

    /// A copy turned by `radians` about `center`, clockwise — y points down here, so a
    /// positive angle turns the way a clock's hand does.
    ///
    /// Unlike a reflection, a rotation **keeps** each contour's winding, so a shape's
    /// holes stay holes without anything else being done about it. Bézier control points
    /// turn with their curve, which is exact: an affine map of a Bézier's control polygon
    /// is the same Bézier mapped.
    ///
    /// The sine and cosine are taken once for the whole path rather than per point.
    pub fn rotated(&self, center: Point, radians: f32) -> Path {
        let (sin, cos) = radians.sin_cos();
        Path {
            verbs: self
                .verbs
                .iter()
                .map(|v| v.rotated(center, sin, cos))
                .collect(),
            fill_rule: self.fill_rule,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The fill rule is the path's, and every copy of it keeps it.
    #[test]
    fn a_fill_rule_survives_every_transform() {
        let p = Path::rect(Rect::new(0.0, 0.0, 4.0, 4.0)).with_fill_rule(FillRule::NonZero);
        assert_eq!(
            Path::new().fill_rule(),
            FillRule::EvenOdd,
            "even-odd by default"
        );
        assert_eq!(p.scaled(2.0).fill_rule(), FillRule::NonZero);
        assert_eq!(p.translated(1.0, 1.0).fill_rule(), FillRule::NonZero);
        assert_eq!(p.mirrored_x(2.0).fill_rule(), FillRule::NonZero);
        assert_eq!(
            p.rotated(Point::new(0.0, 0.0), 1.0).fill_rule(),
            FillRule::NonZero
        );
    }

    /// A reflection is its own inverse, moves nothing on the axis, and leaves y alone.
    /// Those three together are the whole definition, and each one is a mistake somebody
    /// makes: reflecting about the origin instead of the axis, or about the wrong one.
    #[test]
    fn mirroring_reflects_x_about_the_axis_and_nothing_else() {
        let path = Path::new()
            .move_to(Point::new(2.0, 5.0))
            .line_to(Point::new(8.0, 5.0))
            .quad_to(Point::new(10.0, 7.0), Point::new(6.0, 9.0))
            .cubic_to(
                Point::new(4.0, 9.0),
                Point::new(3.0, 8.0),
                Point::new(2.0, 5.0),
            )
            .close();
        let flipped = path.mirrored_x(6.0);
        assert_eq!(
            flipped.verbs(),
            &[
                PathVerb::MoveTo(Point::new(10.0, 5.0)),
                PathVerb::LineTo(Point::new(4.0, 5.0)),
                PathVerb::QuadTo {
                    ctrl: Point::new(2.0, 7.0),
                    to: Point::new(6.0, 9.0),
                },
                PathVerb::CubicTo {
                    c1: Point::new(8.0, 9.0),
                    c2: Point::new(9.0, 8.0),
                    to: Point::new(10.0, 5.0),
                },
                PathVerb::Close,
            ]
        );
        // A point on the axis does not move, and mirroring twice is the identity.
        assert_eq!(path.mirrored_x(6.0).mirrored_x(6.0).verbs(), path.verbs());
    }

    /// A quarter turn, in the direction the screen reads it. Y points **down** here, so
    /// a positive angle has to turn clockwise on screen — the opposite of the convention
    /// in a maths textbook, and the thing that is silently backwards if the sign is taken
    /// from one.
    ///
    /// A point at the centre stays put, and four quarter turns come back where they
    /// started. Those two together rule out rotating about the origin instead of the
    /// centre, which looks like a translation and is the mistake worth catching.
    #[test]
    fn rotating_turns_clockwise_about_the_centre() {
        let centre = Point::new(10.0, 10.0);
        let quarter = std::f32::consts::FRAC_PI_2;
        // Directly above the centre. A clockwise quarter turn puts it to the right.
        let up = Path::new()
            .move_to(centre)
            .line_to(Point::new(10.0, 4.0))
            .close();
        let turned = up.rotated(centre, quarter);
        let PathVerb::LineTo(tip) = turned.verbs()[1] else {
            panic!("the second verb is still a line");
        };
        assert!(
            (tip.x - 16.0).abs() < 1e-4 && (tip.y - 10.0).abs() < 1e-4,
            "a quarter turn puts the tip to the right of the centre: {tip:?}"
        );
        let PathVerb::MoveTo(fixed) = turned.verbs()[0] else {
            panic!("the first verb is still a move");
        };
        assert_eq!(fixed, centre, "the centre does not move");

        // Four quarter turns are the identity, up to the arithmetic.
        let round_trip = up
            .rotated(centre, quarter)
            .rotated(centre, quarter)
            .rotated(centre, quarter)
            .rotated(centre, quarter);
        for (a, b) in round_trip.verbs().iter().zip(up.verbs()) {
            match (a, b) {
                (PathVerb::MoveTo(a), PathVerb::MoveTo(b))
                | (PathVerb::LineTo(a), PathVerb::LineTo(b)) => {
                    assert!(
                        (a.x - b.x).abs() < 1e-3 && (a.y - b.y).abs() < 1e-3,
                        "{a:?}"
                    );
                }
                (PathVerb::Close, PathVerb::Close) => {}
                _ => panic!("the verbs changed kind"),
            }
        }
    }

    /// A curve's control points turn **with** it. An affine map of a Bézier's control
    /// polygon is the same Bézier mapped, so a rotation that moved only the on-curve
    /// points would bend every curve in the icon and pass any test that looked at
    /// endpoints alone.
    #[test]
    fn rotating_carries_the_control_points() {
        let centre = Point::new(0.0, 0.0);
        let half = std::f32::consts::PI;
        let curved = Path::new()
            .move_to(Point::new(1.0, 0.0))
            .quad_to(Point::new(2.0, 3.0), Point::new(4.0, 0.0))
            .cubic_to(
                Point::new(5.0, 1.0),
                Point::new(6.0, 2.0),
                Point::new(7.0, 0.0),
            );
        let turned = curved.rotated(centre, half);
        // A half turn about the origin is a negation of both coordinates.
        let near = |a: Point, x: f32, y: f32| (a.x - x).abs() < 1e-4 && (a.y - y).abs() < 1e-4;
        match turned.verbs()[1] {
            PathVerb::QuadTo { ctrl, to } => {
                assert!(near(ctrl, -2.0, -3.0), "the control point turned: {ctrl:?}");
                assert!(near(to, -4.0, 0.0), "and so did the end: {to:?}");
            }
            ref other => panic!("expected a quad, got {other:?}"),
        }
        match turned.verbs()[2] {
            PathVerb::CubicTo { c1, c2, to } => {
                assert!(
                    near(c1, -5.0, -1.0) && near(c2, -6.0, -2.0),
                    "both controls"
                );
                assert!(near(to, -7.0, 0.0), "and the end: {to:?}");
            }
            ref other => panic!("expected a cubic, got {other:?}"),
        }
    }

    #[test]
    fn builder_records_verbs_in_order() {
        let p = Path::new()
            .move_to(Point::new(1.0, 2.0))
            .line_to(Point::new(3.0, 4.0))
            .quad_to(Point::new(5.0, 6.0), Point::new(7.0, 8.0))
            .close();
        assert_eq!(
            p.verbs(),
            &[
                PathVerb::MoveTo(Point::new(1.0, 2.0)),
                PathVerb::LineTo(Point::new(3.0, 4.0)),
                PathVerb::QuadTo {
                    ctrl: Point::new(5.0, 6.0),
                    to: Point::new(7.0, 8.0)
                },
                PathVerb::Close,
            ]
        );
    }

    /// An arc lands where it was told to and stays on the circle in between — the
    /// property that matters, since the arc is one segment of a larger outline.
    #[test]
    fn an_arc_ends_on_the_circle_it_was_given() {
        use std::f32::consts::PI;
        let centre = Point::new(50.0, 40.0);
        let radius = 12.0;
        for (start, end) in [
            (0.0, PI),        // half a turn
            (PI, 0.0),        // and back the other way
            (0.3, 0.5),       // a sliver
            (-PI, PI * 0.75), // more than a half turn: several pieces
        ] {
            let on = |a: f32| Point::new(centre.x + radius * a.cos(), centre.y + radius * a.sin());
            let path = Path::new()
                .move_to(on(start))
                .arc_to(centre, radius, start, end);
            let last = match path.verbs().last() {
                Some(PathVerb::CubicTo { to, .. }) => *to,
                other => panic!("an arc ends on a cubic, got {other:?}"),
            };
            let want = on(end);
            assert!(
                (last.x - want.x).abs() < 0.01 && (last.y - want.y).abs() < 0.01,
                "arc {start}..{end} ended at {last:?}, wanted {want:?}"
            );
            // Every node the arc puts down sits on the circle.
            for verb in path.verbs() {
                if let PathVerb::CubicTo { to, .. } = verb {
                    let d = ((to.x - centre.x).powi(2) + (to.y - centre.y).powi(2)).sqrt();
                    assert!((d - radius).abs() < 0.01, "node off the circle: {d}");
                }
            }
        }
    }

    #[test]
    fn rect_is_a_closed_quad() {
        let p = Path::rect(Rect::new(0.0, 0.0, 10.0, 20.0));
        // move + 3× line + close = 5 commands (`close` supplies the fourth side).
        assert_eq!(p.verbs().len(), 5);
        assert!(matches!(p.verbs().last(), Some(PathVerb::Close)));
    }

    #[test]
    fn scaled_and_translated_transform_points() {
        let p = Path::new()
            .move_to(Point::new(2.0, 3.0))
            .line_to(Point::new(4.0, 5.0));
        let s = p.scaled(2.0);
        assert_eq!(s.verbs()[0], PathVerb::MoveTo(Point::new(4.0, 6.0)));
        let t = p.translated(10.0, 100.0);
        assert_eq!(t.verbs()[1], PathVerb::LineTo(Point::new(14.0, 105.0)));
    }

    #[test]
    fn circle_starts_at_top_and_closes() {
        let c = Path::circle(Point::new(12.0, 12.0), 10.0);
        assert_eq!(c.verbs()[0], PathVerb::MoveTo(Point::new(12.0, 2.0)));
        assert!(matches!(c.verbs().last(), Some(PathVerb::Close)));
    }
}