Skip to main content

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