Skip to main content

copp\path/
spline.rs

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