copp\path/smoothing.rs
1//! Tolerance-bounded waypoint fitting with adaptive nonuniform quintic B-splines.
2//!
3//! Most users enter this module through
4//! [`Path::from_waypoints_fitting`](crate::path::Path::from_waypoints_fitting) or
5//! its borrowed-view companion
6//! [`Path::from_waypoints_fitting_view`](crate::path::Path::from_waypoints_fitting_view).
7//! The constructor is intended for noisy samples and programmed polylines; its
8//! fitted curve may deviate from interior waypoints within explicit per-axis
9//! tolerances. Use
10//! [`Path::from_waypoints_interpolating`](crate::path::Path::from_waypoints_interpolating)
11//! instead when every waypoint must be interpolated exactly.
12//!
13//! # Geometric contract
14//!
15//! Let `p_i` be the input columns and let `s_i` be their common parameters. On
16//! `s in [s_i, s_{i+1}]`, the reference is the linearly interpolated polyline
17//!
18//! $$
19//! r(s) = (1 - \theta) p_i + \theta p_{i+1}, \qquad
20//! \theta = \frac{s-s_i}{s_{i+1}-s_i}.
21//! $$
22//!
23//! For every selected axis `j`, a successful construction satisfies the
24//! numerically audited bound
25//!
26//! $$
27//! \max_s |q_j(s)-r_j(s)| \le \varepsilon_j.
28//! $$
29//!
30//! This is a same-parameter, per-axis absolute-error box. It is not a nearest
31//! distance to the polyline, a Cartesian/FK error, or a sum-of-squares bound.
32//! Coordinates and tolerances retain the caller's units; angle unwrapping and
33//! unit conversion remain the caller's responsibility.
34//!
35//! # Method
36//!
37//! 1. Normalize the parameter range to `[0, 1]`, subtract the first selected
38//! coordinate, and divide each selected axis by its tolerance.
39//! 2. Accept a single straight span when the endpoint chord already lies in the
40//! tolerance tube, otherwise seed nonuniform knots with a linear-time slope
41//! corridor heuristic.
42//! 3. Fit one open degree-five B-spline. Simple interior knots give `C4`
43//! continuity, while clamped endpoint control points preserve endpoint
44//! positions.
45//! 4. Minimize a data term plus a span-scaled third-parameter-derivative
46//! penalty. Dense knot spans use spacing-weighted sample rows; sparse spans
47//! integrate against the complete reference polyline with Gauss quadrature.
48//! 5. Convert the error on every reference/spline intersection to Bernstein
49//! form. The largest absolute Bernstein coefficient is a sufficient bound
50//! over the whole intersection, including extrema between input points.
51//! 6. Insert knots near failed bounds, refit only overlapping control-point
52//! windows, and repeat until the audit passes or a configured budget is
53//! exhausted.
54//! 7. Restore physical units and perform one final full-domain audit before
55//! returning the path.
56//!
57//! The least-squares solve itself does **not** contain hard tolerance
58//! constraints. Instead, the audit/refinement loop rejects every detected
59//! violation or numerical failure; there is no unchecked fallback path. As
60//! detailed below, the ordinary-`f64` audit is not a formal proof certificate.
61//!
62//! # Continuity and derivatives
63//!
64//! Selected axes share one knot vector and form a quintic `C4` path. Their
65//! endpoint positions are fixed, but endpoint derivatives are determined by the
66//! fit. Unselected axes are represented by a separate quintic `C4` interpolant
67//! through every input waypoint, with zero first and second parameter
68//! derivatives at both endpoints.
69//!
70//! All reported derivatives are with respect to the path parameter `s`, not
71//! time. In particular, penalizing `q'''(s)` is a geometric smoothing strategy;
72//! it does not by itself minimize or bound physical jerk after time
73//! parameterization.
74//!
75//! # Numerical properties
76//!
77//! Degree-five local support gives the normal matrix five nonzero subdiagonals.
78//! A diagonally scaled banded Cholesky factorization is reused across selected
79//! axes, avoiding a dense `M x M` normal matrix. With `N` input points, `M`
80//! controls, and `d` selected axes, band storage and factorization are `O(M)` and
81//! the multiple right-hand sides are `O(M d)` for this fixed degree.
82//! Assembly/auditing otherwise traverses the relevant samples and spans in
83//! approximately `O((N + M) d)` logical work, with logarithmic factors from the
84//! implementation's parameter/span searches.
85//!
86//! The complete adaptive procedure is not globally `O(N)`: repeated knot
87//! insertion, local reassembly, and interval audits depend on the requested
88//! tolerance and refinement history. The compact solver uses normal equations,
89//! which can square the original least-squares condition number; there is no
90//! dense QR/SVD fallback when the banded factorization becomes unsupported.
91//!
92//! Bernstein bounds are computed with ordinary floating-point arithmetic, not
93//! outward-rounded interval arithmetic. They are conservative polynomial bounds
94//! in exact arithmetic, but the report is a numerical audit rather than a
95//! formal proof certificate.
96
97use super::OutOfRangeMode;
98use crate::diag::PathError;
99use nalgebra::DMatrixView;
100
101// --- Public configuration and diagnostics -----------------------------------
102
103/// Absolute deviation limits for the axes selected for smoothing.
104///
105/// Tolerances are expressed in the corresponding input-coordinate units and
106/// must be finite and strictly positive. A scalar tolerance is broadcast to all
107/// selected axes; a vector tolerance follows the exact order of
108/// [`SmoothingConfig::axes`].
109///
110/// If `axes` is `None`, the selected order is `0..waypoints.nrows()`. Therefore
111/// a [`SmoothingTolerance::PerAxis`] vector then follows the natural matrix-row
112/// order.
113#[derive(Clone, Debug)]
114pub enum SmoothingTolerance {
115 /// Broadcast one absolute tolerance to every selected axis.
116 ///
117 /// For example, `SmoothingTolerance::Uniform(1e-3)` permits a deviation of
118 /// `1e-3` in each selected axis's own unit.
119 Uniform(f64),
120 /// Store one absolute tolerance per selected axis.
121 ///
122 /// The vector length must equal the number of selected axes. With
123 /// `axes = Some(vec![4, 0])`, entries `0` and `1` apply to input rows `4`
124 /// and `0`, respectively.
125 PerAxis(Vec<f64>),
126}
127
128impl From<f64> for SmoothingTolerance {
129 fn from(value: f64) -> Self {
130 Self::Uniform(value)
131 }
132}
133
134/// Configuration for tolerance-bounded waypoint fitting.
135///
136/// Error is measured against the input polyline at the same parameter over the
137/// complete parameter domain, not only at waypoint columns. No kinematics,
138/// angle unwrapping, nearest-point search, or unit conversion is performed.
139///
140/// # Defaults
141///
142/// - smooth every input row;
143/// - use absolute tolerance `0.001` on each selected row;
144/// - assign waypoint parameters uniformly on `[0, 1]`;
145/// - allow at most 20 local refinement passes and 20,000 selected-axis spans;
146/// - reject queries outside the parameter domain.
147///
148/// # Example
149///
150/// This example smooths translational rows `0..=2` to `1e-3` input units and
151/// rotary rows `3..=4` to `1e-2` input units. Tolerance entries follow `axes`
152/// order.
153///
154/// ```rust
155/// use copp::path::{Path, SmoothingConfig, SmoothingTolerance};
156/// use nalgebra::DMatrix;
157///
158/// # fn main() -> Result<(), copp::diag::PathError> {
159/// let waypoints = DMatrix::from_fn(5, 4, |axis, point| {
160/// axis as f64 * 0.1 + point as f64
161/// });
162/// let config = SmoothingConfig {
163/// axes: Some(vec![0, 1, 2, 3, 4]),
164/// tolerance: SmoothingTolerance::PerAxis(vec![
165/// 0.001, 0.001, 0.001, 0.01, 0.01,
166/// ]),
167/// ..Default::default()
168/// };
169///
170/// let path = Path::from_waypoints_fitting(&waypoints, config)?;
171/// assert_eq!(path.smoothing_report().unwrap().axes, vec![0, 1, 2, 3, 4]);
172/// # Ok(())
173/// # }
174/// ```
175#[derive(Clone, Debug)]
176pub struct SmoothingConfig {
177 /// Maximum absolute deviation from the reference polyline.
178 ///
179 /// The default is [`SmoothingTolerance::Uniform`]`(0.001)`. Every value is
180 /// interpreted in the corresponding input row's unit.
181 pub tolerance: SmoothingTolerance,
182 /// Input rows allowed to deviate from their reference polylines.
183 ///
184 /// `None` selects every row. An explicit vector must be nonempty and contain
185 /// distinct, in-range row indices. Its order also defines the order of
186 /// [`SmoothingTolerance::PerAxis`] values and [`SmoothingReport::max_errors`].
187 ///
188 /// Unselected rows retain quintic `C4` interpolation through all input
189 /// columns, with zero first and second parameter derivatives at both ends.
190 pub axes: Option<Vec<usize>>,
191 /// Common parameter assigned to each input column.
192 ///
193 /// An explicit vector must have `waypoints.ncols()` finite, strictly
194 /// increasing entries. Its first and last entries become the public path
195 /// range. `None` assigns columns uniformly on `[0, 1]`.
196 ///
197 /// Relative parameter spacing affects the reference correspondence, data
198 /// weights, smoothing penalty, and fitted geometry; it is not merely output
199 /// metadata. A positive affine rescaling leaves the internally normalized
200 /// geometry unchanged but rescales derivatives returned with respect to `s`.
201 pub parameters: Option<Vec<f64>>,
202 /// Maximum number of adaptive knot-refinement passes.
203 ///
204 /// The default is 20. Reaching this limit before the continuous interval
205 /// audit passes returns [`PathError::Smoothing`](crate::diag::PathError::Smoothing).
206 pub max_refinements: usize,
207 /// Maximum number of polynomial spans used by the selected axes.
208 ///
209 /// The default is 20,000. Spans belonging to the separate unselected-axis
210 /// interpolant are reported independently and do not consume this budget.
211 pub max_segments: usize,
212 /// Behavior when evaluating outside the configured parameter domain.
213 ///
214 /// The default is [`OutOfRangeMode::Error`]. Clamping affects later queries,
215 /// not fitting or auditing.
216 pub out_of_range_mode: OutOfRangeMode,
217}
218
219impl Default for SmoothingConfig {
220 fn default() -> Self {
221 Self {
222 tolerance: 0.001.into(),
223 axes: None,
224 parameters: None,
225 max_refinements: 20,
226 max_segments: 20_000,
227 out_of_range_mode: OutOfRangeMode::Error,
228 }
229 }
230}
231
232/// Diagnostics recorded while constructing a smoothed waypoint path.
233///
234/// Obtain this report with [`Path::smoothing_report`](crate::path::Path::smoothing_report).
235/// `axes`, `segments`, and `interpolated_segments` describe the final
236/// representation. `refinements` records completed passes, while `fitting_rows`
237/// and `checked_intervals` accumulate work from the initial construction and
238/// subsequent local refinement. These values help explain tolerance cost and
239/// budget usage; they are not optimality or run-time guarantees.
240///
241/// In exact arithmetic, the Bernstein convex-hull property supplies a sufficient
242/// whole-interval bound. `max_errors` records the corresponding values computed
243/// in physical input units with ordinary floating-point arithmetic rather than
244/// outward-rounded interval arithmetic; they are numerical audit results, not
245/// formal certificates or Cartesian/FK errors.
246#[derive(Clone, Debug)]
247pub struct SmoothingReport {
248 /// Selected input-row indices, in tolerance/report order.
249 pub axes: Vec<usize>,
250 /// Number of final polynomial spans shared by the selected axes.
251 ///
252 /// This count is not guaranteed to be minimal or monotone as tolerance
253 /// changes because initial knot placement and refinement are adaptive.
254 pub segments: usize,
255 /// Number of spans in the separate unselected-axis interpolant.
256 ///
257 /// This is zero when every axis is selected and is not included in
258 /// [`SmoothingReport::segments`] or the configured segment budget.
259 pub interpolated_segments: usize,
260 /// Number of completed local knot-refinement passes.
261 pub refinements: usize,
262 /// Number of discrete or quadrature fitting rows assembled.
263 ///
264 /// Rows assembled again during local refits are counted again; this is a
265 /// work statistic, not the number of distinct input points. Quadrature rows
266 /// used only for the third-derivative penalty are not included.
267 pub fitting_rows: usize,
268 /// Number of reference-polyline intervals visited by numerical audits.
269 ///
270 /// Revisited intervals and the final physical-unit full audit are included.
271 pub checked_intervals: usize,
272 /// Final whole-domain numerical absolute-error bound for each selected axis.
273 ///
274 /// Values use physical input units and follow [`SmoothingReport::axes`]
275 /// order. They are Bernstein-derived numerical bounds, not sampled maxima
276 /// or outward-rounded certificates.
277 pub max_errors: Vec<f64>,
278}
279
280// --- Shared numerical primitives --------------------------------------------
281
282/// Convert any construction-stage failure into the single public smoothing
283/// error variant. Callers either receive a fully audited path or this error;
284/// there is deliberately no unchecked fallback representation.
285fn fail(message: impl Into<String>) -> PathError {
286 PathError::Smoothing {
287 message: message.into(),
288 }
289}
290
291/// Fixed B-spline degree used by the fitting and unselected-axis interpolants.
292const P: usize = 5;
293
294/// Factorials `0!` through `5!`, used for derivatives and binomial factors.
295const FACT: [f64; 6] = [1., 1., 2., 6., 24., 120.];
296
297/// Return the binomial coefficient `n choose k` for degrees up to five.
298fn choose(n: usize, k: usize) -> f64 {
299 FACT[n] / (FACT[k] * FACT[n - k])
300}
301
302// --- Quintic B-spline representation ----------------------------------------
303
304/// Open nonuniform degree-five B-spline shared by `d` coordinate axes.
305///
306/// The knot vector `t` contains six repeated knots at both endpoints and only
307/// simple interior knots. Consequently the represented path interpolates its
308/// first/last control points and is `C4` across every interior knot.
309///
310/// Control points use control-major, axis-minor layout:
311/// `c[control * d + axis]`. All selected axes share `t`, which preserves their
312/// common parameterization and permits one normal matrix to serve every axis.
313#[derive(Clone)]
314struct BSpline {
315 /// Nondecreasing open knot vector on the normalized domain `[0, 1]`.
316 t: Vec<f64>,
317 /// Flat control-point coordinates in control-major, axis-minor order.
318 c: Vec<f64>,
319 /// Number of coordinate axes stored per control point.
320 d: usize,
321}
322
323impl BSpline {
324 /// Return the number of control points.
325 fn m(&self) -> usize {
326 self.c.len() / self.d
327 }
328
329 /// Locate the knot span containing `x`.
330 ///
331 /// Endpoint queries are clamped to the first/last evaluable span. Callers
332 /// validate the public parameter range before reaching this kernel.
333 fn span(&self, x: f64) -> usize {
334 self.t
335 .partition_point(|v| *v <= x)
336 .saturating_sub(1)
337 .clamp(P, self.m() - 1)
338 }
339
340 /// Evaluate the six nonzero B-spline basis functions at `x`.
341 ///
342 /// Returns the global index of the first active control point together with
343 /// basis values `B[start..start+6](x)`. The triangular recurrence is the
344 /// allocation-free degree-five Cox--de Boor basis algorithm.
345 fn values(&self, x: f64) -> (usize, [f64; 6]) {
346 let span = self.span(x);
347 let mut b = [0.; 6];
348 b[0] = 1.;
349 let mut left = [0.; 6];
350 let mut right = [0.; 6];
351 for j in 1..=P {
352 left[j] = x - self.t[span + 1 - j];
353 right[j] = self.t[span + j] - x;
354 let mut saved = 0.;
355 for r in 0..j {
356 let v = b[r] / (right[r + 1] + left[j - r]);
357 b[r] = saved + right[r + 1] * v;
358 saved = left[j - r] * v;
359 }
360 b[j] = saved;
361 }
362 (span - P, b)
363 }
364
365 /// Expand the six active basis functions into Taylor coefficients at `x`.
366 ///
367 /// `returned[i][r]` is the coefficient of `(xi-x)^r` for active basis `i`;
368 /// multiplying it by `r!` yields the `r`-th derivative at `x`. Keeping the
369 /// complete degree-five expansion on the stack supports exact span-to-power
370 /// conversion and derivative quadrature without finite differences.
371 fn powers(&self, span: usize, x: f64) -> [[f64; 6]; 6] {
372 let mut b = [[0.; 6]; 6];
373 b[0][0] = 1.;
374 for j in 1..=P {
375 let mut saved = [0.; 6];
376 for (r, row) in b.iter_mut().enumerate().take(j) {
377 let left = x - self.t[span + 1 - j + r];
378 let right = self.t[span + r + 1] - x;
379 let den = left + right;
380 let mut next = [0.; 6];
381 let mut upper = [0.; 6];
382 for k in 0..=j {
383 let v = row[k] / den;
384 let prev = if k > 0 { row[k - 1] / den } else { 0. };
385 next[k] = saved[k] + right * v - prev;
386 upper[k] = left * v + prev;
387 }
388 *row = next;
389 saved = upper;
390 }
391 b[j] = saved;
392 }
393 b
394 }
395
396 /// Insert one simple knot without changing the represented curve.
397 ///
398 /// The control-point update is the standard local B-spline knot-insertion
399 /// recurrence. It adds one degree of freedom for the following refit while
400 /// preserving the pre-insertion geometry and `C4` continuity.
401 fn insert(&mut self, x: f64) {
402 let k = self.span(x);
403 let m = self.m();
404 let mut q = vec![0.; (m + 1) * self.d];
405 q[..(k - P + 1) * self.d].copy_from_slice(&self.c[..(k - P + 1) * self.d]);
406 q[(k + 1) * self.d..].copy_from_slice(&self.c[k * self.d..]);
407 for i in k - P + 1..=k {
408 let a = (x - self.t[i]) / (self.t[i + P] - self.t[i]);
409 for j in 0..self.d {
410 q[i * self.d + j] =
411 (1. - a) * self.c[(i - 1) * self.d + j] + a * self.c[i * self.d + j];
412 }
413 }
414 self.t.insert(k + 1, x);
415 self.c = q;
416 }
417
418 /// Convert all nonempty knot spans into local quintic power polynomials.
419 ///
420 /// For span `ell`, coefficients are stored for the normalized local
421 /// coordinate `u = (x-breaks[ell]) / h`. The output layout is span-major,
422 /// axis-minor and is optimized for audit and query-time Horner evaluation.
423 fn polynomial(&self) -> Polynomial {
424 let mut breaks = Vec::new();
425 let mut coefficients = Vec::new();
426 for span in P..self.m() {
427 let a = self.t[span];
428 let h = self.t[span + 1] - a;
429 if h <= 0. {
430 continue;
431 }
432 breaks.push(a);
433 let basis = self.powers(span, a);
434 for axis in 0..self.d {
435 let mut p = [0.; 6];
436 for (r, row) in basis.iter().enumerate() {
437 for k in 0..6 {
438 p[k] += row[k] * self.c[(span - P + r) * self.d + axis];
439 }
440 }
441 let mut scale = 1.;
442 for v in &mut p {
443 *v *= scale;
444 scale *= h;
445 }
446 coefficients.push(p);
447 }
448 }
449 breaks.push(1.);
450 Polynomial {
451 breaks,
452 coefficients,
453 d: self.d,
454 }
455 }
456}
457
458// --- Banded normal-equation solver ------------------------------------------
459
460/// Active block of a symmetric normal system with five stored subdiagonals.
461///
462/// For active global controls `[a, z)`, local row `i` stores the lower triangle
463/// as `h[i][k] = H[i, i-k]`, `k=0..=5`. The B-spline basis is common to all
464/// coordinates, so the matrix is shared while `rhs` contains `d` interleaved
465/// right-hand sides.
466///
467/// `add` assembles normal-equation increments around the current control vector;
468/// `solve` applies diagonal equilibration followed by an in-place banded
469/// Cholesky factorization and forward/back substitution.
470struct Normal {
471 /// Lower symmetric band, from the diagonal through the fifth subdiagonal.
472 h: Vec<[f64; 6]>,
473 /// Interleaved right-hand sides in active-control-major order.
474 rhs: Vec<f64>,
475 /// Global index of the first active control point.
476 a: usize,
477 /// Number of coordinate right-hand sides.
478 d: usize,
479}
480
481impl Normal {
482 /// Allocate the active-control system for the half-open range `[a, z)`.
483 fn new(a: usize, z: usize, d: usize) -> Self {
484 Self {
485 h: vec![[0.; 6]; z - a],
486 rhs: vec![0.; (z - a) * d],
487 a,
488 d,
489 }
490 }
491
492 /// Add one weighted least-squares row to the normal system.
493 ///
494 /// `b` contains six consecutive basis/derivative values beginning at
495 /// `start`, `target` contains one desired value per coordinate, and `c` is
496 /// the current global control vector. Controls outside this system's active
497 /// range remain frozen: they contribute to the residual but receive no
498 /// matrix or right-hand-side update.
499 fn add(&mut self, start: usize, b: &[f64; 6], target: &[f64], weight: f64, c: &[f64]) {
500 for i in 0..6 {
501 let global = start + i;
502 if global < self.a || global >= self.a + self.h.len() {
503 continue;
504 }
505 let row = global - self.a;
506 for j in 0..=i {
507 let col = start + j;
508 if col >= self.a {
509 self.h[row][i - j] += weight * b[i] * b[j];
510 }
511 }
512 }
513 for axis in 0..self.d {
514 let value = (0..6)
515 .map(|j| b[j] * c[(start + j) * self.d + axis])
516 .sum::<f64>();
517 let residual = weight * (target[axis] - value);
518 for (i, value) in b.iter().enumerate() {
519 let global = start + i;
520 if global >= self.a && global < self.a + self.h.len() {
521 self.rhs[(global - self.a) * self.d + axis] += value * residual;
522 }
523 }
524 }
525 }
526
527 /// Solve the equilibrated symmetric positive-definite band system.
528 ///
529 /// Returns active control **increments**, interleaved by coordinate. A zero
530 /// or non-finite diagonal, loss of positive definiteness, or non-finite
531 /// solution is reported as [`PathError::Smoothing`] rather than falling back
532 /// to a dense or unchecked solve.
533 fn solve(mut self) -> Result<Vec<f64>, PathError> {
534 let n = self.h.len();
535 let scales: Vec<_> = self.h.iter().map(|r| r[0].sqrt()).collect();
536 if scales.iter().any(|s| !s.is_finite() || *s <= 0.) {
537 return Err(fail("unsupported knot layout"));
538 }
539 for i in 0..n {
540 for k in 0..=P.min(i) {
541 self.h[i][k] /= scales[i] * scales[i - k];
542 }
543 for axis in 0..self.d {
544 self.rhs[i * self.d + axis] /= scales[i];
545 }
546 }
547 for i in 0..n {
548 for j in i.saturating_sub(P)..=i {
549 let mut v = self.h[i][i - j];
550 for k in i.saturating_sub(P)..j {
551 v -= self.h[i][i - k] * self.h[j][j - k];
552 }
553 if i == j {
554 if !v.is_finite() || v <= 0. {
555 return Err(fail("non-positive banded factorization"));
556 }
557 self.h[i][0] = v.sqrt();
558 } else {
559 self.h[i][i - j] = v / self.h[j][0];
560 }
561 }
562 }
563 for i in 0..n {
564 for axis in 0..self.d {
565 for k in 1..=P.min(i) {
566 self.rhs[i * self.d + axis] -= self.h[i][k] * self.rhs[(i - k) * self.d + axis];
567 }
568 self.rhs[i * self.d + axis] /= self.h[i][0];
569 }
570 }
571 for i in (0..n).rev() {
572 for axis in 0..self.d {
573 for j in i + 1..(i + P + 1).min(n) {
574 self.rhs[i * self.d + axis] -= self.h[j][j - i] * self.rhs[j * self.d + axis];
575 }
576 self.rhs[i * self.d + axis] /= self.h[i][0];
577 }
578 }
579 for (i, scale) in scales.iter().enumerate() {
580 for axis in 0..self.d {
581 self.rhs[i * self.d + axis] /= scale;
582 }
583 }
584 if self.rhs.iter().any(|v| !v.is_finite()) {
585 return Err(fail("non-finite coefficients"));
586 }
587 Ok(self.rhs)
588 }
589}
590
591/// Six-point Gauss--Legendre nodes mapped from `[-1, 1]` to `[0, 1]`.
592///
593/// Six points integrate polynomials through degree eleven exactly. On sparse
594/// spans this covers the squared difference between a quintic and a line,
595/// whose degree is at most ten.
596const GX: [f64; 6] = [
597 0.033765242898423975,
598 0.16939530676686776,
599 0.3806904069584015,
600 0.6193095930415985,
601 0.8306046932331322,
602 0.966234757101576,
603];
604
605/// Weights paired with [`GX`] on `[0, 1]`.
606const GW: [f64; 6] = [
607 0.08566224618958517,
608 0.1803807865240693,
609 0.2339569672863455,
610 0.2339569672863455,
611 0.1803807865240693,
612 0.08566224618958517,
613];
614
615/// Fit an active control-point window while freezing all other controls.
616///
617/// For the normalized selected-axis curve `q_hat`, this assembles the
618/// incremental normal equations for
619///
620/// $$
621/// E(C) = E_{data}(C)
622/// + \lambda \sum_l h_l^6
623/// \int_{t_l}^{t_{l+1}} \lVert q_hat'''(x) \rVert_2^2\,dx.
624/// $$
625///
626/// The data term is selected independently for every nonempty knot span:
627///
628/// - spans containing at least eight waypoint samples use trapezoidal
629/// parameter-spacing weights at those samples;
630/// - sparse spans are split at reference-polyline vertices and use six-point
631/// Gauss--Legendre quadrature over every intersection, so long line segments
632/// still influence the fit even when they contain no interior samples.
633///
634/// The third-derivative penalty uses three-point Gauss--Legendre quadrature,
635/// exact for its degree-four squared integrand. The `h_l^6` strategy balances
636/// differently sized local spans; it is not a global time-domain minimum-jerk
637/// objective.
638///
639/// # Arguments
640///
641/// - `b`: complete spline and current control vector;
642/// - `s`, `y`: normalized parameters and point-major selected coordinates;
643/// - `a`, `z`: half-open global control range `[a, z)` allowed to change;
644/// - `strength`: current value of the derivative-penalty weight `lambda`.
645///
646/// # Returns
647///
648/// `(first, last, rows)` identifies the half-open waypoint range touched by the
649/// active support and the number of discrete/quadrature rows assembled. The
650/// range lets the refinement loop conservatively re-audit neighboring reference
651/// intervals.
652fn fit(
653 b: &mut BSpline,
654 s: &[f64],
655 y: &[f64],
656 a: usize,
657 z: usize,
658 strength: f64,
659) -> Result<(usize, usize, usize), PathError> {
660 let lo = b.t[a];
661 let hi = b.t[z + P];
662 let first = s.partition_point(|v| *v < lo);
663 let last = s.partition_point(|v| *v <= hi);
664 let mut normal = Normal::new(a, z, b.d);
665 let mut rows = 0;
666 // Density is local to each knot span: a globally dense trajectory can still
667 // acquire a short refined span with no input vertices inside it.
668 let mut dense = vec![false; b.m()];
669 for (span, populated) in dense.iter_mut().enumerate().skip(P) {
670 let begin = s.partition_point(|v| *v < b.t[span]);
671 let end = s.partition_point(|v| *v < b.t[span + 1]);
672 *populated = end - begin >= 8;
673 }
674 // Dense spans use sample rows with trapezoidal parameter-measure weights.
675 for i in first..last {
676 if !dense[b.span(s[i])] {
677 continue;
678 }
679 let weight = if i == 0 {
680 (s[1] - s[0]) / 2.
681 } else if i + 1 == s.len() {
682 (s[i] - s[i - 1]) / 2.
683 } else {
684 (s[i + 1] - s[i - 1]) / 2.
685 };
686 let (start, basis) = b.values(s[i]);
687 normal.add(start, &basis, &y[i * b.d..(i + 1) * b.d], weight, &b.c);
688 rows += 1;
689 }
690 // Sparse spans are split wherever the piecewise-linear reference changes.
691 // Six quadrature rows per intersection integrate the normal terms exactly.
692 let mut target = vec![0.; b.d];
693 for (span, populated) in dense.iter().enumerate().skip(P) {
694 let left = b.t[span];
695 let right = b.t[span + 1];
696 if *populated || right <= left || left >= hi || right <= lo {
697 continue;
698 }
699 let begin = s.partition_point(|v| *v <= left);
700 let end = s.partition_point(|v| *v < right);
701 let mut mesh = Vec::with_capacity(end - begin + 2);
702 mesh.push(left);
703 mesh.extend_from_slice(&s[begin..end]);
704 mesh.push(right);
705 for pair in mesh.windows(2) {
706 let h = pair[1] - pair[0];
707 for r in 0..6 {
708 let x = pair[0] + GX[r] * h;
709 let i = s
710 .partition_point(|v| *v <= x)
711 .saturating_sub(1)
712 .min(s.len() - 2);
713 let f = (x - s[i]) / (s[i + 1] - s[i]);
714 for axis in 0..b.d {
715 target[axis] =
716 y[i * b.d + axis] + f * (y[(i + 1) * b.d + axis] - y[i * b.d + axis]);
717 }
718 let (start, basis) = b.values(x);
719 normal.add(start, &basis, &target, h * GW[r], &b.c);
720 rows += 1;
721 }
722 }
723 }
724 // Three Gauss--Legendre nodes integrate the squared quadratic q''' basis.
725 // `h^7 = h^6 * h`: the final factor is the quadrature change of variables.
726 let zeros = vec![0.; b.d];
727 for span in P..b.m() {
728 let left = b.t[span];
729 let h = b.t[span + 1] - left;
730 if h <= 0. || left >= hi || b.t[span + 1] <= lo {
731 continue;
732 }
733 for (x, w) in [
734 (0.1127016653792583, 5. / 18.),
735 (0.5, 4. / 9.),
736 (0.8872983346207417, 5. / 18.),
737 ] {
738 let power = b.powers(span, left + x * h);
739 let basis = std::array::from_fn(|i| 6. * power[i][3]);
740 normal.add(span - P, &basis, &zeros, strength * h.powi(7) * w, &b.c);
741 }
742 }
743 let delta = normal.solve()?;
744 for (i, v) in delta.iter().enumerate() {
745 b.c[a * b.d + i] += v;
746 }
747 Ok((first, last, rows))
748}
749
750// --- Piecewise-polynomial evaluation and auditing ---------------------------
751
752/// Piecewise quintic power representation on normalized parameter `[0, 1]`.
753///
754/// `breaks` has one more entry than the number of spans. Coefficients use
755/// span-major, axis-minor layout; each `[f64; 6]` stores ascending powers of
756/// `u = (x-breaks[span]) / (breaks[span+1]-breaks[span])`.
757#[derive(Clone)]
758struct Polynomial {
759 /// Strictly increasing nonempty-span boundaries.
760 breaks: Vec<f64>,
761 /// Local ascending-power coefficients, grouped by span then axis.
762 coefficients: Vec<[f64; 6]>,
763 /// Number of stored coordinate axes per span.
764 d: usize,
765}
766
767impl Polynomial {
768 /// Return the number of nonempty polynomial spans.
769 fn segments(&self) -> usize {
770 self.breaks.len() - 1
771 }
772
773 /// Locate the polynomial span containing normalized parameter `x`.
774 fn span(&self, x: f64) -> usize {
775 self.breaks
776 .partition_point(|v| *v <= x)
777 .saturating_sub(1)
778 .min(self.segments() - 1)
779 }
780
781 /// Evaluate one axis through third order on a known span.
782 ///
783 /// Horner evaluation is performed separately for each derivative order.
784 /// Division by `h^r` converts derivatives from local `u` to normalized
785 /// global parameter `x`. The returned array is `[q, q', q'', q''']`.
786 fn at(&self, span: usize, x: f64, axis: usize) -> [f64; 4] {
787 let h = self.breaks[span + 1] - self.breaks[span];
788 let u = (x - self.breaks[span]) / h;
789 let p = &self.coefficients[span * self.d + axis];
790 let mut out = [0.; 4];
791 for r in 0..4 {
792 let mut v = 0.;
793 for k in (r..6).rev() {
794 v = v * u + p[k] * FACT[k] / FACT[k - r];
795 }
796 out[r] = v / h.powi(r as i32);
797 }
798 out
799 }
800}
801
802/// Bound the selected-axis error over complete reference-polyline intervals.
803///
804/// The routine walks reference intervals and intersecting polynomial spans with
805/// monotone cursors; it does not scan every spline span for every input interval.
806/// On each intersection, it reparameterizes the quintic error to
807/// `tau in [0, 1]` and converts ascending power coefficients `a_j` into degree-five
808/// Bernstein coefficients
809///
810/// $$
811/// \beta_k = \sum_{j=0}^k
812/// \frac{\binom{k}{j}}{\binom{5}{j}} a_j.
813/// $$
814///
815/// Since Bernstein basis functions are nonnegative and sum to one,
816/// `max_k |beta_k|` bounds `|q-r|` everywhere on the intersection. This catches
817/// between-sample extrema, although the convex-hull bound may overestimate the
818/// true maximum.
819///
820/// `first..last` is a half-open range of reference intervals. `bounds` is
821/// reference-interval-major and axis-minor; updated entries receive the largest
822/// Bernstein bound seen on that reference interval. `witness[i]` receives the
823/// midpoint of the intersection producing the largest coefficient and is only
824/// a refinement hint, not an estimated maximizer.
825///
826/// In exact arithmetic the convex-hull inequality is a sufficient upper bound.
827/// Here arithmetic is ordinary `f64`: construction rejects any candidate whose
828/// computed audit fails, but the result is not an outward-rounded formal proof.
829fn audit(
830 p: &Polynomial,
831 s: &[f64],
832 y: &[f64],
833 first: usize,
834 last: usize,
835 bounds: &mut [f64],
836 witness: &mut [f64],
837) -> Result<(), PathError> {
838 let mut span = p.span(s[first]);
839 for i in first..last {
840 bounds[i * p.d..(i + 1) * p.d].fill(0.);
841 let mut peak = -1.;
842 let mut left = s[i];
843 while left < s[i + 1] {
844 while span + 1 < p.segments() && p.breaks[span + 1] <= left {
845 span += 1;
846 }
847 let right = s[i + 1].min(p.breaks[span + 1]);
848 if right <= left {
849 return Err(fail("parameter resolution exhausted during audit"));
850 }
851 let h = p.breaks[span + 1] - p.breaks[span];
852 let u = (left - p.breaks[span]) / h;
853 let w = (right - left) / h;
854 for axis in 0..p.d {
855 let poly = &p.coefficients[span * p.d + axis];
856 // Restrict the span-local polynomial to this intersection and
857 // express it in a fresh power basis over tau in [0, 1].
858 let mut power = [0.; 6];
859 for (j, value) in power.iter_mut().enumerate() {
860 for (k, coefficient) in poly.iter().enumerate().skip(j) {
861 *value += choose(k, j) * coefficient * u.powi((k - j) as i32);
862 }
863 *value *= w.powi(j as i32);
864 }
865 let delta = y[(i + 1) * p.d + axis] - y[i * p.d + axis];
866 // Subtract the same-parameter reference line. Only the constant
867 // and linear power coefficients change.
868 power[0] -= y[i * p.d + axis] + (left - s[i]) / (s[i + 1] - s[i]) * delta;
869 power[1] -= (right - left) / (s[i + 1] - s[i]) * delta;
870 for k in 0..6 {
871 // Convert power coefficient prefix 0..=k to Bernstein beta_k.
872 let mut v = 0.;
873 for (j, value) in power.iter().enumerate().take(k + 1) {
874 v += value * choose(k, j) / choose(P, j);
875 }
876 if !v.is_finite() {
877 return Err(fail("non-finite interval error bound"));
878 }
879 bounds[i * p.d + axis] = bounds[i * p.d + axis].max(v.abs());
880 if v.abs() > peak {
881 peak = v.abs();
882 witness[i] = (left + right) / 2.;
883 }
884 }
885 }
886 left = right;
887 }
888 }
889 Ok(())
890}
891
892// --- Knot initialization and local refinement -------------------------------
893
894/// Seed nonuniform interior knots with a shared multi-axis slope corridor.
895///
896/// Starting at an anchor waypoint, each later normalized point contributes a
897/// per-axis slope interval corresponding to a `0.65` error allowance. The scan
898/// intersects these intervals until the current endpoint slope no longer fits;
899/// the previous point then becomes a new anchor. Every point is processed at
900/// most twice, giving `O(s.len() * d)` corridor work.
901///
902/// Around every internal anchor, three candidate knots are placed symmetrically.
903/// Their width is capped both by neighboring parameter gaps (`0.45`) and by the
904/// largest cross-axis slope jump (`0.35 / ((35/256) * jump)`), concentrating
905/// resolution around sharp changes. Knots closer than `128 * f64::EPSILON` or
906/// too close to an endpoint are discarded.
907///
908/// These constants define an initialization heuristic only. The returned knots
909/// neither certify tolerance nor minimize the final number of spans; [`audit`]
910/// and adaptive refinement establish the construction contract.
911fn seed(s: &[f64], y: &[f64], d: usize) -> Vec<f64> {
912 let mut indices = vec![0];
913 let mut anchor = 0;
914 let mut i = 1;
915 let mut lo = vec![f64::NEG_INFINITY; d];
916 let mut hi = vec![f64::INFINITY; d];
917 while i < s.len() {
918 let ds = s[i] - s[anchor];
919 let mut ok = true;
920 for axis in 0..d {
921 let slope = (y[i * d + axis] - y[anchor * d + axis]) / ds;
922 lo[axis] = lo[axis].max(slope - 0.65 / ds);
923 hi[axis] = hi[axis].min(slope + 0.65 / ds);
924 ok &= slope >= lo[axis] && slope <= hi[axis];
925 }
926 if ok {
927 i += 1;
928 } else {
929 anchor = i - 1;
930 indices.push(anchor);
931 lo.fill(f64::NEG_INFINITY);
932 hi.fill(f64::INFINITY);
933 }
934 }
935 indices.push(s.len() - 1);
936 let mut knots = Vec::new();
937 for triple in indices.windows(3) {
938 let [a, b, c] = [triple[0], triple[1], triple[2]];
939 let h0 = s[b] - s[a];
940 let h1 = s[c] - s[b];
941 let mut jump: f64 = 0.;
942 for axis in 0..d {
943 jump = jump.max(
944 ((y[c * d + axis] - y[b * d + axis]) / h1
945 - (y[b * d + axis] - y[a * d + axis]) / h0)
946 .abs(),
947 );
948 }
949 let width = (0.45 * h0.min(h1)).min(0.35 / ((35. / 256.) * jump));
950 knots.extend([s[b] - width, s[b], s[b] + width]);
951 }
952 knots.sort_by(f64::total_cmp);
953 let mut kept = Vec::new();
954 let gap = 128. * f64::EPSILON;
955 for x in knots {
956 if x > gap && x < 1. - gap && kept.last().is_none_or(|v| x - v > gap) {
957 kept.push(x);
958 }
959 }
960 kept
961}
962
963/// Merge control-point windows affected by newly inserted knots.
964///
965/// A quintic basis has support across six knot spans. Each proposed location is
966/// expanded to a conservative active range, endpoint controls are excluded, and
967/// ranges with overlapping support are coalesced. Controls outside the returned
968/// half-open `[a, z)` windows remain frozen during local refits.
969fn regions(b: &BSpline, x: &[f64]) -> Vec<(usize, usize)> {
970 let mut out: Vec<(usize, usize)> = Vec::new();
971 for value in x {
972 let k = b.span(*value);
973 let a = k.saturating_sub(2 * P).max(1);
974 let z = (k + P + 1).min(b.m() - 1);
975 if let Some(last) = out.last_mut()
976 && a <= last.1 + P
977 {
978 last.1 = last.1.max(z);
979 continue;
980 }
981 out.push((a, z));
982 }
983 out
984}
985
986// --- Complete smoothing representation --------------------------------------
987
988/// Fully constructed representation behind `PathRepr::Smoothed`.
989///
990/// Both polynomial sets use physical coordinate values over normalized internal
991/// parameter `[0, 1]`. `axes` and `other` map their compact polynomial-axis
992/// indices back to rows of the public `Path` output buffers. The original
993/// parameter endpoints are retained so evaluation can apply the chain rule.
994pub(super) struct SmoothedSpline {
995 /// Tolerance-fitted polynomial shared by the selected axes.
996 selected: Polynomial,
997 /// Waypoint-interpolating polynomial for unselected axes, when any exist.
998 ///
999 /// The historical field name means "not selected for smoothing"; values
1000 /// between waypoints are quintic interpolation, not the original polyline.
1001 unchanged: Option<Polynomial>,
1002 /// Original matrix-row indices represented by `selected`, in configured order.
1003 axes: Vec<usize>,
1004 /// Original matrix-row indices represented by `unchanged`, in row order.
1005 other: Vec<usize>,
1006 /// Lower endpoint of the caller-visible path parameter range.
1007 pub(super) start: f64,
1008 /// Upper endpoint of the caller-visible path parameter range.
1009 pub(super) end: f64,
1010 /// Final representation metadata, numerical bounds, and cumulative work.
1011 pub(super) report: SmoothingReport,
1012}
1013
1014impl SmoothedSpline {
1015 /// Construct and audit a tolerance-bounded waypoint approximation.
1016 ///
1017 /// The implementation follows a validate, fit, audit, and refine sequence:
1018 ///
1019 /// 1. validate matrix/configuration contracts and resolve selected axes;
1020 /// 2. normalize the common parameter and each selected coordinate by its
1021 /// tolerance;
1022 /// 3. take the endpoint-chord fast path or seed a nonuniform knot vector;
1023 /// 4. fit the selected axes and audit every continuous reference interval;
1024 /// 5. insert knots and locally refit failed regions within both budgets;
1025 /// 6. restore physical units, repeat the complete audit, and construct the
1026 /// unselected-axis interpolant.
1027 ///
1028 /// Any invalid input, numerical failure, exhausted budget, or failed final
1029 /// audit returns [`PathError::Smoothing`]. No partially fitted object escapes.
1030 pub(super) fn build(
1031 points: DMatrixView<'_, f64>,
1032 cfg: &SmoothingConfig,
1033 ) -> Result<Self, PathError> {
1034 // Validate the common matrix and establish the configured axis mapping.
1035 let d = points.nrows();
1036 let n = points.ncols();
1037 if d == 0 || n < 2 || points.iter().any(|v| !v.is_finite()) {
1038 return Err(fail(
1039 "expected finite (dim, n_points) matrix, dim > 0, n_points >= 2",
1040 ));
1041 }
1042 let axes = cfg.axes.clone().unwrap_or_else(|| (0..d).collect());
1043 let mut seen = vec![false; d];
1044 if axes.is_empty() {
1045 return Err(fail("axes must not be empty"));
1046 }
1047 for &a in &axes {
1048 if a >= d || seen[a] {
1049 return Err(fail("axes must be distinct valid indices"));
1050 }
1051 seen[a] = true;
1052 }
1053 let eps = match &cfg.tolerance {
1054 SmoothingTolerance::Uniform(x) => vec![*x; axes.len()],
1055 SmoothingTolerance::PerAxis(v) => v.clone(),
1056 };
1057 if eps.len() != axes.len() || eps.iter().any(|v| !v.is_finite() || *v <= 0.) {
1058 return Err(fail("positive tolerance required for each selected axis"));
1059 }
1060 // Normalize the caller's common parameter to the internal unit interval.
1061 // The original endpoints are kept for query-time derivative scaling.
1062 let parameters = cfg
1063 .parameters
1064 .clone()
1065 .unwrap_or_else(|| (0..n).map(|i| i as f64 / (n - 1) as f64).collect());
1066 if parameters.len() != n
1067 || parameters.iter().any(|v| !v.is_finite())
1068 || parameters.windows(2).any(|v| v[1] <= v[0])
1069 {
1070 return Err(fail(
1071 "parameters must be finite, strictly increasing and match the point count",
1072 ));
1073 }
1074 let start = parameters[0];
1075 let end = parameters[n - 1];
1076 let length = end - start;
1077 let s: Vec<_> = parameters.iter().map(|v| (v - start) / length).collect();
1078 if !length.is_finite() || s.windows(2).any(|v| v[1] <= v[0]) {
1079 return Err(fail("parameter range cannot be represented reliably"));
1080 }
1081 // Center each selected coordinate at its first value and divide by its
1082 // tolerance. The admissible error is now one on every selected axis.
1083 let k = axes.len();
1084 let mut y = vec![0.; n * k];
1085 for i in 0..n {
1086 for a in 0..k {
1087 y[i * k + a] = (points[(axes[a], i)] - points[(axes[a], 0)]) / eps[a];
1088 }
1089 }
1090 if y.iter().any(|v| !v.is_finite()) {
1091 return Err(fail("coordinate/tolerance scale overflow"));
1092 }
1093 // A 0.95 threshold leaves margin below the unit normalized tolerance.
1094 // The chord/reference difference is linear on each input interval, so
1095 // checking all vertices is sufficient for this special candidate.
1096 let linear = (0..n)
1097 .all(|i| (0..k).all(|a| (y[i * k + a] - s[i] * y[(n - 1) * k + a]).abs() <= 0.95));
1098 let knots = if linear { Vec::new() } else { seed(&s, &y, k) };
1099 if knots.len() + 1 > cfg.max_segments {
1100 return Err(fail("initial segment budget exceeded"));
1101 }
1102 // Open degree-five knot vector. Greville control abscissae reproduce the
1103 // endpoint chord exactly and provide a stable initial iterate for fitting.
1104 let mut t = vec![0.; 6];
1105 t.extend(knots);
1106 t.extend([1.; 6]);
1107 let mut c = vec![0.; (t.len() - 6) * k];
1108 for i in 0..t.len() - 6 {
1109 let u = t[i + 1..i + 6].iter().sum::<f64>() / 5.;
1110 for a in 0..k {
1111 c[i * k + a] = u * y[(n - 1) * k + a];
1112 }
1113 }
1114 let mut b = BSpline { t, c, d: k };
1115 let mut report = SmoothingReport {
1116 axes: axes.clone(),
1117 segments: 0,
1118 interpolated_segments: 0,
1119 refinements: 0,
1120 fitting_rows: 0,
1121 checked_intervals: 0,
1122 max_errors: vec![0.; k],
1123 };
1124 // Endpoint controls 0 and m-1 remain frozen, preserving both input
1125 // endpoint positions. The first global fit uses lambda = 1e-3.
1126 if !linear {
1127 let m = b.m();
1128 report.fitting_rows += fit(&mut b, &s, &y, 1, m - 1, 0.001)?.2;
1129 }
1130 let mut bounds = vec![0.; (n - 1) * k];
1131 let mut witness = vec![0.; n - 1];
1132 audit(&b.polynomial(), &s, &y, 0, n - 1, &mut bounds, &mut witness)?;
1133 report.checked_intervals += n - 1;
1134 // Refine until the computed normalized audit bound is at most 0.99. We
1135 // select every interval above 0.9 to batch near-limit regions and retain
1136 // headroom below the physical-unit acceptance threshold.
1137 loop {
1138 let worst = bounds.iter().copied().fold(0., f64::max);
1139 if worst <= 0.99 {
1140 break;
1141 }
1142 if report.refinements >= cfg.max_refinements {
1143 return Err(fail(format!(
1144 "refinement limit reached; error/tolerance={worst}"
1145 )));
1146 }
1147 let mut locations = Vec::new();
1148 for i in 0..n - 1 {
1149 if bounds[i * k..(i + 1) * k].iter().any(|v| *v > 0.9) {
1150 let span = b.span(witness[i]);
1151 locations.push((b.t[span] + b.t[span + 1]) / 2.);
1152 }
1153 }
1154 locations.sort_by(f64::total_cmp);
1155 locations.dedup();
1156 if b.m() - 5 + locations.len() > cfg.max_segments {
1157 return Err(fail("refinement segment budget exceeded"));
1158 }
1159 // Knot insertion first preserves the current curve exactly; the
1160 // subsequent active-window solve uses the new local freedom.
1161 for &x in &locations {
1162 let span = b.span(x);
1163 if x <= b.t[span] || x >= b.t[span + 1] {
1164 return Err(fail("parameter resolution exhausted"));
1165 }
1166 b.insert(x);
1167 }
1168 report.refinements += 1;
1169 for (a, z) in regions(&b, &locations) {
1170 let (first, last, rows) = fit(
1171 &mut b,
1172 &s,
1173 &y,
1174 a,
1175 z,
1176 // Relax smoothness geometrically as fidelity becomes the
1177 // limiting requirement in repeatedly refined regions.
1178 0.001 * 0.25f64.powi(report.refinements as i32),
1179 )?;
1180 report.fitting_rows += rows;
1181 let left = first.saturating_sub(1);
1182 let right = last.min(n - 1);
1183 audit(
1184 &b.polynomial(),
1185 &s,
1186 &y,
1187 left,
1188 right,
1189 &mut bounds,
1190 &mut witness,
1191 )?;
1192 report.checked_intervals += right - left;
1193 }
1194 }
1195 // Restore selected axes to physical units, then repeat a full audit.
1196 // This catches scaling roundoff and stale local-bound bookkeeping before
1197 // a successful Path can be returned.
1198 for i in 0..b.m() {
1199 for a in 0..k {
1200 b.c[i * k + a] = b.c[i * k + a] * eps[a] + points[(axes[a], 0)];
1201 }
1202 }
1203 let selected = b.polynomial();
1204 for i in 0..n {
1205 for a in 0..k {
1206 y[i * k + a] = points[(axes[a], i)];
1207 }
1208 }
1209 audit(&selected, &s, &y, 0, n - 1, &mut bounds, &mut witness)?;
1210 report.checked_intervals += n - 1;
1211 for a in 0..k {
1212 report.max_errors[a] = (0..n - 1).map(|i| bounds[i * k + a]).fold(0., f64::max);
1213 if report.max_errors[a] > eps[a] {
1214 return Err(fail("final physical-unit interval audit failed"));
1215 }
1216 }
1217 // Rows excluded from smoothing still share the same public parameter,
1218 // but use a separate waypoint-interpolating polynomial and segment count.
1219 let other: Vec<_> = (0..d).filter(|a| !seen[*a]).collect();
1220 let unchanged = if other.is_empty() {
1221 None
1222 } else {
1223 Some(interpolate(points, &s, &other)?)
1224 };
1225 report.segments = selected.segments();
1226 report.interpolated_segments = unchanged.as_ref().map_or(0, Polynomial::segments);
1227 Ok(Self {
1228 selected,
1229 unchanged,
1230 axes,
1231 other,
1232 start,
1233 end,
1234 report,
1235 })
1236 }
1237
1238 /// Evaluate physical coordinates and optional derivatives at one parameter.
1239 ///
1240 /// `x` has already been validated or clamped by `Path`. It is mapped from
1241 /// `[start, end]` to the internal unit interval; an order-`r` derivative is
1242 /// then multiplied by `(end-start)^(-r)`. Compact selected/unselected values
1243 /// are scattered back to their original axis positions in the caller's
1244 /// column-major output buffers.
1245 pub(super) fn evaluate(
1246 &self,
1247 x: f64,
1248 q: &mut [f64],
1249 dq: Option<&mut [f64]>,
1250 ddq: Option<&mut [f64]>,
1251 dddq: Option<&mut [f64]>,
1252 ) {
1253 let u = (x - self.start) / (self.end - self.start);
1254 let scale = 1. / (self.end - self.start);
1255 let mut outputs = [Some(q), dq, ddq, dddq];
1256 for (poly, axes) in [
1257 (Some(&self.selected), &self.axes),
1258 (self.unchanged.as_ref(), &self.other),
1259 ] {
1260 if let Some(p) = poly {
1261 let span = p.span(u);
1262 for (i, &axis) in axes.iter().enumerate() {
1263 let values = p.at(span, u, i);
1264 let mut f = 1.;
1265 for r in 0..4 {
1266 if let Some(out) = outputs[r].as_deref_mut() {
1267 out[axis] = values[r] * f;
1268 }
1269 f *= scale;
1270 }
1271 }
1272 }
1273 }
1274 }
1275}
1276
1277/// Build the waypoint-interpolating quintic `C4` polynomial for unselected axes.
1278///
1279/// One simple internal knot is placed at every interior input parameter. The
1280/// system contains one position equation per waypoint plus zero first- and
1281/// second-parameter-derivative equations at both endpoints, matching the number
1282/// of controls. This path is independent of selected-axis tolerance and may use
1283/// a different number of spans from the fitted polynomial.
1284fn interpolate(
1285 points: DMatrixView<'_, f64>,
1286 s: &[f64],
1287 axes: &[usize],
1288) -> Result<Polynomial, PathError> {
1289 let d = axes.len();
1290 let n = s.len();
1291 let mut t = vec![0.; 6];
1292 t.extend_from_slice(&s[1..n - 1]);
1293 t.extend([1.; 6]);
1294 let mut b = BSpline {
1295 c: vec![0.; (t.len() - 6) * d],
1296 t,
1297 d,
1298 };
1299 let mut system = Normal::new(0, b.m(), d);
1300 let mut target = vec![0.; d];
1301 for i in 0..n {
1302 for a in 0..d {
1303 target[a] = points[(axes[a], i)];
1304 }
1305 let (start, v) = b.values(s[i]);
1306 system.add(start, &v, &target, 1., &b.c);
1307 }
1308 target.fill(0.);
1309 for (x, h) in [(0., s[1] - s[0]), (1., s[n - 1] - s[n - 2])] {
1310 let span = b.span(x);
1311 let power = b.powers(span, x);
1312 for r in 1..=2 {
1313 let v = std::array::from_fn(|i| power[i][r] * FACT[r] * h.powi(r as i32));
1314 system.add(span - P, &v, &target, 1., &b.c);
1315 }
1316 }
1317 b.c = system.solve()?;
1318 Ok(b.polynomial())
1319}
1320
1321#[cfg(test)]
1322mod tests {
1323 use super::*;
1324 use nalgebra::DMatrix;
1325
1326 #[test]
1327 fn basis_partition_derivatives_and_insertion() {
1328 let mut t = vec![0.; 6];
1329 t.extend([0.01, 0.03, 0.14, 0.39, 0.43, 0.88]);
1330 t.extend([1.; 6]);
1331 let c: Vec<_> = (0..t.len() - 6).map(|i| (i as f64 * 1.73).sin()).collect();
1332 let mut b = BSpline { t, c, d: 1 };
1333 let before = b.polynomial();
1334 for i in 0..101 {
1335 let x = i as f64 / 100.;
1336 let (start, v) = b.values(x);
1337 let power = b.powers(start + P, x);
1338 assert!((v.iter().sum::<f64>() - 1.).abs() < 1e-12);
1339 for j in 0..6 {
1340 assert!((v[j] - power[j][0]).abs() < 1e-12);
1341 }
1342 for r in 1..6 {
1343 let sum = power.iter().map(|v| v[r]).sum::<f64>();
1344 let scale = power.iter().map(|v| v[r].abs()).sum::<f64>().max(1.);
1345 assert!(sum.abs() < 1e-12 * scale);
1346 }
1347 }
1348 for x in [0.005, 0.2, 0.5, 0.99] {
1349 b.insert(x);
1350 }
1351 let after = b.polynomial();
1352 for i in 0..301 {
1353 let x = i as f64 / 300.;
1354 let a = before.at(before.span(x), x, 0);
1355 let z = after.at(after.span(x), x, 0);
1356 for r in 0..4 {
1357 assert!((a[r] - z[r]).abs() < 1e-7 * (1. + a[r].abs()));
1358 }
1359 }
1360 }
1361
1362 #[test]
1363 fn audit_sees_interior_extrema_and_polynomial_continuity() {
1364 // Endpoints are zero; a point-only audit would accept this curve.
1365 let p = Polynomial {
1366 breaks: vec![0., 1.],
1367 coefficients: vec![[0., 4., -4., 0., 0., 0.]],
1368 d: 1,
1369 };
1370 let mut bounds = vec![0.];
1371 let mut witness = vec![0.];
1372 audit(&p, &[0., 1.], &[0., 0.], 0, 1, &mut bounds, &mut witness).unwrap();
1373 assert!(bounds[0] >= 1.);
1374 let mut t = vec![0.; 6];
1375 t.extend([0.11, 0.37, 0.51, 0.91]);
1376 t.extend([1.; 6]);
1377 let b = BSpline {
1378 c: (0..t.len() - 6).map(|i| (i as f64).cos()).collect(),
1379 t,
1380 d: 1,
1381 };
1382 let poly = b.polynomial();
1383 for i in 1..poly.segments() {
1384 let left = &poly.coefficients[i - 1];
1385 let right = &poly.coefficients[i];
1386 let hl = poly.breaks[i] - poly.breaks[i - 1];
1387 let hr = poly.breaks[i + 1] - poly.breaks[i];
1388 for r in 0..=4 {
1389 let a = (r..6).map(|k| left[k] * FACT[k] / FACT[k - r]).sum::<f64>()
1390 / hl.powi(r as i32);
1391 let b = right[r] * FACT[r] / hr.powi(r as i32);
1392 assert!((a - b).abs() < 1e-8 * (1. + a.abs()));
1393 }
1394 }
1395 }
1396
1397 #[test]
1398 fn banded_solver_matches_dense_cholesky() {
1399 let n = 17;
1400 let mut h = Normal::new(0, n, 2);
1401 let mut dense = DMatrix::zeros(n, n);
1402 for i in 0..n {
1403 h.h[i][0] = 9.;
1404 dense[(i, i)] = 9.;
1405 for k in 1..=P.min(i) {
1406 let value = 0.2 / (k as f64);
1407 h.h[i][k] = value;
1408 dense[(i, i - k)] = value;
1409 dense[(i - k, i)] = value;
1410 }
1411 h.rhs[2 * i] = (i as f64).sin();
1412 h.rhs[2 * i + 1] = (i as f64).cos();
1413 }
1414 let rhs = DMatrix::from_fn(n, 2, |i, a| h.rhs[2 * i + a]);
1415 let expected = dense.cholesky().unwrap().solve(&rhs);
1416 let actual = h.solve().unwrap();
1417 for i in 0..n {
1418 for a in 0..2 {
1419 assert!((actual[2 * i + a] - expected[(i, a)]).abs() < 1e-13);
1420 }
1421 }
1422 }
1423
1424 #[test]
1425 fn constructed_curve_error_checked_at_quartic_stationary_points() {
1426 let points = DMatrix::from_row_slice(2, 4, &[0., 1., 1., 2., 0., 0., 1., 1.]);
1427 let cfg = SmoothingConfig::default();
1428 let fitted = SmoothedSpline::build(points.as_view(), &cfg).unwrap();
1429 let p = &fitted.selected;
1430 let samples: [f64; 4] = [0., 1. / 3., 2. / 3., 1.];
1431 let mut actual = [0_f64; 2];
1432 for i in 0..3 {
1433 for span in 0..p.segments() {
1434 let a = p.breaks[span];
1435 let h = p.breaks[span + 1] - a;
1436 let left = samples[i].max(a);
1437 let right = samples[i + 1].min(a + h);
1438 if right <= left {
1439 continue;
1440 }
1441 for axis in 0..2 {
1442 let slope =
1443 (points[(axis, i + 1)] - points[(axis, i)]) / (samples[i + 1] - samples[i]);
1444 let mut error = p.coefficients[span * 2 + axis];
1445 error[0] -= points[(axis, i)] + slope * (a - samples[i]);
1446 error[1] -= slope * h;
1447 let roots = roots::find_roots_quartic(
1448 5. * error[5],
1449 4. * error[4],
1450 3. * error[3],
1451 2. * error[2],
1452 error[1],
1453 );
1454 let lo = (left - a) / h;
1455 let hi = (right - a) / h;
1456 for u in [lo, hi].iter().chain(roots.as_ref().iter()) {
1457 if *u < lo || *u > hi {
1458 continue;
1459 }
1460 let value = error.iter().rev().fold(0., |v, c| v * u + c);
1461 actual[axis] = actual[axis].max(value.abs());
1462 }
1463 }
1464 }
1465 }
1466 for (axis, &actual) in actual.iter().enumerate() {
1467 assert!(actual <= 0.001);
1468 assert!(actual <= fitted.report.max_errors[axis] + 1e-10);
1469 }
1470 }
1471}