Skip to main content

valo_geometry/
measure.rs

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