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}