Skip to main content

axiolid_evaluate/
elevation.rs

1//! Heights, grades and chord bounds of elevation laws, including the profile
2//! given by its own curvature, which has no closed form (#238).
3//!
4//! # Closed forms are delegated, the intrinsic profile is integrated
5//!
6//! A polynomial or a circular vertical arc answers
7//! [`ElevationLaw::height_at`] and [`ElevationLaw::grade_at`] exactly, and
8//! this module calls those unchanged. An [`ElevationLaw::Intrinsic`]
9//! profile is a planar curve in `(d, z)` whose curvature runs along its own
10//! arc length `s`, so a height at PLAN distance `d` needs two numerical
11//! steps, both stated here:
12//!
13//! 1. the position `(d(s), z(s))` is [`intrinsic_point`] on the profile
14//!    curve, the Gauss-Legendre quadrature pinned against the Fresnel
15//!    closed form (ADR 0060);
16//! 2. `d(s) = d` is inverted by a Newton iteration safeguarded by a
17//!    bracket, since `d'(s) = cos t(s)` is exact from the closed-form
18//!    heading.
19//!
20//! # Accuracy contract
21//!
22//! The inversion stops once `|d(s) - d| <= INVERSION_TOLERANCE * max(1, d)`
23//! and corrects the height to first order by the residual times the grade.
24//! A reported height therefore carries the quadrature error of
25//! [`intrinsic_point`] plus at most `|grade| * INVERSION_TOLERANCE *
26//! max(1, d)`; a grade carries the curvature times that same arc-length
27//! residual, divided by `cos^2 t`. Measured against an independent 50-digit
28//! integration and root, a 150 m sag-to-crest clothoid agrees to within
29//! `4e-15` m in height and `2e-17` in grade; the tests pin `1e-13` and
30//! `1e-15`.
31//!
32//! # Where the plan distance stops increasing
33//!
34//! `d(s)` is monotone only while the profile's direction stays strictly
35//! inside `(-pi/2, pi/2)`. That is certified, never assumed: over the
36//! arc-length span that brackets the inverse, the heading is bounded by its
37//! closed-form value at a panel start plus the total variation of curvature
38//! over the panel, and panels are bisected until the bound stays below
39//! vertical. The root is then unique in its bracket. A profile that
40//! reaches vertical first, or that cannot be certified within the bisection
41//! budget, is refused by name rather than read on a branch of `d(s)` that
42//! runs backwards.
43
44use core::f64::consts::FRAC_PI_2;
45
46use axiolid_contracts::{GeomError, GeomResult};
47use axiolid_core::{Frame2, Point2, Scalar, Vec2};
48use axiolid_curve::{CurvatureLaw, ElevationLaw, Intrinsic2};
49
50use crate::arc_length::intrinsic_point;
51
52/// Relative stopping tolerance of the plan-distance inversion: the solve
53/// ends once `|d(s) - d| <= INVERSION_TOLERANCE * max(1, d)`.
54pub const INVERSION_TOLERANCE: Scalar = 1e-12;
55
56/// Newton steps before the inversion is refused. Quadratic convergence from
57/// `s = d` needs fewer than ten on any profile that stays below vertical;
58/// the rest is bisection headroom for a nearly vertical one.
59const MAX_INVERSION_STEPS: usize = 100;
60
61/// Bracket enlargements before an inverse is refused as unbracketable.
62const MAX_BRACKET_ROUNDS: usize = 64;
63
64/// Bisection depth of the below-vertical certificate.
65const MAX_CERTIFY_DEPTH: u32 = 24;
66
67/// Panels the below-vertical certificate may inspect in total.
68const MAX_CERTIFY_PANELS: usize = 1 << 16;
69
70/// Least fraction of `cos t` at a panel start that its certified floor may
71/// keep before the panel is bisected.
72const ACCEPT: Scalar = 15.0 / 16.0;
73
74fn invalid(detail: &str) -> GeomError {
75    GeomError::InvalidInput(detail.to_owned())
76}
77
78/// Height at plan distance `distance`, for every law in the family.
79///
80/// Closed-form laws answer through [`ElevationLaw::height_at`]; an
81/// [`ElevationLaw::Intrinsic`] piece, alone or inside a
82/// [`ElevationLaw::Piecewise`], is integrated and inverted as the module
83/// documentation states.
84///
85/// # Errors
86///
87/// [`GeomError::InvalidInput`] for a non-finite distance, a malformed law,
88/// a distance outside a law's domain (past the vertical of a circular arc,
89/// before the start of an intrinsic profile, or past where it turns
90/// vertical), and [`GeomError::BudgetExceeded`] when the below-vertical
91/// certificate or the inversion does not settle within its budget.
92pub fn elevation_height(law: &ElevationLaw, distance: Scalar) -> GeomResult<Scalar> {
93    match leaf(law, distance)? {
94        (
95            ElevationLaw::Intrinsic {
96                height,
97                grade,
98                curvature,
99            },
100            local,
101        ) => Ok(intrinsic_reading(*height, *grade, curvature, local)?.height),
102        // Every closed-form law, including any later one: the curve crate's
103        // match over its own enum is exhaustive and refuses what it cannot
104        // answer, so nothing is guessed here.
105        (closed, local) => closed
106            .height_at(local)
107            .ok_or_else(|| invalid("elevation law has no height at that distance")),
108    }
109}
110
111/// Grade `dz/dd` at plan distance `distance`, for every law in the family.
112///
113/// # Errors
114///
115/// As [`elevation_height`].
116pub fn elevation_grade(law: &ElevationLaw, distance: Scalar) -> GeomResult<Scalar> {
117    match leaf(law, distance)? {
118        (
119            ElevationLaw::Intrinsic {
120                height,
121                grade,
122                curvature,
123            },
124            local,
125        ) => Ok(intrinsic_reading(*height, *grade, curvature, local)?.grade),
126        (closed, local) => closed
127            .grade_at(local)
128            .ok_or_else(|| invalid("elevation law has no grade at that distance")),
129    }
130}
131
132/// The innermost piece at `distance`, or a named refusal.
133fn leaf(law: &ElevationLaw, distance: Scalar) -> GeomResult<(&ElevationLaw, Scalar)> {
134    if !distance.is_finite() {
135        return Err(invalid("plan distance must be finite"));
136    }
137    law.piece_at(distance)
138        .ok_or_else(|| invalid("elevation law has no piece at that distance"))
139}
140
141/// Height and grade read off an intrinsic profile.
142struct Reading {
143    height: Scalar,
144    grade: Scalar,
145    /// The profile's own arc length the reading was taken at.
146    arc: Scalar,
147}
148
149/// The profile as a plane curve: origin `(0, height)`, `x` along the start
150/// direction `atan(grade)`.
151fn profile_curve(
152    height: Scalar,
153    grade: Scalar,
154    curvature: &CurvatureLaw,
155    span: Scalar,
156) -> Intrinsic2 {
157    let norm = grade.hypot(1.0);
158    let (sin0, cos0) = (grade / norm, 1.0 / norm);
159    Intrinsic2::new(
160        Frame2 {
161            origin: Point2::new(0.0, height),
162            x: Vec2::new(cos0, sin0),
163            y: Vec2::new(-sin0, cos0),
164        },
165        curvature.clone(),
166        span,
167    )
168}
169
170/// Check the stored numbers of an intrinsic profile.
171fn checked(height: Scalar, grade: Scalar, curvature: &CurvatureLaw) -> GeomResult<()> {
172    if !(height.is_finite() && grade.is_finite()) {
173        return Err(invalid(
174            "intrinsic profile needs a finite start height and grade",
175        ));
176    }
177    if !curvature.is_well_formed() {
178        return Err(invalid("intrinsic profile curvature law is malformed"));
179    }
180    Ok(())
181}
182
183/// Height and grade of an intrinsic profile at plan distance `d >= 0`.
184fn intrinsic_reading(
185    height: Scalar,
186    grade: Scalar,
187    curvature: &CurvatureLaw,
188    d: Scalar,
189) -> GeomResult<Reading> {
190    checked(height, grade, curvature)?;
191    if d < 0.0 {
192        return Err(invalid(
193            "an intrinsic profile is defined forward from its start only",
194        ));
195    }
196    if d == 0.0 {
197        return Ok(Reading {
198            height,
199            grade,
200            arc: 0.0,
201        });
202    }
203    let theta0 = grade.atan();
204    let curve = profile_curve(height, grade, curvature, d);
205    let upper = bracket(&curve, theta0, d)?;
206
207    // d(s) <= s, so the root is at or past `d`; `upper` has d(upper) >= d.
208    let (mut lo, mut hi) = (d, upper);
209    let mut s = d;
210    let tolerance = INVERSION_TOLERANCE * d.max(1.0);
211    for _ in 0..MAX_INVERSION_STEPS {
212        let point = intrinsic_point(&curve, s)?;
213        let theta = theta0 + heading(&curve, s)?;
214        let residual = point.x - d;
215        if residual.abs() <= tolerance {
216            let slope = theta.tan();
217            // z(d) = z(s) - tan t * (d(s) - d) to first order.
218            return Ok(Reading {
219                height: point.y - slope * residual,
220                grade: slope,
221                arc: s,
222            });
223        }
224        if residual < 0.0 {
225            lo = s;
226        } else {
227            hi = s;
228        }
229        // d'(s) = cos t(s), exact from the closed-form heading.
230        let mut next = s - residual / theta.cos();
231        if !(next > lo && next < hi) {
232            next = 0.5 * (lo + hi);
233        }
234        if next == s {
235            break;
236        }
237        s = next;
238    }
239    Err(GeomError::BudgetExceeded {
240        resource: "intrinsic profile plan-distance inversion",
241    })
242}
243
244/// Closed-form heading turned through `[0, s]` of the profile curve.
245fn heading(curve: &Intrinsic2, s: Scalar) -> GeomResult<Scalar> {
246    curve
247        .heading_at(s)
248        .ok_or_else(|| invalid("curvature law does not integrate to that arc length"))
249}
250
251/// An arc length `upper` with `d(upper) >= d` (to the inversion tolerance),
252/// over whose `[0, upper]` the profile is certified below vertical, so
253/// `d(s)` increases there and the root of `d(s) = d` in `[d, upper]` is
254/// unique.
255///
256/// Steps forward as Newton does from below, `upper += (d - d(upper)) /
257/// cos t(upper)` with `d(upper)` from the quadrature, certifying only each
258/// extension. An extension that runs into the vertical is halved, since the
259/// target may still lie before it; one that cannot shrink further refuses
260/// with the certificate's reason.
261fn bracket(curve: &Intrinsic2, theta0: Scalar, d: Scalar) -> GeomResult<Scalar> {
262    cos_floor(curve, theta0, 0.0, d)?;
263    let tolerance = INVERSION_TOLERANCE * d.max(1.0);
264    let mut upper = d;
265    for _ in 0..MAX_BRACKET_ROUNDS {
266        let reached = intrinsic_point(curve, upper)?.x;
267        if reached >= d - tolerance {
268            return Ok(upper);
269        }
270        let cos = (theta0 + heading(curve, upper)?).cos();
271        // Slightly long, so a concave d(s) -- approached from below without
272        // ever crossing -- is still overtaken.
273        let mut step = (d - reached) / cos * (1.0 + 1e-6) + Scalar::EPSILON * upper.max(1.0);
274        loop {
275            match cos_floor(curve, theta0, upper, upper + step) {
276                Ok(_) => break,
277                Err(error) => {
278                    step *= 0.5;
279                    if step <= Scalar::EPSILON * upper.max(1.0) {
280                        return Err(error);
281                    }
282                }
283            }
284        }
285        upper += step;
286    }
287    Err(GeomError::BudgetExceeded {
288        resource: "intrinsic profile plan-distance bracket",
289    })
290}
291
292/// Certified lower bound of `cos t(s)` over `[lo, hi]`, where
293/// `t = theta0 + heading`: the certificate that the profile stays below
294/// vertical there.
295///
296/// On a panel `[a, b]` the heading lies within `t(a) +- V`, `V` the total
297/// variation bound of curvature over the panel, so `cos t >= cos(|t(a)| +
298/// V)` there. A panel is accepted once that reach stays below `pi/2` and
299/// keeps at least [`ACCEPT`] of `cos t(a)`, which keeps the floor close to
300/// the true least `cos`; otherwise it is bisected, and at the depth limit it
301/// is accepted on the first condition alone. A panel start at or past
302/// vertical refuses, and so does a panel at the depth limit whose reach
303/// still touches vertical: the profile meets vertical within the
304/// certificate's resolution there.
305fn cos_floor(curve: &Intrinsic2, theta0: Scalar, lo: Scalar, hi: Scalar) -> GeomResult<Scalar> {
306    let vertical = || invalid("the profile turns vertical before reaching that plan distance");
307    let mut floor: Scalar = 1.0;
308    let mut stack = vec![(lo, hi, 0_u32)];
309    let mut inspected = 0_usize;
310    while let Some((a, b, depth)) = stack.pop() {
311        inspected += 1;
312        if inspected > MAX_CERTIFY_PANELS {
313            return Err(GeomError::BudgetExceeded {
314                resource: "intrinsic profile below-vertical certificate",
315            });
316        }
317        let theta_a = theta0 + heading(curve, a)?;
318        if theta_a.is_nan() || theta_a.abs() >= FRAC_PI_2 {
319            return Err(vertical());
320        }
321        let shifted = curve
322            .curvature
323            .shifted(a)
324            .ok_or_else(|| invalid("curvature law cannot be read from that arc length"))?;
325        let variation = Intrinsic2::new(curve.start, shifted, b - a)
326            .turning_variation_bound(b - a)
327            .ok_or_else(|| invalid("curvature law does not integrate over the span"))?;
328        let reach = theta_a.abs() + variation;
329        let panel_floor = reach.cos();
330        let below_vertical = reach < FRAC_PI_2 && panel_floor > 0.0;
331        if below_vertical && (panel_floor >= ACCEPT * theta_a.cos() || depth >= MAX_CERTIFY_DEPTH) {
332            floor = floor.min(panel_floor);
333            continue;
334        }
335        if depth >= MAX_CERTIFY_DEPTH {
336            return Err(vertical());
337        }
338        let mid = 0.5 * (a + b);
339        stack.push((mid, b, depth + 1));
340        stack.push((a, mid, depth + 1));
341    }
342    Ok(floor)
343}
344
345/// Certified bound on how far an elevation law strays from the chord of
346/// its heights over the plan-distance span `[a, b]`:
347/// `sup |z(d) - L(d)|`, `L` the straight line through `(a, z(a))` and
348/// `(b, z(b))`.
349///
350/// This is the vertical half of flattening an elevated curve:
351/// `(b - a)^2 / 8 * sup |z''|` with `z'' = k / cos^3 t` for a curve in
352/// `(d, z)` of curvature `k`, bounded in closed form per law:
353///
354/// - polynomial: `sum i (i - 1) |c_i| m^(i - 2)`, `m = max(|a|, |b|)`;
355/// - circular arc: `1 / (|R| cos^3 t)`, `cos t` least at the span end with
356///   the larger `|sin t|`, since `sin t` is linear in `d`;
357/// - intrinsic: the curvature supremum over a certified arc-length span
358///   containing `[s(a), s(b)]`, over the cube of the certified `cos t`
359///   floor there.
360///
361/// `None` -- unbounded, never a guess -- when the span is not finite, a
362/// piecewise seam lies strictly inside it (the grade may jump there), the
363/// span leaves a law's domain, or the law or its curvature law is a family
364/// this function does not bound.
365#[must_use]
366pub fn elevation_chord_bound(law: &ElevationLaw, a: Scalar, b: Scalar) -> Option<Scalar> {
367    if !(a.is_finite() && b.is_finite()) {
368        return None;
369    }
370    let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
371    if lo == hi {
372        return Some(0.0);
373    }
374    let second = second_derivative_bound(law, lo, hi)?;
375    let h = hi - lo;
376    let bound = h * h * 0.125 * second;
377    // A few ulps of slack for the rounding in the bound's own arithmetic.
378    Some(bound * (1.0 + 1e-12)).filter(|b| b.is_finite())
379}
380
381/// `sup |z''|` over `[lo, hi]`, or `None` when not bounded.
382fn second_derivative_bound(law: &ElevationLaw, lo: Scalar, hi: Scalar) -> Option<Scalar> {
383    match law {
384        ElevationLaw::Polynomial { coefficients } => {
385            let m = lo.abs().max(hi.abs());
386            Some(
387                coefficients
388                    .iter()
389                    .enumerate()
390                    .skip(2)
391                    .map(|(i, c)| {
392                        let i_f = i as Scalar;
393                        i_f * (i_f - 1.0) * c.abs() * m.powi(i as i32 - 2)
394                    })
395                    .sum(),
396            )
397            .filter(|s: &Scalar| s.is_finite())
398        }
399        ElevationLaw::Piecewise { breaks, laws } => {
400            if laws.len() != breaks.len() + 1 {
401                return None;
402            }
403            // The piece owning the span; it must contain both ends.
404            let mid = 0.5 * (lo + hi);
405            let index = breaks.partition_point(|b| *b <= mid);
406            let start = if index == 0 { 0.0 } else { breaks[index - 1] };
407            let lower = if index == 0 {
408                Scalar::NEG_INFINITY
409            } else {
410                start
411            };
412            let upper = breaks.get(index).copied().unwrap_or(Scalar::INFINITY);
413            if lo < lower || hi > upper {
414                return None;
415            }
416            second_derivative_bound(laws.get(index)?, lo - start, hi - start)
417        }
418        ElevationLaw::CircularArc { grade, radius, .. } => {
419            if !(grade.is_finite() && radius.is_finite() && *radius != 0.0) {
420                return None;
421            }
422            let sin0 = grade / grade.hypot(1.0);
423            let reach = (sin0 + lo / radius).abs().max((sin0 + hi / radius).abs());
424            if reach.is_nan() || reach >= 1.0 {
425                return None;
426            }
427            let cos = ((1.0 - reach) * (1.0 + reach)).sqrt();
428            Some(1.0 / (radius.abs() * cos * cos * cos)).filter(|s| s.is_finite())
429        }
430        ElevationLaw::Intrinsic {
431            height,
432            grade,
433            curvature,
434        } => {
435            checked(*height, *grade, curvature).ok()?;
436            if lo < 0.0 {
437                return None;
438            }
439            // s(lo) >= lo because d(s) <= s, and `bracket` returns an arc
440            // length past s(hi); the span read is [s(lo), s(hi)] inside
441            // [lo, upper], and the floor is certified over that.
442            let theta0 = grade.atan();
443            let curve = profile_curve(*height, *grade, curvature, hi);
444            // Bracket a little past `hi`, so the bracket's own tolerance
445            // cannot leave s(hi) outside it.
446            let past = hi + 2.0 * INVERSION_TOLERANCE * hi.max(1.0);
447            let upper = bracket(&curve, theta0, past).ok()?;
448            let floor = cos_floor(&curve, theta0, lo, upper).ok()?;
449            let kappa = curvature_sup(&curvature.shifted(lo)?, upper - lo)?;
450            Some(kappa / (floor * floor * floor)).filter(|s| s.is_finite())
451        }
452        // A later elevation family this function does not know: unbounded.
453        _ => None,
454    }
455}
456
457/// Upper bound on `|k(s)|` over `[0, span]`, `span >= 0`.
458fn curvature_sup(law: &CurvatureLaw, span: Scalar) -> Option<Scalar> {
459    let polynomial = |coefficients: &[Scalar]| -> Scalar {
460        coefficients
461            .iter()
462            .enumerate()
463            .map(|(i, c)| c.abs() * span.powi(i as i32))
464            .sum()
465    };
466    let sup = match law {
467        CurvatureLaw::Constant { curvature } => curvature.abs(),
468        // A linear law (the clothoid) peaks at an end: exact.
469        CurvatureLaw::Polynomial { coefficients } if coefficients.len() <= 2 => {
470            let start = coefficients.first().copied().unwrap_or(0.0);
471            let rate = coefficients.get(1).copied().unwrap_or(0.0);
472            start.abs().max((start + rate * span).abs())
473        }
474        CurvatureLaw::Polynomial { coefficients } => polynomial(coefficients),
475        CurvatureLaw::Sinusoid {
476            mean, amplitude, ..
477        } => mean.abs() + amplitude.abs(),
478        CurvatureLaw::Composite {
479            polynomial: terms,
480            harmonics,
481        } => polynomial(terms) + harmonics.iter().map(|h| h.amplitude.abs()).sum::<Scalar>(),
482        CurvatureLaw::Piecewise { breaks, laws } => {
483            if !law.is_well_formed() {
484                return None;
485            }
486            let mut sup: Scalar = 0.0;
487            let mut start = 0.0;
488            for (index, piece) in laws.iter().enumerate() {
489                let end = breaks.get(index).copied().unwrap_or(span).min(span);
490                sup = sup.max(curvature_sup(piece, (end - start).max(0.0))?);
491                if end >= span {
492                    break;
493                }
494                start = end;
495            }
496            sup
497        }
498        // A later curvature family this function does not know: unbounded.
499        _ => return None,
500    };
501    Some(sup).filter(|s| s.is_finite())
502}
503
504// --- second derivatives, derivative bounds and seams (#252) ------------------
505
506/// `z''(d)`, the second derivative of height in plan distance, for every
507/// law in the family: `sum i (i - 1) c_i d^(i - 2)` for a polynomial,
508/// `1 / (R cos^3 t)` for a circular arc, `k(s) / cos^3 t` for an intrinsic
509/// profile read at the arc length `s` its height is read at.
510///
511/// # Errors
512///
513/// As [`elevation_height`].
514pub(crate) fn elevation_second(law: &ElevationLaw, distance: Scalar) -> GeomResult<Scalar> {
515    let value = match leaf(law, distance)? {
516        (ElevationLaw::Polynomial { coefficients }, d) => coefficients
517            .iter()
518            .enumerate()
519            .skip(2)
520            .rev()
521            .fold(0.0, |accumulated, (i, c)| {
522                accumulated * d + c * (i * (i - 1)) as Scalar
523            }),
524        (ElevationLaw::CircularArc { grade, radius, .. }, d) => {
525            let sin = arc_sin(*grade, *radius, d)
526                .ok_or_else(|| invalid("elevation law has no height at that distance"))?;
527            let cos = ((1.0 - sin) * (1.0 + sin)).sqrt();
528            1.0 / (radius * cos * cos * cos)
529        }
530        (
531            ElevationLaw::Intrinsic {
532                height,
533                grade,
534                curvature,
535            },
536            d,
537        ) => {
538            let reading = intrinsic_reading(*height, *grade, curvature, d)?;
539            let kappa = crate::frenet::law_value(curvature, reading.arc)
540                .ok_or_else(|| invalid("intrinsic profile curvature is not defined there"))?;
541            let secant = reading.grade.hypot(1.0);
542            kappa * secant * secant * secant
543        }
544        _ => {
545            return Err(invalid(
546                "elevation law family has no second derivative here",
547            ))
548        }
549    };
550    if value.is_finite() {
551        Ok(value)
552    } else {
553        Err(invalid("elevation second derivative is not finite"))
554    }
555}
556
557/// `sin t(d) = sin t0 + d / R` of a circular arc, inside its domain.
558fn arc_sin(grade: Scalar, radius: Scalar, d: Scalar) -> Option<Scalar> {
559    if !(grade.is_finite() && radius.is_finite() && radius != 0.0 && d.is_finite()) {
560        return None;
561    }
562    let sin = grade / grade.hypot(1.0) + d / radius;
563    (sin.abs() < 1.0).then_some(sin)
564}
565
566/// Certified `(sup |z'|, sup |z''|, sup |z'''|)` over the plan-distance
567/// span `[lo, hi]`, or `None` -- unbounded, never a guess -- where
568/// [`elevation_chord_bound`] would refuse, and also where an intrinsic
569/// profile's curvature law has a seam in the span (its `z'''` jumps
570/// there) or a family whose derivative is not bounded here.
571///
572/// Per law, with `m = max(|lo|, |hi|)` in the piece's own distance:
573///
574/// - polynomial: `sum i!/(i-k)! |c_i| m^(i-k)` for `k = 1, 2, 3`;
575/// - circular arc, `sin t` linear in `d` so `|sin t|` peaks at an end:
576///   `z' = tan t`, `z'' = 1 / (R cos^3 t)`, `z''' = 3 sin t / (R^2 cos^5 t)`
577///   (from `cos t t' = 1 / R`);
578/// - intrinsic, `d/dd = (1 / cos t) d/ds` and `t' = k` in its own arc
579///   length: `z'' = k / cos^3 t`, `z''' = k' / cos^4 t + 3 k^2 sin t /
580///   cos^5 t`, over the certified `cos t` floor and curvature suprema of
581///   [`elevation_chord_bound`]'s span.
582pub(crate) fn profile_bounds(law: &ElevationLaw, lo: Scalar, hi: Scalar) -> Option<[Scalar; 3]> {
583    if !(lo.is_finite() && hi.is_finite()) || lo > hi {
584        return None;
585    }
586    let out = match law {
587        ElevationLaw::Polynomial { coefficients } => {
588            let m = lo.abs().max(hi.abs());
589            let term = |k: usize| -> Scalar {
590                coefficients
591                    .iter()
592                    .enumerate()
593                    .skip(k)
594                    .map(|(i, c)| {
595                        let falling: Scalar = (0..k).map(|j| (i - j) as Scalar).product();
596                        falling * c.abs() * m.powi((i - k) as i32)
597                    })
598                    .sum()
599            };
600            [term(1), term(2), term(3)]
601        }
602        ElevationLaw::Piecewise { breaks, laws } => {
603            if laws.len() != breaks.len() + 1 {
604                return None;
605            }
606            let mid = 0.5 * (lo + hi);
607            let index = breaks.partition_point(|b| *b <= mid);
608            let start = if index == 0 { 0.0 } else { breaks[index - 1] };
609            let lower = if index == 0 {
610                Scalar::NEG_INFINITY
611            } else {
612                start
613            };
614            let upper = breaks.get(index).copied().unwrap_or(Scalar::INFINITY);
615            if lo < lower || hi > upper {
616                return None;
617            }
618            return profile_bounds(laws.get(index)?, lo - start, hi - start);
619        }
620        ElevationLaw::CircularArc { grade, radius, .. } => {
621            if !(grade.is_finite() && radius.is_finite() && *radius != 0.0) {
622                return None;
623            }
624            let sin0 = grade / grade.hypot(1.0);
625            let reach = (sin0 + lo / radius).abs().max((sin0 + hi / radius).abs());
626            if reach.is_nan() || reach >= 1.0 {
627                return None;
628            }
629            let cos = ((1.0 - reach) * (1.0 + reach)).sqrt();
630            let r = radius.abs();
631            [
632                reach / cos,
633                1.0 / (r * cos.powi(3)),
634                3.0 * reach / (r * r * cos.powi(5)),
635            ]
636        }
637        ElevationLaw::Intrinsic {
638            height,
639            grade,
640            curvature,
641        } => {
642            checked(*height, *grade, curvature).ok()?;
643            if lo < 0.0 {
644                return None;
645            }
646            let theta0 = grade.atan();
647            let profile = profile_curve(*height, *grade, curvature, hi);
648            let past = hi + 2.0 * INVERSION_TOLERANCE * hi.max(1.0);
649            let upper = bracket(&profile, theta0, past).ok()?;
650            // `z'''` carries `k'`, which a seam of the curvature law turns
651            // into a jump: refuse a span that may contain one.
652            if curvature
653                .seams_within(upper)
654                .iter()
655                .any(|&seam| seam > lo && seam < upper)
656            {
657                return None;
658            }
659            let floor = cos_floor(&profile, theta0, lo, upper).ok()?;
660            let kappa = curvature_sup(&curvature.shifted(lo)?, upper - lo)?;
661            let rate = curvature_sup(&curvature.derivative().shifted(lo)?, upper - lo)?;
662            let sin = ((1.0 - floor) * (1.0 + floor)).max(0.0).sqrt();
663            [
664                sin / floor,
665                kappa / floor.powi(3),
666                rate / floor.powi(4) + 3.0 * kappa * kappa * sin / floor.powi(5),
667            ]
668        }
669        _ => return None,
670    };
671    let out = out.map(|v| v * (1.0 + 1e-12));
672    out.iter().all(|v| v.is_finite()).then_some(out)
673}
674
675/// Certified `(sup |z'|, sup |z''|, sup |z'''|, sup |z''''|)` over the
676/// plan-distance span `[lo, hi]` (#294), or `None` -- unbounded, never a
677/// guess -- across a seam, and for an intrinsic profile, whose fourth
678/// derivative is not bounded here.
679///
680/// As [`profile_bounds`], one order further: a polynomial's
681/// `sum i!/(i-4)! |c_i| m^(i-4)`, and a circular arc's `z'''' = 3 (1 + 4
682/// sin^2 t) / (R^3 cos^7 t)`, largest where `|sin t|` is, at an end.
683pub(crate) fn profile_bounds4(law: &ElevationLaw, lo: Scalar, hi: Scalar) -> Option<[Scalar; 4]> {
684    if !(lo.is_finite() && hi.is_finite()) || lo > hi {
685        return None;
686    }
687    let out = match law {
688        ElevationLaw::Polynomial { coefficients } => {
689            let m = lo.abs().max(hi.abs());
690            let term = |k: usize| -> Scalar {
691                coefficients
692                    .iter()
693                    .enumerate()
694                    .skip(k)
695                    .map(|(i, c)| {
696                        let falling: Scalar = (0..k).map(|j| (i - j) as Scalar).product();
697                        falling * c.abs() * m.powi((i - k) as i32)
698                    })
699                    .sum()
700            };
701            [term(1), term(2), term(3), term(4)]
702        }
703        ElevationLaw::Piecewise { breaks, laws } => {
704            if laws.len() != breaks.len() + 1 {
705                return None;
706            }
707            let mid = 0.5 * (lo + hi);
708            let index = breaks.partition_point(|b| *b <= mid);
709            let start = if index == 0 { 0.0 } else { breaks[index - 1] };
710            let lower = if index == 0 {
711                Scalar::NEG_INFINITY
712            } else {
713                start
714            };
715            let upper = breaks.get(index).copied().unwrap_or(Scalar::INFINITY);
716            if lo < lower || hi > upper {
717                return None;
718            }
719            return profile_bounds4(laws.get(index)?, lo - start, hi - start);
720        }
721        ElevationLaw::CircularArc { grade, radius, .. } => {
722            if !(grade.is_finite() && radius.is_finite() && *radius != 0.0) {
723                return None;
724            }
725            let sin0 = grade / grade.hypot(1.0);
726            let reach = (sin0 + lo / radius).abs().max((sin0 + hi / radius).abs());
727            if reach.is_nan() || reach >= 1.0 {
728                return None;
729            }
730            let cos = ((1.0 - reach) * (1.0 + reach)).sqrt();
731            let r = radius.abs();
732            [
733                reach / cos,
734                1.0 / (r * cos.powi(3)),
735                3.0 * reach / (r * r * cos.powi(5)),
736                3.0 * (1.0 + 4.0 * reach * reach) / (r * r * r * cos.powi(7)),
737            ]
738        }
739        _ => return None,
740    };
741    let out = out.map(|v| v * (1.0 + 1e-12));
742    out.iter().all(|v| v.is_finite()).then_some(out)
743}
744
745/// Every seam of a law in plan distance, nested piecewise laws included,
746/// ascending: where its grade may jump.
747pub(crate) fn elevation_seams(law: &ElevationLaw) -> Vec<Scalar> {
748    fn collect(law: &ElevationLaw, offset: Scalar, out: &mut Vec<Scalar>) {
749        if let ElevationLaw::Piecewise { breaks, laws } = law {
750            for (index, piece) in laws.iter().enumerate() {
751                let start = if index == 0 { 0.0 } else { breaks[index - 1] };
752                collect(piece, offset + start, out);
753            }
754            out.extend(breaks.iter().map(|b| offset + b));
755        }
756    }
757    let mut out = Vec::new();
758    if law.is_well_formed() {
759        collect(law, 0.0, &mut out);
760    }
761    out.sort_by(Scalar::total_cmp);
762    out.dedup();
763    out
764}
765
766/// The grade arriving at `distance` from below: the piece that ENDS
767/// there, where [`elevation_grade`] reads the one that starts there.
768///
769/// # Errors
770///
771/// As [`elevation_grade`].
772pub(crate) fn elevation_grade_before(law: &ElevationLaw, distance: Scalar) -> GeomResult<Scalar> {
773    match law {
774        ElevationLaw::Piecewise { breaks, laws } => {
775            if laws.len() != breaks.len() + 1 {
776                return Err(invalid("elevation law piece list is malformed"));
777            }
778            let index = breaks.partition_point(|b| *b < distance);
779            let start = if index == 0 { 0.0 } else { breaks[index - 1] };
780            elevation_grade_before(&laws[index], distance - start)
781        }
782        other => elevation_grade(other, distance),
783    }
784}