Skip to main content

copp\path/
spline.rs

1//! Waypoint-based spline path construction and evaluation kernels.
2//!
3//! Most users should construct waypoint paths through
4//! [`Path::from_waypoints_interpolating`](crate::path::Path::from_waypoints_interpolating), passing a
5//! [`SplineConfig`] when the default spline settings
6//! need to be changed. This module contains the lower-level spline
7//! implementation used by that constructor.
8//!
9//! # Design
10//!
11//! All splines are **odd-order**:
12//! $$
13//! p = 2m + 1,\quad m \ge 1.
14//! $$
15//!   - order 3 (m=1): C2 cubic, boundary specifies v at both ends
16//!   - order 5 (m=2): C4 quintic, boundary specifies v,a at both ends
17//!   - order 7 (m=3): C6 septic, boundary specifies v,a,j at both ends
18//!
19//! The user supplies `start_state` and `end_state` matrices of shape `(dim, m)`,
20//! where column `r-1` contains the r-th derivative value at the endpoint.
21//! `None` means all-zero (the most common default).
22//!
23//! Internally, all orders share a single `solve_general_thomas` solver that
24//! implements the O(N) block-Thomas algorithm on the `m x m` block-tridiagonal
25//! system arising from Hermite parametrisation and
26//! $$
27//! C^{m+1}, \ldots, C^{2m}
28//! $$
29//! continuity.
30
31use crate::diag::PathError;
32use crate::path::OutOfRangeMode;
33use nalgebra::{DMatrix, DMatrixView, DVector};
34use rayon::prelude::*;
35
36// --- Public configuration ----------------------------------------------------
37
38/// Parameter assignment policy for waypoint splines.
39#[derive(Clone, Copy, Debug)]
40pub enum Parametrization {
41    /// Assign waypoints uniformly over the configured parameter range.
42    Uniform,
43}
44
45/// Configuration for waypoint-spline path construction.
46///
47/// The default is a quintic spline on `s in [0, 1]` with zero endpoint
48/// derivative boundary conditions and out-of-range errors.
49///
50/// Most callers use this type through
51/// [`Path::from_waypoints_interpolating`](crate::path::Path::from_waypoints_interpolating)
52/// rather than
53/// constructing [`SplinePath`] directly.
54///
55/// # Example
56/// The example below customizes the waypoint parameter range and endpoint
57/// derivative conditions for a two-dimensional quintic spline.
58///
59/// ```rust
60/// # fn main() -> Result<(), copp::diag::CoppError> {
61/// use copp::path::{OutOfRangeMode, Parametrization, Path, SplineConfig};
62/// use nalgebra::DMatrix;
63///
64/// let waypoints = DMatrix::from_row_slice(
65///     2,
66///     3,
67///     &[
68///         0.0, 0.5, 1.0,
69///         0.0, 0.2, 0.0,
70///     ],
71/// );
72///
73/// // For order 5, m = 2, so each endpoint state is `(dim x 2)`:
74/// // column 0 is velocity and column 1 is acceleration.
75/// let start_state = DMatrix::from_row_slice(2, 2, &[0.0, 0.0, 0.0, 0.0]);
76/// let end_state = DMatrix::from_row_slice(2, 2, &[0.0, 0.0, 0.0, 0.0]);
77///
78/// let cfg = SplineConfig {
79///     order: 5,
80///     parametrization: Parametrization::Uniform,
81///     s_min: 0.0,
82///     s_max: 2.0,
83///     out_of_range_mode: OutOfRangeMode::Error,
84///     start_state: Some(start_state),
85///     end_state: Some(end_state),
86/// };
87///
88/// let path = Path::from_waypoints_interpolating(&waypoints, cfg)?;
89/// assert_eq!(path.dim(), 2);
90/// # Ok(())
91/// # }
92/// ```
93pub struct SplineConfig {
94    /// Spline order: must be an odd number >= 3.
95    pub order: usize,
96    /// Waypoint parameter assignment policy.
97    pub parametrization: Parametrization,
98    /// Lower endpoint of the path parameter range.
99    pub s_min: f64,
100    /// Upper endpoint of the path parameter range.
101    pub s_max: f64,
102    /// Behavior when evaluating outside `[s_min, s_max]`.
103    pub out_of_range_mode: OutOfRangeMode,
104    /// Boundary derivatives at s_min: shape `(dim, m)` where `m = (order-1)/2`.
105    /// Column r (0-indexed) = (r+1)-th derivative value.
106    /// `None` = all-zero (default).
107    pub start_state: Option<DMatrix<f64>>,
108    /// Boundary derivatives at s_max: same shape as `start_state`.
109    /// `None` = all-zero (default).
110    pub end_state: Option<DMatrix<f64>>,
111}
112
113impl Default for SplineConfig {
114    fn default() -> Self {
115        Self {
116            order: 5,
117            parametrization: Parametrization::Uniform,
118            s_min: 0.0,
119            s_max: 1.0,
120            out_of_range_mode: OutOfRangeMode::Error,
121            start_state: None,
122            end_state: None,
123        }
124    }
125}
126
127// --- SplinePath ---------------------------------------------------------------
128
129/// Low-level piecewise-polynomial path in normalised segment coordinates.
130///
131/// Coefficients are stored in column-major blocks by dimension:
132/// `[dim0_seg0.., dim0_seg1.., ..., dim1_seg0.., ...]`.
133///
134/// Most users should construct a public [`Path`](crate::path::Path) with
135/// [`Path::from_waypoints_interpolating`](crate::path::Path::from_waypoints_interpolating)
136/// instead of using
137/// this implementation type directly.
138pub struct SplinePath {
139    // --- Evaluation metadata (all cheap Copy types) ----------------------
140    /// Polynomial order used for each spline segment.
141    pub order: usize,
142    /// Lower endpoint of the path parameter range.
143    pub s_min: f64,
144    /// Upper endpoint of the path parameter range.
145    pub s_max: f64,
146    /// Behavior when evaluating outside `[s_min, s_max]`.
147    pub out_of_range_mode: OutOfRangeMode,
148    n_coef: usize,
149    n_segments: usize,
150    /// Scale factor: maps a change in `s` to normalised segment units.
151    /// `inv_ds = n_segments / (s_max - s_min)`.
152    /// Each derivative order gains one additional factor of `inv_ds`.
153    inv_ds: f64,
154    // --- Coefficient storage ---------------------------------------------
155    /// Flat Horner coefficients, layout: `[dim][segment][coef]`.
156    coeffs: Vec<f64>,
157}
158
159impl SplinePath {
160    /// Build spline coefficients from a waypoint matrix of shape `(dim, n_points)`.
161    pub fn from_waypoints(waypoints: &DMatrix<f64>, cfg: &SplineConfig) -> Result<Self, PathError> {
162        Self::from_waypoints_view(waypoints.as_view(), cfg)
163    }
164
165    /// Build spline coefficients from a borrowed waypoint matrix view.
166    ///
167    /// This accepts nalgebra views such as `waypoints.as_view()` and compatible
168    /// strided column-major views. See [`SplinePath::from_waypoints`] for the
169    /// owned-matrix convenience API and shared behavior.
170    pub fn from_waypoints_view(
171        waypoints: DMatrixView<'_, f64>,
172        cfg: &SplineConfig,
173    ) -> Result<Self, PathError> {
174        let order = cfg.order;
175        if order < 3 || order.is_multiple_of(2) {
176            return Err(PathError::InvalidOrder { order });
177        }
178        let dim = waypoints.nrows();
179        let n_points = waypoints.ncols();
180        if n_points < 2 {
181            return Err(PathError::NotEnoughWaypoints { n: n_points });
182        }
183        let n_segments = n_points - 1;
184        let n_coef = order + 1;
185        let m = (order - 1) / 2;
186
187        // Validate / extract boundary state (dim x m)
188        let start = extract_boundary(cfg.start_state.as_ref(), dim, m)?;
189        let end = extract_boundary(cfg.end_state.as_ref(), dim, m)?;
190        let coeffs = solve_general_thomas(waypoints, order, m, &start, &end)?;
191
192        let range = cfg.s_max - cfg.s_min;
193        let inv_ds = n_segments as f64 / range;
194
195        Ok(Self {
196            order,
197            s_min: cfg.s_min,
198            s_max: cfg.s_max,
199            out_of_range_mode: cfg.out_of_range_mode,
200            n_coef,
201            n_segments,
202            inv_ds,
203            coeffs,
204        })
205    }
206
207    #[inline(always)]
208    fn segment_tau(&self, s: f64) -> (usize, f64) {
209        let scaled = (s - self.s_min) * self.inv_ds;
210        if scaled <= 0.0 {
211            return (0, 0.0);
212        }
213        let max_scaled = self.n_segments as f64;
214        if scaled >= max_scaled {
215            return (self.n_segments - 1, 1.0);
216        }
217        let seg = scaled.floor() as usize;
218        (seg, scaled - seg as f64)
219    }
220
221    /// Evaluate Horner polynomial and up to 3 derivatives at `t in [0,1]`.
222    ///
223    /// Returns `(q, dq, ddq, dddq)`.  Specialised fast paths for degree 5 and 3
224    /// (the two most common cases); falls back to the general Horner+derivative
225    /// accumulation for other degrees.
226    #[inline(always)]
227    fn eval_poly<const ORDER: u8>(coeffs: &[f64], t: f64) -> (f64, f64, f64, f64) {
228        if coeffs.len() == 6 {
229            let [a0, a1, a2, a3, a4, a5] = [
230                coeffs[0], coeffs[1], coeffs[2], coeffs[3], coeffs[4], coeffs[5],
231            ];
232            let q = a5
233                .mul_add(t, a4)
234                .mul_add(t, a3)
235                .mul_add(t, a2)
236                .mul_add(t, a1)
237                .mul_add(t, a0);
238            if ORDER == 0 {
239                return (q, 0.0, 0.0, 0.0);
240            }
241            let dq = (5.0 * a5)
242                .mul_add(t, 4.0 * a4)
243                .mul_add(t, 3.0 * a3)
244                .mul_add(t, 2.0 * a2)
245                .mul_add(t, a1);
246            let ddq = (20.0 * a5)
247                .mul_add(t, 12.0 * a4)
248                .mul_add(t, 6.0 * a3)
249                .mul_add(t, 2.0 * a2);
250            if ORDER == 2 {
251                return (q, dq, ddq, 0.0);
252            }
253            let dddq = (60.0 * a5).mul_add(t, 24.0 * a4).mul_add(t, 6.0 * a3);
254            return (q, dq, ddq, dddq);
255        }
256        if coeffs.len() == 4 {
257            let [a0, a1, a2, a3] = [coeffs[0], coeffs[1], coeffs[2], coeffs[3]];
258            let q = a3.mul_add(t, a2).mul_add(t, a1).mul_add(t, a0);
259            if ORDER == 0 {
260                return (q, 0.0, 0.0, 0.0);
261            }
262            let dq = (3.0 * a3).mul_add(t, 2.0 * a2).mul_add(t, a1);
263            let ddq = (6.0 * a3).mul_add(t, 2.0 * a2);
264            if ORDER == 2 {
265                return (q, dq, ddq, 0.0);
266            }
267            return (q, dq, ddq, 6.0 * a3);
268        }
269        // General Horner + simultaneous derivative accumulation (arbitrary degree).
270        let n = coeffs.len() - 1;
271        let mut b0 = coeffs[n];
272        let mut b1 = 0.0_f64;
273        let mut b2 = 0.0_f64;
274        let mut b3 = 0.0_f64;
275        for k in (0..n).rev() {
276            if ORDER >= 3 {
277                b3 = b3 * t + 3.0 * b2;
278            }
279            if ORDER >= 2 {
280                b2 = b2 * t + 2.0 * b1;
281            }
282            if ORDER >= 1 {
283                b1 = b1 * t + b0;
284            }
285            b0 = b0 * t + coeffs[k];
286        }
287        (b0, b1, b2, b3)
288    }
289
290    /// Evaluate position and up to `ORDER` derivatives at path parameter `s`
291    /// for all `dim` joints, writing results into the supplied column slices.
292    ///
293    /// `ORDER` controls which slices must be valid:
294    ///   - `0` : only `q_col` is written; `dq_col / ddq_col / dddq_col` are ignored
295    ///   - `2` : `q_col`, `dq_col`, `ddq_col` are written; `dddq_col` is ignored
296    ///   - `3` : all four slices are written
297    ///
298    /// Callers in `path_core.rs` specialise this with `eval_at::<0>`, `eval_at::<2>`,
299    /// and `eval_at::<3>`, replacing the former `eval_at_q_only`, `eval_at_up_to_2nd`,
300    /// and `eval_at_full` methods.
301    #[inline(always)]
302    pub fn eval_at<const ORDER: u8>(
303        &self,
304        s: f64,
305        _dim: usize,
306        q_col: &mut [f64],
307        dq_col: &mut [f64],
308        ddq_col: &mut [f64],
309        dddq_col: &mut [f64],
310    ) {
311        let (seg, tau) = self.segment_tau(s);
312        let seg_offset = seg * self.n_coef;
313        let inv_ds2 = self.inv_ds * self.inv_ds;
314        let inv_ds3 = inv_ds2 * self.inv_ds;
315        let stride = self.n_segments * self.n_coef;
316        for (i, q_v) in q_col.iter_mut().enumerate() {
317            let start = i * stride + seg_offset;
318            let (q_val, dq_val, ddq_val, dddq_val) =
319                Self::eval_poly::<ORDER>(&self.coeffs[start..start + self.n_coef], tau);
320            *q_v = q_val;
321            if ORDER >= 2 {
322                dq_col[i] = dq_val * self.inv_ds;
323                ddq_col[i] = ddq_val * inv_ds2;
324            }
325            if ORDER >= 3 {
326                dddq_col[i] = dddq_val * inv_ds3;
327            }
328        }
329    }
330}
331
332// --- Boundary helpers --------------------------------------------------------
333
334/// Extract boundary state as a flat `dim x m` array (row-major: `[d][r]`).
335/// Returns `vec![0; dim*m]` when `state` is `None`.
336fn extract_boundary(
337    state: Option<&DMatrix<f64>>,
338    dim: usize,
339    m: usize,
340) -> Result<Vec<f64>, PathError> {
341    match state {
342        None => Ok(vec![0.0; dim * m]),
343        Some(mat) => {
344            if mat.nrows() != dim || mat.ncols() != m {
345                return Err(PathError::DimensionMismatch);
346            }
347            // Row-major: out[d * m + r] = mat[(d, r)]
348            let mut out = vec![0.0; dim * m];
349            for (d, row) in out.chunks_mut(m).enumerate() {
350                for (r, val) in row.iter_mut().enumerate() {
351                    *val = mat[(d, r)];
352                }
353            }
354            Ok(out)
355        }
356    }
357}
358
359// --- Unified O(N) block-Thomas solver ----------------------------------------
360//
361// For any odd order p=2m+1 with uniform parametrisation (h=1 per segment):
362//
363// Each segment i has 2m+2 Horner coefficients.  The lower m+1 are:
364//   a[0] = y_i,  a[r] = u_r(i)  for r=1..m
365// where u_r(i) are the m derivative unknowns at node i.
366//
367// The upper m+1 (a[m+1]..a[2m+1]) are determined by the endpoint conditions
368// and expressed as a linear combination of (dy_i, u(i), u(i+1)).
369//
370// Enforcing C^{m+1}..C^{2m} continuity at each interior node gives:
371//
372//   A*u_{i-1}  +  B*u_i  +  C*u_{i+1}  =  R*[dy_{i-1}, dy_i]^T
373//
374// where A, B, C, R are constant m x m (resp. m x 2) matrices depending only on m.
375// These are stored as compile-time constants for m=1,2,3 and computed
376// at run-time for larger m via the general formula.
377//
378// The block Thomas algorithm solves this in O(N) time per dimension.
379// The solve for each dimension is independent and runs in parallel via Rayon.
380//
381// Boundary conditions at the two endpoint nodes:
382//   u(0)   = start_state[d, :]   (the m boundary derivative values)
383//   u(N-1) = end_state[d, :]
384//
385// These are absorbed as known vectors; the interior system has size (N-2) x m.
386
387// --- Block matrices ----------------------------------------------------------
388
389/// Precomputed block-tridiagonal matrices for a given `m`.
390///
391/// All `m x m` matrices use `DMatrix<f64>` for nalgebra operations.
392/// `h_coeff` has shape `(m+1) x (1+2m)`.
393struct BlockMatrices {
394    m: usize,
395    a: DMatrix<f64>,
396    b: DMatrix<f64>,
397    c: DMatrix<f64>,
398    /// R: `m x 2`.
399    r: DMatrix<f64>,
400    /// Shape `(m+1) x (1+2m)`; row i: `a[m+1+i]`.
401    h_coeff: DMatrix<f64>,
402}
403
404impl BlockMatrices {
405    fn for_order(m: usize) -> Self {
406        // Use precomputed exact-rational values for m=1,2,3; fall back to
407        // the general derivation (floating point) for larger m.
408        match m {
409            1 => Self::m1(),
410            2 => Self::m2(),
411            3 => Self::m3(),
412            _ => Self::general(m),
413        }
414    }
415
416    // p=3 (m=1): A=[1], B=[4], C=[1], R=[[3,3]]
417    // Hermite: a[2]=3*dy-2*u0-u1, a[3]=-2*dy+u0+u1
418    fn m1() -> Self {
419        BlockMatrices {
420            m: 1,
421            a: DMatrix::from_row_slice(1, 1, &[1.0]),
422            b: DMatrix::from_row_slice(1, 1, &[4.0]),
423            c: DMatrix::from_row_slice(1, 1, &[1.0]),
424            r: DMatrix::from_row_slice(1, 2, &[3.0, 3.0]),
425            h_coeff: DMatrix::from_row_slice(2, 3, &[3.0, -2.0, -1.0, -2.0, 1.0, 1.0]),
426        }
427    }
428
429    // p=5 (m=2): known exact matrices (verified by Python derivation)
430    // Hermite: a[3]=10dy-6u0_1-3u0_2-4u1_1+u1_2
431    //          a[4]=-15dy+8u0_1+3u0_2+7u1_1-2u1_2
432    //          a[5]=6dy-3u0_1-u0_2-3u1_1+u1_2
433    // A=[[-4,-1],[-7,-2]], B=[[0,6],[-16,0]], C=[[4,-1],[-7,2]]
434    // R=[[-10,10],[-15,-15]]
435    fn m2() -> Self {
436        BlockMatrices {
437            m: 2,
438            a: DMatrix::from_row_slice(2, 2, &[-4.0, -1.0, -7.0, -2.0]),
439            b: DMatrix::from_row_slice(2, 2, &[0.0, 6.0, -16.0, 0.0]),
440            c: DMatrix::from_row_slice(2, 2, &[4.0, -1.0, -7.0, 2.0]),
441            r: DMatrix::from_row_slice(2, 2, &[-10.0, 10.0, -15.0, -15.0]),
442            h_coeff: DMatrix::from_row_slice(
443                3,
444                5,
445                &[
446                    10.0, -6.0, -3.0, -4.0, 1.0, -15.0, 8.0, 3.0, 7.0, -2.0, 6.0, -3.0, -1.0, -3.0,
447                    1.0,
448                ],
449            ),
450        }
451    }
452
453    // p=7 (m=3): derived by Python symbolic computation
454    // A=[[15,5,1],[39,14,3],[34,13,3]]
455    // B=[[40,0,8],[0,-40,0],[72,0,8]]
456    // C=[[15,-5,1],[-39,14,-3],[34,-13,3]]
457    // R=[[35,35],[84,-84],[70,70]]
458    fn m3() -> Self {
459        BlockMatrices {
460            m: 3,
461            a: DMatrix::from_row_slice(3, 3, &[15.0, 5.0, 1.0, 39.0, 14.0, 3.0, 34.0, 13.0, 3.0]),
462            b: DMatrix::from_row_slice(3, 3, &[40.0, 0.0, 8.0, 0.0, -40.0, 0.0, 72.0, 0.0, 8.0]),
463            c: DMatrix::from_row_slice(
464                3,
465                3,
466                &[15.0, -5.0, 1.0, -39.0, 14.0, -3.0, 34.0, -13.0, 3.0],
467            ),
468            r: DMatrix::from_row_slice(3, 2, &[35.0, 35.0, 84.0, -84.0, 70.0, 70.0]),
469            h_coeff: DMatrix::from_row_slice(
470                4,
471                7,
472                &[
473                    35.0, -20.0, -10.0, -4.0, -15.0, 5.0, -1.0, -84.0, 45.0, 20.0, 6.0, 39.0,
474                    -14.0, 3.0, 70.0, -36.0, -15.0, -4.0, -34.0, 13.0, -3.0, -20.0, 10.0, 4.0, 1.0,
475                    10.0, -4.0, 1.0,
476                ],
477            ),
478        }
479    }
480
481    /// General derivation for m >= 4 via Gauss-Jordan inversion (f64 arithmetic).
482    fn general(m: usize) -> Self {
483        let p = 2 * m + 1;
484
485        // Build M ((m+1) x (m+1)): endpoint conditions at t=1
486        // M[r][k-(m+1)] = C(k, r)  for r=0..m, k=m+1..2m+1
487        let n = m + 1;
488
489        let binom = |nn: usize, k: usize| -> f64 {
490            if k > nn {
491                return 0.0;
492            }
493            (0..k).fold(1.0f64, |acc, i| acc * (nn - i) as f64 / (i + 1) as f64)
494        };
495
496        // Augmented matrix [M | I] for Gauss-Jordan inversion
497        let mut aug = DMatrix::<f64>::zeros(n, 2 * n);
498        for r in 0..n {
499            for (col_idx, k) in (m + 1..=p).enumerate() {
500                aug[(r, col_idx)] = binom(k, r);
501            }
502            aug[(r, n + r)] = 1.0;
503        }
504        for col in 0..n {
505            let pivot_row = (col..n)
506                .max_by(|&a, &b| {
507                    aug[(a, col)]
508                        .abs()
509                        .partial_cmp(&aug[(b, col)].abs())
510                        .unwrap()
511                })
512                .unwrap();
513            if pivot_row != col {
514                aug.swap_rows(col, pivot_row);
515            }
516            let piv_inv = 1.0 / aug[(col, col)];
517            for j in 0..2 * n {
518                aug[(col, j)] *= piv_inv;
519            }
520            for r in 0..n {
521                if r == col {
522                    continue;
523                }
524                let factor = aug[(r, col)];
525                if factor == 0.0 {
526                    continue;
527                }
528                for j in 0..2 * n {
529                    let sub = factor * aug[(col, j)];
530                    aug[(r, j)] -= sub;
531                }
532            }
533        }
534        let minv = aug.columns(n, n).into_owned();
535
536        // RHS basis vectors: shape n x (1+2m)
537        let basis = 1 + 2 * m;
538        let mut rhs_mat = DMatrix::<f64>::zeros(n, basis);
539        rhs_mat[(0, 0)] = 1.0;
540        for b in 1..=m {
541            rhs_mat[(0, b)] = -1.0;
542        }
543        for r in 1..n {
544            rhs_mat[(r, m + r)] = 1.0;
545            for k in r..n {
546                rhs_mat[(r, k)] -= binom(k, r);
547            }
548        }
549
550        // h_coeff = M^{-1} * rhs_mat
551        let h_coeff = &minv * &rhs_mat;
552
553        // Build A, B, C, R from continuity equations
554        let bs = 2 + 3 * m;
555        let get_coeff_vec = |k: usize, is_left: bool| -> DVector<f64> {
556            let mut v = DVector::<f64>::zeros(bs);
557            if k == 0 {
558                return v;
559            }
560            if k <= m {
561                let offset = if is_left { 2 } else { 2 + m };
562                v[offset + k - 1] = 1.0;
563                return v;
564            }
565            let ic = k - (m + 1);
566            for (b, &cv) in h_coeff.row(ic).iter().enumerate() {
567                if cv == 0.0 {
568                    continue;
569                }
570                if b == 0 {
571                    v[if is_left { 0 } else { 1 }] += cv;
572                } else if b <= m {
573                    let offset = if is_left { 2 } else { 2 + m };
574                    v[offset + b - 1] += cv;
575                } else {
576                    let offset = if is_left { 2 + m } else { 2 + 2 * m };
577                    v[offset + b - m - 1] += cv;
578                }
579            }
580            v
581        };
582
583        let mut a_mat = DMatrix::<f64>::zeros(m, m);
584        let mut b_mat = DMatrix::<f64>::zeros(m, m);
585        let mut c_mat = DMatrix::<f64>::zeros(m, m);
586        let mut r_mat = DMatrix::<f64>::zeros(m, 2);
587
588        for (eq_idx, r) in (m + 1..=2 * m).enumerate() {
589            let lhs = (r..=p).fold(DVector::<f64>::zeros(bs), |acc, k| {
590                acc + get_coeff_vec(k, true) * binom(k, r)
591            });
592            let rhs_v = get_coeff_vec(r, false);
593            let eq = lhs - rhs_v;
594            for j in 0..m {
595                a_mat[(eq_idx, j)] = eq[2 + j];
596                b_mat[(eq_idx, j)] = eq[2 + m + j];
597                c_mat[(eq_idx, j)] = eq[2 + 2 * m + j];
598            }
599            r_mat[(eq_idx, 0)] = -eq[0];
600            r_mat[(eq_idx, 1)] = -eq[1];
601        }
602
603        BlockMatrices {
604            m,
605            a: a_mat,
606            b: b_mat,
607            c: c_mat,
608            r: r_mat,
609            h_coeff,
610        }
611    }
612
613    /// Compute RHS: `out = R * [dy_prev, dy_next]^T`
614    #[inline(always)]
615    fn compute_rhs(&self, dy_prev: f64, dy_next: f64, out: &mut [f64]) {
616        let r0 = self.r.column(0);
617        let r1 = self.r.column(1);
618        for (o, (&c0, &c1)) in out.iter_mut().zip(r0.iter().zip(r1.iter())) {
619            *o = c0 * dy_prev + c1 * dy_next;
620        }
621    }
622
623    /// Reconstruct upper Horner coefficients `a[m+1]..a[2m+1]` into `out`.
624    ///
625    /// `h_coeff` row layout: `[dy_coeff, u0_1..m, u1_1..m]` (1 + 2m columns).
626    #[inline(always)]
627    fn upper_coeffs(&self, dy: f64, u0: &[f64], u1: &[f64], out: &mut [f64]) {
628        let m = self.m;
629        for (o, row) in out.iter_mut().zip(self.h_coeff.row_iter()) {
630            // dot(row[1..=m], u0) + dot(row[m+1..=2m], u1)
631            let dot_u0: f64 = row
632                .iter()
633                .skip(1)
634                .take(m)
635                .zip(u0.iter())
636                .map(|(&c, &v)| c * v)
637                .sum();
638            let dot_u1: f64 = row
639                .iter()
640                .skip(1 + m)
641                .zip(u1.iter())
642                .map(|(&c, &v)| c * v)
643                .sum();
644            *o = row[0] * dy + dot_u0 + dot_u1;
645        }
646    }
647}
648
649// --- Main solver -------------------------------------------------------------
650
651/// Unified O(N) block-Thomas spline solver for any odd order `p = 2m+1`.
652///
653/// `start_bd` and `end_bd` are flat `dim x m` arrays (row-major) containing
654/// the m boundary derivative values at the first and last waypoint respectively.
655fn solve_general_thomas(
656    waypoints: DMatrixView<'_, f64>,
657    order: usize,
658    m: usize,
659    start_bd: &[f64],
660    end_bd: &[f64],
661) -> Result<Vec<f64>, PathError> {
662    let dim = waypoints.nrows();
663    let n = waypoints.ncols(); // number of nodes
664    let ns = n - 1; // number of segments
665    let n_coef = order + 1; // = 2m+2
666
667    let bm = BlockMatrices::for_order(m);
668
669    let chunk_len = ns * n_coef;
670    let mut coeffs = vec![0.0f64; dim * chunk_len];
671    let waypoints = &waypoints;
672
673    coeffs.par_chunks_mut(chunk_len).enumerate().try_for_each(
674        |(d, chunk)| -> Result<(), PathError> {
675            // Boundary derivative slices for this dimension: no allocation needed.
676            let u_start = &start_bd[d * m..(d + 1) * m];
677            let u_end = &end_bd[d * m..(d + 1) * m];
678            thomas_row(
679                waypoints,
680                (d, n, ns, m, n_coef),
681                &bm,
682                (u_start, u_end),
683                chunk,
684            )
685        },
686    )?;
687
688    Ok(coeffs)
689}
690
691/// Solve one dimension with the block-Thomas algorithm.
692///
693/// # Block-Thomas algorithm
694///
695/// The block-tridiagonal system is:
696///   A*u_{i-1} + B*u_i + C*u_{i+1} = rhs_i,  i=1..N-2
697/// with u_0 = u_start, u_{N-1} = u_end (known boundary values).
698///
699/// Forward sweep: B'_0 = B - A*(0) = B (u_{-1}=u_start contributes only to rhs).
700///   B'_i = B - A * (B'_{i-1}^{-1} * C)
701///   r'_i = rhs_i - A * (B'_{i-1}^{-1} * r'_{i-1})
702///
703/// Back substitution: u_{N-2} = B'_{N-2}^{-1} * r'_{N-2}
704///   u_i = B'_i^{-1} * (r'_i - C * u_{i+1})
705fn thomas_row(
706    waypoints: &DMatrixView<'_, f64>,
707    (d, n, ns, m, n_coef): (usize, usize, usize, usize, usize),
708    bm: &BlockMatrices,
709    (u_start, u_end): (&[f64], &[f64]),
710    chunk: &mut [f64],
711) -> Result<(), PathError> {
712    // Segment differences: dy[i] = waypoints[d, i+1] - waypoints[d, i]
713    let row_d = waypoints.row(d);
714    let dy: Vec<f64> = row_d
715        .iter()
716        .zip(row_d.iter().skip(1))
717        .map(|(&a, &b)| b - a)
718        .collect();
719
720    // Scratch buffers
721    let mut rhs = vec![0.0f64; m];
722    let mut upper_buf = vec![0.0f64; m + 1];
723
724    // Special case: single segment
725    if n == 2 {
726        bm.upper_coeffs(dy[0], u_start, u_end, &mut upper_buf);
727        write_segment_coeffs(chunk, 0, n_coef, m, waypoints[(d, 0)], u_start, &upper_buf);
728        return Ok(());
729    }
730
731    let ni = n - 2; // number of interior nodes (indices 1..=n-2)
732
733    // --- Forward sweep -------------------------------------------------------
734    // b_inv_c_list[i] : B'_i^{-1} * C   (m x m DMatrix)
735    // b_inv_r_list[i] : B'_i^{-1} * r'_i (m-DVector)
736    // Forward sweep: accumulate B'^{-1}*C and B'^{-1}*rhs using DMatrix
737    let mut b_inv_c_list: Vec<DMatrix<f64>> = Vec::with_capacity(ni);
738    let mut b_inv_r_list: Vec<DVector<f64>> = Vec::with_capacity(ni);
739
740    // Precompute boundary correction vectors (allocated once, reused across iterations)
741    let u_start_vec = DVector::from_column_slice(u_start);
742    let u_end_vec = DVector::from_column_slice(u_end);
743
744    for i in 0..ni {
745        let node = i + 1;
746        bm.compute_rhs(dy[node - 1], dy[node], &mut rhs);
747        let mut rhs_curr = DVector::from_column_slice(&rhs);
748
749        // Absorb boundary nodes into the RHS at the two ends of the interior system
750        if i == 0 {
751            rhs_curr -= &bm.a * &u_start_vec;
752        }
753        if i == ni - 1 {
754            rhs_curr -= &bm.c * &u_end_vec;
755        }
756
757        // B'_i = B  (i=0) or  B - A * (B'_{i-1}^{-1} * C)
758        let b_cur = if i == 0 {
759            bm.b.clone()
760        } else {
761            &bm.b - &bm.a * &b_inv_c_list[i - 1]
762        };
763
764        // r'_i -= A * (B'_{i-1}^{-1} * r'_{i-1})  for i > 0
765        if i > 0 {
766            rhs_curr -= &bm.a * &b_inv_r_list[i - 1];
767        }
768
769        let lu = b_cur.lu();
770        b_inv_c_list.push(lu.solve(&bm.c).ok_or(PathError::SingularSystem)?);
771        b_inv_r_list.push(lu.solve(&rhs_curr).ok_or(PathError::SingularSystem)?);
772    }
773
774    // --- Back substitution ---------------------------------------------------
775    // u_list[i] stores the recovered derivative DVector at interior node i+1.
776    let mut u_list: Vec<DVector<f64>> = vec![DVector::zeros(m); ni];
777    u_list[ni - 1] = b_inv_r_list[ni - 1].clone();
778    for i in (0..ni - 1).rev() {
779        u_list[i] = &b_inv_r_list[i] - &b_inv_c_list[i] * &u_list[i + 1];
780    }
781
782    // --- Reconstruct Horner coefficients ------------------------------------
783    let u_at = |k: usize| -> &[f64] {
784        if k == 0 {
785            u_start
786        } else if k >= n - 1 {
787            u_end
788        } else {
789            u_list[k - 1].as_slice()
790        }
791    };
792
793    for seg in 0..ns {
794        let ul = u_at(seg);
795        let ur = u_at(seg + 1);
796        bm.upper_coeffs(dy[seg], ul, ur, &mut upper_buf);
797        write_segment_coeffs(chunk, seg, n_coef, m, waypoints[(d, seg)], ul, &upper_buf);
798    }
799
800    Ok(())
801}
802
803/// Write the full Horner coefficient block for one segment into `chunk`.
804/// Layout: `chunk[seg * n_coef .. (seg+1) * n_coef]`
805///   - a[0]       = y_left
806///   - a[1..m]    = u_left[0..m-1]  (derivatives 1..m)
807///   - a[m+1..2m+1] = upper[0..m]
808#[inline(always)]
809fn write_segment_coeffs(
810    chunk: &mut [f64],
811    seg: usize,
812    n_coef: usize,
813    m: usize,
814    y_left: f64,
815    u_left: &[f64],
816    upper: &[f64],
817) {
818    let base = seg * n_coef;
819    chunk[base] = y_left;
820    chunk[base + 1..base + 1 + m].copy_from_slice(u_left);
821    chunk[base + m + 1..base + m + 1 + (m + 1)].copy_from_slice(upper);
822}