Skip to main content

axiolid_evaluate/station/
seam.rs

1//! Seams of a station's basis curve: where they lie, which piece a station
2//! on one reads, and the mitre between the two (#263, ADR 0082 amendment).
3//!
4//! # Where the seams are
5//!
6//! [`station_seams2`] and [`station_seams3`] list the places strictly
7//! inside a curve where two of its pieces meet, each as a
8//! [`StationSeam`]: its distance in the curve's station measure (the
9//! [parent module](super)'s convention), its native parameter, whether
10//! the stored data guarantees the same section frame on both sides
11//! ([`StationSeam::smooth`]), and whether the distance is read from stored
12//! data alone ([`StationSeam::exact`]). They are read without evaluating
13//! the curve:
14//!
15//! - a polyline's interior vertices, at the running sum of its segment
16//!   lengths (a repeated vertex is one seam), not smooth;
17//! - a B-spline's interior knots of multiplicity at least its degree
18//!   (where it may turn a corner), not smooth, and NOT exact: the arc
19//!   length to a knot is a quadrature ([`crate::arc_parameter`]);
20//! - an intrinsic curve's curvature (and, in 3D, torsion) law seams, and
21//!   an arc-length chain's joins and its intrinsic pieces' seams, at their
22//!   stored arc lengths, smooth: the heading is continuous by construction;
23//! - an elevated curve's plan seams (smooth, as above) and its profile's
24//!   seams, where the grade may jump (not smooth); a banked curve's, and
25//!   the seams of its cant law (the roll may jump) and its pivot law (the
26//!   grade of its point path may jump), all at their stored plan
27//!   distances.
28//!
29//! A line, a circle and an ellipse have none. [`exact_station_seams2`] and
30//! [`exact_station_seams3`] refuse, by a typed
31//! [`GeomError::UnsupportedInput`], a curve with a seam that is not exact.
32//!
33//! # Which piece a station on a seam reads
34//!
35//! A station whose distance lies within [`ARC_LENGTH_TOLERANCE`]` * max(1,
36//! s)` of a seam that is not smooth is ON it, and is read at the seam's
37//! own distance from the piece [`SeamSide`] names: the piece that starts
38//! there ([`SeamSide::Outgoing`], what every evaluator reads by default) or
39//! the one that ends there ([`SeamSide::Incoming`]). The incoming piece is
40//! read as the curve TRUNCATED at the seam, at its end: a polyline's
41//! segment ending at the vertex, a B-spline's span ending at the knot
42//! (the reversed spline's outgoing span), an elevated curve whose profile,
43//! and a banked curve whose cant and pivot laws, stop at the seam.
44//!
45//! # Mitre
46//!
47//! Where a run of sections or offsets crosses a seam whose two tangents
48//! differ, its section there stands in the [`Mitre`] plane: through the
49//! seam's point, normal to the bisector `n = (t_in + t_out) / |t_in +
50//! t_out|`. A section point is placed twice, once in each side's frame,
51//! each projected along its own side's tangent onto the plane, which is
52//! where that side's extrusion of the section meets it; the two agree
53//! whenever the outgoing frame is the incoming one turned about `t_in x
54//! t_out` (a plan turn, a grade break on a straight plan), and their
55//! midpoint is taken otherwise. A longitudinal offset moves the section
56//! along `n`. Tangents within [`SEAM_TANGENT_TOLERANCE`] of each other
57//! need no mitre, and a near reversal, `|t_in + t_out| / 2` (the cosine of
58//! half the turn) at most [`MITRE_TOLERANCE`], has no usable plane and is
59//! refused by name.
60
61use axiolid_contracts::{BackendId, GeomError, GeomResult, Operation};
62use axiolid_core::{Point2, Point3, Scalar, Vec3};
63use axiolid_curve::{
64    BSplineCurve, Banked3, CantLaw, ChainPiece2, Curve2, Curve3, Elevated3, ElevationLaw, SeamSide,
65};
66
67use super::{
68    banked_frame, invalid, planar_frame, reference_up_frame, station_length2, station_length3,
69    SectionFrame,
70};
71use crate::arc_parameter::{arc_length2, arc_length3, ARC_LENGTH_TOLERANCE};
72use crate::curve::{derivative2, derivative3, domain2, domain3, evaluate2, evaluate3};
73
74/// Largest angle, in radians, between the incoming and outgoing tangents
75/// of a seam that still counts as one direction: no mitre is cut there.
76pub const SEAM_TANGENT_TOLERANCE: Scalar = 1e-9;
77
78/// Smallest cosine of half the turn at a seam, `|t_in + t_out| / 2`, that
79/// a [`Mitre`] admits; below it the tangents nearly reverse and the mitre
80/// plane would stretch a section without bound.
81pub const MITRE_TOLERANCE: Scalar = 1e-6;
82
83/// A place inside a curve where two of its pieces meet; see the
84/// [module documentation](self).
85#[non_exhaustive]
86#[derive(Debug, Clone, Copy, PartialEq)]
87pub struct StationSeam {
88    /// Distance from the curve's start in its station measure: plan
89    /// distance on an elevated or banked curve, arc length otherwise.
90    pub distance: Scalar,
91    /// The curve's own parameter there: the vertex index of a polyline,
92    /// the knot of a B-spline, the distance itself on every family whose
93    /// parameter is its measure.
94    pub parameter: Scalar,
95    /// Whether the stored data guarantees one section frame on both sides
96    /// (a chain join, an intrinsic law's seam). `false` means the frame
97    /// MAY jump: evaluate both sides to see whether it does.
98    pub smooth: bool,
99    /// Whether `distance` is read from stored data alone (sums of stored
100    /// lengths and of segment lengths); `false` for a quadrature.
101    pub exact: bool,
102}
103
104impl StationSeam {
105    /// A seam at `distance` (the station measure) and `parameter` (the
106    /// curve's own), `smooth` when the stored data guarantees one frame on
107    /// both sides and `exact` when `distance` needs no quadrature.
108    #[must_use]
109    pub const fn new(distance: Scalar, parameter: Scalar, smooth: bool, exact: bool) -> Self {
110        Self {
111            distance,
112            parameter,
113            smooth,
114            exact,
115        }
116    }
117
118    /// A seam whose parameter is its distance.
119    const fn measured(distance: Scalar, smooth: bool) -> Self {
120        Self::new(distance, distance, smooth, true)
121    }
122}
123
124/// The arc-length tolerance around `distance`: what counts as ON a seam.
125fn slack(distance: Scalar) -> Scalar {
126    ARC_LENGTH_TOLERANCE * distance.abs().max(1.0)
127}
128
129/// Seams strictly inside `(0, length)`, sorted, those within tolerance of
130/// each other merged (smooth and exact only if every merged one is).
131pub(super) fn tidy(mut seams: Vec<StationSeam>, length: Option<Scalar>) -> Vec<StationSeam> {
132    seams.retain(|seam| {
133        seam.distance.is_finite()
134            && seam.distance > slack(0.0)
135            && length.is_none_or(|l| seam.distance < l - slack(l))
136    });
137    seams.sort_by(|a, b| a.distance.total_cmp(&b.distance));
138    let mut out: Vec<StationSeam> = Vec::with_capacity(seams.len());
139    for seam in seams {
140        match out.last_mut() {
141            Some(last) if seam.distance - last.distance <= slack(seam.distance) => {
142                last.smooth &= seam.smooth;
143                last.exact &= seam.exact;
144            }
145            _ => out.push(seam),
146        }
147    }
148    out
149}
150
151/// The non-degenerate segments of a polyline's points: `(start vertex,
152/// end vertex, length)`, a closed polyline's wrap included.
153fn polyline_segments<P>(
154    points: &[P],
155    closed: bool,
156    length: impl Fn(P, P) -> Scalar,
157) -> Vec<(usize, usize, Scalar)>
158where
159    P: Copy,
160{
161    let count = points.len();
162    let spans: Vec<(usize, usize)> = if closed {
163        (0..count).map(|i| (i, (i + 1) % count)).collect()
164    } else {
165        (0..count.saturating_sub(1)).map(|i| (i, i + 1)).collect()
166    };
167    spans
168        .into_iter()
169        .map(|(i, j)| (i, j, length(points[i], points[j])))
170        .collect()
171}
172
173/// A polyline's seams: the vertex where each non-degenerate segment
174/// starts after an earlier non-degenerate one, at the running sum of the
175/// segment lengths. The parameter is that segment's start vertex.
176fn polyline_seams(segments: &[(usize, usize, Scalar)]) -> Vec<StationSeam> {
177    let mut out = Vec::new();
178    let mut run = 0.0;
179    let mut started = false;
180    for &(start, _, length) in segments {
181        if length > 0.0 {
182            if started {
183                out.push(StationSeam::new(run, start as Scalar, false, true));
184            }
185            started = true;
186        }
187        run += length;
188    }
189    out
190}
191
192/// The segment a polyline vertex seam reads on `side`: the first
193/// non-degenerate one starting at `vertex` or later, or the last ending at
194/// it or earlier. Its index is its start vertex.
195fn polyline_side(
196    segments: &[(usize, usize, Scalar)],
197    vertex: usize,
198    side: SeamSide,
199) -> GeomResult<(usize, usize)> {
200    let at = segments
201        .iter()
202        .position(|&(start, _, _)| start == vertex)
203        .ok_or_else(|| invalid("station: the seam is not a vertex of the polyline".into()))?;
204    let found = match side {
205        SeamSide::Incoming => segments[..at].iter().rev().find(|s| s.2 > 0.0),
206        _ => segments[at..].iter().find(|s| s.2 > 0.0),
207    };
208    found.map(|&(i, j, _)| (i, j)).ok_or_else(|| {
209        invalid("station: the polyline has no segment on that side of the seam".into())
210    })
211}
212
213/// Interior knots of a B-spline where it may turn a corner (multiplicity
214/// at least its degree), inside its domain.
215fn corner_knots(breaks: Vec<Scalar>, lo: Scalar, hi: Scalar) -> Vec<Scalar> {
216    breaks.into_iter().filter(|&t| t > lo && t < hi).collect()
217}
218
219/// The same B-spline traversed backwards: knots mirrored about the sum of
220/// the first and last, every list reversed. Its outgoing span at the
221/// mirrored knot `first + last - t` is this spline's incoming span at `t`.
222fn reversed_spline<P: Clone>(spline: &BSplineCurve<P>) -> (BSplineCurve<P>, Scalar) {
223    let first = spline.knots.first().copied().unwrap_or(0.0);
224    let last = spline.knots.last().copied().unwrap_or(0.0);
225    let pivot = first + last;
226    let mut reversed = spline.clone();
227    reversed.knots = spline.knots.iter().rev().map(|t| pivot - t).collect();
228    reversed.multiplicities.reverse();
229    reversed.control_points.reverse();
230    if let Some(weights) = reversed.weights.as_mut() {
231        weights.reverse();
232    }
233    (reversed, pivot)
234}
235
236/// The seams of a 2D curve, every one located (see the
237/// [module documentation](self)); a B-spline's corner knots by quadrature,
238/// flagged not exact.
239///
240/// # Errors
241///
242/// A family a station cannot be measured on, a malformed length, and the
243/// quadrature's refusals, by name.
244pub fn station_seams2(curve: &Curve2) -> GeomResult<Vec<StationSeam>> {
245    let length = station_length2(curve)?;
246    let seams = match curve {
247        Curve2::Polyline(polyline) => polyline_seams(&polyline_segments(
248            &polyline.points,
249            polyline.closed,
250            |a: Point2, b: Point2| (b - a).length(),
251        )),
252        Curve2::BSpline(_) => {
253            let domain = domain2(curve);
254            let (lo, hi) = (domain.start.min(domain.end), domain.start.max(domain.end));
255            corner_knots(crate::bound::continuity_breaks2(curve, 1), lo, hi)
256                .into_iter()
257                .map(|t| {
258                    Ok(StationSeam::new(
259                        arc_length2(curve, lo, t)?,
260                        t,
261                        false,
262                        false,
263                    ))
264                })
265                .collect::<GeomResult<_>>()?
266        }
267        Curve2::Intrinsic(intrinsic) => intrinsic
268            .curvature
269            .seams_within(intrinsic.length)
270            .into_iter()
271            .map(|s| StationSeam::measured(s, true))
272            .collect(),
273        Curve2::Chain(chain) => chain
274            .joins()
275            .unwrap_or_default()
276            .into_iter()
277            .chain(crate::chain::chain_curvature_seams(chain))
278            .map(|s| StationSeam::measured(s, true))
279            .collect(),
280        _ => Vec::new(),
281    };
282    Ok(tidy(seams, length))
283}
284
285/// The seams of a 3D curve, every one located; see [`station_seams2`].
286///
287/// # Errors
288///
289/// As [`station_seams2`].
290pub fn station_seams3(curve: &Curve3) -> GeomResult<Vec<StationSeam>> {
291    let length = station_length3(curve)?;
292    let seams = match curve {
293        Curve3::Polyline(polyline) => polyline_seams(&polyline_segments(
294            &polyline.points,
295            polyline.closed,
296            |a: Point3, b: Point3| (b - a).length(),
297        )),
298        Curve3::BSpline(_) => {
299            let domain = domain3(curve);
300            let (lo, hi) = (domain.start.min(domain.end), domain.start.max(domain.end));
301            corner_knots(crate::bound::continuity_breaks3(curve, 1), lo, hi)
302                .into_iter()
303                .map(|t| {
304                    Ok(StationSeam::new(
305                        arc_length3(curve, lo, t)?,
306                        t,
307                        false,
308                        false,
309                    ))
310                })
311                .collect::<GeomResult<_>>()?
312        }
313        Curve3::Intrinsic(intrinsic) => intrinsic
314            .curvature
315            .seams_within(intrinsic.length)
316            .into_iter()
317            .chain(intrinsic.torsion.seams_within(intrinsic.length))
318            .map(|s| StationSeam::measured(s, true))
319            .collect(),
320        Curve3::Elevated(elevated) => elevated_seams(elevated)?,
321        Curve3::Banked(banked) => {
322            let mut seams = elevated_seams(&banked.base)?;
323            seams.extend(
324                banked
325                    .cant
326                    .seams()
327                    .into_iter()
328                    .chain(banked.pivot.seams())
329                    .map(|s| StationSeam::measured(s, false)),
330            );
331            seams
332        }
333        _ => Vec::new(),
334    };
335    Ok(tidy(seams, length))
336}
337
338/// An elevated curve's plan seams and its profile's.
339fn elevated_seams(curve: &Elevated3) -> GeomResult<Vec<StationSeam>> {
340    let mut seams = match curve.plan.as_ref() {
341        // The plan's own seams are in its arc length, which is the plan
342        // distance; a line or a circle has none.
343        plan @ (Curve2::Intrinsic(_) | Curve2::Chain(_)) => station_seams2(plan)?,
344        _ => Vec::new(),
345    };
346    seams.extend(
347        crate::elevation::elevation_seams(&curve.elevation)
348            .into_iter()
349            .map(|s| StationSeam::measured(s, false)),
350    );
351    Ok(seams)
352}
353
354fn not_exact(seams: Vec<StationSeam>) -> GeomResult<Vec<StationSeam>> {
355    if seams.iter().any(|seam| !seam.exact) {
356        return Err(GeomError::UnsupportedInput {
357            backend: BackendId::new("axiolid-evaluate"),
358            operation: Operation::CurveEvaluation,
359            input: "exact seam distances of a B-spline with a corner knot: its arc length to \
360                    the knot is a quadrature",
361        });
362    }
363    Ok(seams)
364}
365
366/// A chain whose parametric piece turns a corner inside itself: that
367/// corner is a seam no stored length locates.
368fn chain_hides_corners(curve: &Curve2) -> bool {
369    match curve {
370        Curve2::Chain(chain) => chain.pieces.iter().any(|piece| match piece {
371            ChainPiece2::Parametric { curve, .. } => {
372                !crate::bound::continuity_breaks2(curve, 1).is_empty()
373            }
374            _ => false,
375        }),
376        _ => false,
377    }
378}
379
380fn hidden_corner() -> GeomError {
381    GeomError::UnsupportedInput {
382        backend: BackendId::new("axiolid-evaluate"),
383        operation: Operation::CurveEvaluation,
384        input: "exact seam distances of a chain whose parametric piece turns a corner inside \
385                itself: no stored length locates it",
386    }
387}
388
389/// [`station_seams2`], refusing a curve with a seam whose distance is not
390/// read from stored data alone.
391///
392/// # Errors
393///
394/// As [`station_seams2`], and [`GeomError::UnsupportedInput`] for a
395/// B-spline with a corner knot or a chain whose parametric piece has one.
396pub fn exact_station_seams2(curve: &Curve2) -> GeomResult<Vec<StationSeam>> {
397    if chain_hides_corners(curve) {
398        return Err(hidden_corner());
399    }
400    not_exact(station_seams2(curve)?)
401}
402
403/// [`station_seams3`], refusing a curve with a seam whose distance is not
404/// read from stored data alone.
405///
406/// # Errors
407///
408/// As [`exact_station_seams2`]; an elevated or banked curve's chain plan
409/// is checked the same way.
410pub fn exact_station_seams3(curve: &Curve3) -> GeomResult<Vec<StationSeam>> {
411    let plan = match curve {
412        Curve3::Elevated(elevated) => Some(elevated.plan.as_ref()),
413        Curve3::Banked(banked) => Some(banked.base.plan.as_ref()),
414        _ => None,
415    };
416    if plan.is_some_and(chain_hides_corners) {
417        return Err(hidden_corner());
418    }
419    not_exact(station_seams3(curve)?)
420}
421
422// --- a station on a seam ------------------------------------------------------
423
424/// The seam a station at the admitted `distance` lies on, if any: a seam
425/// that is not smooth within the arc-length tolerance.
426fn seam_near(seams: Vec<StationSeam>, distance: Scalar) -> Option<StationSeam> {
427    seams
428        .into_iter()
429        .filter(|seam| !seam.smooth && (seam.distance - distance).abs() <= slack(distance))
430        .min_by(|a, b| {
431            (a.distance - distance)
432                .abs()
433                .total_cmp(&(b.distance - distance).abs())
434        })
435}
436
437/// Whether a family can have a seam a station reads two ways; the others
438/// skip locating seams altogether.
439fn sided2(curve: &Curve2) -> bool {
440    matches!(curve, Curve2::Polyline(_) | Curve2::BSpline(_))
441}
442
443pub(super) fn sided3(curve: &Curve3) -> bool {
444    matches!(
445        curve,
446        Curve3::Polyline(_) | Curve3::BSpline(_) | Curve3::Elevated(_) | Curve3::Banked(_)
447    )
448}
449
450/// The section of a 2D curve ON a seam near `distance`, read from `side`;
451/// `None` when no seam is near.
452pub(super) fn seam_section2(
453    curve: &Curve2,
454    distance: Scalar,
455    side: SeamSide,
456) -> GeomResult<Option<SectionFrame>> {
457    if !sided2(curve) {
458        return Ok(None);
459    }
460    let Some(seam) = seam_near(station_seams2(curve)?, distance) else {
461        return Ok(None);
462    };
463    let frame = match curve {
464        Curve2::Polyline(polyline) => {
465            let segments = polyline_segments(&polyline.points, polyline.closed, |a: Point2, b| {
466                (b - a).length()
467            });
468            let vertex = seam.parameter as usize;
469            let (i, j) = polyline_side(&segments, vertex, side)?;
470            planar_frame(
471                polyline.points[vertex],
472                polyline.points[j] - polyline.points[i],
473            )?
474        }
475        Curve2::BSpline(spline) => {
476            let point = evaluate2(curve, seam.parameter)?;
477            let tangent = match side {
478                SeamSide::Incoming => {
479                    let (reversed, pivot) = reversed_spline(spline);
480                    -derivative2(&Curve2::BSpline(reversed), pivot - seam.parameter)?
481                }
482                _ => derivative2(curve, seam.parameter)?,
483            };
484            planar_frame(point, tangent)?
485        }
486        _ => return Ok(None),
487    };
488    Ok(Some(frame))
489}
490
491/// The seam a station at the admitted `distance` along a 3D curve lies ON
492/// and reads two ways, if any: the rule [`seam_section3`] applies, and
493/// the curve-evaluation provider's sided queries with it (#286).
494pub(crate) fn seam_at3(curve: &Curve3, distance: Scalar) -> GeomResult<Option<StationSeam>> {
495    if !sided3(curve) {
496        return Ok(None);
497    }
498    Ok(seam_near(station_seams3(curve)?, distance))
499}
500
501/// The section of a 3D curve ON a seam near `distance`, read from `side`;
502/// `None` when no seam is near.
503pub(super) fn seam_section3(
504    curve: &Curve3,
505    distance: Scalar,
506    side: SeamSide,
507) -> GeomResult<Option<SectionFrame>> {
508    let Some(seam) = seam_at3(curve, distance)? else {
509        return Ok(None);
510    };
511    let incoming = side == SeamSide::Incoming;
512    let at = seam.distance;
513    let frame = match curve {
514        Curve3::Polyline(polyline) => {
515            let segments = polyline_segments(&polyline.points, polyline.closed, |a: Point3, b| {
516                (b - a).length()
517            });
518            let vertex = seam.parameter as usize;
519            let (i, j) = polyline_side(&segments, vertex, side)?;
520            reference_up_frame(
521                polyline.points[vertex],
522                polyline.points[j] - polyline.points[i],
523            )?
524        }
525        Curve3::BSpline(spline) => {
526            let point = evaluate3(curve, seam.parameter)?;
527            let tangent = if incoming {
528                let (reversed, pivot) = reversed_spline(spline);
529                -derivative3(&Curve3::BSpline(reversed), pivot - seam.parameter)?
530            } else {
531                derivative3(curve, seam.parameter)?
532            };
533            reference_up_frame(point, tangent)?
534        }
535        Curve3::Elevated(elevated) => {
536            let truncated;
537            let read = if incoming {
538                truncated = Elevated3 {
539                    plan: elevated.plan.clone(),
540                    elevation: elevation_ending_at(&elevated.elevation, at),
541                };
542                &truncated
543            } else {
544                elevated
545            };
546            reference_up_frame(
547                crate::arc_length::elevated_point(read, at)?,
548                crate::arc_length::elevated_tangent(read, at)?,
549            )?
550        }
551        Curve3::Banked(banked) => {
552            let truncated;
553            let read = if incoming {
554                truncated = Banked3 {
555                    base: Elevated3 {
556                        plan: banked.base.plan.clone(),
557                        elevation: elevation_ending_at(&banked.base.elevation, at),
558                    },
559                    cant: law_ending_at(&banked.cant, at),
560                    pivot: law_ending_at(&banked.pivot, at),
561                    ..banked.clone()
562                };
563                &truncated
564            } else {
565                banked
566            };
567            banked_frame(read, at)?
568        }
569        _ => return Ok(None),
570    };
571    Ok(Some(frame))
572}
573
574/// A profile cut at `distance`: the pieces starting before it (outside
575/// the tolerance), nested laws cut the same way, so the piece read at
576/// `distance` is the one ENDING there.
577fn elevation_ending_at(law: &ElevationLaw, distance: Scalar) -> ElevationLaw {
578    match law {
579        ElevationLaw::Piecewise { breaks, laws } if laws.len() == breaks.len() + 1 => {
580            let keep = breaks.partition_point(|b| *b < distance - slack(distance));
581            let start = if keep == 0 { 0.0 } else { breaks[keep - 1] };
582            let mut kept = laws[..keep].to_vec();
583            kept.push(elevation_ending_at(&laws[keep], distance - start));
584            ElevationLaw::Piecewise {
585                breaks: breaks[..keep].to_vec(),
586                laws: kept,
587            }
588        }
589        other => other.clone(),
590    }
591}
592
593/// A cant or pivot law cut at `distance`: the pieces starting before it,
594/// so its last piece, closed at its far end, is the one ending there.
595fn law_ending_at(law: &CantLaw, distance: Scalar) -> CantLaw {
596    let mut start = 0.0;
597    let mut pieces = Vec::with_capacity(law.pieces.len());
598    for piece in &law.pieces {
599        if !pieces.is_empty() && start >= distance - slack(distance) {
600            break;
601        }
602        pieces.push(piece.clone());
603        start += piece.length;
604    }
605    CantLaw::new(pieces)
606}
607
608// --- mitre --------------------------------------------------------------------
609
610/// The plane a section stands in where a run crosses a seam whose
611/// tangents differ; see the [module documentation](self#mitre).
612#[non_exhaustive]
613#[derive(Debug, Clone, Copy, PartialEq)]
614pub struct Mitre {
615    /// The section frame of the piece ending at the seam.
616    pub incoming: SectionFrame,
617    /// The section frame of the piece starting there.
618    pub outgoing: SectionFrame,
619    /// Unit normal of the mitre plane, `(t_in + t_out) / |t_in + t_out|`.
620    pub normal: Vec3,
621}
622
623impl Mitre {
624    /// The mitre between the two frames of a seam, or `None` when their
625    /// tangents agree within [`SEAM_TANGENT_TOLERANCE`].
626    ///
627    /// # Errors
628    ///
629    /// Tangents that nearly reverse (the cosine of half the turn at most
630    /// [`MITRE_TOLERANCE`]), and two frames that do not share their point
631    /// (a seam that is not position continuous), by name.
632    pub fn between(incoming: &SectionFrame, outgoing: &SectionFrame) -> GeomResult<Option<Self>> {
633        let (t_in, t_out) = (incoming.tangent, outgoing.tangent);
634        let turn = t_in.cross(t_out).length().atan2(t_in.dot(t_out));
635        if !turn.is_finite() {
636            return Err(invalid("station: a seam's tangents are not finite".into()));
637        }
638        if turn <= SEAM_TANGENT_TOLERANCE {
639            return Ok(None);
640        }
641        let sum = t_in + t_out;
642        let half = 0.5 * sum.length();
643        if half <= MITRE_TOLERANCE {
644            return Err(GeomError::Degenerate(format!(
645                "station: the curve turns back on itself at a seam (by {turn} rad), so a run \
646                 across it has no mitre plane"
647            )));
648        }
649        let gap = (outgoing.point - incoming.point).length();
650        let scale = incoming.point.abs().max_element().max(1.0);
651        if gap > ARC_LENGTH_TOLERANCE * scale {
652            return Err(invalid(format!(
653                "station: the curve's pieces are {gap} apart at a seam, so a run across it has \
654                 no mitre"
655            )));
656        }
657        Ok(Some(Self {
658            incoming: *incoming,
659            outgoing: *outgoing,
660            normal: sum / (2.0 * half),
661        }))
662    }
663
664    /// A section point in the mitre plane: `incoming` and `outgoing` are
665    /// the same section point placed in each side's frame (without a
666    /// longitudinal offset); each is projected along its side's tangent
667    /// onto the plane, and the midpoint is moved `longitudinal` along the
668    /// normal.
669    #[must_use]
670    pub fn place(&self, incoming: Point3, outgoing: Point3, longitudinal: Scalar) -> Point3 {
671        let project = |frame: &SectionFrame, point: Point3| {
672            let height = (point - frame.point).dot(self.normal);
673            point - frame.tangent * (height / frame.tangent.dot(self.normal))
674        };
675        let a = project(&self.incoming, incoming);
676        let b = project(&self.outgoing, outgoing);
677        (a + b) * 0.5 + self.normal * longitudinal
678    }
679}