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 layout the contract's `frame_at` documents (#242).
49//!
50//! The curve-evaluation provider reads a seam side through this module:
51//! `ReferenceCurveEvaluator`'s `point_at_on`, `tangent_at_on` and
52//! `frame_at_on` answer a measure on a seam (below) with
53//! [`station_section3_on`] (#286).
54//!
55//! [`SectionFrame::plan`] is the vertical alternative: the tangent's
56//! horizontal projection, the horizontal left normal and `+Z`, with grade
57//! and bank dropped.
58//!
59//! # Explicit orientation (#246)
60//!
61//! [`SectionFrame::oriented`] turns a section frame by an explicit axis and
62//! reference direction given as components in that frame, `(tangent,
63//! lateral, up)`, not in world coordinates: a linear placement's axes "are
64//! relative to the curve used for linear referencing ..., maintaining the
65//! relationship to the tangent of the curve" (buildingSMART IFC 4.3,
66//! `IfcAxis2PlacementLinear`), whose local `X`, `Y`, `Z` are the tangent,
67//! the left lateral and up. The axis is the oriented up, exact; the
68//! reference direction is made perpendicular to it (Gram-Schmidt, axis
69//! primary) and becomes the oriented tangent. The point does not move.
70//!
71//! # Seams (#263)
72//!
73//! A curve made of pieces has two frames where a polyline turns at a
74//! vertex, a B-spline at a corner knot, an elevated curve's grade breaks
75//! or a banked curve's cant jumps. A station within
76//! [`ARC_LENGTH_TOLERANCE`]` * max(1, s)` of such a seam is ON it: it is
77//! read at the seam's own distance, from the piece that starts there
78//! (`SeamSide::Outgoing`, [`station_section2`] and [`station_section3`])
79//! or from the side [`station_section2_on`] and [`station_section3_on`]
80//! are given. [`station_seams2`] and [`station_seams3`] say where the
81//! seams are without evaluating the curve, and [`Mitre`] is the plane a
82//! run of sections crossing one stands in; see [`seam`] for both. At the
83//! curve's start and end there is one piece, and both sides read it.
84//!
85//! # Placing at a station (#264)
86//!
87//! [`SectionFrame::placement`] is the rigid motion a node placed at a
88//! station is moved by: local `x`, `y`, `z` onto the tangent, the left
89//! lateral and up, the origin onto the point (a linear placement's
90//! reading). [`station_frame_is_exact2`] and [`station_frame_is_exact3`]
91//! say whether that frame is exact (a line) or carries the tolerances
92//! below. [`SectionFrame::carried`] is the frame a station on the placed
93//! curve has (#285): the source's moved where the placement keeps `+Z`,
94//! the placed curve's own reference-up frame where it tilts it.
95//!
96//! # Composite bases (#285)
97//!
98//! [`CompositeBasis`] measures stations along pieces of atomic curves laid
99//! end to end ([`StationPiece`]: a span of a curve, reversed or placed),
100//! the neutral form of a composite curve relation. The distance runs
101//! through the pieces in their common convention, every joint is a seam
102//! read by the rule above, and a frame is exact only where every piece up
103//! to the one read is; see [`composite`].
104//!
105//! # Offset bases (#289)
106//!
107//! A piece may be an offset of a base piece ([`StationOffset`]), measured
108//! in its OWN length from its start: arc length, or its own plan length
109//! beside an elevated or banked curve. [`offset_pieces`] offsets pieces
110//! laid end to end, one offset piece per span between seams; see
111//! [`offset`] for the closed forms, the numerical reading and the
112//! refusals.
113//!
114//! # Certified joint distances and seam-snapping windows (#294)
115//!
116//! A composite's joints lie at the running sum of its pieces' lengths, and
117//! some of those lengths are quadratures (an offset other than a line or a
118//! circle beside one, an ellipse, a B-spline). [`certify`] bounds each from
119//! both sides, so [`CompositeBasis::joint_intervals`] answers every joint
120//! with a [`DistanceInterval`] that contains its true distance, no wider
121//! than a requested tolerance; a sum of stored lengths is a point. The
122//! curve-evaluation provider hands them out as `path_joint_distances`.
123//!
124//! A station may carry a seam-snapping window, wider than the arc-length
125//! tolerance: [`snapped_distance2`], [`snapped_distance3`] and
126//! [`CompositeBasis::snapped_distance`] read a station within its window
127//! of exactly one seam whose frame may jump at that seam, using the
128//! certified intervals where a seam's distance is numerical, and refuse a
129//! window holding two seams or with one on its edge by name; see
130//! [`window`].
131//!
132//! # Accuracy contract
133//!
134//! The point and frame are the exact curve's at a measure within
135//! [`ARC_LENGTH_TOLERANCE`]` * max(1, s)` of `s` where the arc length is
136//! numerical (a conic, a polyline is exact, a B-spline), at `s` itself on a
137//! line, an intrinsic curve, a chain and an elevated or banked curve (their
138//! parameter is the measure), each evaluated to its own stated accuracy
139//! (Gauss-Legendre quadrature for intrinsic positions, ADR 0060). A station
140//! on a seam is read at the seam's distance, within the same tolerance of
141//! its own.
142
143use axiolid_contracts::{GeomError, GeomResult};
144use axiolid_core::{Frame3, Point3, Scalar, Transform3, Vec3};
145use axiolid_curve::{Curve2, Curve3, SeamSide};
146/// The convention a station basis is measured in (#285): the
147/// curve-evaluation contract's, re-exported so a caller can name it.
148pub use axiolid_curve_evaluate_contract::DistanceConvention;
149
150pub mod certify;
151pub mod composite;
152pub mod frame_bound;
153pub mod offset;
154pub mod path_bound;
155pub mod seam;
156pub mod window;
157
158pub(crate) use certify::CurveLengths;
159pub use certify::{DistanceInterval, MAX_CERTIFIED_PANELS, POINT_ROUNDING, UNCERTIFIED_LENGTH};
160pub use composite::{CompositeBasis, StationCurve, StationPiece, JOINT_TOLERANCE};
161pub use frame_bound::{
162    piece_frame_bounds, BendBounds, FrameLaw, FrameLawBounds, FrameReading, StationFrameError,
163};
164pub use offset::{offset_pieces, StationOffset, CUSP_TOLERANCE, OFFSET_TOLERANCE};
165pub use path_bound::{
166    point_bound_tolerance, PATH_POINT_AMBIGUOUS, PATH_POINT_OFF_CURVE, POINT_BOUND_UNSUPPORTED,
167};
168pub use seam::{
169    exact_station_seams2, exact_station_seams3, station_seams2, station_seams3, Mitre, StationSeam,
170    MITRE_TOLERANCE, SEAM_TANGENT_TOLERANCE,
171};
172pub use window::{
173    snapped_distance2, snapped_distance3, SEAM_WINDOW_AMBIGUOUS, SEAM_WINDOW_STRADDLED,
174    WINDOW_CERTIFICATION,
175};
176
177use crate::arc_parameter::{
178    arc_length2, arc_length3, parameter_at_arc_length2, parameter_at_arc_length3,
179    ARC_LENGTH_TOLERANCE,
180};
181use crate::curve::{derivative2, derivative3, domain2, domain3, evaluate2, evaluate3};
182
183/// Largest sine of the angle between an orientation's axis and reference
184/// direction that still counts as parallel ([`SectionFrame::oriented`]);
185/// the graph refuses the same pairs when a station is pushed.
186pub const ORIENTATION_TOLERANCE: Scalar = 1e-9;
187
188/// How far a rigid placement's image of `+Z` may lean and still count as
189/// keeping `+Z` ([`SectionFrame::carried`]): rounding only.
190pub const KEEPS_UP_TOLERANCE: Scalar = 1e-12;
191
192fn invalid(detail: String) -> GeomError {
193    GeomError::InvalidInput(detail)
194}
195
196/// A point on a curve and the section axes there: tangent, left lateral
197/// and up, unit and right-handed (`up = tangent x lateral`).
198#[non_exhaustive]
199#[derive(Debug, Clone, Copy, PartialEq)]
200pub struct SectionFrame {
201    /// The curve point.
202    pub point: Point3,
203    /// Unit tangent, in the direction of increasing distance.
204    pub tangent: Vec3,
205    /// Unit lateral axis, to the left of the tangent.
206    pub lateral: Vec3,
207    /// Unit section up, `tangent x lateral`.
208    pub up: Vec3,
209}
210
211impl SectionFrame {
212    /// The point offset from this frame's point: `lateral` along the
213    /// lateral axis (positive to the left), `vertical` along up and
214    /// `longitudinal` along the tangent.
215    #[must_use]
216    pub fn place(&self, lateral: Scalar, vertical: Scalar, longitudinal: Scalar) -> Point3 {
217        self.point + lateral * self.lateral + vertical * self.up + longitudinal * self.tangent
218    }
219
220    /// The same point framed vertically: the tangent's horizontal
221    /// projection, the horizontal left normal and `+Z`. Grade and bank are
222    /// dropped.
223    ///
224    /// # Errors
225    ///
226    /// A vertical tangent, which has no horizontal projection.
227    pub fn plan(&self) -> GeomResult<Self> {
228        let horizontal = Vec3::new(self.tangent.x, self.tangent.y, 0.0);
229        let length = horizontal.length();
230        if !length.is_finite() || length <= 1e-12 {
231            return Err(invalid(
232                "station: the tangent is vertical, so it has no plan direction".into(),
233            ));
234        }
235        let tangent = horizontal / length;
236        Ok(Self {
237            point: self.point,
238            tangent,
239            lateral: Vec3::new(-tangent.y, tangent.x, 0.0),
240            up: Vec3::Z,
241        })
242    }
243
244    /// The same point with its axes turned by an explicit orientation
245    /// (#246): `axis` and `ref_direction` are components in THIS frame,
246    /// `(tangent, lateral, up)`, defaulting to `(0, 0, 1)` and `(1, 0, 0)`.
247    ///
248    /// Gram-Schmidt with the axis primary: `up' = axis / |axis|`,
249    /// `tangent' = normalise(r - (r . up') up')` for the unit reference
250    /// direction `r`, `lateral' = up' x tangent'`. This is the reading of a
251    /// linear placement's `Axis` and `RefDirection` relative to the curve
252    /// (see the [module documentation](self#explicit-orientation-246)).
253    ///
254    /// # Errors
255    ///
256    /// A zero or non-finite vector, or an axis and reference direction
257    /// parallel or anti-parallel within [`ORIENTATION_TOLERANCE`], by name.
258    pub fn oriented(&self, axis: Option<Vec3>, ref_direction: Option<Vec3>) -> GeomResult<Self> {
259        let unit = |vector: Vec3, what: &str| {
260            let length = vector.length();
261            if vector.is_finite() && length.is_finite() && length > 1e-12 {
262                Ok(vector / length)
263            } else {
264                Err(invalid(format!(
265                    "station: the orientation's {what} {vector:?} is zero or not finite"
266                )))
267            }
268        };
269        let axis = unit(axis.unwrap_or(Vec3::Z), "axis")?;
270        let reference = unit(ref_direction.unwrap_or(Vec3::X), "reference direction")?;
271        if axis.cross(reference).length() <= ORIENTATION_TOLERANCE {
272            return Err(invalid(format!(
273                "station: the orientation's axis {axis:?} and reference direction \
274                 {reference:?} are parallel"
275            )));
276        }
277        let x = (reference - reference.dot(axis) * axis).normalize();
278        let y = axis.cross(x);
279        let world = |v: Vec3| v.x * self.tangent + v.y * self.lateral + v.z * self.up;
280        Ok(Self {
281            point: self.point,
282            tangent: world(x),
283            lateral: world(y),
284            up: world(axis),
285        })
286    }
287
288    /// The rigid placement this frame stands for (#264): local `x` onto the
289    /// tangent, `y` onto the lateral (to the left), `z` onto up, the local
290    /// origin onto the point.
291    ///
292    /// This is how a linear placement maps its local axes (buildingSMART
293    /// IFC 4.3, `IfcAxis2PlacementLinear`: `X` the tangent, `Y` the left
294    /// lateral, `Z` up), and how a node placed at a station is moved. It is
295    /// not [`Self::frame`]'s provider layout (`y` up, `z` right).
296    #[must_use]
297    pub fn placement(&self) -> Transform3 {
298        Transform3::from_cols(self.tangent, self.lateral, self.up, self.point)
299    }
300
301    /// This frame carried by a rigid motion: the point by `rigid`, the
302    /// axes by its linear part.
303    ///
304    /// A section frame on a curve moved rigidly is the moved curve's own
305    /// only where its convention does not depend on the world: always for
306    /// its tangent, and for the reference-up and plan frames only when
307    /// `rigid` keeps `+Z`; checking that is the caller's.
308    #[must_use]
309    pub fn moved(&self, rigid: Transform3) -> Self {
310        Self {
311            point: rigid.transform_point3(self.point),
312            tangent: rigid.transform_vector3(self.tangent),
313            lateral: rigid.transform_vector3(self.lateral),
314            up: rigid.transform_vector3(self.up),
315        }
316    }
317
318    /// This frame, read on a curve, as the section frame of that curve
319    /// carried by the rigid motion `rigid` (#264, #285): the frame a
320    /// station on the placed curve has, `measure` the convention the
321    /// source curve is measured in.
322    ///
323    /// Where `rigid` keeps `+Z` (within [`KEEPS_UP_TOLERANCE`]) this is
324    /// [`Self::moved`]: the moved reference-up, planar or banked frame is
325    /// the placed curve's own, and plan distance is kept. Where it tilts
326    /// `+Z`, an arc-length-measured curve (arc length is kept by any rigid
327    /// motion) gets the reference-up frame against `+Z` of its moved point
328    /// and tangent, which is the placed 3D curve's own section frame; the
329    /// moved frame would not be.
330    ///
331    /// # Errors
332    ///
333    /// A plan-measured curve (elevated or banked) under a motion that
334    /// tilts `+Z`, whose plan distance and section frame are not the placed
335    /// curve's, by a typed [`GeomError::UnsupportedInput`]; and a moved
336    /// tangent with no reference-up frame (vertical), by name.
337    pub fn carried(&self, rigid: Transform3, measure: DistanceConvention) -> GeomResult<Self> {
338        if (rigid.transform_vector3(Vec3::Z) - Vec3::Z).length() <= KEEPS_UP_TOLERANCE {
339            return Ok(self.moved(rigid));
340        }
341        if measure == DistanceConvention::PlanDistance {
342            return Err(GeomError::UnsupportedInput {
343                backend: axiolid_contracts::BackendId::new("axiolid-evaluate"),
344                operation: axiolid_contracts::Operation::CurveEvaluation,
345                input: PLAN_MEASURED_TILTED,
346            });
347        }
348        reference_up_frame(
349            rigid.transform_point3(self.point),
350            rigid.transform_vector3(self.tangent),
351        )
352    }
353
354    /// The frame in the curve-evaluation provider's layout: `x` the
355    /// tangent, `y` up, `z = x x y`, which is `-lateral` (to the right).
356    #[must_use]
357    pub fn frame(&self) -> Frame3 {
358        Frame3 {
359            origin: self.point,
360            x: self.tangent,
361            y: self.up,
362            z: -self.lateral,
363        }
364    }
365}
366
367/// The refusal of a plan-measured curve placed in a tilted frame.
368const PLAN_MEASURED_TILTED: &str =
369    "a station along an elevated or banked curve placed at a station whose frame tilts +Z: its \
370     plan distance and its section frame carried by the placement are not the placed curve's own \
371     (#264)";
372
373/// A 2D curve's point and unit tangent lifted to `z = 0`, framed with the
374/// left normal and `+Z`.
375fn planar_frame(
376    point: axiolid_core::Point2,
377    tangent: axiolid_core::Vec2,
378) -> GeomResult<SectionFrame> {
379    let length = tangent.length();
380    if !length.is_finite() || length <= 0.0 {
381        return Err(invalid(
382            "station: the curve has no tangent direction there".into(),
383        ));
384    }
385    let t = tangent / length;
386    Ok(SectionFrame {
387        point: Point3::new(point.x, point.y, 0.0),
388        tangent: Vec3::new(t.x, t.y, 0.0),
389        lateral: Vec3::new(-t.y, t.x, 0.0),
390        up: Vec3::Z,
391    })
392}
393
394/// The reference-up frame against `+Z`, as `ReferenceCurveEvaluator`
395/// builds it: `right = t x Z`, `up = right x t`, `lateral = -right`.
396fn reference_up_frame(point: Point3, derivative: Vec3) -> GeomResult<SectionFrame> {
397    let length = derivative.length();
398    if !length.is_finite() || length <= 0.0 {
399        return Err(invalid(
400            "station: the curve has no tangent direction there".into(),
401        ));
402    }
403    let tangent = derivative / length;
404    let right = tangent.cross(Vec3::Z);
405    let magnitude = right.length();
406    if !magnitude.is_finite() || magnitude <= 1e-12 {
407        return Err(invalid(
408            "station: the tangent is vertical, so the section has no lateral axis".into(),
409        ));
410    }
411    let right = right / magnitude;
412    Ok(SectionFrame {
413        point,
414        tangent,
415        lateral: -right,
416        up: right.cross(tangent),
417    })
418}
419
420/// A banked curve's rolled section at plan distance `d`, as a station
421/// frame.
422fn banked_frame(curve: &axiolid_curve::Banked3, d: Scalar) -> GeomResult<SectionFrame> {
423    let section = crate::banked::banked_section(curve, d)?;
424    Ok(SectionFrame {
425        point: section.point,
426        tangent: section.tangent,
427        lateral: section.lateral,
428        up: section.up,
429    })
430}
431
432/// Check `distance` against the curve length `length` (`None`:
433/// unbounded) and return the distance to read, clamped onto `L` within
434/// tolerance.
435fn admitted(distance: Scalar, length: Option<Scalar>) -> GeomResult<Scalar> {
436    if !distance.is_finite() {
437        return Err(invalid(format!(
438            "station: distance {distance} along the curve is not finite"
439        )));
440    }
441    if distance < 0.0 {
442        return Err(invalid(format!(
443            "station: distance {distance} is before the curve's start"
444        )));
445    }
446    match length {
447        None => Ok(distance),
448        Some(length) => {
449            let slack = ARC_LENGTH_TOLERANCE * length.abs().max(1.0);
450            if distance > length + slack {
451                Err(invalid(format!(
452                    "station: distance {distance} is beyond the curve's length {length}"
453                )))
454            } else {
455                Ok(distance.min(length))
456            }
457        }
458    }
459}
460
461fn unmeasured(dimension: &str) -> GeomError {
462    invalid(format!(
463        "station: this {dimension} curve family has no distance a station can be measured by"
464    ))
465}
466
467/// Where a 2D curve's measure starts: parameter `0`, or the domain's
468/// first parameter for a polyline or a B-spline.
469fn start2(curve: &Curve2) -> Scalar {
470    match curve {
471        Curve2::Polyline(_) | Curve2::BSpline(_) => {
472            let domain = domain2(curve);
473            domain.start.min(domain.end)
474        }
475        _ => 0.0,
476    }
477}
478
479fn start3(curve: &Curve3) -> Scalar {
480    match curve {
481        Curve3::Polyline(_) | Curve3::BSpline(_) => {
482            let domain = domain3(curve);
483            domain.start.min(domain.end)
484        }
485        _ => 0.0,
486    }
487}
488
489/// The length a station on a 2D curve is measured against: `None` for a
490/// line (unbounded), one turn for a circle or an ellipse, the arc length
491/// of its domain for a polyline or a B-spline, the stated length of an
492/// intrinsic curve or a chain.
493///
494/// # Errors
495///
496/// Any other family, a chain without a valid length, and the quadrature's
497/// refusals, by name.
498pub fn station_length2(curve: &Curve2) -> GeomResult<Option<Scalar>> {
499    match curve {
500        Curve2::Line(_) => Ok(None),
501        Curve2::Circle(_) | Curve2::Ellipse(_) => {
502            arc_length2(curve, 0.0, core::f64::consts::TAU).map(Some)
503        }
504        Curve2::Polyline(_) | Curve2::BSpline(_) => {
505            let domain = domain2(curve);
506            let (lo, hi) = (domain.start.min(domain.end), domain.start.max(domain.end));
507            if hi <= lo {
508                return Err(invalid("station: the curve has an empty domain".into()));
509            }
510            arc_length2(curve, lo, hi).map(Some)
511        }
512        Curve2::Intrinsic(intrinsic) => {
513            if intrinsic.length.is_finite() && intrinsic.length > 0.0 {
514                Ok(Some(intrinsic.length))
515            } else {
516                Err(invalid(
517                    "station: the intrinsic curve has no positive finite length".into(),
518                ))
519            }
520        }
521        Curve2::Chain(chain) => chain
522            .length()
523            .map(Some)
524            .ok_or_else(|| invalid("station: the chain has no valid length".into())),
525        _ => Err(unmeasured("2D")),
526    }
527}
528
529/// The length a station on a 3D curve is measured against, as
530/// [`station_length2`]; an elevated curve's is its plan's, a banked
531/// curve's the span of its cant law.
532///
533/// # Errors
534///
535/// As [`station_length2`].
536pub fn station_length3(curve: &Curve3) -> GeomResult<Option<Scalar>> {
537    match curve {
538        Curve3::Line(_) => Ok(None),
539        Curve3::Circle(_) | Curve3::Ellipse(_) => {
540            arc_length3(curve, 0.0, core::f64::consts::TAU).map(Some)
541        }
542        Curve3::Polyline(_) | Curve3::BSpline(_) => {
543            let domain = domain3(curve);
544            let (lo, hi) = (domain.start.min(domain.end), domain.start.max(domain.end));
545            if hi <= lo {
546                return Err(invalid("station: the curve has an empty domain".into()));
547            }
548            arc_length3(curve, lo, hi).map(Some)
549        }
550        Curve3::Intrinsic(intrinsic) => {
551            if intrinsic.length.is_finite() && intrinsic.length > 0.0 {
552                Ok(Some(intrinsic.length))
553            } else {
554                Err(invalid(
555                    "station: the intrinsic curve has no positive finite length".into(),
556                ))
557            }
558        }
559        Curve3::Elevated(elevated) => station_length2(&elevated.plan),
560        Curve3::Banked(banked) => {
561            let domain = domain3(curve);
562            if domain.end > domain.start {
563                Ok(Some(domain.end))
564            } else {
565                Err(invalid(format!(
566                    "station: the banked curve's cant law has no valid span ({})",
567                    banked.span()
568                )))
569            }
570        }
571        _ => Err(unmeasured("3D")),
572    }
573}
574
575/// Whether a station frame on this 2D curve is exact (#264): its point
576/// and axes at the station's distance itself, in closed form, rounding
577/// aside, with no quadrature or root find behind them.
578///
579/// Only a line: on every other family the distance is read by the
580/// arc-length inverse ([`ARC_LENGTH_TOLERANCE`], an estimate) or the point
581/// by quadrature (an intrinsic curve, a chain), so the frame is not exact
582/// and a placement in it must not be reported as exact.
583#[must_use]
584pub fn station_frame_is_exact2(curve: &Curve2) -> bool {
585    matches!(curve, Curve2::Line(_))
586}
587
588/// [`station_frame_is_exact2`] for a 3D curve: only a line.
589#[must_use]
590pub fn station_frame_is_exact3(curve: &Curve3) -> bool {
591    matches!(curve, Curve3::Line(_))
592}
593
594/// The section of a 2D curve at arc length `distance` from its start; see
595/// the [module documentation](self) for the conventions.
596///
597/// # Errors
598///
599/// A distance that is not finite, negative or beyond the curve's length,
600/// an unmeasurable family, and the evaluators' refusals, by name.
601pub fn station_section2(curve: &Curve2, distance: Scalar) -> GeomResult<SectionFrame> {
602    station_section2_on(curve, distance, SeamSide::Outgoing)
603}
604
605/// [`station_section2`] reading `side` of a seam the station lies on (see
606/// the [module documentation](self#seams-263)); off a seam the side does
607/// not matter.
608///
609/// # Errors
610///
611/// As [`station_section2`].
612pub fn station_section2_on(
613    curve: &Curve2,
614    distance: Scalar,
615    side: SeamSide,
616) -> GeomResult<SectionFrame> {
617    let distance = admitted(distance, station_length2(curve)?)?;
618    if let Some(section) = seam::seam_section2(curve, distance, side)? {
619        return Ok(section);
620    }
621    let t = parameter_at_arc_length2(curve, start2(curve), distance)?;
622    planar_frame(evaluate2(curve, t)?, derivative2(curve, t)?)
623}
624
625/// Whether a station at `distance` along a 3D curve lies ON a seam it
626/// reads two ways, by the rule [`station_section3_on`] applies (#286):
627/// the curve-evaluation provider's sided queries read a station there
628/// through it and answer every other measure side-lessly.
629///
630/// A distance outside `[0, L]` lies on no interior seam: `false`, so the
631/// side-less query keeps its own refusal or answer there.
632///
633/// # Errors
634///
635/// A curve whose length or seams cannot be read, by name.
636pub(crate) fn station_on_seam3(curve: &Curve3, distance: Scalar) -> GeomResult<bool> {
637    if !seam::sided3(curve) {
638        return Ok(false);
639    }
640    let Ok(distance) = admitted(distance, station_length3(curve)?) else {
641        return Ok(false);
642    };
643    Ok(seam::seam_at3(curve, distance)?.is_some())
644}
645
646/// The section of a 3D curve at `distance` from its start: plan distance
647/// on an elevated or banked curve, arc length on any other; see the
648/// [module documentation](self).
649///
650/// # Errors
651///
652/// As [`station_section2`], and a vertical tangent where the reference-up
653/// frame is built.
654pub fn station_section3(curve: &Curve3, distance: Scalar) -> GeomResult<SectionFrame> {
655    station_section3_on(curve, distance, SeamSide::Outgoing)
656}
657
658/// [`station_section3`] reading `side` of a seam the station lies on (see
659/// the [module documentation](self#seams-263)); off a seam the side does
660/// not matter.
661///
662/// # Errors
663///
664/// As [`station_section3`].
665pub fn station_section3_on(
666    curve: &Curve3,
667    distance: Scalar,
668    side: SeamSide,
669) -> GeomResult<SectionFrame> {
670    let distance = admitted(distance, station_length3(curve)?)?;
671    if let Some(section) = seam::seam_section3(curve, distance, side)? {
672        return Ok(section);
673    }
674    match curve {
675        Curve3::Banked(banked) => banked_frame(banked, distance),
676        Curve3::Elevated(elevated) => reference_up_frame(
677            crate::arc_length::elevated_point(elevated, distance)?,
678            crate::arc_length::elevated_tangent(elevated, distance)?,
679        ),
680        _ => {
681            let t = parameter_at_arc_length3(curve, start3(curve), distance)?;
682            reference_up_frame(evaluate3(curve, t)?, derivative3(curve, t)?)
683        }
684    }
685}