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//! # Seams (#263)
68//!
69//! A curve made of pieces has two frames where a polyline turns at a
70//! vertex, a B-spline at a corner knot, an elevated curve's grade breaks
71//! or a banked curve's cant jumps. A station within
72//! [`ARC_LENGTH_TOLERANCE`]` * max(1, s)` of such a seam is ON it: it is
73//! read at the seam's own distance, from the piece that starts there
74//! (`SeamSide::Outgoing`, [`station_section2`] and [`station_section3`])
75//! or from the side [`station_section2_on`] and [`station_section3_on`]
76//! are given. [`station_seams2`] and [`station_seams3`] say where the
77//! seams are without evaluating the curve, and [`Mitre`] is the plane a
78//! run of sections crossing one stands in; see [`seam`] for both. At the
79//! curve's start and end there is one piece, and both sides read it.
80//!
81//! # Placing at a station (#264)
82//!
83//! [`SectionFrame::placement`] is the rigid motion a node placed at a
84//! station is moved by: local `x`, `y`, `z` onto the tangent, the left
85//! lateral and up, the origin onto the point (a linear placement's
86//! reading). [`station_frame_is_exact2`] and [`station_frame_is_exact3`]
87//! say whether that frame is exact (a line) or carries the tolerances
88//! below.
89//!
90//! # Accuracy contract
91//!
92//! The point and frame are the exact curve's at a measure within
93//! [`ARC_LENGTH_TOLERANCE`]` * max(1, s)` of `s` where the arc length is
94//! numerical (a conic, a polyline is exact, a B-spline), at `s` itself on a
95//! line, an intrinsic curve, a chain and an elevated or banked curve (their
96//! parameter is the measure), each evaluated to its own stated accuracy
97//! (Gauss-Legendre quadrature for intrinsic positions, ADR 0060). A station
98//! on a seam is read at the seam's distance, within the same tolerance of
99//! its own.
100
101use axiolid_contracts::{GeomError, GeomResult};
102use axiolid_core::{Frame3, Point3, Scalar, Transform3, Vec3};
103use axiolid_curve::{Curve2, Curve3, SeamSide};
104
105pub mod seam;
106
107pub use seam::{
108    exact_station_seams2, exact_station_seams3, station_seams2, station_seams3, Mitre, StationSeam,
109    MITRE_TOLERANCE, SEAM_TANGENT_TOLERANCE,
110};
111
112use crate::arc_parameter::{
113    arc_length2, arc_length3, parameter_at_arc_length2, parameter_at_arc_length3,
114    ARC_LENGTH_TOLERANCE,
115};
116use crate::curve::{derivative2, derivative3, domain2, domain3, evaluate2, evaluate3};
117
118/// Largest sine of the angle between an orientation's axis and reference
119/// direction that still counts as parallel ([`SectionFrame::oriented`]);
120/// the graph refuses the same pairs when a station is pushed.
121pub const ORIENTATION_TOLERANCE: Scalar = 1e-9;
122
123fn invalid(detail: String) -> GeomError {
124    GeomError::InvalidInput(detail)
125}
126
127/// A point on a curve and the section axes there: tangent, left lateral
128/// and up, unit and right-handed (`up = tangent x lateral`).
129#[non_exhaustive]
130#[derive(Debug, Clone, Copy, PartialEq)]
131pub struct SectionFrame {
132    /// The curve point.
133    pub point: Point3,
134    /// Unit tangent, in the direction of increasing distance.
135    pub tangent: Vec3,
136    /// Unit lateral axis, to the left of the tangent.
137    pub lateral: Vec3,
138    /// Unit section up, `tangent x lateral`.
139    pub up: Vec3,
140}
141
142impl SectionFrame {
143    /// The point offset from this frame's point: `lateral` along the
144    /// lateral axis (positive to the left), `vertical` along up and
145    /// `longitudinal` along the tangent.
146    #[must_use]
147    pub fn place(&self, lateral: Scalar, vertical: Scalar, longitudinal: Scalar) -> Point3 {
148        self.point + lateral * self.lateral + vertical * self.up + longitudinal * self.tangent
149    }
150
151    /// The same point framed vertically: the tangent's horizontal
152    /// projection, the horizontal left normal and `+Z`. Grade and bank are
153    /// dropped.
154    ///
155    /// # Errors
156    ///
157    /// A vertical tangent, which has no horizontal projection.
158    pub fn plan(&self) -> GeomResult<Self> {
159        let horizontal = Vec3::new(self.tangent.x, self.tangent.y, 0.0);
160        let length = horizontal.length();
161        if !length.is_finite() || length <= 1e-12 {
162            return Err(invalid(
163                "station: the tangent is vertical, so it has no plan direction".into(),
164            ));
165        }
166        let tangent = horizontal / length;
167        Ok(Self {
168            point: self.point,
169            tangent,
170            lateral: Vec3::new(-tangent.y, tangent.x, 0.0),
171            up: Vec3::Z,
172        })
173    }
174
175    /// The same point with its axes turned by an explicit orientation
176    /// (#246): `axis` and `ref_direction` are components in THIS frame,
177    /// `(tangent, lateral, up)`, defaulting to `(0, 0, 1)` and `(1, 0, 0)`.
178    ///
179    /// Gram-Schmidt with the axis primary: `up' = axis / |axis|`,
180    /// `tangent' = normalise(r - (r . up') up')` for the unit reference
181    /// direction `r`, `lateral' = up' x tangent'`. This is the reading of a
182    /// linear placement's `Axis` and `RefDirection` relative to the curve
183    /// (see the [module documentation](self#explicit-orientation-246)).
184    ///
185    /// # Errors
186    ///
187    /// A zero or non-finite vector, or an axis and reference direction
188    /// parallel or anti-parallel within [`ORIENTATION_TOLERANCE`], by name.
189    pub fn oriented(&self, axis: Option<Vec3>, ref_direction: Option<Vec3>) -> GeomResult<Self> {
190        let unit = |vector: Vec3, what: &str| {
191            let length = vector.length();
192            if vector.is_finite() && length.is_finite() && length > 1e-12 {
193                Ok(vector / length)
194            } else {
195                Err(invalid(format!(
196                    "station: the orientation's {what} {vector:?} is zero or not finite"
197                )))
198            }
199        };
200        let axis = unit(axis.unwrap_or(Vec3::Z), "axis")?;
201        let reference = unit(ref_direction.unwrap_or(Vec3::X), "reference direction")?;
202        if axis.cross(reference).length() <= ORIENTATION_TOLERANCE {
203            return Err(invalid(format!(
204                "station: the orientation's axis {axis:?} and reference direction \
205                 {reference:?} are parallel"
206            )));
207        }
208        let x = (reference - reference.dot(axis) * axis).normalize();
209        let y = axis.cross(x);
210        let world = |v: Vec3| v.x * self.tangent + v.y * self.lateral + v.z * self.up;
211        Ok(Self {
212            point: self.point,
213            tangent: world(x),
214            lateral: world(y),
215            up: world(axis),
216        })
217    }
218
219    /// The rigid placement this frame stands for (#264): local `x` onto the
220    /// tangent, `y` onto the lateral (to the left), `z` onto up, the local
221    /// origin onto the point.
222    ///
223    /// This is how a linear placement maps its local axes (buildingSMART
224    /// IFC 4.3, `IfcAxis2PlacementLinear`: `X` the tangent, `Y` the left
225    /// lateral, `Z` up), and how a node placed at a station is moved. It is
226    /// not [`Self::frame`]'s provider layout (`y` up, `z` right).
227    #[must_use]
228    pub fn placement(&self) -> Transform3 {
229        Transform3::from_cols(self.tangent, self.lateral, self.up, self.point)
230    }
231
232    /// This frame carried by a rigid motion: the point by `rigid`, the
233    /// axes by its linear part.
234    ///
235    /// A section frame on a curve moved rigidly is the moved curve's own
236    /// only where its convention does not depend on the world: always for
237    /// its tangent, and for the reference-up and plan frames only when
238    /// `rigid` keeps `+Z`; checking that is the caller's.
239    #[must_use]
240    pub fn moved(&self, rigid: Transform3) -> Self {
241        Self {
242            point: rigid.transform_point3(self.point),
243            tangent: rigid.transform_vector3(self.tangent),
244            lateral: rigid.transform_vector3(self.lateral),
245            up: rigid.transform_vector3(self.up),
246        }
247    }
248
249    /// The frame in the curve-evaluation provider's layout: `x` the
250    /// tangent, `y` up, `z = x x y`, which is `-lateral` (to the right).
251    #[must_use]
252    pub fn frame(&self) -> Frame3 {
253        Frame3 {
254            origin: self.point,
255            x: self.tangent,
256            y: self.up,
257            z: -self.lateral,
258        }
259    }
260}
261
262/// A 2D curve's point and unit tangent lifted to `z = 0`, framed with the
263/// left normal and `+Z`.
264fn planar_frame(
265    point: axiolid_core::Point2,
266    tangent: axiolid_core::Vec2,
267) -> GeomResult<SectionFrame> {
268    let length = tangent.length();
269    if !length.is_finite() || length <= 0.0 {
270        return Err(invalid(
271            "station: the curve has no tangent direction there".into(),
272        ));
273    }
274    let t = tangent / length;
275    Ok(SectionFrame {
276        point: Point3::new(point.x, point.y, 0.0),
277        tangent: Vec3::new(t.x, t.y, 0.0),
278        lateral: Vec3::new(-t.y, t.x, 0.0),
279        up: Vec3::Z,
280    })
281}
282
283/// The reference-up frame against `+Z`, as `ReferenceCurveEvaluator`
284/// builds it: `right = t x Z`, `up = right x t`, `lateral = -right`.
285fn reference_up_frame(point: Point3, derivative: Vec3) -> GeomResult<SectionFrame> {
286    let length = derivative.length();
287    if !length.is_finite() || length <= 0.0 {
288        return Err(invalid(
289            "station: the curve has no tangent direction there".into(),
290        ));
291    }
292    let tangent = derivative / length;
293    let right = tangent.cross(Vec3::Z);
294    let magnitude = right.length();
295    if !magnitude.is_finite() || magnitude <= 1e-12 {
296        return Err(invalid(
297            "station: the tangent is vertical, so the section has no lateral axis".into(),
298        ));
299    }
300    let right = right / magnitude;
301    Ok(SectionFrame {
302        point,
303        tangent,
304        lateral: -right,
305        up: right.cross(tangent),
306    })
307}
308
309/// A banked curve's rolled section at plan distance `d`, as a station
310/// frame.
311fn banked_frame(curve: &axiolid_curve::Banked3, d: Scalar) -> GeomResult<SectionFrame> {
312    let section = crate::banked::banked_section(curve, d)?;
313    Ok(SectionFrame {
314        point: section.point,
315        tangent: section.tangent,
316        lateral: section.lateral,
317        up: section.up,
318    })
319}
320
321/// Check `distance` against the curve length `length` (`None`:
322/// unbounded) and return the distance to read, clamped onto `L` within
323/// tolerance.
324fn admitted(distance: Scalar, length: Option<Scalar>) -> GeomResult<Scalar> {
325    if !distance.is_finite() {
326        return Err(invalid(format!(
327            "station: distance {distance} along the curve is not finite"
328        )));
329    }
330    if distance < 0.0 {
331        return Err(invalid(format!(
332            "station: distance {distance} is before the curve's start"
333        )));
334    }
335    match length {
336        None => Ok(distance),
337        Some(length) => {
338            let slack = ARC_LENGTH_TOLERANCE * length.abs().max(1.0);
339            if distance > length + slack {
340                Err(invalid(format!(
341                    "station: distance {distance} is beyond the curve's length {length}"
342                )))
343            } else {
344                Ok(distance.min(length))
345            }
346        }
347    }
348}
349
350fn unmeasured(dimension: &str) -> GeomError {
351    invalid(format!(
352        "station: this {dimension} curve family has no distance a station can be measured by"
353    ))
354}
355
356/// Where a 2D curve's measure starts: parameter `0`, or the domain's
357/// first parameter for a polyline or a B-spline.
358fn start2(curve: &Curve2) -> Scalar {
359    match curve {
360        Curve2::Polyline(_) | Curve2::BSpline(_) => {
361            let domain = domain2(curve);
362            domain.start.min(domain.end)
363        }
364        _ => 0.0,
365    }
366}
367
368fn start3(curve: &Curve3) -> Scalar {
369    match curve {
370        Curve3::Polyline(_) | Curve3::BSpline(_) => {
371            let domain = domain3(curve);
372            domain.start.min(domain.end)
373        }
374        _ => 0.0,
375    }
376}
377
378/// The length a station on a 2D curve is measured against: `None` for a
379/// line (unbounded), one turn for a circle or an ellipse, the arc length
380/// of its domain for a polyline or a B-spline, the stated length of an
381/// intrinsic curve or a chain.
382///
383/// # Errors
384///
385/// Any other family, a chain without a valid length, and the quadrature's
386/// refusals, by name.
387pub fn station_length2(curve: &Curve2) -> GeomResult<Option<Scalar>> {
388    match curve {
389        Curve2::Line(_) => Ok(None),
390        Curve2::Circle(_) | Curve2::Ellipse(_) => {
391            arc_length2(curve, 0.0, core::f64::consts::TAU).map(Some)
392        }
393        Curve2::Polyline(_) | Curve2::BSpline(_) => {
394            let domain = domain2(curve);
395            let (lo, hi) = (domain.start.min(domain.end), domain.start.max(domain.end));
396            if hi <= lo {
397                return Err(invalid("station: the curve has an empty domain".into()));
398            }
399            arc_length2(curve, lo, hi).map(Some)
400        }
401        Curve2::Intrinsic(intrinsic) => {
402            if intrinsic.length.is_finite() && intrinsic.length > 0.0 {
403                Ok(Some(intrinsic.length))
404            } else {
405                Err(invalid(
406                    "station: the intrinsic curve has no positive finite length".into(),
407                ))
408            }
409        }
410        Curve2::Chain(chain) => chain
411            .length()
412            .map(Some)
413            .ok_or_else(|| invalid("station: the chain has no valid length".into())),
414        _ => Err(unmeasured("2D")),
415    }
416}
417
418/// The length a station on a 3D curve is measured against, as
419/// [`station_length2`]; an elevated curve's is its plan's, a banked
420/// curve's the span of its cant law.
421///
422/// # Errors
423///
424/// As [`station_length2`].
425pub fn station_length3(curve: &Curve3) -> GeomResult<Option<Scalar>> {
426    match curve {
427        Curve3::Line(_) => Ok(None),
428        Curve3::Circle(_) | Curve3::Ellipse(_) => {
429            arc_length3(curve, 0.0, core::f64::consts::TAU).map(Some)
430        }
431        Curve3::Polyline(_) | Curve3::BSpline(_) => {
432            let domain = domain3(curve);
433            let (lo, hi) = (domain.start.min(domain.end), domain.start.max(domain.end));
434            if hi <= lo {
435                return Err(invalid("station: the curve has an empty domain".into()));
436            }
437            arc_length3(curve, lo, hi).map(Some)
438        }
439        Curve3::Intrinsic(intrinsic) => {
440            if intrinsic.length.is_finite() && intrinsic.length > 0.0 {
441                Ok(Some(intrinsic.length))
442            } else {
443                Err(invalid(
444                    "station: the intrinsic curve has no positive finite length".into(),
445                ))
446            }
447        }
448        Curve3::Elevated(elevated) => station_length2(&elevated.plan),
449        Curve3::Banked(banked) => {
450            let domain = domain3(curve);
451            if domain.end > domain.start {
452                Ok(Some(domain.end))
453            } else {
454                Err(invalid(format!(
455                    "station: the banked curve's cant law has no valid span ({})",
456                    banked.span()
457                )))
458            }
459        }
460        _ => Err(unmeasured("3D")),
461    }
462}
463
464/// Whether a station frame on this 2D curve is exact (#264): its point
465/// and axes at the station's distance itself, in closed form, rounding
466/// aside, with no quadrature or root find behind them.
467///
468/// Only a line: on every other family the distance is read by the
469/// arc-length inverse ([`ARC_LENGTH_TOLERANCE`], an estimate) or the point
470/// by quadrature (an intrinsic curve, a chain), so the frame is not exact
471/// and a placement in it must not be reported as exact.
472#[must_use]
473pub fn station_frame_is_exact2(curve: &Curve2) -> bool {
474    matches!(curve, Curve2::Line(_))
475}
476
477/// [`station_frame_is_exact2`] for a 3D curve: only a line.
478#[must_use]
479pub fn station_frame_is_exact3(curve: &Curve3) -> bool {
480    matches!(curve, Curve3::Line(_))
481}
482
483/// The section of a 2D curve at arc length `distance` from its start; see
484/// the [module documentation](self) for the conventions.
485///
486/// # Errors
487///
488/// A distance that is not finite, negative or beyond the curve's length,
489/// an unmeasurable family, and the evaluators' refusals, by name.
490pub fn station_section2(curve: &Curve2, distance: Scalar) -> GeomResult<SectionFrame> {
491    station_section2_on(curve, distance, SeamSide::Outgoing)
492}
493
494/// [`station_section2`] reading `side` of a seam the station lies on (see
495/// the [module documentation](self#seams-263)); off a seam the side does
496/// not matter.
497///
498/// # Errors
499///
500/// As [`station_section2`].
501pub fn station_section2_on(
502    curve: &Curve2,
503    distance: Scalar,
504    side: SeamSide,
505) -> GeomResult<SectionFrame> {
506    let distance = admitted(distance, station_length2(curve)?)?;
507    if let Some(section) = seam::seam_section2(curve, distance, side)? {
508        return Ok(section);
509    }
510    let t = parameter_at_arc_length2(curve, start2(curve), distance)?;
511    planar_frame(evaluate2(curve, t)?, derivative2(curve, t)?)
512}
513
514/// The section of a 3D curve at `distance` from its start: plan distance
515/// on an elevated or banked curve, arc length on any other; see the
516/// [module documentation](self).
517///
518/// # Errors
519///
520/// As [`station_section2`], and a vertical tangent where the reference-up
521/// frame is built.
522pub fn station_section3(curve: &Curve3, distance: Scalar) -> GeomResult<SectionFrame> {
523    station_section3_on(curve, distance, SeamSide::Outgoing)
524}
525
526/// [`station_section3`] reading `side` of a seam the station lies on (see
527/// the [module documentation](self#seams-263)); off a seam the side does
528/// not matter.
529///
530/// # Errors
531///
532/// As [`station_section3`].
533pub fn station_section3_on(
534    curve: &Curve3,
535    distance: Scalar,
536    side: SeamSide,
537) -> GeomResult<SectionFrame> {
538    let distance = admitted(distance, station_length3(curve)?)?;
539    if let Some(section) = seam::seam_section3(curve, distance, side)? {
540        return Ok(section);
541    }
542    match curve {
543        Curve3::Banked(banked) => banked_frame(banked, distance),
544        Curve3::Elevated(elevated) => reference_up_frame(
545            crate::arc_length::elevated_point(elevated, distance)?,
546            crate::arc_length::elevated_tangent(elevated, distance)?,
547        ),
548        _ => {
549            let t = parameter_at_arc_length3(curve, start3(curve), distance)?;
550            reference_up_frame(evaluate3(curve, t)?, derivative3(curve, t)?)
551        }
552    }
553}