axiolid_curve/
intrinsic.rs

1//! Intrinsic (natural) plane curve data: curvature as a function of arc length.
2//!
3//! A plane curve is fixed up to rigid motion by its curvature law k(s). Anchoring
4//! that law to a start frame fixes it absolutely. This is the *natural equation*
5//! (Cesaro/Whewell) rather than a parametric map, and it is the honest way to
6//! carry a clothoid: the spiral has no elementary parametric form, so a kernel
7//! that only speaks parametrically has to approximate it before it has even
8//! stored it.
9//!
10//! # Exact and symbolic, deliberately not evaluated
11//!
12//! Everything here is closed-form symbolic data and closed-form symbolic
13//! operations on it: differentiate the law, negate it, measure the total turning
14//! it accumulates. No module in this crate turns a law into points.
15//!
16//! That boundary is not laziness, it is the mathematics. Recovering position from
17//! k(s) requires integrating the tangent angle and then integrating the tangent,
18//! which for a linear law is the Fresnel integral -- not an elementary function.
19//! Any point you get is a quadrature result carrying a tolerance, so it belongs
20//! to an evaluator that can state that tolerance, never to the representation.
21//! Storing the law exactly and refusing to fake points is what keeps a clothoid
22//! a clothoid instead of a polyline that used to be one.
23
24use axiolid_core::{Frame2, Scalar};
25
26/// One sinusoidal term of a curvature law:
27/// `amplitude * sin(angular_frequency * s + phase)`.
28///
29/// A term is deliberately not a law: it carries no mean. The constant part of
30/// a composite law lives in its polynomial, so a given function has one
31/// representation instead of many that differ only in where the mean was put.
32#[derive(Debug, Clone, Copy, PartialEq)]
33pub struct Harmonic {
34    /// Peak deviation contributed by this term.
35    pub amplitude: Scalar,
36    /// Radians of phase per unit arc length.
37    pub angular_frequency: Scalar,
38    /// Phase offset at `s = 0`, in radians.
39    pub phase: Scalar,
40}
41
42/// Curvature as a closed-form function of arc length.
43///
44/// Sign follows the usual plane convention: positive curvature turns the
45/// tangent counter-clockwise. `s` is arc length measured from the curve start,
46/// so a law is meaningful on `[0, length]` of the curve that carries it.
47#[non_exhaustive]
48#[derive(Debug, Clone, PartialEq)]
49pub enum CurvatureLaw {
50    /// `k(s) = curvature`.
51    ///
52    /// Zero is a straight line; any other value is a circular arc of radius
53    /// `1 / curvature`.
54    Constant {
55        /// The constant curvature.
56        curvature: Scalar,
57    },
58    /// `k(s) = coefficients[0] + coefficients[1] * s + coefficients[2] * s^2 + ...`
59    ///
60    /// Degree 1 is the clothoid (Euler spiral), whose curvature is linear in
61    /// arc length; that is the transition spiral used between straight and
62    /// circular track so lateral acceleration ramps linearly instead of
63    /// stepping. Higher degrees cover the Bloss and cubic-parabola families.
64    ///
65    /// An empty coefficient list is the zero polynomial: a straight line.
66    Polynomial {
67        /// Coefficients in ascending powers of arc length.
68        coefficients: Vec<Scalar>,
69    },
70    /// `k(s) = mean + amplitude * sin(angular_frequency * s + phase)`.
71    ///
72    /// The sinusoidal transition family (Klein, cosine ramps). Kept distinct
73    /// from `Polynomial` because it is exactly representable this way and a
74    /// truncated series would not be.
75    Sinusoid {
76        /// Curvature the oscillation is centred on.
77        mean: Scalar,
78        /// Peak deviation from `mean`.
79        amplitude: Scalar,
80        /// Radians of phase per unit arc length.
81        angular_frequency: Scalar,
82        /// Phase offset at `s = 0`, in radians.
83        phase: Scalar,
84    },
85    /// `k(s) = sum c_i s^i + sum A_j sin(w_j s + p_j)`.
86    ///
87    /// A polynomial and any number of harmonic terms at once. Neither
88    /// `Polynomial` nor `Sinusoid` can hold a law with both a secular trend and
89    /// an oscillation, so a curve of that shape previously had to be refused or
90    /// approximated; this variant stores it exactly.
91    ///
92    /// The shape is flat and additive rather than a recursive `Sum(Vec<Self>)`.
93    /// A recursive sum would let the same function be written in unboundedly
94    /// many ways, would make `is_constant` a search over arbitrary trees, and
95    /// would admit nested sums that mean nothing extra. Flattening keeps one
96    /// canonical slot per kind of term, keeps the family closed under
97    /// differentiation and integration, and keeps the structural predicates a
98    /// finite check over two lists.
99    ///
100    /// Empty `harmonics` is exactly the polynomial law; an empty polynomial with
101    /// empty harmonics is the zero law. Both are legal, so a caller assembling
102    /// terms never has to special-case the empty stage.
103    Composite {
104        /// Coefficients in ascending powers of arc length.
105        polynomial: Vec<Scalar>,
106        /// Additive sinusoidal terms.
107        harmonics: Vec<Harmonic>,
108    },
109    /// Pieces laid end to end along arc length, each with its own law.
110    ///
111    /// `breaks` holds the INTERIOR seam positions in arc length from the
112    /// curve start, so `laws.len() == breaks.len() + 1` and piece `i` spans
113    /// `breaks[i - 1] .. breaks[i]`, the first starting at `0` and the last
114    /// ending at the carrying curve's length.
115    ///
116    /// Each piece's law is written in its OWN arc length, restarting at zero
117    /// at its seam, so a piece does not depend on where it sits and moving
118    /// one never rewrites its coefficients.
119    ///
120    /// This variant exists because a piecewise profile genuinely cannot be
121    /// decomposed into several `Intrinsic2` values. Every piece after the
122    /// first would need an absolute start frame whose origin is the position
123    /// at the seam, and that position is the non-elementary integral this
124    /// crate refuses to compute. Holding the pieces in ONE curve keeps a
125    /// single absolute frame at the start and anchors the interior purely by
126    /// arc length, so no interior position is ever required.
127    ///
128    /// Unlike a summed law, pieces are disjoint and ordered: the seams are
129    /// observable data, not a redundant re-encoding of one function. That is
130    /// why nesting is meaningful here and was not for `Composite` -- a piece
131    /// may itself be piecewise, expressing refinement.
132    ///
133    /// Mismatched lengths stay representable, as everywhere else in this
134    /// crate; the operations report `None`/`false` rather than guessing.
135    Piecewise {
136        /// Interior seam positions in arc length, ascending.
137        breaks: Vec<Scalar>,
138        /// One law per piece; `laws.len() == breaks.len() + 1`.
139        laws: Vec<CurvatureLaw>,
140    },
141}
142
143impl CurvatureLaw {
144    /// The zero law: a straight line.
145    #[must_use]
146    pub const fn straight() -> Self {
147        Self::Constant { curvature: 0.0 }
148    }
149
150    /// A circular arc of the given signed curvature.
151    #[must_use]
152    pub const fn circular(curvature: Scalar) -> Self {
153        Self::Constant { curvature }
154    }
155
156    /// A clothoid whose curvature runs from `start` to `end` over `length`.
157    ///
158    /// This is the transition-spiral constructor: the rate is derived rather
159    /// than asked for, because the two endpoint curvatures and the length are
160    /// what an alignment actually specifies.
161    ///
162    /// A non-finite or zero `length` cannot define a rate, so the result is a
163    /// constant `start` law -- the honest degenerate answer, not a division by
164    /// zero smuggled into a coefficient.
165    #[must_use]
166    pub fn clothoid(start: Scalar, end: Scalar, length: Scalar) -> Self {
167        if !length.is_finite() || length == 0.0 {
168            return Self::Constant { curvature: start };
169        }
170        Self::Polynomial {
171            coefficients: vec![start, (end - start) / length],
172        }
173    }
174
175    /// A transition whose linear ramp carries one full sine correction over
176    /// its length: `k(s) = start + (d/L) s - (d / 2pi) sin(2 pi s / L)`, where
177    /// `d = end - start`.
178    ///
179    /// The sine term removes the curvature-rate step a plain clothoid has at
180    /// each end, so the rate starts and ends at zero instead of jumping. The
181    /// mean rate is still `d / L`, so the total turning is unchanged from the
182    /// clothoid's `(start + end) / 2 * L`.
183    ///
184    /// As with `clothoid`, a non-finite or zero `length` cannot define a rate,
185    /// so the result degrades to a constant `start` law.
186    #[must_use]
187    pub fn sine_corrected_transition(start: Scalar, end: Scalar, length: Scalar) -> Self {
188        if !length.is_finite() || length == 0.0 {
189            return Self::Constant { curvature: start };
190        }
191        let delta = end - start;
192        let turn = core::f64::consts::TAU;
193        Self::Composite {
194            polynomial: vec![start, delta / length],
195            harmonics: vec![Harmonic {
196                amplitude: -delta / turn,
197                angular_frequency: turn / length,
198                phase: 0.0,
199            }],
200        }
201    }
202    /// Pieces laid end to end, each carrying its own law.
203    ///
204    /// The seams are interior positions in ascending arc length; the caller
205    /// supplies one more law than seam. A mismatch is storable and reported
206    /// by `is_well_formed`, not rejected here.
207    #[must_use]
208    pub fn piecewise(breaks: Vec<Scalar>, laws: Vec<CurvatureLaw>) -> Self {
209        Self::Piecewise { breaks, laws }
210    }
211
212    /// Whether the stored shape is internally consistent.
213    ///
214    /// Only `Piecewise` can be malformed: it carries two lists whose lengths
215    /// must agree and seams that must ascend. Every other variant is
216    /// well-formed by construction, so this is `true` for them.
217    ///
218    /// Structural, like the other predicates: it inspects stored data and
219    /// never evaluates the law. Non-finite or descending seams are reported
220    /// rather than silently sorted, because reordering would change which
221    /// piece owns which arc length.
222    #[must_use]
223    pub fn is_well_formed(&self) -> bool {
224        match self {
225            Self::Piecewise { breaks, laws } => {
226                // n pieces need n-1 interior seams. The empty law is the
227                // one exception: zero pieces carry zero seams, and it is a
228                // legitimate value (an alignment with nothing in it yet)
229                // rather than a broken one, so it is well formed and turns
230                // nothing.
231                if laws.is_empty() {
232                    return breaks.is_empty();
233                }
234                laws.len() == breaks.len() + 1
235                    && breaks.iter().all(|b| b.is_finite())
236                    && breaks.windows(2).all(|w| w[0] < w[1])
237                    && laws.iter().all(Self::is_well_formed)
238            }
239            _ => true,
240        }
241    }
242    /// Whether the law is identically zero, i.e. a straight line.
243    ///
244    /// Exact: this is a structural test on the stored coefficients, not a
245    /// sampled one.
246    #[must_use]
247    pub fn is_straight(&self) -> bool {
248        match self {
249            Self::Constant { curvature } => *curvature == 0.0,
250            Self::Polynomial { coefficients } => coefficients.iter().all(|c| *c == 0.0),
251            Self::Sinusoid {
252                mean,
253                amplitude,
254                angular_frequency,
255                ..
256            } => {
257                // A zero frequency freezes the sine at its phase value, so the
258                // law is constant but not necessarily zero; that case is only
259                // straight when the frozen value cancels the mean, which needs
260                // evaluation. Report false rather than guess.
261                *mean == 0.0 && *amplitude == 0.0 && angular_frequency.is_finite()
262            }
263            Self::Composite {
264                polynomial,
265                harmonics,
266            } => {
267                // Every polynomial coefficient must vanish, and every harmonic
268                // must contribute nothing. A harmonic contributes nothing only
269                // when its amplitude is zero: a zero-frequency term freezes at
270                // A*sin(p), which cancels only for particular phases, and
271                // deciding that needs evaluation. Report false rather than
272                // guess, matching the Sinusoid precedent.
273                polynomial.iter().all(|c| *c == 0.0)
274                    && harmonics
275                        .iter()
276                        .all(|h| h.amplitude == 0.0 && h.angular_frequency.is_finite())
277            }
278            // Straight overall exactly when every piece is straight. The
279            // seams are irrelevant to this question: a union of zero-curvature
280            // pieces is zero-curvature whatever the break positions.
281            Self::Piecewise { laws, .. } => laws.iter().all(Self::is_straight),
282        }
283    }
284
285    /// Whether the law is constant in arc length.
286    #[must_use]
287    pub fn is_constant(&self) -> bool {
288        match self {
289            Self::Constant { .. } => true,
290            Self::Polynomial { coefficients } => coefficients.iter().skip(1).all(|c| *c == 0.0),
291            Self::Sinusoid { amplitude, .. } => *amplitude == 0.0,
292            Self::Composite {
293                polynomial,
294                harmonics,
295            } => {
296                // Constant in s: no polynomial term above degree 0 survives, and
297                // no harmonic actually oscillates. A zero-frequency harmonic is
298                // frozen at A*sin(p) and so IS constant, unlike the straightness
299                // case where its value would also have to cancel.
300                polynomial.iter().skip(1).all(|c| *c == 0.0)
301                    && harmonics
302                        .iter()
303                        .all(|h| h.amplitude == 0.0 || h.angular_frequency == 0.0)
304            }
305            // Constant across the WHOLE curve needs every piece constant and
306            // every piece equal to its neighbours: a staircase of differing
307            // constants is piecewise-constant but not constant.
308            //
309            // Structural equality is the honest test available. Two pieces can
310            // be equal in value while differing in form (`Constant { 0 }` versus
311            // an empty `Polynomial`), and settling that needs evaluation, so
312            // report false rather than guess -- the Sinusoid precedent.
313            Self::Piecewise { laws, .. } => {
314                self.is_well_formed()
315                    && laws.iter().all(Self::is_constant)
316                    && laws.windows(2).all(|w| w[0] == w[1])
317            }
318        }
319    }
320
321    /// The law's value, when it does not vary with arc length.
322    ///
323    /// Returns `None` for a law that varies, so a caller that needs a
324    /// single number cannot silently read one off a varying law. The
325    /// variants that `is_constant` accepts are exactly the ones answered
326    /// here: a frozen harmonic contributes `A * sin(p)`, which is why a
327    /// zero-FREQUENCY term is constant without being zero.
328    #[must_use]
329    pub fn constant_value(&self) -> Option<Scalar> {
330        if !self.is_constant() {
331            return None;
332        }
333        match self {
334            Self::Constant { curvature } => Some(*curvature),
335            Self::Polynomial { coefficients } => Some(coefficients.first().copied().unwrap_or(0.0)),
336            Self::Sinusoid { mean, .. } => Some(*mean),
337            Self::Composite {
338                polynomial,
339                harmonics,
340            } => {
341                let constant = polynomial.first().copied().unwrap_or(0.0);
342                let frozen: Scalar = harmonics.iter().map(|h| h.amplitude * h.phase.sin()).sum();
343                Some(constant + frozen)
344            }
345            // `is_constant` already required every piece equal, so the first
346            // piece speaks for the whole law.
347            Self::Piecewise { laws, .. } => laws.first().and_then(Self::constant_value),
348        }
349    }
350    /// Interior seam positions strictly inside `(0, span)`, ascending.
351    ///
352    /// A piecewise law is only piecewise-smooth: its value can jump at a
353    /// seam. Gauss-Legendre quadrature assumes the integrand is smooth
354    /// across a panel, so a panel straddling a seam loses most of its
355    /// accuracy -- measured at 3.0e-3 on a joined curve, against 1e-9
356    /// once panels break at the seam. An integrator must therefore split
357    /// its panels here rather than spreading them uniformly.
358    ///
359    /// Nested piecewise laws report their inner seams too, in absolute
360    /// arc length from this law's origin, because a piece may itself be
361    /// piecewise and its seams are just as discontinuous.
362    #[must_use]
363    pub fn seams_within(&self, span: Scalar) -> Vec<Scalar> {
364        let mut out = Vec::new();
365        self.collect_seams(0.0, span, &mut out);
366        out.sort_by(Scalar::total_cmp);
367        out.dedup();
368        out
369    }
370
371    fn collect_seams(&self, origin: Scalar, span: Scalar, out: &mut Vec<Scalar>) {
372        let Self::Piecewise { breaks, laws } = self else {
373            return;
374        };
375        if laws.len() != breaks.len() + 1 {
376            return;
377        }
378        let mut start = 0.0;
379        for (index, piece) in laws.iter().enumerate() {
380            let end = breaks.get(index).copied().unwrap_or(Scalar::INFINITY);
381            let absolute = origin + start;
382            if absolute > 0.0 && absolute < span {
383                out.push(absolute);
384            }
385            piece.collect_seams(absolute, span, out);
386            if !end.is_finite() || origin + end >= span {
387                break;
388            }
389            start = end;
390        }
391    }
392    /// The derivative law `dk/ds`, in closed form.
393    ///
394    /// Exact symbolic differentiation. The sharpness of a clothoid is the
395    /// constant this returns.
396    #[must_use]
397    pub fn derivative(&self) -> Self {
398        match self {
399            Self::Constant { .. } => Self::Constant { curvature: 0.0 },
400            Self::Polynomial { coefficients } => Self::Polynomial {
401                coefficients: coefficients
402                    .iter()
403                    .enumerate()
404                    .skip(1)
405                    .map(|(power, c)| *c * power as Scalar)
406                    .collect(),
407            },
408            // d/ds [m + A sin(w s + p)] = A w cos(w s + p)
409            //                           = A w sin(w s + p + pi/2)
410            // Folding the cosine back into a sine keeps the family closed under
411            // differentiation, so no new variant is needed.
412            Self::Sinusoid {
413                amplitude,
414                angular_frequency,
415                phase,
416                ..
417            } => Self::Sinusoid {
418                mean: 0.0,
419                amplitude: amplitude * angular_frequency,
420                angular_frequency: *angular_frequency,
421                phase: phase + core::f64::consts::FRAC_PI_2,
422            },
423            // Differentiation is linear, so each part differentiates in place:
424            // the polynomial drops a degree, and each harmonic keeps its
425            // frequency while gaining a factor w and a quarter-turn of phase.
426            // The result is again a Composite, so the family stays closed.
427            Self::Composite {
428                polynomial,
429                harmonics,
430            } => Self::Composite {
431                polynomial: polynomial
432                    .iter()
433                    .enumerate()
434                    .skip(1)
435                    .map(|(power, c)| *c * power as Scalar)
436                    .collect(),
437                harmonics: harmonics
438                    .iter()
439                    .map(|h| Harmonic {
440                        amplitude: h.amplitude * h.angular_frequency,
441                        angular_frequency: h.angular_frequency,
442                        phase: h.phase + core::f64::consts::FRAC_PI_2,
443                    })
444                    .collect(),
445            },
446            // Differentiate each piece in its own arc length. Seams are
447            // untouched: a piece restarts at zero at its seam, so its
448            // derivative is again a law in the same local parameter.
449            //
450            // dk/ds is generally DISCONTINUOUS at a seam. That is the correct
451            // answer, not a defect: a profile assembled from pieces jumps
452            // wherever the pieces disagree, and nothing here smooths it.
453            Self::Piecewise { breaks, laws } => Self::Piecewise {
454                breaks: breaks.clone(),
455                laws: laws.iter().map(Self::derivative).collect(),
456            },
457        }
458    }
459
460    /// The same function re-written in a coordinate that starts at `a`.
461    ///
462    /// Returns the law `g` with `g(u) = self(a + u)`. This is what a TRIM
463    /// needs: restricting a curve to `[a, b]` does not approximate the
464    /// shape, it re-anchors the same law, so the trimmed curve is exactly
465    /// the original one on that span (ADR 0062).
466    ///
467    /// The family is closed under this operation, which is why trimming is
468    /// exact rather than a refit:
469    ///
470    /// - a polynomial shifts by the binomial expansion of `(a + u)^i`;
471    /// - a sinusoid shifts purely in PHASE, `p -> p + w * a`, because the
472    ///   amplitude and frequency do not depend on where the window starts;
473    /// - a piecewise law drops the pieces that end before `a`, rebases the
474    ///   one containing `a`, and keeps the rest with their seams moved back.
475    ///
476    /// Returns `None` when `a` is not finite, or when a piecewise law is
477    /// malformed, since the shifted law would then be a guess.
478    #[must_use]
479    pub fn shifted(&self, a: Scalar) -> Option<Self> {
480        if !a.is_finite() {
481            return None;
482        }
483        match self {
484            Self::Constant { curvature } => Some(Self::Constant {
485                curvature: *curvature,
486            }),
487            Self::Polynomial { coefficients } => Some(Self::Polynomial {
488                coefficients: shift_polynomial(coefficients, a),
489            }),
490            Self::Sinusoid {
491                mean,
492                amplitude,
493                angular_frequency,
494                phase,
495            } => Some(Self::Sinusoid {
496                mean: *mean,
497                amplitude: *amplitude,
498                angular_frequency: *angular_frequency,
499                phase: phase + angular_frequency * a,
500            }),
501            Self::Composite {
502                polynomial,
503                harmonics,
504            } => Some(Self::Composite {
505                polynomial: shift_polynomial(polynomial, a),
506                harmonics: harmonics
507                    .iter()
508                    .map(|h| Harmonic {
509                        amplitude: h.amplitude,
510                        angular_frequency: h.angular_frequency,
511                        phase: h.phase + h.angular_frequency * a,
512                    })
513                    .collect(),
514            }),
515            Self::Piecewise { breaks, laws } => shift_piecewise(breaks, laws, a),
516        }
517    }
518
519    /// The mirrored law `-k(s)`: the same curve reflected.
520    #[must_use]
521    pub fn reversed_orientation(&self) -> Self {
522        match self {
523            Self::Constant { curvature } => Self::Constant {
524                curvature: -curvature,
525            },
526            Self::Polynomial { coefficients } => Self::Polynomial {
527                coefficients: coefficients.iter().map(|c| -c).collect(),
528            },
529            Self::Sinusoid {
530                mean,
531                amplitude,
532                angular_frequency,
533                phase,
534            } => Self::Sinusoid {
535                mean: -mean,
536                amplitude: -amplitude,
537                angular_frequency: *angular_frequency,
538                phase: *phase,
539            },
540            // Negation is linear too: negate every coefficient and every
541            // amplitude. Frequencies and phases are untouched, so mirroring
542            // twice returns the original law exactly.
543            Self::Composite {
544                polynomial,
545                harmonics,
546            } => Self::Composite {
547                polynomial: polynomial.iter().map(|c| -c).collect(),
548                harmonics: harmonics
549                    .iter()
550                    .map(|h| Harmonic {
551                        amplitude: -h.amplitude,
552                        angular_frequency: h.angular_frequency,
553                        phase: h.phase,
554                    })
555                    .collect(),
556            },
557            // Mirroring negates curvature everywhere, so it negates each piece
558            // in place. The seams are positions in arc length, not curvature
559            // values, so they are unchanged -- this mirrors the curve about its
560            // start tangent rather than reversing its direction of travel.
561            Self::Piecewise { breaks, laws } => Self::Piecewise {
562                breaks: breaks.clone(),
563                laws: laws.iter().map(Self::reversed_orientation).collect(),
564            },
565        }
566    }
567}
568
569/// Integral over `[0, s]` of `sum c_i x^i`, i.e. `sum c_i s^(i+1) / (i+1)`.
570/// Turning accumulated by `law` over `span` arc length from its own start.
571///
572/// Closed form for every variant, including nested pieces: each piece is
573/// integrated over its OWN subinterval and the results are added. Recursion
574/// terminates because a piece's span is strictly shorter than its parent's.
575///
576/// Returns `None` when the shape cannot define an integral: a malformed
577/// piecewise law, or seams that fall outside `[0, span]`. Reporting the
578/// refusal beats inventing a clamp the caller did not ask for.
579fn turning_over(law: &CurvatureLaw, span: Scalar) -> Option<Scalar> {
580    match law {
581        CurvatureLaw::Constant { curvature } => Some(curvature * span),
582        CurvatureLaw::Polynomial { coefficients } => Some(polynomial_turning(coefficients, span)),
583        CurvatureLaw::Sinusoid {
584            mean,
585            amplitude,
586            angular_frequency,
587            phase,
588        } => Some(
589            mean * span
590                + harmonic_turning(
591                    &Harmonic {
592                        amplitude: *amplitude,
593                        angular_frequency: *angular_frequency,
594                        phase: *phase,
595                    },
596                    span,
597                ),
598        ),
599        CurvatureLaw::Composite {
600            polynomial,
601            harmonics,
602        } => Some(
603            polynomial_turning(polynomial, span)
604                + harmonics
605                    .iter()
606                    .map(|h| harmonic_turning(h, span))
607                    .sum::<Scalar>(),
608        ),
609        CurvatureLaw::Piecewise { breaks, laws } => {
610            if !law.is_well_formed() {
611                return None;
612            }
613            // A seam at or before zero would mean the pieces do not tile the
614            // span from its start, which is a malformed law rather than a
615            // short window.
616            if breaks.iter().any(|b| *b <= 0.0) {
617                return None;
618            }
619            // Seams BEYOND the span are fine: integrating over `[0, span]`
620            // simply stops inside whichever piece contains `span`, and the
621            // later pieces are never reached. Rejecting them would make a
622            // partial evaluation of a piecewise curve impossible, which is
623            // exactly what trimming and mid-curve evaluation need.
624            let mut total = 0.0;
625            let mut start = 0.0;
626            for (index, piece) in laws.iter().enumerate() {
627                let end = breaks.get(index).copied().unwrap_or(span).min(span);
628                if end <= start {
629                    break;
630                }
631                total += turning_over(piece, end - start)?;
632                start = end;
633            }
634            Some(total)
635        }
636    }
637}
638/// Coefficients of `p(a + u)` in ascending powers of `u`.
639///
640/// Binomial expansion: the `j`-th shifted coefficient collects
641/// `c_i * C(i, j) * a^(i-j)` over every `i >= j`. Exact in the family:
642/// a degree-`n` polynomial shifts to a degree-`n` polynomial.
643fn shift_polynomial(coefficients: &[Scalar], a: Scalar) -> Vec<Scalar> {
644    let n = coefficients.len();
645    let mut out = vec![0.0; n];
646    for (i, c) in coefficients.iter().enumerate() {
647        // Pascal's triangle row `i`, built incrementally so no factorial
648        // overflows and no combinatorial function is needed.
649        let mut binomial = 1.0;
650        for (j, slot) in out.iter_mut().enumerate().take(i + 1) {
651            *slot += c * binomial * a.powi((i - j) as i32);
652            // C(i, j+1) = C(i, j) * (i - j) / (j + 1)
653            binomial = binomial * ((i - j) as Scalar) / ((j + 1) as Scalar);
654        }
655    }
656    out
657}
658
659/// Restrict a piecewise law to the window starting at `a`.
660///
661/// Pieces wholly before `a` are dropped; the piece containing `a` is
662/// rebased into its own coordinate; later pieces keep their laws and move
663/// their seams back by `a`. Each piece is already written in its own arc
664/// length, so only the piece straddling `a` is rewritten.
665fn shift_piecewise(breaks: &[Scalar], laws: &[CurvatureLaw], a: Scalar) -> Option<CurvatureLaw> {
666    if laws.len() != breaks.len() + 1 {
667        return None;
668    }
669    if breaks.windows(2).any(|w| w[1] <= w[0]) || breaks.iter().any(|b| !b.is_finite()) {
670        return None;
671    }
672    // Which piece contains `a`? Pieces are [0, b0), [b0, b1), ...
673    let index = breaks.iter().take_while(|b| **b <= a).count();
674    let piece_start = if index == 0 { 0.0 } else { breaks[index - 1] };
675    let head = laws[index].shifted(a - piece_start)?;
676    if index == breaks.len() {
677        // `a` lies in the final piece: the window is that piece alone.
678        return Some(head);
679    }
680    let mut kept = Vec::with_capacity(laws.len() - index);
681    kept.push(head);
682    kept.extend_from_slice(&laws[index + 1..]);
683    let moved: Vec<Scalar> = breaks[index..].iter().map(|b| b - a).collect();
684    Some(CurvatureLaw::Piecewise {
685        breaks: moved,
686        laws: kept,
687    })
688}
689fn polynomial_turning(coefficients: &[Scalar], s: Scalar) -> Scalar {
690    coefficients
691        .iter()
692        .enumerate()
693        .map(|(power, c)| c * s.powi(power as i32 + 1) / (power as Scalar + 1.0))
694        .sum()
695}
696
697/// Integral over `[0, s]` of `A sin(w x + p)`.
698///
699/// That antiderivative is `-(A/w)[cos(w s + p) - cos(p)]`, which is undefined
700/// at `w == 0`. The integrand is then the constant `A sin(p)`, so the integral
701/// is `A sin(p) s` -- the honest limit, not a special case invented to avoid a
702/// division.
703fn harmonic_turning(harmonic: &Harmonic, s: Scalar) -> Scalar {
704    let Harmonic {
705        amplitude,
706        angular_frequency,
707        phase,
708    } = *harmonic;
709    if angular_frequency == 0.0 {
710        return amplitude * phase.sin() * s;
711    }
712    -(amplitude / angular_frequency) * ((angular_frequency * s + phase).cos() - phase.cos())
713}
714/// A plane curve given by its natural equation: a curvature law anchored to a
715/// start frame and run for a finite arc length.
716///
717/// The frame supplies the rigid motion that k(s) alone cannot: its origin is the
718/// curve start, and its `x` axis is the start tangent direction.
719///
720/// Like every other value in this crate, dirty imported data stays
721/// representable -- a non-positive length is storable, and naming it is the
722/// job of a validator, not of the type.
723#[derive(Debug, Clone, PartialEq)]
724pub struct Intrinsic2 {
725    /// Start frame: origin at the curve start, `x` along the start tangent.
726    pub start: Frame2,
727    /// Curvature as a function of arc length from `start`.
728    pub curvature: CurvatureLaw,
729    /// Arc length the law is defined over.
730    pub length: Scalar,
731}
732
733impl Intrinsic2 {
734    /// Anchor a curvature law to a start frame over an arc length.
735    #[must_use]
736    pub const fn new(start: Frame2, curvature: CurvatureLaw, length: Scalar) -> Self {
737        Self {
738            start,
739            curvature,
740            length,
741        }
742    }
743
744    /// Whether the curve is a straight segment.
745    #[must_use]
746    pub fn is_straight(&self) -> bool {
747        self.curvature.is_straight()
748    }
749
750    /// Tangent heading at arc length `s` from the start, in radians.
751    ///
752    /// The integral of `k` over `[0, s]`, which every law in the family has in
753    /// closed form -- so the heading is exact even though the POSITION is not
754    /// and has to be quadratured. Measured from the start tangent, so the
755    /// absolute heading is this plus the start frame's rotation.
756    ///
757    /// Returns `None` when `s` is not finite or a piecewise law does not tile
758    /// `[0, s]`, matching [`Self::total_turning`].
759    #[must_use]
760    pub fn heading_at(&self, s: Scalar) -> Option<Scalar> {
761        if !s.is_finite() {
762            return None;
763        }
764        turning_over(&self.curvature, s)
765    }
766
767    /// Total tangent turning over the curve, in radians, in closed form.
768    ///
769    /// This is the integral of k(s) over `[0, length]` -- exact for every law in
770    /// the family, because each one has an elementary antiderivative. It is the
771    /// *angle* that integrates in closed form; position does not, which is why
772    /// this method exists and an `evaluate` does not.
773    ///
774    /// Returns `None` when the length is not finite, since the integral is then
775    /// undefined rather than merely large.
776    #[must_use]
777    pub fn total_turning(&self) -> Option<Scalar> {
778        if !self.length.is_finite() {
779            return None;
780        }
781        // Over the DECLARED length, the pieces must tile the whole domain: a
782        // seam at or beyond the end means the stored law is not the one being
783        // asked about, so refuse rather than clamp. This is stricter than
784        // `heading_at`, which asks for a partial span and legitimately stops
785        // inside whichever piece contains the sample.
786        if let CurvatureLaw::Piecewise { breaks, .. } = &self.curvature {
787            if breaks.iter().any(|b| *b >= self.length) {
788                return None;
789            }
790        }
791        turning_over(&self.curvature, self.length)
792    }
793
794    /// Upper bound on the total variation of heading over `[0, s]`, in radians.
795    ///
796    /// `total_turning` is the SIGNED integral of `k`; over a zero-mean
797    /// oscillation it is zero however violently the curve wiggles. A quadrature
798    /// budget derived from it would then spend one panel on a curve that needs
799    /// many, so this returns the integral of `|k|` instead -- or a bound on it.
800    ///
801    /// Exact for `Constant`. For the other families an exact total variation
802    /// needs the sign changes of `k`, so this returns a cheap upper bound from
803    /// the triangle inequality: a bound is what a panel budget wants, and
804    /// overestimating costs panels while underestimating costs correctness.
805    ///
806    /// `None` when `s` is not finite, matching `heading_at`.
807    #[must_use]
808    pub fn turning_variation_bound(&self, s: Scalar) -> Option<Scalar> {
809        if !s.is_finite() {
810            return None;
811        }
812        variation_bound(&self.curvature, s)
813    }
814}
815
816/// Upper bound on the integral of `|k|` over `[0, span]`.
817fn variation_bound(law: &CurvatureLaw, span: Scalar) -> Option<Scalar> {
818    let span_abs = span.abs();
819    match law {
820        // Exact: |k| is constant, so the integral is just |k| * span.
821        CurvatureLaw::Constant { curvature } => Some(curvature.abs() * span_abs),
822        // sum |c_i| s^(i+1) / (i+1) >= |int sum c_i s^i|, term by term.
823        CurvatureLaw::Polynomial { coefficients } => {
824            Some(polynomial_variation(coefficients, span_abs))
825        }
826        // |mean| * span + |amplitude| * span bounds both parts; the harmonic
827        // term integrates to at most its amplitude times the span.
828        CurvatureLaw::Sinusoid {
829            mean, amplitude, ..
830        } => Some((mean.abs() + amplitude.abs()) * span_abs),
831        CurvatureLaw::Composite {
832            polynomial,
833            harmonics,
834        } => Some(
835            polynomial_variation(polynomial, span_abs)
836                + harmonics
837                    .iter()
838                    .map(|h| h.amplitude.abs() * span_abs)
839                    .sum::<Scalar>(),
840        ),
841        // Pieces tile the span, so their variations add. Each piece is written
842        // in its own arc length, exactly as `turning_over` treats them.
843        CurvatureLaw::Piecewise { breaks, laws } => {
844            if !law.is_well_formed() {
845                return None;
846            }
847            if breaks.iter().any(|b| *b <= 0.0) {
848                return None;
849            }
850            // As in `turning_over`: a seam past the span just means the later
851            // pieces are not reached by this window.
852            let mut total = 0.0;
853            let mut start = 0.0;
854            for (index, piece) in laws.iter().enumerate() {
855                let end = breaks.get(index).copied().unwrap_or(span_abs).min(span_abs);
856                if end <= start {
857                    break;
858                }
859                total += variation_bound(piece, end - start)?;
860                start = end;
861            }
862            Some(total)
863        }
864    }
865}
866
867/// Term-by-term bound on the integral of |polynomial| over `[0, span]`.
868fn polynomial_variation(coefficients: &[Scalar], span: Scalar) -> Scalar {
869    coefficients
870        .iter()
871        .enumerate()
872        .map(|(power, c)| c.abs() * span.powi(power as i32 + 1) / (power as Scalar + 1.0))
873        .sum()
874}