Skip to main content

axiolid_evaluate/station/
offset.rs

1//! Offset pieces of a station basis: the curve beside a span of an atomic
2//! curve, measured in its own length (#289, ADR 0082 amendment).
3//!
4//! A [`StationOffset`] is the curve `C(v) = B(v) + D(v)` beside a base
5//! piece `B` (a [`StationPiece`] of an atomic curve, reversed or placed),
6//! `v` the base's station measure into the piece and `D` the displacement
7//! its [`OffsetLaw`] gives in the base's frame at `v`:
8//!
9//! - [`OffsetLaw::Planar`]: `d` along the base's left lateral, on a 2D
10//!   base (an offset curve 2D: positive to the left, the orthogonal
11//!   complement of the tangent);
12//! - [`OffsetLaw::Directed`]: `d` along `normalise(V x T)`, `T` the base's
13//!   unit tangent (an offset curve 3D); a tangent parallel to `V` is
14//!   refused by name;
15//! - [`OffsetLaw::Linear`]: lateral, vertical and longitudinal offsets
16//!   interpolated linearly in `v` between the piece's ends, in the base's
17//!   section or plan frame (one interval of an offset by distances).
18//!
19//! # Distance convention
20//!
21//! A station on an offset is measured in the OFFSET's own length from its
22//! start (the base piece's start), not in the base's: its arc length, or,
23//! where the base is plan-measured (an elevated or banked curve), its own
24//! plan length (the arc length of its plan projection), so an offset of an
25//! alignment keeps the alignment's convention and joins plan-measured
26//! pieces. The offset's frame is its own section frame: the planar frame
27//! of its own tangent where it runs horizontally in a horizontal plane,
28//! else the reference-up frame against `+Z` of its own point and tangent;
29//! an offset of a banked curve keeps the base's rolled lateral, made
30//! perpendicular to its own tangent.
31//!
32//! # Exactness
33//!
34//! A constant offset of a line is a line beside it, and a constant offset
35//! (no longitudinal part) of a circle whose offset stays in planes normal
36//! to its axis (a 2D or horizontal circle under a planar, section or plan
37//! law, any circle under a reference direction along its axis) is a
38//! circle of radius `r - a`, `a` the displacement towards the centre: its
39//! length is the base's scaled by `(r - a) / r`, read in closed form.
40//! Every other offset is read numerically: its speed is the base's
41//! tangent plus the derivative of the displacement by a five-point
42//! difference, integrated by adaptive Gauss-Legendre to
43//! [`OFFSET_TOLERANCE`] and inverted by a safeguarded Newton step within a
44//! panel. That is an estimate, not a proof: a frame on such an offset is
45//! never exact, and a mesh placed in it is `Unbounded` by name.
46//!
47//! # Refusals
48//!
49//! By name: a base that is itself an offset, a base span with a seam of
50//! its curve inside it (split it there, as [`offset_pieces`] does), a 2D
51//! law on a 3D base, a circle whose offset radius collapses to zero or
52//! below, an offset whose speed in the base's direction falls to
53//! [`CUSP_TOLERANCE`] of the base's or below (a cusp, or a reversal), and
54//! an offset lying in a horizontal plane that crosses itself within the
55//! piece.
56
57use axiolid_contracts::{GeomError, GeomResult};
58use axiolid_core::{Point3, Scalar, Transform3, Vec3};
59use axiolid_curve::{Curve2, Curve3, OffsetFrame, OffsetLaw, SeamSide};
60use axiolid_curve_evaluate_contract::DistanceConvention;
61
62use super::composite::{StationCurve, StationPiece};
63use super::{invalid, reference_up_frame, SectionFrame};
64use crate::arc_parameter::{NODES, WEIGHTS};
65
66/// Relative tolerance of a numerical offset's own length and its inverse:
67/// `OFFSET_TOLERANCE * max(1, L)` in the length unit. An estimate, as the
68/// quadrature's agreement test is.
69pub const OFFSET_TOLERANCE: Scalar = 1e-9;
70
71/// Smallest speed of an offset in its base's direction, relative to the
72/// base's, that is not a cusp; also the smallest scale `(r - a) / r` of an
73/// offset circle that has not collapsed.
74pub const CUSP_TOLERANCE: Scalar = 1e-6;
75
76/// Most quadrature panels an offset's length may use.
77const MAX_OFFSET_PANELS: usize = 1 << 12;
78
79/// Root-find iterations within a panel.
80const MAX_ITERATIONS: usize = 100;
81
82fn degenerate(detail: String) -> GeomError {
83    GeomError::Degenerate(detail)
84}
85
86/// The atomic curve under an offset's base.
87#[derive(Debug, Clone, Copy, PartialEq)]
88enum BaseCurve<'c> {
89    Two(&'c Curve2),
90    Three(&'c Curve3),
91}
92
93/// An offset of one base piece, measured in its own length; see the
94/// [module documentation](self).
95#[non_exhaustive]
96#[derive(Debug, Clone, Copy, PartialEq)]
97pub struct StationOffset<'c> {
98    curve: BaseCurve<'c>,
99    start: Scalar,
100    end: Scalar,
101    reversed: bool,
102    placement: Option<Transform3>,
103    placement_exact: bool,
104    law: OffsetLaw,
105}
106
107/// How an offset's own measure maps to its base's: closed form, or panels.
108#[derive(Debug, Clone, PartialEq)]
109pub(crate) struct OffsetTable {
110    /// The speed ratio of a closed form (a line or a circle), else `None`.
111    closed: Option<Scalar>,
112    /// Panel bounds in the base's measure into the piece, ascending.
113    knots: Vec<Scalar>,
114    /// The offset's own measure at each knot.
115    sums: Vec<Scalar>,
116}
117
118impl OffsetTable {
119    /// The offset's own length.
120    pub(crate) fn length(&self, base: Scalar) -> Scalar {
121        match self.closed {
122            Some(scale) => scale * base,
123            None => self.sums[self.sums.len() - 1],
124        }
125    }
126}
127
128impl<'c> StationOffset<'c> {
129    /// The offset of `base` by `law`.
130    ///
131    /// # Errors
132    ///
133    /// A base that is itself an offset, a base span with a seam of its
134    /// curve strictly inside it, a planar law on a 3D base, a non-finite
135    /// law or a zero reference direction, each by name.
136    pub fn new(base: StationPiece<'c>, law: OffsetLaw) -> GeomResult<Self> {
137        let curve =
138            match base.curve {
139                StationCurve::Two(curve) => BaseCurve::Two(curve),
140                StationCurve::Three(curve) => BaseCurve::Three(curve),
141                _ => return Err(invalid(
142                    "station: an offset of an offset as a station basis: offset the base once, \
143                     by the summed law"
144                        .into(),
145                )),
146            };
147        match law {
148            OffsetLaw::Planar { distance } => {
149                if !distance.is_finite() {
150                    return Err(invalid("station: an offset distance is not finite".into()));
151                }
152                if matches!(curve, BaseCurve::Three(_)) {
153                    return Err(invalid(
154                        "station: a planar (2D) offset of a 3D curve: a 3D offset needs a \
155                         reference direction"
156                            .into(),
157                    ));
158                }
159            }
160            OffsetLaw::Directed {
161                distance,
162                reference_direction,
163            } => {
164                if !distance.is_finite()
165                    || !reference_direction.is_finite()
166                    || reference_direction.length() <= 1e-12
167                {
168                    return Err(invalid(
169                        "station: a 3D offset's distance or reference direction is not finite, \
170                         or the direction is zero"
171                            .into(),
172                    ));
173                }
174            }
175            OffsetLaw::Linear { start, end, .. } => {
176                let finite = |o: axiolid_curve::PathOffsets| {
177                    o.lateral.is_finite() && o.vertical.is_finite() && o.longitudinal.is_finite()
178                };
179                if !(finite(start) && finite(end)) {
180                    return Err(invalid(
181                        "station: an offset by distances has an offset that is not finite".into(),
182                    ));
183                }
184            }
185            _ => {
186                return Err(invalid(
187                    "station: an offset law this evaluator does not know".into(),
188                ))
189            }
190        }
191        if !base.seams()?.is_empty() {
192            return Err(invalid(
193                "station: an offset piece whose base crosses a seam of its curve: split the base \
194                 there, one offset piece per span between seams"
195                    .into(),
196            ));
197        }
198        Ok(Self {
199            curve,
200            start: base.start,
201            end: base.end,
202            reversed: base.reversed,
203            placement: base.placement,
204            placement_exact: base.placement_exact,
205            law,
206        })
207    }
208
209    /// The base piece.
210    #[must_use]
211    pub fn base(&self) -> StationPiece<'c> {
212        let curve = match self.curve {
213            BaseCurve::Two(curve) => StationCurve::Two(curve),
214            BaseCurve::Three(curve) => StationCurve::Three(curve),
215        };
216        StationPiece {
217            curve,
218            start: self.start,
219            end: self.end,
220            reversed: self.reversed,
221            placement: self.placement,
222            placement_exact: self.placement_exact,
223        }
224    }
225
226    /// The law.
227    #[must_use]
228    pub fn law(&self) -> OffsetLaw {
229        self.law
230    }
231
232    /// The convention the offset is measured in: its base's (plan distance
233    /// beside an elevated or banked curve, arc length beside any other).
234    #[must_use]
235    pub fn convention(&self) -> DistanceConvention {
236        self.base().convention()
237    }
238
239    /// The offset's own length in its convention, from its start.
240    ///
241    /// # Errors
242    ///
243    /// The refusals of the [module documentation](self#refusals), and the
244    /// base's evaluators'.
245    pub fn length(&self) -> GeomResult<Scalar> {
246        Ok(self.table()?.length(self.base_length()))
247    }
248
249    pub(super) fn base_length(&self) -> Scalar {
250        self.end - self.start
251    }
252
253    fn plan_measured(&self) -> bool {
254        self.convention() == DistanceConvention::PlanDistance
255    }
256
257    fn banked(&self) -> bool {
258        matches!(self.curve, BaseCurve::Three(Curve3::Banked(_)))
259    }
260
261    /// Whether the offset is a line beside a line, its frame read exactly.
262    pub(crate) fn frame_is_exact(&self) -> bool {
263        self.law.is_constant()
264            && matches!(
265                self.curve,
266                BaseCurve::Two(Curve2::Line(_)) | BaseCurve::Three(Curve3::Line(_))
267            )
268            && self.placement_exact
269    }
270
271    /// Whether its measure is read in closed form (a line or a circle).
272    pub(crate) fn measure_is_exact(&self) -> bool {
273        matches!(self.closed_form(), Ok(Some(_)))
274    }
275
276    /// The base's section at `v` into the base piece, read from inside.
277    fn base_section(&self, v: Scalar) -> GeomResult<SectionFrame> {
278        self.base().section_on(v, SeamSide::Outgoing)
279    }
280
281    /// The displacement at `v` from the base's section there.
282    pub(super) fn displacement(&self, section: &SectionFrame, v: Scalar) -> GeomResult<Vec3> {
283        match self.law {
284            OffsetLaw::Planar { distance } => Ok(distance * section.lateral),
285            OffsetLaw::Directed {
286                distance,
287                reference_direction,
288            } => {
289                let reference = reference_direction.normalize();
290                let normal = reference.cross(section.tangent);
291                let size = normal.length();
292                if !(size.is_finite() && size > 1e-9) {
293                    return Err(invalid(
294                        "station: a 3D offset whose base tangent is parallel to its reference \
295                         direction: the offset direction is undefined there"
296                            .into(),
297                    ));
298                }
299                Ok(distance / size * normal)
300            }
301            OffsetLaw::Linear { start, end, frame } => {
302                let length = self.base_length();
303                let at = start.lerp(end, (v / length).clamp(0.0, 1.0));
304                let frame = match frame {
305                    OffsetFrame::Section => *section,
306                    OffsetFrame::Plan => section.plan()?,
307                    _ => {
308                        return Err(invalid(
309                            "station: an offset frame this evaluator does not know".into(),
310                        ))
311                    }
312                };
313                Ok(at.lateral * frame.lateral
314                    + at.vertical * frame.up
315                    + at.longitudinal * frame.tangent)
316            }
317            _ => Err(invalid(
318                "station: an offset law this evaluator does not know".into(),
319            )),
320        }
321    }
322
323    /// The displacement at `v`, its base section read afresh.
324    fn displacement_at(&self, v: Scalar) -> GeomResult<Vec3> {
325        let section = self.base_section(v)?;
326        self.displacement(&section, v)
327    }
328
329    /// The step of the displacement's difference quotient.
330    fn step(&self) -> Scalar {
331        (self.base_length() / 8.0).min(1e-2)
332    }
333
334    /// The offset's velocity `dC/dv` at `v`, and the base's section there.
335    fn velocity(&self, v: Scalar) -> GeomResult<(Vec3, SectionFrame)> {
336        let section = self.base_section(v)?;
337        // The base's own rate: unit in arc length, `1 / |T_xy|` in plan
338        // distance.
339        let rate = if self.plan_measured() {
340            let horizontal = Vec3::new(section.tangent.x, section.tangent.y, 0.0).length();
341            if horizontal <= 1e-12 {
342                return Err(invalid(
343                    "station: a plan-measured offset base runs vertically, so it has no plan \
344                     rate"
345                        .into(),
346                ));
347            }
348            1.0 / horizontal
349        } else {
350            1.0
351        };
352        let length = self.base_length();
353        let h = self.step();
354        let d = |x: Scalar| self.displacement_at(x);
355        // Five-point differences, one-sided at the piece's ends so that
356        // nothing is read beyond the base piece.
357        let derivative = if v - 2.0 * h < 0.0 {
358            (-25.0 * self.displacement(&section, v)? + 48.0 * d(v + h)? - 36.0 * d(v + 2.0 * h)?
359                + 16.0 * d(v + 3.0 * h)?
360                - 3.0 * d(v + 4.0 * h)?)
361                / (12.0 * h)
362        } else if v + 2.0 * h > length {
363            (25.0 * self.displacement(&section, v)? - 48.0 * d(v - h)? + 36.0 * d(v - 2.0 * h)?
364                - 16.0 * d(v - 3.0 * h)?
365                + 3.0 * d(v - 4.0 * h)?)
366                / (12.0 * h)
367        } else {
368            (-d(v + 2.0 * h)? + 8.0 * d(v + h)? - 8.0 * d(v - h)? + d(v - 2.0 * h)?) / (12.0 * h)
369        };
370        Ok((rate * section.tangent + derivative, section))
371    }
372
373    /// The speed of the offset's own measure along `v`, refusing a cusp.
374    fn speed(&self, v: Scalar) -> GeomResult<Scalar> {
375        let (velocity, section) = self.velocity(v)?;
376        let (along, forward) = if self.plan_measured() {
377            let flat = Vec3::new(velocity.x, velocity.y, 0.0);
378            let heading = Vec3::new(section.tangent.x, section.tangent.y, 0.0).normalize();
379            (flat.length(), flat.dot(heading))
380        } else {
381            (velocity.length(), velocity.dot(section.tangent))
382        };
383        if !(forward.is_finite() && forward > CUSP_TOLERANCE) {
384            return Err(degenerate(format!(
385                "station: an offset with a cusp or turning back at {v} along its base: its speed \
386                 in the base's direction is {forward}, the offset reaches the base's radius of \
387                 curvature there"
388            )));
389        }
390        Ok(along)
391    }
392
393    /// The speed ratio of a closed form: `1` beside a line, `(r - a) / r`
394    /// beside a circle; `None` where the offset is read numerically.
395    fn closed_form(&self) -> GeomResult<Option<Scalar>> {
396        if !self.law.is_constant() {
397            return Ok(None);
398        }
399        let longitudinal = matches!(
400            self.law,
401            OffsetLaw::Linear { start, .. } if start.longitudinal != 0.0
402        );
403        let place_point = |p: Point3| self.placement.map_or(p, |r| r.transform_point3(p));
404        let place_vector = |v: Vec3| self.placement.map_or(v, |r| r.transform_vector3(v));
405        let (centre, axis, radius) = match self.curve {
406            BaseCurve::Two(Curve2::Line(_)) | BaseCurve::Three(Curve3::Line(_)) => {
407                return Ok(Some(1.0))
408            }
409            BaseCurve::Two(Curve2::Circle(circle)) => (
410                Point3::new(circle.frame.origin.x, circle.frame.origin.y, 0.0),
411                Vec3::Z,
412                circle.radius,
413            ),
414            BaseCurve::Three(Curve3::Circle(circle)) => {
415                (circle.frame.origin, circle.frame.z, circle.radius)
416            }
417            _ => return Ok(None),
418        };
419        if longitudinal {
420            return Ok(None);
421        }
422        let (centre, axis) = (place_point(centre), place_vector(axis).normalize());
423        let upright = axis.cross(Vec3::Z).length() <= 1e-12;
424        let invariant = match self.law {
425            OffsetLaw::Directed {
426                reference_direction,
427                ..
428            } => reference_direction.normalize().cross(axis).length() <= 1e-12,
429            _ => upright,
430        };
431        if !invariant {
432            return Ok(None);
433        }
434        let section = self.base_section(0.0)?;
435        let towards = centre - section.point;
436        let inward = towards - towards.dot(axis) * axis;
437        let inward = inward / inward.length();
438        let scale = (radius - self.displacement(&section, 0.0)?.dot(inward)) / radius;
439        if !(scale.is_finite() && scale > CUSP_TOLERANCE) {
440            return Err(degenerate(format!(
441                "station: an offset of a circle of radius {radius} whose radius collapses to {} \
442                 (at or below zero): the offset passes through or beyond the centre",
443                scale * radius
444            )));
445        }
446        Ok(Some(scale))
447    }
448
449    /// The panels of the offset's own measure; see the
450    /// [module documentation](self#exactness).
451    pub(crate) fn table(&self) -> GeomResult<OffsetTable> {
452        if let Some(scale) = self.closed_form()? {
453            return Ok(OffsetTable {
454                closed: Some(scale),
455                knots: Vec::new(),
456                sums: Vec::new(),
457            });
458        }
459        let length = self.base_length();
460        let rule = |a: Scalar, b: Scalar| -> GeomResult<Scalar> {
461            let (half, mid) = (0.5 * (b - a), 0.5 * (a + b));
462            let mut sum = 0.0;
463            for (node, weight) in NODES.iter().zip(WEIGHTS.iter()) {
464                sum += weight * self.speed(mid + half * node)?;
465            }
466            Ok(sum * half)
467        };
468        let coarse = rule(0.0, length)?;
469        let tolerance = OFFSET_TOLERANCE * coarse.abs().max(1.0);
470        let floor = 4.0 * self.step() * 1e-3;
471        let mut knots = vec![0.0];
472        let mut sums = vec![0.0];
473        let mut stack = vec![(0.0, length, coarse)];
474        while let Some((lo, hi, whole)) = stack.pop() {
475            if knots.len() > MAX_OFFSET_PANELS {
476                return Err(GeomError::BudgetExceeded {
477                    resource: "offset length quadrature panels",
478                });
479            }
480            let mid = 0.5 * (lo + hi);
481            let (left, right) = (rule(lo, mid)?, rule(mid, hi)?);
482            let share = tolerance * (hi - lo) / length;
483            if (left + right - whole).abs() <= share || hi - lo <= floor || !(mid > lo && mid < hi)
484            {
485                let run = sums[sums.len() - 1];
486                knots.extend([mid, hi]);
487                sums.extend([run + left, run + left + right]);
488            } else {
489                stack.push((mid, hi, right));
490                stack.push((lo, mid, left));
491            }
492        }
493        let table = OffsetTable {
494            closed: None,
495            knots,
496            sums,
497        };
498        self.refuse_self_crossing(&table)?;
499        Ok(table)
500    }
501
502    /// Refuse an offset lying in one horizontal plane that crosses itself:
503    /// its points at the panel bounds and quarter points, as a polyline.
504    fn refuse_self_crossing(&self, table: &OffsetTable) -> GeomResult<()> {
505        let mut points = Vec::new();
506        for pair in table.knots.windows(2) {
507            for k in 0..4 {
508                let v = pair[0] + (pair[1] - pair[0]) * Scalar::from(k) / 4.0;
509                points.push(self.point_at_base(v)?);
510            }
511        }
512        points.push(self.point_at_base(self.base_length())?);
513        let z = points[0].z;
514        if points.iter().any(|p| p.z != z) {
515            return Ok(());
516        }
517        let cross =
518            |a: Point3, b: Point3, c: Point3| (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
519        let n = points.len();
520        for i in 0..n.saturating_sub(1) {
521            for j in i + 2..n - 1 {
522                let (a, b, c, d) = (points[i], points[i + 1], points[j], points[j + 1]);
523                let (d1, d2) = (cross(a, b, c), cross(a, b, d));
524                let (d3, d4) = (cross(c, d, a), cross(c, d, b));
525                if d1 * d2 < 0.0 && d3 * d4 < 0.0 {
526                    return Err(degenerate(
527                        "station: an offset that crosses itself within its piece: the offset \
528                         distance exceeds the room the base's curvature leaves"
529                            .into(),
530                    ));
531                }
532            }
533        }
534        Ok(())
535    }
536
537    /// The base's measure `v` into the piece at the offset's own measure
538    /// `m`.
539    pub(super) fn base_measure(&self, m: Scalar, table: &OffsetTable) -> GeomResult<Scalar> {
540        let length = self.base_length();
541        if let Some(scale) = table.closed {
542            return Ok((m / scale).clamp(0.0, length));
543        }
544        let last = table.sums.len() - 1;
545        let m = m.clamp(0.0, table.sums[last]);
546        let i = table.sums[1..last]
547            .partition_point(|sum| *sum <= m)
548            .min(last - 1);
549        let (lo, hi) = (table.knots[i], table.knots[i + 1]);
550        let target = m - table.sums[i];
551        let span = table.sums[i + 1] - table.sums[i];
552        let tolerance = 1e-3 * OFFSET_TOLERANCE * m.max(1.0);
553        let travelled = |v: Scalar| -> GeomResult<Scalar> {
554            let (half, mid) = (0.5 * (v - lo), 0.5 * (lo + v));
555            let mut sum = 0.0;
556            for (node, weight) in NODES.iter().zip(WEIGHTS.iter()) {
557                sum += weight * self.speed(mid + half * node)?;
558            }
559            Ok(sum * half)
560        };
561        let (mut a, mut b) = (lo, hi);
562        let mut v = if span > 0.0 {
563            lo + (hi - lo) * (target / span)
564        } else {
565            lo
566        };
567        for _ in 0..MAX_ITERATIONS {
568            let residual = travelled(v)? - target;
569            if residual.abs() <= tolerance {
570                return Ok(v);
571            }
572            if residual < 0.0 {
573                a = v;
574            } else {
575                b = v;
576            }
577            let newton = v - residual / self.speed(v)?;
578            v = if newton > a && newton < b {
579                newton
580            } else {
581                0.5 * (a + b)
582            };
583            if !(v > a && v < b) {
584                return Ok(v);
585            }
586        }
587        Ok(v)
588    }
589
590    /// The offset's point at the base's measure `v`.
591    fn point_at_base(&self, v: Scalar) -> GeomResult<Point3> {
592        let section = self.base_section(v)?;
593        Ok(section.point + self.displacement(&section, v)?)
594    }
595
596    /// The point at the offset's own measure `m`, through `table` (built
597    /// afresh when `None`).
598    pub(crate) fn point_at(&self, m: Scalar, table: Option<&OffsetTable>) -> GeomResult<Point3> {
599        let built;
600        let table = match table {
601            Some(table) => table,
602            None => {
603                built = self.table()?;
604                &built
605            }
606        };
607        self.point_at_base(self.base_measure(m, table)?)
608    }
609
610    /// The section frame at the offset's own measure `m`, through `table`
611    /// (built afresh when `None`); see the
612    /// [module documentation](self#distance-convention).
613    pub(crate) fn section_at(
614        &self,
615        m: Scalar,
616        table: Option<&OffsetTable>,
617    ) -> GeomResult<SectionFrame> {
618        let built;
619        let table = match table {
620            Some(table) => table,
621            None => {
622                built = self.table()?;
623                &built
624            }
625        };
626        let v = self.base_measure(m, table)?;
627        let (point, tangent, base) = if table.closed.is_some() {
628            // A line or a circle beside its base: the base's tangent.
629            let section = self.base_section(v)?;
630            let point = section.point + self.displacement(&section, v)?;
631            (point, section.tangent, section)
632        } else {
633            let (velocity, section) = self.velocity(v)?;
634            let point = section.point + self.displacement(&section, v)?;
635            (point, velocity, section)
636        };
637        own_frame(point, tangent, &base, self.banked())
638    }
639}
640
641/// The offset's own section frame at `point` with tangent `direction`:
642/// the base's rolled lateral made perpendicular to it beside a banked
643/// curve; else the planar frame where it runs horizontally at `z` exactly
644/// level, the reference-up frame elsewhere.
645fn own_frame(
646    point: Point3,
647    direction: Vec3,
648    base: &SectionFrame,
649    banked: bool,
650) -> GeomResult<SectionFrame> {
651    let size = direction.length();
652    if !(size.is_finite() && size > 0.0) {
653        return Err(invalid(
654            "station: the offset has no tangent direction there".into(),
655        ));
656    }
657    let tangent = direction / size;
658    if banked {
659        let lateral = base.lateral - base.lateral.dot(tangent) * tangent;
660        let magnitude = lateral.length();
661        if !(magnitude.is_finite() && magnitude > 1e-12) {
662            return Err(invalid(
663                "station: the offset's tangent lies along its banked base's lateral".into(),
664            ));
665        }
666        let lateral = lateral / magnitude;
667        return Ok(SectionFrame {
668            point,
669            tangent,
670            lateral,
671            up: tangent.cross(lateral),
672        });
673    }
674    if tangent.z == 0.0 {
675        return Ok(SectionFrame {
676            point,
677            tangent,
678            lateral: Vec3::new(-tangent.y, tangent.x, 0.0),
679            up: Vec3::Z,
680        });
681    }
682    reference_up_frame(point, tangent)
683}
684
685/// The offset of pieces laid end to end (a composite's, in its measure)
686/// by `law`: one [`StationOffset`] piece per span of a base piece between
687/// the seams of its curve, so every seam and joint of the base is a joint
688/// of the offset. A [`OffsetLaw::Linear`] law runs from its `start` at the
689/// first piece's start to its `end` at the last piece's end, linearly in
690/// the base's measure.
691///
692/// The pieces are not joined here: [`super::CompositeBasis::new`] refuses
693/// two that do not meet (an offset across a corner of its base, or a jump
694/// of a banked base's roll) by name.
695///
696/// # Errors
697///
698/// No pieces, and the refusals of [`StationOffset::new`] and of the
699/// offset's length, by name.
700pub fn offset_pieces<'c>(
701    base: &[StationPiece<'c>],
702    law: OffsetLaw,
703) -> GeomResult<Vec<StationPiece<'c>>> {
704    if base.is_empty() {
705        return Err(invalid("station: an offset of no pieces".into()));
706    }
707    let total: Scalar = base.iter().map(StationPiece::length).sum();
708    let mut out = Vec::new();
709    let mut run = 0.0;
710    for piece in base {
711        let mut cuts = vec![0.0];
712        cuts.extend(piece.seams()?.into_iter().map(|seam| seam.distance));
713        cuts.push(piece.length());
714        for pair in cuts.windows(2) {
715            let (a, b) = (pair[0], pair[1]);
716            let mut span = *piece;
717            if piece.reversed {
718                span.start = piece.end - b;
719                span.end = piece.end - a;
720            } else {
721                span.start = piece.start + a;
722                span.end = piece.start + b;
723            }
724            let law = match law {
725                OffsetLaw::Linear { start, end, frame } => OffsetLaw::Linear {
726                    start: start.lerp(end, (run + a) / total),
727                    end: start.lerp(end, (run + b) / total),
728                    frame,
729                },
730                other => other,
731            };
732            out.push(StationPiece::offset(StationOffset::new(span, law)?)?);
733        }
734        run += piece.length();
735    }
736    Ok(out)
737}