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}