Skip to main content

valo_geometry/
measure.rs

1//! Arc-length measurement along a path — Skia's `SkContourMeasure`, Flutter's
2//! `PathMetric`. What text-on-a-path, dashed strokes and "animate along this
3//! curve" all need: how long is this contour, and where is the point a given
4//! distance into it.
5//!
6//! Measurement runs on the FLATTENED contour, so its accuracy is the
7//! flattening tolerance's. That is the same trade the renderer already makes
8//! (it draws the flattening, not the ideal curve), which keeps a sampled point
9//! on the line that actually gets drawn.
10
11use crate::{Contour, Point};
12
13/// A point on a contour and the direction the contour runs there.
14#[derive(Clone, Copy, Debug, PartialEq)]
15pub struct PathSample {
16    pub position: Point,
17    /// Unit vector along the contour, pointing towards increasing distance.
18    pub tangent: Point,
19}
20
21/// One contour, measured. Build these with [`crate::Path::measure`].
22#[derive(Clone, Debug)]
23pub struct ContourMeasure {
24    points: Vec<Point>,
25    /// Distance from the start to each point, so the last entry is the whole
26    /// contour's length. Monotonic, which is what makes sampling a binary
27    /// search rather than a walk.
28    distances: Vec<f32>,
29    closed: bool,
30}
31
32impl ContourMeasure {
33    /// Measure a flattened contour. Zero-length segments are dropped so the
34    /// distance table stays strictly increasing and sampling can't divide by
35    /// a zero span.
36    pub(crate) fn of(contour: &Contour) -> Option<Self> {
37        let mut points = Vec::with_capacity(contour.points.len());
38        let mut distances = Vec::with_capacity(contour.points.len());
39        let mut total = 0.0;
40        for &point in &contour.points {
41            match points.last() {
42                None => {
43                    points.push(point);
44                    distances.push(0.0);
45                }
46                Some(&previous) => {
47                    let step = distance_between(previous, point);
48                    if step > 0.0 {
49                        total += step;
50                        points.push(point);
51                        distances.push(total);
52                    }
53                }
54            }
55        }
56        (points.len() > 1).then_some(Self {
57            points,
58            distances,
59            closed: contour.closed,
60        })
61    }
62
63    pub fn length(&self) -> f32 {
64        *self
65            .distances
66            .last()
67            .expect("measured contours have length")
68    }
69
70    pub fn is_closed(&self) -> bool {
71        self.closed
72    }
73
74    /// Position and tangent `distance` along the contour. Distances outside
75    /// the contour clamp to its ends, matching Skia — a caller animating past
76    /// the end gets the final point, not a wrapped or missing one.
77    pub fn sample(&self, distance: f32) -> PathSample {
78        // NaN would reach the comparator and take the binary search down with
79        // it; the start is the honest answer. Infinities need no special case
80        // — they clamp to the ends like any over-long distance.
81        let distance = if distance.is_nan() { 0.0 } else { distance };
82        let distance = distance.clamp(0.0, self.length());
83        let index = self.segment_containing(distance);
84        let (start, end) = (self.points[index], self.points[index + 1]);
85        let span = self.distances[index + 1] - self.distances[index];
86        let fraction = (distance - self.distances[index]) / span;
87        PathSample {
88            position: lerp(start, end, fraction),
89            tangent: unit_vector(start, end),
90        }
91    }
92
93    /// The stretch between two distances as its own contour — Skia's
94    /// `getSegment`. `None` when the range is empty after clamping.
95    pub fn segment(&self, start: f32, end: f32) -> Option<Contour> {
96        if start.is_nan() || end.is_nan() {
97            return None;
98        }
99        let length = self.length();
100        let (start, end) = (start.clamp(0.0, length), end.clamp(0.0, length));
101        if end <= start {
102            return None;
103        }
104        let mut points = vec![self.sample(start).position];
105        let first = self.segment_containing(start);
106        let last = self.segment_containing(end);
107        for index in first + 1..=last {
108            points.push(self.points[index]);
109        }
110        points.push(self.sample(end).position);
111        Some(Contour {
112            points: dedup_adjacent(points),
113            closed: false,
114            // A slice of a measured contour is real geometry by construction:
115            // `end <= start` returned above, so there is length here.
116            has_segments: true,
117        })
118    }
119
120    /// The index of the segment `distance` falls in — the last point whose
121    /// cumulative distance does not exceed it, capped so `index + 1` is
122    /// always a real point.
123    fn segment_containing(&self, distance: f32) -> usize {
124        match self
125            .distances
126            .binary_search_by(|entry| entry.partial_cmp(&distance).expect("finite distances"))
127        {
128            Ok(index) => index.min(self.points.len() - 2),
129            Err(insert) => insert - 1,
130        }
131    }
132}
133
134fn distance_between(a: Point, b: Point) -> f32 {
135    ((b.x - a.x).powi(2) + (b.y - a.y).powi(2)).sqrt()
136}
137
138fn lerp(a: Point, b: Point, t: f32) -> Point {
139    Point::new(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)
140}
141
142fn unit_vector(a: Point, b: Point) -> Point {
143    let length = distance_between(a, b);
144    Point::new((b.x - a.x) / length, (b.y - a.y) / length)
145}
146
147fn dedup_adjacent(points: Vec<Point>) -> Vec<Point> {
148    let mut out: Vec<Point> = Vec::with_capacity(points.len());
149    for point in points {
150        if out.last() != Some(&point) {
151            out.push(point);
152        }
153    }
154    out
155}
156
157#[cfg(test)]
158mod tests {
159    use crate::{Path, PathBuilder, Point};
160
161    fn measured(build: impl FnOnce(&mut PathBuilder)) -> Vec<crate::ContourMeasure> {
162        let mut builder = PathBuilder::new();
163        build(&mut builder);
164        let path: std::sync::Arc<Path> = builder.build();
165        path.measure(0.05)
166    }
167
168    #[test]
169    fn a_straight_line_measures_its_own_length() {
170        let measures = measured(|b| {
171            b.move_to((10.0, 10.0)).line_to((10.0, 60.0));
172        });
173        assert_eq!(measures.len(), 1);
174        assert!((measures[0].length() - 50.0).abs() < 1e-3);
175        assert!(!measures[0].is_closed());
176    }
177
178    #[test]
179    fn sampling_walks_the_line_and_clamps_past_its_ends() {
180        let measures = measured(|b| {
181            b.move_to((0.0, 0.0)).line_to((100.0, 0.0));
182        });
183        let middle = measures[0].sample(25.0);
184        assert!((middle.position.x - 25.0).abs() < 1e-3);
185        assert!((middle.tangent.x - 1.0).abs() < 1e-3);
186        assert!(middle.tangent.y.abs() < 1e-3);
187
188        // Past either end the sample sticks to the end point.
189        assert!((measures[0].sample(-10.0).position.x - 0.0).abs() < 1e-3);
190        assert!((measures[0].sample(500.0).position.x - 100.0).abs() < 1e-3);
191    }
192
193    #[test]
194    fn a_closed_square_measures_its_whole_perimeter() {
195        let measures = measured(|b| {
196            b.rect(crate::Rect::new(0.0, 0.0, 30.0, 30.0));
197        });
198        assert_eq!(measures.len(), 1);
199        assert!(measures[0].is_closed());
200        // The closing edge counts: four sides, not three.
201        assert!((measures[0].length() - 120.0).abs() < 1e-3);
202    }
203
204    #[test]
205    fn a_circle_measures_near_two_pi_r() {
206        let measures = measured(|b| {
207            b.circle((0.0, 0.0), 50.0);
208        });
209        let circumference = std::f32::consts::TAU * 50.0;
210        // Flattening cuts corners, so the polyline is a touch short.
211        let error = (measures[0].length() - circumference).abs() / circumference;
212        assert!(error < 0.001, "circumference off by {error}");
213    }
214
215    #[test]
216    fn each_contour_is_measured_separately() {
217        let measures = measured(|b| {
218            b.move_to((0.0, 0.0)).line_to((10.0, 0.0));
219            b.move_to((0.0, 20.0)).line_to((0.0, 60.0));
220        });
221        assert_eq!(measures.len(), 2);
222        assert!((measures[0].length() - 10.0).abs() < 1e-3);
223        assert!((measures[1].length() - 40.0).abs() < 1e-3);
224    }
225
226    #[test]
227    fn a_segment_spans_exactly_the_requested_stretch() {
228        let measures = measured(|b| {
229            b.move_to((0.0, 0.0))
230                .line_to((100.0, 0.0))
231                .line_to((100.0, 100.0));
232        });
233        let segment = measures[0].segment(50.0, 150.0).expect("non-empty");
234        assert_eq!(segment.points.first(), Some(&Point::new(50.0, 0.0)));
235        assert_eq!(segment.points.last(), Some(&Point::new(100.0, 50.0)));
236        // It keeps the corner it crosses.
237        assert!(segment.points.contains(&Point::new(100.0, 0.0)));
238        assert!(!segment.closed);
239    }
240
241    #[test]
242    fn an_empty_or_reversed_range_measures_nothing() {
243        let measures = measured(|b| {
244            b.move_to((0.0, 0.0)).line_to((10.0, 0.0));
245        });
246        assert!(measures[0].segment(5.0, 5.0).is_none());
247        assert!(measures[0].segment(8.0, 2.0).is_none());
248    }
249
250    #[test]
251    fn non_finite_distances_answer_instead_of_panicking() {
252        let measures = measured(|b| {
253            b.move_to((0.0, 0.0)).line_to((10.0, 0.0));
254        });
255        assert_eq!(measures[0].sample(f32::NAN).position, Point::new(0.0, 0.0));
256        assert!(measures[0].segment(f32::NAN, 5.0).is_none());
257        assert!(measures[0].segment(0.0, f32::INFINITY).is_some());
258    }
259
260    #[test]
261    fn degenerate_contours_are_dropped_rather_than_measured() {
262        // A lone point has no length, so there is nothing to sample.
263        let measures = measured(|b| {
264            b.move_to((5.0, 5.0));
265        });
266        assert!(measures.is_empty());
267    }
268}