Skip to main content

axiolid_evaluate/station/
frame_bound.rs

1//! Certified bounds on a section frame law's derivatives in station
2//! distance (#247, ADR 0082 amendment).
3//!
4//! A run of sections placed at stations ([the parent module](super)) puts
5//! a section point at `A(s) = O(s) + x(s) L(s) + y(s) U(s) + z(s) T(s)`:
6//! the basis point `O` and the section frame (tangent `T`, lateral `L`,
7//! up `U`) at the distance `s`, with local coordinates linear in `s`
8//! between two stations. A chord of `A` is within `h^2 / 8 sup |A''|` of
9//! it (`crate::bound`), and
10//!
11//! `|A''| <= |O''| + |x| |L''| + |y| |U''| + |z| |T''| + 2 (|x'| |L'| + |y'| |U'| + |z'| |T'|)`,
12//!
13//! so a run is bounded once `|O'|, |O''|` and the first two derivatives of
14//! each axis are, over a span of distance. [`piece_frame_bounds`] returns
15//! them for a span inside one [`StationPiece`] that crosses no seam of its
16//! curve, or `None` where they are not certified.
17//!
18//! # Where the bounds come from
19//!
20//! Everything is derived in the piece's own station measure `s` from the
21//! curve's tangent law: `k1 >= sup |T'|`, `k2 >= sup |T''|`.
22//!
23//! - A line and a polyline's segment: `T` constant, `k1 = k2 = 0`.
24//! - A circle of radius `r` with an orthonormal frame, by arc length:
25//!   `|O''| = k1 = 1 / r`, `k2 = 1 / r^2`.
26//! - An elevated curve (plan a line or a circle, profile of closed-form
27//!   pieces) and a banked curve, by plan distance, which is their
28//!   parameter: with `D_j >= sup |c^(j)|` (`crate::elevated`) and `sigma`
29//!   a lower bound of `|c'|` over the span (`|c'(mid)| - h/2 D_2`), the
30//!   unit tangent has `k1 = D_2 / sigma` and `k2 = 2 D_3 / sigma + 6 D_2^2
31//!   / sigma^2`, the bounds a swept disk's wall rests on (#252).
32//!
33//! The axes follow from `T` through one rule: a unit vector `w / |w|` with
34//! `|w| >= rho` has `|u'| <= |w'| / rho` and `|u''| <= 2 |w''| / rho + 6
35//! |w'|^2 / rho^2` (`super::certify`). `rho` is the horizontal length of
36//! `T` at the middle of the span less `h/2 k1`, a lower bound over it.
37//!
38//! - A 2D curve's planar frame: `L` is `T` turned a quarter, `U = +Z`.
39//! - The reference-up frame: `R = (T x Z) / |T x Z|` with `|T x Z| =
40//!   |T_xy| >= rho` gives `m1 = k1 / rho`, `m2 = 2 k2 / rho + 6 k1^2 /
41//!   rho^2`; `L = -R`, and `U = R x T` has `|U'| <= m1 + k1`, `|U''| <=
42//!   m2 + 2 m1 k1 + k2`.
43//! - A banked curve's rolled section: `n` the horizontal left normal
44//!   (bounds `m1`, `m2` as `R`), `u = T x n` (bounds `k1 + m1`, `k2 + 2 k1
45//!   m1 + m2`), `L = cos(phi) n + sin(phi) u` and `U = -sin(phi) n +
46//!   cos(phi) u` for the roll `phi`. With `a_j = hypot(|n^(j)|, |u^(j)|)`
47//!   (Cauchy-Schwarz over `cos`, `sin`) and `p_j >= sup |phi^(j)|`, both
48//!   have `|X'| <= p1 + a1` and `|X''| <= p2 + p1^2 + 2 p1 a1 + a2`. The
49//!   roll is `asin(g)`, `g = D / b` (or `D sqrt(1 + G^2) / b` under the
50//!   vertical-rise convention, `G` the grade), so `|phi'| <= |g'| / c` and
51//!   `|phi''| <= |g''| / c + |g| |g'|^2 / c^3` with `c = sqrt(1 - sup
52//!   |g|^2)`, from the cant law's certified derivative suprema
53//!   (`crate::banked`).
54//! - The plan reading ([`FrameReading::Plan`]): `T_p = T_xy / |T_xy|` has
55//!   the bounds `m1`, `m2`, `L_p` its quarter turn, `U = +Z`.
56//!
57//! A reversed piece negates `T` and `L`, which keeps every magnitude; a
58//! placement that keeps `+Z` moves the frame rigidly (its stretch, a hair
59//! above one, scales the bounds); one that tilts `+Z` reads the
60//! reference-up frame of the moved tangent ([`SectionFrame::carried`]),
61//! bounded as above with `rho` from the moved tangent, and a plan-measured
62//! piece so placed is not bounded.
63//!
64//! # The measure
65//!
66//! [`FrameLawBounds::measure`] bounds how far the distance a frame is read
67//! at may lie from the one asked for. A line, an elevated and a banked
68//! curve read their parameter from the distance in closed form: `0`. A
69//! circle with an orthonormal frame and a polyline read it by the
70//! arc-length inverse ([`crate::arc_parameter`]); their speed is constant
71//! on every panel (a polyline's panels split at its vertices), so the
72//! Gauss-Legendre rule is exact up to rounding and the root find stops
73//! within its tolerance: twice [`ARC_LENGTH_TOLERANCE`]` * max(1, s)`.
74//!
75//! # The signed bend (#361)
76//!
77//! The derivative suprema bound how far a section moves, not whether the
78//! section planes keep advancing. [`FrameLawBounds::forward`] bounds the
79//! forward rate `O'.N` of the basis point from below (one in arc length and
80//! in plan distance, the horizontal part of the tangent under the plan
81//! reading of an arc-length space curve, times a placement's smallest
82//! column length), and [`FrameLaw::bend`] bounds it together with the
83//! tangent's turning toward the lateral and the up axis, `L.N'` and `U.N'`:
84//! a section point `(a, b)` advances at `O'.N - a L.N' - b U.N'` plus its
85//! longitudinal rate, and the planes fold where that is not positive. The
86//! `bend` module states the closed forms.
87//!
88//! # Read by quadrature (#316)
89//!
90//! An ellipse, a B-spline and an offset beside a line, a circle, an
91//! ellipse or a B-spline read their frames through estimates (the
92//! arc-length inverse, an offset's own length table, an offset's tangent by
93//! a difference quotient), so [`piece_frame_bounds`] leaves them unbounded.
94//! [`FrameLaw`] bounds them against their EXACT frame law instead: the
95//! curve the length certificates of [`super::certify`] measure, framed by
96//! its own tangent at its true arc length from the piece's start. Each read
97//! frame stands at a native parameter where the exact curve's point and
98//! velocity are known in closed form to a certified error; the read
99//! point's and each read axis's distance from the exact frame there are
100//! computed and carried as [`FrameLawBounds::point_error`] and
101//! [`FrameLawBounds::axis_error`], and the true arc length to that
102//! parameter, bounded by chord panels over the piece measured once, gives
103//! the measure. Over a span between two reads the exact law is bounded in
104//! its own arc length from the certified suprema of `|P''|` and `|P'''|`
105//! (the base's `|B''|` to `|B''''|` beside an offset, and the frame's unit
106//! vectors) and a floor of `|P'|`: `|O''| = |T'| <= M2 / sigma^2` and
107//! `|T''| <= (M3 / sigma + 3 M2^2 / sigma^2) / sigma^2 + M2^2 / sigma^4`.
108//! The axes follow from the tangent as above. On a composite, a piece after
109//! one measured by quadrature starts where the kernel sums the lengths
110//! before it; its distance from the certified joint
111//! ([`super::CompositeBasis::joint_intervals`]) adds to the measure.
112//!
113//! # Intrinsic curves (#361)
114//!
115//! An intrinsic curve's heading is the closed-form integral of its
116//! curvature law, so over a seam-free span `|T'| <= sup |k|` and `|T''| <=
117//! sup |k'| + sup k^2` in arc length, from the law's own suprema
118//! (`crate::bound::intrinsic_derivative_bounds2`), and the axes follow by
119//! the rules above. An elevated curve over an intrinsic plan composes them
120//! with the profile in plan distance, as over a line or a circle, and a
121//! banked curve over one adds the roll bounded from its cant law (#396,
122//! `Kind::Banked`). Only the
123//! POINT is a quadrature, with no error bound of its own, so
124//! [`piece_frame_bounds`] leaves these curves unbounded and [`FrameLaw`]
125//! compares every frame it reads with the exact one, as for the curves
126//! read by quadrature: the exact point from a certified
127//! [`IntrinsicPositionTable`](crate::bound::IntrinsicPositionTable), the
128//! exact axes from the closed-form heading. The measure is exact. The
129//! families are a 2D intrinsic curve, a level planar intrinsic space curve
130//! and an elevated or banked curve over an intrinsic plan with a
131//! closed-form profile (a banked curve's cant without a held-rail piece),
132//! each with an orthonormal start frame and any curvature law
133//! with closed-form bounds of `k` to `k'''`.
134//!
135//! # One station (#405)
136//!
137//! A part placed at ONE station rests on one frame, not a run of them:
138//! [`FrameLaw::error_at`] charges the frame read at a distance against the
139//! exact frame law's there, its point and each axis ([`StationFrameError`]).
140//!
141//! The frame is read as [`StationPiece::section_on`] reads it: the piece a
142//! composite names at `distance` from `side`, a seam of the curve inside it
143//! read from the side named (#263). Two charges make the error:
144//!
145//! - **The read** (`e_p`, `e_a`). A slot read in closed form (a circle, a
146//!   polyline, an elevated or banked curve over a line or a circle) has no
147//!   error of its own but the rounding of the read: [`POINT_ROUNDING`](super::POINT_ROUNDING)
148//!   of the point's largest coordinate (one at least), and `1e-14` per axis. An intrinsic curve's slot (a 2D curve, a level planar space
149//!   curve, an elevated or banked curve over an intrinsic plan) compares the read
150//!   frame with the exact one, as [`FrameLaw::bounds`] does for a run: both
151//!   sides of a seam of its law are compared, which covers the one named.
152//! - **The distance's resolution** (`delta`, the bounds' `measure`). Where
153//!   the distance is resolved by the arc-length inverse (a circle, a
154//!   polyline) the read stands within `delta` of the distance asked for,
155//!   and on a composite a piece after pieces measured by quadrature adds
156//!   its start's certified distance from the true joint. The exact frame at
157//!   the distance asked for is then within `delta |O'|` of the read point
158//!   and `delta max(|T'|, |L'|, |U'|)` of each read axis, the suprema taken
159//!   over `[u - 2 delta, u + 2 delta]` on the side read: where the station
160//!   is ON a seam it is clipped to the side named. A curve whose distance is
161//!   exact (a line, an elevated or banked curve, an intrinsic curve) has
162//!   `delta = 0`.
163//!
164//! Not charged, `None`: a slot read by quadrature or unbounded, a composite
165//! piece placed inexactly, a seam or a joint within `2 delta` of the
166//! station that it is not on (the exact frame might stand on its other
167//! side), and a result that is not finite.
168//!
169//! # Not bounded
170//!
171//! `None`, never a guess: a chain, and an offset beside one of them, beside
172//! an intrinsic, an elevated or a banked curve; a banked curve over a
173//! chain plan; an intrinsic space curve with torsion or out of
174//! a level plane; an intrinsic curve with a stretched or skewed start
175//! frame; a circle with a skewed frame; an elevated or banked curve with
176//! another plan, an intrinsic profile or a held-rail cant over an intrinsic plan;
177//! a span that crosses a seam, a vertex of a polyline
178//! or a piece of a law; a tangent too steep for a horizontal lower bound;
179//! a cant whose roll reaches a quarter turn; a curve read by quadrature
180//! placed in a frame that tilts `+Z`, and a placed offset piece.
181//! [`piece_frame_bounds`] alone also leaves an ellipse, a B-spline and
182//! every offset unbounded: they are [`FrameLaw`]'s.
183//!
184//! [`SectionFrame::carried`]: super::SectionFrame::carried
185
186use std::cell::OnceCell;
187
188use axiolid_contracts::GeomResult;
189use axiolid_core::{Scalar, Transform3, Vec2, Vec3};
190use axiolid_curve::{Curve2, Curve3};
191
192use super::certify::{checked_tolerance, reads_intrinsic, IntrinsicReader, PieceReader};
193use super::offset::OffsetTable;
194use super::{CompositeBasis, DistanceConvention, StationCurve, StationPiece, KEEPS_UP_TOLERANCE};
195use crate::arc_parameter::ARC_LENGTH_TOLERANCE;
196use crate::bound::ROUNDING;
197
198mod bend;
199mod error;
200
201pub use bend::BendBounds;
202pub use error::StationFrameError;
203
204/// Which section frame a run places its sections in.
205#[non_exhaustive]
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub enum FrameReading {
208    /// The curve's section frame ([`super::SectionFrame`]).
209    Section,
210    /// The plan frame ([`super::SectionFrame::plan`]).
211    Plan,
212}
213
214/// Certified suprema, over a span of station distance, of the derivatives
215/// of a section frame law in that distance; see the
216/// [module documentation](self).
217#[non_exhaustive]
218#[derive(Debug, Clone, Copy, PartialEq)]
219pub struct FrameLawBounds {
220    /// `sup |O'|` and `sup |O''|` of the basis point.
221    pub point: [Scalar; 2],
222    /// `sup |T'|` and `sup |T''|` of the unit tangent.
223    pub tangent: [Scalar; 2],
224    /// `sup |L'|` and `sup |L''|` of the lateral axis.
225    pub lateral: [Scalar; 2],
226    /// `sup |U'|` and `sup |U''|` of the up axis.
227    pub up: [Scalar; 2],
228    /// How far the distance a frame is read at may lie from the distance
229    /// asked for (see [the measure](self#the-measure)).
230    pub measure: Scalar,
231    /// How far a frame's point read at a distance may lie from the exact
232    /// frame law's point at the distance it truly stands at: zero where
233    /// the frame is read in closed form, a certified bound where it is
234    /// read by quadrature ([`FrameLaw`], #316).
235    pub point_error: Scalar,
236    /// How far each of a read frame's axes may lie from the exact frame
237    /// law's, likewise.
238    pub axis_error: Scalar,
239    /// A certified lower bound over the span of `O'.N`, `N` the frame's
240    /// tangent: one in arc length and in plan distance, the horizontal
241    /// floor `rho` under the plan reading of an arc-length space curve,
242    /// times a placement's smallest column length, less rounding (#361).
243    pub forward: Scalar,
244}
245
246impl FrameLawBounds {
247    /// Whether the frame is constant and the point moves on a straight
248    /// line: every second derivative, and every axis's first, is zero.
249    #[must_use]
250    pub fn is_straight(&self) -> bool {
251        self.point[1] == 0.0
252            && self.tangent == [0.0; 2]
253            && self.lateral == [0.0; 2]
254            && self.up == [0.0; 2]
255    }
256}
257
258/// How a curve's frame is built from its tangent.
259#[derive(Debug, Clone, Copy)]
260enum Kind {
261    /// A 2D curve's frame: the tangent, its quarter turn, `+Z`.
262    Planar,
263    /// The reference-up frame against `+Z`.
264    ReferenceUp,
265    /// A banked curve's rolled section, with the roll's derivative bounds.
266    Banked([Scalar; 2]),
267}
268
269/// A curve's tangent law over a span.
270#[derive(Debug, Clone, Copy)]
271struct TangentLaw {
272    point: [Scalar; 2],
273    tangent: [Scalar; 2],
274    /// The unit tangent at the middle of the span.
275    middle: Vec3,
276    kind: Kind,
277    measure: Scalar,
278}
279
280/// The measure slack of a distance read by the arc-length inverse on a
281/// speed constant per panel.
282fn numerical_measure(hi: Scalar) -> Scalar {
283    2.0 * ARC_LENGTH_TOLERANCE * hi.abs().max(1.0)
284}
285
286fn unit(v: Vec3) -> Option<Vec3> {
287    let length = v.length();
288    (length.is_finite() && length > 0.0).then(|| v / length)
289}
290
291fn lift(v: Vec2) -> Vec3 {
292    Vec3::new(v.x, v.y, 0.0)
293}
294
295/// Whether a conic frame's axes are orthonormal to rounding, so a circle's
296/// speed is its radius everywhere.
297fn orthonormal(x: Vec3, y: Vec3) -> bool {
298    let near = 1e-12;
299    (x.length() - 1.0).abs() <= near && (y.length() - 1.0).abs() <= near && x.dot(y).abs() <= near
300}
301
302fn straight(direction: Vec3, kind: Kind, measure: Scalar) -> Option<TangentLaw> {
303    Some(TangentLaw {
304        point: [1.0, 0.0],
305        tangent: [0.0, 0.0],
306        middle: unit(direction)?,
307        kind,
308        measure,
309    })
310}
311
312fn circle(radius: Scalar, middle: Vec3, kind: Kind, hi: Scalar) -> Option<TangentLaw> {
313    if !(radius.is_finite() && radius > 0.0) {
314        return None;
315    }
316    let k = 1.0 / radius;
317    Some(TangentLaw {
318        point: [1.0, k],
319        tangent: [k, k * k],
320        middle: unit(middle)?,
321        kind,
322        measure: numerical_measure(hi),
323    })
324}
325
326/// The segment of a polyline holding the measure span `[lo, hi]`, as its
327/// direction; `None` across a vertex.
328fn polyline_segment<P: Copy>(
329    points: &[P],
330    closed: bool,
331    lo: Scalar,
332    hi: Scalar,
333    difference: impl Fn(P, P) -> Vec3,
334) -> Option<Vec3> {
335    let count = points.len();
336    let spans: Vec<(usize, usize)> = if closed {
337        (0..count).map(|i| (i, (i + 1) % count)).collect()
338    } else {
339        (0..count.saturating_sub(1)).map(|i| (i, i + 1)).collect()
340    };
341    let total: Scalar = spans
342        .iter()
343        .map(|&(i, j)| difference(points[i], points[j]).length())
344        .sum();
345    let slack = numerical_measure(total);
346    let mut start = 0.0;
347    for (i, j) in spans {
348        let step = difference(points[i], points[j]);
349        let end = start + step.length();
350        if step.length() > 0.0 && lo >= start - slack && hi <= end + slack {
351            return Some(step);
352        }
353        start = end;
354    }
355    None
356}
357
358fn tangent_law2(curve: &Curve2, lo: Scalar, hi: Scalar) -> Option<TangentLaw> {
359    match curve {
360        Curve2::Line(line) => straight(lift(line.direction), Kind::Planar, 0.0),
361        Curve2::Circle(c) => {
362            if !orthonormal(lift(c.frame.x), lift(c.frame.y)) {
363                return None;
364            }
365            let middle = crate::curve::derivative2(curve, 0.5 * (lo + hi) / c.radius).ok()?;
366            circle(c.radius, lift(middle), Kind::Planar, hi)
367        }
368        Curve2::Polyline(polyline) => {
369            let step = polyline_segment(&polyline.points, polyline.closed, lo, hi, |a, b| {
370                lift(b - a)
371            })?;
372            straight(step, Kind::Planar, numerical_measure(hi))
373        }
374        _ => None,
375    }
376}
377
378/// An elevated or banked curve's point law in plan distance, from its
379/// certified derivative suprema and its derivative at the middle.
380fn plan_measured(
381    bounds: crate::bound::CurveDerivativeBounds,
382    middle: Vec3,
383    lo: Scalar,
384    hi: Scalar,
385    kind: Kind,
386) -> Option<TangentLaw> {
387    let (d2, d3) = (bounds.second, bounds.third);
388    let sigma = middle.length() - 0.5 * (hi - lo) * d2;
389    if !(sigma.is_finite() && sigma > 0.0) {
390        return None;
391    }
392    Some(TangentLaw {
393        point: [bounds.first, d2],
394        tangent: [
395            d2 / sigma,
396            2.0 * d3 / sigma + 6.0 * d2 * d2 / (sigma * sigma),
397        ],
398        middle: unit(middle)?,
399        kind,
400        measure: 0.0,
401    })
402}
403
404/// Whether an elevated curve's point is closed-form with certified
405/// derivatives: a line or circle plan and a profile of closed-form pieces.
406fn closed_form_elevated(curve: &axiolid_curve::Elevated3) -> bool {
407    let plan = match curve.plan.as_ref() {
408        Curve2::Line(_) => true,
409        Curve2::Circle(c) => orthonormal(lift(c.frame.x), lift(c.frame.y)),
410        _ => false,
411    };
412    plan && crate::elevated::certifies_elevated(curve)
413}
414
415fn tangent_law3(curve: &Curve3, lo: Scalar, hi: Scalar) -> Option<TangentLaw> {
416    let mid = 0.5 * (lo + hi);
417    match curve {
418        Curve3::Line(line) => straight(line.direction, Kind::ReferenceUp, 0.0),
419        Curve3::Circle(c) => {
420            if !orthonormal(c.frame.x, c.frame.y) {
421                return None;
422            }
423            let middle = crate::curve::derivative3(curve, mid / c.radius).ok()?;
424            circle(c.radius, middle, Kind::ReferenceUp, hi)
425        }
426        Curve3::Polyline(polyline) => {
427            let step = polyline_segment(&polyline.points, polyline.closed, lo, hi, |a, b| b - a)?;
428            straight(step, Kind::ReferenceUp, numerical_measure(hi))
429        }
430        Curve3::Elevated(elevated) => {
431            if !closed_form_elevated(elevated) {
432                return None;
433            }
434            let bounds = crate::elevated::elevated_derivative_bounds(elevated, lo, hi)?;
435            let middle = crate::elevated::elevated_derivative(elevated, mid).ok()?;
436            plan_measured(bounds, middle, lo, hi, Kind::ReferenceUp)
437        }
438        Curve3::Banked(banked) => {
439            if !closed_form_elevated(&banked.base) {
440                return None;
441            }
442            let bounds = crate::elevated::banked_derivative_bounds(banked, lo, hi)?;
443            let roll = crate::banked::roll_bounds(banked, lo, hi)?;
444            let middle = crate::banked::banked_derivative(banked, mid).ok()?;
445            plan_measured(bounds, middle, lo, hi, Kind::Banked(roll))
446        }
447        _ => None,
448    }
449}
450
451/// `sup |u'|` and `sup |u''|` of a unit vector `w / |w|` with `|w'| <=
452/// w1`, `|w''| <= w2` and `|w| >= rho`.
453fn normalised(w1: Scalar, w2: Scalar, rho: Scalar) -> [Scalar; 2] {
454    [w1 / rho, 2.0 * w2 / rho + 6.0 * w1 * w1 / (rho * rho)]
455}
456
457/// The largest factor a placement's linear part stretches a length by, or
458/// `None` for one whose columns are not orthogonal to rounding.
459fn placement_stretch(rigid: axiolid_core::Transform3) -> Option<Scalar> {
460    let m = rigid.matrix3;
461    let (sx, sy, sz) = (m.x_axis.length(), m.y_axis.length(), m.z_axis.length());
462    let eps = 32.0 * f64::EPSILON;
463    let orthogonal = m.x_axis.dot(m.y_axis).abs() <= eps * sx * sy
464        && m.x_axis.dot(m.z_axis).abs() <= eps * sx * sz
465        && m.y_axis.dot(m.z_axis).abs() <= eps * sy * sz;
466    orthogonal.then(|| sx.max(sy).max(sz) * (1.0 + 3.0 * eps))
467}
468
469/// The smallest factor a placement's linear part shrinks a length by; call
470/// it only where [`placement_stretch`] holds (orthogonal columns).
471fn placement_shrink(rigid: axiolid_core::Transform3) -> Scalar {
472    let m = rigid.matrix3;
473    let (sx, sy, sz) = (m.x_axis.length(), m.y_axis.length(), m.z_axis.length());
474    sx.min(sy).min(sz) * (1.0 - 3.0 * 32.0 * f64::EPSILON)
475}
476
477/// Certified bounds on the derivatives, in station distance, of the
478/// section frame `piece` presents over `[from, to]` (distances into the
479/// piece, in the composite's direction), read as `reading`; see the
480/// [module documentation](self).
481///
482/// The span must lie inside the piece and cross no seam of its curve
483/// (`StationPiece`'s seams, a polyline's vertices, a piece of an
484/// elevation, cant or pivot law); a caller splits there.
485///
486/// `None` where the frame law is not certified: an ellipse, a B-spline,
487/// an intrinsic curve (only [`FrameLaw`] certifies those, #361), a chain,
488/// an offset, a skewed circle, an elevated or
489/// banked curve whose plan is not a line or a circle or whose profile is
490/// not of closed-form pieces, a span across a seam, a tangent too steep to
491/// bound its horizontal part, a roll reaching a quarter turn, and a
492/// plan-measured piece under a placement that tilts `+Z`.
493#[must_use]
494pub fn piece_frame_bounds(
495    piece: &StationPiece<'_>,
496    from: Scalar,
497    to: Scalar,
498    reading: FrameReading,
499) -> Option<FrameLawBounds> {
500    let (u0, u1) = (from.min(to), from.max(to));
501    let length = piece.length();
502    let slack = numerical_measure(length);
503    if !(u0.is_finite() && u1.is_finite()) || u0 < -slack || u1 > length + slack {
504        return None;
505    }
506    let (u0, u1) = (u0.max(0.0), u1.min(length));
507    let (m0, m1) = if piece.reversed {
508        (piece.end - u1, piece.end - u0)
509    } else {
510        (piece.start + u0, piece.start + u1)
511    };
512    let law = match piece.curve {
513        StationCurve::Two(curve) => tangent_law2(curve, m0, m1)?,
514        StationCurve::Three(curve) => tangent_law3(curve, m0, m1)?,
515        StationCurve::Offset(_) => return None,
516    };
517    finish(piece, law, m1 - m0, reading, [0.0; 2])
518}
519
520/// The bounds of a piece read by quadrature (#316) over `[from, to]` into
521/// it, from `reader`'s certified frame law; `None` where it is not
522/// bounded. An offset piece reads through `table`.
523pub(crate) fn read_frame_bounds(
524    reader: &PieceReader<'_>,
525    piece: &StationPiece<'_>,
526    from: Scalar,
527    to: Scalar,
528    table: Option<&OffsetTable>,
529    reading: FrameReading,
530) -> Option<FrameLawBounds> {
531    let (u0, u1) = (from.min(to), from.max(to));
532    let length = piece.length();
533    let slack = numerical_measure(length);
534    if !(u0.is_finite() && u1.is_finite()) || u0 < -slack || u1 > length + slack {
535        return None;
536    }
537    let read = reader.read(piece, u0, u1, table).ok()??;
538    let law = TangentLaw {
539        point: read.point,
540        tangent: read.tangent,
541        middle: unit(read.middle)?,
542        kind: if read.planar {
543            Kind::Planar
544        } else {
545            Kind::ReferenceUp
546        },
547        measure: read.measure,
548    };
549    finish(
550        piece,
551        law,
552        u1 - u0 + 2.0 * read.measure,
553        reading,
554        [read.point_error, read.axis_error],
555    )
556}
557
558/// The bounds of `law` over a span `span` long, as `piece` presents it
559/// (reversed, placed) and read as `reading`, with a read point's and a
560/// read axis's errors `errors` (#316).
561fn finish(
562    piece: &StationPiece<'_>,
563    mut law: TangentLaw,
564    span: Scalar,
565    reading: FrameReading,
566    errors: [Scalar; 2],
567) -> Option<FrameLawBounds> {
568    if piece.reversed {
569        law.middle = -law.middle;
570    }
571    let mut scale = 1.0;
572    let mut shrink = 1.0;
573    if let Some(rigid) = piece.placement {
574        scale = placement_stretch(rigid)?;
575        shrink = placement_shrink(rigid);
576        law.middle = unit(rigid.transform_vector3(law.middle))?;
577        if (rigid.transform_vector3(Vec3::Z) - Vec3::Z).length() > KEEPS_UP_TOLERANCE {
578            if piece.convention() == DistanceConvention::PlanDistance {
579                return None;
580            }
581            law.kind = Kind::ReferenceUp;
582        }
583    }
584    let [k1, k2] = law.tangent;
585    let horizontal = || -> Option<Scalar> {
586        let rho = law.middle.x.hypot(law.middle.y) - 0.5 * span * k1;
587        (rho.is_finite() && rho > 0.0).then_some(rho)
588    };
589    let tangent = [k1, k2];
590    let (lateral, up) = match law.kind {
591        Kind::Planar => (tangent, [0.0, 0.0]),
592        Kind::ReferenceUp => {
593            let [r1, r2] = normalised(k1, k2, horizontal()?);
594            ([r1, r2], [r1 + k1, r2 + 2.0 * r1 * k1 + k2])
595        }
596        Kind::Banked([p1, p2]) => {
597            let [n1, n2] = normalised(k1, k2, horizontal()?);
598            let (s1, s2) = (k1 + n1, k2 + 2.0 * k1 * n1 + n2);
599            let (a1, a2) = (n1.hypot(s1), n2.hypot(s2));
600            let axis = [p1 + a1, p2 + p1 * p1 + 2.0 * p1 * a1 + a2];
601            (axis, axis)
602        }
603    };
604    let [point_error, mut axis_error] = errors;
605    let mut forward = 1.0;
606    let (tangent, lateral, up) = match reading {
607        FrameReading::Section => (tangent, lateral, up),
608        // A 2D curve's frame is already its plan frame.
609        FrameReading::Plan if matches!(law.kind, Kind::Planar) => (tangent, lateral, up),
610        FrameReading::Plan => {
611            let rho = horizontal()?;
612            // A tangent `e` off has its plan direction `2 e / rho` off.
613            if axis_error > 0.0 {
614                axis_error = 2.0 * axis_error / rho;
615            }
616            let flat = normalised(k1, k2, rho);
617            // `O'` is the horizontal unit vector in plan distance, and the
618            // tangent's horizontal part in arc length.
619            if piece.convention() != DistanceConvention::PlanDistance {
620                forward = rho;
621            }
622            (flat, flat, [0.0, 0.0])
623        }
624    };
625    let grow = |v: [Scalar; 2]| v.map(|x| x * scale * (1.0 + ROUNDING));
626    let out = FrameLawBounds {
627        point: grow(law.point),
628        tangent: grow(tangent),
629        lateral: grow(lateral),
630        up: grow(up),
631        measure: law.measure,
632        point_error: point_error * scale * (1.0 + ROUNDING),
633        axis_error: axis_error * scale * (1.0 + ROUNDING),
634        forward: forward * shrink * (1.0 - ROUNDING),
635    };
636    let finite = [out.point, out.tangent, out.lateral, out.up]
637        .iter()
638        .flatten()
639        .chain([
640            &out.measure,
641            &out.point_error,
642            &out.axis_error,
643            &out.forward,
644        ])
645        .all(|v| v.is_finite());
646    finite.then_some(out)
647}
648
649/// The tangent law of a piece of an intrinsic curve over `[lo, hi]` of its
650/// measure (#361): the curvature law's closed-form suprema, `|T'| <= sup
651/// |k|` and `|T''| <= sup |k'| + sup k^2` in arc length, composed with the
652/// profile in plan distance on an elevated curve (the line and circle
653/// plans' path), and a banked curve's roll bounded from its cant law (#396).
654/// `None` across a seam of the law, where `k'` may jump: the
655/// caller splits.
656fn intrinsic_tangent_law(
657    reader: &IntrinsicReader<'_>,
658    lo: Scalar,
659    hi: Scalar,
660) -> Option<TangentLaw> {
661    let (bounds, middle) = reader.derivatives(lo, hi)?;
662    if reader.is_plan_measured() {
663        let kind = match reader.banked() {
664            // The roll from the cant law, as over a line or a circle (#396).
665            Some(banked) => Kind::Banked(crate::banked::roll_bounds(banked, lo, hi)?),
666            None => Kind::ReferenceUp,
667        };
668        return plan_measured(bounds, middle, lo, hi, kind);
669    }
670    if !(bounds.third.is_finite() && bounds.second.is_finite()) {
671        return None;
672    }
673    Some(TangentLaw {
674        point: [bounds.first, bounds.second],
675        tangent: [bounds.second, bounds.third],
676        middle: unit(middle)?,
677        kind: Kind::Planar,
678        measure: 0.0,
679    })
680}
681
682/// The bounds of a piece of an intrinsic curve (#361) over `[from, to]`
683/// into it: the closed-form tangent law, with the read frames compared
684/// against the exact ones. `None` where they are not bounded.
685fn intrinsic_frame_bounds(
686    reader: &IntrinsicReader<'_>,
687    piece: &StationPiece<'_>,
688    from: Scalar,
689    to: Scalar,
690    reading: FrameReading,
691) -> Option<FrameLawBounds> {
692    let (u0, u1) = (from.min(to), from.max(to));
693    let length = piece.length();
694    let slack = numerical_measure(length);
695    if !(u0.is_finite() && u1.is_finite()) || u0 < -slack || u1 > length + slack {
696        return None;
697    }
698    let (u0, u1) = (u0.max(0.0), u1.min(length));
699    let (m0, m1) = if piece.reversed {
700        (piece.end - u1, piece.end - u0)
701    } else {
702        (piece.start + u0, piece.start + u1)
703    };
704    let law = intrinsic_tangent_law(reader, m0, m1)?;
705    let errors = reader.errors(piece, u0, u1).ok()??;
706    finish(piece, law, m1 - m0, reading, errors)
707}
708
709/// A piece's reader, built when first asked for.
710enum Slot<'c> {
711    /// A piece whose frame is read in closed form.
712    Closed,
713    /// A piece read by quadrature, with its reader.
714    Read(Box<PieceReader<'c>>),
715    /// A piece of an intrinsic curve (#361), with its reader.
716    Intrinsic(Box<IntrinsicReader<'c>>),
717    /// A piece read by quadrature that is not bounded.
718    Unbounded,
719}
720
721/// What a [`FrameLaw`] is the law along.
722// Large only by `StationCurve`'s offset variant; one value lives per law.
723#[allow(clippy::large_enum_variant)]
724enum LawBasis<'b, 'c> {
725    /// One atomic curve, possibly placed; `whole` the curve as one piece
726    /// where it is read by quadrature.
727    Atomic {
728        curve: StationCurve<'c>,
729        placement: Option<Transform3>,
730        /// Whether the placement is exact (#408).
731        placement_exact: bool,
732        whole: Option<StationPiece<'c>>,
733    },
734    Composite(&'b CompositeBasis<'c>),
735}
736
737/// Certified bounds on the frame law along a station basis (#316): an
738/// atomic curve or a [`CompositeBasis`], over spans that cross no seam.
739///
740/// A piece read in closed form is bounded by [`piece_frame_bounds`]. A
741/// piece read by quadrature -- an ellipse, a B-spline, an offset beside a
742/// line, a circle, an ellipse or a B-spline -- is bounded against its
743/// exact frame law, its own length measured once by chord panels to the
744/// tolerance (see [the module documentation](self#read-by-quadrature-316)):
745/// the measure is how far a read's true distance lies from the one asked
746/// for, and the read point's and axes' distances from the exact frame at
747/// that true distance are [`FrameLawBounds::point_error`] and
748/// [`FrameLawBounds::axis_error`]. On a composite, a piece after one
749/// measured by quadrature starts where the kernel sums the lengths before
750/// it, which [`CompositeBasis::joint_intervals`] certifies: its distance
751/// from the true joint is added to the measure.
752///
753/// A 2D intrinsic curve, a level planar intrinsic space curve and an
754/// elevated or banked curve over an intrinsic plan are bounded by their curvature
755/// law's closed-form suprema, each frame read compared with the exact one
756/// (see [the module documentation](self#intrinsic-curves-361)).
757///
758/// Not bounded (`None`): every family [`piece_frame_bounds`] leaves
759/// unbounded other than those above (a chain, an intrinsic space curve
760/// with torsion or tilted, a banked curve over a chain plan,
761/// an offset beside a curve parameterised by its plan length or beside a
762/// banked curve, a skewed circle, a steep tangent), a curve read by
763/// quadrature placed in a frame that tilts `+Z`, a placed offset piece,
764/// a curve placed at a station whose frame is not exact, and a piece after joints whose distances are not certified at the
765/// tolerance.
766pub struct FrameLaw<'b, 'c> {
767    basis: LawBasis<'b, 'c>,
768    tolerance: Scalar,
769    /// Each joint's distance from where it truly lies, built when first
770    /// needed; `None` where the joints are not certified.
771    joints: OnceCell<Option<Vec<Scalar>>>,
772    /// Whether a piece placed inexactly is bounded as read (#408).
773    placements_read_as_exact: bool,
774    slots: Vec<OnceCell<Slot<'c>>>,
775}
776
777impl core::fmt::Debug for FrameLaw<'_, '_> {
778    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
779        f.debug_struct("FrameLaw")
780            .field("tolerance", &self.tolerance)
781            .finish_non_exhaustive()
782    }
783}
784
785/// A span of the basis, as the piece that holds it sees it.
786struct Located<'b, 'c> {
787    /// The piece's slot.
788    slot: usize,
789    piece: StationPiece<'c>,
790    /// The span as distances into the piece.
791    from: Scalar,
792    to: Scalar,
793    table: Option<&'b OffsetTable>,
794    /// The composite the piece is in, whose joints add to the measure.
795    composite: Option<&'b CompositeBasis<'c>>,
796}
797
798/// Whether a piece's frame is read by quadrature.
799fn by_quadrature(curve: StationCurve<'_>) -> bool {
800    matches!(curve, StationCurve::Offset(_)) || !curve.measure_is_exact()
801}
802
803/// Whether a piece's frame needs a reader: read by quadrature, or an
804/// intrinsic curve whose point is compared with certified positions.
805fn needs_reader(curve: StationCurve<'_>) -> bool {
806    by_quadrature(curve) || reads_intrinsic(curve)
807}
808
809impl<'b, 'c> FrameLaw<'b, 'c> {
810    /// The frame law along the atomic `curve`, placed by `placement` if
811    /// any, its quadratures certified to `tolerance`.
812    ///
813    /// # Errors
814    ///
815    /// A tolerance that is not finite and positive, and a curve read by
816    /// quadrature whose length a station cannot be measured against, by
817    /// name.
818    pub fn atomic(
819        curve: StationCurve<'c>,
820        placement: Option<Transform3>,
821        tolerance: Scalar,
822    ) -> GeomResult<Self> {
823        Self::atomic_placed(curve, placement, true, tolerance)
824    }
825
826    /// As [`atomic`](Self::atomic), the placement `placement_exact` when
827    /// the curve is placed at a station whose frame is exact. A law
828    /// placed inexactly bounds nothing: the run is certified against the
829    /// frame read there, not the exact one (#408).
830    ///
831    /// `placement_exact = false` with `placement = None` is meaningless (no
832    /// placement is inexact): pass `true` there.
833    ///
834    /// # Errors
835    ///
836    /// As [`atomic`](Self::atomic).
837    pub fn atomic_placed(
838        curve: StationCurve<'c>,
839        placement: Option<Transform3>,
840        placement_exact: bool,
841        tolerance: Scalar,
842    ) -> GeomResult<Self> {
843        let tolerance = checked_tolerance(tolerance)?;
844        let whole = if needs_reader(curve) {
845            let piece = StationPiece::whole(curve)?;
846            Some(match placement {
847                Some(rigid) => piece.placed(rigid, placement_exact),
848                None => piece,
849            })
850        } else {
851            None
852        };
853        Ok(Self {
854            basis: LawBasis::Atomic {
855                curve,
856                placement,
857                placement_exact,
858                whole,
859            },
860            tolerance,
861            joints: OnceCell::new(),
862            placements_read_as_exact: false,
863            slots: vec![OnceCell::new()],
864        })
865    }
866
867    /// The frame law along `basis`, its quadratures certified to
868    /// `tolerance`.
869    ///
870    /// # Errors
871    ///
872    /// A tolerance that is not finite and positive, by name.
873    pub fn composite(basis: &'b CompositeBasis<'c>, tolerance: Scalar) -> GeomResult<Self> {
874        let tolerance = checked_tolerance(tolerance)?;
875        Ok(Self {
876            basis: LawBasis::Composite(basis),
877            tolerance,
878            joints: OnceCell::new(),
879            placements_read_as_exact: false,
880            slots: basis.pieces().iter().map(|_| OnceCell::new()).collect(),
881        })
882    }
883
884    /// This law bounding the frames of a piece placed inexactly as read,
885    /// its placement taken as exact (#408). The bounds then hold for the
886    /// frames the mesh is built from, not for the exact ones: a consumer
887    /// may decide what depends on the read frames alone (a fold of the
888    /// section planes), never certify a deviation from the exact surface.
889    /// [`Self::error_at`] stays `None` for such a piece.
890    #[must_use]
891    pub fn with_placements_read_as_exact(mut self) -> Self {
892        self.placements_read_as_exact = true;
893        self
894    }
895
896    /// Certified bounds of the frame law over the distances `[a, b]` along
897    /// the basis, a span that crosses no seam, read as `reading`; `None`
898    /// where they are not certified (see the [type documentation](Self)).
899    #[must_use]
900    pub fn bounds(&self, a: Scalar, b: Scalar, reading: FrameReading) -> Option<FrameLawBounds> {
901        let at = self.locate(a, b)?;
902        if !at.piece.placement_exact && !self.placements_read_as_exact {
903            return None;
904        }
905        let mut bounds = self.read(at.slot, &at.piece, at.from, at.to, at.table, reading)?;
906        if let Some(composite) = at.composite {
907            bounds.measure += self.joint_error(composite, at.slot)?;
908        }
909        Some(bounds)
910    }
911
912    /// The piece holding the span `[a, b]` of the basis and the span as
913    /// distances into it; `None` for a span that lies in no piece.
914    fn locate(&self, a: Scalar, b: Scalar) -> Option<Located<'b, 'c>> {
915        match &self.basis {
916            LawBasis::Atomic {
917                curve,
918                placement,
919                placement_exact,
920                whole,
921            } => match whole {
922                Some(whole) => Some(Located {
923                    slot: 0,
924                    piece: *whole,
925                    from: a,
926                    to: b,
927                    table: None,
928                    composite: None,
929                }),
930                None => {
931                    let mut piece = StationPiece::between(*curve, a, b).ok()?;
932                    if let Some(rigid) = placement {
933                        piece = piece.placed(*rigid, *placement_exact);
934                    }
935                    Some(Located {
936                        slot: 0,
937                        piece,
938                        from: 0.0,
939                        to: b - a,
940                        table: None,
941                        composite: None,
942                    })
943                }
944            },
945            LawBasis::Composite(composite) => {
946                let composite: &'b CompositeBasis<'c> = composite;
947                let slack = numerical_measure(b);
948                let starts = composite.starts();
949                // The piece holding the span's middle: a span narrower than
950                // the slack that starts at a joint is the later piece's.
951                let mid = 0.5 * (a + b);
952                composite
953                    .pieces()
954                    .iter()
955                    .enumerate()
956                    .find_map(|(k, piece)| {
957                        let (start, end) = (starts[k], starts[k] + piece.length());
958                        let holds = a >= start - slack && b <= end + slack;
959                        (holds && mid >= start && mid <= end).then(|| Located {
960                            slot: k,
961                            piece: *piece,
962                            from: a - start,
963                            to: b - start,
964                            table: composite.tables()[k].as_ref(),
965                            composite: Some(composite),
966                        })
967                    })
968            }
969        }
970    }
971
972    /// Piece `k`'s reader, built when first asked for.
973    fn slot(&self, k: usize, piece: &StationPiece<'c>, table: Option<&OffsetTable>) -> &Slot<'c> {
974        self.slots[k].get_or_init(|| {
975            if reads_intrinsic(piece.curve) {
976                return match IntrinsicReader::new(piece, self.tolerance) {
977                    Ok(Some(reader)) => Slot::Intrinsic(Box::new(reader)),
978                    _ => Slot::Unbounded,
979                };
980            }
981            if !by_quadrature(piece.curve) {
982                return Slot::Closed;
983            }
984            match PieceReader::new(piece, table, self.tolerance) {
985                Ok(Some(reader)) => Slot::Read(Box::new(reader)),
986                _ => Slot::Unbounded,
987            }
988        })
989    }
990
991    /// The bounds of piece `k` over `[from, to]` into it.
992    fn read(
993        &self,
994        k: usize,
995        piece: &StationPiece<'c>,
996        from: Scalar,
997        to: Scalar,
998        table: Option<&OffsetTable>,
999        reading: FrameReading,
1000    ) -> Option<FrameLawBounds> {
1001        match self.slot(k, piece, table) {
1002            Slot::Closed => piece_frame_bounds(piece, from, to, reading),
1003            Slot::Read(reader) => read_frame_bounds(reader, piece, from, to, table, reading),
1004            Slot::Intrinsic(reader) => intrinsic_frame_bounds(reader, piece, from, to, reading),
1005            Slot::Unbounded => None,
1006        }
1007    }
1008
1009    /// How far piece `k`'s start, where the kernel sums the lengths before
1010    /// it, may lie from where it truly lies: zero after pieces measured
1011    /// from stored data alone.
1012    fn joint_error(&self, composite: &CompositeBasis<'c>, k: usize) -> Option<Scalar> {
1013        if composite.pieces()[..k]
1014            .iter()
1015            .all(|piece| piece.curve.measure_is_exact())
1016        {
1017            return Some(0.0);
1018        }
1019        let errors = self.joints.get_or_init(|| {
1020            let joints = composite.joint_intervals(self.tolerance).ok()?;
1021            let starts = composite.starts();
1022            Some(
1023                joints
1024                    .iter()
1025                    .enumerate()
1026                    .map(|(j, joint)| {
1027                        let at = starts[j + 1];
1028                        (at - joint.lower).max(joint.upper - at).max(0.0) * (1.0 + ROUNDING)
1029                    })
1030                    .collect(),
1031            )
1032        });
1033        errors.as_ref()?.get(k.checked_sub(1)?).copied()
1034    }
1035}
1036
1037#[cfg(test)]
1038mod tests {
1039    use super::*;
1040    use crate::station::{station_section2, station_section3, SectionFrame};
1041    use axiolid_core::{Frame2, Point2, Point3};
1042    use axiolid_curve::{
1043        BankConvention, Banked3, CantLaw, CantPiece, Circle2, Circle3, Elevated3, ElevationLaw,
1044        Line2,
1045    };
1046
1047    /// A frame's axis (or point) read as a vector.
1048    type Axis = fn(&SectionFrame) -> Vec3;
1049
1050    /// Second differences of each axis and the point, over a lattice of
1051    /// the span, never above the bounds; first differences likewise.
1052    fn assert_bounded(
1053        section: impl Fn(Scalar) -> SectionFrame,
1054        bounds: &FrameLawBounds,
1055        lo: Scalar,
1056        hi: Scalar,
1057    ) {
1058        let h = 1e-4 * (hi - lo);
1059        for i in 1..20 {
1060            let s = lo + (hi - lo) * i as Scalar / 20.0;
1061            let (a, b, c) = (section(s - h), section(s), section(s + h));
1062            let first = |f: fn(&SectionFrame) -> Vec3| ((f(&c) - f(&a)) / (2.0 * h)).length();
1063            let second =
1064                |f: fn(&SectionFrame) -> Vec3| ((f(&c) - 2.0 * f(&b) + f(&a)) / (h * h)).length();
1065            let slack = |bound: Scalar| bound * (1.0 + 1e-3) + 1e-6;
1066            let axes: [(Axis, [Scalar; 2], &str); 4] = [
1067                (|f| f.point - Point3::ZERO, bounds.point, "point"),
1068                (|f| f.tangent, bounds.tangent, "tangent"),
1069                (|f| f.lateral, bounds.lateral, "lateral"),
1070                (|f| f.up, bounds.up, "up"),
1071            ];
1072            for (f, [b1, b2], name) in axes {
1073                assert!(first(f) <= slack(b1), "{name}' {} > {b1} at {s}", first(f));
1074                assert!(
1075                    second(f) <= slack(b2),
1076                    "{name}'' {} > {b2} at {s}",
1077                    second(f)
1078                );
1079            }
1080        }
1081    }
1082
1083    fn whole3(curve: &Curve3, lo: Scalar, hi: Scalar) -> StationPiece<'_> {
1084        StationPiece::between(StationCurve::Three(curve), lo, hi).unwrap()
1085    }
1086
1087    #[test]
1088    fn a_plan_circle_bounds_its_frame() {
1089        let curve = Curve2::Circle(Circle2 {
1090            frame: Frame2 {
1091                origin: Point2::new(1.0, 2.0),
1092                x: Vec2::X,
1093                y: Vec2::Y,
1094            },
1095            radius: 3.0,
1096        });
1097        let piece = StationPiece::between(StationCurve::Two(&curve), 1.0, 5.0).unwrap();
1098        let bounds = piece_frame_bounds(&piece, 0.0, 4.0, FrameReading::Section).unwrap();
1099        assert_bounded(
1100            |s| station_section2(&curve, 1.0 + s).unwrap(),
1101            &bounds,
1102            0.0,
1103            4.0,
1104        );
1105        assert!((bounds.point[1] - 1.0 / 3.0).abs() <= 1e-12);
1106    }
1107
1108    /// A certified lower bound of `O'.N` in station distance, at sampled
1109    /// stations of a piece: `forward` never exceeds the forward rate the
1110    /// frames show.
1111    fn assert_forward(
1112        section: impl Fn(Scalar) -> SectionFrame,
1113        bounds: &FrameLawBounds,
1114        lo: Scalar,
1115        hi: Scalar,
1116    ) {
1117        let h = 1e-5 * (hi - lo);
1118        for i in 1..20 {
1119            let s = lo + (hi - lo) * i as Scalar / 20.0;
1120            let rate =
1121                (section(s + h).point - section(s - h).point).dot(section(s).tangent) / (2.0 * h);
1122            assert!(
1123                bounds.forward <= rate + 1e-6,
1124                "{} > {rate} at {s}",
1125                bounds.forward
1126            );
1127        }
1128    }
1129
1130    #[test]
1131    fn a_plan_circle_advances_at_unit_rate() {
1132        let curve = Curve2::Circle(Circle2 {
1133            frame: Frame2 {
1134                origin: Point2::ZERO,
1135                x: Vec2::X,
1136                y: Vec2::Y,
1137            },
1138            radius: 10.0,
1139        });
1140        let piece = StationPiece::between(StationCurve::Two(&curve), 0.0, 20.0).unwrap();
1141        for reading in [FrameReading::Section, FrameReading::Plan] {
1142            let bounds = piece_frame_bounds(&piece, 0.0, 20.0, reading).unwrap();
1143            assert!(
1144                (1.0 - bounds.forward).abs() <= 2.0 * ROUNDING,
1145                "{}",
1146                bounds.forward
1147            );
1148            assert_forward(|s| station_section2(&curve, s).unwrap(), &bounds, 0.0, 20.0);
1149        }
1150    }
1151
1152    #[test]
1153    fn a_graded_curve_advances_at_least_at_unit_rate_in_plan_distance() {
1154        let curve = Curve3::Elevated(Elevated3 {
1155            plan: Box::new(Curve2::Line(Line2 {
1156                origin: Point2::ZERO,
1157                direction: Vec2::X,
1158            })),
1159            elevation: ElevationLaw::Polynomial {
1160                coefficients: vec![0.0, 0.1],
1161            },
1162        });
1163        let piece = whole3(&curve, 0.0, 20.0);
1164        for reading in [FrameReading::Section, FrameReading::Plan] {
1165            let bounds = piece_frame_bounds(&piece, 0.0, 20.0, reading).unwrap();
1166            assert!(
1167                (1.0 - bounds.forward).abs() <= 2.0 * ROUNDING,
1168                "{}",
1169                bounds.forward
1170            );
1171        }
1172    }
1173
1174    #[test]
1175    fn a_tilted_line_advances_at_its_horizontal_part_in_the_plan_reading() {
1176        let tilt: Scalar = 0.6;
1177        let curve = Curve3::Line(axiolid_curve::Line3 {
1178            origin: Point3::ZERO,
1179            direction: Vec3::new(tilt.cos(), 0.0, tilt.sin()),
1180        });
1181        let piece = whole3(&curve, 0.0, 10.0);
1182        let section = piece_frame_bounds(&piece, 0.0, 10.0, FrameReading::Section).unwrap();
1183        assert!((1.0 - section.forward).abs() <= 2.0 * ROUNDING);
1184        let plan = piece_frame_bounds(&piece, 0.0, 10.0, FrameReading::Plan).unwrap();
1185        assert!(plan.forward <= tilt.cos());
1186        assert!(
1187            tilt.cos() - plan.forward <= 4.0 * ROUNDING,
1188            "{}",
1189            plan.forward
1190        );
1191    }
1192
1193    #[test]
1194    fn a_placement_scales_the_forward_rate_by_its_smallest_column() {
1195        let curve = Curve2::Line(Line2 {
1196            origin: Point2::ZERO,
1197            direction: Vec2::X,
1198        });
1199        let squeeze = Transform3::from_cols(
1200            Vec3::new(0.5, 0.0, 0.0),
1201            Vec3::new(0.0, 0.5, 0.0),
1202            Vec3::new(0.0, 0.0, 0.5),
1203            Point3::ZERO,
1204        );
1205        let piece = StationPiece::between(StationCurve::Two(&curve), 0.0, 10.0)
1206            .unwrap()
1207            .placed(squeeze, true);
1208        let bounds = piece_frame_bounds(&piece, 0.0, 10.0, FrameReading::Section).unwrap();
1209        assert!(bounds.forward <= 0.5);
1210        assert!(0.5 - bounds.forward <= 1e-12, "{}", bounds.forward);
1211    }
1212
1213    #[test]
1214    fn a_tilted_circle_bounds_its_reference_up_frame() {
1215        let x = Vec3::new(1.0, 0.0, 0.0);
1216        let y = Vec3::new(0.0, 0.8, 0.6);
1217        let curve = Curve3::Circle(Circle3 {
1218            frame: axiolid_core::Frame3 {
1219                origin: Point3::ZERO,
1220                x,
1221                y,
1222                z: x.cross(y),
1223            },
1224            radius: 5.0,
1225        });
1226        // Away from where the tangent is steepest.
1227        let piece = whole3(&curve, 0.5, 2.5);
1228        for reading in [FrameReading::Section, FrameReading::Plan] {
1229            let bounds = piece_frame_bounds(&piece, 0.0, 2.0, reading).unwrap();
1230            assert_bounded(
1231                |s| {
1232                    let f = station_section3(&curve, 0.5 + s).unwrap();
1233                    match reading {
1234                        FrameReading::Section => f,
1235                        FrameReading::Plan => f.plan().unwrap(),
1236                    }
1237                },
1238                &bounds,
1239                0.0,
1240                2.0,
1241            );
1242        }
1243    }
1244
1245    #[test]
1246    fn a_vertical_curve_bounds_its_leaning_frame() {
1247        let curve = Curve3::Elevated(Elevated3::new(
1248            Curve2::Circle(Circle2 {
1249                frame: Frame2 {
1250                    origin: Point2::ZERO,
1251                    x: Vec2::X,
1252                    y: Vec2::Y,
1253                },
1254                radius: 40.0,
1255            }),
1256            ElevationLaw::Polynomial {
1257                coefficients: vec![1.0, 0.03, -0.0004],
1258            },
1259        ));
1260        let piece = whole3(&curve, 0.0, 30.0);
1261        let bounds = piece_frame_bounds(&piece, 2.0, 28.0, FrameReading::Section).unwrap();
1262        assert_bounded(|s| station_section3(&curve, s).unwrap(), &bounds, 2.0, 28.0);
1263        assert_eq!(bounds.measure, 0.0);
1264    }
1265
1266    #[test]
1267    fn a_banked_transition_bounds_its_rolled_frame() {
1268        // A long, gentle transition and a short, steep one, where the
1269        // roll's rate dominates the frame's turning.
1270        let cases = [
1271            (BankConvention::TangentRotation, 60.0, 10.0, 50.0),
1272            (BankConvention::VerticalRise, 60.0, 10.0, 50.0),
1273            (BankConvention::TangentRotation, 8.0, 1.0, 7.0),
1274            (BankConvention::VerticalRise, 8.0, 1.0, 7.0),
1275        ];
1276        for (convention, length, lo, hi) in cases {
1277            let curve = Curve3::Banked(Banked3::new(
1278                Elevated3::new(
1279                    Curve2::Circle(Circle2 {
1280                        frame: Frame2 {
1281                            origin: Point2::ZERO,
1282                            x: Vec2::X,
1283                            y: Vec2::Y,
1284                        },
1285                        radius: 300.0,
1286                    }),
1287                    ElevationLaw::constant_grade(60.0, 0.02),
1288                ),
1289                CantLaw::new(vec![CantPiece::cosine(length, 0.0, 0.15)]),
1290                CantLaw::zero(length),
1291                1.5,
1292                convention,
1293            ));
1294            let piece = whole3(&curve, 0.0, length);
1295            let bounds = piece_frame_bounds(&piece, lo, hi, FrameReading::Section).unwrap();
1296            assert_bounded(|s| station_section3(&curve, s).unwrap(), &bounds, lo, hi);
1297        }
1298    }
1299
1300    #[test]
1301    fn lines_are_straight_and_numerical_families_unbounded() {
1302        let line = Curve2::Line(Line2 {
1303            origin: Point2::ZERO,
1304            direction: Vec2::new(3.0, 4.0),
1305        });
1306        let piece = StationPiece::between(StationCurve::Two(&line), 0.0, 10.0).unwrap();
1307        let bounds = piece_frame_bounds(&piece, 0.0, 10.0, FrameReading::Section).unwrap();
1308        assert!(bounds.is_straight());
1309        let ellipse = Curve3::Ellipse(axiolid_curve::Ellipse3 {
1310            frame: axiolid_core::Frame3 {
1311                origin: Point3::ZERO,
1312                x: Vec3::X,
1313                y: Vec3::Y,
1314                z: Vec3::Z,
1315            },
1316            semi_axis_x: 2.0,
1317            semi_axis_y: 1.0,
1318        });
1319        let piece = whole3(&ellipse, 0.0, 1.0);
1320        assert!(piece_frame_bounds(&piece, 0.0, 1.0, FrameReading::Section).is_none());
1321    }
1322}