Skip to main content

axiolid_evaluate/
station.rs

1//! Stations: a position at a distance along a curve, framed by the curve's
2//! section (#241, ADR 0082).
3//!
4//! A station names a place by a measure along a basis curve instead of by
5//! coordinates, the way an alignment is staked out. Reading it back is
6//! curve evaluation, so it lives here, beside the arc-length machinery it
7//! reuses ([`crate::arc_parameter`]).
8//!
9//! # Distance convention
10//!
11//! The distance is measured in the basis curve's own convention, from its
12//! start:
13//!
14//! - an [`Elevated3`](axiolid_curve::Elevated3) or a
15//!   [`Banked3`](axiolid_curve::Banked3) is authored against PLAN distance,
16//!   its own parameter, and a station on it is a plan distance (the
17//!   curve-evaluation contract's `DistanceConvention::PlanDistance`);
18//! - every other curve, 2D or 3D, is measured by its arc length (for a 2D
19//!   curve, the plan length).
20//!
21//! The start is parameter `0` for a line, a circle, an ellipse, an
22//! intrinsic curve and a chain, and the first parameter of the domain for
23//! a polyline or a B-spline. A distance must lie in `[0, L]`, `L` the
24//! curve's length ([`station_length2`], [`station_length3`]): one full turn
25//! for a closed conic, no limit for a line. A negative or non-finite
26//! distance, and one past `L` by more than
27//! [`ARC_LENGTH_TOLERANCE`]` * max(1, L)`, is refused by name; one past it
28//! by less is read at `L`.
29//!
30//! # Section frame
31//!
32//! [`SectionFrame`] carries the point and three unit axes: `tangent` along
33//! the curve, `lateral` to the LEFT of it, and `up = tangent x lateral`.
34//!
35//! - A 2D curve lies in the plane `z = 0`: `lateral` is the left normal,
36//!   `up` is `+Z`.
37//! - A [`Banked3`](axiolid_curve::Banked3) is framed by its rolled section
38//!   ([`crate::banked::banked_section`]): `lateral` towards the left rail
39//!   head.
40//! - Every other 3D curve, an [`Elevated3`](axiolid_curve::Elevated3)
41//!   included, gets the reference-up frame against `+Z`, the one
42//!   `ReferenceCurveEvaluator::frame_at` returns: `lateral` is horizontal,
43//!   `up` leans back with the grade. A vertical tangent has no such frame
44//!   and is refused by name.
45//!
46//! [`SectionFrame::frame`] returns the axes in the curve-evaluation
47//! provider's layout: `x` the tangent, `y` up, `z = -lateral` (to the
48//! right). The contract's documentation of `frame_at` still says `z` is up
49//! (#242); this module follows the provider.
50//!
51//! [`SectionFrame::plan`] is the vertical alternative: the tangent's
52//! horizontal projection, the horizontal left normal and `+Z`, with grade
53//! and bank dropped.
54//!
55//! # Explicit orientation (#246)
56//!
57//! [`SectionFrame::oriented`] turns a section frame by an explicit axis and
58//! reference direction given as components in that frame, `(tangent,
59//! lateral, up)`, not in world coordinates: a linear placement's axes "are
60//! relative to the curve used for linear referencing ..., maintaining the
61//! relationship to the tangent of the curve" (buildingSMART IFC 4.3,
62//! `IfcAxis2PlacementLinear`), whose local `X`, `Y`, `Z` are the tangent,
63//! the left lateral and up. The axis is the oriented up, exact; the
64//! reference direction is made perpendicular to it (Gram-Schmidt, axis
65//! primary) and becomes the oriented tangent. The point does not move.
66//!
67//! # Accuracy contract
68//!
69//! The point and frame are the exact curve's at a measure within
70//! [`ARC_LENGTH_TOLERANCE`]` * max(1, s)` of `s` where the arc length is
71//! numerical (a conic, a polyline is exact, a B-spline), at `s` itself on a
72//! line, an intrinsic curve, a chain and an elevated or banked curve (their
73//! parameter is the measure), each evaluated to its own stated accuracy
74//! (Gauss-Legendre quadrature for intrinsic positions, ADR 0060).
75
76use axiolid_contracts::{GeomError, GeomResult};
77use axiolid_core::{Frame3, Point3, Scalar, Vec3};
78use axiolid_curve::{Curve2, Curve3};
79
80use crate::arc_parameter::{
81    arc_length2, arc_length3, parameter_at_arc_length2, parameter_at_arc_length3,
82    ARC_LENGTH_TOLERANCE,
83};
84use crate::curve::{derivative2, derivative3, domain2, domain3, evaluate2, evaluate3};
85
86/// Largest sine of the angle between an orientation's axis and reference
87/// direction that still counts as parallel ([`SectionFrame::oriented`]);
88/// the graph refuses the same pairs when a station is pushed.
89pub const ORIENTATION_TOLERANCE: Scalar = 1e-9;
90
91fn invalid(detail: String) -> GeomError {
92    GeomError::InvalidInput(detail)
93}
94
95/// A point on a curve and the section axes there: tangent, left lateral
96/// and up, unit and right-handed (`up = tangent x lateral`).
97#[non_exhaustive]
98#[derive(Debug, Clone, Copy, PartialEq)]
99pub struct SectionFrame {
100    /// The curve point.
101    pub point: Point3,
102    /// Unit tangent, in the direction of increasing distance.
103    pub tangent: Vec3,
104    /// Unit lateral axis, to the left of the tangent.
105    pub lateral: Vec3,
106    /// Unit section up, `tangent x lateral`.
107    pub up: Vec3,
108}
109
110impl SectionFrame {
111    /// The point offset from this frame's point: `lateral` along the
112    /// lateral axis (positive to the left), `vertical` along up and
113    /// `longitudinal` along the tangent.
114    #[must_use]
115    pub fn place(&self, lateral: Scalar, vertical: Scalar, longitudinal: Scalar) -> Point3 {
116        self.point + lateral * self.lateral + vertical * self.up + longitudinal * self.tangent
117    }
118
119    /// The same point framed vertically: the tangent's horizontal
120    /// projection, the horizontal left normal and `+Z`. Grade and bank are
121    /// dropped.
122    ///
123    /// # Errors
124    ///
125    /// A vertical tangent, which has no horizontal projection.
126    pub fn plan(&self) -> GeomResult<Self> {
127        let horizontal = Vec3::new(self.tangent.x, self.tangent.y, 0.0);
128        let length = horizontal.length();
129        if !length.is_finite() || length <= 1e-12 {
130            return Err(invalid(
131                "station: the tangent is vertical, so it has no plan direction".into(),
132            ));
133        }
134        let tangent = horizontal / length;
135        Ok(Self {
136            point: self.point,
137            tangent,
138            lateral: Vec3::new(-tangent.y, tangent.x, 0.0),
139            up: Vec3::Z,
140        })
141    }
142
143    /// The same point with its axes turned by an explicit orientation
144    /// (#246): `axis` and `ref_direction` are components in THIS frame,
145    /// `(tangent, lateral, up)`, defaulting to `(0, 0, 1)` and `(1, 0, 0)`.
146    ///
147    /// Gram-Schmidt with the axis primary: `up' = axis / |axis|`,
148    /// `tangent' = normalise(r - (r . up') up')` for the unit reference
149    /// direction `r`, `lateral' = up' x tangent'`. This is the reading of a
150    /// linear placement's `Axis` and `RefDirection` relative to the curve
151    /// (see the [module documentation](self#explicit-orientation-246)).
152    ///
153    /// # Errors
154    ///
155    /// A zero or non-finite vector, or an axis and reference direction
156    /// parallel or anti-parallel within [`ORIENTATION_TOLERANCE`], by name.
157    pub fn oriented(&self, axis: Option<Vec3>, ref_direction: Option<Vec3>) -> GeomResult<Self> {
158        let unit = |vector: Vec3, what: &str| {
159            let length = vector.length();
160            if vector.is_finite() && length.is_finite() && length > 1e-12 {
161                Ok(vector / length)
162            } else {
163                Err(invalid(format!(
164                    "station: the orientation's {what} {vector:?} is zero or not finite"
165                )))
166            }
167        };
168        let axis = unit(axis.unwrap_or(Vec3::Z), "axis")?;
169        let reference = unit(ref_direction.unwrap_or(Vec3::X), "reference direction")?;
170        if axis.cross(reference).length() <= ORIENTATION_TOLERANCE {
171            return Err(invalid(format!(
172                "station: the orientation's axis {axis:?} and reference direction \
173                 {reference:?} are parallel"
174            )));
175        }
176        let x = (reference - reference.dot(axis) * axis).normalize();
177        let y = axis.cross(x);
178        let world = |v: Vec3| v.x * self.tangent + v.y * self.lateral + v.z * self.up;
179        Ok(Self {
180            point: self.point,
181            tangent: world(x),
182            lateral: world(y),
183            up: world(axis),
184        })
185    }
186
187    /// The frame in the curve-evaluation provider's layout: `x` the
188    /// tangent, `y` up, `z = x x y`, which is `-lateral` (to the right).
189    #[must_use]
190    pub fn frame(&self) -> Frame3 {
191        Frame3 {
192            origin: self.point,
193            x: self.tangent,
194            y: self.up,
195            z: -self.lateral,
196        }
197    }
198}
199
200/// A 2D curve's point and unit tangent lifted to `z = 0`, framed with the
201/// left normal and `+Z`.
202fn planar_frame(
203    point: axiolid_core::Point2,
204    tangent: axiolid_core::Vec2,
205) -> GeomResult<SectionFrame> {
206    let length = tangent.length();
207    if !length.is_finite() || length <= 0.0 {
208        return Err(invalid(
209            "station: the curve has no tangent direction there".into(),
210        ));
211    }
212    let t = tangent / length;
213    Ok(SectionFrame {
214        point: Point3::new(point.x, point.y, 0.0),
215        tangent: Vec3::new(t.x, t.y, 0.0),
216        lateral: Vec3::new(-t.y, t.x, 0.0),
217        up: Vec3::Z,
218    })
219}
220
221/// The reference-up frame against `+Z`, as `ReferenceCurveEvaluator`
222/// builds it: `right = t x Z`, `up = right x t`, `lateral = -right`.
223fn reference_up_frame(point: Point3, derivative: Vec3) -> GeomResult<SectionFrame> {
224    let length = derivative.length();
225    if !length.is_finite() || length <= 0.0 {
226        return Err(invalid(
227            "station: the curve has no tangent direction there".into(),
228        ));
229    }
230    let tangent = derivative / length;
231    let right = tangent.cross(Vec3::Z);
232    let magnitude = right.length();
233    if !magnitude.is_finite() || magnitude <= 1e-12 {
234        return Err(invalid(
235            "station: the tangent is vertical, so the section has no lateral axis".into(),
236        ));
237    }
238    let right = right / magnitude;
239    Ok(SectionFrame {
240        point,
241        tangent,
242        lateral: -right,
243        up: right.cross(tangent),
244    })
245}
246
247/// Check `distance` against the curve length `length` (`None`:
248/// unbounded) and return the distance to read, clamped onto `L` within
249/// tolerance.
250fn admitted(distance: Scalar, length: Option<Scalar>) -> GeomResult<Scalar> {
251    if !distance.is_finite() {
252        return Err(invalid(format!(
253            "station: distance {distance} along the curve is not finite"
254        )));
255    }
256    if distance < 0.0 {
257        return Err(invalid(format!(
258            "station: distance {distance} is before the curve's start"
259        )));
260    }
261    match length {
262        None => Ok(distance),
263        Some(length) => {
264            let slack = ARC_LENGTH_TOLERANCE * length.abs().max(1.0);
265            if distance > length + slack {
266                Err(invalid(format!(
267                    "station: distance {distance} is beyond the curve's length {length}"
268                )))
269            } else {
270                Ok(distance.min(length))
271            }
272        }
273    }
274}
275
276fn unmeasured(dimension: &str) -> GeomError {
277    invalid(format!(
278        "station: this {dimension} curve family has no distance a station can be measured by"
279    ))
280}
281
282/// Where a 2D curve's measure starts: parameter `0`, or the domain's
283/// first parameter for a polyline or a B-spline.
284fn start2(curve: &Curve2) -> Scalar {
285    match curve {
286        Curve2::Polyline(_) | Curve2::BSpline(_) => {
287            let domain = domain2(curve);
288            domain.start.min(domain.end)
289        }
290        _ => 0.0,
291    }
292}
293
294fn start3(curve: &Curve3) -> Scalar {
295    match curve {
296        Curve3::Polyline(_) | Curve3::BSpline(_) => {
297            let domain = domain3(curve);
298            domain.start.min(domain.end)
299        }
300        _ => 0.0,
301    }
302}
303
304/// The length a station on a 2D curve is measured against: `None` for a
305/// line (unbounded), one turn for a circle or an ellipse, the arc length
306/// of its domain for a polyline or a B-spline, the stated length of an
307/// intrinsic curve or a chain.
308///
309/// # Errors
310///
311/// Any other family, a chain without a valid length, and the quadrature's
312/// refusals, by name.
313pub fn station_length2(curve: &Curve2) -> GeomResult<Option<Scalar>> {
314    match curve {
315        Curve2::Line(_) => Ok(None),
316        Curve2::Circle(_) | Curve2::Ellipse(_) => {
317            arc_length2(curve, 0.0, core::f64::consts::TAU).map(Some)
318        }
319        Curve2::Polyline(_) | Curve2::BSpline(_) => {
320            let domain = domain2(curve);
321            let (lo, hi) = (domain.start.min(domain.end), domain.start.max(domain.end));
322            if hi <= lo {
323                return Err(invalid("station: the curve has an empty domain".into()));
324            }
325            arc_length2(curve, lo, hi).map(Some)
326        }
327        Curve2::Intrinsic(intrinsic) => {
328            if intrinsic.length.is_finite() && intrinsic.length > 0.0 {
329                Ok(Some(intrinsic.length))
330            } else {
331                Err(invalid(
332                    "station: the intrinsic curve has no positive finite length".into(),
333                ))
334            }
335        }
336        Curve2::Chain(chain) => chain
337            .length()
338            .map(Some)
339            .ok_or_else(|| invalid("station: the chain has no valid length".into())),
340        _ => Err(unmeasured("2D")),
341    }
342}
343
344/// The length a station on a 3D curve is measured against, as
345/// [`station_length2`]; an elevated curve's is its plan's, a banked
346/// curve's the span of its cant law.
347///
348/// # Errors
349///
350/// As [`station_length2`].
351pub fn station_length3(curve: &Curve3) -> GeomResult<Option<Scalar>> {
352    match curve {
353        Curve3::Line(_) => Ok(None),
354        Curve3::Circle(_) | Curve3::Ellipse(_) => {
355            arc_length3(curve, 0.0, core::f64::consts::TAU).map(Some)
356        }
357        Curve3::Polyline(_) | Curve3::BSpline(_) => {
358            let domain = domain3(curve);
359            let (lo, hi) = (domain.start.min(domain.end), domain.start.max(domain.end));
360            if hi <= lo {
361                return Err(invalid("station: the curve has an empty domain".into()));
362            }
363            arc_length3(curve, lo, hi).map(Some)
364        }
365        Curve3::Intrinsic(intrinsic) => {
366            if intrinsic.length.is_finite() && intrinsic.length > 0.0 {
367                Ok(Some(intrinsic.length))
368            } else {
369                Err(invalid(
370                    "station: the intrinsic curve has no positive finite length".into(),
371                ))
372            }
373        }
374        Curve3::Elevated(elevated) => station_length2(&elevated.plan),
375        Curve3::Banked(banked) => {
376            let domain = domain3(curve);
377            if domain.end > domain.start {
378                Ok(Some(domain.end))
379            } else {
380                Err(invalid(format!(
381                    "station: the banked curve's cant law has no valid span ({})",
382                    banked.span()
383                )))
384            }
385        }
386        _ => Err(unmeasured("3D")),
387    }
388}
389
390/// The section of a 2D curve at arc length `distance` from its start; see
391/// the [module documentation](self) for the conventions.
392///
393/// # Errors
394///
395/// A distance that is not finite, negative or beyond the curve's length,
396/// an unmeasurable family, and the evaluators' refusals, by name.
397pub fn station_section2(curve: &Curve2, distance: Scalar) -> GeomResult<SectionFrame> {
398    let distance = admitted(distance, station_length2(curve)?)?;
399    let t = parameter_at_arc_length2(curve, start2(curve), distance)?;
400    planar_frame(evaluate2(curve, t)?, derivative2(curve, t)?)
401}
402
403/// The section of a 3D curve at `distance` from its start: plan distance
404/// on an elevated or banked curve, arc length on any other; see the
405/// [module documentation](self).
406///
407/// # Errors
408///
409/// As [`station_section2`], and a vertical tangent where the reference-up
410/// frame is built.
411pub fn station_section3(curve: &Curve3, distance: Scalar) -> GeomResult<SectionFrame> {
412    let distance = admitted(distance, station_length3(curve)?)?;
413    match curve {
414        Curve3::Banked(banked) => {
415            let section = crate::banked::banked_section(banked, distance)?;
416            Ok(SectionFrame {
417                point: section.point,
418                tangent: section.tangent,
419                lateral: section.lateral,
420                up: section.up,
421            })
422        }
423        Curve3::Elevated(elevated) => reference_up_frame(
424            crate::arc_length::elevated_point(elevated, distance)?,
425            crate::arc_length::elevated_tangent(elevated, distance)?,
426        ),
427        _ => {
428            let t = parameter_at_arc_length3(curve, start3(curve), distance)?;
429            reference_up_frame(evaluate3(curve, t)?, derivative3(curve, t)?)
430        }
431    }
432}