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/// A vector path: a sequence of [`PathVerb`]. Built in *builder* style (chainable),
162/// and consumed at render time.
163///
164/// ```
165/// use frus_core::{Path, Point};
166/// let triangle = Path::new()
167///     .move_to(Point::new(0.0, 0.0))
168///     .line_to(Point::new(10.0, 0.0))
169///     .line_to(Point::new(5.0, 8.0))
170///     .close();
171/// assert_eq!(triangle.verbs().len(), 4);
172/// ```
173#[derive(Clone, Debug, Default, PartialEq)]
174pub struct Path {
175    verbs: Vec<PathVerb>,
176}
177
178impl Path {
179    /// An empty path.
180    pub fn new() -> Self {
181        Self::default()
182    }
183
184    /// Starts a sub-path at `p`.
185    pub fn move_to(mut self, p: Point) -> Self {
186        self.verbs.push(PathVerb::MoveTo(p));
187        self
188    }
189
190    /// A straight segment to `p`.
191    pub fn line_to(mut self, p: Point) -> Self {
192        self.verbs.push(PathVerb::LineTo(p));
193        self
194    }
195
196    /// A quadratic curve (one control point, `ctrl`) to `to`.
197    pub fn quad_to(mut self, ctrl: Point, to: Point) -> Self {
198        self.verbs.push(PathVerb::QuadTo { ctrl, to });
199        self
200    }
201
202    /// A cubic curve (two control points, `c1` and `c2`) to `to`.
203    pub fn cubic_to(mut self, c1: Point, c2: Point, to: Point) -> Self {
204        self.verbs.push(PathVerb::CubicTo { c1, c2, to });
205        self
206    }
207
208    /// Closes the current sub-path.
209    pub fn close(mut self) -> Self {
210        self.verbs.push(PathVerb::Close);
211        self
212    }
213
214    /// This path and then `other`, as one: under the even-odd rule a path inside another
215    /// is a hole in it, which is how a band is drawn.
216    #[must_use]
217    pub fn append(mut self, other: Path) -> Self {
218        self.verbs.extend(other.verbs);
219        self
220    }
221
222    /// The path's commands, in order.
223    pub fn verbs(&self) -> &[PathVerb] {
224        &self.verbs
225    }
226
227    /// `true` when the path holds no commands at all.
228    pub fn is_empty(&self) -> bool {
229        self.verbs.is_empty()
230    }
231
232    /// A filled rectangle (a closed outline, clockwise).
233    pub fn rect(r: Rect) -> Self {
234        Self::new()
235            .move_to(Point::new(r.x, r.y))
236            .line_to(Point::new(r.x + r.width, r.y))
237            .line_to(Point::new(r.x + r.width, r.y + r.height))
238            .line_to(Point::new(r.x, r.y + r.height))
239            .close()
240    }
241
242    /// Continues the path along a **circular arc**, from `start` to `end` (radians,
243    /// measured from the positive x axis, y downwards), around `center`.
244    ///
245    /// The current point is assumed to be the arc's start; nothing moves it there,
246    /// because an arc that jumped to its own beginning could not be part of an
247    /// outline. Approximated by cubics, split so that no piece spans more than a
248    /// quarter turn — which is where the constant used by [`Path::circle`] stops
249    /// being accurate enough.
250    pub fn arc_to(mut self, center: Point, radius: f32, start: f32, end: f32) -> Self {
251        let sweep = end - start;
252        if sweep.abs() < 1e-6 || radius <= 0.0 {
253            return self;
254        }
255        let pieces = (sweep.abs() / std::f32::consts::FRAC_PI_2).ceil().max(1.0);
256        let step = sweep / pieces;
257        // The control-point distance for a cubic that fits a `step` arc exactly at
258        // its ends and its middle. At a quarter turn this is the familiar 0.5523.
259        let k = 4.0 / 3.0 * (step / 4.0).tan();
260        let mut angle = start;
261        for _ in 0..pieces as usize {
262            let next = angle + step;
263            let (s0, c0) = angle.sin_cos();
264            let (s1, c1) = next.sin_cos();
265            let p0 = Point::new(center.x + radius * c0, center.y + radius * s0);
266            let p1 = Point::new(center.x + radius * c1, center.y + radius * s1);
267            self = self.cubic_to(
268                Point::new(p0.x - k * radius * s0, p0.y + k * radius * c0),
269                Point::new(p1.x + k * radius * s1, p1.y - k * radius * c1),
270                p1,
271            );
272            angle = next;
273        }
274        self
275    }
276
277    /// A circle, approximated by **four cubic arcs** (the Bézier constant `0.5523`,
278    /// which is exact at the nodes).
279    pub fn circle(center: Point, radius: f32) -> Self {
280        const K: f32 = 0.552_284_8;
281        let (cx, cy, r) = (center.x, center.y, radius);
282        let k = r * K;
283        Self::new()
284            .move_to(Point::new(cx, cy - r))
285            .cubic_to(
286                Point::new(cx + k, cy - r),
287                Point::new(cx + r, cy - k),
288                Point::new(cx + r, cy),
289            )
290            .cubic_to(
291                Point::new(cx + r, cy + k),
292                Point::new(cx + k, cy + r),
293                Point::new(cx, cy + r),
294            )
295            .cubic_to(
296                Point::new(cx - k, cy + r),
297                Point::new(cx - r, cy + k),
298                Point::new(cx - r, cy),
299            )
300            .cubic_to(
301                Point::new(cx - r, cy - k),
302                Point::new(cx - k, cy - r),
303                Point::new(cx, cy - r),
304            )
305            .close()
306    }
307
308    /// An **ellipse** inscribed in `r`, by the same four-cubic approximation as
309    /// [`Path::circle`] — which it generalises: a square `r` gives that circle back.
310    ///
311    /// A circle scaled along one axis, in other words, without needing a transform
312    /// to say so: it is how a wide, shallow arc (an overscroll glow, a soft
313    /// highlight) is drawn as a plain filled path.
314    pub fn oval(r: Rect) -> Self {
315        const K: f32 = 0.552_284_8;
316        let (rx, ry) = (r.width * 0.5, r.height * 0.5);
317        let (cx, cy) = (r.x + rx, r.y + ry);
318        let (kx, ky) = (rx * K, ry * K);
319        Self::new()
320            .move_to(Point::new(cx, cy - ry))
321            .cubic_to(
322                Point::new(cx + kx, cy - ry),
323                Point::new(cx + rx, cy - ky),
324                Point::new(cx + rx, cy),
325            )
326            .cubic_to(
327                Point::new(cx + rx, cy + ky),
328                Point::new(cx + kx, cy + ry),
329                Point::new(cx, cy + ry),
330            )
331            .cubic_to(
332                Point::new(cx - kx, cy + ry),
333                Point::new(cx - rx, cy + ky),
334                Point::new(cx - rx, cy),
335            )
336            .cubic_to(
337                Point::new(cx - rx, cy - ky),
338                Point::new(cx - kx, cy - ry),
339                Point::new(cx, cy - ry),
340            )
341            .close()
342    }
343
344    /// A copy scaled by `factor` about the origin — used for logical-to-physical
345    /// conversion, or to fit a `24×24` icon to its real size.
346    pub fn scaled(&self, factor: f32) -> Path {
347        Path {
348            verbs: self.verbs.iter().map(|v| v.scaled(factor)).collect(),
349        }
350    }
351
352    /// A copy translated by `(dx, dy)` — used to place an icon inside its box.
353    pub fn translated(&self, dx: f32, dy: f32) -> Path {
354        Path {
355            verbs: self.verbs.iter().map(|v| v.translated(dx, dy)).collect(),
356        }
357    }
358
359    /// A copy reflected in the **vertical line** `x = axis` — how an icon that carries a
360    /// direction (an arrow, an indent, a reply) is turned round for a right-to-left
361    /// reading order.
362    ///
363    /// The reflection reverses each contour's winding, and reverses **every** contour, so
364    /// a shape's holes stay holes: the non-zero rule cares about the relative direction
365    /// of the contours, not about their absolute one.
366    pub fn mirrored_x(&self, axis: f32) -> Path {
367        Path {
368            verbs: self.verbs.iter().map(|v| v.mirrored_x(axis)).collect(),
369        }
370    }
371
372    /// A copy turned by `radians` about `center`, clockwise — y points down here, so a
373    /// positive angle turns the way a clock's hand does.
374    ///
375    /// Unlike a reflection, a rotation **keeps** each contour's winding, so a shape's
376    /// holes stay holes without anything else being done about it. Bézier control points
377    /// turn with their curve, which is exact: an affine map of a Bézier's control polygon
378    /// is the same Bézier mapped.
379    ///
380    /// The sine and cosine are taken once for the whole path rather than per point.
381    pub fn rotated(&self, center: Point, radians: f32) -> Path {
382        let (sin, cos) = radians.sin_cos();
383        Path {
384            verbs: self
385                .verbs
386                .iter()
387                .map(|v| v.rotated(center, sin, cos))
388                .collect(),
389        }
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    /// A reflection is its own inverse, moves nothing on the axis, and leaves y alone.
398    /// Those three together are the whole definition, and each one is a mistake somebody
399    /// makes: reflecting about the origin instead of the axis, or about the wrong one.
400    #[test]
401    fn mirroring_reflects_x_about_the_axis_and_nothing_else() {
402        let path = Path::new()
403            .move_to(Point::new(2.0, 5.0))
404            .line_to(Point::new(8.0, 5.0))
405            .quad_to(Point::new(10.0, 7.0), Point::new(6.0, 9.0))
406            .cubic_to(
407                Point::new(4.0, 9.0),
408                Point::new(3.0, 8.0),
409                Point::new(2.0, 5.0),
410            )
411            .close();
412        let flipped = path.mirrored_x(6.0);
413        assert_eq!(
414            flipped.verbs(),
415            &[
416                PathVerb::MoveTo(Point::new(10.0, 5.0)),
417                PathVerb::LineTo(Point::new(4.0, 5.0)),
418                PathVerb::QuadTo {
419                    ctrl: Point::new(2.0, 7.0),
420                    to: Point::new(6.0, 9.0),
421                },
422                PathVerb::CubicTo {
423                    c1: Point::new(8.0, 9.0),
424                    c2: Point::new(9.0, 8.0),
425                    to: Point::new(10.0, 5.0),
426                },
427                PathVerb::Close,
428            ]
429        );
430        // A point on the axis does not move, and mirroring twice is the identity.
431        assert_eq!(path.mirrored_x(6.0).mirrored_x(6.0).verbs(), path.verbs());
432    }
433
434    /// A quarter turn, in the direction the screen reads it. Y points **down** here, so
435    /// a positive angle has to turn clockwise on screen — the opposite of the convention
436    /// in a maths textbook, and the thing that is silently backwards if the sign is taken
437    /// from one.
438    ///
439    /// A point at the centre stays put, and four quarter turns come back where they
440    /// started. Those two together rule out rotating about the origin instead of the
441    /// centre, which looks like a translation and is the mistake worth catching.
442    #[test]
443    fn rotating_turns_clockwise_about_the_centre() {
444        let centre = Point::new(10.0, 10.0);
445        let quarter = std::f32::consts::FRAC_PI_2;
446        // Directly above the centre. A clockwise quarter turn puts it to the right.
447        let up = Path::new()
448            .move_to(centre)
449            .line_to(Point::new(10.0, 4.0))
450            .close();
451        let turned = up.rotated(centre, quarter);
452        let PathVerb::LineTo(tip) = turned.verbs()[1] else {
453            panic!("the second verb is still a line");
454        };
455        assert!(
456            (tip.x - 16.0).abs() < 1e-4 && (tip.y - 10.0).abs() < 1e-4,
457            "a quarter turn puts the tip to the right of the centre: {tip:?}"
458        );
459        let PathVerb::MoveTo(fixed) = turned.verbs()[0] else {
460            panic!("the first verb is still a move");
461        };
462        assert_eq!(fixed, centre, "the centre does not move");
463
464        // Four quarter turns are the identity, up to the arithmetic.
465        let round_trip = up
466            .rotated(centre, quarter)
467            .rotated(centre, quarter)
468            .rotated(centre, quarter)
469            .rotated(centre, quarter);
470        for (a, b) in round_trip.verbs().iter().zip(up.verbs()) {
471            match (a, b) {
472                (PathVerb::MoveTo(a), PathVerb::MoveTo(b))
473                | (PathVerb::LineTo(a), PathVerb::LineTo(b)) => {
474                    assert!(
475                        (a.x - b.x).abs() < 1e-3 && (a.y - b.y).abs() < 1e-3,
476                        "{a:?}"
477                    );
478                }
479                (PathVerb::Close, PathVerb::Close) => {}
480                _ => panic!("the verbs changed kind"),
481            }
482        }
483    }
484
485    /// A curve's control points turn **with** it. An affine map of a Bézier's control
486    /// polygon is the same Bézier mapped, so a rotation that moved only the on-curve
487    /// points would bend every curve in the icon and pass any test that looked at
488    /// endpoints alone.
489    #[test]
490    fn rotating_carries_the_control_points() {
491        let centre = Point::new(0.0, 0.0);
492        let half = std::f32::consts::PI;
493        let curved = Path::new()
494            .move_to(Point::new(1.0, 0.0))
495            .quad_to(Point::new(2.0, 3.0), Point::new(4.0, 0.0))
496            .cubic_to(
497                Point::new(5.0, 1.0),
498                Point::new(6.0, 2.0),
499                Point::new(7.0, 0.0),
500            );
501        let turned = curved.rotated(centre, half);
502        // A half turn about the origin is a negation of both coordinates.
503        let near = |a: Point, x: f32, y: f32| (a.x - x).abs() < 1e-4 && (a.y - y).abs() < 1e-4;
504        match turned.verbs()[1] {
505            PathVerb::QuadTo { ctrl, to } => {
506                assert!(near(ctrl, -2.0, -3.0), "the control point turned: {ctrl:?}");
507                assert!(near(to, -4.0, 0.0), "and so did the end: {to:?}");
508            }
509            ref other => panic!("expected a quad, got {other:?}"),
510        }
511        match turned.verbs()[2] {
512            PathVerb::CubicTo { c1, c2, to } => {
513                assert!(
514                    near(c1, -5.0, -1.0) && near(c2, -6.0, -2.0),
515                    "both controls"
516                );
517                assert!(near(to, -7.0, 0.0), "and the end: {to:?}");
518            }
519            ref other => panic!("expected a cubic, got {other:?}"),
520        }
521    }
522
523    #[test]
524    fn builder_records_verbs_in_order() {
525        let p = Path::new()
526            .move_to(Point::new(1.0, 2.0))
527            .line_to(Point::new(3.0, 4.0))
528            .quad_to(Point::new(5.0, 6.0), Point::new(7.0, 8.0))
529            .close();
530        assert_eq!(
531            p.verbs(),
532            &[
533                PathVerb::MoveTo(Point::new(1.0, 2.0)),
534                PathVerb::LineTo(Point::new(3.0, 4.0)),
535                PathVerb::QuadTo {
536                    ctrl: Point::new(5.0, 6.0),
537                    to: Point::new(7.0, 8.0)
538                },
539                PathVerb::Close,
540            ]
541        );
542    }
543
544    /// An arc lands where it was told to and stays on the circle in between — the
545    /// property that matters, since the arc is one segment of a larger outline.
546    #[test]
547    fn an_arc_ends_on_the_circle_it_was_given() {
548        use std::f32::consts::PI;
549        let centre = Point::new(50.0, 40.0);
550        let radius = 12.0;
551        for (start, end) in [
552            (0.0, PI),        // half a turn
553            (PI, 0.0),        // and back the other way
554            (0.3, 0.5),       // a sliver
555            (-PI, PI * 0.75), // more than a half turn: several pieces
556        ] {
557            let on = |a: f32| Point::new(centre.x + radius * a.cos(), centre.y + radius * a.sin());
558            let path = Path::new()
559                .move_to(on(start))
560                .arc_to(centre, radius, start, end);
561            let last = match path.verbs().last() {
562                Some(PathVerb::CubicTo { to, .. }) => *to,
563                other => panic!("an arc ends on a cubic, got {other:?}"),
564            };
565            let want = on(end);
566            assert!(
567                (last.x - want.x).abs() < 0.01 && (last.y - want.y).abs() < 0.01,
568                "arc {start}..{end} ended at {last:?}, wanted {want:?}"
569            );
570            // Every node the arc puts down sits on the circle.
571            for verb in path.verbs() {
572                if let PathVerb::CubicTo { to, .. } = verb {
573                    let d = ((to.x - centre.x).powi(2) + (to.y - centre.y).powi(2)).sqrt();
574                    assert!((d - radius).abs() < 0.01, "node off the circle: {d}");
575                }
576            }
577        }
578    }
579
580    #[test]
581    fn rect_is_a_closed_quad() {
582        let p = Path::rect(Rect::new(0.0, 0.0, 10.0, 20.0));
583        // move + 3× line + close = 5 commands (`close` supplies the fourth side).
584        assert_eq!(p.verbs().len(), 5);
585        assert!(matches!(p.verbs().last(), Some(PathVerb::Close)));
586    }
587
588    #[test]
589    fn scaled_and_translated_transform_points() {
590        let p = Path::new()
591            .move_to(Point::new(2.0, 3.0))
592            .line_to(Point::new(4.0, 5.0));
593        let s = p.scaled(2.0);
594        assert_eq!(s.verbs()[0], PathVerb::MoveTo(Point::new(4.0, 6.0)));
595        let t = p.translated(10.0, 100.0);
596        assert_eq!(t.verbs()[1], PathVerb::LineTo(Point::new(14.0, 105.0)));
597    }
598
599    #[test]
600    fn circle_starts_at_top_and_closes() {
601        let c = Path::circle(Point::new(12.0, 12.0), 10.0);
602        assert_eq!(c.verbs()[0], PathVerb::MoveTo(Point::new(12.0, 2.0)));
603        assert!(matches!(c.verbs().last(), Some(PathVerb::Close)));
604    }
605}