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