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}