Skip to main content

axiolid_curve/
banked.rs

1//! A centreline carrying a roll about its tangent: cant, bank angle and the
2//! point the section rotates about.
3//!
4//! A track is authored as three independent laws over plan distance: the
5//! plan, the vertical profile ([`Elevated3`]), and a cant law giving how far
6//! one rail head stands above the other. [`Banked3`] pairs the first two
7//! with the third, so the section frame an object is placed by is part of
8//! the curve value instead of something every consumer re-derives.
9//!
10//! # The laws
11//!
12//! [`CantLaw`] is a run of [`CantPiece`]s laid end to end from plan distance
13//! zero. Each piece is written in its OWN normalised coordinate
14//! `xi = s / length`, `s` being the distance into the piece, because that is
15//! how transition forms are tabulated: a Bloss transition is
16//! `D1 + dD (3 - 2 xi) xi^2` whatever its length. The forms ([`CantForm`])
17//! give the cant `D` directly (polynomial in `xi`, half-cosine, sine), or the
18//! bank angle `psi` directly (the seventh-degree Viennese bend). A law value
19//! says which it is ([`CantValue`]).
20//!
21//! The rail-head distance `b` relates them: `psi = asin(D / b)` and
22//! `D = b sin(psi)`. A cant larger than `b` has no bank angle and is refused
23//! by name ([`BankError::CantExceedsRailHeadDistance`]), never clamped.
24//!
25//! The pivot law `e(d)` is a second [`CantLaw`] of height pieces: the
26//! elevation of the point the section rotates about above the profile. Zero
27//! rotates about the centreline, `D / 2` (for a left-high cant) about the
28//! low rail. The banked curve's point is `base(d) + e(d) z`.
29//!
30//! # Rotation about a held rail
31//!
32//! A pivot piece may instead be [`CantForm::AboutRail`] (#279): the section
33//! rotates about one rail head, which keeps its height `e0` above the
34//! profile while the other moves by the cant. Its elevation is DERIVED from
35//! the cant law at the same plan distance,
36//! `e(d) = e0 + s (b / 2) sin(psi(d)) = e0 + s D(d) / 2`, with `s = +1`
37//! about the right rail and `-1` about the left, whether the cant law
38//! gives `D` or `psi` (`D = b sin(psi)`). So a Viennese bend, whose law is
39//! a polynomial in the bank angle, rotates about its low rail exactly,
40//! with rate `e' = s (b / 2) cos(psi) psi'`. Under
41//! [`BankConvention::VerticalRise`] the held rail head then stands exactly
42//! `e0` above the profile at every station, on any grade; under
43//! [`BankConvention::TangentRotation`] it does on level track, and drifts
44//! by `(D / 2)(1 - cos theta)` where the point path climbs at angle
45//! `theta`. A held-rail piece has no value of its own, so it belongs in a
46//! pivot law only ([`BankError::RailInCant`]).
47//!
48//! # Positive cant raises the LEFT rail
49//!
50//! `D` is the left rail head's height minus the right one's, left being
51//! the side to the left of the direction of travel along the plan. A
52//! positive bank angle rolls the section anticlockwise seen from behind.
53//!
54//! # Two conventions, named, never defaulted
55//!
56//! On a grade, "the rail heads differ by `D`" is ambiguous, and the
57//! difference is systematic, not noise. [`BankConvention`] names the two
58//! readings and a [`Banked3`] carries one; there is no default (ADR 0081).
59//! With `t` the unit tangent of the banked curve, `theta` its grade angle,
60//! `n` the horizontal left normal and `u = t x n` (the up direction square
61//! to the tangent), the section is rolled about `t` by an angle `rho`:
62//! lateral `l = cos(rho) n + sin(rho) u`, section up
63//! `v = -sin(rho) n + cos(rho) u`. Both frames are exactly orthonormal and
64//! the rail heads lie `b` apart along `l` in the plane square to `t`.
65//!
66//! - [`BankConvention::TangentRotation`]: `rho = psi = asin(D / b)`. The
67//!   cross-section is the nominal level-track section turned about the
68//!   tangent. The rail heads then differ in height by `D cos(theta)`.
69//! - [`BankConvention::VerticalRise`]: `rho = asin(D / (b cos theta))`, the
70//!   one roll for which the rail heads differ in height by exactly `D`.
71//!   Refused when `|D| > b cos(theta)`.
72//!
73//! The rises differ by `D (1 - cos theta)`: `1.9994e-4 D` at a 2% grade,
74//! 0.03 mm on a 150 mm cant. On level track the two agree exactly.
75
76use core::fmt;
77
78use axiolid_core::Scalar;
79
80use crate::Elevated3;
81
82/// The shape of one cant piece over its own `xi = s / length` in `[0, 1]`.
83///
84/// Height forms give the cant `D` (the left rail head above the right) in
85/// length units; the angle form gives the bank angle `psi` in radians.
86#[non_exhaustive]
87#[derive(Debug, Clone, PartialEq)]
88#[cfg_attr(
89    feature = "serde",
90    derive(serde::Serialize, serde::Deserialize),
91    serde(deny_unknown_fields)
92)]
93pub enum CantForm {
94    /// `D(xi) = coefficients[0] + coefficients[1] xi + coefficients[2] xi^2 + ...`
95    ///
96    /// Covers a constant cant (degree 0), a linear transition (degree 1),
97    /// the Bloss transition `D1 + dD (3 - 2 xi) xi^2` (degree 3) and each
98    /// half of a Helmert transition (degree 2; see [`CantPiece::helmert`]).
99    /// An empty list is zero.
100    Polynomial {
101        /// Coefficients in ascending powers of `xi`.
102        coefficients: Vec<Scalar>,
103    },
104    /// `D(xi) = start + change (1 - cos(pi xi)) / 2`: the half-cosine
105    /// transition, tangent to its neighbours' slopes at both ends.
106    Cosine {
107        /// Cant at `xi = 0`.
108        start: Scalar,
109        /// Cant gained over the piece.
110        change: Scalar,
111    },
112    /// `D(xi) = start + change (xi - sin(2 pi xi) / (2 pi))`: the sine
113    /// transition, whose slope AND curvature vanish at both ends.
114    Sine {
115        /// Cant at `xi = 0`.
116        start: Scalar,
117        /// Cant gained over the piece.
118        change: Scalar,
119    },
120    /// `psi(xi) = start + change xi^4 (35 - 84 xi + 70 xi^2 - 20 xi^3)`: the
121    /// Viennese bend, written for the bank angle itself. Its first three
122    /// derivatives vanish at both ends. The cant follows as `b sin(psi)`.
123    VienneseBend {
124        /// Bank angle at `xi = 0`, radians.
125        start: Scalar,
126        /// Bank angle gained over the piece, radians.
127        change: Scalar,
128    },
129    /// A pivot piece rotating about the held `rail` (#279):
130    /// `e = elevation + s (b / 2) sin(psi) = elevation + s D / 2`, `D` and
131    /// `psi` the cant law's at the same plan distance, `s = +1` about the
132    /// right rail and `-1` about the left. See the
133    /// [module docs](self#rotation-about-a-held-rail).
134    ///
135    /// Derived, not a law of `xi`: [`CantLaw::value_at`] has no value for
136    /// it and [`Banked3::pivot_at`] reads it. Refused in a cant law.
137    AboutRail {
138        /// The rail head that keeps its height.
139        rail: RailSide,
140        /// Height `e0` of the held rail head above the profile.
141        elevation: Scalar,
142    },
143}
144
145/// A rail head of a canted track, left or right of the direction of
146/// travel along the plan.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
148#[cfg_attr(
149    feature = "serde",
150    derive(serde::Serialize, serde::Deserialize),
151    serde(deny_unknown_fields)
152)]
153pub enum RailSide {
154    /// The rail head to the left, raised by a positive cant.
155    Left,
156    /// The rail head to the right.
157    Right,
158}
159
160impl RailSide {
161    /// `s` in `e = e0 + s D / 2` for a rotation about this rail: `+1`
162    /// about the right rail (the pivot rises with a left-high cant), `-1`
163    /// about the left.
164    #[must_use]
165    pub const fn pivot_sign(self) -> Scalar {
166        match self {
167            Self::Left => -1.0,
168            Self::Right => 1.0,
169        }
170    }
171}
172
173/// What a cant law gives at a station: a cant height or a bank angle.
174#[non_exhaustive]
175#[derive(Debug, Clone, Copy, PartialEq)]
176pub enum CantValue {
177    /// Cant `D`, or a rate of it, in length units (per unit plan distance
178    /// for a rate).
179    Height(Scalar),
180    /// Bank angle `psi`, or a rate of it, in radians (per unit plan
181    /// distance for a rate).
182    Angle(Scalar),
183}
184
185/// One piece of a [`CantLaw`]: a form over a plan length.
186#[derive(Debug, Clone, PartialEq)]
187#[cfg_attr(
188    feature = "serde",
189    derive(serde::Serialize, serde::Deserialize),
190    serde(deny_unknown_fields)
191)]
192pub struct CantPiece {
193    /// Plan length the piece covers. Positive and finite when well formed.
194    pub length: Scalar,
195    /// The law over the piece's own `xi = s / length`.
196    pub form: CantForm,
197}
198
199impl CantPiece {
200    /// A piece of `form` over `length`.
201    #[must_use]
202    pub const fn new(length: Scalar, form: CantForm) -> Self {
203        Self { length, form }
204    }
205
206    /// A constant cant.
207    #[must_use]
208    pub fn constant(length: Scalar, cant: Scalar) -> Self {
209        Self::new(
210            length,
211            CantForm::Polynomial {
212                coefficients: vec![cant],
213            },
214        )
215    }
216
217    /// A linear transition from `start` to `end`.
218    #[must_use]
219    pub fn linear(length: Scalar, start: Scalar, end: Scalar) -> Self {
220        Self::new(
221            length,
222            CantForm::Polynomial {
223                coefficients: vec![start, end - start],
224            },
225        )
226    }
227
228    /// A Bloss transition: `start + (end - start) (3 - 2 xi) xi^2`.
229    #[must_use]
230    pub fn bloss(length: Scalar, start: Scalar, end: Scalar) -> Self {
231        let change = end - start;
232        Self::new(
233            length,
234            CantForm::Polynomial {
235                coefficients: vec![start, 0.0, 3.0 * change, -2.0 * change],
236            },
237        )
238    }
239
240    /// A Helmert (two-parabola) transition from `start` to `end` over
241    /// `length`, as its two halves.
242    ///
243    /// Over the whole transition, with `x = s / length`, the cant is
244    /// `start + 2 dD x^2` up to `x = 1/2` and `end - 2 dD (1 - x)^2` after.
245    /// Each half is written in its own `xi`:
246    /// `start + dD/2 xi^2`, then `start + dD/2 + dD xi - dD/2 xi^2`.
247    #[must_use]
248    pub fn helmert(length: Scalar, start: Scalar, end: Scalar) -> [Self; 2] {
249        let change = end - start;
250        let half = 0.5 * length;
251        [
252            Self::new(
253                half,
254                CantForm::Polynomial {
255                    coefficients: vec![start, 0.0, 0.5 * change],
256                },
257            ),
258            Self::new(
259                half,
260                CantForm::Polynomial {
261                    coefficients: vec![start + 0.5 * change, change, -0.5 * change],
262                },
263            ),
264        ]
265    }
266
267    /// A half-cosine transition from `start` to `end`.
268    #[must_use]
269    pub fn cosine(length: Scalar, start: Scalar, end: Scalar) -> Self {
270        Self::new(
271            length,
272            CantForm::Cosine {
273                start,
274                change: end - start,
275            },
276        )
277    }
278
279    /// A sine transition from `start` to `end`.
280    #[must_use]
281    pub fn sine(length: Scalar, start: Scalar, end: Scalar) -> Self {
282        Self::new(
283            length,
284            CantForm::Sine {
285                start,
286                change: end - start,
287            },
288        )
289    }
290
291    /// A Viennese bend from bank angle `start` to `end`, radians.
292    #[must_use]
293    pub fn viennese_bend(length: Scalar, start: Scalar, end: Scalar) -> Self {
294        Self::new(
295            length,
296            CantForm::VienneseBend {
297                start,
298                change: end - start,
299            },
300        )
301    }
302
303    /// A pivot piece rotating about the held `rail`, whose head stands
304    /// `elevation` above the profile ([`CantForm::AboutRail`]).
305    #[must_use]
306    pub const fn about_rail(length: Scalar, rail: RailSide, elevation: Scalar) -> Self {
307        Self::new(length, CantForm::AboutRail { rail, elevation })
308    }
309
310    /// Value and rate (per unit PLAN distance) at `s` into the piece.
311    fn sample(&self, s: Scalar) -> Option<(CantValue, CantValue)> {
312        let length = self.length;
313        if !(length.is_finite() && length > 0.0) {
314            return None;
315        }
316        let xi = s / length;
317        let (value, slope, angle) = match &self.form {
318            CantForm::Polynomial { coefficients } => {
319                let value = coefficients
320                    .iter()
321                    .rev()
322                    .fold(0.0, |accumulated, c| accumulated * xi + c);
323                let slope = coefficients
324                    .iter()
325                    .enumerate()
326                    .skip(1)
327                    .rev()
328                    .fold(0.0, |accumulated, (power, c)| {
329                        accumulated * xi + c * power as Scalar
330                    });
331                (value, slope, false)
332            }
333            CantForm::Cosine { start, change } => {
334                let phase = core::f64::consts::PI * xi;
335                (
336                    start + change * 0.5 * (1.0 - phase.cos()),
337                    change * 0.5 * core::f64::consts::PI * phase.sin(),
338                    false,
339                )
340            }
341            CantForm::Sine { start, change } => {
342                let phase = core::f64::consts::TAU * xi;
343                (
344                    start + change * (xi - phase.sin() / core::f64::consts::TAU),
345                    change * (1.0 - phase.cos()),
346                    false,
347                )
348            }
349            CantForm::VienneseBend { start, change } => {
350                let xi2 = xi * xi;
351                let shape = xi2 * xi2 * (35.0 + xi * (-84.0 + xi * (70.0 - 20.0 * xi)));
352                let one_minus = 1.0 - xi;
353                let rate = 140.0 * xi2 * xi * one_minus * one_minus * one_minus;
354                (start + change * shape, change * rate, true)
355            }
356            // Derived from the cant law: no value of its own.
357            CantForm::AboutRail { .. } => return None,
358        };
359        let rate = slope / length;
360        if !(value.is_finite() && rate.is_finite()) {
361            return None;
362        }
363        Some(if angle {
364            (CantValue::Angle(value), CantValue::Angle(rate))
365        } else {
366            (CantValue::Height(value), CantValue::Height(rate))
367        })
368    }
369
370    fn is_well_formed(&self) -> bool {
371        let finite = match &self.form {
372            CantForm::Polynomial { coefficients } => coefficients.iter().all(|c| c.is_finite()),
373            CantForm::Cosine { start, change }
374            | CantForm::Sine { start, change }
375            | CantForm::VienneseBend { start, change } => start.is_finite() && change.is_finite(),
376            CantForm::AboutRail { elevation, .. } => elevation.is_finite(),
377        };
378        finite && self.length.is_finite() && self.length > 0.0
379    }
380
381    fn is_angle(&self) -> bool {
382        matches!(self.form, CantForm::VienneseBend { .. })
383    }
384
385    fn is_rail(&self) -> bool {
386        matches!(self.form, CantForm::AboutRail { .. })
387    }
388}
389
390/// A scalar law over plan distance: pieces laid end to end from zero.
391///
392/// Piece `i` spans `[sum of earlier lengths, + length_i)`; a seam belongs to
393/// the piece that starts there, and the last piece is closed at its far end.
394/// A distance before zero or past the last piece has no value: the law is
395/// not extrapolated, because an extrapolated transition is not a cant
396/// anyone authored.
397#[derive(Debug, Clone, PartialEq, Default)]
398#[cfg_attr(
399    feature = "serde",
400    derive(serde::Serialize, serde::Deserialize),
401    serde(deny_unknown_fields)
402)]
403pub struct CantLaw {
404    /// Pieces in order of plan distance.
405    pub pieces: Vec<CantPiece>,
406}
407
408impl CantLaw {
409    /// A law from its pieces.
410    #[must_use]
411    pub const fn new(pieces: Vec<CantPiece>) -> Self {
412        Self { pieces }
413    }
414
415    /// Zero over `length`: the pivot law of a rotation about the
416    /// centreline.
417    #[must_use]
418    pub fn zero(length: Scalar) -> Self {
419        Self::new(vec![CantPiece::constant(length, 0.0)])
420    }
421
422    /// Total plan length the law covers.
423    #[must_use]
424    pub fn length(&self) -> Scalar {
425        self.pieces.iter().map(|piece| piece.length).sum()
426    }
427
428    /// Interior seams, in plan distance, ascending.
429    #[must_use]
430    pub fn seams(&self) -> Vec<Scalar> {
431        let mut at = 0.0;
432        let mut seams = Vec::with_capacity(self.pieces.len().saturating_sub(1));
433        for piece in self.pieces.iter().take(self.pieces.len().saturating_sub(1)) {
434            at += piece.length;
435            seams.push(at);
436        }
437        seams
438    }
439
440    /// Whether every piece has a positive finite length and finite
441    /// coefficients, and there is at least one piece.
442    #[must_use]
443    pub fn is_well_formed(&self) -> bool {
444        !self.pieces.is_empty() && self.pieces.iter().all(CantPiece::is_well_formed)
445    }
446
447    /// Whether any piece gives a bank angle rather than a height.
448    #[must_use]
449    pub fn has_angle_pieces(&self) -> bool {
450        self.pieces.iter().any(CantPiece::is_angle)
451    }
452
453    /// Whether any piece rotates about a held rail
454    /// ([`CantForm::AboutRail`]), a pivot derived from the cant law.
455    #[must_use]
456    pub fn has_rail_pieces(&self) -> bool {
457        self.pieces.iter().any(CantPiece::is_rail)
458    }
459
460    /// Value at plan distance `distance`.
461    ///
462    /// `None` when the law is malformed, `distance` is outside it, or the
463    /// piece there is a held-rail pivot ([`CantForm::AboutRail`]), whose
464    /// value comes from the cant law ([`Banked3::pivot_at`]).
465    #[must_use]
466    pub fn value_at(&self, distance: Scalar) -> Option<CantValue> {
467        self.sample(distance).map(|(value, _)| value)
468    }
469
470    /// Rate of change per unit plan distance at `distance`, from the piece
471    /// that owns it (the one starting there, at a seam).
472    #[must_use]
473    pub fn rate_at(&self, distance: Scalar) -> Option<CantValue> {
474        self.sample(distance).map(|(_, rate)| rate)
475    }
476
477    fn sample(&self, distance: Scalar) -> Option<(CantValue, CantValue)> {
478        let (piece, s) = self.locate(distance)?;
479        piece.sample(s)
480    }
481
482    /// The piece owning `distance` (the one starting there, at a seam) and
483    /// the distance into it.
484    fn locate(&self, distance: Scalar) -> Option<(&CantPiece, Scalar)> {
485        if !distance.is_finite() || distance < 0.0 || !self.is_well_formed() {
486            return None;
487        }
488        let mut start = 0.0;
489        let last = self.pieces.len() - 1;
490        for (index, piece) in self.pieces.iter().enumerate() {
491            let end = start + piece.length;
492            if distance < end || (index == last && distance <= end) {
493                return Some((piece, distance - start));
494            }
495            start = end;
496        }
497        None
498    }
499}
500
501/// Which height difference a cant `D` states on a grade (ADR 0081).
502///
503/// See the [module docs](self) for both frames. No variant is a default:
504/// a [`Banked3`] names one.
505#[non_exhaustive]
506#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
507#[cfg_attr(
508    feature = "serde",
509    derive(serde::Serialize, serde::Deserialize),
510    serde(deny_unknown_fields)
511)]
512pub enum BankConvention {
513    /// The level-track section turned about the 3D tangent by
514    /// `psi = asin(D / b)`. The rail heads differ in height by
515    /// `D cos(theta)` at grade angle `theta`.
516    TangentRotation,
517    /// The section rolled about the 3D tangent by `asin(D / (b cos theta))`,
518    /// so the rail heads, `b` apart square to the tangent, differ in height
519    /// by exactly `D`.
520    VerticalRise,
521}
522
523impl BankConvention {
524    /// The roll about the tangent, in radians, for cant `cant` over
525    /// rail-head distance `rail_head_distance` at a grade whose angle has
526    /// cosine `grade_cosine`.
527    ///
528    /// # Errors
529    ///
530    /// [`BankError::RailHeadDistance`] for a non-positive or non-finite
531    /// `b`; [`BankError::CantExceedsRailHeadDistance`] when `|D| > b`;
532    /// [`BankError::CantExceedsVerticalSpan`] when, under
533    /// [`VerticalRise`](Self::VerticalRise), `|D| > b cos(theta)`;
534    /// [`BankError::NonFinite`] for a non-finite cant or cosine.
535    pub fn roll(
536        self,
537        cant: Scalar,
538        rail_head_distance: Scalar,
539        grade_cosine: Scalar,
540    ) -> Result<Scalar, BankError> {
541        let psi = bank_angle(cant, rail_head_distance)?;
542        self.roll_from_angle(psi, rail_head_distance, grade_cosine)
543    }
544
545    /// [`roll`](Self::roll) from a nominal bank angle `psi` with
546    /// `sin(psi) = D / b`, as an angle-form piece gives it.
547    ///
548    /// Under [`TangentRotation`](Self::TangentRotation) this is `psi`
549    /// itself, with no round trip through the cant.
550    ///
551    /// # Errors
552    ///
553    /// As [`roll`](Self::roll), and [`BankError::AngleOutOfRange`] for
554    /// `|psi| > pi / 2`, which no cant gives.
555    pub fn roll_from_angle(
556        self,
557        psi: Scalar,
558        rail_head_distance: Scalar,
559        grade_cosine: Scalar,
560    ) -> Result<Scalar, BankError> {
561        check_rail_head_distance(rail_head_distance)?;
562        if !psi.is_finite() || !grade_cosine.is_finite() {
563            return Err(BankError::NonFinite);
564        }
565        if psi.abs() > core::f64::consts::FRAC_PI_2 {
566            return Err(BankError::AngleOutOfRange { angle: psi });
567        }
568        match self {
569            Self::TangentRotation => Ok(psi),
570            Self::VerticalRise => {
571                let cant = rail_head_distance * psi.sin();
572                let span = rail_head_distance * grade_cosine;
573                if span <= 0.0 || cant.abs() > span {
574                    return Err(BankError::CantExceedsVerticalSpan { cant, span });
575                }
576                Ok((cant / span).asin())
577            }
578        }
579    }
580}
581
582/// The nominal bank angle `psi = asin(D / b)`.
583///
584/// # Errors
585///
586/// [`BankError::RailHeadDistance`] for a non-positive or non-finite `b`,
587/// [`BankError::NonFinite`] for a non-finite cant, and
588/// [`BankError::CantExceedsRailHeadDistance`] when `|D| > b`.
589pub fn bank_angle(cant: Scalar, rail_head_distance: Scalar) -> Result<Scalar, BankError> {
590    check_rail_head_distance(rail_head_distance)?;
591    if !cant.is_finite() {
592        return Err(BankError::NonFinite);
593    }
594    if cant.abs() > rail_head_distance {
595        return Err(BankError::CantExceedsRailHeadDistance {
596            cant,
597            rail_head_distance,
598        });
599    }
600    Ok((cant / rail_head_distance).asin())
601}
602
603fn check_rail_head_distance(rail_head_distance: Scalar) -> Result<(), BankError> {
604    if rail_head_distance.is_finite() && rail_head_distance > 0.0 {
605        Ok(())
606    } else {
607        Err(BankError::RailHeadDistance(rail_head_distance))
608    }
609}
610
611/// Why a banked curve has no section at a station.
612#[non_exhaustive]
613#[derive(Debug, Clone, Copy, PartialEq)]
614pub enum BankError {
615    /// The rail-head distance is not positive and finite.
616    RailHeadDistance(Scalar),
617    /// `|D| > b`: the rail heads cannot differ by more than they are apart.
618    CantExceedsRailHeadDistance {
619        /// The cant asked for.
620        cant: Scalar,
621        /// The rail-head distance `b`.
622        rail_head_distance: Scalar,
623    },
624    /// Under [`BankConvention::VerticalRise`], `|D| > b cos(theta)`: the
625    /// grade leaves the rail heads too little vertical reach for the cant.
626    CantExceedsVerticalSpan {
627        /// The cant asked for.
628        cant: Scalar,
629        /// `b cos(theta)`.
630        span: Scalar,
631    },
632    /// A bank angle beyond a quarter turn.
633    AngleOutOfRange {
634        /// The angle, radians.
635        angle: Scalar,
636    },
637    /// The named law is malformed or has no value at the distance.
638    OutsideLaw {
639        /// `"cant"` or `"pivot"`.
640        law: &'static str,
641        /// The plan distance asked for.
642        distance: Scalar,
643    },
644    /// The pivot law has an angle-form piece; a pivot is an elevation.
645    AngleInPivot,
646    /// A non-finite cant, angle or grade.
647    NonFinite,
648    /// The cant law has a held-rail piece ([`CantForm::AboutRail`]); only
649    /// a pivot derives from the cant.
650    RailInCant,
651}
652
653impl fmt::Display for BankError {
654    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
655        match self {
656            Self::RailHeadDistance(b) => {
657                write!(f, "rail-head distance {b} is not positive and finite")
658            }
659            Self::CantExceedsRailHeadDistance {
660                cant,
661                rail_head_distance,
662            } => write!(
663                f,
664                "cant {cant} exceeds the rail-head distance {rail_head_distance}, so it has no bank angle"
665            ),
666            Self::CantExceedsVerticalSpan { cant, span } => write!(
667                f,
668                "cant {cant} exceeds the vertical span {span} of the rail heads on this grade"
669            ),
670            Self::AngleOutOfRange { angle } => {
671                write!(f, "bank angle {angle} is beyond a quarter turn")
672            }
673            Self::OutsideLaw { law, distance } => write!(
674                f,
675                "{law} law is malformed or has no value at plan distance {distance}"
676            ),
677            Self::AngleInPivot => {
678                write!(f, "pivot law has an angle-form piece; a pivot is an elevation")
679            }
680            Self::NonFinite => write!(f, "cant, bank angle or grade is not finite"),
681            Self::RailInCant => write!(
682                f,
683                "cant law has a held-rail piece; only a pivot derives from the cant"
684            ),
685        }
686    }
687}
688
689impl std::error::Error for BankError {}
690
691/// A centreline with a roll law: the composition a canted track is
692/// authored as. See the [module docs](self).
693///
694/// Evaluated by PLAN distance `d`, like its base. The point is
695/// `base(d) + pivot(d) z`; the section frame is rolled about that point's
696/// own unit tangent by the angle [`convention`](Self::convention) gives.
697/// The curve's span is the cant law's: no station outside it has a section.
698#[derive(Debug, Clone, PartialEq)]
699#[cfg_attr(
700    feature = "serde",
701    derive(serde::Serialize, serde::Deserialize),
702    serde(deny_unknown_fields)
703)]
704pub struct Banked3 {
705    /// Centreline: plan and vertical profile.
706    pub base: Elevated3,
707    /// Cant `D(d)` (left rail head above right), or bank angle, by piece.
708    pub cant: CantLaw,
709    /// Elevation `e(d)` of the rotation point above the profile: height
710    /// pieces, or held-rail pieces derived from the cant
711    /// ([`CantForm::AboutRail`]). No angle pieces.
712    pub pivot: CantLaw,
713    /// Rail-head distance `b`, from which `psi = asin(D / b)`.
714    pub rail_head_distance: Scalar,
715    /// Which height difference `D` states on a grade.
716    pub convention: BankConvention,
717}
718
719impl Banked3 {
720    /// Pair a centreline with cant and pivot laws under a named
721    /// convention.
722    #[must_use]
723    pub const fn new(
724        base: Elevated3,
725        cant: CantLaw,
726        pivot: CantLaw,
727        rail_head_distance: Scalar,
728        convention: BankConvention,
729    ) -> Self {
730        Self {
731            base,
732            cant,
733            pivot,
734            rail_head_distance,
735            convention,
736        }
737    }
738
739    /// Plan length the curve is defined over: the cant law's.
740    #[must_use]
741    pub fn span(&self) -> Scalar {
742        self.cant.length()
743    }
744
745    /// Cant `D` at `distance`: the law's height, or `b sin(psi)` for an
746    /// angle piece.
747    ///
748    /// # Errors
749    ///
750    /// [`BankError::OutsideLaw`] off the law, [`BankError::RailHeadDistance`]
751    /// for a bad `b`, and the angle checks of [`bank_angle_at`](Self::bank_angle_at).
752    pub fn cant_at(&self, distance: Scalar) -> Result<Scalar, BankError> {
753        check_rail_head_distance(self.rail_head_distance)?;
754        match self.cant_value(distance)? {
755            CantValue::Height(cant) => {
756                bank_angle(cant, self.rail_head_distance)?;
757                Ok(cant)
758            }
759            CantValue::Angle(psi) => {
760                check_angle(psi)?;
761                Ok(self.rail_head_distance * psi.sin())
762            }
763        }
764    }
765
766    /// Nominal bank angle `psi = asin(D / b)` at `distance`; an angle piece
767    /// gives it directly.
768    ///
769    /// # Errors
770    ///
771    /// [`BankError::CantExceedsRailHeadDistance`] when `|D| > b`,
772    /// [`BankError::AngleOutOfRange`] for an angle piece beyond a quarter
773    /// turn, [`BankError::OutsideLaw`] off the law.
774    pub fn bank_angle_at(&self, distance: Scalar) -> Result<Scalar, BankError> {
775        match self.cant_value(distance)? {
776            CantValue::Height(cant) => bank_angle(cant, self.rail_head_distance),
777            CantValue::Angle(psi) => {
778                check_rail_head_distance(self.rail_head_distance)?;
779                check_angle(psi)?;
780                Ok(psi)
781            }
782        }
783    }
784
785    /// Rate of the cant `dD/dd` per unit plan distance at `distance`: the
786    /// law's, or `b cos(psi) psi'` for an angle piece.
787    ///
788    /// # Errors
789    ///
790    /// As [`cant_at`](Self::cant_at).
791    pub fn cant_rate_at(&self, distance: Scalar) -> Result<Scalar, BankError> {
792        let psi = self.bank_angle_at(distance)?;
793        let outside = BankError::OutsideLaw {
794            law: "cant",
795            distance,
796        };
797        let rate = match self.cant.rate_at(distance).ok_or(outside)? {
798            CantValue::Height(rate) => rate,
799            CantValue::Angle(rate) => self.rail_head_distance * psi.cos() * rate,
800        };
801        if rate.is_finite() {
802            Ok(rate)
803        } else {
804            Err(BankError::NonFinite)
805        }
806    }
807
808    /// Elevation `e` of the rotation point above the profile at `distance`,
809    /// and its rate per unit plan distance.
810    ///
811    /// A held-rail piece ([`CantForm::AboutRail`]) derives both from the
812    /// cant law: `e = e0 + s D / 2` and `e' = s D' / 2`, which for an angle
813    /// piece are `e0 + s (b / 2) sin(psi)` and `s (b / 2) cos(psi) psi'`.
814    ///
815    /// # Errors
816    ///
817    /// [`BankError::AngleInPivot`] for a pivot law with an angle piece,
818    /// [`BankError::OutsideLaw`] off the law; for a held-rail piece, the
819    /// cant law's refusals at `distance` ([`cant_at`](Self::cant_at)).
820    pub fn pivot_at(&self, distance: Scalar) -> Result<(Scalar, Scalar), BankError> {
821        if self.pivot.has_angle_pieces() {
822            return Err(BankError::AngleInPivot);
823        }
824        let outside = BankError::OutsideLaw {
825            law: "pivot",
826            distance,
827        };
828        let (piece, _) = self.pivot.locate(distance).ok_or(outside)?;
829        if let CantForm::AboutRail { rail, elevation } = piece.form {
830            let half = 0.5 * rail.pivot_sign();
831            let cant = self.cant_at(distance)?;
832            let rate = self.cant_rate_at(distance)?;
833            return Ok((elevation + half * cant, half * rate));
834        }
835        match (self.pivot.value_at(distance), self.pivot.rate_at(distance)) {
836            (Some(CantValue::Height(value)), Some(CantValue::Height(rate))) => Ok((value, rate)),
837            _ => Err(outside),
838        }
839    }
840
841    fn cant_value(&self, distance: Scalar) -> Result<CantValue, BankError> {
842        if self.cant.has_rail_pieces() {
843            return Err(BankError::RailInCant);
844        }
845        self.cant.value_at(distance).ok_or(BankError::OutsideLaw {
846            law: "cant",
847            distance,
848        })
849    }
850}
851
852fn check_angle(psi: Scalar) -> Result<(), BankError> {
853    if !psi.is_finite() {
854        return Err(BankError::NonFinite);
855    }
856    if psi.abs() > core::f64::consts::FRAC_PI_2 {
857        return Err(BankError::AngleOutOfRange { angle: psi });
858    }
859    Ok(())
860}