axiolid_curve/elevation.rs
1//! Elevation laws and the composition of a planar curve with one.
2//!
3//! A road or rail centreline is authored as two independent laws: a planar
4//! layout carrying the transition spirals, and a vertical profile giving
5//! height as a function of distance along that layout. The 3D centreline is
6//! their composition. This module holds the vertical half and the pairing;
7//! the planar half is an ordinary [`Curve2`].
8//!
9//! # Height is a function of PLAN distance, not 3D arc length
10//!
11//! The vertical profile a surveyor writes is indexed by chainage measured on
12//! the horizontal layout, so `height_at` takes plan distance. The two differ
13//! whenever the grade is non-zero, because `ds3 = sqrt(1 + g^2) ds_plan`.
14//! Over a 120 m curve at a 2% entry grade the 3D length exceeds the plan
15//! length by 12.5 mm, which reads the profile 0.35 mm off if the distances
16//! are confused. That is small but it is a systematic misreading, not noise,
17//! and it compounds along a chain of segments.
18//!
19//! Naming the convention here means a consumer never has to guess which
20//! distance a law is written in.
21
22use axiolid_core::Scalar;
23
24use crate::Curve2;
25
26/// Height as a function of distance along the plan.
27///
28/// Mirrors [`CurvatureLaw`](crate::CurvatureLaw) in shape so the two halves of
29/// an alignment read the same way, but stays a separate type: a curvature law
30/// is a property of a planar curve and an elevation law is not, and sharing one
31/// enum would make a meaningless pairing representable.
32///
33/// Dirty imported data stays representable, as everywhere else in this crate.
34/// Mismatched piece lists report `None` rather than guessing.
35#[non_exhaustive]
36#[derive(Debug, Clone, PartialEq)]
37pub enum ElevationLaw {
38 /// `z(d) = coefficients[0] + coefficients[1] * d + coefficients[2] * d^2 + ...`
39 ///
40 /// Degree 1 is a constant gradient, degree 2 the parabolic vertical curve
41 /// used to join two grades. Those are the two vertical segment kinds that
42 /// carry most alignment data, and both are exact here.
43 ///
44 /// An empty coefficient list is the zero polynomial: height zero.
45 Polynomial {
46 /// Coefficients in ascending powers of plan distance.
47 coefficients: Vec<Scalar>,
48 },
49 /// Pieces laid end to end along plan distance, each with its own law.
50 ///
51 /// `breaks` holds the INTERIOR seam positions measured from the start, so
52 /// `laws.len() == breaks.len() + 1` and piece `i` spans
53 /// `breaks[i - 1] .. breaks[i]`.
54 ///
55 /// Each piece's law is written in its OWN distance, restarting at zero at
56 /// its seam, so moving a piece never rewrites its coefficients. This
57 /// matches [`CurvatureLaw::Piecewise`](crate::CurvatureLaw::Piecewise)
58 /// deliberately: a vertical profile is authored as a run of segments and
59 /// the seams are observable data.
60 Piecewise {
61 /// Interior seam positions in plan distance, ascending.
62 breaks: Vec<Scalar>,
63 /// One law per piece; `laws.len() == breaks.len() + 1`.
64 laws: Vec<ElevationLaw>,
65 },
66}
67
68impl ElevationLaw {
69 /// A constant height.
70 #[must_use]
71 pub fn level(height: Scalar) -> Self {
72 Self::Polynomial {
73 coefficients: vec![height],
74 }
75 }
76
77 /// A constant gradient: `z(d) = height + grade * d`.
78 #[must_use]
79 pub fn constant_grade(height: Scalar, grade: Scalar) -> Self {
80 Self::Polynomial {
81 coefficients: vec![height, grade],
82 }
83 }
84
85 /// A parabolic vertical curve joining `entry_grade` to `exit_grade` over
86 /// `length`.
87 ///
88 /// The rate of change of grade is `(exit - entry) / length`, so
89 /// `z(d) = height + entry * d + (exit - entry) / (2 * length) * d^2`.
90 /// A non-positive length is storable; naming it is a validator's job.
91 #[must_use]
92 pub fn parabolic(
93 height: Scalar,
94 entry_grade: Scalar,
95 exit_grade: Scalar,
96 length: Scalar,
97 ) -> Self {
98 Self::Polynomial {
99 coefficients: vec![
100 height,
101 entry_grade,
102 (exit_grade - entry_grade) / (2.0 * length),
103 ],
104 }
105 }
106
107 /// Whether the piece lists agree and the seams ascend.
108 #[must_use]
109 pub fn is_well_formed(&self) -> bool {
110 match self {
111 Self::Polynomial { coefficients } => coefficients.iter().all(|c| c.is_finite()),
112 Self::Piecewise { breaks, laws } => {
113 laws.len() == breaks.len() + 1
114 && breaks.iter().all(|b| b.is_finite())
115 && breaks.windows(2).all(|w| w[0] <= w[1])
116 && laws.iter().all(Self::is_well_formed)
117 }
118 }
119 }
120
121 /// Height at `distance` along the plan.
122 ///
123 /// Returns `None` when the law is malformed or the distance is not finite,
124 /// rather than extrapolating off a piece that does not exist.
125 #[must_use]
126 pub fn height_at(&self, distance: Scalar) -> Option<Scalar> {
127 if !distance.is_finite() {
128 return None;
129 }
130 match self {
131 Self::Polynomial { coefficients } => {
132 Some(horner(coefficients, distance)).filter(|z| z.is_finite())
133 }
134 Self::Piecewise { breaks, laws } => {
135 let (law, local) = piece_at(breaks, laws, distance)?;
136 law.height_at(local)
137 }
138 }
139 }
140
141 /// Grade -- `dz/dd` -- at `distance` along the plan.
142 ///
143 /// This is the slope the 3D tangent needs, and it is why the polynomial is
144 /// differentiated exactly rather than differenced.
145 #[must_use]
146 pub fn grade_at(&self, distance: Scalar) -> Option<Scalar> {
147 if !distance.is_finite() {
148 return None;
149 }
150 match self {
151 Self::Polynomial { coefficients } => {
152 let derivative: Vec<Scalar> = coefficients
153 .iter()
154 .enumerate()
155 .skip(1)
156 .map(|(power, c)| c * power as Scalar)
157 .collect();
158 Some(horner(&derivative, distance)).filter(|g| g.is_finite())
159 }
160 Self::Piecewise { breaks, laws } => {
161 let (law, local) = piece_at(breaks, laws, distance)?;
162 law.grade_at(local)
163 }
164 }
165 }
166}
167
168/// Evaluate ascending-power coefficients at `x`.
169fn horner(coefficients: &[Scalar], x: Scalar) -> Scalar {
170 coefficients
171 .iter()
172 .rev()
173 .fold(0.0, |accumulated, c| accumulated * x + c)
174}
175
176/// The piece covering `distance`, and that distance rebased to the piece start.
177///
178/// Pieces are half-open so a seam belongs to the piece that starts there; the
179/// final piece is closed at its far end so the curve's last point evaluates.
180fn piece_at<'a>(
181 breaks: &[Scalar],
182 laws: &'a [ElevationLaw],
183 distance: Scalar,
184) -> Option<(&'a ElevationLaw, Scalar)> {
185 if laws.len() != breaks.len() + 1 {
186 return None;
187 }
188 let index = breaks.partition_point(|b| *b <= distance);
189 let start = if index == 0 { 0.0 } else { breaks[index - 1] };
190 laws.get(index).map(|law| (law, distance - start))
191}
192
193/// A planar curve carrying an independent elevation law.
194///
195/// This is the composition an alignment centreline actually is: the plan is
196/// exact -- including the transition spirals a [`Curve2::Intrinsic`] holds --
197/// and the vertical profile is exact, and neither is approximated to pair
198/// them. Evaluation is parameterised by PLAN DISTANCE, which is the parameter
199/// both halves are authored against.
200///
201/// Composition rather than re-encoding: a `Curve3::BSpline` fitted through the
202/// pair would lose both. Storing the two laws keeps each one's own exactness
203/// and lets a consumer recover either half unchanged.
204///
205/// Only a plan whose parameter is arc length (line, circle, intrinsic) can
206/// carry a law. A B-spline's parameter is not a distance, so evaluators
207/// refuse an elevated B-spline plan rather than reinterpret the law
208/// (ADR 0060).
209#[derive(Debug, Clone, PartialEq)]
210pub struct Elevated3 {
211 /// Horizontal layout. Boxed to keep [`Curve3`](crate::Curve3) small.
212 pub plan: Box<Curve2>,
213 /// Height as a function of distance along `plan`.
214 pub elevation: ElevationLaw,
215}
216
217impl Elevated3 {
218 /// Pair a planar curve with an elevation law.
219 #[must_use]
220 pub fn new(plan: Curve2, elevation: ElevationLaw) -> Self {
221 Self {
222 plan: Box::new(plan),
223 elevation,
224 }
225 }
226}