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    /// A contour at `point`, in the room a dropped path left on this thread
32    /// when there is some.
33    fn starting_at(point: Point) -> Self {
34        let mut points =
35            crate::thread_state::with(|state| state.path_buffers.borrow_mut().points());
36        points.push(point);
37        Self {
38            points,
39            closed: false,
40        }
41    }
42}
43
44/// The point lists and contour lists dropped paths left on a thread, which
45/// the next paths built there take: a path an app builds in each draw, as
46/// Compose apps do, then allocates nothing once its lists reached its size.
47pub(crate) struct PathBuffers {
48    points: Vec<Vec<Point>>,
49    /// The room of the kept point lists, in points.
50    kept_points: usize,
51    contours: Vec<Vec<PathContour>>,
52}
53
54impl PathBuffers {
55    /// The most contour lists a thread keeps.
56    const KEPT: usize = 32;
57    /// The most room, in points, the kept point lists hold together: 32 KB,
58    /// small enough for an ESP32 build.
59    const POINT_BUDGET: usize = 4096;
60
61    pub(crate) const fn new() -> Self {
62        Self {
63            points: Vec::new(),
64            kept_points: 0,
65            contours: Vec::new(),
66        }
67    }
68
69    fn points(&mut self) -> Vec<Point> {
70        match self.points.pop() {
71            Some(points) => {
72                self.kept_points -= points.capacity();
73                points
74            }
75            None => Vec::with_capacity(PathContour::INITIAL_POINTS),
76        }
77    }
78
79    fn contours(&mut self) -> Vec<PathContour> {
80        self.contours.pop().unwrap_or_default()
81    }
82
83    /// Keeps the point lists of `contours`, which it leaves empty.
84    fn keep_points(&mut self, contours: &mut Vec<PathContour>) {
85        for PathContour { mut points, .. } in contours.drain(..) {
86            let room = points.capacity();
87            if self.kept_points + room <= Self::POINT_BUDGET {
88                points.clear();
89                self.kept_points += room;
90                self.points.push(points);
91            }
92        }
93    }
94
95    /// Keeps the point lists of `contours` and the list itself.
96    fn keep(&mut self, mut contours: Vec<PathContour>) {
97        self.keep_points(&mut contours);
98        if self.contours.len() < Self::KEPT {
99            self.contours.push(contours);
100        }
101    }
102}
103
104/// A path built from lines and curves: Compose's `Path`.
105///
106/// Example: a sparkline, stroked by [`crate::DrawScope::draw_path`]:
107///
108/// ```
109/// use cranpose_ui_graphics::{Path, Point};
110///
111/// let mut path = Path::new();
112/// path.move_to(Point::new(0.0, 10.0));
113/// path.line_to(Point::new(10.0, 4.0));
114/// path.quadratic_to(Point::new(15.0, 0.0), Point::new(20.0, 6.0));
115/// assert_eq!(path.contours().len(), 1);
116/// ```
117#[derive(Clone, Debug, Default, PartialEq)]
118pub struct Path {
119    contours: Vec<PathContour>,
120    pen: Point,
121}
122
123impl Path {
124    /// An empty path.
125    pub fn new() -> Self {
126        Self::default()
127    }
128
129    /// Starts a new contour at `point`.
130    pub fn move_to(&mut self, point: Point) {
131        self.push_contour(point);
132        self.pen = point;
133    }
134
135    fn push_contour(&mut self, start: Point) {
136        if self.contours.capacity() == 0 {
137            self.contours =
138                crate::thread_state::with(|state| state.path_buffers.borrow_mut().contours());
139        }
140        self.contours.push(PathContour::starting_at(start));
141    }
142
143    /// Adds a straight edge from the pen to `point`.
144    #[inline]
145    pub fn line_to(&mut self, point: Point) {
146        match self.contours.last_mut() {
147            Some(contour) if !contour.closed => contour.points.push(point),
148            _ => self.line_to_new_contour(point),
149        }
150        self.pen = point;
151    }
152
153    // An edge with no open contour to extend: out of line, so the loops an
154    // app builds a path in inline only the push onto the open one.
155    #[cold]
156    #[inline(never)]
157    fn line_to_new_contour(&mut self, point: Point) {
158        self.open_contour().push(point);
159    }
160
161    /// Adds the quadratic curve from the pen through `control` to `end`:
162    /// Compose's `quadraticTo`.
163    pub fn quadratic_to(&mut self, control: Point, end: Point) {
164        let (first, second) = quad_as_cubic(self.pen, control, end);
165        self.cubic_to(first, second, end);
166    }
167
168    /// Adds the cubic curve from the pen through `first` and `second` to
169    /// `end`: Compose's `cubicTo`.
170    pub fn cubic_to(&mut self, first: Point, second: Point, end: Point) {
171        let start = self.pen;
172        flatten_cubic_into(self.open_contour(), start, first, second, end);
173        self.pen = end;
174    }
175
176    /// Closes the current contour back to its first point; the next edge
177    /// starts a new contour there.
178    pub fn close(&mut self) {
179        if let Some(contour) = self.contours.last_mut().filter(|contour| !contour.closed) {
180            contour.closed = true;
181            if let Some(&first) = contour.points.first() {
182                self.pen = first;
183            }
184        }
185    }
186
187    /// Removes every contour.
188    pub fn reset(&mut self) {
189        if !self.contours.is_empty() {
190            crate::thread_state::with(|state| {
191                state
192                    .path_buffers
193                    .borrow_mut()
194                    .keep_points(&mut self.contours);
195            });
196        }
197        self.pen = Point::ZERO;
198    }
199
200    /// The contours, each flattened to points.
201    pub fn contours(&self) -> &[PathContour] {
202        &self.contours
203    }
204
205    /// Whether the path has no edge.
206    pub fn is_empty(&self) -> bool {
207        !self
208            .contours
209            .iter()
210            .any(|contour| contour.points.len() >= 2)
211    }
212
213    /// The box of every point, or an empty rect at the origin for an
214    /// empty path.
215    pub fn bounds(&self) -> Rect {
216        let mut points = self.contours.iter().flat_map(|contour| &contour.points);
217        let Some(&first) = points.next() else {
218            return Rect::EMPTY;
219        };
220        let (min, max) = points.fold((first, first), |(min, max), point| {
221            (
222                Point::new(min.x.min(point.x), min.y.min(point.y)),
223                Point::new(max.x.max(point.x), max.y.max(point.y)),
224            )
225        });
226        Rect {
227            x: min.x,
228            y: min.y,
229            width: max.x - min.x,
230            height: max.y - min.y,
231        }
232    }
233
234    /// The path as a fill: every contour closed, under `fill_rule`.
235    pub fn to_vector_path(&self, fill_rule: PathFillRule) -> VectorPath {
236        contours_vector_path(&self.contours, fill_rule)
237    }
238
239    /// The contour an edge extends: the last one when it is still open,
240    /// otherwise a new one starting at the pen.
241    fn open_contour(&mut self) -> &mut Vec<Point> {
242        if self.contours.last().is_none_or(|contour| contour.closed) {
243            self.push_contour(self.pen);
244        }
245        let last = self.contours.len() - 1;
246        &mut self.contours[last].points
247    }
248}
249
250// `contours` as a fill: every contour closed, under `fill_rule`.
251pub(crate) fn contours_vector_path(
252    contours: &[PathContour],
253    fill_rule: PathFillRule,
254) -> VectorPath {
255    VectorPath::from_subpaths(
256        contours
257            .iter()
258            .filter(|contour| contour.points.len() >= 2)
259            .map(|contour| contour.points.clone())
260            .collect(),
261        fill_rule,
262    )
263}
264
265impl Drop for Path {
266    fn drop(&mut self) {
267        if self.contours.capacity() == 0 {
268            return;
269        }
270        let contours = std::mem::take(&mut self.contours);
271        crate::thread_state::with(|state| state.path_buffers.borrow_mut().keep(contours));
272    }
273}
274
275/// Dashes along a stroke: on for the first interval, off for the second,
276/// and so on, starting `phase` into the pattern. Compose's
277/// `PathEffect.dashPathEffect(intervals, phase)`. Up to
278/// [`DashPathEffect::MAX_INTERVALS`] intervals; a pattern with an odd
279/// count or a non-positive sum draws the stroke whole, as Skia does.
280#[derive(Clone, Copy, Debug, PartialEq)]
281pub struct DashPathEffect {
282    intervals: [f32; Self::MAX_INTERVALS],
283    count: usize,
284    phase: f32,
285}
286
287impl DashPathEffect {
288    /// The most intervals a pattern holds.
289    pub const MAX_INTERVALS: usize = 8;
290
291    /// A dash pattern of `intervals`, the extras past
292    /// [`Self::MAX_INTERVALS`] dropped, starting `phase` into it.
293    pub fn new(intervals: &[f32], phase: f32) -> Self {
294        let mut stored = [0.0; Self::MAX_INTERVALS];
295        let count = intervals.len().min(Self::MAX_INTERVALS);
296        stored[..count].copy_from_slice(&intervals[..count]);
297        Self {
298            intervals: stored,
299            count,
300            phase,
301        }
302    }
303
304    /// The intervals, on then off in turn.
305    pub fn intervals(&self) -> &[f32] {
306        &self.intervals[..self.count]
307    }
308
309    fn period(&self) -> Option<f32> {
310        let period: f32 = self
311            .intervals()
312            .iter()
313            .map(|interval| interval.max(0.0))
314            .sum();
315        (self.count >= 2 && self.count.is_multiple_of(2) && period > 0.0 && period.is_finite())
316            .then_some(period)
317    }
318
319    /// Hands `dash` each dash of the polyline through `points` in turn, as
320    /// a polyline of its own; the whole polyline when the pattern draws it
321    /// whole.
322    pub fn for_each_dash(&self, points: &[Point], mut dash: impl FnMut(&[Point])) {
323        let Some(period) = self.period() else {
324            dash(points);
325            return;
326        };
327        let interval = |index: usize| self.intervals[index].max(0.0);
328        let mut current: Vec<Point> = Vec::new();
329        // Where in the pattern the walk is, and which interval it is in.
330        let mut into = self.phase.rem_euclid(period);
331        let mut index = 0;
332        while into >= interval(index) {
333            into -= interval(index);
334            index = (index + 1) % self.count;
335        }
336        for pair in points.windows(2) {
337            let (start, end) = (pair[0], pair[1]);
338            let length = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt();
339            let at = |distance: f32| {
340                let t = if length > 0.0 { distance / length } else { 0.0 };
341                Point::new(
342                    start.x + (end.x - start.x) * t,
343                    start.y + (end.y - start.y) * t,
344                )
345            };
346            let mut walked = 0.0;
347            while walked < length {
348                let on = index.is_multiple_of(2);
349                let step = (interval(index) - into).min(length - walked);
350                if on {
351                    if current.is_empty() {
352                        current.push(at(walked));
353                    }
354                    current.push(at(walked + step));
355                }
356                walked += step;
357                into += step;
358                if into >= interval(index) {
359                    into = 0.0;
360                    if on && current.len() >= 2 {
361                        dash(&current);
362                    }
363                    current.clear();
364                    index = (index + 1) % self.count;
365                }
366            }
367        }
368        if current.len() >= 2 {
369            dash(&current);
370        }
371    }
372}
373
374/// How [`crate::DrawScope::draw_path`] paints a path: Compose's `Fill` and
375/// `Stroke` draw styles, the stroke dashed when it carries a path effect.
376#[derive(Clone, Copy, Debug, PartialEq)]
377pub enum DrawStyle {
378    /// Fills the path's contours, each closed, under the non-zero rule.
379    Fill,
380    /// Strokes the path's edges with the stroke's width, cap and join.
381    Stroke(Stroke),
382    /// Strokes the dashes the effect cuts the path's edges into, each dash
383    /// capped by the stroke's cap.
384    DashedStroke(Stroke, DashPathEffect),
385}
386
387/// Hands `line` the segments a stroke draws for the polyline through
388/// `points`, closed back to its first point when `closed`. An open
389/// polyline's two ends take the stroke's cap; every corner takes the cap
390/// closest to the stroke's join, round for round and bevel joins, square
391/// for a miter, which is exact at a right angle. A segment whose two ends
392/// want the same cap draws with it; one whose ends differ draws butt ends,
393/// grown by half the width at a square end, and a dot of the stroke at a
394/// round one.
395pub fn for_each_stroke_line(
396    points: &[Point],
397    closed: bool,
398    stroke: Stroke,
399    mut line: impl FnMut(LineGeometry),
400) {
401    let corner = match stroke.join {
402        StrokeJoin::Miter => StrokeCap::Square,
403        StrokeJoin::Round | StrokeJoin::Bevel => StrokeCap::Round,
404    };
405    let closing = closed
406        .then(|| points.first().zip(points.last()))
407        .flatten()
408        .filter(|(first, last)| first != last)
409        .map(|(&first, &last)| (last, first));
410    let mut edges = points
411        .windows(2)
412        .map(|pair| (pair[0], pair[1]))
413        .chain(closing)
414        .filter(|(start, end)| start != end)
415        .peekable();
416    let mut first = true;
417    while let Some((start, end)) = edges.next() {
418        let start_cap = if closed || !first { corner } else { stroke.cap };
419        first = false;
420        let end_cap = if closed || edges.peek().is_some() {
421            corner
422        } else {
423            stroke.cap
424        };
425        let with_cap = |cap| Stroke { cap, ..stroke };
426        if start_cap == end_cap {
427            line(LineGeometry::new(start, end, with_cap(start_cap)));
428            continue;
429        }
430        let half_width = stroke.half_width();
431        let length = ((end.x - start.x).powi(2) + (end.y - start.y).powi(2)).sqrt();
432        let grow = |cap: StrokeCap| {
433            if cap == StrokeCap::Square {
434                half_width / length
435            } else {
436                0.0
437            }
438        };
439        let (back, ahead) = (grow(start_cap), grow(end_cap));
440        let (dx, dy) = (end.x - start.x, end.y - start.y);
441        line(LineGeometry::new(
442            Point::new(start.x - dx * back, start.y - dy * back),
443            Point::new(end.x + dx * ahead, end.y + dy * ahead),
444            with_cap(StrokeCap::Butt),
445        ));
446        for (point, cap) in [(start, start_cap), (end, end_cap)] {
447            if cap == StrokeCap::Round {
448                line(LineGeometry::new(point, point, with_cap(StrokeCap::Round)));
449            }
450        }
451    }
452}
453
454#[cfg(test)]
455#[path = "tests/path_tests.rs"]
456mod tests;