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//! The section is rolled about that tangent `t`. With `n` the horizontal
12//! left normal and `u = t x n`, the roll `rho` gives the lateral axis
13//! `l = cos(rho) n + sin(rho) u` (towards the left rail head) and the
14//! section up `v = -sin(rho) n + cos(rho) u`; `rho` comes from
15//! [`BankConvention`](axiolid_curve::BankConvention): `asin(D / b)` for a
16//! rotation about the tangent, `asin(D / (b cos theta))` for an exact
17//! vertical rise `D`. The rail heads are at `point +- (b / 2) l`. Every
18//! refusal of the curve crate's [`BankError`] reaches the caller by name.
19//!
20//! Position is as exact as the centreline's; the frame adds closed-form
21//! trigonometry only.
22
23use axiolid_contracts::{GeomError, GeomResult};
24use axiolid_core::{Frame3, Point3, Scalar, Vec3};
25use axiolid_curve::{BankError, Banked3, CantForm, CantLaw, CantPiece, CantValue};
26
27use crate::arc_length::{elevated_point, elevated_tangent};
28
29fn refused(error: BankError) -> GeomError {
30    GeomError::InvalidInput(format!("banked curve: {error}"))
31}
32
33fn invalid(detail: &str) -> GeomError {
34    GeomError::InvalidInput(detail.to_owned())
35}
36
37/// The section of a banked curve at one station.
38#[non_exhaustive]
39#[derive(Debug, Clone, Copy, PartialEq)]
40pub struct BankedSection {
41    /// The rotation point: the centreline point raised by the pivot.
42    pub point: Point3,
43    /// Unit tangent of the banked curve.
44    pub tangent: Vec3,
45    /// Unit lateral axis, square to the tangent, towards the left rail head.
46    pub lateral: Vec3,
47    /// Unit section up, `tangent x lateral`.
48    pub up: Vec3,
49    /// Cant `D`: the left rail head above the right, as the law states it.
50    pub cant: Scalar,
51    /// Nominal bank angle `psi = asin(D / b)`, radians.
52    pub bank_angle: Scalar,
53    /// The roll about the tangent the convention gives, radians: `psi`
54    /// itself for a rotation about the tangent.
55    pub roll: Scalar,
56    /// Elevation of the rotation point above the profile.
57    pub pivot: Scalar,
58    /// Grade of the banked curve, `dz/dd`: the profile's plus the pivot's
59    /// rate.
60    pub grade: Scalar,
61    /// Rail-head distance `b`.
62    pub rail_head_distance: Scalar,
63}
64
65impl BankedSection {
66    /// The section as a frame, in the curve-evaluation contract's layout:
67    /// `x` the tangent, `y` the section up, `z = x x y` (to the right).
68    #[must_use]
69    pub fn frame(&self) -> Frame3 {
70        Frame3 {
71            origin: self.point,
72            x: self.tangent,
73            y: self.up,
74            z: -self.lateral,
75        }
76    }
77
78    /// Left and right rail heads, `b / 2` either side of the rotation point
79    /// along the lateral axis.
80    #[must_use]
81    pub fn rail_heads(&self) -> (Point3, Point3) {
82        let half = 0.5 * self.rail_head_distance * self.lateral;
83        (self.point + half, self.point - half)
84    }
85}
86
87/// Unit plan tangent, horizontal, at plan distance `d`.
88fn plan_direction(curve: &Banked3, d: Scalar) -> GeomResult<Vec3> {
89    let tangent = elevated_tangent(&curve.base, d)?;
90    let horizontal = Vec3::new(tangent.x, tangent.y, 0.0);
91    let length = horizontal.length();
92    if !length.is_finite() || length <= 0.0 {
93        return Err(invalid("banked curve: centreline has no plan direction"));
94    }
95    Ok(horizontal / length)
96}
97
98/// The cant law covers `d`, or the station has no section.
99fn covered(curve: &Banked3, d: Scalar) -> GeomResult<()> {
100    curve.cant.value_at(d).map(|_| ()).ok_or_else(|| {
101        refused(BankError::OutsideLaw {
102            law: "cant",
103            distance: d,
104        })
105    })
106}
107
108/// Position on a banked curve at plan distance `d`: `base(d) + e(d) z`.
109///
110/// # Errors
111///
112/// The centreline's refusals, and the pivot and cant laws' by name
113/// outside their span.
114pub fn banked_point(curve: &Banked3, d: Scalar) -> GeomResult<Point3> {
115    if !d.is_finite() {
116        return Err(invalid("plan distance must be finite"));
117    }
118    covered(curve, d)?;
119    let (pivot, _) = curve.pivot_at(d).map_err(refused)?;
120    let base = elevated_point(&curve.base, d)?;
121    Ok(base + pivot * Vec3::Z)
122}
123
124/// Derivative of [`banked_point`] in plan distance: `(p, g + e')`. Not
125/// unit: its length is `sqrt(1 + (g + e')^2)`.
126///
127/// # Errors
128///
129/// As [`banked_point`].
130pub fn banked_derivative(curve: &Banked3, d: Scalar) -> GeomResult<Vec3> {
131    if !d.is_finite() {
132        return Err(invalid("plan distance must be finite"));
133    }
134    covered(curve, d)?;
135    let (_, pivot_rate) = curve.pivot_at(d).map_err(refused)?;
136    let plan = plan_direction(curve, d)?;
137    // Every elevation law, including an intrinsic profile (#238).
138    let grade = crate::elevation::elevation_grade(&curve.base.elevation, d)?;
139    let derivative = Vec3::new(plan.x, plan.y, grade + pivot_rate);
140    if !derivative.is_finite() {
141        return Err(invalid("banked curve: grade is not finite"));
142    }
143    Ok(derivative)
144}
145
146/// Unit tangent of a banked curve at plan distance `d`.
147///
148/// # Errors
149///
150/// As [`banked_point`].
151pub fn banked_tangent(curve: &Banked3, d: Scalar) -> GeomResult<Vec3> {
152    Ok(banked_derivative(curve, d)?.normalize())
153}
154
155/// Second derivative of [`banked_point`] in plan distance (#252):
156/// the centreline's `(p'', z'')` plus the pivot's `e''` vertically.
157///
158/// # Errors
159///
160/// As [`banked_point`].
161pub fn banked_second_derivative(curve: &Banked3, d: Scalar) -> GeomResult<Vec3> {
162    if !d.is_finite() {
163        return Err(invalid("plan distance must be finite"));
164    }
165    covered(curve, d)?;
166    if curve.pivot.has_angle_pieces() {
167        return Err(refused(BankError::AngleInPivot));
168    }
169    let [_, _, bend] = pivot_jet(&curve.pivot, d, false).ok_or_else(|| {
170        refused(BankError::OutsideLaw {
171            law: "pivot",
172            distance: d,
173        })
174    })?;
175    let base = crate::elevated::elevated_second_derivative(&curve.base, d)?;
176    Ok(base + bend * Vec3::Z)
177}
178
179/// The height piece of a law at `d` and the distance into it: the piece
180/// starting there, or with `before` the one ending there.
181fn piece_at(law: &CantLaw, d: Scalar, before: bool) -> Option<(&CantPiece, Scalar)> {
182    if !d.is_finite() || d < 0.0 || !law.is_well_formed() {
183        return None;
184    }
185    let mut start = 0.0;
186    let last = law.pieces.len() - 1;
187    for (index, piece) in law.pieces.iter().enumerate() {
188        let end = start + piece.length;
189        let inside = if before { d <= end } else { d < end };
190        if inside || (index == last && d <= end) {
191            return Some((piece, d - start));
192        }
193        start = end;
194    }
195    None
196}
197
198/// `(e, e', e'')` of a height law at `d`, in plan distance; `None` off
199/// the law or for an angle piece.
200pub(crate) fn pivot_jet(law: &CantLaw, d: Scalar, before: bool) -> Option<[Scalar; 3]> {
201    let (piece, s) = piece_at(law, d, before)?;
202    let length = piece.length;
203    let xi = s / length;
204    let (value, slope, bend) = match &piece.form {
205        CantForm::Polynomial { coefficients } => {
206            let horner =
207                |k: usize| -> Scalar {
208                    coefficients.iter().enumerate().skip(k).rev().fold(
209                        0.0,
210                        |accumulated, (i, c)| {
211                            let falling: Scalar = (0..k).map(|j| (i - j) as Scalar).product();
212                            accumulated * xi + c * falling
213                        },
214                    )
215                };
216            (horner(0), horner(1), horner(2))
217        }
218        CantForm::Cosine { start, change } => {
219            let pi = core::f64::consts::PI;
220            let (sin, cos) = (pi * xi).sin_cos();
221            (
222                start + change * 0.5 * (1.0 - cos),
223                change * 0.5 * pi * sin,
224                change * 0.5 * pi * pi * cos,
225            )
226        }
227        CantForm::Sine { start, change } => {
228            let tau = core::f64::consts::TAU;
229            let (sin, cos) = (tau * xi).sin_cos();
230            (
231                start + change * (xi - sin / tau),
232                change * (1.0 - cos),
233                change * tau * sin,
234            )
235        }
236        _ => return None,
237    };
238    let out = [value, slope / length, bend / (length * length)];
239    out.iter().all(|v| v.is_finite()).then_some(out)
240}
241
242/// Certified `(sup |e'|, sup |e''|, sup |e'''|)` of a height law over
243/// `[lo, hi]` inside ONE piece (#252); `None` across a seam, off the law,
244/// or for an angle piece.
245///
246/// In the piece's `xi = s / L`, the `k`-th derivative in plan distance is
247/// the form's `k`-th in `xi` over `L^k`: a polynomial's by
248/// `sum i!/(i-k)! |c_i| m^(i-k)` with `m` the larger `|xi|`, the
249/// half-cosine's `|dD| / 2 pi^k`, the sine transition's `2 |dD|`,
250/// `2 pi |dD|`, `4 pi^2 |dD|`.
251pub(crate) fn pivot_bounds(law: &CantLaw, lo: Scalar, hi: Scalar) -> Option<[Scalar; 3]> {
252    let (lo, hi) = (lo.min(hi), lo.max(hi));
253    let (piece, s_lo) = piece_at(law, lo, false)?;
254    let s_hi = s_lo + (hi - lo);
255    if s_hi > piece.length * (1.0 + 1e-12) {
256        return None;
257    }
258    let length = piece.length;
259    let in_xi = match &piece.form {
260        CantForm::Polynomial { coefficients } => {
261            let m = (s_lo / length).abs().max((s_hi / length).abs());
262            let term = |k: usize| -> Scalar {
263                coefficients
264                    .iter()
265                    .enumerate()
266                    .skip(k)
267                    .map(|(i, c)| {
268                        let falling: Scalar = (0..k).map(|j| (i - j) as Scalar).product();
269                        falling * c.abs() * m.powi((i - k) as i32)
270                    })
271                    .sum()
272            };
273            [term(1), term(2), term(3)]
274        }
275        CantForm::Cosine { change, .. } => {
276            let pi = core::f64::consts::PI;
277            let half = 0.5 * change.abs();
278            [half * pi, half * pi * pi, half * pi * pi * pi]
279        }
280        CantForm::Sine { change, .. } => {
281            let tau = core::f64::consts::TAU;
282            let c = change.abs();
283            [2.0 * c, tau * c, tau * tau * c]
284        }
285        _ => return None,
286    };
287    let out = [
288        in_xi[0] / length,
289        in_xi[1] / (length * length),
290        in_xi[2] / (length * length * length),
291    ]
292    .map(|v| v * (1.0 + crate::bound::ROUNDING));
293    out.iter().all(|v| v.is_finite()).then_some(out)
294}
295
296/// The section at plan distance `d`: rotation point, rolled frame, cant,
297/// bank angle and roll.
298///
299/// # Errors
300///
301/// As [`banked_point`], and by name: a cant beyond the rail-head distance,
302/// beyond the vertical span `b cos(theta)` under a vertical-rise
303/// convention, an angle piece beyond a quarter turn, an angle piece in the
304/// pivot law, a bad rail-head distance.
305pub fn banked_section(curve: &Banked3, d: Scalar) -> GeomResult<BankedSection> {
306    let point = banked_point(curve, d)?;
307    let derivative = banked_derivative(curve, d)?;
308    let (pivot, _) = curve.pivot_at(d).map_err(refused)?;
309    let b = curve.rail_head_distance;
310    let tangent = derivative.normalize();
311    // `cos(theta)` from the derivative `(p, G)`: `1 / sqrt(1 + G^2)`.
312    let grade = derivative.z;
313    let grade_cosine = 1.0 / grade.hypot(1.0);
314    let cant = curve.cant_at(d).map_err(refused)?;
315    let bank_angle = curve.bank_angle_at(d).map_err(refused)?;
316    let roll = match curve.cant.value_at(d) {
317        Some(CantValue::Angle(psi)) => curve.convention.roll_from_angle(psi, b, grade_cosine),
318        _ => curve.convention.roll(cant, b, grade_cosine),
319    }
320    .map_err(refused)?;
321    let plan = Vec3::new(derivative.x, derivative.y, 0.0);
322    let normal = Vec3::Z.cross(plan).normalize();
323    let square_up = tangent.cross(normal);
324    let (sin, cos) = roll.sin_cos();
325    let lateral = cos * normal + sin * square_up;
326    let up = -sin * normal + cos * square_up;
327    Ok(BankedSection {
328        point,
329        tangent,
330        lateral,
331        up,
332        cant,
333        bank_angle,
334        roll,
335        pivot,
336        grade,
337        rail_head_distance: b,
338    })
339}