Skip to main content

ogeom_algo/
length.rs

1//! Arc length, and distributing points along a curve by it.
2//!
3//! A curve's parameter is not its length. A B-spline traverses its own
4//! parameter at whatever speed its knots imply; a cone's slant runs at a rate
5//! set by its half angle. So "a point every two millimetres" and "twenty evenly
6//! spaced points" are questions about *length*, and answering them from
7//! parameter values is answering a different question.
8//!
9//! # Two operations, one of them an inversion
10//!
11//! Length is an integral: the speed `|c'(u)|` integrated over the range. That
12//! is [`curve_length`], and it is exact to a stated tolerance rather than
13//! summed from a polyline.
14//!
15//! Placing a point *at* a length is the inverse of that integral, which has no
16//! closed form for anything but a line and a circle. It is solved rather than
17//! approximated: the length from the start is strictly increasing wherever the
18//! parameterization is regular, so a bracketed root find always converges, and
19//! there is no risk of the multiple-root trouble a general solve would have.
20//!
21//! # This is not tessellation
22//!
23//! [`ogeom_mesh::discretize()`] places points where the curve *bends*,
24//! which is what a mesh wants and what a drawing wants. These place points
25//! where the caller asked, evenly along the curve, which is what a toolpath, a
26//! dimension chain or a sampling pattern wants. Neither substitutes for the
27//! other: an evenly spaced polyline through a tight corner misses it, and a
28//! deflection-driven one has no even spacing to speak of.
29
30use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
31use ogeom_geom::{Curve, Curve3d};
32use ogeom_math::{Point, integrate, solve};
33
34/// The arc length of a curve over `range`.
35///
36/// Integrated from the curve's own speed, so it is the length of the *curve*
37/// rather than of a polyline that approximates it. A tessellated length is
38/// always short (every chord cuts a corner), and the shortfall is exactly what
39/// a deflection tolerance permits, which is far larger than this.
40///
41/// # Errors
42///
43/// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if `range` is not finite;
44/// [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if the integral does not
45/// converge, which means the parameterization is singular somewhere in the
46/// range rather than that the curve is long.
47pub fn curve_length(curve: &Curve, range: (f64, f64), tol: Tolerances) -> OgeomResult<f64> {
48    let (lo, hi) = range;
49    if !lo.is_finite() || !hi.is_finite() {
50        ogeom_bail!(Domain, "cannot measure the length of [{lo}, {hi}]");
51    }
52    if (hi - lo).abs() <= tol.parametric() {
53        return Ok(0.0);
54    }
55    // The magnitude of the derivative is the speed along the curve, and its
56    // integral is the distance covered. A failure to evaluate is a zero
57    // contribution rather than a panic: the integrator samples inside the
58    // range, and a curve that cannot be differentiated there has a singular
59    // parameterization, which is what the integrator will then report.
60    let speed = |u: f64| curve.d1_at(u, tol).map_or(0.0, |d| d.magnitude());
61    let length = integrate(speed, lo, hi, tol.confusion())?;
62    Ok(length.abs())
63}
64
65/// The parameter at which a given arc length from the start of `range` is
66/// reached.
67///
68/// The inverse of [`curve_length`]. `target` is measured from `range.0`, and
69/// must lie between zero and the curve's total length over the range; asking
70/// for a point beyond the end is refused rather than clamped, because a
71/// clamped answer is indistinguishable from a correct one at the end.
72///
73/// # Errors
74///
75/// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if `target` is negative or
76/// past the end; [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if the solve
77/// does not converge.
78pub fn parameter_at_length(
79    curve: &Curve,
80    range: (f64, f64),
81    target: f64,
82    tol: Tolerances,
83) -> OgeomResult<f64> {
84    let total = curve_length(curve, range, tol)?;
85    if !target.is_finite() || target < -tol.confusion() {
86        ogeom_bail!(
87            Domain,
88            "arc length {target} is not a distance along a curve"
89        );
90    }
91    if target > total + tol.confusion() {
92        ogeom_bail!(
93            Domain,
94            "asked for the point {target} along a curve {total} long; clamping \
95             it would give an answer indistinguishable from a correct one at \
96             the end"
97        );
98    }
99    if target <= tol.confusion() {
100        return Ok(range.0);
101    }
102    if target >= total - tol.confusion() {
103        return Ok(range.1);
104    }
105
106    // Length from the start is strictly increasing wherever the speed is
107    // non-zero, so this has exactly one root in the range and a bracketed
108    // method cannot land on the wrong one.
109    let residual = |u: f64| curve_length(curve, (range.0, u), tol).unwrap_or(0.0) - target;
110    let criteria = solve::Criteria {
111        // The residual is a *length*, so it is measured against a spatial
112        // tolerance; the step is a parameter and is measured against a
113        // parametric one.
114        residual: tol.confusion(),
115        step: tol.parametric(),
116        ..solve::Criteria::default()
117    };
118    Ok(solve::brent(residual, range.0, range.1, criteria)?.value)
119}
120
121/// `count` points evenly spaced *by arc length* along a curve, ends included.
122///
123/// `count` is the number of points, so two gives the ends and nothing between.
124///
125/// # Errors
126///
127/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if fewer than two
128/// points are asked for; otherwise as [`parameter_at_length`].
129pub fn points_by_count(
130    curve: &Curve,
131    range: (f64, f64),
132    count: usize,
133    tol: Tolerances,
134) -> OgeomResult<Vec<(f64, Point)>> {
135    if count < 2 {
136        ogeom_bail!(
137            Construction,
138            "a distribution along a curve needs at least its two ends, got \
139             {count}"
140        );
141    }
142    let total = curve_length(curve, range, tol)?;
143    #[allow(clippy::cast_precision_loss)]
144    let step = total / (count - 1) as f64;
145    let mut out = Vec::with_capacity(count);
146    for i in 0..count {
147        #[allow(clippy::cast_precision_loss)]
148        let at = parameter_at_length(curve, range, step * i as f64, tol)?;
149        out.push((at, curve.point_at(at, tol)?));
150    }
151    Ok(out)
152}
153
154/// Points along a curve at a fixed arc-length `spacing`.
155///
156/// The first point is at the start. The last is at the end *whatever the
157/// spacing divides to*, so the final gap is short rather than the curve being
158/// left unfinished: a distribution that stops before the end is a different
159/// answer from the one asked for, and silently dropping the tail is how a
160/// toolpath ends up not reaching the edge of the material.
161///
162/// # Errors
163///
164/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `spacing` is
165/// not finite and positive; otherwise as [`parameter_at_length`].
166pub fn points_by_spacing(
167    curve: &Curve,
168    range: (f64, f64),
169    spacing: f64,
170    tol: Tolerances,
171) -> OgeomResult<Vec<(f64, Point)>> {
172    if !spacing.is_finite() || spacing <= tol.confusion() {
173        ogeom_bail!(
174            Construction,
175            "spacing {spacing} must be finite and positive"
176        );
177    }
178    let total = curve_length(curve, range, tol)?;
179    let mut out = Vec::new();
180    let mut at_length = 0.0;
181    while at_length < total - tol.confusion() {
182        let at = parameter_at_length(curve, range, at_length, tol)?;
183        out.push((at, curve.point_at(at, tol)?));
184        at_length += spacing;
185    }
186    out.push((range.1, curve.point_at(range.1, tol)?));
187    Ok(out)
188}
189
190#[cfg(test)]
191#[allow(clippy::unwrap_used)]
192mod tests {
193    use super::*;
194    use approx::assert_relative_eq;
195    use core::f64::consts::{PI, TAU};
196    use ogeom_geom::{BSplineCurve, CircleCurve, LineCurve};
197    use ogeom_math::{Circle, Frame, KnotVector};
198
199    const T: Tolerances = Tolerances::millimetres();
200
201    fn circle(radius: f64) -> Curve {
202        CircleCurve::new(Circle::new(Frame::WORLD, radius, T).unwrap()).into()
203    }
204
205    #[test]
206    fn a_lines_length_is_the_distance_between_its_ends() {
207        let line: Curve = LineCurve::segment(Point::ORIGIN, Point::new(3.0, 4.0, 0.0), T)
208            .unwrap()
209            .into();
210        assert_relative_eq!(
211            curve_length(&line, (0.0, 5.0), T).unwrap(),
212            5.0,
213            epsilon = 1e-12
214        );
215        // Its parameter is already arc length, so half the length is half way.
216        assert_relative_eq!(
217            parameter_at_length(&line, (0.0, 5.0), 2.5, T).unwrap(),
218            2.5,
219            epsilon = 1e-9
220        );
221    }
222
223    #[test]
224    fn a_circles_length_is_its_circumference_and_an_arcs_is_the_fraction() {
225        let c = circle(2.0);
226        assert_relative_eq!(
227            curve_length(&c, (0.0, TAU), T).unwrap(),
228            TAU * 2.0,
229            epsilon = 1e-9
230        );
231        assert_relative_eq!(
232            curve_length(&c, (0.0, PI), T).unwrap(),
233            PI * 2.0,
234            epsilon = 1e-9
235        );
236    }
237
238    #[test]
239    fn length_is_measured_on_the_curve_not_on_a_polyline_through_it() {
240        // The distinction that makes this worth having. A chord always cuts a
241        // corner, so a tessellated length is short, and short by whatever the
242        // deflection permits, which is far more than this integral's error.
243        let c = circle(1.0);
244        let exact = TAU;
245        let integrated = curve_length(&c, (0.0, TAU), T).unwrap();
246        assert!(
247            (integrated - exact).abs() < 1e-9,
248            "got {integrated} against {exact}"
249        );
250
251        let mesh =
252            ogeom_mesh::discretize(&c, (0.0, TAU), ogeom_mesh::Deflection::default(), T).unwrap();
253        assert!(
254            mesh.length() < exact - 1e-4,
255            "a coarse polyline should be visibly short, got {}",
256            mesh.length()
257        );
258    }
259
260    #[test]
261    fn points_by_count_are_evenly_spaced_along_the_curve() {
262        // On a circle, even in length is even in angle, which is the check
263        // that the inversion is doing its job rather than returning parameters.
264        let c = circle(3.0);
265        let points = points_by_count(&c, (0.0, TAU), 9, T).unwrap();
266        assert_eq!(points.len(), 9);
267
268        let step = TAU / 8.0;
269        for (i, (at, _)) in points.iter().enumerate() {
270            #[allow(clippy::cast_precision_loss)]
271            let want = step * i as f64;
272            assert!((at - want).abs() < 1e-6, "point {i} at {at}, wanted {want}");
273        }
274        // And the chords between consecutive points are all the same length.
275        let first = points[0].1.distance(points[1].1);
276        for pair in points.windows(2) {
277            assert_relative_eq!(pair[0].1.distance(pair[1].1), first, max_relative = 1e-6);
278        }
279    }
280
281    #[test]
282    fn an_unevenly_parameterized_curve_is_still_evenly_divided() {
283        // The case a parameter-space distribution gets wrong. This spline's
284        // knots make it cover ground at very different speeds, so equal
285        // parameter steps are not equal distances and equal distances are not
286        // equal parameter steps.
287        let knots = KnotVector::new(vec![0.0, 0.0, 0.0, 0.0, 0.2, 1.0, 1.0, 1.0, 1.0], 3).unwrap();
288        let control = vec![
289            Point::new(0.0, 0.0, 0.0),
290            Point::new(0.5, 4.0, 0.0),
291            Point::new(3.0, 4.0, 0.0),
292            Point::new(9.0, 0.5, 0.0),
293            Point::new(10.0, 0.0, 0.0),
294        ];
295        let spline: Curve = BSplineCurve::new(knots, control, T).unwrap().into();
296        let range = spline.domain();
297
298        let points = points_by_count(&spline, range, 12, T).unwrap();
299        // Equal *arc* length between consecutive points, which is what was
300        // asked for. Not equal chords: a chord cuts the corner, so where this
301        // curve bends hardest its chord is several percent shorter than the arc
302        // it spans, and asserting on chords would be asserting the curve is
303        // straight.
304        let step = curve_length(&spline, (points[0].0, points[1].0), T).unwrap();
305        for pair in points.windows(2) {
306            let along = curve_length(&spline, (pair[0].0, pair[1].0), T).unwrap();
307            assert_relative_eq!(along, step, max_relative = 1e-6);
308        }
309
310        // And the parameters are *not* evenly spaced, which is the whole point.
311        let steps: Vec<f64> = points.windows(2).map(|w| w[1].0 - w[0].0).collect();
312        let spread = steps.iter().fold(0.0_f64, |a, b| a.max(*b))
313            / steps.iter().fold(f64::MAX, |a, b| a.min(*b));
314        assert!(
315            spread > 1.5,
316            "this curve's parameter should be visibly uneven, spread {spread}"
317        );
318    }
319
320    #[test]
321    fn spacing_always_reaches_the_end_even_when_it_does_not_divide() {
322        let c = circle(1.0);
323        let total = TAU;
324        // Deliberately does not divide the circumference.
325        let points = points_by_spacing(&c, (0.0, total), 1.0, T).unwrap();
326        assert!(points.len() >= 7);
327        assert_relative_eq!(points[0].0, 0.0, epsilon = 1e-12);
328        assert_relative_eq!(points[points.len() - 1].0, total, epsilon = 1e-9);
329
330        // Every gap but the last is the spacing asked for; the last is short.
331        for pair in points[..points.len() - 1].windows(2) {
332            let along = curve_length(&c, (pair[0].0, pair[1].0), T).unwrap();
333            assert_relative_eq!(along, 1.0, max_relative = 1e-6);
334        }
335        let tail = curve_length(
336            &c,
337            (points[points.len() - 2].0, points[points.len() - 1].0),
338            T,
339        )
340        .unwrap();
341        assert!(
342            tail <= 1.0 + 1e-9,
343            "the last gap should be short, got {tail}"
344        );
345    }
346
347    #[test]
348    fn asking_beyond_the_end_is_refused_rather_than_clamped() {
349        // A clamped answer sits exactly where a correct one at the end would,
350        // so the caller cannot tell the difference.
351        let c = circle(1.0);
352        assert!(parameter_at_length(&c, (0.0, PI), PI * 2.0, T).is_err());
353        assert!(parameter_at_length(&c, (0.0, PI), -1.0, T).is_err());
354        assert!(parameter_at_length(&c, (0.0, PI), PI, T).is_ok());
355    }
356
357    #[test]
358    fn distributions_that_describe_nothing_are_refused() {
359        let c = circle(1.0);
360        assert!(points_by_count(&c, (0.0, TAU), 1, T).is_err());
361        assert!(points_by_count(&c, (0.0, TAU), 0, T).is_err());
362        assert!(points_by_spacing(&c, (0.0, TAU), 0.0, T).is_err());
363        assert!(points_by_spacing(&c, (0.0, TAU), -1.0, T).is_err());
364        assert!(points_by_spacing(&c, (0.0, TAU), f64::NAN, T).is_err());
365    }
366}