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//! # Read by quadrature (#316)
76//!
77//! An ellipse, a B-spline and an offset beside a line, a circle, an
78//! ellipse or a B-spline read their frames through estimates (the
79//! arc-length inverse, an offset's own length table, an offset's tangent by
80//! a difference quotient), so [`piece_frame_bounds`] leaves them unbounded.
81//! [`FrameLaw`] bounds them against their EXACT frame law instead: the
82//! curve the length certificates of [`super::certify`] measure, framed by
83//! its own tangent at its true arc length from the piece's start. Each read
84//! frame stands at a native parameter where the exact curve's point and
85//! velocity are known in closed form to a certified error; the read
86//! point's and each read axis's distance from the exact frame there are
87//! computed and carried as [`FrameLawBounds::point_error`] and
88//! [`FrameLawBounds::axis_error`], and the true arc length to that
89//! parameter, bounded by chord panels over the piece measured once, gives
90//! the measure. Over a span between two reads the exact law is bounded in
91//! its own arc length from the certified suprema of `|P''|` and `|P'''|`
92//! (the base's `|B''|` to `|B''''|` beside an offset, and the frame's unit
93//! vectors) and a floor of `|P'|`: `|O''| = |T'| <= M2 / sigma^2` and
94//! `|T''| <= (M3 / sigma + 3 M2^2 / sigma^2) / sigma^2 + M2^2 / sigma^4`.
95//! The axes follow from the tangent as above. On a composite, a piece after
96//! one measured by quadrature starts where the kernel sums the lengths
97//! before it; its distance from the certified joint
98//! ([`super::CompositeBasis::joint_intervals`]) adds to the measure.
99//!
100//! # Not bounded
101//!
102//! `None`, never a guess: an intrinsic curve, a chain, and an offset beside
103//! one of them, beside an elevated or a banked curve; a circle with a
104//! skewed frame; an elevated or banked curve with another plan or an
105//! intrinsic profile; a span that crosses a seam, a vertex of a polyline
106//! or a piece of a law; a tangent too steep for a horizontal lower bound;
107//! a cant whose roll reaches a quarter turn; a curve read by quadrature
108//! placed in a frame that tilts `+Z`, and a placed offset piece.
109//! [`piece_frame_bounds`] alone also leaves an ellipse, a B-spline and
110//! every offset unbounded: they are [`FrameLaw`]'s.
111//!
112//! [`SectionFrame::carried`]: super::SectionFrame::carried
113
114use std::cell::OnceCell;
115
116use axiolid_contracts::GeomResult;
117use axiolid_core::{Scalar, Transform3, Vec2, Vec3};
118use axiolid_curve::{Curve2, Curve3};
119
120use super::certify::{checked_tolerance, PieceReader};
121use super::offset::OffsetTable;
122use super::{CompositeBasis, DistanceConvention, StationCurve, StationPiece, KEEPS_UP_TOLERANCE};
123use crate::arc_parameter::ARC_LENGTH_TOLERANCE;
124use crate::bound::ROUNDING;
125
126/// Which section frame a run places its sections in.
127#[non_exhaustive]
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum FrameReading {
130    /// The curve's section frame ([`super::SectionFrame`]).
131    Section,
132    /// The plan frame ([`super::SectionFrame::plan`]).
133    Plan,
134}
135
136/// Certified suprema, over a span of station distance, of the derivatives
137/// of a section frame law in that distance; see the
138/// [module documentation](self).
139#[non_exhaustive]
140#[derive(Debug, Clone, Copy, PartialEq)]
141pub struct FrameLawBounds {
142    /// `sup |O'|` and `sup |O''|` of the basis point.
143    pub point: [Scalar; 2],
144    /// `sup |T'|` and `sup |T''|` of the unit tangent.
145    pub tangent: [Scalar; 2],
146    /// `sup |L'|` and `sup |L''|` of the lateral axis.
147    pub lateral: [Scalar; 2],
148    /// `sup |U'|` and `sup |U''|` of the up axis.
149    pub up: [Scalar; 2],
150    /// How far the distance a frame is read at may lie from the distance
151    /// asked for (see [the measure](self#the-measure)).
152    pub measure: Scalar,
153    /// How far a frame's point read at a distance may lie from the exact
154    /// frame law's point at the distance it truly stands at: zero where
155    /// the frame is read in closed form, a certified bound where it is
156    /// read by quadrature ([`FrameLaw`], #316).
157    pub point_error: Scalar,
158    /// How far each of a read frame's axes may lie from the exact frame
159    /// law's, likewise.
160    pub axis_error: Scalar,
161}
162
163impl FrameLawBounds {
164    /// Whether the frame is constant and the point moves on a straight
165    /// line: every second derivative, and every axis's first, is zero.
166    #[must_use]
167    pub fn is_straight(&self) -> bool {
168        self.point[1] == 0.0
169            && self.tangent == [0.0; 2]
170            && self.lateral == [0.0; 2]
171            && self.up == [0.0; 2]
172    }
173}
174
175/// How a curve's frame is built from its tangent.
176#[derive(Debug, Clone, Copy)]
177enum Kind {
178    /// A 2D curve's frame: the tangent, its quarter turn, `+Z`.
179    Planar,
180    /// The reference-up frame against `+Z`.
181    ReferenceUp,
182    /// A banked curve's rolled section, with the roll's derivative bounds.
183    Banked([Scalar; 2]),
184}
185
186/// A curve's tangent law over a span.
187#[derive(Debug, Clone, Copy)]
188struct TangentLaw {
189    point: [Scalar; 2],
190    tangent: [Scalar; 2],
191    /// The unit tangent at the middle of the span.
192    middle: Vec3,
193    kind: Kind,
194    measure: Scalar,
195}
196
197/// The measure slack of a distance read by the arc-length inverse on a
198/// speed constant per panel.
199fn numerical_measure(hi: Scalar) -> Scalar {
200    2.0 * ARC_LENGTH_TOLERANCE * hi.abs().max(1.0)
201}
202
203fn unit(v: Vec3) -> Option<Vec3> {
204    let length = v.length();
205    (length.is_finite() && length > 0.0).then(|| v / length)
206}
207
208fn lift(v: Vec2) -> Vec3 {
209    Vec3::new(v.x, v.y, 0.0)
210}
211
212/// Whether a conic frame's axes are orthonormal to rounding, so a circle's
213/// speed is its radius everywhere.
214fn orthonormal(x: Vec3, y: Vec3) -> bool {
215    let near = 1e-12;
216    (x.length() - 1.0).abs() <= near && (y.length() - 1.0).abs() <= near && x.dot(y).abs() <= near
217}
218
219fn straight(direction: Vec3, kind: Kind, measure: Scalar) -> Option<TangentLaw> {
220    Some(TangentLaw {
221        point: [1.0, 0.0],
222        tangent: [0.0, 0.0],
223        middle: unit(direction)?,
224        kind,
225        measure,
226    })
227}
228
229fn circle(radius: Scalar, middle: Vec3, kind: Kind, hi: Scalar) -> Option<TangentLaw> {
230    if !(radius.is_finite() && radius > 0.0) {
231        return None;
232    }
233    let k = 1.0 / radius;
234    Some(TangentLaw {
235        point: [1.0, k],
236        tangent: [k, k * k],
237        middle: unit(middle)?,
238        kind,
239        measure: numerical_measure(hi),
240    })
241}
242
243/// The segment of a polyline holding the measure span `[lo, hi]`, as its
244/// direction; `None` across a vertex.
245fn polyline_segment<P: Copy>(
246    points: &[P],
247    closed: bool,
248    lo: Scalar,
249    hi: Scalar,
250    difference: impl Fn(P, P) -> Vec3,
251) -> Option<Vec3> {
252    let count = points.len();
253    let spans: Vec<(usize, usize)> = if closed {
254        (0..count).map(|i| (i, (i + 1) % count)).collect()
255    } else {
256        (0..count.saturating_sub(1)).map(|i| (i, i + 1)).collect()
257    };
258    let total: Scalar = spans
259        .iter()
260        .map(|&(i, j)| difference(points[i], points[j]).length())
261        .sum();
262    let slack = numerical_measure(total);
263    let mut start = 0.0;
264    for (i, j) in spans {
265        let step = difference(points[i], points[j]);
266        let end = start + step.length();
267        if step.length() > 0.0 && lo >= start - slack && hi <= end + slack {
268            return Some(step);
269        }
270        start = end;
271    }
272    None
273}
274
275fn tangent_law2(curve: &Curve2, lo: Scalar, hi: Scalar) -> Option<TangentLaw> {
276    match curve {
277        Curve2::Line(line) => straight(lift(line.direction), Kind::Planar, 0.0),
278        Curve2::Circle(c) => {
279            if !orthonormal(lift(c.frame.x), lift(c.frame.y)) {
280                return None;
281            }
282            let middle = crate::curve::derivative2(curve, 0.5 * (lo + hi) / c.radius).ok()?;
283            circle(c.radius, lift(middle), Kind::Planar, hi)
284        }
285        Curve2::Polyline(polyline) => {
286            let step = polyline_segment(&polyline.points, polyline.closed, lo, hi, |a, b| {
287                lift(b - a)
288            })?;
289            straight(step, Kind::Planar, numerical_measure(hi))
290        }
291        _ => None,
292    }
293}
294
295/// An elevated or banked curve's point law in plan distance, from its
296/// certified derivative suprema and its derivative at the middle.
297fn plan_measured(
298    bounds: crate::bound::CurveDerivativeBounds,
299    middle: Vec3,
300    lo: Scalar,
301    hi: Scalar,
302    kind: Kind,
303) -> Option<TangentLaw> {
304    let (d2, d3) = (bounds.second, bounds.third);
305    let sigma = middle.length() - 0.5 * (hi - lo) * d2;
306    if !(sigma.is_finite() && sigma > 0.0) {
307        return None;
308    }
309    Some(TangentLaw {
310        point: [bounds.first, d2],
311        tangent: [
312            d2 / sigma,
313            2.0 * d3 / sigma + 6.0 * d2 * d2 / (sigma * sigma),
314        ],
315        middle: unit(middle)?,
316        kind,
317        measure: 0.0,
318    })
319}
320
321/// Whether an elevated curve's point is closed-form with certified
322/// derivatives: a line or circle plan and a profile of closed-form pieces.
323fn closed_form_elevated(curve: &axiolid_curve::Elevated3) -> bool {
324    let plan = match curve.plan.as_ref() {
325        Curve2::Line(_) => true,
326        Curve2::Circle(c) => orthonormal(lift(c.frame.x), lift(c.frame.y)),
327        _ => false,
328    };
329    plan && crate::elevated::certifies_elevated(curve)
330}
331
332fn tangent_law3(curve: &Curve3, lo: Scalar, hi: Scalar) -> Option<TangentLaw> {
333    let mid = 0.5 * (lo + hi);
334    match curve {
335        Curve3::Line(line) => straight(line.direction, Kind::ReferenceUp, 0.0),
336        Curve3::Circle(c) => {
337            if !orthonormal(c.frame.x, c.frame.y) {
338                return None;
339            }
340            let middle = crate::curve::derivative3(curve, mid / c.radius).ok()?;
341            circle(c.radius, middle, Kind::ReferenceUp, hi)
342        }
343        Curve3::Polyline(polyline) => {
344            let step = polyline_segment(&polyline.points, polyline.closed, lo, hi, |a, b| b - a)?;
345            straight(step, Kind::ReferenceUp, numerical_measure(hi))
346        }
347        Curve3::Elevated(elevated) => {
348            if !closed_form_elevated(elevated) {
349                return None;
350            }
351            let bounds = crate::elevated::elevated_derivative_bounds(elevated, lo, hi)?;
352            let middle = crate::elevated::elevated_derivative(elevated, mid).ok()?;
353            plan_measured(bounds, middle, lo, hi, Kind::ReferenceUp)
354        }
355        Curve3::Banked(banked) => {
356            if !closed_form_elevated(&banked.base) {
357                return None;
358            }
359            let bounds = crate::elevated::banked_derivative_bounds(banked, lo, hi)?;
360            let roll = crate::banked::roll_bounds(banked, lo, hi)?;
361            let middle = crate::banked::banked_derivative(banked, mid).ok()?;
362            plan_measured(bounds, middle, lo, hi, Kind::Banked(roll))
363        }
364        _ => None,
365    }
366}
367
368/// `sup |u'|` and `sup |u''|` of a unit vector `w / |w|` with `|w'| <=
369/// w1`, `|w''| <= w2` and `|w| >= rho`.
370fn normalised(w1: Scalar, w2: Scalar, rho: Scalar) -> [Scalar; 2] {
371    [w1 / rho, 2.0 * w2 / rho + 6.0 * w1 * w1 / (rho * rho)]
372}
373
374/// The largest factor a placement's linear part stretches a length by, or
375/// `None` for one whose columns are not orthogonal to rounding.
376fn placement_stretch(rigid: axiolid_core::Transform3) -> Option<Scalar> {
377    let m = rigid.matrix3;
378    let (sx, sy, sz) = (m.x_axis.length(), m.y_axis.length(), m.z_axis.length());
379    let eps = 32.0 * f64::EPSILON;
380    let orthogonal = m.x_axis.dot(m.y_axis).abs() <= eps * sx * sy
381        && m.x_axis.dot(m.z_axis).abs() <= eps * sx * sz
382        && m.y_axis.dot(m.z_axis).abs() <= eps * sy * sz;
383    orthogonal.then(|| sx.max(sy).max(sz) * (1.0 + 3.0 * eps))
384}
385
386/// Certified bounds on the derivatives, in station distance, of the
387/// section frame `piece` presents over `[from, to]` (distances into the
388/// piece, in the composite's direction), read as `reading`; see the
389/// [module documentation](self).
390///
391/// The span must lie inside the piece and cross no seam of its curve
392/// (`StationPiece`'s seams, a polyline's vertices, a piece of an
393/// elevation, cant or pivot law); a caller splits there.
394///
395/// `None` where the frame law is not certified: an ellipse, a B-spline,
396/// an intrinsic curve, a chain, an offset, a skewed circle, an elevated or
397/// banked curve whose plan is not a line or a circle or whose profile is
398/// not of closed-form pieces, a span across a seam, a tangent too steep to
399/// bound its horizontal part, a roll reaching a quarter turn, and a
400/// plan-measured piece under a placement that tilts `+Z`.
401#[must_use]
402pub fn piece_frame_bounds(
403    piece: &StationPiece<'_>,
404    from: Scalar,
405    to: Scalar,
406    reading: FrameReading,
407) -> Option<FrameLawBounds> {
408    let (u0, u1) = (from.min(to), from.max(to));
409    let length = piece.length();
410    let slack = numerical_measure(length);
411    if !(u0.is_finite() && u1.is_finite()) || u0 < -slack || u1 > length + slack {
412        return None;
413    }
414    let (u0, u1) = (u0.max(0.0), u1.min(length));
415    let (m0, m1) = if piece.reversed {
416        (piece.end - u1, piece.end - u0)
417    } else {
418        (piece.start + u0, piece.start + u1)
419    };
420    let law = match piece.curve {
421        StationCurve::Two(curve) => tangent_law2(curve, m0, m1)?,
422        StationCurve::Three(curve) => tangent_law3(curve, m0, m1)?,
423        StationCurve::Offset(_) => return None,
424    };
425    finish(piece, law, m1 - m0, reading, [0.0; 2])
426}
427
428/// The bounds of a piece read by quadrature (#316) over `[from, to]` into
429/// it, from `reader`'s certified frame law; `None` where it is not
430/// bounded. An offset piece reads through `table`.
431pub(crate) fn read_frame_bounds(
432    reader: &PieceReader<'_>,
433    piece: &StationPiece<'_>,
434    from: Scalar,
435    to: Scalar,
436    table: Option<&OffsetTable>,
437    reading: FrameReading,
438) -> Option<FrameLawBounds> {
439    let (u0, u1) = (from.min(to), from.max(to));
440    let length = piece.length();
441    let slack = numerical_measure(length);
442    if !(u0.is_finite() && u1.is_finite()) || u0 < -slack || u1 > length + slack {
443        return None;
444    }
445    let read = reader.read(piece, u0, u1, table).ok()??;
446    let law = TangentLaw {
447        point: read.point,
448        tangent: read.tangent,
449        middle: unit(read.middle)?,
450        kind: if read.planar {
451            Kind::Planar
452        } else {
453            Kind::ReferenceUp
454        },
455        measure: read.measure,
456    };
457    finish(
458        piece,
459        law,
460        u1 - u0 + 2.0 * read.measure,
461        reading,
462        [read.point_error, read.axis_error],
463    )
464}
465
466/// The bounds of `law` over a span `span` long, as `piece` presents it
467/// (reversed, placed) and read as `reading`, with a read point's and a
468/// read axis's errors `errors` (#316).
469fn finish(
470    piece: &StationPiece<'_>,
471    mut law: TangentLaw,
472    span: Scalar,
473    reading: FrameReading,
474    errors: [Scalar; 2],
475) -> Option<FrameLawBounds> {
476    if piece.reversed {
477        law.middle = -law.middle;
478    }
479    let mut scale = 1.0;
480    if let Some(rigid) = piece.placement {
481        scale = placement_stretch(rigid)?;
482        law.middle = unit(rigid.transform_vector3(law.middle))?;
483        if (rigid.transform_vector3(Vec3::Z) - Vec3::Z).length() > KEEPS_UP_TOLERANCE {
484            if piece.convention() == DistanceConvention::PlanDistance {
485                return None;
486            }
487            law.kind = Kind::ReferenceUp;
488        }
489    }
490    let [k1, k2] = law.tangent;
491    let horizontal = || -> Option<Scalar> {
492        let rho = law.middle.x.hypot(law.middle.y) - 0.5 * span * k1;
493        (rho.is_finite() && rho > 0.0).then_some(rho)
494    };
495    let tangent = [k1, k2];
496    let (lateral, up) = match law.kind {
497        Kind::Planar => (tangent, [0.0, 0.0]),
498        Kind::ReferenceUp => {
499            let [r1, r2] = normalised(k1, k2, horizontal()?);
500            ([r1, r2], [r1 + k1, r2 + 2.0 * r1 * k1 + k2])
501        }
502        Kind::Banked([p1, p2]) => {
503            let [n1, n2] = normalised(k1, k2, horizontal()?);
504            let (s1, s2) = (k1 + n1, k2 + 2.0 * k1 * n1 + n2);
505            let (a1, a2) = (n1.hypot(s1), n2.hypot(s2));
506            let axis = [p1 + a1, p2 + p1 * p1 + 2.0 * p1 * a1 + a2];
507            (axis, axis)
508        }
509    };
510    let [point_error, mut axis_error] = errors;
511    let (tangent, lateral, up) = match reading {
512        FrameReading::Section => (tangent, lateral, up),
513        // A 2D curve's frame is already its plan frame.
514        FrameReading::Plan if matches!(law.kind, Kind::Planar) => (tangent, lateral, up),
515        FrameReading::Plan => {
516            let rho = horizontal()?;
517            // A tangent `e` off has its plan direction `2 e / rho` off.
518            if axis_error > 0.0 {
519                axis_error = 2.0 * axis_error / rho;
520            }
521            let flat = normalised(k1, k2, rho);
522            (flat, flat, [0.0, 0.0])
523        }
524    };
525    let grow = |v: [Scalar; 2]| v.map(|x| x * scale * (1.0 + ROUNDING));
526    let out = FrameLawBounds {
527        point: grow(law.point),
528        tangent: grow(tangent),
529        lateral: grow(lateral),
530        up: grow(up),
531        measure: law.measure,
532        point_error: point_error * scale * (1.0 + ROUNDING),
533        axis_error: axis_error * scale * (1.0 + ROUNDING),
534    };
535    let finite = [out.point, out.tangent, out.lateral, out.up]
536        .iter()
537        .flatten()
538        .chain([&out.measure, &out.point_error, &out.axis_error])
539        .all(|v| v.is_finite());
540    finite.then_some(out)
541}
542
543/// A piece's reader, built when first asked for.
544enum Slot<'c> {
545    /// A piece whose frame is read in closed form.
546    Closed,
547    /// A piece read by quadrature, with its reader.
548    Read(Box<PieceReader<'c>>),
549    /// A piece read by quadrature that is not bounded.
550    Unbounded,
551}
552
553/// What a [`FrameLaw`] is the law along.
554// Large only by `StationCurve`'s offset variant; one value lives per law.
555#[allow(clippy::large_enum_variant)]
556enum LawBasis<'b, 'c> {
557    /// One atomic curve, possibly placed; `whole` the curve as one piece
558    /// where it is read by quadrature.
559    Atomic {
560        curve: StationCurve<'c>,
561        placement: Option<Transform3>,
562        whole: Option<StationPiece<'c>>,
563    },
564    Composite(&'b CompositeBasis<'c>),
565}
566
567/// Certified bounds on the frame law along a station basis (#316): an
568/// atomic curve or a [`CompositeBasis`], over spans that cross no seam.
569///
570/// A piece read in closed form is bounded by [`piece_frame_bounds`]. A
571/// piece read by quadrature -- an ellipse, a B-spline, an offset beside a
572/// line, a circle, an ellipse or a B-spline -- is bounded against its
573/// exact frame law, its own length measured once by chord panels to the
574/// tolerance (see [the module documentation](self#read-by-quadrature-316)):
575/// the measure is how far a read's true distance lies from the one asked
576/// for, and the read point's and axes' distances from the exact frame at
577/// that true distance are [`FrameLawBounds::point_error`] and
578/// [`FrameLawBounds::axis_error`]. On a composite, a piece after one
579/// measured by quadrature starts where the kernel sums the lengths before
580/// it, which [`CompositeBasis::joint_intervals`] certifies: its distance
581/// from the true joint is added to the measure.
582///
583/// Not bounded (`None`): every family [`piece_frame_bounds`] leaves
584/// unbounded other than those above (an intrinsic curve, a chain, an
585/// offset beside a curve parameterised by its plan length or beside a
586/// banked curve, a skewed circle, a steep tangent), a curve read by
587/// quadrature placed in a frame that tilts `+Z`, a placed offset piece,
588/// and a piece after joints whose distances are not certified at the
589/// tolerance.
590pub struct FrameLaw<'b, 'c> {
591    basis: LawBasis<'b, 'c>,
592    tolerance: Scalar,
593    /// Each joint's distance from where it truly lies, built when first
594    /// needed; `None` where the joints are not certified.
595    joints: OnceCell<Option<Vec<Scalar>>>,
596    slots: Vec<OnceCell<Slot<'c>>>,
597}
598
599impl core::fmt::Debug for FrameLaw<'_, '_> {
600    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
601        f.debug_struct("FrameLaw")
602            .field("tolerance", &self.tolerance)
603            .finish_non_exhaustive()
604    }
605}
606
607/// Whether a piece's frame is read by quadrature.
608fn by_quadrature(curve: StationCurve<'_>) -> bool {
609    matches!(curve, StationCurve::Offset(_)) || !curve.measure_is_exact()
610}
611
612impl<'b, 'c> FrameLaw<'b, 'c> {
613    /// The frame law along the atomic `curve`, placed by `placement` if
614    /// any, its quadratures certified to `tolerance`.
615    ///
616    /// # Errors
617    ///
618    /// A tolerance that is not finite and positive, and a curve read by
619    /// quadrature whose length a station cannot be measured against, by
620    /// name.
621    pub fn atomic(
622        curve: StationCurve<'c>,
623        placement: Option<Transform3>,
624        tolerance: Scalar,
625    ) -> GeomResult<Self> {
626        let tolerance = checked_tolerance(tolerance)?;
627        let whole = if by_quadrature(curve) {
628            let piece = StationPiece::whole(curve)?;
629            Some(match placement {
630                Some(rigid) => piece.placed(rigid, true),
631                None => piece,
632            })
633        } else {
634            None
635        };
636        Ok(Self {
637            basis: LawBasis::Atomic {
638                curve,
639                placement,
640                whole,
641            },
642            tolerance,
643            joints: OnceCell::new(),
644            slots: vec![OnceCell::new()],
645        })
646    }
647
648    /// The frame law along `basis`, its quadratures certified to
649    /// `tolerance`.
650    ///
651    /// # Errors
652    ///
653    /// A tolerance that is not finite and positive, by name.
654    pub fn composite(basis: &'b CompositeBasis<'c>, tolerance: Scalar) -> GeomResult<Self> {
655        let tolerance = checked_tolerance(tolerance)?;
656        Ok(Self {
657            basis: LawBasis::Composite(basis),
658            tolerance,
659            joints: OnceCell::new(),
660            slots: basis.pieces().iter().map(|_| OnceCell::new()).collect(),
661        })
662    }
663
664    /// Certified bounds of the frame law over the distances `[a, b]` along
665    /// the basis, a span that crosses no seam, read as `reading`; `None`
666    /// where they are not certified (see the [type documentation](Self)).
667    #[must_use]
668    pub fn bounds(&self, a: Scalar, b: Scalar, reading: FrameReading) -> Option<FrameLawBounds> {
669        match &self.basis {
670            LawBasis::Atomic {
671                curve,
672                placement,
673                whole,
674            } => match whole {
675                Some(whole) => self.read(0, whole, a, b, None, reading),
676                None => {
677                    let mut piece = StationPiece::between(*curve, a, b).ok()?;
678                    if let Some(rigid) = placement {
679                        piece = piece.placed(*rigid, true);
680                    }
681                    piece_frame_bounds(&piece, 0.0, b - a, reading)
682                }
683            },
684            LawBasis::Composite(composite) => {
685                let slack = numerical_measure(b);
686                let starts = composite.starts();
687                for (k, piece) in composite.pieces().iter().enumerate() {
688                    let (start, end) = (starts[k], starts[k] + piece.length());
689                    if a >= start - slack && b <= end + slack {
690                        let table = composite.tables()[k].as_ref();
691                        let mut bounds =
692                            self.read(k, piece, a - start, b - start, table, reading)?;
693                        bounds.measure += self.joint_error(composite, k)?;
694                        return Some(bounds);
695                    }
696                }
697                None
698            }
699        }
700    }
701
702    /// The bounds of piece `k` over `[from, to]` into it.
703    fn read(
704        &self,
705        k: usize,
706        piece: &StationPiece<'c>,
707        from: Scalar,
708        to: Scalar,
709        table: Option<&OffsetTable>,
710        reading: FrameReading,
711    ) -> Option<FrameLawBounds> {
712        let slot = self.slots[k].get_or_init(|| {
713            if !by_quadrature(piece.curve) {
714                return Slot::Closed;
715            }
716            match PieceReader::new(piece, table, self.tolerance) {
717                Ok(Some(reader)) => Slot::Read(Box::new(reader)),
718                _ => Slot::Unbounded,
719            }
720        });
721        match slot {
722            Slot::Closed => piece_frame_bounds(piece, from, to, reading),
723            Slot::Read(reader) => read_frame_bounds(reader, piece, from, to, table, reading),
724            Slot::Unbounded => None,
725        }
726    }
727
728    /// How far piece `k`'s start, where the kernel sums the lengths before
729    /// it, may lie from where it truly lies: zero after pieces measured
730    /// from stored data alone.
731    fn joint_error(&self, composite: &CompositeBasis<'c>, k: usize) -> Option<Scalar> {
732        if composite.pieces()[..k]
733            .iter()
734            .all(|piece| piece.curve.measure_is_exact())
735        {
736            return Some(0.0);
737        }
738        let errors = self.joints.get_or_init(|| {
739            let joints = composite.joint_intervals(self.tolerance).ok()?;
740            let starts = composite.starts();
741            Some(
742                joints
743                    .iter()
744                    .enumerate()
745                    .map(|(j, joint)| {
746                        let at = starts[j + 1];
747                        (at - joint.lower).max(joint.upper - at).max(0.0) * (1.0 + ROUNDING)
748                    })
749                    .collect(),
750            )
751        });
752        errors.as_ref()?.get(k.checked_sub(1)?).copied()
753    }
754}
755
756#[cfg(test)]
757mod tests {
758    use super::*;
759    use crate::station::{station_section2, station_section3, SectionFrame};
760    use axiolid_core::{Frame2, Point2, Point3};
761    use axiolid_curve::{
762        BankConvention, Banked3, CantLaw, CantPiece, Circle2, Circle3, Elevated3, ElevationLaw,
763        Line2,
764    };
765
766    /// A frame's axis (or point) read as a vector.
767    type Axis = fn(&SectionFrame) -> Vec3;
768
769    /// Second differences of each axis and the point, over a lattice of
770    /// the span, never above the bounds; first differences likewise.
771    fn assert_bounded(
772        section: impl Fn(Scalar) -> SectionFrame,
773        bounds: &FrameLawBounds,
774        lo: Scalar,
775        hi: Scalar,
776    ) {
777        let h = 1e-4 * (hi - lo);
778        for i in 1..20 {
779            let s = lo + (hi - lo) * i as Scalar / 20.0;
780            let (a, b, c) = (section(s - h), section(s), section(s + h));
781            let first = |f: fn(&SectionFrame) -> Vec3| ((f(&c) - f(&a)) / (2.0 * h)).length();
782            let second =
783                |f: fn(&SectionFrame) -> Vec3| ((f(&c) - 2.0 * f(&b) + f(&a)) / (h * h)).length();
784            let slack = |bound: Scalar| bound * (1.0 + 1e-3) + 1e-6;
785            let axes: [(Axis, [Scalar; 2], &str); 4] = [
786                (|f| f.point - Point3::ZERO, bounds.point, "point"),
787                (|f| f.tangent, bounds.tangent, "tangent"),
788                (|f| f.lateral, bounds.lateral, "lateral"),
789                (|f| f.up, bounds.up, "up"),
790            ];
791            for (f, [b1, b2], name) in axes {
792                assert!(first(f) <= slack(b1), "{name}' {} > {b1} at {s}", first(f));
793                assert!(
794                    second(f) <= slack(b2),
795                    "{name}'' {} > {b2} at {s}",
796                    second(f)
797                );
798            }
799        }
800    }
801
802    fn whole3(curve: &Curve3, lo: Scalar, hi: Scalar) -> StationPiece<'_> {
803        StationPiece::between(StationCurve::Three(curve), lo, hi).unwrap()
804    }
805
806    #[test]
807    fn a_plan_circle_bounds_its_frame() {
808        let curve = Curve2::Circle(Circle2 {
809            frame: Frame2 {
810                origin: Point2::new(1.0, 2.0),
811                x: Vec2::X,
812                y: Vec2::Y,
813            },
814            radius: 3.0,
815        });
816        let piece = StationPiece::between(StationCurve::Two(&curve), 1.0, 5.0).unwrap();
817        let bounds = piece_frame_bounds(&piece, 0.0, 4.0, FrameReading::Section).unwrap();
818        assert_bounded(
819            |s| station_section2(&curve, 1.0 + s).unwrap(),
820            &bounds,
821            0.0,
822            4.0,
823        );
824        assert!((bounds.point[1] - 1.0 / 3.0).abs() <= 1e-12);
825    }
826
827    #[test]
828    fn a_tilted_circle_bounds_its_reference_up_frame() {
829        let x = Vec3::new(1.0, 0.0, 0.0);
830        let y = Vec3::new(0.0, 0.8, 0.6);
831        let curve = Curve3::Circle(Circle3 {
832            frame: axiolid_core::Frame3 {
833                origin: Point3::ZERO,
834                x,
835                y,
836                z: x.cross(y),
837            },
838            radius: 5.0,
839        });
840        // Away from where the tangent is steepest.
841        let piece = whole3(&curve, 0.5, 2.5);
842        for reading in [FrameReading::Section, FrameReading::Plan] {
843            let bounds = piece_frame_bounds(&piece, 0.0, 2.0, reading).unwrap();
844            assert_bounded(
845                |s| {
846                    let f = station_section3(&curve, 0.5 + s).unwrap();
847                    match reading {
848                        FrameReading::Section => f,
849                        FrameReading::Plan => f.plan().unwrap(),
850                    }
851                },
852                &bounds,
853                0.0,
854                2.0,
855            );
856        }
857    }
858
859    #[test]
860    fn a_vertical_curve_bounds_its_leaning_frame() {
861        let curve = Curve3::Elevated(Elevated3::new(
862            Curve2::Circle(Circle2 {
863                frame: Frame2 {
864                    origin: Point2::ZERO,
865                    x: Vec2::X,
866                    y: Vec2::Y,
867                },
868                radius: 40.0,
869            }),
870            ElevationLaw::Polynomial {
871                coefficients: vec![1.0, 0.03, -0.0004],
872            },
873        ));
874        let piece = whole3(&curve, 0.0, 30.0);
875        let bounds = piece_frame_bounds(&piece, 2.0, 28.0, FrameReading::Section).unwrap();
876        assert_bounded(|s| station_section3(&curve, s).unwrap(), &bounds, 2.0, 28.0);
877        assert_eq!(bounds.measure, 0.0);
878    }
879
880    #[test]
881    fn a_banked_transition_bounds_its_rolled_frame() {
882        // A long, gentle transition and a short, steep one, where the
883        // roll's rate dominates the frame's turning.
884        let cases = [
885            (BankConvention::TangentRotation, 60.0, 10.0, 50.0),
886            (BankConvention::VerticalRise, 60.0, 10.0, 50.0),
887            (BankConvention::TangentRotation, 8.0, 1.0, 7.0),
888            (BankConvention::VerticalRise, 8.0, 1.0, 7.0),
889        ];
890        for (convention, length, lo, hi) in cases {
891            let curve = Curve3::Banked(Banked3::new(
892                Elevated3::new(
893                    Curve2::Circle(Circle2 {
894                        frame: Frame2 {
895                            origin: Point2::ZERO,
896                            x: Vec2::X,
897                            y: Vec2::Y,
898                        },
899                        radius: 300.0,
900                    }),
901                    ElevationLaw::constant_grade(60.0, 0.02),
902                ),
903                CantLaw::new(vec![CantPiece::cosine(length, 0.0, 0.15)]),
904                CantLaw::zero(length),
905                1.5,
906                convention,
907            ));
908            let piece = whole3(&curve, 0.0, length);
909            let bounds = piece_frame_bounds(&piece, lo, hi, FrameReading::Section).unwrap();
910            assert_bounded(|s| station_section3(&curve, s).unwrap(), &bounds, lo, hi);
911        }
912    }
913
914    #[test]
915    fn lines_are_straight_and_numerical_families_unbounded() {
916        let line = Curve2::Line(Line2 {
917            origin: Point2::ZERO,
918            direction: Vec2::new(3.0, 4.0),
919        });
920        let piece = StationPiece::between(StationCurve::Two(&line), 0.0, 10.0).unwrap();
921        let bounds = piece_frame_bounds(&piece, 0.0, 10.0, FrameReading::Section).unwrap();
922        assert!(bounds.is_straight());
923        let ellipse = Curve3::Ellipse(axiolid_curve::Ellipse3 {
924            frame: axiolid_core::Frame3 {
925                origin: Point3::ZERO,
926                x: Vec3::X,
927                y: Vec3::Y,
928                z: Vec3::Z,
929            },
930            semi_axis_x: 2.0,
931            semi_axis_y: 1.0,
932        });
933        let piece = whole3(&ellipse, 0.0, 1.0);
934        assert!(piece_frame_bounds(&piece, 0.0, 1.0, FrameReading::Section).is_none());
935    }
936}