Skip to main content

axiolid_evaluate/
banked.rs

1//! Evaluation of banked curves: point, tangent and the rolled section frame.
2//!
3//! A [`Banked3`] is evaluated by PLAN distance `d`, the parameter of its
4//! centreline. The point is `base(d) + e(d) z`, `e` the pivot law. The
5//! tangent is that point's own: its derivative in `d` is
6//! `(p, g + e')`, `p` the unit plan tangent, `g` the profile grade and `e'`
7//! the pivot's rate, so a pivot that moves (rotation about the low rail
8//! through a transition) tilts the tangent by exactly what it adds to the
9//! grade. With a constant pivot it is the centreline's tangent.
10//!
11//! A held-rail pivot piece (`CantForm::AboutRail`, #279) is derived from
12//! the cant law: `e = e0 + s D / 2`, `s = +-1`. Over a height piece of the
13//! cant its derivatives are `s D^(k) / 2`; over an angle piece, with
14//! `D = b sin(psi)`,
15//! `e' = s (b / 2) cos(psi) psi'`,
16//! `e'' = s (b / 2) (cos(psi) psi'' - sin(psi) psi'^2)` and
17//! `e''' = s (b / 2) (cos(psi) psi''' - 3 sin(psi) psi' psi'' - cos(psi) psi'^3)`,
18//! so with `P_k = sup |psi^(k)|` the certified bounds are
19//! `(b / 2) P_1`, `(b / 2) (P_2 + P_1^2)` and
20//! `(b / 2) (P_3 + 3 P_1 P_2 + P_1^3)`. The Viennese bend's `P_k` are
21//! exact over the span asked for: each derivative of its shape is taken at
22//! the span's ends and at its critical points inside (`viennese_sup`);
23//! over a whole piece they are `140 / 64`, `16.8 / sqrt(5)` and `52.5`
24//! times `|dpsi| / L^k`. Where the pivot is derived, the cant law's seams
25//! are the pivot's too (`derived_cant_seams`).
26//!
27//! The section is rolled about that tangent `t`. With `n` the horizontal
28//! left normal and `u = t x n`, the roll `rho` gives the lateral axis
29//! `l = cos(rho) n + sin(rho) u` (towards the left rail head) and the
30//! section up `v = -sin(rho) n + cos(rho) u`; `rho` comes from
31//! [`BankConvention`](axiolid_curve::BankConvention): `asin(D / b)` for a
32//! rotation about the tangent, `asin(D / (b cos theta))` for an exact
33//! vertical rise `D`. The rail heads are at `point +- (b / 2) l`. Every
34//! refusal of the curve crate's [`BankError`] reaches the caller by name.
35//!
36//! Position is as exact as the centreline's; the frame adds closed-form
37//! trigonometry only.
38
39use axiolid_contracts::{GeomError, GeomResult};
40use axiolid_core::{Frame3, Point3, Scalar, Vec3};
41use axiolid_curve::{BankError, Banked3, CantForm, CantLaw, CantPiece, CantValue};
42
43use crate::arc_length::{elevated_point, elevated_tangent};
44
45fn refused(error: BankError) -> GeomError {
46    GeomError::InvalidInput(format!("banked curve: {error}"))
47}
48
49fn invalid(detail: &str) -> GeomError {
50    GeomError::InvalidInput(detail.to_owned())
51}
52
53/// The section of a banked curve at one station.
54#[non_exhaustive]
55#[derive(Debug, Clone, Copy, PartialEq)]
56pub struct BankedSection {
57    /// The rotation point: the centreline point raised by the pivot.
58    pub point: Point3,
59    /// Unit tangent of the banked curve.
60    pub tangent: Vec3,
61    /// Unit lateral axis, square to the tangent, towards the left rail head.
62    pub lateral: Vec3,
63    /// Unit section up, `tangent x lateral`.
64    pub up: Vec3,
65    /// Cant `D`: the left rail head above the right, as the law states it.
66    pub cant: Scalar,
67    /// Nominal bank angle `psi = asin(D / b)`, radians.
68    pub bank_angle: Scalar,
69    /// The roll about the tangent the convention gives, radians: `psi`
70    /// itself for a rotation about the tangent.
71    pub roll: Scalar,
72    /// Elevation of the rotation point above the profile.
73    pub pivot: Scalar,
74    /// Grade of the banked curve, `dz/dd`: the profile's plus the pivot's
75    /// rate.
76    pub grade: Scalar,
77    /// Rail-head distance `b`.
78    pub rail_head_distance: Scalar,
79}
80
81impl BankedSection {
82    /// The section as a frame, in the curve-evaluation contract's layout:
83    /// `x` the tangent, `y` the section up, `z = x x y` (to the right).
84    #[must_use]
85    pub fn frame(&self) -> Frame3 {
86        Frame3 {
87            origin: self.point,
88            x: self.tangent,
89            y: self.up,
90            z: -self.lateral,
91        }
92    }
93
94    /// Left and right rail heads, `b / 2` either side of the rotation point
95    /// along the lateral axis.
96    #[must_use]
97    pub fn rail_heads(&self) -> (Point3, Point3) {
98        let half = 0.5 * self.rail_head_distance * self.lateral;
99        (self.point + half, self.point - half)
100    }
101}
102
103/// Unit plan tangent, horizontal, at plan distance `d`.
104fn plan_direction(curve: &Banked3, d: Scalar) -> GeomResult<Vec3> {
105    let tangent = elevated_tangent(&curve.base, d)?;
106    let horizontal = Vec3::new(tangent.x, tangent.y, 0.0);
107    let length = horizontal.length();
108    if !length.is_finite() || length <= 0.0 {
109        return Err(invalid("banked curve: centreline has no plan direction"));
110    }
111    Ok(horizontal / length)
112}
113
114/// The cant law covers `d`, or the station has no section.
115fn covered(curve: &Banked3, d: Scalar) -> GeomResult<()> {
116    if curve.cant.has_rail_pieces() {
117        return Err(refused(BankError::RailInCant));
118    }
119    curve.cant.value_at(d).map(|_| ()).ok_or_else(|| {
120        refused(BankError::OutsideLaw {
121            law: "cant",
122            distance: d,
123        })
124    })
125}
126
127/// Position on a banked curve at plan distance `d`: `base(d) + e(d) z`.
128///
129/// # Errors
130///
131/// The centreline's refusals, and the pivot and cant laws' by name
132/// outside their span.
133pub fn banked_point(curve: &Banked3, d: Scalar) -> GeomResult<Point3> {
134    if !d.is_finite() {
135        return Err(invalid("plan distance must be finite"));
136    }
137    covered(curve, d)?;
138    let (pivot, _) = curve.pivot_at(d).map_err(refused)?;
139    let base = elevated_point(&curve.base, d)?;
140    Ok(base + pivot * Vec3::Z)
141}
142
143/// Derivative of [`banked_point`] in plan distance: `(p, g + e')`. Not
144/// unit: its length is `sqrt(1 + (g + e')^2)`.
145///
146/// # Errors
147///
148/// As [`banked_point`].
149pub fn banked_derivative(curve: &Banked3, d: Scalar) -> GeomResult<Vec3> {
150    if !d.is_finite() {
151        return Err(invalid("plan distance must be finite"));
152    }
153    covered(curve, d)?;
154    let (_, pivot_rate) = curve.pivot_at(d).map_err(refused)?;
155    let plan = plan_direction(curve, d)?;
156    // Every elevation law, including an intrinsic profile (#238).
157    let grade = crate::elevation::elevation_grade(&curve.base.elevation, d)?;
158    let derivative = Vec3::new(plan.x, plan.y, grade + pivot_rate);
159    if !derivative.is_finite() {
160        return Err(invalid("banked curve: grade is not finite"));
161    }
162    Ok(derivative)
163}
164
165/// Unit tangent of a banked curve at plan distance `d`.
166///
167/// # Errors
168///
169/// As [`banked_point`].
170pub fn banked_tangent(curve: &Banked3, d: Scalar) -> GeomResult<Vec3> {
171    Ok(banked_derivative(curve, d)?.normalize())
172}
173
174/// Second derivative of [`banked_point`] in plan distance (#252):
175/// the centreline's `(p'', z'')` plus the pivot's `e''` vertically.
176///
177/// # Errors
178///
179/// As [`banked_point`].
180pub fn banked_second_derivative(curve: &Banked3, d: Scalar) -> GeomResult<Vec3> {
181    if !d.is_finite() {
182        return Err(invalid("plan distance must be finite"));
183    }
184    covered(curve, d)?;
185    // Every refusal of the laws by name first, then the jet.
186    curve.pivot_at(d).map_err(refused)?;
187    let [_, _, bend] = pivot_jet(curve, d, false).ok_or_else(|| {
188        refused(BankError::OutsideLaw {
189            law: "pivot",
190            distance: d,
191        })
192    })?;
193    let base = crate::elevated::elevated_second_derivative(&curve.base, d)?;
194    Ok(base + bend * Vec3::Z)
195}
196
197/// The height piece of a law at `d` and the distance into it: the piece
198/// starting there, or with `before` the one ending there.
199fn piece_at(law: &CantLaw, d: Scalar, before: bool) -> Option<(&CantPiece, Scalar)> {
200    if !d.is_finite() || d < 0.0 || !law.is_well_formed() {
201        return None;
202    }
203    let mut start = 0.0;
204    let last = law.pieces.len() - 1;
205    for (index, piece) in law.pieces.iter().enumerate() {
206        let end = start + piece.length;
207        let inside = if before { d <= end } else { d < end };
208        if inside || (index == last && d <= end) {
209            return Some((piece, d - start));
210        }
211        start = end;
212    }
213    None
214}
215
216/// `(e, e', e'')` of a banked curve's pivot at `d`, in plan distance,
217/// read from the piece starting there or with `before` the one ending
218/// there; `None` off the law, for an angle piece, or where a held-rail
219/// piece's cant has no value.
220pub(crate) fn pivot_jet(curve: &Banked3, d: Scalar, before: bool) -> Option<[Scalar; 3]> {
221    let (piece, s) = piece_at(&curve.pivot, d, before)?;
222    if let CantForm::AboutRail { rail, elevation } = piece.form {
223        let half = 0.5 * rail.pivot_sign();
224        let [cant, rate, bend] = cant_jet(curve, d, before)?;
225        let out = [elevation + half * cant, half * rate, half * bend];
226        return out.iter().all(|v| v.is_finite()).then_some(out);
227    }
228    height_jet(piece, s)
229}
230
231/// `(D, D', D'')` of a banked curve's cant at `d`, in plan distance: a
232/// height piece's own, an angle piece's through `D = b sin(psi)`. `None`
233/// off the law, for a held-rail piece, or where `|D| > b` or
234/// `|psi| > pi / 2`.
235fn cant_jet(curve: &Banked3, d: Scalar, before: bool) -> Option<[Scalar; 3]> {
236    let b = curve.rail_head_distance;
237    if !(b.is_finite() && b > 0.0) {
238        return None;
239    }
240    let (piece, s) = piece_at(&curve.cant, d, before)?;
241    let out = if let CantForm::VienneseBend { start, change } = piece.form {
242        let length = piece.length;
243        let xi = s / length;
244        let xi2 = xi * xi;
245        let one_minus = 1.0 - xi;
246        let shape = xi2 * xi2 * (35.0 + xi * (-84.0 + xi * (70.0 - 20.0 * xi)));
247        let slope = 140.0 * xi2 * xi * one_minus * one_minus * one_minus;
248        let bend = 420.0 * xi2 * one_minus * one_minus * (1.0 - 2.0 * xi);
249        let psi = start + change * shape;
250        if !psi.is_finite() || psi.abs() > core::f64::consts::FRAC_PI_2 {
251            return None;
252        }
253        let rate = change * slope / length;
254        let accel = change * bend / (length * length);
255        let (sin, cos) = psi.sin_cos();
256        [
257            b * sin,
258            b * cos * rate,
259            b * (cos * accel - sin * rate * rate),
260        ]
261    } else {
262        let jet = height_jet(piece, s)?;
263        if jet[0].abs() > b {
264            return None;
265        }
266        jet
267    };
268    out.iter().all(|v| v.is_finite()).then_some(out)
269}
270
271/// `(v, v', v'')` of a height piece `s` into it, in plan distance; `None`
272/// for an angle or held-rail piece.
273fn height_jet(piece: &CantPiece, s: Scalar) -> Option<[Scalar; 3]> {
274    let length = piece.length;
275    let xi = s / length;
276    let (value, slope, bend) = match &piece.form {
277        CantForm::Polynomial { coefficients } => {
278            let horner =
279                |k: usize| -> Scalar {
280                    coefficients.iter().enumerate().skip(k).rev().fold(
281                        0.0,
282                        |accumulated, (i, c)| {
283                            let falling: Scalar = (0..k).map(|j| (i - j) as Scalar).product();
284                            accumulated * xi + c * falling
285                        },
286                    )
287                };
288            (horner(0), horner(1), horner(2))
289        }
290        CantForm::Cosine { start, change } => {
291            let pi = core::f64::consts::PI;
292            let (sin, cos) = (pi * xi).sin_cos();
293            (
294                start + change * 0.5 * (1.0 - cos),
295                change * 0.5 * pi * sin,
296                change * 0.5 * pi * pi * cos,
297            )
298        }
299        CantForm::Sine { start, change } => {
300            let tau = core::f64::consts::TAU;
301            let (sin, cos) = (tau * xi).sin_cos();
302            (
303                start + change * (xi - sin / tau),
304                change * (1.0 - cos),
305                change * tau * sin,
306            )
307        }
308        _ => return None,
309    };
310    let out = [value, slope / length, bend / (length * length)];
311    out.iter().all(|v| v.is_finite()).then_some(out)
312}
313
314/// Certified `(sup |e'|, sup |e''|, sup |e'''|)` of a banked curve's pivot
315/// over `[lo, hi]` inside ONE piece (#252), and for a held-rail piece
316/// inside one piece of the cant law too (#279: half the cant's bounds, see
317/// the [module documentation](self)); `None` across a seam, off the law,
318/// or for an angle piece.
319pub(crate) fn pivot_bounds(curve: &Banked3, lo: Scalar, hi: Scalar) -> Option<[Scalar; 3]> {
320    let (lo, hi) = (lo.min(hi), lo.max(hi));
321    let (piece, s_lo) = within_one_piece(&curve.pivot, lo, hi)?;
322    if let CantForm::AboutRail { .. } = piece.form {
323        return cant_bounds(curve, lo, hi).map(|bounds| bounds.map(|v| 0.5 * v));
324    }
325    height_bounds(piece, s_lo, s_lo + (hi - lo))
326}
327
328/// The piece of `law` holding all of `[lo, hi]`, and `lo`'s distance into
329/// it.
330fn within_one_piece(law: &CantLaw, lo: Scalar, hi: Scalar) -> Option<(&CantPiece, Scalar)> {
331    let (piece, s_lo) = piece_at(law, lo, false)?;
332    (s_lo + (hi - lo) <= piece.length * (1.0 + 1e-12)).then_some((piece, s_lo))
333}
334
335/// Certified `(sup |D'|, sup |D''|, sup |D'''|)` of a banked curve's cant
336/// over `[lo, hi]` inside one of its pieces: a height piece's own, an
337/// angle piece's through `D = b sin(psi)` (module documentation).
338fn cant_bounds(curve: &Banked3, lo: Scalar, hi: Scalar) -> Option<[Scalar; 3]> {
339    let b = curve.rail_head_distance;
340    if !(b.is_finite() && b > 0.0) {
341        return None;
342    }
343    let (piece, s_lo) = within_one_piece(&curve.cant, lo, hi)?;
344    let out = if let CantForm::VienneseBend { change, .. } = piece.form {
345        let length = piece.length;
346        let c = change.abs();
347        let span = (s_lo / length, (s_lo + (hi - lo)) / length);
348        let p1 = c * viennese_sup(1, span) / length;
349        let p2 = c * viennese_sup(2, span) / (length * length);
350        let p3 = c * viennese_sup(3, span) / (length * length * length);
351        [
352            b * p1,
353            b * (p2 + p1 * p1),
354            b * (p3 + 3.0 * p1 * p2 + p1 * p1 * p1),
355        ]
356        .map(|v| v * (1.0 + crate::bound::ROUNDING))
357    } else {
358        height_bounds(piece, s_lo, s_lo + (hi - lo))?
359    };
360    out.iter().all(|v| v.is_finite()).then_some(out)
361}
362
363/// Certified `(sup |phi'|, sup |phi''|)` of a banked curve's roll over
364/// `[lo, hi]` inside one piece of its cant law (#247).
365///
366/// The roll is `asin(g)`: `g = D / b` about the tangent, `g = D w / b`
367/// with `w = sqrt(1 + G^2) = 1 / cos(theta)` for a vertical rise, `G` the
368/// grade of the point path. With `G_j` the point path's certified
369/// suprema (`|G| <= sup |c'|`, `|G'| <= sup |c''|`, `|G''| <= sup |c'''|`),
370/// `|w'| <= |G'|` and `|w''| <= |G''| + |G'|^2`; `D`'s are the cant's
371/// (`cant_bounds`), `sup |D| <= |D(lo)| + (hi - lo) sup |D'|`. Then
372/// `phi' = g' / c` and `phi'' = g'' / c + g g'^2 / c^3`, `c = sqrt(1 -
373/// g^2) >= sqrt(1 - sup |g|^2)`. `None` across a seam of the cant law, off
374/// it, for a held-rail piece, or where `sup |g|` reaches one.
375pub(crate) fn roll_bounds(curve: &Banked3, lo: Scalar, hi: Scalar) -> Option<[Scalar; 2]> {
376    let (lo, hi) = (lo.min(hi), lo.max(hi));
377    let b = curve.rail_head_distance;
378    if !(b.is_finite() && b > 0.0) || curve.cant.has_rail_pieces() {
379        return None;
380    }
381    let [d1, d2, _] = cant_bounds(curve, lo, hi)?;
382    let d0 = curve.cant_at(lo).ok()?.abs() + (hi - lo) * d1;
383    let (g0, g1, g2) = match curve.convention {
384        axiolid_curve::BankConvention::TangentRotation => (d0 / b, d1 / b, d2 / b),
385        axiolid_curve::BankConvention::VerticalRise => {
386            let point = crate::elevated::banked_derivative_bounds(curve, lo, hi)?;
387            let w = point.first.hypot(1.0);
388            let w1 = point.second;
389            let w2 = point.third + point.second * point.second;
390            (
391                d0 * w / b,
392                (d1 * w + d0 * w1) / b,
393                (d2 * w + 2.0 * d1 * w1 + d0 * w2) / b,
394            )
395        }
396        _ => return None,
397    };
398    if g0.is_nan() || g0 >= 1.0 {
399        return None;
400    }
401    let c = (1.0 - g0 * g0).sqrt();
402    let out =
403        [g1 / c, g2 / c + g0 * g1 * g1 / (c * c * c)].map(|v| v * (1.0 + crate::bound::ROUNDING));
404    out.iter().all(|v| v.is_finite()).then_some(out)
405}
406
407/// `sup |f^(k)|` over `xi` in `[lo, hi]` (clamped to `[0, 1]`) of the
408/// Viennese bend's shape `f = xi^4 (35 - 84 xi + 70 xi^2 - 20 xi^3)`, for
409/// `k` in `1..=3`:
410/// `f' = 140 w^3`, `f'' = 420 w^2 (1 - 2 xi)`, `f''' = 840 w (1 - 5 w)`
411/// with `w = xi (1 - xi)`. Each is smooth, so its supremum over the span
412/// is at an end or at a critical point inside: `1/2` for `f'`,
413/// `1/2 +- 1 / sqrt(20)` for `f''`, `1/2` and `1/2 +- sqrt(0.15)` for
414/// `f'''`. Over the whole piece that is `140 / 64`, `16.8 / sqrt(5)` and
415/// `52.5`. The values are inflated by [`crate::bound::ROUNDING`] relative
416/// and `1e-15` absolute for the rounding of their own evaluation.
417fn viennese_sup(k: usize, (lo, hi): (Scalar, Scalar)) -> Scalar {
418    let (lo, hi) = (lo.clamp(0.0, 1.0), hi.clamp(0.0, 1.0));
419    let derivative = |xi: Scalar| -> Scalar {
420        let w = xi * (1.0 - xi);
421        match k {
422            1 => 140.0 * w * w * w,
423            2 => 420.0 * w * w * (1.0 - 2.0 * xi),
424            _ => 840.0 * w * (1.0 - 5.0 * w),
425        }
426    };
427    let critical: &[Scalar] = match k {
428        1 => &[0.5],
429        2 => &[0.5 - 0.223_606_797_749_979, 0.5 + 0.223_606_797_749_979],
430        _ => &[
431            0.5 - 0.387_298_334_620_741_7,
432            0.5,
433            0.5 + 0.387_298_334_620_741_7,
434        ],
435    };
436    let sup = critical
437        .iter()
438        .copied()
439        .filter(|&xi| xi > lo && xi < hi)
440        .chain([lo, hi])
441        .map(|xi| derivative(xi).abs())
442        .fold(0.0, Scalar::max);
443    sup * (1.0 + crate::bound::ROUNDING) + 1e-15
444}
445
446/// Certified `(sup |v'|, sup |v''|, sup |v'''|)` of a height piece over
447/// `[s_lo, s_hi]` into it; `None` for an angle or held-rail piece.
448///
449/// In the piece's `xi = s / L`, the `k`-th derivative in plan distance is
450/// the form's `k`-th in `xi` over `L^k`: a polynomial's by
451/// `sum i!/(i-k)! |c_i| m^(i-k)` with `m` the larger `|xi|`, the
452/// half-cosine's `|dD| / 2 pi^k`, the sine transition's `2 |dD|`,
453/// `2 pi |dD|`, `4 pi^2 |dD|`.
454fn height_bounds(piece: &CantPiece, s_lo: Scalar, s_hi: Scalar) -> Option<[Scalar; 3]> {
455    let length = piece.length;
456    let in_xi = match &piece.form {
457        CantForm::Polynomial { coefficients } => {
458            let m = (s_lo / length).abs().max((s_hi / length).abs());
459            let term = |k: usize| -> Scalar {
460                coefficients
461                    .iter()
462                    .enumerate()
463                    .skip(k)
464                    .map(|(i, c)| {
465                        let falling: Scalar = (0..k).map(|j| (i - j) as Scalar).product();
466                        falling * c.abs() * m.powi((i - k) as i32)
467                    })
468                    .sum()
469            };
470            [term(1), term(2), term(3)]
471        }
472        CantForm::Cosine { change, .. } => {
473            let pi = core::f64::consts::PI;
474            let half = 0.5 * change.abs();
475            [half * pi, half * pi * pi, half * pi * pi * pi]
476        }
477        CantForm::Sine { change, .. } => {
478            let tau = core::f64::consts::TAU;
479            let c = change.abs();
480            [2.0 * c, tau * c, tau * tau * c]
481        }
482        _ => return None,
483    };
484    let out = [
485        in_xi[0] / length,
486        in_xi[1] / (length * length),
487        in_xi[2] / (length * length * length),
488    ]
489    .map(|v| v * (1.0 + crate::bound::ROUNDING));
490    out.iter().all(|v| v.is_finite()).then_some(out)
491}
492
493/// The cant law's seams inside or bounding a held-rail piece of the
494/// pivot law, ascending: where the pivot derived from the cant may lose
495/// smoothness (#279). Empty when the pivot has no held-rail piece.
496pub(crate) fn derived_cant_seams(curve: &Banked3) -> Vec<Scalar> {
497    let mut spans = Vec::new();
498    let mut start = 0.0;
499    for piece in &curve.pivot.pieces {
500        let end = start + piece.length;
501        if matches!(piece.form, CantForm::AboutRail { .. }) {
502            spans.push((start, end));
503        }
504        start = end;
505    }
506    if spans.is_empty() {
507        return Vec::new();
508    }
509    curve
510        .cant
511        .seams()
512        .into_iter()
513        .filter(|&seam| spans.iter().any(|&(a, b)| seam >= a && seam <= b))
514        .collect()
515}
516
517/// The section at plan distance `d`: rotation point, rolled frame, cant,
518/// bank angle and roll.
519///
520/// # Errors
521///
522/// As [`banked_point`], and by name: a cant beyond the rail-head distance,
523/// beyond the vertical span `b cos(theta)` under a vertical-rise
524/// convention, an angle piece beyond a quarter turn, an angle piece in the
525/// pivot law, a bad rail-head distance.
526pub fn banked_section(curve: &Banked3, d: Scalar) -> GeomResult<BankedSection> {
527    let point = banked_point(curve, d)?;
528    let derivative = banked_derivative(curve, d)?;
529    let (pivot, _) = curve.pivot_at(d).map_err(refused)?;
530    let b = curve.rail_head_distance;
531    let tangent = derivative.normalize();
532    // `cos(theta)` from the derivative `(p, G)`: `1 / sqrt(1 + G^2)`.
533    let grade = derivative.z;
534    let grade_cosine = 1.0 / grade.hypot(1.0);
535    let cant = curve.cant_at(d).map_err(refused)?;
536    let bank_angle = curve.bank_angle_at(d).map_err(refused)?;
537    let roll = match curve.cant.value_at(d) {
538        Some(CantValue::Angle(psi)) => curve.convention.roll_from_angle(psi, b, grade_cosine),
539        _ => curve.convention.roll(cant, b, grade_cosine),
540    }
541    .map_err(refused)?;
542    let plan = Vec3::new(derivative.x, derivative.y, 0.0);
543    let normal = Vec3::Z.cross(plan).normalize();
544    let square_up = tangent.cross(normal);
545    let (sin, cos) = roll.sin_cos();
546    let lateral = cos * normal + sin * square_up;
547    let up = -sin * normal + cos * square_up;
548    Ok(BankedSection {
549        point,
550        tangent,
551        lateral,
552        up,
553        cant,
554        bank_angle,
555        roll,
556        pivot,
557        grade,
558        rail_head_distance: b,
559    })
560}