Skip to main content

axiolid_evaluate/
arc_parameter.rs

1//! Arc length along any evaluable curve, and its inverse.
2//!
3//! Most curves have no closed-form arc length: a cubic parabola
4//! `y = A x^3` needs `int sqrt(1 + 9 A^2 x^4) dx`, an elliptic integral, and
5//! a B-spline or an ellipse is no better. Yet some data names a position by
6//! distance along such a curve: an arc-length trim selector, or a chain
7//! piece read by arc length. This module discharges both with quadrature of
8//! the speed `|c'(t)|` and a safeguarded Newton root find, the same bargain
9//! [`crate::arc_length`] makes for intrinsic curves: the stored value stays
10//! exact, only reading it needs numerical work.
11//!
12//! # Accuracy contract
13//!
14//! - [`arc_length2`] and [`arc_length3`] integrate the speed by adaptive
15//!   8-point Gauss-Legendre, panels split at every knot and polyline vertex
16//!   (the speed is only piecewise smooth there) and bisected until a panel
17//!   and its two halves agree to the panel's share of
18//!   `ARC_LENGTH_TOLERANCE * max(1, length)`. That agreement is an error
19//!   ESTIMATE, as QUADPACK's is, not a proof; on a smooth speed the halves
20//!   are far more accurate than the estimate says.
21//! - [`parameter_at_arc_length2`] and [`parameter_at_arc_length3`] return a
22//!   parameter whose arc length from `from` matches the request to the same
23//!   tolerance, or a parameter bracket collapsed to rounding.
24//! - Lines, intrinsic curves and chains are exact: their parameter is a
25//!   multiple of arc length, so no quadrature is run.
26//!
27//! A request past the end of a bounded curve, a start parameter outside its
28//! domain, a curve whose speed does not evaluate, and a quadrature that
29//! needs more than [`MAX_PANELS`] panels are refused by name.
30
31use axiolid_contracts::{GeomError, GeomResult};
32use axiolid_core::{Interval, Scalar};
33use axiolid_curve::{Curve2, Curve3};
34
35use crate::curve::{derivative2, derivative3, domain2, domain3};
36
37/// Relative tolerance of every arc length and arc-length inverse this module
38/// returns: the absolute tolerance for a length `l` is
39/// `ARC_LENGTH_TOLERANCE * max(1, |l|)`, in the curve's length unit.
40pub const ARC_LENGTH_TOLERANCE: Scalar = 1e-12;
41
42/// Most quadrature panels one arc length may use before it refuses.
43pub const MAX_PANELS: usize = 1 << 14;
44
45/// Root-find iterations before the inverse refuses.
46const MAX_ITERATIONS: usize = 200;
47
48/// Nodes of the 8-point Gauss-Legendre rule on `[-1, 1]`.
49pub(crate) const NODES: [Scalar; 8] = [
50    -0.960_289_856_497_536_2,
51    -0.796_666_477_413_626_7,
52    -0.525_532_409_916_328_9,
53    -0.183_434_642_495_649_8,
54    0.183_434_642_495_649_8,
55    0.525_532_409_916_328_9,
56    0.796_666_477_413_626_7,
57    0.960_289_856_497_536_2,
58];
59
60/// Weights matching [`NODES`].
61pub(crate) const WEIGHTS: [Scalar; 8] = [
62    0.101_228_536_290_376_3,
63    0.222_381_034_453_374_5,
64    0.313_706_645_877_887_3,
65    0.362_683_783_378_361_9,
66    0.362_683_783_378_361_9,
67    0.313_706_645_877_887_3,
68    0.222_381_034_453_374_5,
69    0.101_228_536_290_376_3,
70];
71
72fn invalid(detail: String) -> GeomError {
73    GeomError::InvalidInput(detail)
74}
75
76/// How a curve family's parameter relates to its arc length.
77enum Family {
78    /// Arc length is `speed * (to - from)` exactly.
79    Proportional(Scalar),
80    /// Arc length needs quadrature; the parameter may run over `domain`
81    /// (`None`: anywhere, as for a periodic conic) and the speed is only
82    /// piecewise smooth across `breaks`.
83    Numeric {
84        domain: Option<(Scalar, Scalar)>,
85        breaks: Vec<Scalar>,
86    },
87}
88
89/// Every knot of a B-spline, every polyline vertex, every chain join.
90const ALL_BREAKS: usize = u16::MAX as usize + 1;
91
92fn bounded(domain: Interval) -> Option<(Scalar, Scalar)> {
93    Some((domain.start.min(domain.end), domain.start.max(domain.end)))
94}
95
96fn family2(curve: &Curve2) -> GeomResult<Family> {
97    Ok(match curve {
98        Curve2::Line(line) => {
99            let speed = line.direction.length();
100            if !(speed.is_finite() && speed > 0.0) {
101                return Err(invalid("line has no direction, so no arc length".into()));
102            }
103            Family::Proportional(speed)
104        }
105        Curve2::Intrinsic(_) | Curve2::Chain(_) => Family::Proportional(1.0),
106        Curve2::Circle(_) | Curve2::Ellipse(_) | Curve2::Sinusoid(_) => Family::Numeric {
107            domain: None,
108            breaks: Vec::new(),
109        },
110        _ => Family::Numeric {
111            domain: bounded(domain2(curve)),
112            breaks: crate::bound::continuity_breaks2(curve, ALL_BREAKS),
113        },
114    })
115}
116
117fn family3(curve: &Curve3) -> GeomResult<Family> {
118    Ok(match curve {
119        Curve3::Line(line) => {
120            let speed = line.direction.length();
121            if !(speed.is_finite() && speed > 0.0) {
122                return Err(invalid("line has no direction, so no arc length".into()));
123            }
124            Family::Proportional(speed)
125        }
126        Curve3::Intrinsic(_) => Family::Proportional(1.0),
127        Curve3::Circle(_) | Curve3::Ellipse(_) => Family::Numeric {
128            domain: None,
129            breaks: Vec::new(),
130        },
131        _ => Family::Numeric {
132            domain: bounded(domain3(curve)),
133            breaks: crate::bound::continuity_breaks3(curve, ALL_BREAKS),
134        },
135    })
136}
137
138/// Signed arc length of a 2D curve from parameter `from` to `to`: negative
139/// when `to < from`. See the [module documentation](self) for the
140/// tolerance.
141pub fn arc_length2(curve: &Curve2, from: Scalar, to: Scalar) -> GeomResult<Scalar> {
142    let speed = |t| derivative2(curve, t).map(|d| d.length());
143    arc_length(&family2(curve)?, &speed, from, to)
144}
145
146/// Signed arc length of a 3D curve from parameter `from` to `to`; see
147/// [`arc_length2`].
148pub fn arc_length3(curve: &Curve3, from: Scalar, to: Scalar) -> GeomResult<Scalar> {
149    let speed = |t| derivative3(curve, t).map(|d| d.length());
150    arc_length(&family3(curve)?, &speed, from, to)
151}
152
153/// The parameter at signed arc length `length` from parameter `from` along
154/// a 2D curve: in increasing parameter for a positive length, decreasing
155/// for a negative one.
156///
157/// Refuses by name a length past the end of a bounded curve, a `from`
158/// outside its domain, and non-finite input. See the
159/// [module documentation](self) for the tolerance.
160pub fn parameter_at_arc_length2(
161    curve: &Curve2,
162    from: Scalar,
163    length: Scalar,
164) -> GeomResult<Scalar> {
165    let speed = |t| derivative2(curve, t).map(|d| d.length());
166    parameter_at(&family2(curve)?, &speed, from, length)
167}
168
169/// [`parameter_at_arc_length2`] for a 3D curve.
170pub fn parameter_at_arc_length3(
171    curve: &Curve3,
172    from: Scalar,
173    length: Scalar,
174) -> GeomResult<Scalar> {
175    let speed = |t| derivative3(curve, t).map(|d| d.length());
176    parameter_at(&family3(curve)?, &speed, from, length)
177}
178
179fn tolerance_for(length: Scalar) -> Scalar {
180    ARC_LENGTH_TOLERANCE * length.abs().max(1.0)
181}
182
183fn check_finite(from: Scalar, other: Scalar, what: &str) -> GeomResult<()> {
184    if from.is_finite() && other.is_finite() {
185        Ok(())
186    } else {
187        Err(invalid(format!(
188            "arc length needs a finite start parameter and {what}"
189        )))
190    }
191}
192
193fn inside(domain: Option<(Scalar, Scalar)>, t: Scalar) -> bool {
194    domain.is_none_or(|(lo, hi)| {
195        let slack = 1e-12 * (hi - lo).abs().max(1.0);
196        t >= lo - slack && t <= hi + slack
197    })
198}
199
200fn arc_length<F>(family: &Family, speed: &F, from: Scalar, to: Scalar) -> GeomResult<Scalar>
201where
202    F: Fn(Scalar) -> GeomResult<Scalar>,
203{
204    check_finite(from, to, "a finite end parameter")?;
205    match family {
206        Family::Proportional(rate) => Ok(rate * (to - from)),
207        Family::Numeric { domain, breaks } => {
208            if !inside(*domain, from) || !inside(*domain, to) {
209                return Err(invalid(format!(
210                    "arc length from parameter {from} to {to} leaves the curve's domain"
211                )));
212            }
213            let (lo, hi) = (from.min(to), from.max(to));
214            let magnitude = integrate(speed, breaks, lo, hi)?;
215            Ok(if to < from { -magnitude } else { magnitude })
216        }
217    }
218}
219
220/// Integral of the speed over `[lo, hi]`, split at the breaks inside.
221fn integrate<F>(speed: &F, breaks: &[Scalar], lo: Scalar, hi: Scalar) -> GeomResult<Scalar>
222where
223    F: Fn(Scalar) -> GeomResult<Scalar>,
224{
225    if hi <= lo {
226        return Ok(0.0);
227    }
228    let mut cuts = vec![lo];
229    cuts.extend(breaks.iter().copied().filter(|&t| t > lo && t < hi));
230    cuts.push(hi);
231    // A first, coarse pass sizes the absolute tolerance to the length.
232    let mut coarse = 0.0;
233    for w in cuts.windows(2) {
234        coarse += rule(speed, w[0], w[1])?;
235    }
236    let tolerance = tolerance_for(coarse);
237    let mut total = 0.0;
238    let mut panels = 0usize;
239    for w in cuts.windows(2) {
240        let share = tolerance * (w[1] - w[0]) / (hi - lo);
241        total += adaptive(speed, w[0], w[1], share, &mut panels)?;
242    }
243    if !total.is_finite() {
244        return Err(invalid(
245            "curve speed does not integrate to a finite arc length".into(),
246        ));
247    }
248    Ok(total)
249}
250
251/// One 8-point Gauss-Legendre panel.
252fn rule<F>(speed: &F, a: Scalar, b: Scalar) -> GeomResult<Scalar>
253where
254    F: Fn(Scalar) -> GeomResult<Scalar>,
255{
256    let half = 0.5 * (b - a);
257    let mid = 0.5 * (a + b);
258    let mut sum = 0.0;
259    for (node, weight) in NODES.iter().zip(WEIGHTS.iter()) {
260        let value = speed(mid + half * node)?;
261        if !value.is_finite() {
262            return Err(invalid(format!(
263                "curve speed is not finite at parameter {}",
264                mid + half * node
265            )));
266        }
267        sum += weight * value;
268    }
269    Ok(sum * half)
270}
271
272/// Bisect `[a, b]` until each panel agrees with its two halves to its share
273/// of `tolerance`; panels are processed from a stack in increasing
274/// parameter, so the sum is deterministic.
275fn adaptive<F>(
276    speed: &F,
277    a: Scalar,
278    b: Scalar,
279    tolerance: Scalar,
280    panels: &mut usize,
281) -> GeomResult<Scalar>
282where
283    F: Fn(Scalar) -> GeomResult<Scalar>,
284{
285    let width = b - a;
286    let mut stack = vec![(a, b, rule(speed, a, b)?)];
287    let mut total = 0.0;
288    while let Some((lo, hi, whole)) = stack.pop() {
289        *panels += 1;
290        if *panels > MAX_PANELS {
291            return Err(GeomError::BudgetExceeded {
292                resource: "arc-length quadrature panels",
293            });
294        }
295        let mid = 0.5 * (lo + hi);
296        let left = rule(speed, lo, mid)?;
297        let right = rule(speed, mid, hi)?;
298        let share = tolerance * (hi - lo) / width;
299        // A panel too narrow to bisect has nothing left to refine.
300        if (left + right - whole).abs() <= share || !(mid > lo && mid < hi) {
301            total += left + right;
302        } else {
303            stack.push((mid, hi, right));
304            stack.push((lo, mid, left));
305        }
306    }
307    Ok(total)
308}
309
310fn parameter_at<F>(family: &Family, speed: &F, from: Scalar, length: Scalar) -> GeomResult<Scalar>
311where
312    F: Fn(Scalar) -> GeomResult<Scalar>,
313{
314    check_finite(from, length, "a finite arc length")?;
315    let (domain, breaks) = match family {
316        Family::Proportional(rate) => return Ok(from + length / rate),
317        Family::Numeric { domain, breaks } => (*domain, breaks),
318    };
319    if !inside(domain, from) {
320        return Err(invalid(format!(
321            "arc length is measured from parameter {from}, outside the curve's domain"
322        )));
323    }
324    if length == 0.0 {
325        return Ok(from);
326    }
327    let direction = length.signum();
328    let target = length.abs();
329    let tolerance = tolerance_for(target);
330    // `g(x)`: arc length from `from` to `from + direction * x`, x >= 0.
331    let travelled = |x: Scalar| -> GeomResult<Scalar> {
332        let to = from + direction * x;
333        integrate(speed, breaks, from.min(to), from.max(to))
334    };
335    // Bracket the root: up to the domain end, or by doubling when the
336    // curve runs on.
337    let (mut hi, mut at_hi) = match domain {
338        Some((lo, hi)) => {
339            let reach = if direction > 0.0 {
340                hi - from
341            } else {
342                from - lo
343            }
344            .max(0.0);
345            let available = travelled(reach)?;
346            if available < target - tolerance {
347                return Err(invalid(format!(
348                    "arc length {target} exceeds the {available} available along the curve \
349                     from parameter {from}"
350                )));
351            }
352            if available <= target {
353                return Ok(from + direction * reach);
354            }
355            (reach, available)
356        }
357        None => {
358            let mut reach: Scalar = 1.0;
359            let mut grown = 0;
360            loop {
361                let available = travelled(reach)?;
362                if available >= target {
363                    break (reach, available);
364                }
365                grown += 1;
366                if grown > 64 {
367                    return Err(invalid(format!(
368                        "arc length {target} is not reached along the curve from parameter {from}"
369                    )));
370                }
371                reach *= 2.0;
372            }
373        }
374    };
375    let mut lo = 0.0;
376    let mut at_lo = 0.0;
377    // Start from the linear interpolant of the bracket.
378    let mut x = hi * target / at_hi;
379    for _ in 0..MAX_ITERATIONS {
380        if !(x > lo && x < hi) {
381            x = 0.5 * (lo + hi);
382        }
383        let g = travelled(x)?;
384        let residual = g - target;
385        if residual.abs() <= tolerance {
386            // One last Newton step costs no quadrature and squares the
387            // residual; it is kept only if it stays inside the bracket.
388            let rate = speed(from + direction * x)?;
389            let polished = x - residual / rate;
390            let x = if rate.is_finite() && rate > 0.0 && polished >= lo && polished <= hi {
391                polished
392            } else {
393                x
394            };
395            return Ok(from + direction * x);
396        }
397        if residual < 0.0 {
398            lo = x;
399            at_lo = g;
400        } else {
401            hi = x;
402            at_hi = g;
403        }
404        let midpoint = 0.5 * (lo + hi);
405        if !(midpoint > lo && midpoint < hi) {
406            // The bracket has collapsed to rounding: the parameter is known
407            // as well as a float can hold it.
408            return Ok(from + direction * x);
409        }
410        let rate = speed(from + direction * x)?;
411        let newton = if rate.is_finite() && rate > 0.0 {
412            x - residual / rate
413        } else {
414            Scalar::NAN
415        };
416        x = if newton > lo && newton < hi {
417            newton
418        } else {
419            // Fall back to the secant of the bracket, then bisection.
420            let secant = lo + (target - at_lo) * (hi - lo) / (at_hi - at_lo);
421            if secant > lo && secant < hi {
422                secant
423            } else {
424                midpoint
425            }
426        };
427    }
428    Err(GeomError::BudgetExceeded {
429        resource: "arc-length inverse iterations",
430    })
431}