Skip to main content

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}