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    // Span by span between the curve's breaks: across a kink (a polyline
62    // spline's corner) the speed jumps, and an adaptive rule straddling the
63    // jump refines there without end. Within a span it is smooth.
64    let (from, to) = (lo.min(hi), lo.max(hi));
65    let mut stations = vec![from];
66    stations.extend(breaks(curve).into_iter().filter(|&b| b > from && b < to));
67    stations.push(to);
68    let mut length = 0.0;
69    for pair in stations.windows(2) {
70        if pair[1] - pair[0] > tol.parametric() {
71            length += integrate(speed, pair[0], pair[1], tol.confusion())?.abs();
72        }
73    }
74    Ok(length)
75}
76
77/// Where a curve's speed may jump: a spline's interior knots, and a trim's
78/// basis's inside its window (mirrored for a trim walked backwards).
79fn breaks(curve: &Curve) -> Vec<f64> {
80    match curve {
81        Curve::BSpline(spline) => spline
82            .knots()
83            .distinct()
84            .into_iter()
85            .map(|(value, _)| value)
86            .collect(),
87        Curve::Trimmed(trim) => {
88            let (lo, hi) = trim.domain();
89            let inner = breaks(trim.basis());
90            if trim.is_reversed() {
91                inner.into_iter().map(|b| lo + hi - b).collect()
92            } else {
93                inner
94            }
95        }
96        _ => Vec::new(),
97    }
98}
99
100/// The parameter at which a given arc length from the start of `range` is
101/// reached.
102///
103/// The inverse of [`curve_length`]. `target` is measured from `range.0`, and
104/// must lie between zero and the curve's total length over the range; asking
105/// for a point beyond the end is refused rather than clamped, because a
106/// clamped answer is indistinguishable from a correct one at the end.
107///
108/// # Errors
109///
110/// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if `target` is negative or
111/// past the end; [`OgeomError::NotDone`](ogeom_core::OgeomError::NotDone) if the solve
112/// does not converge.
113pub fn parameter_at_length(
114    curve: &Curve,
115    range: (f64, f64),
116    target: f64,
117    tol: Tolerances,
118) -> OgeomResult<f64> {
119    let total = curve_length(curve, range, tol)?;
120    if !target.is_finite() || target < -tol.confusion() {
121        ogeom_bail!(
122            Domain,
123            "arc length {target} is not a distance along a curve"
124        );
125    }
126    if target > total + tol.confusion() {
127        ogeom_bail!(
128            Domain,
129            "asked for the point {target} along a curve {total} long; clamping \
130             it would give an answer indistinguishable from a correct one at \
131             the end"
132        );
133    }
134    if target <= tol.confusion() {
135        return Ok(range.0);
136    }
137    if target >= total - tol.confusion() {
138        return Ok(range.1);
139    }
140
141    // Length from the start is strictly increasing wherever the speed is
142    // non-zero, so this has exactly one root in the range and a bracketed
143    // method cannot land on the wrong one.
144    // A length that cannot be measured is an error to report, not a zero
145    // to solve against: the root a zero bends the residual to is a wrong
146    // point no caller could tell from a right one.
147    let failed: core::cell::Cell<Option<ogeom_core::OgeomError>> = core::cell::Cell::new(None);
148    let residual = |u: f64| match curve_length(curve, (range.0, u), tol) {
149        Ok(length) => length - target,
150        Err(e) => {
151            failed.set(Some(e));
152            0.0
153        }
154    };
155    let criteria = solve::Criteria {
156        // The residual is a *length*, so it is measured against a spatial
157        // tolerance; the step is a parameter and is measured against a
158        // parametric one.
159        residual: tol.confusion(),
160        step: tol.parametric(),
161        ..solve::Criteria::default()
162    };
163    let found = solve::brent(residual, range.0, range.1, criteria);
164    if let Some(e) = failed.take() {
165        return Err(e);
166    }
167    Ok(found?.value)
168}
169
170/// `count` points evenly spaced *by arc length* along a curve, ends included.
171///
172/// `count` is the number of points, so two gives the ends and nothing between.
173///
174/// # Errors
175///
176/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if fewer than two
177/// points are asked for; otherwise as [`parameter_at_length`].
178pub fn points_by_count(
179    curve: &Curve,
180    range: (f64, f64),
181    count: usize,
182    tol: Tolerances,
183) -> OgeomResult<Vec<(f64, Point)>> {
184    if count < 2 {
185        ogeom_bail!(
186            Construction,
187            "a distribution along a curve needs at least its two ends, got \
188             {count}"
189        );
190    }
191    let total = curve_length(curve, range, tol)?;
192    #[allow(clippy::cast_precision_loss)]
193    let step = total / (count - 1) as f64;
194    let mut out = Vec::with_capacity(count);
195    for i in 0..count {
196        #[allow(clippy::cast_precision_loss)]
197        let at = parameter_at_length(curve, range, step * i as f64, tol)?;
198        out.push((at, curve.point_at(at, tol)?));
199    }
200    Ok(out)
201}
202
203/// Points along a curve at a fixed arc-length `spacing`.
204///
205/// The first point is at the start. The last is at the end *whatever the
206/// spacing divides to*, so the final gap is short rather than the curve being
207/// left unfinished: a distribution that stops before the end is a different
208/// answer from the one asked for, and silently dropping the tail is how a
209/// toolpath ends up not reaching the edge of the material.
210///
211/// # Errors
212///
213/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `spacing` is
214/// not finite and positive; otherwise as [`parameter_at_length`].
215pub fn points_by_spacing(
216    curve: &Curve,
217    range: (f64, f64),
218    spacing: f64,
219    tol: Tolerances,
220) -> OgeomResult<Vec<(f64, Point)>> {
221    if !spacing.is_finite() || spacing <= tol.confusion() {
222        ogeom_bail!(
223            Construction,
224            "spacing {spacing} must be finite and positive"
225        );
226    }
227    let total = curve_length(curve, range, tol)?;
228    let mut out = Vec::new();
229    let mut at_length = 0.0;
230    while at_length < total - tol.confusion() {
231        let at = parameter_at_length(curve, range, at_length, tol)?;
232        out.push((at, curve.point_at(at, tol)?));
233        at_length += spacing;
234    }
235    out.push((range.1, curve.point_at(range.1, tol)?));
236    Ok(out)
237}
238
239#[cfg(test)]
240#[allow(clippy::unwrap_used)]
241mod tests {
242    use super::*;
243    use approx::assert_relative_eq;
244    use core::f64::consts::{PI, TAU};
245    use ogeom_geom::{BSplineCurve, CircleCurve, LineCurve};
246    use ogeom_math::{Circle, Frame, KnotVector, Point};
247
248    const T: Tolerances = Tolerances::millimetres();
249
250    fn circle(radius: f64) -> Curve {
251        CircleCurve::new(Circle::new(Frame::WORLD, radius, T).unwrap()).into()
252    }
253
254    #[test]
255    fn a_lines_length_is_the_distance_between_its_ends() {
256        let line: Curve = LineCurve::segment(Point::ORIGIN, Point::new(3.0, 4.0, 0.0), T)
257            .unwrap()
258            .into();
259        assert_relative_eq!(
260            curve_length(&line, (0.0, 5.0), T).unwrap(),
261            5.0,
262            epsilon = 1e-12
263        );
264        // Its parameter is already arc length, so half the length is half way.
265        assert_relative_eq!(
266            parameter_at_length(&line, (0.0, 5.0), 2.5, T).unwrap(),
267            2.5,
268            epsilon = 1e-9
269        );
270    }
271
272    #[test]
273    fn a_circles_length_is_its_circumference_and_an_arcs_is_the_fraction() {
274        let c = circle(2.0);
275        assert_relative_eq!(
276            curve_length(&c, (0.0, TAU), T).unwrap(),
277            TAU * 2.0,
278            epsilon = 1e-9
279        );
280        assert_relative_eq!(
281            curve_length(&c, (0.0, PI), T).unwrap(),
282            PI * 2.0,
283            epsilon = 1e-9
284        );
285    }
286
287    #[test]
288    fn length_is_measured_on_the_curve_not_on_a_polyline_through_it() {
289        // The distinction that makes this worth having. A chord always cuts a
290        // corner, so a tessellated length is short, and short by whatever the
291        // deflection permits, which is far more than this integral's error.
292        let c = circle(1.0);
293        let exact = TAU;
294        let integrated = curve_length(&c, (0.0, TAU), T).unwrap();
295        assert!(
296            (integrated - exact).abs() < 1e-9,
297            "got {integrated} against {exact}"
298        );
299
300        let mesh =
301            ogeom_mesh::discretize(&c, (0.0, TAU), ogeom_mesh::Deflection::default(), T).unwrap();
302        assert!(
303            mesh.length() < exact - 1e-4,
304            "a coarse polyline should be visibly short, got {}",
305            mesh.length()
306        );
307    }
308
309    #[test]
310    fn points_by_count_are_evenly_spaced_along_the_curve() {
311        // On a circle, even in length is even in angle, which is the check
312        // that the inversion is doing its job rather than returning parameters.
313        let c = circle(3.0);
314        let points = points_by_count(&c, (0.0, TAU), 9, T).unwrap();
315        assert_eq!(points.len(), 9);
316
317        let step = TAU / 8.0;
318        for (i, (at, _)) in points.iter().enumerate() {
319            #[allow(clippy::cast_precision_loss)]
320            let want = step * i as f64;
321            assert!((at - want).abs() < 1e-6, "point {i} at {at}, wanted {want}");
322        }
323        // And the chords between consecutive points are all the same length.
324        let first = points[0].1.distance(points[1].1);
325        for pair in points.windows(2) {
326            assert_relative_eq!(pair[0].1.distance(pair[1].1), first, max_relative = 1e-6);
327        }
328    }
329
330    #[test]
331    fn an_unevenly_parameterized_curve_is_still_evenly_divided() {
332        // The case a parameter-space distribution gets wrong. This spline's
333        // knots make it cover ground at very different speeds, so equal
334        // parameter steps are not equal distances and equal distances are not
335        // equal parameter steps.
336        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();
337        let control = vec![
338            Point::new(0.0, 0.0, 0.0),
339            Point::new(0.5, 4.0, 0.0),
340            Point::new(3.0, 4.0, 0.0),
341            Point::new(9.0, 0.5, 0.0),
342            Point::new(10.0, 0.0, 0.0),
343        ];
344        let spline: Curve = BSplineCurve::new(knots, control, T).unwrap().into();
345        let range = spline.domain();
346
347        let points = points_by_count(&spline, range, 12, T).unwrap();
348        // Equal *arc* length between consecutive points, which is what was
349        // asked for. Not equal chords: a chord cuts the corner, so where this
350        // curve bends hardest its chord is several percent shorter than the arc
351        // it spans, and asserting on chords would be asserting the curve is
352        // straight.
353        let step = curve_length(&spline, (points[0].0, points[1].0), T).unwrap();
354        for pair in points.windows(2) {
355            let along = curve_length(&spline, (pair[0].0, pair[1].0), T).unwrap();
356            assert_relative_eq!(along, step, max_relative = 1e-6);
357        }
358
359        // And the parameters are *not* evenly spaced, which is the whole point.
360        let steps: Vec<f64> = points.windows(2).map(|w| w[1].0 - w[0].0).collect();
361        let spread = steps.iter().fold(0.0_f64, |a, b| a.max(*b))
362            / steps.iter().fold(f64::MAX, |a, b| a.min(*b));
363        assert!(
364            spread > 1.5,
365            "this curve's parameter should be visibly uneven, spread {spread}"
366        );
367    }
368
369    #[test]
370    fn spacing_always_reaches_the_end_even_when_it_does_not_divide() {
371        let c = circle(1.0);
372        let total = TAU;
373        // Deliberately does not divide the circumference.
374        let points = points_by_spacing(&c, (0.0, total), 1.0, T).unwrap();
375        assert!(points.len() >= 7);
376        assert_relative_eq!(points[0].0, 0.0, epsilon = 1e-12);
377        assert_relative_eq!(points[points.len() - 1].0, total, epsilon = 1e-9);
378
379        // Every gap but the last is the spacing asked for; the last is short.
380        for pair in points[..points.len() - 1].windows(2) {
381            let along = curve_length(&c, (pair[0].0, pair[1].0), T).unwrap();
382            assert_relative_eq!(along, 1.0, max_relative = 1e-6);
383        }
384        let tail = curve_length(
385            &c,
386            (points[points.len() - 2].0, points[points.len() - 1].0),
387            T,
388        )
389        .unwrap();
390        assert!(
391            tail <= 1.0 + 1e-9,
392            "the last gap should be short, got {tail}"
393        );
394    }
395
396    #[test]
397    fn asking_beyond_the_end_is_refused_rather_than_clamped() {
398        // A clamped answer sits exactly where a correct one at the end would,
399        // so the caller cannot tell the difference.
400        let c = circle(1.0);
401        assert!(parameter_at_length(&c, (0.0, PI), PI * 2.0, T).is_err());
402        assert!(parameter_at_length(&c, (0.0, PI), -1.0, T).is_err());
403        assert!(parameter_at_length(&c, (0.0, PI), PI, T).is_ok());
404    }
405
406    #[test]
407    fn distributions_that_describe_nothing_are_refused() {
408        let c = circle(1.0);
409        assert!(points_by_count(&c, (0.0, TAU), 1, T).is_err());
410        assert!(points_by_count(&c, (0.0, TAU), 0, T).is_err());
411        assert!(points_by_spacing(&c, (0.0, TAU), 0.0, T).is_err());
412        assert!(points_by_spacing(&c, (0.0, TAU), -1.0, T).is_err());
413        assert!(points_by_spacing(&c, (0.0, TAU), f64::NAN, T).is_err());
414    }
415
416    /// A polyline spline turns a corner at its interior knot, and lengths
417    /// measured across the corner, and points placed by length past it,
418    /// come out where the polyline puts them.
419    #[test]
420    fn lengths_and_points_across_a_kink() {
421        let knots = KnotVector::new(vec![0.0, 0.0, 0.37, 1.0, 1.0], 1).unwrap();
422        let corner: Curve = BSplineCurve::new(
423            knots,
424            vec![
425                Point::new(0.0, 0.0, 0.0),
426                Point::new(30.0, 0.0, 0.0),
427                Point::new(30.0, 70.0, 0.0),
428            ],
429            T,
430        )
431        .unwrap()
432        .into();
433        assert_relative_eq!(
434            curve_length(&corner, (0.0, 1.0), T).unwrap(),
435            100.0,
436            epsilon = 1e-9
437        );
438        let placed = points_by_count(&corner, (0.0, 1.0), 5, T).unwrap();
439        let want = [
440            (0.0, 0.0),
441            (25.0, 0.0),
442            (30.0, 20.0),
443            (30.0, 45.0),
444            (30.0, 70.0),
445        ];
446        for ((_, p), (x, y)) in placed.iter().zip(want) {
447            assert!(
448                p.distance(Point::new(x, y, 0.0)) < 1e-6,
449                "{p:?} against ({x}, {y})"
450            );
451        }
452    }
453}