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