Skip to main content

axiolid_evaluate/station/
composite.rs

1//! A composite station basis: stations along pieces of curves laid end to
2//! end (#285, ADR 0082 amendment).
3//!
4//! A [`CompositeBasis`] is a run of [`StationPiece`]s. Each piece is a span
5//! of an atomic 2D or 3D curve ([`StationCurve`]) between two distances in
6//! that curve's own station measure, traversed forwards or backwards, and
7//! optionally carried by a rigid placement; or of an offset of such a span
8//! ([`StationCurve::Offset`], #289), in the offset's own length (see
9//! [the offset module](super::offset)). This is the form of a composite
10//! curve relation this module measures: a format adapter or a graph
11//! compiler flattens its trims, sense flags, nested composites and curves
12//! placed at stations into pieces, and this module measures stations along
13//! them.
14//!
15//! The same pieces as an owned, neutral value are `axiolid-curve`'s
16//! [`CurvePath`] (#290), which the curve-evaluation contract can see:
17//! [`CompositeBasis::from_path`] measures a path with its pieces borrowed
18//! from it, [`CompositeBasis::path`] hands the pieces out as one, and a
19//! [`StationPiece`] converts to a [`PathPiece`] and back
20//! ([`StationPiece::from_path_piece`]). The contract's `path_*` queries
21//! read a path through the same [`CompositeBasis::section_on`] in the
22//! reference provider. A [`StationPiece`] borrows its curve so that a
23//! station on a graph relation, resolved again for every station of a run,
24//! copies none.
25//!
26//! # Distance convention
27//!
28//! A composite measures stations as its pieces do, end to end: the
29//! distance of a point is the sum of the lengths of the pieces before it
30//! plus its distance into its own piece, each in its piece's station
31//! measure. A piece measures in its atomic curve's convention ([the parent
32//! module](super)): PLAN distance on an elevated or banked curve, arc
33//! length on every other curve (a 2D curve's arc length is its plan
34//! length). A rigid placement keeps arc length; it keeps plan distance only
35//! when it keeps `+Z`, and a plan-measured piece placed by one that tilts
36//! `+Z` is refused by name ([`SectionFrame::carried`]). So:
37//!
38//! - every piece plan-measured (elevated or banked curves, trimmed,
39//!   reversed or placed upright): the composite is measured in plan
40//!   distance, [`DistanceConvention::PlanDistance`];
41//! - every piece arc-length-measured (lines, conics, polylines,
42//!   B-splines, intrinsic curves and chains, 2D or 3D, placed or not): the
43//!   composite is measured in arc length,
44//!   [`DistanceConvention::ArcLength3d`]. A composite of segments placed at
45//!   stations of an elevated curve is measured in ITS OWN pieces' arc
46//!   length, whatever the base the segments were placed on;
47//! - pieces of both kinds: refused by name, since no one distance runs
48//!   through it.
49//!
50//! A piece with no length is refused by name, as is an unbounded one: an
51//! untrimmed line inside a composite is its parameter domain `[0, 1]`, as
52//! a composite directrix reads it ([`StationPiece::whole`]).
53//!
54//! # Joints
55//!
56//! Consecutive pieces must meet: the end of one within [`JOINT_TOLERANCE`]
57//! `* max(1, |p|)` of the start of the next, `|p|` the largest coordinate
58//! magnitude of the joint. A joint whose pieces do not meet is refused by
59//! name: as a reversed piece where the next piece's END meets the previous
60//! one (or the first piece's start meets the second), which is a piece
61//! whose sense was not declared; as a gap otherwise.
62//!
63//! Every interior joint is a seam under the #263 rule
64//! ([the seam module](super::seam)): it is never smooth, since a
65//! declared continuity is not a guarantee in the data. A station within
66//! [`ARC_LENGTH_TOLERANCE`]` * max(1, s)` of a joint is ON it and reads the
67//! piece its [`SeamSide`] names at the joint's own distance:
68//! [`SeamSide::Outgoing`] the piece that starts there, at its start;
69//! [`SeamSide::Incoming`] the piece that ends there, at its end. Each side
70//! reads its own piece's point: two pieces that meet within the joint
71//! tolerance are not snapped onto one another. At the composite's start
72//! and end only one piece exists, and both sides read it; likewise a piece
73//! is always read from the inside at its own ends, so a trim that starts
74//! on a seam of its curve never reads the part of the curve it trims away.
75//! The seams of a piece's curve strictly inside the piece are the
76//! composite's seams too, read by the curve's own rule.
77//!
78//! # Reversed and placed pieces
79//!
80//! A reversed piece is its span read from its end: the point at distance
81//! `u` into the piece is the curve's at `end - u`, the side of a seam is
82//! swapped (the incoming piece of the composite is the curve's outgoing
83//! one), and the frame's tangent and lateral are negated while up is kept,
84//! which is the section frame of the curve traversed the other way (the
85//! reference-up frame and the planar frame are exactly that; a banked
86//! curve's rolled section keeps its up and keeps the left rail on the
87//! left).
88//!
89//! A placed piece's frame is its curve's carried by the placement
90//! ([`SectionFrame::carried`]): moved rigidly where the placement keeps
91//! `+Z`; where it tilts `+Z`, the reference-up frame of the placed curve's
92//! own point and tangent, since the moved frame would not be the placed 3D
93//! curve's own section frame. A plan-measured curve placed tilted is
94//! refused by name.
95//!
96//! # Open ends (#298)
97//!
98//! A [`PathPiece`] may leave its end open ([`PathPiece::to_end`],
99//! [`PathPiece::whole`]): a caller with no provider cannot state the length
100//! of an offset beside a gradient curve, a clothoid or a B-spline. Its end
101//! is resolved, when the path is read, to where the curve ends by THIS
102//! evaluator's length, the number [`StationPiece::whole`] and so a graph
103//! compiler's flattening measure the whole curve by: a line's parameter
104//! domain end, a length read from stored data exactly, and otherwise the
105//! numerical estimate (an ellipse's or a B-spline's arc-length quadrature,
106//! an offset's own table, to [`super::OFFSET_TOLERANCE`]). An open path is
107//! therefore read exactly as the stated path holding those numbers,
108//! which [`CompositeBasis::path`] hands back: bitwise as a station resolved
109//! on the same pieces. The distance arithmetic, which joint a station is
110//! on and how far into its piece, runs through those numbers; where a
111//! joint truly lies is [`CompositeBasis::joint_intervals`]' certified
112//! interval, whose piece ends are the curve's own (no station is refused
113//! for falling within it: a caller who must know a side certifies the
114//! joint, or snaps by a window).
115//!
116//! # Exactness
117//!
118//! A station frame on a composite is exact, rounding aside, only where
119//! every piece up to and including the one it is read on is exact
120//! ([`CompositeBasis::frame_is_exact_at`]): those pieces' lengths make the
121//! distance, and the read piece makes the frame. A piece is exact when its
122//! curve is a line and its placement, if any, was exact
123//! ([`StationPiece::frame_is_exact`]). Elsewhere the frame carries the
124//! tolerances of the parent module.
125
126use axiolid_contracts::{BackendId, GeomError, GeomResult, Operation};
127use axiolid_core::{Point3, Scalar, Transform3};
128use axiolid_curve::{Curve2, Curve3, CurvePath, PathCurve, PathOffset, PathPiece, SeamSide};
129use axiolid_curve_evaluate_contract::DistanceConvention;
130
131use super::certify::{
132    checked_tolerance, curve_span, piece_length, CertifiedSeam, DistanceInterval, Running,
133};
134use super::offset::{OffsetTable, StationOffset};
135use super::seam::tidy;
136use super::window::{certification, checked_window, snap};
137use super::{
138    admitted, invalid, start2, start3, station_length2, station_length3, station_seams2,
139    station_seams3, station_section2_on, station_section3_on, SectionFrame, StationSeam,
140};
141use crate::arc_parameter::{
142    arc_length2, arc_length3, parameter_at_arc_length2, parameter_at_arc_length3,
143    ARC_LENGTH_TOLERANCE,
144};
145use crate::curve::{evaluate2, evaluate3};
146
147/// Relative tolerance within which two consecutive pieces of a
148/// [`CompositeBasis`] meet: their joint points may be `JOINT_TOLERANCE *
149/// max(1, |p|)` apart, `|p|` the joint's largest coordinate magnitude.
150pub const JOINT_TOLERANCE: Scalar = 1e-9;
151
152/// The arc-length tolerance around a distance: what counts as ON a seam or
153/// a joint.
154fn slack(distance: Scalar) -> Scalar {
155    ARC_LENGTH_TOLERANCE * distance.abs().max(1.0)
156}
157
158fn unsupported(input: &'static str) -> GeomError {
159    GeomError::UnsupportedInput {
160        backend: BackendId::new("axiolid-evaluate"),
161        operation: Operation::CurveEvaluation,
162        input,
163    }
164}
165
166/// The atomic curve under a [`StationPiece`].
167#[non_exhaustive]
168#[derive(Debug, Clone, Copy, PartialEq)]
169pub enum StationCurve<'c> {
170    /// A 2D curve, lying in `z = 0`.
171    Two(&'c Curve2),
172    /// A 3D curve.
173    Three(&'c Curve3),
174    /// An offset of a base piece, measured in its own length (#289; see
175    /// [the offset module](super::offset)).
176    Offset(StationOffset<'c>),
177}
178
179impl From<StationCurve<'_>> for PathCurve {
180    /// The curve, copied.
181    fn from(curve: StationCurve<'_>) -> Self {
182        match curve {
183            StationCurve::Two(curve) => PathCurve::Two(curve.clone()),
184            StationCurve::Three(curve) => PathCurve::Three(curve.clone()),
185            StationCurve::Offset(offset) => PathCurve::Offset(Box::new(PathOffset::new(
186                PathPiece::from(offset.base()),
187                offset.law(),
188            ))),
189        }
190    }
191}
192
193impl<'c> StationCurve<'c> {
194    /// The station curve of a path piece's curve, borrowed from it.
195    ///
196    /// # Errors
197    ///
198    /// A piece kind this evaluator does not know, by name.
199    pub fn from_path_curve(curve: &'c PathCurve) -> GeomResult<Self> {
200        match curve {
201            PathCurve::Two(curve) => Ok(Self::Two(curve)),
202            PathCurve::Three(curve) => Ok(Self::Three(curve)),
203            PathCurve::Offset(offset) => Ok(Self::Offset(StationOffset::new(
204                StationPiece::from_path_piece(&offset.base)?,
205                offset.law,
206            )?)),
207            _ => Err(unsupported(
208                "a curve path piece of a kind this evaluator does not know",
209            )),
210        }
211    }
212}
213
214impl StationCurve<'_> {
215    /// The distance convention a station on this curve is measured in:
216    /// plan distance on an elevated or banked curve, arc length on every
217    /// other (a 2D curve's arc length is its plan length).
218    /// An offset is measured as its base is (#289).
219    #[must_use]
220    pub fn convention(self) -> DistanceConvention {
221        match self {
222            Self::Three(Curve3::Elevated(_) | Curve3::Banked(_)) => {
223                DistanceConvention::PlanDistance
224            }
225            Self::Offset(offset) => offset.convention(),
226            _ => DistanceConvention::ArcLength3d,
227        }
228    }
229
230    /// The station length (the parent module's `station_length`).
231    fn length(self) -> GeomResult<Option<Scalar>> {
232        match self {
233            Self::Two(curve) => station_length2(curve),
234            Self::Three(curve) => station_length3(curve),
235            Self::Offset(offset) => offset.length().map(Some),
236        }
237    }
238
239    /// Whether a frame read on it is exact: a line, or an offset of a line
240    /// by a constant law.
241    fn frame_is_exact(self) -> bool {
242        match self {
243            Self::Offset(offset) => offset.frame_is_exact(),
244            _ => self.is_line(),
245        }
246    }
247
248    /// Where the curve ends in its station measure, by this evaluator's own
249    /// length (what an open end resolves to, #298): an unbounded line's
250    /// parameter domain end `|d|`, else its station length, exact where it
251    /// is read from stored data and the numerical estimate otherwise (an
252    /// ellipse's or a B-spline's quadrature, an offset's table).
253    fn end(self) -> GeomResult<Scalar> {
254        match self.line_speed() {
255            Some(speed) => Ok(speed),
256            None => self
257                .length()?
258                .ok_or_else(|| invalid("station: a composite piece has no bounded length".into())),
259        }
260    }
261
262    fn is_line(self) -> bool {
263        matches!(
264            self,
265            Self::Two(Curve2::Line(_)) | Self::Three(Curve3::Line(_))
266        )
267    }
268
269    /// A circle or an ellipse: its measure runs round, so a span may cross
270    /// its parameter seam.
271    fn is_closed_conic(self) -> bool {
272        matches!(
273            self,
274            Self::Two(Curve2::Circle(_) | Curve2::Ellipse(_))
275                | Self::Three(Curve3::Circle(_) | Curve3::Ellipse(_))
276        )
277    }
278
279    /// Whether a distance on it is read from stored data alone: false for
280    /// an ellipse and a B-spline, whose arc length is a quadrature, and an
281    /// offset other than a line or a circle.
282    pub(super) fn measure_is_exact(self) -> bool {
283        match self {
284            Self::Offset(offset) => offset.measure_is_exact(),
285            _ => !matches!(
286                self,
287                Self::Two(Curve2::Ellipse(_) | Curve2::BSpline(_))
288                    | Self::Three(Curve3::Ellipse(_) | Curve3::BSpline(_))
289            ),
290        }
291    }
292
293    /// A line's direction length, `None` for any other family.
294    fn line_speed(self) -> Option<Scalar> {
295        match self {
296            Self::Two(Curve2::Line(line)) => Some(line.direction.length()),
297            Self::Three(Curve3::Line(line)) => Some(line.direction.length()),
298            _ => None,
299        }
300    }
301
302    /// A closed conic's measure `m` folded into one turn `[0, L]`.
303    fn folded(self, m: Scalar) -> GeomResult<Scalar> {
304        if !self.is_closed_conic() {
305            return Ok(m);
306        }
307        let turn = self.length()?.unwrap_or(Scalar::INFINITY);
308        Ok(if m > turn { m - turn } else { m })
309    }
310
311    /// The station measure at native parameter `t`, from the curve's
312    /// start: `t |d|` on a line (any sign), `r t` on a circle, the arc
313    /// length from the domain's start on a polyline, a B-spline or an
314    /// ellipse, `t` itself on a curve parameterised by its measure.
315    fn measure_at(self, t: Scalar) -> GeomResult<Scalar> {
316        Ok(match self {
317            Self::Two(Curve2::Line(line)) => t * line.direction.length(),
318            Self::Three(Curve3::Line(line)) => t * line.direction.length(),
319            Self::Two(Curve2::Circle(circle)) => t * circle.radius,
320            Self::Three(Curve3::Circle(circle)) => t * circle.radius,
321            Self::Two(curve @ (Curve2::Polyline(_) | Curve2::BSpline(_) | Curve2::Ellipse(_))) => {
322                arc_length2(curve, start2(curve), t)?
323            }
324            Self::Three(
325                curve @ (Curve3::Polyline(_) | Curve3::BSpline(_) | Curve3::Ellipse(_)),
326            ) => arc_length3(curve, start3(curve), t)?,
327            Self::Offset(_) => {
328                return Err(unsupported(
329                    "the native parameter of an offset piece: an offset takes its basis's \
330                     parameter, not its own length",
331                ))
332            }
333            _ => t,
334        })
335    }
336
337    /// The point at measure `m` from the curve's start; an offset's
338    /// through `table`.
339    fn point_at(self, m: Scalar, table: Option<&OffsetTable>) -> GeomResult<Point3> {
340        let m = self.folded(m)?;
341        match self {
342            Self::Offset(offset) => offset.point_at(m, table),
343            Self::Two(curve) => {
344                let t = match curve {
345                    Curve2::Line(line) => m / line.direction.length(),
346                    _ => parameter_at_arc_length2(curve, start2(curve), m)?,
347                };
348                let p = evaluate2(curve, t)?;
349                Ok(Point3::new(p.x, p.y, 0.0))
350            }
351            Self::Three(curve) => match curve {
352                Curve3::Elevated(elevated) => crate::arc_length::elevated_point(elevated, m),
353                Curve3::Banked(banked) => crate::banked::banked_point(banked, m),
354                Curve3::Line(line) => evaluate3(curve, m / line.direction.length()),
355                _ => evaluate3(curve, parameter_at_arc_length3(curve, start3(curve), m)?),
356            },
357        }
358    }
359
360    /// The section frame at measure `m`, read from `side` on a seam; an
361    /// offset's through `table`.
362    fn section_at(
363        self,
364        m: Scalar,
365        side: SeamSide,
366        table: Option<&OffsetTable>,
367    ) -> GeomResult<SectionFrame> {
368        let m = self.folded(m)?;
369        match self {
370            Self::Offset(offset) => offset.section_at(m, table),
371            // A line inside a composite may be trimmed before its origin, so
372            // its measure may be negative: read it directly.
373            Self::Two(Curve2::Line(line)) => {
374                let point = line.origin + line.direction * (m / line.direction.length());
375                super::planar_frame(point, line.direction)
376            }
377            Self::Three(curve @ Curve3::Line(line)) => super::reference_up_frame(
378                evaluate3(curve, m / line.direction.length())?,
379                line.direction,
380            ),
381            Self::Two(curve) => station_section2_on(curve, m, side),
382            Self::Three(curve) => station_section3_on(curve, m, side),
383        }
384    }
385
386    fn seams(self) -> GeomResult<Vec<StationSeam>> {
387        if self.is_closed_conic() {
388            return Ok(Vec::new());
389        }
390        match self {
391            Self::Two(curve) => station_seams2(curve),
392            Self::Three(curve) => station_seams3(curve),
393            // Its base spans no seam of its curve.
394            Self::Offset(_) => Ok(Vec::new()),
395        }
396    }
397}
398
399/// One piece of a [`CompositeBasis`]: the span `[start, end]` of an atomic
400/// curve, in that curve's station measure, traversed forwards or
401/// backwards, optionally placed by a rigid motion; see the
402/// [module documentation](self).
403#[non_exhaustive]
404#[derive(Debug, Clone, Copy, PartialEq)]
405pub struct StationPiece<'c> {
406    /// The atomic curve.
407    pub curve: StationCurve<'c>,
408    /// Where the span starts, in the curve's station measure from its
409    /// start (negative only on a line).
410    pub start: Scalar,
411    /// Where the span ends, greater than `start`.
412    pub end: Scalar,
413    /// Whether the composite runs from `end` to `start`.
414    pub reversed: bool,
415    /// The rigid motion carrying the curve, if it is placed.
416    pub placement: Option<Transform3>,
417    /// Whether that placement is exact, rounding aside (`true` with no
418    /// placement).
419    pub placement_exact: bool,
420}
421
422impl<'c> StationPiece<'c> {
423    /// The whole curve: `[0, L]`, `L` its station length; an unbounded
424    /// line is its parameter domain `[0, 1]`, from its origin to `origin +
425    /// direction`.
426    ///
427    /// # Errors
428    ///
429    /// A curve whose length a station cannot be measured against, by name.
430    pub fn whole(curve: StationCurve<'c>) -> GeomResult<Self> {
431        Self::between(curve, 0.0, curve.end()?)
432    }
433
434    /// The span `[start, end]` of `curve`, in its station measure.
435    ///
436    /// # Errors
437    ///
438    /// A non-finite or empty span, and one outside the curve's measure
439    /// (before its start or past its length, beyond the arc-length
440    /// tolerance; a closed conic's span may cross its parameter seam but
441    /// not exceed one turn), by name.
442    pub fn between(curve: StationCurve<'c>, start: Scalar, end: Scalar) -> GeomResult<Self> {
443        if !(start.is_finite() && end.is_finite()) || end <= start {
444            return Err(GeomError::Degenerate(format!(
445                "station: a composite piece spans [{start}, {end}], which is empty or not finite"
446            )));
447        }
448        if curve
449            .line_speed()
450            .is_some_and(|speed| !(speed.is_finite() && speed > 0.0))
451        {
452            return Err(GeomError::Degenerate(
453                "station: a composite piece is a line with no direction".into(),
454            ));
455        }
456        if !curve.is_line() {
457            let length = curve.length()?.unwrap_or(Scalar::INFINITY);
458            let reach = if curve.is_closed_conic() {
459                start + length
460            } else {
461                length
462            };
463            if start < -slack(0.0) || start > length + slack(length) || end > reach + slack(reach) {
464                return Err(invalid(format!(
465                    "station: a composite piece spans [{start}, {end}], outside its curve's \
466                     measure [0, {length}]"
467                )));
468            }
469        }
470        Ok(Self {
471            curve,
472            start,
473            end,
474            reversed: false,
475            placement: None,
476            placement_exact: true,
477        })
478    }
479
480    /// The span between native parameters `t0 < t1` of `curve`, converted
481    /// to its station measure. A closed conic's span may start anywhere
482    /// and cross its parameter seam (at most one turn).
483    ///
484    /// # Errors
485    ///
486    /// As [`Self::between`], and the arc-length quadrature's refusals.
487    pub fn between_parameters(curve: StationCurve<'c>, t0: Scalar, t1: Scalar) -> GeomResult<Self> {
488        if !(t0.is_finite() && t1.is_finite()) || t1 <= t0 {
489            return Err(GeomError::Degenerate(format!(
490                "station: a composite piece spans parameters [{t0}, {t1}], which is empty or \
491                 not finite"
492            )));
493        }
494        if curve.is_closed_conic() {
495            let turn = core::f64::consts::TAU;
496            let from = t0.rem_euclid(turn);
497            let to = from + (t1 - t0);
498            let start = curve.measure_at(from)?;
499            let end = if to <= turn {
500                curve.measure_at(to)?
501            } else {
502                let length = curve.length()?.unwrap_or(Scalar::INFINITY);
503                length + curve.measure_at(to - turn)?
504            };
505            return Self::between(curve, start, end);
506        }
507        Self::between(curve, curve.measure_at(t0)?, curve.measure_at(t1)?)
508    }
509
510    /// The same span traversed the other way.
511    #[must_use]
512    pub const fn reversed(mut self) -> Self {
513        self.reversed = !self.reversed;
514        self
515    }
516
517    /// The same piece carried further by the rigid motion `rigid` (applied
518    /// after any placement it already has), `exact` when that motion is
519    /// (as [`PathPiece::placed`]).
520    #[must_use]
521    pub fn placed(mut self, rigid: Transform3, exact: bool) -> Self {
522        self.placement = Some(match self.placement {
523            Some(inner) => rigid * inner,
524            None => rigid,
525        });
526        self.placement_exact &= exact;
527        self
528    }
529
530    /// The whole of an offset (#289): `[0, L]`, `L` its own length.
531    ///
532    /// # Errors
533    ///
534    /// The offset's refusals (a collapse, a cusp, a self-crossing), by
535    /// name.
536    pub fn offset(offset: StationOffset<'c>) -> GeomResult<Self> {
537        let end = offset.length()?;
538        if !(end.is_finite() && end > 0.0) {
539            return Err(GeomError::Degenerate(
540                "station: an offset piece has no length".into(),
541            ));
542        }
543        Ok(Self {
544            curve: StationCurve::Offset(offset),
545            start: 0.0,
546            end,
547            reversed: false,
548            placement: None,
549            placement_exact: true,
550        })
551    }
552
553    /// The offset's measure table, for an offset piece.
554    pub(super) fn table(&self) -> GeomResult<Option<OffsetTable>> {
555        match self.curve {
556            StationCurve::Offset(offset) => offset.table().map(Some),
557            _ => Ok(None),
558        }
559    }
560
561    /// The station piece of a path piece: its span checked against its
562    /// curve as [`Self::between`] checks it, then reversed and placed as
563    /// the path piece is. An open end ([`PathPiece::open_end`], #298) is
564    /// resolved first to where the curve ends by this evaluator's own
565    /// length, the end [`Self::whole`] reads (see the
566    /// [module documentation](self#open-ends-298)).
567    ///
568    /// # Errors
569    ///
570    /// As [`Self::between`], an open end on a curve with no bounded length,
571    /// and a piece kind this evaluator does not know, by name.
572    pub fn from_path_piece(piece: &'c PathPiece) -> GeomResult<Self> {
573        let curve = StationCurve::from_path_curve(&piece.curve)?;
574        let end = if piece.open_end {
575            curve.end()?
576        } else {
577            piece.end
578        };
579        Ok(Self {
580            reversed: piece.reversed,
581            placement: piece.placement,
582            placement_exact: piece.placement_exact,
583            ..Self::between(curve, piece.start, end)?
584        })
585    }
586
587    /// The piece's length in its station measure.
588    #[must_use]
589    pub fn length(&self) -> Scalar {
590        self.end - self.start
591    }
592
593    /// The distance convention the piece is measured in.
594    #[must_use]
595    pub fn convention(&self) -> DistanceConvention {
596        self.curve.convention()
597    }
598
599    /// Whether a frame read on this piece is exact, rounding aside: a line
600    /// (an offset of a line by a constant law included), placed, if at
601    /// all, exactly (as [`PathPiece::frame_is_exact`]).
602    #[must_use]
603    pub fn frame_is_exact(&self) -> bool {
604        self.curve.frame_is_exact() && self.placement_exact
605    }
606
607    /// The curve's measure at distance `u` into the piece, and the side of
608    /// a seam of the curve that `side` (in the composite's direction) is.
609    fn measure(&self, u: Scalar, side: SeamSide) -> (Scalar, SeamSide) {
610        if self.reversed {
611            let swapped = match side {
612                SeamSide::Incoming => SeamSide::Outgoing,
613                _ => SeamSide::Incoming,
614            };
615            (self.end - u, swapped)
616        } else {
617            (self.start + u, side)
618        }
619    }
620
621    /// The point at distance `u` into the piece; an offset's through
622    /// `table`.
623    fn point_at(&self, u: Scalar, table: Option<&OffsetTable>) -> GeomResult<Point3> {
624        let (m, _) = self.measure(u, SeamSide::Outgoing);
625        let point = self.curve.point_at(m, table)?;
626        Ok(match self.placement {
627            Some(rigid) => rigid.transform_point3(point),
628            None => point,
629        })
630    }
631
632    /// The section frame at distance `u` into the piece, read from `side`
633    /// on a seam of its curve; at the piece's own ends it is always read
634    /// from inside the piece.
635    ///
636    /// # Errors
637    ///
638    /// The curve evaluators' refusals, and a plan-measured curve placed by
639    /// a motion that tilts `+Z`, by name.
640    pub fn section_on(&self, u: Scalar, side: SeamSide) -> GeomResult<SectionFrame> {
641        self.section_with(u, side, None)
642    }
643
644    /// [`Self::section_on`], an offset read through `table`.
645    fn section_with(
646        &self,
647        u: Scalar,
648        side: SeamSide,
649        table: Option<&OffsetTable>,
650    ) -> GeomResult<SectionFrame> {
651        let length = self.length();
652        let u = u.clamp(0.0, length);
653        let side = if u <= slack(u) {
654            SeamSide::Outgoing
655        } else if u >= length - slack(length) {
656            SeamSide::Incoming
657        } else {
658            side
659        };
660        let (m, curve_side) = self.measure(u, side);
661        let mut section = self.curve.section_at(m, curve_side, table)?;
662        if self.reversed {
663            section.tangent = -section.tangent;
664            section.lateral = -section.lateral;
665        }
666        match self.placement {
667            Some(rigid) => section.carried(rigid, self.convention()),
668            None => Ok(section),
669        }
670    }
671
672    /// The seams of the curve strictly inside the piece, at their distance
673    /// into the piece in the composite's direction.
674    pub(super) fn seams(&self) -> GeomResult<Vec<StationSeam>> {
675        let mut out: Vec<StationSeam> = self
676            .curve
677            .seams()?
678            .into_iter()
679            .filter(|seam| {
680                seam.distance > self.start + slack(self.start)
681                    && seam.distance < self.end - slack(self.end)
682            })
683            .map(|seam| {
684                let u = if self.reversed {
685                    self.end - seam.distance
686                } else {
687                    seam.distance - self.start
688                };
689                StationSeam::new(u, u, seam.smooth, seam.exact)
690            })
691            .collect();
692        out.sort_by(|a, b| a.distance.total_cmp(&b.distance));
693        Ok(out)
694    }
695}
696
697impl From<StationPiece<'_>> for PathPiece {
698    /// The piece, its curve copied.
699    fn from(piece: StationPiece<'_>) -> Self {
700        let mut out = PathPiece::new(piece.curve.into(), piece.start, piece.end);
701        out.reversed = piece.reversed;
702        out.placement = piece.placement;
703        out.placement_exact = piece.placement_exact;
704        out
705    }
706}
707
708/// Pieces of curves laid end to end as one station basis; see the
709/// [module documentation](self).
710#[derive(Debug, Clone, PartialEq)]
711pub struct CompositeBasis<'c> {
712    pieces: Vec<StationPiece<'c>>,
713    /// Each offset piece's measure table, built once (#289).
714    tables: Vec<Option<OffsetTable>>,
715    /// Each piece's start distance, and the total length last.
716    starts: Vec<Scalar>,
717    convention: DistanceConvention,
718}
719
720impl<'c> CompositeBasis<'c> {
721    /// The composite of `pieces`, in order.
722    ///
723    /// # Errors
724    ///
725    /// No pieces, a piece with no length, pieces measured in different
726    /// conventions, and a joint whose pieces do not meet (a reversed piece
727    /// or a gap), each by name; and the evaluators' refusals reading the
728    /// joints' points.
729    pub fn new(pieces: Vec<StationPiece<'c>>) -> GeomResult<Self> {
730        let Some(first) = pieces.first() else {
731            return Err(invalid("station: a composite basis has no pieces".into()));
732        };
733        let convention = first.convention();
734        for (index, piece) in pieces.iter().enumerate() {
735            let length = piece.length();
736            if !length.is_finite() || length <= 0.0 {
737                return Err(GeomError::Degenerate(format!(
738                    "station: piece {index} of a composite basis has no length"
739                )));
740            }
741            if piece.convention() != convention {
742                return Err(unsupported(
743                    "a composite station basis whose pieces measure stations differently (plan \
744                     distance on an elevated or banked piece, arc length on another): no one \
745                     distance runs through it",
746                ));
747            }
748        }
749        let tables = pieces
750            .iter()
751            .map(StationPiece::table)
752            .collect::<GeomResult<Vec<_>>>()?;
753        for index in 1..pieces.len() {
754            check_joint(
755                (&pieces[index - 1], tables[index - 1].as_ref()),
756                (&pieces[index], tables[index].as_ref()),
757                index,
758            )?;
759        }
760        let mut starts = Vec::with_capacity(pieces.len() + 1);
761        let mut run = 0.0;
762        for piece in &pieces {
763            starts.push(run);
764            run += piece.length();
765        }
766        starts.push(run);
767        Ok(Self {
768            pieces,
769            tables,
770            starts,
771            convention,
772        })
773    }
774
775    /// The composite along `path` (#290), its curves borrowed from it: each
776    /// piece checked against its curve ([`StationPiece::from_path_piece`]),
777    /// then measured as [`Self::new`] measures them.
778    ///
779    /// # Errors
780    ///
781    /// As [`StationPiece::from_path_piece`] for each piece, naming its
782    /// index, and as [`Self::new`].
783    pub fn from_path(path: &'c CurvePath) -> GeomResult<Self> {
784        let pieces = path
785            .pieces()
786            .iter()
787            .map(StationPiece::from_path_piece)
788            .collect::<GeomResult<Vec<_>>>()?;
789        Self::new(pieces)
790    }
791
792    /// The composite's pieces as a neutral [`CurvePath`] (#290), their
793    /// curves copied.
794    #[must_use]
795    pub fn path(&self) -> CurvePath {
796        self.pieces
797            .iter()
798            .map(|piece| PathPiece::from(*piece))
799            .collect()
800    }
801
802    /// The pieces, in order.
803    #[must_use]
804    pub fn pieces(&self) -> &[StationPiece<'c>] {
805        &self.pieces
806    }
807
808    /// The total length in the composite's station measure.
809    #[must_use]
810    pub fn length(&self) -> Scalar {
811        self.starts[self.pieces.len()]
812    }
813
814    /// The convention every piece, and so the composite, is measured in.
815    #[must_use]
816    pub fn convention(&self) -> DistanceConvention {
817        self.convention
818    }
819
820    /// The piece a station at the admitted `distance` reads on `side`, and
821    /// the distance into it.
822    fn locate(&self, distance: Scalar, side: SeamSide) -> (usize, Scalar) {
823        let count = self.pieces.len();
824        for joint in 1..count {
825            let at = self.starts[joint];
826            if (at - distance).abs() <= slack(distance) {
827                return match side {
828                    SeamSide::Incoming => (joint - 1, self.pieces[joint - 1].length()),
829                    _ => (joint, 0.0),
830                };
831            }
832        }
833        let k = self.starts[1..count].partition_point(|start| *start <= distance);
834        (k, distance - self.starts[k])
835    }
836
837    /// The piece a station at `distance` reads on `side` (the one
838    /// [`Self::section_on`] reads).
839    ///
840    /// # Errors
841    ///
842    /// As [`Self::section_on`] for the distance.
843    pub(crate) fn piece_read(
844        &self,
845        distance: Scalar,
846        side: SeamSide,
847    ) -> GeomResult<&StationPiece<'c>> {
848        let distance = admitted(distance, Some(self.length()))?;
849        Ok(&self.pieces[self.locate(distance, side).0])
850    }
851
852    /// The section frame at `distance` along the composite, read from
853    /// `side` on a joint or a seam inside a piece; see the
854    /// [module documentation](self#joints).
855    ///
856    /// # Errors
857    ///
858    /// A distance that is not finite, negative or beyond the composite's
859    /// length (by more than the arc-length tolerance), and the piece's
860    /// refusals, by name.
861    pub fn section_on(&self, distance: Scalar, side: SeamSide) -> GeomResult<SectionFrame> {
862        let distance = admitted(distance, Some(self.length()))?;
863        let (piece, into) = self.locate(distance, side);
864        self.pieces[piece].section_with(into, side, self.tables[piece].as_ref())
865    }
866
867    /// Whether the frame at `distance`, read from `side`, is exact: every
868    /// piece up to and including the one it is read on is
869    /// ([`StationPiece::frame_is_exact`]). `false` for a distance outside
870    /// the composite.
871    #[must_use]
872    pub fn frame_is_exact_at(&self, distance: Scalar, side: SeamSide) -> bool {
873        let Ok(distance) = admitted(distance, Some(self.length())) else {
874            return false;
875        };
876        let (piece, _) = self.locate(distance, side);
877        self.pieces[..=piece]
878            .iter()
879            .all(StationPiece::frame_is_exact)
880    }
881
882    /// The composite's seams: every interior joint (never smooth) and the
883    /// seams of each piece's curve strictly inside the piece, at their
884    /// distance along the composite, which is also the parameter reported.
885    /// A seam's distance is exact when every piece before it, and its own
886    /// seam, is measured from stored data alone (not an ellipse or a
887    /// B-spline).
888    ///
889    /// # Errors
890    ///
891    /// The seam readers' refusals.
892    pub fn seams(&self) -> GeomResult<Vec<StationSeam>> {
893        let mut out = Vec::new();
894        let mut exact_before = true;
895        for (k, piece) in self.pieces.iter().enumerate() {
896            let at = self.starts[k];
897            if k > 0 {
898                out.push(StationSeam::new(at, at, false, exact_before));
899            }
900            for seam in piece.seams()? {
901                let distance = at + seam.distance;
902                out.push(StationSeam::new(
903                    distance,
904                    distance,
905                    seam.smooth,
906                    seam.exact && exact_before,
907                ));
908            }
909            exact_before &= piece.curve.measure_is_exact();
910        }
911        Ok(tidy(out, Some(self.length())))
912    }
913
914    /// [`Self::seams`], every one exact or refused.
915    ///
916    /// # Errors
917    ///
918    /// As [`Self::seams`], and [`GeomError::UnsupportedInput`] for a seam
919    /// whose distance needs a quadrature: one after an ellipse or a
920    /// B-spline piece, or a B-spline's corner knot.
921    pub fn exact_seams(&self) -> GeomResult<Vec<StationSeam>> {
922        let seams = self.seams()?;
923        if seams.iter().any(|seam| !seam.exact) {
924            return Err(unsupported(
925                "exact seam distances of a composite with an ellipse or a B-spline piece before \
926                 a seam, or a B-spline's corner knot: their arc length is a quadrature",
927            ));
928        }
929        Ok(seams)
930    }
931
932    /// Each piece's share of a `tolerance` on a sum of their lengths: a
933    /// numerical piece's in proportion to its length, kept a little under
934    /// the whole so the rounding of the sum fits.
935    fn shares(&self, tolerance: Scalar) -> Vec<Scalar> {
936        self.shares_until(tolerance, Scalar::INFINITY)
937    }
938
939    /// [`Self::shares`] among the pieces starting by `until` (where the
940    /// kernel reads them), the only ones a sum up to there holds; a piece
941    /// past it keeps the share it would have had.
942    fn shares_until(&self, tolerance: Scalar, until: Scalar) -> Vec<Scalar> {
943        let numeric: Scalar = self
944            .pieces
945            .iter()
946            .zip(&self.starts)
947            .filter(|(_, start)| **start <= until)
948            .map(|(piece, _)| piece)
949            .filter(|piece| !piece.curve.measure_is_exact())
950            .map(StationPiece::length)
951            .sum();
952        self.pieces
953            .iter()
954            .map(|piece| {
955                if numeric > 0.0 {
956                    0.9 * tolerance * piece.length() / numeric
957                } else {
958                    tolerance
959                }
960            })
961            .collect()
962    }
963
964    /// Where the composite's interior joints truly lie (#294): for each
965    /// joint, in order, a [`DistanceInterval`] containing the sum of the
966    /// true lengths of the pieces before it, each between the points read
967    /// as its ends, no wider than `tolerance`. A sum of lengths read from
968    /// stored data is a point interval; see [`super::certify`] for how a
969    /// numerical length is bounded.
970    ///
971    /// # Errors
972    ///
973    /// A tolerance that is not finite and positive, a piece whose length
974    /// cannot be certified ([`super::certify::UNCERTIFIED_LENGTH`]) or not
975    /// within the panel budget, and the evaluators' refusals, by name.
976    pub fn joint_intervals(&self, tolerance: Scalar) -> GeomResult<Vec<DistanceInterval>> {
977        let tolerance = checked_tolerance(tolerance)?;
978        let shares = self.shares(tolerance);
979        let joints = self.pieces.len().saturating_sub(1);
980        let mut run = Running::ZERO;
981        let mut out = Vec::with_capacity(joints);
982        for ((piece, table), share) in self
983            .pieces
984            .iter()
985            .zip(&self.tables)
986            .zip(shares)
987            .take(joints)
988        {
989            run = run.plus(piece_length(piece, table.as_ref(), share)?);
990            let interval = run.interval();
991            if interval.width().is_nan() || interval.width() > tolerance {
992                return Err(unsupported(super::certify::UNCERTIFIED_LENGTH));
993            }
994            out.push(interval);
995        }
996        Ok(out)
997    }
998
999    /// The composite's seams whose frame may jump, each with its certified
1000    /// distance, up to the first whose interval starts past `until`: every
1001    /// interior joint, and each piece's own seams inside it.
1002    pub(crate) fn certified_seams(
1003        &self,
1004        tolerance: Scalar,
1005        until: Scalar,
1006    ) -> GeomResult<Vec<CertifiedSeam>> {
1007        let shares = self.shares_until(tolerance, until);
1008        let mut run = Running::ZERO;
1009        let mut out = Vec::new();
1010        for (k, piece) in self.pieces.iter().enumerate() {
1011            if k > 0 {
1012                out.push(CertifiedSeam {
1013                    distance: self.starts[k],
1014                    interval: run.interval(),
1015                });
1016            }
1017            if run.interval().lower > until {
1018                break;
1019            }
1020            for seam in piece.seams()? {
1021                if seam.smooth {
1022                    continue;
1023                }
1024                let part = if seam.exact {
1025                    DistanceInterval::point(seam.distance)
1026                } else {
1027                    let u = seam.distance;
1028                    let (m0, m1) = if piece.reversed {
1029                        (piece.end - u, piece.end)
1030                    } else {
1031                        (piece.start, piece.start + u)
1032                    };
1033                    curve_span(piece.curve, m0, m1, shares[k])?
1034                };
1035                out.push(CertifiedSeam {
1036                    distance: self.starts[k] + seam.distance,
1037                    interval: run.plus(part).interval(),
1038                });
1039            }
1040            if k + 1 < self.pieces.len() {
1041                run = run.plus(piece_length(piece, self.tables[k].as_ref(), shares[k])?);
1042            }
1043        }
1044        Ok(out)
1045    }
1046
1047    /// The distance a station at `distance` with the seam-snapping
1048    /// `window` is read at along the composite (#294): the joint's or
1049    /// seam's own distance when exactly one lies within the window, else
1050    /// `distance`; see [`super::window`].
1051    ///
1052    /// # Errors
1053    ///
1054    /// A negative or non-finite window, two seams within it
1055    /// ([`super::window::SEAM_WINDOW_AMBIGUOUS`]), a seam straddling its
1056    /// edge ([`super::window::SEAM_WINDOW_STRADDLED`]), and a seam whose
1057    /// distance cannot be certified, by name.
1058    pub fn snapped_distance(&self, distance: Scalar, window: Scalar) -> GeomResult<Scalar> {
1059        if checked_window(window)? == 0.0 || !distance.is_finite() {
1060            return Ok(distance);
1061        }
1062        let seams = self.certified_seams(certification(window), distance + window)?;
1063        snap(&seams, distance, window)
1064    }
1065
1066    /// The pieces between distances `start < end` along the composite,
1067    /// clipped: what a trim of the composite by its station measure keeps.
1068    ///
1069    /// # Errors
1070    ///
1071    /// A non-finite or empty interval, or one outside `[0, L]` beyond the
1072    /// arc-length tolerance, by name.
1073    pub fn pieces_between(&self, start: Scalar, end: Scalar) -> GeomResult<Vec<StationPiece<'c>>> {
1074        let length = self.length();
1075        if !(start.is_finite() && end.is_finite()) || end <= start {
1076            return Err(GeomError::Degenerate(format!(
1077                "station: a trim of a composite basis spans [{start}, {end}], which is empty or \
1078                 not finite"
1079            )));
1080        }
1081        if start < -slack(0.0) || end > length + slack(length) {
1082            return Err(invalid(format!(
1083                "station: a trim of a composite basis spans [{start}, {end}], outside its \
1084                 length {length}"
1085            )));
1086        }
1087        let (start, end) = (start.max(0.0), end.min(length));
1088        let mut out = Vec::new();
1089        for (k, piece) in self.pieces.iter().enumerate() {
1090            let (lo, hi) = (self.starts[k], self.starts[k + 1]);
1091            let (a, b) = (start.max(lo) - lo, end.min(hi) - lo);
1092            if b - a <= slack(b) {
1093                continue;
1094            }
1095            let mut clipped = *piece;
1096            if piece.reversed {
1097                clipped.start = piece.end - b;
1098                clipped.end = piece.end - a;
1099            } else {
1100                clipped.start = piece.start + a;
1101                clipped.end = piece.start + b;
1102            }
1103            out.push(clipped);
1104        }
1105        Ok(out)
1106    }
1107}
1108
1109/// A piece with its offset table, if any.
1110type Read<'a, 'c> = (&'a StationPiece<'c>, Option<&'a OffsetTable>);
1111
1112/// Refuse a joint whose pieces do not meet, naming an offset across a
1113/// corner, a reversed piece or the gap.
1114fn check_joint(
1115    (before, before_table): Read<'_, '_>,
1116    (after, after_table): Read<'_, '_>,
1117    index: usize,
1118) -> GeomResult<()> {
1119    let end = before.point_at(before.length(), before_table)?;
1120    let start = after.point_at(0.0, after_table)?;
1121    let tolerance = |p: Point3| JOINT_TOLERANCE * p.abs().max_element().max(1.0);
1122    let meets = |a: Point3, b: Point3| (a - b).length() <= tolerance(a);
1123    if meets(end, start) {
1124        return Ok(());
1125    }
1126    if [before, after]
1127        .iter()
1128        .any(|piece| matches!(piece.curve, StationCurve::Offset(_)))
1129    {
1130        return Err(invalid(format!(
1131            "station: an offset across a corner of its basis at joint {index}: the offsets of \
1132             the two sides do not meet (a gap of {}), so no distance runs across it (the \
1133             basis's tangent or roll jumps there)",
1134            (end - start).length()
1135        )));
1136    }
1137    let after_end = after.point_at(after.length(), after_table)?;
1138    let reversed = meets(end, after_end)
1139        || (index == 1 && {
1140            let before_start = before.point_at(0.0, before_table)?;
1141            meets(before_start, start) || meets(before_start, after_end)
1142        });
1143    if reversed {
1144        return Err(invalid(format!(
1145            "station: a reversed piece at joint {index} of a composite basis: its pieces meet \
1146             end to end or start to start, so one runs against the composite (its sense is not \
1147             declared)"
1148        )));
1149    }
1150    Err(invalid(format!(
1151        "station: a gap of {} at joint {index} of a composite basis: its pieces do not meet, so \
1152         no distance runs across it",
1153        (end - start).length()
1154    )))
1155}