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