ifc_alignment/cant/frame.rs
1//! Cant as data at a station: rail heights, bank angle and the section frame.
2//!
3//! The cant layout states, per station, how far each rail head sits above
4//! the vertical profile (`IfcAlignmentCantSegment` "measured relatively to
5//! vertical alignment"). With the parent's `RailHeadDistance` `b` that fixes
6//! the cross-section exactly:
7//!
8//! - cant `D = left - right`, positive when the left rail is higher;
9//! - bank angle `psi = arcsin(D / b)`, the relation IFC4.3 ADD2
10//! (`IfcAlignmentCantSegmentTypeEnum`) states, so the two rail heads sit
11//! `b/2 sin(psi) = D/2` above and below the section's rotation point;
12//! - that point -- the deviating elevation `IfcSegmentedReferenceCurve`
13//! describes -- lies `(left + right) / 2` above the profile: zero for a
14//! rotation about the track centreline, `D/2` for one about the low rail.
15//!
16//! The frame is the section rotated by `psi` about the centreline tangent,
17//! right-handed: in the basis `(t, n, u)` -- `t` the unit 3D tangent of the
18//! gradient curve, `n` the horizontal unit normal to its left, `u = t x n`
19//! -- the rail-to-rail axis is `cos(psi) n + sin(psi) u` and the section's
20//! up axis `-sin(psi) n + cos(psi) u`.
21//!
22//! This is data, not lowered geometry: the neutral curve vocabulary has no
23//! roll law to attach it to (see `lower_segmented_reference_curve`), and the
24//! tangent comes from whichever evaluator the caller uses. [`CantFrame::orient`]
25//! turns a caller-supplied point and tangent into a world frame; it is
26//! algebra on those vectors, not evaluation of any curve.
27
28use axiolid_core::{Frame3, Point3, Vec2, Vec3};
29
30use crate::cant::layout::CantLayout;
31use crate::error::{AlignmentError, AlignmentResult};
32
33/// The cant cross-section at one station.
34#[derive(Debug, Clone, Copy, PartialEq)]
35#[non_exhaustive]
36pub struct CantFrame {
37 /// Absolute distance along the horizontal alignment, in metres.
38 pub distance_along: f64,
39 /// Left rail head above the vertical profile, in metres.
40 pub left: f64,
41 /// Right rail head above the vertical profile, in metres.
42 pub right: f64,
43 /// Cant `D = left - right`, positive when the left rail is higher.
44 pub cant: f64,
45 /// Elevation of the section's rotation point above the vertical
46 /// profile: `(left + right) / 2`.
47 pub axis_elevation: f64,
48 /// Bank angle `psi = arcsin(D / b)` in radians, positive raising the
49 /// left rail (counter-clockwise looking along the tangent).
50 pub bank_angle: f64,
51 /// Rail-to-rail axis (towards the left rail) as `(n, u)` components.
52 pub lateral: Vec2,
53 /// Section up axis as `(n, u)` components.
54 pub up: Vec2,
55}
56
57impl CantFrame {
58 /// The section frame in world coordinates.
59 ///
60 /// `point` and `tangent` are the gradient curve's point and 3D tangent
61 /// at this frame's station, from the caller's evaluator. The origin is
62 /// `point` raised by [`Self::axis_elevation`] along world up (the cant
63 /// is measured vertically from the profile); `x` is the unit tangent,
64 /// `y` the rail-to-rail axis and `z` the section up axis.
65 ///
66 /// # Errors
67 ///
68 /// Refuses a non-finite point or tangent, and a vertical or zero
69 /// tangent, which has no horizontal left normal.
70 pub fn orient(&self, point: Point3, tangent: Vec3) -> AlignmentResult<Frame3> {
71 let invalid = |detail| Err(AlignmentError::InvalidUnits { detail });
72 if !(point.is_finite() && tangent.is_finite()) {
73 return invalid("cant frame point and tangent must be finite");
74 }
75 let horizontal = (tangent.x * tangent.x + tangent.y * tangent.y).sqrt();
76 if horizontal == 0.0 {
77 return invalid("a vertical or zero tangent has no horizontal left normal");
78 }
79 let t = tangent.normalize();
80 let n = Vec3::new(-tangent.y / horizontal, tangent.x / horizontal, 0.0);
81 let u = t.cross(n);
82 Ok(Frame3 {
83 origin: point + Vec3::Z * self.axis_elevation,
84 x: t,
85 y: n * self.lateral.x + u * self.lateral.y,
86 z: n * self.up.x + u * self.up.y,
87 })
88 }
89}
90
91impl CantLayout {
92 /// The cant cross-section at an absolute distance along.
93 ///
94 /// # Errors
95 ///
96 /// Refuses a distance outside the profile's span, a segment whose cant
97 /// cannot be evaluated exactly, and a cant exceeding the
98 /// `RailHeadDistance` (`|D| > b`, no real bank angle).
99 pub fn frame_at_distance(&self, distance_along: f64) -> AlignmentResult<CantFrame> {
100 let at = self.cant_at_distance(distance_along)?;
101 let cant = at.left - at.right;
102 let ratio = cant / self.rail_head_distance;
103 if !(-1.0..=1.0).contains(&ratio) {
104 return Err(AlignmentError::SemanticViolation {
105 entity: Some(self.entity),
106 rule: "cant must not exceed the rail head distance (|D| <= b)",
107 });
108 }
109 let bank_angle = ratio.asin();
110 let (sin, cos) = bank_angle.sin_cos();
111 Ok(CantFrame {
112 distance_along,
113 left: at.left,
114 right: at.right,
115 cant,
116 axis_elevation: 0.5 * (at.left + at.right),
117 bank_angle,
118 lateral: Vec2::new(cos, sin),
119 up: Vec2::new(-sin, cos),
120 })
121 }
122}