Skip to main content

cranpose_ui_graphics/
path.rs

1//! Paths an app builds from lines and curves, and the styles a draw scope
2//! fills or strokes them with: Compose's `Path`, `DrawStyle` and
3//! `PathEffect.dashPathEffect`.
4//!
5//! Curves are flattened as they are added, within the tolerance vector icons
6//! use, so a path holds polylines only. A fill rasterizes them as
7//! [`crate::VectorPath`] does; a stroke draws each flattened edge as a
8//! [`crate::DrawPrimitive::Line`] on the GPU.
9
10use crate::{
11    LineGeometry, PathFillRule, Point, Rect, Stroke, StrokeCap, StrokeJoin, VectorPath,
12    vector_path::{flatten_cubic_into, quad_as_cubic},
13};
14
15/// One contour of a [`Path`]: its flattened points and whether it closes
16/// back to its first.
17#[derive(Clone, Debug, Default, PartialEq)]
18pub struct PathContour {
19    pub points: Vec<Point>,
20    pub closed: bool,
21}
22
23/// A path built from lines and curves: Compose's `Path`.
24///
25/// Example: a sparkline, stroked by [`crate::DrawScope::draw_path`]:
26///
27/// ```
28/// use cranpose_ui_graphics::{Path, Point};
29///
30/// let mut path = Path::new();
31/// path.move_to(Point::new(0.0, 10.0));
32/// path.line_to(Point::new(10.0, 4.0));
33/// path.quadratic_to(Point::new(15.0, 0.0), Point::new(20.0, 6.0));
34/// assert_eq!(path.contours().len(), 1);
35/// ```
36#[derive(Clone, Debug, Default, PartialEq)]
37pub struct Path {
38    contours: Vec<PathContour>,
39    pen: Point,
40}
41
42impl Path {
43    /// An empty path.
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    /// Starts a new contour at `point`.
49    pub fn move_to(&mut self, point: Point) {
50        self.contours.push(PathContour {
51            points: vec![point],
52            closed: false,
53        });
54        self.pen = point;
55    }
56
57    /// Adds a straight edge from the pen to `point`.
58    pub fn line_to(&mut self, point: Point) {
59        self.open_contour().push(point);
60        self.pen = point;
61    }
62
63    /// Adds the quadratic curve from the pen through `control` to `end`:
64    /// Compose's `quadraticTo`.
65    pub fn quadratic_to(&mut self, control: Point, end: Point) {
66        let (first, second) = quad_as_cubic(self.pen, control, end);
67        self.cubic_to(first, second, end);
68    }
69
70    /// Adds the cubic curve from the pen through `first` and `second` to
71    /// `end`: Compose's `cubicTo`.
72    pub fn cubic_to(&mut self, first: Point, second: Point, end: Point) {
73        let start = self.pen;
74        flatten_cubic_into(self.open_contour(), start, first, second, end);
75        self.pen = end;
76    }
77
78    /// Closes the current contour back to its first point; the next edge
79    /// starts a new contour there.
80    pub fn close(&mut self) {
81        if let Some(contour) = self.contours.last_mut().filter(|contour| !contour.closed) {
82            contour.closed = true;
83            if let Some(&first) = contour.points.first() {
84                self.pen = first;
85            }
86        }
87    }
88
89    /// Removes every contour.
90    pub fn reset(&mut self) {
91        self.contours.clear();
92        self.pen = Point::ZERO;
93    }
94
95    /// The contours, each flattened to points.
96    pub fn contours(&self) -> &[PathContour] {
97        &self.contours
98    }
99
100    /// Whether the path has no edge.
101    pub fn is_empty(&self) -> bool {
102        !self
103            .contours
104            .iter()
105            .any(|contour| contour.points.len() >= 2)
106    }
107
108    /// The box of every point, or an empty rect at the origin for an
109    /// empty path.
110    pub fn bounds(&self) -> Rect {
111        let mut points = self.contours.iter().flat_map(|contour| &contour.points);
112        let Some(&first) = points.next() else {
113            return Rect::EMPTY;
114        };
115        let (min, max) = points.fold((first, first), |(min, max), point| {
116            (
117                Point::new(min.x.min(point.x), min.y.min(point.y)),
118                Point::new(max.x.max(point.x), max.y.max(point.y)),
119            )
120        });
121        Rect {
122            x: min.x,
123            y: min.y,
124            width: max.x - min.x,
125            height: max.y - min.y,
126        }
127    }
128
129    /// The path as a fill: every contour closed, under `fill_rule`.
130    pub fn to_vector_path(&self, fill_rule: PathFillRule) -> VectorPath {
131        VectorPath::from_subpaths(
132            self.contours
133                .iter()
134                .filter(|contour| contour.points.len() >= 2)
135                .map(|contour| contour.points.clone())
136                .collect(),
137            fill_rule,
138        )
139    }
140
141    /// The contour an edge extends: the last one when it is still open,
142    /// otherwise a new one starting at the pen.
143    fn open_contour(&mut self) -> &mut Vec<Point> {
144        if self.contours.last().is_none_or(|contour| contour.closed) {
145            self.contours.push(PathContour {
146                points: vec![self.pen],
147                closed: false,
148            });
149        }
150        let last = self.contours.len() - 1;
151        &mut self.contours[last].points
152    }
153}
154
155/// Dashes along a stroke: on for the first interval, off for the second,
156/// and so on, starting `phase` into the pattern. Compose's
157/// `PathEffect.dashPathEffect(intervals, phase)`. Up to
158/// [`DashPathEffect::MAX_INTERVALS`] intervals; a pattern with an odd
159/// count or a non-positive sum draws the stroke whole, as Skia does.
160#[derive(Clone, Copy, Debug, PartialEq)]
161pub struct DashPathEffect {
162    intervals: [f32; DashPathEffect::MAX_INTERVALS],
163    count: usize,
164    phase: f32,
165}
166
167impl DashPathEffect {
168    /// The most intervals a pattern holds.
169    pub const MAX_INTERVALS: usize = 8;
170
171    /// A dash pattern of `intervals`, the extras past
172    /// [`Self::MAX_INTERVALS`] dropped, starting `phase` into it.
173    pub fn new(intervals: &[f32], phase: f32) -> Self {
174        let mut stored = [0.0; Self::MAX_INTERVALS];
175        let count = intervals.len().min(Self::MAX_INTERVALS);
176        stored[..count].copy_from_slice(&intervals[..count]);
177        Self {
178            intervals: stored,
179            count,
180            phase,
181        }
182    }
183
184    /// The intervals, on then off in turn.
185    pub fn intervals(&self) -> &[f32] {
186        &self.intervals[..self.count]
187    }
188
189    fn period(&self) -> Option<f32> {
190        let period: f32 = self
191            .intervals()
192            .iter()
193            .map(|interval| interval.max(0.0))
194            .sum();
195        (self.count >= 2 && self.count.is_multiple_of(2) && period > 0.0 && period.is_finite())
196            .then_some(period)
197    }
198
199    /// Hands `dash` each dash of the polyline through `points` in turn, as
200    /// a polyline of its own; the whole polyline when the pattern draws it
201    /// whole.
202    pub fn for_each_dash(&self, points: &[Point], mut dash: impl FnMut(&[Point])) {
203        let Some(period) = self.period() else {
204            dash(points);
205            return;
206        };
207        let interval = |index: usize| self.intervals[index].max(0.0);
208        let mut current: Vec<Point> = Vec::new();
209        // Where in the pattern the walk is, and which interval it is in.
210        let mut into = self.phase.rem_euclid(period);
211        let mut index = 0;
212        while into >= interval(index) {
213            into -= interval(index);
214            index = (index + 1) % self.count;
215        }
216        for pair in points.windows(2) {
217            let (start, end) = (pair[0], pair[1]);
218            let length = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt();
219            let at = |distance: f32| {
220                let t = if length > 0.0 { distance / length } else { 0.0 };
221                Point::new(
222                    start.x + (end.x - start.x) * t,
223                    start.y + (end.y - start.y) * t,
224                )
225            };
226            let mut walked = 0.0;
227            while walked < length {
228                let on = index.is_multiple_of(2);
229                let step = (interval(index) - into).min(length - walked);
230                if on {
231                    if current.is_empty() {
232                        current.push(at(walked));
233                    }
234                    current.push(at(walked + step));
235                }
236                walked += step;
237                into += step;
238                if into >= interval(index) {
239                    into = 0.0;
240                    if on && current.len() >= 2 {
241                        dash(&current);
242                    }
243                    current.clear();
244                    index = (index + 1) % self.count;
245                }
246            }
247        }
248        if current.len() >= 2 {
249            dash(&current);
250        }
251    }
252}
253
254/// How [`crate::DrawScope::draw_path`] paints a path: Compose's `Fill` and
255/// `Stroke` draw styles, the stroke dashed when it carries a path effect.
256#[derive(Clone, Copy, Debug, PartialEq)]
257pub enum DrawStyle {
258    /// Fills the path's contours, each closed, under the non-zero rule.
259    Fill,
260    /// Strokes the path's edges with the stroke's width, cap and join.
261    Stroke(Stroke),
262    /// Strokes the dashes the effect cuts the path's edges into, each dash
263    /// capped by the stroke's cap.
264    DashedStroke(Stroke, DashPathEffect),
265}
266
267/// Hands `line` the segments a stroke draws for the polyline through
268/// `points`, closed back to its first point when `closed`. An open
269/// polyline's two ends take the stroke's cap; every corner takes the cap
270/// closest to the stroke's join, round for round and bevel joins, square
271/// for a miter, which is exact at a right angle. A segment whose two ends
272/// want the same cap draws with it; one whose ends differ draws butt ends,
273/// grown by half the width at a square end, and a dot of the stroke at a
274/// round one.
275pub fn for_each_stroke_line(
276    points: &[Point],
277    closed: bool,
278    stroke: Stroke,
279    mut line: impl FnMut(LineGeometry),
280) {
281    let corner = match stroke.join {
282        StrokeJoin::Miter => StrokeCap::Square,
283        StrokeJoin::Round | StrokeJoin::Bevel => StrokeCap::Round,
284    };
285    let closing = closed
286        .then(|| points.first().zip(points.last()))
287        .flatten()
288        .filter(|(first, last)| first != last)
289        .map(|(&first, &last)| (last, first));
290    let edges = points
291        .windows(2)
292        .map(|pair| (pair[0], pair[1]))
293        .chain(closing)
294        .filter(|(start, end)| start != end);
295    let count = edges.clone().count();
296    for (index, (start, end)) in edges.enumerate() {
297        let start_cap = if closed || index > 0 {
298            corner
299        } else {
300            stroke.cap
301        };
302        let end_cap = if closed || index + 1 < count {
303            corner
304        } else {
305            stroke.cap
306        };
307        let with_cap = |cap| Stroke { cap, ..stroke };
308        if start_cap == end_cap {
309            line(LineGeometry::new(start, end, with_cap(start_cap)));
310            continue;
311        }
312        let half_width = stroke.half_width();
313        let length = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt();
314        let grow = |cap: StrokeCap| {
315            if cap == StrokeCap::Square {
316                half_width / length
317            } else {
318                0.0
319            }
320        };
321        let (back, ahead) = (grow(start_cap), grow(end_cap));
322        let (dx, dy) = (end.x - start.x, end.y - start.y);
323        line(LineGeometry::new(
324            Point::new(start.x - dx * back, start.y - dy * back),
325            Point::new(end.x + dx * ahead, end.y + dy * ahead),
326            with_cap(StrokeCap::Butt),
327        ));
328        for (point, cap) in [(start, start_cap), (end, end_cap)] {
329            if cap == StrokeCap::Round {
330                line(LineGeometry::new(point, point, with_cap(StrokeCap::Round)));
331            }
332        }
333    }
334}
335
336#[cfg(test)]
337#[path = "tests/path_tests.rs"]
338mod tests;