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}