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//! # Bounds at a parameter or a point (#304)
117//!
118//! A [`PathPiece`] may start or end at a [`PathBound`]: a native parameter
119//! of its curve, or a point on it in the curve's own coordinates, since
120//! the distance to an interior parameter of an ellipse or a B-spline is a
121//! quadrature a caller with no provider cannot state. A point is resolved
122//! to the one parameter naming it, or refused by name when it is off the
123//! curve or several parameters name it ([the path-bound
124//! module](super::path_bound)). A parameter is resolved to the curve's
125//! measure there, the number [`StationPiece::between_parameters`] measures
126//! by: exact where the arc length to it is read from stored data (a line,
127//! a circle, a polyline, a curve parameterised by its measure), the
128//! arc-length quadrature of an ellipse or a B-spline otherwise. A piece
129//! bounded at both ends IS the piece between those parameters, so it reads
130//! bitwise as the composite a graph compiler builds for a trim between
131//! them in the curve's sense. A closed conic's angle runs round, as in
132//! such a trim: from the start's angle forwards by `t1 - t0` where that is
133//! within one turn, else by it wrapped into one turn (a point's angle lies
134//! in `[0, 2 pi)`, so an end before the start crosses the seam; the same
135//! place at both ends is the whole turn). One bound beside a stated or
136//! open other end is folded into the first turn, `[0, 2 pi)`, and an end
137//! not after the start lies a turn further. Offsets take
138//! no bound of their own (their base piece may carry one). Certification
139//! is unchanged: [`CompositeBasis::joint_intervals`] bounds each numerical
140//! piece's length between the points the kernel reads as its ends, which
141//! for a bounded piece are the curve's at its resolved measures.
142//!
143//! # Cutting a composite (#424)
144//!
145//! A graph compiler that cuts a composite at two distances reads the pieces
146//! between them ([`CompositeBasis::pieces_between`]), each piece's native
147//! parameters ([`StationPiece::native_span`]) and, where a measure is a
148//! quadrature, how far the cut lies from the distance asked
149//! ([`CompositeBasis::distance_interval_at`]); [`CompositeBasis::end_points`]
150//! are the points read at distance `0` and at the length.
151//!
152//! # Exactness
153//!
154//! A station frame on a composite is exact, rounding aside, only where
155//! every piece up to and including the one it is read on is exact
156//! ([`CompositeBasis::frame_is_exact_at`]): those pieces' lengths make the
157//! distance, and the read piece makes the frame. A piece is exact when its
158//! curve is a line and its placement, if any, was exact
159//! ([`StationPiece::frame_is_exact`]). Elsewhere the frame carries the
160//! tolerances of the parent module.
161
162use axiolid_contracts::{BackendId, GeomError, GeomResult, Operation};
163use axiolid_core::{Point3, Scalar, Transform3};
164use axiolid_curve::{
165    Curve2, Curve3, CurvePath, PathBound, PathCurve, PathOffset, PathPiece, SeamSide,
166};
167use axiolid_curve_evaluate_contract::{window_span, DistanceConvention};
168
169use super::certify::{
170    checked_tolerance, curve_span, piece_length, read_parameter, CertifiedSeam, DistanceInterval,
171    Running, POINT_ROUNDING, UNCERTIFIED_LENGTH,
172};
173use super::offset::{OffsetTable, StationOffset};
174use super::seam::tidy;
175use super::window::{certification, checked_window, snap};
176use super::{
177    admitted, invalid, start2, start3, station_length2, station_length3, station_seams2,
178    station_seams3, station_section2_on, station_section3_on, SectionFrame, StationSeam,
179};
180use crate::arc_parameter::{
181    arc_length2, arc_length3, parameter_at_arc_length2, parameter_at_arc_length3,
182    ARC_LENGTH_TOLERANCE,
183};
184use crate::curve::{evaluate2, evaluate3};
185
186/// Relative tolerance within which two consecutive pieces of a
187/// [`CompositeBasis`] meet: their joint points may be `JOINT_TOLERANCE *
188/// max(1, |p|)` apart, `|p|` the joint's largest coordinate magnitude.
189pub const JOINT_TOLERANCE: Scalar = 1e-9;
190
191/// The rounding a piece's chord ([`StationPiece::length_floor`]) is reduced
192/// by, relative to the largest coordinate of its end points and its
193/// length: a hundred times [`POINT_ROUNDING`], so the floor stays below the
194/// lower bound [`super::certify`] certifies, whose chords carry that
195/// rounding once each.
196const CHORD_ROUNDING: Scalar = 100.0 * POINT_ROUNDING;
197
198/// Relative slack within which a closed conic's piece bounded at two
199/// parameters (#304) still spans `t1 - t0` rather than that wrapped into
200/// one turn: a graph compiler's slack for a periodic trim, so both read
201/// the same span.
202const TURN_SLACK: Scalar = 1e-9;
203
204/// The arc-length tolerance around a distance: what counts as ON a seam or
205/// a joint.
206fn slack(distance: Scalar) -> Scalar {
207    ARC_LENGTH_TOLERANCE * distance.abs().max(1.0)
208}
209
210fn unsupported(input: &'static str) -> GeomError {
211    GeomError::UnsupportedInput {
212        backend: BackendId::new("axiolid-evaluate"),
213        operation: Operation::CurveEvaluation,
214        input,
215    }
216}
217
218/// The atomic curve under a [`StationPiece`].
219#[non_exhaustive]
220#[derive(Debug, Clone, Copy, PartialEq)]
221pub enum StationCurve<'c> {
222    /// A 2D curve, lying in `z = 0`.
223    Two(&'c Curve2),
224    /// A 3D curve.
225    Three(&'c Curve3),
226    /// An offset of a base piece, measured in its own length (#289; see
227    /// [the offset module](super::offset)).
228    Offset(StationOffset<'c>),
229}
230
231impl From<StationCurve<'_>> for PathCurve {
232    /// The curve, copied.
233    fn from(curve: StationCurve<'_>) -> Self {
234        match curve {
235            StationCurve::Two(curve) => PathCurve::Two(curve.clone()),
236            StationCurve::Three(curve) => PathCurve::Three(curve.clone()),
237            StationCurve::Offset(offset) => PathCurve::Offset(Box::new(PathOffset::new(
238                PathPiece::from(offset.base()),
239                offset.law(),
240            ))),
241        }
242    }
243}
244
245impl<'c> StationCurve<'c> {
246    /// The station curve of a path piece's curve, borrowed from it.
247    ///
248    /// # Errors
249    ///
250    /// A piece kind this evaluator does not know, by name.
251    pub fn from_path_curve(curve: &'c PathCurve) -> GeomResult<Self> {
252        match curve {
253            PathCurve::Two(curve) => Ok(Self::Two(curve)),
254            PathCurve::Three(curve) => Ok(Self::Three(curve)),
255            PathCurve::Offset(offset) => Ok(Self::Offset(StationOffset::new(
256                StationPiece::from_path_piece(&offset.base)?,
257                offset.law,
258            )?)),
259            _ => Err(unsupported(
260                "a curve path piece of a kind this evaluator does not know",
261            )),
262        }
263    }
264}
265
266impl StationCurve<'_> {
267    /// The distance convention a station on this curve is measured in:
268    /// plan distance on an elevated or banked curve, arc length on every
269    /// other (a 2D curve's arc length is its plan length).
270    /// An offset is measured as its base is (#289).
271    #[must_use]
272    pub fn convention(self) -> DistanceConvention {
273        match self {
274            Self::Three(Curve3::Elevated(_) | Curve3::Banked(_)) => {
275                DistanceConvention::PlanDistance
276            }
277            Self::Offset(offset) => offset.convention(),
278            _ => DistanceConvention::ArcLength3d,
279        }
280    }
281
282    /// The station length (the parent module's `station_length`).
283    fn length(self) -> GeomResult<Option<Scalar>> {
284        match self {
285            Self::Two(curve) => station_length2(curve),
286            Self::Three(curve) => station_length3(curve),
287            Self::Offset(offset) => offset.length().map(Some),
288        }
289    }
290
291    /// Whether a frame read on it is exact: a line, or an offset of a line
292    /// by a constant law.
293    fn frame_is_exact(self) -> bool {
294        match self {
295            Self::Offset(offset) => offset.frame_is_exact(),
296            _ => self.is_line(),
297        }
298    }
299
300    /// Where the curve ends in its station measure, by this evaluator's own
301    /// length (what an open end resolves to, #298): an unbounded line's
302    /// parameter domain end `|d|`, else its station length, exact where it
303    /// is read from stored data and the numerical estimate otherwise (an
304    /// ellipse's or a B-spline's quadrature, an offset's table).
305    fn end(self) -> GeomResult<Scalar> {
306        match self.line_speed() {
307            Some(speed) => Ok(speed),
308            None => self
309                .length()?
310                .ok_or_else(|| invalid("station: a composite piece has no bounded length".into())),
311        }
312    }
313
314    fn is_line(self) -> bool {
315        matches!(
316            self,
317            Self::Two(Curve2::Line(_)) | Self::Three(Curve3::Line(_))
318        )
319    }
320
321    /// A circle or an ellipse: its measure runs round, so a span may cross
322    /// its parameter seam.
323    fn is_closed_conic(self) -> bool {
324        matches!(
325            self,
326            Self::Two(Curve2::Circle(_) | Curve2::Ellipse(_))
327                | Self::Three(Curve3::Circle(_) | Curve3::Ellipse(_))
328        )
329    }
330
331    /// Whether a distance on it is read from stored data alone: false for
332    /// an ellipse and a B-spline, whose arc length is a quadrature, and an
333    /// offset other than a line or a circle.
334    pub(super) fn measure_is_exact(self) -> bool {
335        match self {
336            Self::Offset(offset) => offset.measure_is_exact(),
337            _ => !matches!(
338                self,
339                Self::Two(Curve2::Ellipse(_) | Curve2::BSpline(_))
340                    | Self::Three(Curve3::Ellipse(_) | Curve3::BSpline(_))
341            ),
342        }
343    }
344
345    /// A line's direction length, `None` for any other family.
346    fn line_speed(self) -> Option<Scalar> {
347        match self {
348            Self::Two(Curve2::Line(line)) => Some(line.direction.length()),
349            Self::Three(Curve3::Line(line)) => Some(line.direction.length()),
350            _ => None,
351        }
352    }
353
354    /// A closed conic's angle folded into the turn from its seam, `[0, 2
355    /// pi)`; any other curve's parameter as it is.
356    fn turned(self, t: Scalar) -> Scalar {
357        if self.is_closed_conic() {
358            t.rem_euclid(core::f64::consts::TAU)
359        } else {
360            t
361        }
362    }
363
364    /// The parameters a piece bounded at both ends spans (#304): as given,
365    /// except on a closed conic, whose angle runs round: from `t0` forwards
366    /// by `t1 - t0` where that is within one turn (to [`TURN_SLACK`]),
367    /// else by `t1 - t0` wrapped into one turn, a whole turn where the two
368    /// are whole turns apart. That is the span a graph compiler reads for
369    /// a trim of the conic in its sense, with the same arithmetic.
370    fn wound(self, t0: Scalar, t1: Scalar) -> (Scalar, Scalar) {
371        if !self.is_closed_conic() {
372            return (t0, t1);
373        }
374        let turn = core::f64::consts::TAU;
375        let travelled = t1 - t0;
376        let span = if travelled > 0.0 && travelled <= turn * (1.0 + TURN_SLACK) {
377            travelled
378        } else {
379            match travelled.rem_euclid(turn) {
380                0.0 => turn,
381                wrapped => wrapped,
382            }
383        };
384        (t0, t0 + span)
385    }
386
387    /// The measure an end bounded at parameter `t` stands at after a span
388    /// starting at measure `start` (#304): the measure at `t`; on a closed
389    /// conic at its angle in `[0, 2 pi)`, a turn further where that is not
390    /// after `start` (the span crosses the seam, ends on it, or is the
391    /// whole turn).
392    fn measure_after(self, t: Scalar, start: Scalar) -> GeomResult<Scalar> {
393        let m = self.measure_at(self.turned(t))?;
394        if self.is_closed_conic() && m <= start {
395            return Ok(self.length()?.unwrap_or(Scalar::INFINITY) + m);
396        }
397        Ok(m)
398    }
399
400    /// The native parameter a path piece bound names on the curve (#304):
401    /// a parameter as it is, a point the one parameter naming it (see
402    /// [the path-bound module](super::path_bound)).
403    ///
404    /// # Errors
405    ///
406    /// A bound that is not finite; a point off the curve, named by several
407    /// parameters, or on a curve this evaluator does not invert; each by
408    /// name.
409    pub fn parameter_at_bound(self, bound: PathBound) -> GeomResult<Scalar> {
410        super::path_bound::bound_parameter(self, bound)
411    }
412
413    /// A closed conic's measure `m` folded into one turn `[0, L]`.
414    fn folded(self, m: Scalar) -> GeomResult<Scalar> {
415        if !self.is_closed_conic() {
416            return Ok(m);
417        }
418        let turn = self.length()?.unwrap_or(Scalar::INFINITY);
419        Ok(if m > turn { m - turn } else { m })
420    }
421
422    /// The station measure at native parameter `t`, from the curve's
423    /// start: `t |d|` on a line (any sign), `r t` on a circle, the arc
424    /// length from the domain's start on a polyline, a B-spline or an
425    /// ellipse, `t` itself on a curve parameterised by its measure.
426    fn measure_at(self, t: Scalar) -> GeomResult<Scalar> {
427        Ok(match self {
428            Self::Two(Curve2::Line(line)) => t * line.direction.length(),
429            Self::Three(Curve3::Line(line)) => t * line.direction.length(),
430            Self::Two(Curve2::Circle(circle)) => t * circle.radius,
431            Self::Three(Curve3::Circle(circle)) => t * circle.radius,
432            Self::Two(curve @ (Curve2::Polyline(_) | Curve2::BSpline(_) | Curve2::Ellipse(_))) => {
433                arc_length2(curve, start2(curve), t)?
434            }
435            Self::Three(
436                curve @ (Curve3::Polyline(_) | Curve3::BSpline(_) | Curve3::Ellipse(_)),
437            ) => arc_length3(curve, start3(curve), t)?,
438            Self::Offset(_) => {
439                return Err(unsupported(
440                    "the native parameter of an offset piece: an offset takes its basis's \
441                     parameter, not its own length",
442                ))
443            }
444            _ => t,
445        })
446    }
447
448    /// The native parameter the kernel reads the station measure `m` at
449    /// (from the curve's start): the one [`Self::point_at`] evaluates the
450    /// curve at, so a span between two such parameters is the span between
451    /// the points a station reads.
452    fn native_parameter(self, m: Scalar) -> GeomResult<Scalar> {
453        match self {
454            Self::Offset(_) => Err(unsupported(
455                "the native parameter of an offset piece: an offset takes its basis's \
456                 parameter, not its own length",
457            )),
458            Self::Three(Curve3::Elevated(_) | Curve3::Banked(_)) => Err(unsupported(
459                "the native parameter of an elevated or banked curve: a piece of it is \
460                 measured in plan distance, not in arc length",
461            )),
462            Self::Two(Curve2::Intrinsic(_) | Curve2::Chain(_))
463            | Self::Three(Curve3::Intrinsic(_)) => Ok(m),
464            Self::Two(
465                curve @ (Curve2::Line(_)
466                | Curve2::Circle(_)
467                | Curve2::Ellipse(_)
468                | Curve2::BSpline(_)),
469            ) => read_parameter(Self::Two(curve), m),
470            Self::Three(
471                curve @ (Curve3::Line(_)
472                | Curve3::Circle(_)
473                | Curve3::Ellipse(_)
474                | Curve3::BSpline(_)),
475            ) => read_parameter(Self::Three(curve), m),
476            Self::Two(curve) => parameter_at_arc_length2(curve, start2(curve), m),
477            Self::Three(curve) => parameter_at_arc_length3(curve, start3(curve), m),
478        }
479    }
480
481    /// The point at measure `m` from the curve's start; an offset's
482    /// through `table`.
483    fn point_at(self, m: Scalar, table: Option<&OffsetTable>) -> GeomResult<Point3> {
484        let m = self.folded(m)?;
485        match self {
486            Self::Offset(offset) => offset.point_at(m, table),
487            Self::Two(curve) => {
488                let t = match curve {
489                    Curve2::Line(line) => m / line.direction.length(),
490                    _ => parameter_at_arc_length2(curve, start2(curve), m)?,
491                };
492                let p = evaluate2(curve, t)?;
493                Ok(Point3::new(p.x, p.y, 0.0))
494            }
495            Self::Three(curve) => match curve {
496                Curve3::Elevated(elevated) => crate::arc_length::elevated_point(elevated, m),
497                Curve3::Banked(banked) => crate::banked::banked_point(banked, m),
498                Curve3::Line(line) => evaluate3(curve, m / line.direction.length()),
499                _ => evaluate3(curve, parameter_at_arc_length3(curve, start3(curve), m)?),
500            },
501        }
502    }
503
504    /// The section frame at measure `m`, read from `side` on a seam; an
505    /// offset's through `table`.
506    fn section_at(
507        self,
508        m: Scalar,
509        side: SeamSide,
510        table: Option<&OffsetTable>,
511    ) -> GeomResult<SectionFrame> {
512        let m = self.folded(m)?;
513        match self {
514            Self::Offset(offset) => offset.section_at(m, table),
515            // A line inside a composite may be trimmed before its origin, so
516            // its measure may be negative: read it directly.
517            Self::Two(Curve2::Line(line)) => {
518                let point = line.origin + line.direction * (m / line.direction.length());
519                super::planar_frame(point, line.direction)
520            }
521            Self::Three(curve @ Curve3::Line(line)) => super::reference_up_frame(
522                evaluate3(curve, m / line.direction.length())?,
523                line.direction,
524            ),
525            Self::Two(curve) => station_section2_on(curve, m, side),
526            Self::Three(curve) => station_section3_on(curve, m, side),
527        }
528    }
529
530    fn seams(self) -> GeomResult<Vec<StationSeam>> {
531        if self.is_closed_conic() {
532            return Ok(Vec::new());
533        }
534        match self {
535            Self::Two(curve) => station_seams2(curve),
536            Self::Three(curve) => station_seams3(curve),
537            // Its base spans no seam of its curve.
538            Self::Offset(_) => Ok(Vec::new()),
539        }
540    }
541}
542
543/// One piece of a [`CompositeBasis`]: the span `[start, end]` of an atomic
544/// curve, in that curve's station measure, traversed forwards or
545/// backwards, optionally placed by a rigid motion; see the
546/// [module documentation](self).
547#[non_exhaustive]
548#[derive(Debug, Clone, Copy, PartialEq)]
549pub struct StationPiece<'c> {
550    /// The atomic curve.
551    pub curve: StationCurve<'c>,
552    /// Where the span starts, in the curve's station measure from its
553    /// start (negative only on a line).
554    pub start: Scalar,
555    /// Where the span ends, greater than `start`.
556    pub end: Scalar,
557    /// Whether the composite runs from `end` to `start`.
558    pub reversed: bool,
559    /// The rigid motion carrying the curve, if it is placed.
560    pub placement: Option<Transform3>,
561    /// Whether that placement is exact, rounding aside (`true` with no
562    /// placement).
563    pub placement_exact: bool,
564}
565
566impl<'c> StationPiece<'c> {
567    /// The whole curve: `[0, L]`, `L` its station length; an unbounded
568    /// line is its parameter domain `[0, 1]`, from its origin to `origin +
569    /// direction`.
570    ///
571    /// # Errors
572    ///
573    /// A curve whose length a station cannot be measured against, by name.
574    pub fn whole(curve: StationCurve<'c>) -> GeomResult<Self> {
575        Self::between(curve, 0.0, curve.end()?)
576    }
577
578    /// The span `[start, end]` of `curve`, in its station measure.
579    ///
580    /// # Errors
581    ///
582    /// A non-finite or empty span, and one outside the curve's measure
583    /// (before its start or past its length, beyond the arc-length
584    /// tolerance; a closed conic's span may cross its parameter seam but
585    /// not exceed one turn), by name.
586    pub fn between(curve: StationCurve<'c>, start: Scalar, end: Scalar) -> GeomResult<Self> {
587        if !(start.is_finite() && end.is_finite()) || end <= start {
588            return Err(GeomError::Degenerate(format!(
589                "station: a composite piece spans [{start}, {end}], which is empty or not finite"
590            )));
591        }
592        if curve
593            .line_speed()
594            .is_some_and(|speed| !(speed.is_finite() && speed > 0.0))
595        {
596            return Err(GeomError::Degenerate(
597                "station: a composite piece is a line with no direction".into(),
598            ));
599        }
600        if !curve.is_line() {
601            let length = curve.length()?.unwrap_or(Scalar::INFINITY);
602            let reach = if curve.is_closed_conic() {
603                start + length
604            } else {
605                length
606            };
607            if start < -slack(0.0) || start > length + slack(length) || end > reach + slack(reach) {
608                return Err(invalid(format!(
609                    "station: a composite piece spans [{start}, {end}], outside its curve's \
610                     measure [0, {length}]"
611                )));
612            }
613        }
614        Ok(Self {
615            curve,
616            start,
617            end,
618            reversed: false,
619            placement: None,
620            placement_exact: true,
621        })
622    }
623
624    /// The span between native parameters `t0 < t1` of `curve`, converted
625    /// to its station measure. A closed conic's span may start anywhere
626    /// and cross its parameter seam (at most one turn).
627    ///
628    /// # Errors
629    ///
630    /// As [`Self::between`], and the arc-length quadrature's refusals.
631    pub fn between_parameters(curve: StationCurve<'c>, t0: Scalar, t1: Scalar) -> GeomResult<Self> {
632        if !(t0.is_finite() && t1.is_finite()) || t1 <= t0 {
633            return Err(GeomError::Degenerate(format!(
634                "station: a composite piece spans parameters [{t0}, {t1}], which is empty or \
635                 not finite"
636            )));
637        }
638        if curve.is_closed_conic() {
639            let turn = core::f64::consts::TAU;
640            let from = t0.rem_euclid(turn);
641            let to = from + (t1 - t0);
642            let start = curve.measure_at(from)?;
643            let end = if to <= turn {
644                curve.measure_at(to)?
645            } else {
646                let length = curve.length()?.unwrap_or(Scalar::INFINITY);
647                length + curve.measure_at(to - turn)?
648            };
649            return Self::between(curve, start, end);
650        }
651        Self::between(curve, curve.measure_at(t0)?, curve.measure_at(t1)?)
652    }
653
654    /// The same span traversed the other way.
655    #[must_use]
656    pub const fn reversed(mut self) -> Self {
657        self.reversed = !self.reversed;
658        self
659    }
660
661    /// The same piece carried further by the rigid motion `rigid` (applied
662    /// after any placement it already has), `exact` when that motion is
663    /// (as [`PathPiece::placed`]).
664    #[must_use]
665    pub fn placed(mut self, rigid: Transform3, exact: bool) -> Self {
666        self.placement = Some(match self.placement {
667            Some(inner) => rigid * inner,
668            None => rigid,
669        });
670        self.placement_exact &= exact;
671        self
672    }
673
674    /// The whole of an offset (#289): `[0, L]`, `L` its own length.
675    ///
676    /// # Errors
677    ///
678    /// The offset's refusals (a collapse, a cusp, a self-crossing), by
679    /// name.
680    pub fn offset(offset: StationOffset<'c>) -> GeomResult<Self> {
681        let end = offset.length()?;
682        if !(end.is_finite() && end > 0.0) {
683            return Err(GeomError::Degenerate(
684                "station: an offset piece has no length".into(),
685            ));
686        }
687        Ok(Self {
688            curve: StationCurve::Offset(offset),
689            start: 0.0,
690            end,
691            reversed: false,
692            placement: None,
693            placement_exact: true,
694        })
695    }
696
697    /// The offset's measure table, for an offset piece.
698    pub(super) fn table(&self) -> GeomResult<Option<OffsetTable>> {
699        match self.curve {
700            StationCurve::Offset(offset) => offset.table().map(Some),
701            _ => Ok(None),
702        }
703    }
704
705    /// The station piece of a path piece: its span checked against its
706    /// curve as [`Self::between`] checks it, then reversed and placed as
707    /// the path piece is. An open end ([`PathPiece::open_end`], #298) is
708    /// resolved first to where the curve ends by this evaluator's own
709    /// length, the end [`Self::whole`] reads (see the
710    /// [module documentation](self#open-ends-298)). A bound at a parameter
711    /// or a point ([`PathPiece::start_bound`], [`PathPiece::end_bound`],
712    /// #304) is resolved to the curve's parameter there
713    /// ([`StationCurve::parameter_at_bound`]) and that to its measure: a
714    /// piece bounded at both ends is [`Self::between_parameters`] (see the
715    /// [module documentation](self#bounds-at-a-parameter-or-a-point-304)).
716    ///
717    /// # Errors
718    ///
719    /// As [`Self::between`] and [`Self::between_parameters`], an open end on
720    /// a curve with no bounded length, an end both open and bounded, a
721    /// bound's refusals ([`StationCurve::parameter_at_bound`]), and a piece
722    /// kind this evaluator does not know, by name.
723    pub fn from_path_piece(piece: &'c PathPiece) -> GeomResult<Self> {
724        let curve = StationCurve::from_path_curve(&piece.curve)?;
725        if piece.open_end && piece.end_bound.is_some() {
726            return Err(invalid(
727                "station: a curve path piece whose end is both open and bounded by a parameter \
728                 or a point"
729                    .into(),
730            ));
731        }
732        let t0 = piece
733            .start_bound
734            .map(|bound| curve.parameter_at_bound(bound))
735            .transpose()?;
736        let t1 = piece
737            .end_bound
738            .map(|bound| curve.parameter_at_bound(bound))
739            .transpose()?;
740        let span = match (t0, t1) {
741            // Both ends at parameters: the piece between them, bitwise.
742            (Some(t0), Some(t1)) => {
743                let (t0, t1) = curve.wound(t0, t1);
744                Self::between_parameters(curve, t0, t1)?
745            }
746            _ => {
747                let start = match t0 {
748                    Some(t) => curve.measure_at(curve.turned(t))?,
749                    None => piece.start,
750                };
751                let end = if piece.open_end {
752                    curve.end()?
753                } else if let Some(t) = t1 {
754                    curve.measure_after(t, start)?
755                } else {
756                    piece.end
757                };
758                Self::between(curve, start, end)?
759            }
760        };
761        Ok(Self {
762            reversed: piece.reversed,
763            placement: piece.placement,
764            placement_exact: piece.placement_exact,
765            ..span
766        })
767    }
768
769    /// The native parameters `t0 < t1` the kernel reads at the piece's
770    /// start and end measures, the ones a point read inside the piece is
771    /// evaluated at: `m / |d|` on a line, `m / r` on a circle (past `2 pi`
772    /// where the piece crosses its seam), `m` on a curve parameterised by
773    /// its measure, and on a polyline, an ellipse or a B-spline the
774    /// arc-length inverse from the curve's start (a closed ellipse past its
775    /// length: `2 pi` plus the inverse of the measure less the length). A
776    /// graph compiler that flattens the curve between them lands on the
777    /// points [`CompositeBasis::section_on`] reads at the same distances.
778    ///
779    /// # Errors
780    ///
781    /// An offset piece (it takes its basis's parameter, not its own
782    /// length), an elevated or banked curve (it is measured in plan
783    /// distance), and the quadratures' refusals, by name.
784    pub fn native_span(&self) -> GeomResult<(Scalar, Scalar)> {
785        Ok((
786            self.curve.native_parameter(self.start)?,
787            self.curve.native_parameter(self.end)?,
788        ))
789    }
790
791    /// The piece's length in its station measure.
792    #[must_use]
793    pub fn length(&self) -> Scalar {
794        self.end - self.start
795    }
796
797    /// The distance convention the piece is measured in.
798    #[must_use]
799    pub fn convention(&self) -> DistanceConvention {
800        self.curve.convention()
801    }
802
803    /// Whether a frame read on this piece is exact, rounding aside: a line
804    /// (an offset of a line by a constant law included), placed, if at
805    /// all, exactly (as [`PathPiece::frame_is_exact`]).
806    #[must_use]
807    pub fn frame_is_exact(&self) -> bool {
808        self.curve.frame_is_exact() && self.placement_exact
809    }
810
811    /// The curve's measure at distance `u` into the piece, and the side of
812    /// a seam of the curve that `side` (in the composite's direction) is.
813    fn measure(&self, u: Scalar, side: SeamSide) -> (Scalar, SeamSide) {
814        if self.reversed {
815            let swapped = match side {
816                SeamSide::Incoming => SeamSide::Outgoing,
817                _ => SeamSide::Incoming,
818            };
819            (self.end - u, swapped)
820        } else {
821            (self.start + u, side)
822        }
823    }
824
825    /// The point at distance `u` into the piece; an offset's through
826    /// `table`.
827    fn point_at(&self, u: Scalar, table: Option<&OffsetTable>) -> GeomResult<Point3> {
828        let (m, _) = self.measure(u, SeamSide::Outgoing);
829        let point = self.curve.point_at(m, table)?;
830        Ok(match self.placement {
831            Some(rigid) => rigid.transform_point3(point),
832            None => point,
833        })
834    }
835
836    /// A certified lower bound of the piece's length in its station measure
837    /// (#305), with no quadrature: the chord between the points read as its
838    /// ends, in the plan where it is measured in plan (a plan length is
839    /// never shorter than its plan chord), less their rounding. Read
840    /// unplaced: a placement is rigid, and one that tilts `+Z` under a
841    /// plan-measured curve is refused where the piece is read.
842    fn length_floor(&self, table: Option<&OffsetTable>) -> GeomResult<Scalar> {
843        let a = self.curve.point_at(self.start, table)?;
844        let b = self.curve.point_at(self.end, table)?;
845        let d = b - a;
846        let chord = match self.convention() {
847            DistanceConvention::PlanDistance => d.x.hypot(d.y),
848            _ => d.length(),
849        };
850        let scale = a
851            .abs()
852            .max_element()
853            .max(b.abs().max_element())
854            .max(self.length())
855            .max(1.0);
856        Ok((chord - CHORD_ROUNDING * scale).max(0.0))
857    }
858
859    /// The section frame at distance `u` into the piece, read from `side`
860    /// on a seam of its curve; at the piece's own ends it is always read
861    /// from inside the piece.
862    ///
863    /// # Errors
864    ///
865    /// The curve evaluators' refusals, and a plan-measured curve placed by
866    /// a motion that tilts `+Z`, by name.
867    pub fn section_on(&self, u: Scalar, side: SeamSide) -> GeomResult<SectionFrame> {
868        self.section_with(u, side, None)
869    }
870
871    /// [`Self::section_on`], an offset read through `table`.
872    pub(crate) fn section_with(
873        &self,
874        u: Scalar,
875        side: SeamSide,
876        table: Option<&OffsetTable>,
877    ) -> GeomResult<SectionFrame> {
878        let (_, section) = self.raw_section(u, side, table)?;
879        self.finished(section)
880    }
881
882    /// The curve's own measure a read at distance `u` into the piece from
883    /// `side` stands at, and the curve's section there before the piece
884    /// reverses or places it: what [`Self::section_on`] reads (#316).
885    pub(crate) fn raw_section(
886        &self,
887        u: Scalar,
888        side: SeamSide,
889        table: Option<&OffsetTable>,
890    ) -> GeomResult<(Scalar, SectionFrame)> {
891        let (m, curve_side) = self.read_at(u, side);
892        Ok((m, self.curve.section_at(m, curve_side, table)?))
893    }
894
895    /// The curve's own measure a read at distance `u` into the piece from
896    /// `side` stands at, and the side of the curve it reads (#361).
897    pub(crate) fn read_at(&self, u: Scalar, side: SeamSide) -> (Scalar, SeamSide) {
898        let length = self.length();
899        let u = u.clamp(0.0, length);
900        let side = if u <= slack(u) {
901            SeamSide::Outgoing
902        } else if u >= length - slack(length) {
903            SeamSide::Incoming
904        } else {
905            side
906        };
907        self.measure(u, side)
908    }
909
910    /// A curve's section as the piece presents it: reversed, then placed.
911    pub(crate) fn finished(&self, mut section: SectionFrame) -> GeomResult<SectionFrame> {
912        if self.reversed {
913            section.tangent = -section.tangent;
914            section.lateral = -section.lateral;
915        }
916        match self.placement {
917            Some(rigid) => section.carried(rigid, self.convention()),
918            None => Ok(section),
919        }
920    }
921
922    /// The seams of the curve strictly inside the piece, at their distance
923    /// into the piece in the composite's direction.
924    pub(super) fn seams(&self) -> GeomResult<Vec<StationSeam>> {
925        let mut out: Vec<StationSeam> = self
926            .curve
927            .seams()?
928            .into_iter()
929            .filter(|seam| {
930                seam.distance > self.start + slack(self.start)
931                    && seam.distance < self.end - slack(self.end)
932            })
933            .map(|seam| {
934                let u = if self.reversed {
935                    self.end - seam.distance
936                } else {
937                    seam.distance - self.start
938                };
939                StationSeam::new(u, u, seam.smooth, seam.exact)
940            })
941            .collect();
942        out.sort_by(|a, b| a.distance.total_cmp(&b.distance));
943        Ok(out)
944    }
945}
946
947impl From<StationPiece<'_>> for PathPiece {
948    /// The piece, its curve copied.
949    fn from(piece: StationPiece<'_>) -> Self {
950        let mut out = PathPiece::new(piece.curve.into(), piece.start, piece.end);
951        out.reversed = piece.reversed;
952        out.placement = piece.placement;
953        out.placement_exact = piece.placement_exact;
954        out
955    }
956}
957
958/// Pieces of curves laid end to end as one station basis; see the
959/// [module documentation](self).
960#[derive(Debug, Clone, PartialEq)]
961pub struct CompositeBasis<'c> {
962    pieces: Vec<StationPiece<'c>>,
963    /// Each offset piece's measure table, built once (#289).
964    tables: Vec<Option<OffsetTable>>,
965    /// Each piece's start distance, and the total length last.
966    starts: Vec<Scalar>,
967    convention: DistanceConvention,
968}
969
970impl<'c> CompositeBasis<'c> {
971    /// The composite of `pieces`, in order.
972    ///
973    /// # Errors
974    ///
975    /// No pieces, a piece with no length, pieces measured in different
976    /// conventions, and a joint whose pieces do not meet (a reversed piece
977    /// or a gap), each by name; and the evaluators' refusals reading the
978    /// joints' points.
979    pub fn new(pieces: Vec<StationPiece<'c>>) -> GeomResult<Self> {
980        let Some(first) = pieces.first() else {
981            return Err(invalid("station: a composite basis has no pieces".into()));
982        };
983        let convention = first.convention();
984        for (index, piece) in pieces.iter().enumerate() {
985            let length = piece.length();
986            if !length.is_finite() || length <= 0.0 {
987                return Err(GeomError::Degenerate(format!(
988                    "station: piece {index} of a composite basis has no length"
989                )));
990            }
991            if piece.convention() != convention {
992                return Err(unsupported(
993                    "a composite station basis whose pieces measure stations differently (plan \
994                     distance on an elevated or banked piece, arc length on another): no one \
995                     distance runs through it",
996                ));
997            }
998        }
999        let tables = pieces
1000            .iter()
1001            .map(StationPiece::table)
1002            .collect::<GeomResult<Vec<_>>>()?;
1003        for index in 1..pieces.len() {
1004            check_joint(
1005                (&pieces[index - 1], tables[index - 1].as_ref()),
1006                (&pieces[index], tables[index].as_ref()),
1007                index,
1008            )?;
1009        }
1010        let mut starts = Vec::with_capacity(pieces.len() + 1);
1011        let mut run = 0.0;
1012        for piece in &pieces {
1013            starts.push(run);
1014            run += piece.length();
1015        }
1016        starts.push(run);
1017        Ok(Self {
1018            pieces,
1019            tables,
1020            starts,
1021            convention,
1022        })
1023    }
1024
1025    /// The composite along `path` (#290), its curves borrowed from it: each
1026    /// piece checked against its curve ([`StationPiece::from_path_piece`]),
1027    /// then measured as [`Self::new`] measures them.
1028    ///
1029    /// # Errors
1030    ///
1031    /// As [`StationPiece::from_path_piece`] for each piece, naming its
1032    /// index, and as [`Self::new`].
1033    pub fn from_path(path: &'c CurvePath) -> GeomResult<Self> {
1034        let pieces = path
1035            .pieces()
1036            .iter()
1037            .map(StationPiece::from_path_piece)
1038            .collect::<GeomResult<Vec<_>>>()?;
1039        Self::new(pieces)
1040    }
1041
1042    /// The composite's pieces as a neutral [`CurvePath`] (#290), their
1043    /// curves copied.
1044    #[must_use]
1045    pub fn path(&self) -> CurvePath {
1046        self.pieces
1047            .iter()
1048            .map(|piece| PathPiece::from(*piece))
1049            .collect()
1050    }
1051
1052    /// The pieces, in order.
1053    #[must_use]
1054    pub fn pieces(&self) -> &[StationPiece<'c>] {
1055        &self.pieces
1056    }
1057
1058    /// Each piece's start distance, and the total length last.
1059    pub(crate) fn starts(&self) -> &[Scalar] {
1060        &self.starts
1061    }
1062
1063    /// Each offset piece's measure table.
1064    pub(crate) fn tables(&self) -> &[Option<OffsetTable>] {
1065        &self.tables
1066    }
1067
1068    /// Certified bounds on the frame law along the composite (#316), its
1069    /// quadratures certified to `tolerance` ([`super::FrameLaw`]).
1070    ///
1071    /// # Errors
1072    ///
1073    /// A tolerance that is not finite and positive, by name.
1074    pub fn frame_law(&self, tolerance: Scalar) -> GeomResult<super::FrameLaw<'_, 'c>> {
1075        super::FrameLaw::composite(self, tolerance)
1076    }
1077
1078    /// The total length in the composite's station measure.
1079    #[must_use]
1080    pub fn length(&self) -> Scalar {
1081        self.starts[self.pieces.len()]
1082    }
1083
1084    /// The convention every piece, and so the composite, is measured in.
1085    #[must_use]
1086    pub fn convention(&self) -> DistanceConvention {
1087        self.convention
1088    }
1089
1090    /// The piece a station at the admitted `distance` reads on `side`, and
1091    /// the distance into it.
1092    fn locate(&self, distance: Scalar, side: SeamSide) -> (usize, Scalar) {
1093        let count = self.pieces.len();
1094        for joint in 1..count {
1095            let at = self.starts[joint];
1096            if (at - distance).abs() <= slack(distance) {
1097                return match side {
1098                    SeamSide::Incoming => (joint - 1, self.pieces[joint - 1].length()),
1099                    _ => (joint, 0.0),
1100                };
1101            }
1102        }
1103        let k = self.starts[1..count].partition_point(|start| *start <= distance);
1104        (k, distance - self.starts[k])
1105    }
1106
1107    /// The piece a station at `distance` reads on `side` and the distance
1108    /// into it, the distance admitted as [`Self::section_on`] does (#405).
1109    ///
1110    /// # Errors
1111    ///
1112    /// As [`Self::section_on`] for the distance.
1113    pub(super) fn locate_admitted(
1114        &self,
1115        distance: Scalar,
1116        side: SeamSide,
1117    ) -> GeomResult<(usize, Scalar)> {
1118        Ok(self.locate(admitted(distance, Some(self.length()))?, side))
1119    }
1120
1121    /// The piece a station at `distance` reads on `side` (the one
1122    /// [`Self::section_on`] reads).
1123    ///
1124    /// # Errors
1125    ///
1126    /// As [`Self::section_on`] for the distance.
1127    pub(crate) fn piece_read(
1128        &self,
1129        distance: Scalar,
1130        side: SeamSide,
1131    ) -> GeomResult<&StationPiece<'c>> {
1132        let distance = admitted(distance, Some(self.length()))?;
1133        Ok(&self.pieces[self.locate(distance, side).0])
1134    }
1135
1136    /// The section frame at `distance` along the composite, read from
1137    /// `side` on a joint or a seam inside a piece; see the
1138    /// [module documentation](self#joints).
1139    ///
1140    /// # Errors
1141    ///
1142    /// A distance that is not finite, negative or beyond the composite's
1143    /// length (by more than the arc-length tolerance), and the piece's
1144    /// refusals, by name.
1145    pub fn section_on(&self, distance: Scalar, side: SeamSide) -> GeomResult<SectionFrame> {
1146        let distance = admitted(distance, Some(self.length()))?;
1147        let (piece, into) = self.locate(distance, side);
1148        self.pieces[piece].section_with(into, side, self.tables[piece].as_ref())
1149    }
1150
1151    /// Whether the frame at `distance`, read from `side`, is exact: every
1152    /// piece up to and including the one it is read on is
1153    /// ([`StationPiece::frame_is_exact`]). `false` for a distance outside
1154    /// the composite.
1155    #[must_use]
1156    pub fn frame_is_exact_at(&self, distance: Scalar, side: SeamSide) -> bool {
1157        let Ok(distance) = admitted(distance, Some(self.length())) else {
1158            return false;
1159        };
1160        let (piece, _) = self.locate(distance, side);
1161        self.pieces[..=piece]
1162            .iter()
1163            .all(StationPiece::frame_is_exact)
1164    }
1165
1166    /// The composite's seams: every interior joint (never smooth) and the
1167    /// seams of each piece's curve strictly inside the piece, at their
1168    /// distance along the composite, which is also the parameter reported.
1169    /// A seam's distance is exact when every piece before it, and its own
1170    /// seam, is measured from stored data alone (not an ellipse or a
1171    /// B-spline).
1172    ///
1173    /// # Errors
1174    ///
1175    /// The seam readers' refusals.
1176    pub fn seams(&self) -> GeomResult<Vec<StationSeam>> {
1177        let mut out = Vec::new();
1178        let mut exact_before = true;
1179        for (k, piece) in self.pieces.iter().enumerate() {
1180            let at = self.starts[k];
1181            if k > 0 {
1182                out.push(StationSeam::new(at, at, false, exact_before));
1183            }
1184            for seam in piece.seams()? {
1185                let distance = at + seam.distance;
1186                out.push(StationSeam::new(
1187                    distance,
1188                    distance,
1189                    seam.smooth,
1190                    seam.exact && exact_before,
1191                ));
1192            }
1193            exact_before &= piece.curve.measure_is_exact();
1194        }
1195        Ok(tidy(out, Some(self.length())))
1196    }
1197
1198    /// [`Self::seams`], every one exact or refused.
1199    ///
1200    /// # Errors
1201    ///
1202    /// As [`Self::seams`], and [`GeomError::UnsupportedInput`] for a seam
1203    /// whose distance needs a quadrature: one after an ellipse or a
1204    /// B-spline piece, or a B-spline's corner knot.
1205    pub fn exact_seams(&self) -> GeomResult<Vec<StationSeam>> {
1206        let seams = self.seams()?;
1207        if seams.iter().any(|seam| !seam.exact) {
1208            return Err(unsupported(
1209                "exact seam distances of a composite with an ellipse or a B-spline piece before \
1210                 a seam, or a B-spline's corner knot: their arc length is a quadrature",
1211            ));
1212        }
1213        Ok(seams)
1214    }
1215
1216    /// Each piece's share of a `tolerance` on a sum of their lengths: a
1217    /// numerical piece's in proportion to its length, kept a little under
1218    /// the whole so the rounding of the sum fits.
1219    fn shares(&self, tolerance: Scalar) -> Vec<Scalar> {
1220        self.shares_until(tolerance, Scalar::INFINITY)
1221    }
1222
1223    /// [`Self::shares`] among the pieces starting by `until` (where the
1224    /// kernel reads them), the only ones a sum up to there holds; a piece
1225    /// past it keeps the share it would have had.
1226    fn shares_until(&self, tolerance: Scalar, until: Scalar) -> Vec<Scalar> {
1227        let numeric: Scalar = self
1228            .pieces
1229            .iter()
1230            .zip(&self.starts)
1231            .filter(|(_, start)| **start <= until)
1232            .map(|(piece, _)| piece)
1233            .filter(|piece| !piece.curve.measure_is_exact())
1234            .map(StationPiece::length)
1235            .sum();
1236        self.pieces
1237            .iter()
1238            .map(|piece| {
1239                if numeric > 0.0 {
1240                    0.9 * tolerance * piece.length() / numeric
1241                } else {
1242                    tolerance
1243                }
1244            })
1245            .collect()
1246    }
1247
1248    /// Where the composite's interior joints truly lie (#294): for each
1249    /// joint, in order, a [`DistanceInterval`] containing the sum of the
1250    /// true lengths of the pieces before it, each between the points read
1251    /// as its ends, no wider than `tolerance`. A sum of lengths read from
1252    /// stored data is a point interval; see [`super::certify`] for how a
1253    /// numerical length is bounded.
1254    ///
1255    /// # Errors
1256    ///
1257    /// A tolerance that is not finite and positive, a piece whose length
1258    /// cannot be certified ([`super::certify::UNCERTIFIED_LENGTH`]) or not
1259    /// within the panel budget, and the evaluators' refusals, by name.
1260    pub fn joint_intervals(&self, tolerance: Scalar) -> GeomResult<Vec<DistanceInterval>> {
1261        let tolerance = checked_tolerance(tolerance)?;
1262        Ok(self
1263            .certified_joints(tolerance, None)?
1264            .into_iter()
1265            .map(|(_, interval)| interval)
1266            .collect())
1267    }
1268
1269    /// The composite's interior joints that can fall within `window` of
1270    /// `around` (#305): each with its index among the joints and the
1271    /// interval [`Self::joint_intervals`] gives it at `tolerance`,
1272    /// bitwise (the tolerance shared among the same pieces, summed in the
1273    /// same order), for every joint whose interval meets `[around - window,
1274    /// around + window]`, edges included.
1275    ///
1276    /// Only the pieces those joints lie after are certified. Every piece
1277    /// before the window is, since its length moves the joints after it.
1278    /// A joint is known to lie past the window, and no piece after it is
1279    /// read, once the certified lower end of the joint before it plus a
1280    /// certified lower bound of its piece's length with no quadrature (the
1281    /// chord between its ends, in the plan where it is measured in plan)
1282    /// passes the window's end. So a piece whose length cannot be certified does not refuse the query
1283    /// when it lies after the window, or across its end far enough that
1284    /// its chord carries its own end past it; before the window, or ending
1285    /// within reach of it, it refuses by name. A joint left out truly lies
1286    /// outside the window, and one whose interval lies inside it is
1287    /// returned.
1288    ///
1289    /// # Errors
1290    ///
1291    /// A tolerance that is not finite and positive, an `around` that is not
1292    /// finite or a `window` that is negative or not finite, by name; and as
1293    /// [`Self::joint_intervals`], for the pieces before and within the
1294    /// window only.
1295    pub fn joint_intervals_within(
1296        &self,
1297        around: Scalar,
1298        window: Scalar,
1299        tolerance: Scalar,
1300    ) -> GeomResult<Vec<(usize, DistanceInterval)>> {
1301        let tolerance = checked_tolerance(tolerance)?;
1302        let span = window_span(around, window)?;
1303        self.certified_joints(tolerance, Some(span))
1304    }
1305
1306    /// Each interior joint's index and certified interval at `tolerance`;
1307    /// within `span` only, when one is given (see
1308    /// [`Self::joint_intervals_within`]).
1309    fn certified_joints(
1310        &self,
1311        tolerance: Scalar,
1312        span: Option<DistanceInterval>,
1313    ) -> GeomResult<Vec<(usize, DistanceInterval)>> {
1314        let shares = self.shares(tolerance);
1315        let joints = self.pieces.len().saturating_sub(1);
1316        let mut run = Running::ZERO;
1317        let mut out = Vec::with_capacity(joints);
1318        for (k, ((piece, table), share)) in self
1319            .pieces
1320            .iter()
1321            .zip(&self.tables)
1322            .zip(shares)
1323            .take(joints)
1324            .enumerate()
1325        {
1326            if let Some(span) = span {
1327                // The joint's lower end with no quadrature, rounded down.
1328                let floor = (run.interval().lower + piece.length_floor(table.as_ref())?)
1329                    * (1.0 - 4.0 * Scalar::EPSILON);
1330                if floor > span.upper {
1331                    break;
1332                }
1333            }
1334            run = run.plus(piece_length(piece, table.as_ref(), share)?);
1335            let interval = run.interval();
1336            if interval.width().is_nan() || interval.width() > tolerance {
1337                return Err(unsupported(UNCERTIFIED_LENGTH));
1338            }
1339            if span.is_none_or(|span| interval.meets(&span)) {
1340                out.push((k, interval));
1341            }
1342        }
1343        Ok(out)
1344    }
1345
1346    /// The composite's seams whose frame may jump, each with its certified
1347    /// distance, up to the first whose interval starts past `until`: every
1348    /// interior joint, and each piece's own seams inside it.
1349    pub(crate) fn certified_seams(
1350        &self,
1351        tolerance: Scalar,
1352        until: Scalar,
1353    ) -> GeomResult<Vec<CertifiedSeam>> {
1354        let shares = self.shares_until(tolerance, until);
1355        let mut run = Running::ZERO;
1356        let mut out = Vec::new();
1357        for (k, piece) in self.pieces.iter().enumerate() {
1358            if k > 0 {
1359                out.push(CertifiedSeam {
1360                    distance: self.starts[k],
1361                    interval: run.interval(),
1362                });
1363            }
1364            if run.interval().lower > until {
1365                break;
1366            }
1367            for seam in piece.seams()? {
1368                if seam.smooth {
1369                    continue;
1370                }
1371                let part = if seam.exact {
1372                    DistanceInterval::point(seam.distance)
1373                } else {
1374                    let u = seam.distance;
1375                    let (m0, m1) = if piece.reversed {
1376                        (piece.end - u, piece.end)
1377                    } else {
1378                        (piece.start, piece.start + u)
1379                    };
1380                    curve_span(piece.curve, m0, m1, shares[k])?
1381                };
1382                out.push(CertifiedSeam {
1383                    distance: self.starts[k] + seam.distance,
1384                    interval: run.plus(part).interval(),
1385                });
1386            }
1387            if k + 1 < self.pieces.len() {
1388                run = run.plus(piece_length(piece, self.tables[k].as_ref(), shares[k])?);
1389            }
1390        }
1391        Ok(out)
1392    }
1393
1394    /// The distance a station at `distance` with the seam-snapping
1395    /// `window` is read at along the composite (#294): the joint's or
1396    /// seam's own distance when exactly one lies within the window, else
1397    /// `distance`; see [`super::window`].
1398    ///
1399    /// # Errors
1400    ///
1401    /// A negative or non-finite window, two seams within it
1402    /// ([`super::window::SEAM_WINDOW_AMBIGUOUS`]), a seam straddling its
1403    /// edge ([`super::window::SEAM_WINDOW_STRADDLED`]), and a seam whose
1404    /// distance cannot be certified, by name.
1405    pub fn snapped_distance(&self, distance: Scalar, window: Scalar) -> GeomResult<Scalar> {
1406        if checked_window(window)? == 0.0 || !distance.is_finite() {
1407            return Ok(distance);
1408        }
1409        let seams = self.certified_seams(certification(window), distance + window)?;
1410        snap(&seams, distance, window)
1411    }
1412
1413    /// The points read at distance `0` and at the composite's length `L`:
1414    /// the first piece's start and the last piece's end, placed.
1415    ///
1416    /// # Errors
1417    ///
1418    /// The evaluators' refusals reading the points.
1419    pub fn end_points(&self) -> GeomResult<[Point3; 2]> {
1420        let last = self.pieces.len() - 1;
1421        Ok([
1422            self.pieces[0].point_at(0.0, self.tables[0].as_ref())?,
1423            self.pieces[last].point_at(self.pieces[last].length(), self.tables[last].as_ref())?,
1424        ])
1425    }
1426
1427    /// A certified interval containing the true length of the composite
1428    /// from its start to the point read at `distance`: the certified
1429    /// lengths of the pieces before it plus its own piece up to it (as
1430    /// `certified_seams` sums them), no wider than `tolerance`. It
1431    /// is the point `distance` where every length summed is read from
1432    /// stored data. It says how far a cut at `distance` lies from the
1433    /// distance asked, where a measure is a quadrature (an ellipse, a
1434    /// B-spline).
1435    ///
1436    /// # Errors
1437    ///
1438    /// A tolerance that is not finite and positive and a distance outside
1439    /// `[0, L]` (beyond the arc-length tolerance), by name, and as
1440    /// [`Self::joint_intervals`].
1441    pub fn distance_interval_at(
1442        &self,
1443        distance: Scalar,
1444        tolerance: Scalar,
1445    ) -> GeomResult<DistanceInterval> {
1446        let tolerance = checked_tolerance(tolerance)?;
1447        let distance = admitted(distance, Some(self.length()))?;
1448        let (k, into) = self.locate(distance, SeamSide::Outgoing);
1449        let shares = self.shares(tolerance);
1450        let mut run = Running::ZERO;
1451        let mut exact = true;
1452        for ((piece, table), share) in self.pieces.iter().zip(&self.tables).zip(&shares).take(k) {
1453            let length = piece_length(piece, table.as_ref(), *share)?;
1454            exact &= length.is_point();
1455            run = run.plus(length);
1456        }
1457        if into > 0.0 {
1458            let piece = &self.pieces[k];
1459            let into = into.min(piece.length());
1460            let mut part = *piece;
1461            if piece.reversed {
1462                part.start = piece.end - into;
1463            } else {
1464                part.end = piece.start + into;
1465            }
1466            let length = piece_length(&part, self.tables[k].as_ref(), shares[k])?;
1467            exact &= length.is_point();
1468            run = run.plus(length);
1469        }
1470        if exact {
1471            return Ok(DistanceInterval::point(distance));
1472        }
1473        let interval = run.interval();
1474        if interval.width().is_nan() || interval.width() > tolerance {
1475            return Err(unsupported(UNCERTIFIED_LENGTH));
1476        }
1477        Ok(interval)
1478    }
1479
1480    /// The pieces between distances `start < end` along the composite,
1481    /// clipped: what a trim of the composite by its station measure keeps.
1482    ///
1483    /// # Errors
1484    ///
1485    /// A non-finite or empty interval, or one outside `[0, L]` beyond the
1486    /// arc-length tolerance, by name.
1487    pub fn pieces_between(&self, start: Scalar, end: Scalar) -> GeomResult<Vec<StationPiece<'c>>> {
1488        let length = self.length();
1489        if !(start.is_finite() && end.is_finite()) || end <= start {
1490            return Err(GeomError::Degenerate(format!(
1491                "station: a trim of a composite basis spans [{start}, {end}], which is empty or \
1492                 not finite"
1493            )));
1494        }
1495        if start < -slack(0.0) || end > length + slack(length) {
1496            return Err(invalid(format!(
1497                "station: a trim of a composite basis spans [{start}, {end}], outside its \
1498                 length {length}"
1499            )));
1500        }
1501        let (start, end) = (start.max(0.0), end.min(length));
1502        let mut out = Vec::new();
1503        for (k, piece) in self.pieces.iter().enumerate() {
1504            let (lo, hi) = (self.starts[k], self.starts[k + 1]);
1505            let (a, b) = (start.max(lo) - lo, end.min(hi) - lo);
1506            if b - a <= slack(b) {
1507                continue;
1508            }
1509            let mut clipped = *piece;
1510            if piece.reversed {
1511                clipped.start = piece.end - b;
1512                clipped.end = piece.end - a;
1513            } else {
1514                clipped.start = piece.start + a;
1515                clipped.end = piece.start + b;
1516            }
1517            out.push(clipped);
1518        }
1519        Ok(out)
1520    }
1521}
1522
1523/// A piece with its offset table, if any.
1524type Read<'a, 'c> = (&'a StationPiece<'c>, Option<&'a OffsetTable>);
1525
1526/// Refuse a joint whose pieces do not meet, naming an offset across a
1527/// corner, a reversed piece or the gap.
1528fn check_joint(
1529    (before, before_table): Read<'_, '_>,
1530    (after, after_table): Read<'_, '_>,
1531    index: usize,
1532) -> GeomResult<()> {
1533    let end = before.point_at(before.length(), before_table)?;
1534    let start = after.point_at(0.0, after_table)?;
1535    let tolerance = |p: Point3| JOINT_TOLERANCE * p.abs().max_element().max(1.0);
1536    let meets = |a: Point3, b: Point3| (a - b).length() <= tolerance(a);
1537    if meets(end, start) {
1538        return Ok(());
1539    }
1540    if [before, after]
1541        .iter()
1542        .any(|piece| matches!(piece.curve, StationCurve::Offset(_)))
1543    {
1544        return Err(invalid(format!(
1545            "station: an offset across a corner of its basis at joint {index}: the offsets of \
1546             the two sides do not meet (a gap of {}), so no distance runs across it (the \
1547             basis's tangent or roll jumps there)",
1548            (end - start).length()
1549        )));
1550    }
1551    let after_end = after.point_at(after.length(), after_table)?;
1552    let reversed = meets(end, after_end)
1553        || (index == 1 && {
1554            let before_start = before.point_at(0.0, before_table)?;
1555            meets(before_start, start) || meets(before_start, after_end)
1556        });
1557    if reversed {
1558        return Err(invalid(format!(
1559            "station: a reversed piece at joint {index} of a composite basis: its pieces meet \
1560             end to end or start to start, so one runs against the composite (its sense is not \
1561             declared)"
1562        )));
1563    }
1564    Err(invalid(format!(
1565        "station: a gap of {} at joint {index} of a composite basis: its pieces do not meet, so \
1566         no distance runs across it",
1567        (end - start).length()
1568    )))
1569}