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