Skip to main content

copp\path/
path_core.rs

1use crate::diag::PathError;
2use crate::path::OutOfRangeMode;
3use crate::path::autodiff::Jet3;
4use crate::path::spline::{SplineConfig, SplinePath};
5use nalgebra::{DMatrix, DMatrixView};
6use rayon::prelude::*;
7use std::sync::Arc;
8
9const EPS_RANGE: f64 = 1e-12;
10
11/// Shared analytic path function used by [`Path::from_parametric`].
12///
13/// The input is a seeded [`Jet3`] scalar representing path parameter `s`, and
14/// the returned vector contains one [`Jet3`] per path dimension.
15pub type ParametricFn = Arc<dyn Fn(Jet3) -> Vec<Jet3> + Send + Sync>;
16
17/// User-provided path evaluator with explicit derivatives up to second order.
18///
19/// Implement this trait when path derivatives are already available from an
20/// external model, library, or hand-written analytic formula. Unlike
21/// [`Path::from_parametric`], no automatic differentiation is performed: the
22/// evaluator writes `q`, `dq`, and `ddq` directly into pre-allocated
23/// column-major buffers.
24///
25/// Buffer layout is always `dim x s.len()` in column-major order:
26/// `buffer[row + col * dim]` corresponds to path dimension `row` at sample
27/// `s[col]`. Empty `s` slices are valid no-ops.
28pub trait PathEvaluator2nd: Send + Sync {
29    /// Return the path dimension.
30    ///
31    /// The dimension must remain stable for the lifetime of the evaluator and
32    /// must be greater than zero.
33    fn dim(&self) -> usize;
34
35    /// Evaluate position only.
36    ///
37    /// The default implementation calls [`PathEvaluator2nd::evaluate_up_to_2nd`]
38    /// with temporary derivative buffers. Override this method if computing
39    /// only `q` is substantially cheaper for the evaluator.
40    fn evaluate_q(&self, s: &[f64], q: &mut [f64]) -> Result<(), PathError> {
41        let mut dq = vec![0.0; q.len()];
42        let mut ddq = vec![0.0; q.len()];
43        self.evaluate_up_to_2nd(s, q, &mut dq, &mut ddq)
44    }
45
46    /// Evaluate `q`, `dq`, and `ddq` at all supplied path parameters.
47    ///
48    /// All output buffers have length `dim() * s.len()` and use column-major
49    /// layout.
50    fn evaluate_up_to_2nd(
51        &self,
52        s: &[f64],
53        q: &mut [f64],
54        dq: &mut [f64],
55        ddq: &mut [f64],
56    ) -> Result<(), PathError>;
57}
58
59/// User-provided path evaluator with explicit derivatives up to third order.
60///
61/// This extends [`PathEvaluator2nd`] with jerk-level path derivatives. Use it
62/// when the path will be sampled by TOPP3/COPP3 workflows or any API that calls
63/// [`Path::evaluate_up_to_3rd`].
64pub trait PathEvaluator3rd: PathEvaluator2nd {
65    /// Evaluate `q`, `dq`, `ddq`, and `dddq` at all supplied path parameters.
66    ///
67    /// All output buffers have length `dim() * s.len()` and use column-major
68    /// layout.
69    fn evaluate_up_to_3rd(
70        &self,
71        s: &[f64],
72        q: &mut [f64],
73        dq: &mut [f64],
74        ddq: &mut [f64],
75        dddq: &mut [f64],
76    ) -> Result<(), PathError>;
77}
78
79/// Compatibility alias for third-order explicit path evaluators.
80///
81/// New code should prefer [`PathEvaluator2nd`] or [`PathEvaluator3rd`] to make
82/// the supported derivative order explicit.
83pub trait PathEvaluator: PathEvaluator3rd {}
84
85impl<T: PathEvaluator3rd + ?Sized> PathEvaluator for T {}
86
87/// Output of path evaluation.
88///
89/// `dq`, `ddq`, `dddq` are `None` when the evaluation did not request them
90/// (e.g. `evaluate_q` only fills `q`; `evaluate_up_to_2nd` fills `q/dq/ddq`).
91#[derive(Debug)]
92pub struct PathDerivatives {
93    /// Position samples with shape `(dim, s.len())`.
94    pub q: DMatrix<f64>,
95    /// First derivative samples `dq/ds`, populated by second- and third-order evaluation.
96    pub dq: Option<DMatrix<f64>>,
97    /// Second derivative samples `d^2q/ds^2`, populated by second- and third-order evaluation.
98    pub ddq: Option<DMatrix<f64>>,
99    /// Third derivative samples `d^3q/ds^3`, populated only by third-order evaluation.
100    pub dddq: Option<DMatrix<f64>>,
101}
102
103/// How many derivative orders to compute.
104#[derive(Clone, Copy, PartialEq, Eq)]
105enum Order {
106    Zero,  // q only
107    Two,   // q, dq, ddq
108    Three, // q, dq, ddq, dddq
109}
110
111/// Unified path abstraction over parametric, spline, and evaluator representations.
112///
113/// Construct via [`Path::from_parametric`](crate::path::Path::from_parametric),
114/// [`Path::from_waypoints`](crate::path::Path::from_waypoints),
115/// [`Path::from_evaluator_2nd`](crate::path::Path::from_evaluator_2nd), or
116/// [`Path::from_evaluator_3rd`](crate::path::Path::from_evaluator_3rd), then
117/// query a batch of parameter values with the `evaluate_*` family of methods.
118///
119/// The valid parameter domain is `[s_min, s_max]` (set at construction time).
120/// Out-of-range behaviour is controlled by [`OutOfRangeMode`](crate::path::OutOfRangeMode): the default is to
121/// return an error; it can be changed to silent clamping.
122pub struct Path {
123    dim: usize,
124    s_min: f64,
125    s_max: f64,
126    out_of_range_mode: OutOfRangeMode,
127    repr: PathRepr,
128}
129
130enum PathRepr {
131    /// Closure-based path evaluated via third-order forward AD.
132    Parametric(ParametricFn),
133    /// Piecewise-polynomial path built from waypoints.
134    Spline(SplinePath),
135    /// User-provided path evaluator with explicit derivatives up to second order.
136    Evaluator2nd(Arc<dyn PathEvaluator2nd>),
137    /// User-provided path evaluator with explicit derivatives up to third order.
138    Evaluator3rd(Arc<dyn PathEvaluator3rd>),
139}
140
141impl Path {
142    /// Build a parametric path from an analytic closure.
143    ///
144    /// Derivatives up to third order are computed automatically via [`Jet3`](crate::path::Jet3)
145    /// forward-mode AD.  The closure only needs to express `q(s)` symbolically;
146    /// no manual differentiation is required.
147    ///
148    /// # Arguments
149    /// - `q_fn`  : closure mapping scalar `s` to a `dim`-dimensional position vector
150    /// - `s_min` : lower bound of the path parameter
151    /// - `s_max` : upper bound of the path parameter (`s_max > s_min` required)
152    ///
153    /// # Errors
154    /// - [`PathError::InvalidRange`](crate::diag::PathError::InvalidRange)     : `s_min >= s_max` or either value is non-finite
155    /// - [`PathError::InvalidDimension`](crate::diag::PathError::InvalidDimension) : closure returned an empty vector
156    pub fn from_parametric<F>(q_fn: F, s_min: f64, s_max: f64) -> Result<Self, PathError>
157    where
158        F: Fn(Jet3) -> Vec<Jet3> + Send + Sync + 'static,
159    {
160        validate_range(s_min, s_max)?;
161
162        let sample = q_fn(Jet3::constant((s_min + s_max) * 0.5));
163        if sample.is_empty() {
164            return Err(PathError::InvalidDimension { dim: 0 });
165        }
166        let dim = sample.len();
167
168        Ok(Self {
169            dim,
170            s_min,
171            s_max,
172            out_of_range_mode: OutOfRangeMode::Error,
173            repr: PathRepr::Parametric(Arc::new(q_fn)),
174        })
175    }
176
177    /// Build a path from an evaluator that provides explicit derivatives up to second order.
178    ///
179    /// Use this constructor when derivatives are already available from an
180    /// external source and automatic differentiation is not desired. The
181    /// evaluator is owned by the returned [`Path`] through an internal
182    /// [`Arc`], so the path can be passed around without borrowing the original
183    /// value.
184    ///
185    /// # Arguments
186    /// - `evaluator`: object that writes column-major derivative buffers
187    /// - `s_min`: lower bound of the path parameter
188    /// - `s_max`: upper bound of the path parameter (`s_max > s_min` required)
189    ///
190    /// # Errors
191    /// - [`PathError::InvalidRange`](crate::diag::PathError::InvalidRange): `s_min >= s_max` or either value is non-finite
192    /// - [`PathError::InvalidDimension`](crate::diag::PathError::InvalidDimension): evaluator dimension is zero
193    pub fn from_evaluator_2nd<E>(evaluator: E, s_min: f64, s_max: f64) -> Result<Self, PathError>
194    where
195        E: PathEvaluator2nd + 'static,
196    {
197        Self::from_shared_evaluator_2nd(Arc::new(evaluator), s_min, s_max)
198    }
199
200    /// Build a path from a shared explicit-derivative evaluator up to second order.
201    ///
202    /// This is the same representation as [`Path::from_evaluator_2nd`], but accepts
203    /// an already shared evaluator. It is useful when multiple paths or
204    /// application components need to hold the same evaluator object.
205    ///
206    /// # Errors
207    /// - [`PathError::InvalidRange`](crate::diag::PathError::InvalidRange): `s_min >= s_max` or either value is non-finite
208    /// - [`PathError::InvalidDimension`](crate::diag::PathError::InvalidDimension): evaluator dimension is zero
209    pub fn from_shared_evaluator_2nd(
210        evaluator: Arc<dyn PathEvaluator2nd>,
211        s_min: f64,
212        s_max: f64,
213    ) -> Result<Self, PathError> {
214        validate_range(s_min, s_max)?;
215
216        let dim = evaluator.dim();
217        if dim == 0 {
218            return Err(PathError::InvalidDimension { dim });
219        }
220
221        Ok(Self {
222            dim,
223            s_min,
224            s_max,
225            out_of_range_mode: OutOfRangeMode::Error,
226            repr: PathRepr::Evaluator2nd(evaluator),
227        })
228    }
229
230    /// Build a path from an evaluator that provides explicit derivatives up to third order.
231    ///
232    /// This is the constructor to use when the path will be evaluated by
233    /// third-order APIs such as TOPP3/COPP3 sampling.  For TOPP2/COPP2-only
234    /// usage, [`Path::from_evaluator_2nd`] avoids requiring a third derivative
235    /// implementation.
236    ///
237    /// # Errors
238    /// - [`PathError::InvalidRange`](crate::diag::PathError::InvalidRange): `s_min >= s_max` or either value is non-finite
239    /// - [`PathError::InvalidDimension`](crate::diag::PathError::InvalidDimension): evaluator dimension is zero
240    pub fn from_evaluator_3rd<E>(evaluator: E, s_min: f64, s_max: f64) -> Result<Self, PathError>
241    where
242        E: PathEvaluator3rd + 'static,
243    {
244        Self::from_shared_evaluator_3rd(Arc::new(evaluator), s_min, s_max)
245    }
246
247    /// Build a path from a shared explicit-derivative evaluator up to third order.
248    ///
249    /// # Errors
250    /// - [`PathError::InvalidRange`](crate::diag::PathError::InvalidRange): `s_min >= s_max` or either value is non-finite
251    /// - [`PathError::InvalidDimension`](crate::diag::PathError::InvalidDimension): evaluator dimension is zero
252    pub fn from_shared_evaluator_3rd(
253        evaluator: Arc<dyn PathEvaluator3rd>,
254        s_min: f64,
255        s_max: f64,
256    ) -> Result<Self, PathError> {
257        validate_range(s_min, s_max)?;
258
259        let dim = evaluator.dim();
260        if dim == 0 {
261            return Err(PathError::InvalidDimension { dim });
262        }
263
264        Ok(Self {
265            dim,
266            s_min,
267            s_max,
268            out_of_range_mode: OutOfRangeMode::Error,
269            repr: PathRepr::Evaluator3rd(evaluator),
270        })
271    }
272
273    /// Build a path from a third-order explicit-derivative evaluator.
274    ///
275    /// This compatibility constructor is equivalent to
276    /// [`Path::from_evaluator_3rd`]. New code should prefer
277    /// [`Path::from_evaluator_2nd`] or [`Path::from_evaluator_3rd`] to make the
278    /// supported derivative order explicit.
279    pub fn from_evaluator<E>(evaluator: E, s_min: f64, s_max: f64) -> Result<Self, PathError>
280    where
281        E: PathEvaluator3rd + 'static,
282    {
283        Self::from_evaluator_3rd(evaluator, s_min, s_max)
284    }
285
286    /// Build a path from a shared third-order explicit-derivative evaluator.
287    ///
288    /// This compatibility constructor is equivalent to
289    /// [`Path::from_shared_evaluator_3rd`].
290    pub fn from_shared_evaluator(
291        evaluator: Arc<dyn PathEvaluator3rd>,
292        s_min: f64,
293        s_max: f64,
294    ) -> Result<Self, PathError> {
295        Self::from_shared_evaluator_3rd(evaluator, s_min, s_max)
296    }
297
298    /// Build a spline path by interpolating a waypoint matrix.
299    ///
300    /// Internally solves the Hermite spline system with an O(N) block-Thomas
301    /// algorithm; all dimensions are solved in parallel.
302    /// The default configuration ([`SplineConfig::default`](crate::path::SplineConfig::default)) uses a quintic
303    /// (order-5) spline with `s in [0, 1]`.
304    ///
305    /// # Arguments
306    /// - `waypoints` : matrix of shape `(dim, n_points)`; each column is one waypoint
307    /// - `cfg`       : spline configuration (order, parameter range, boundary derivatives, out-of-range mode)
308    ///
309    /// # Errors
310    /// - [`PathError::InvalidDimension`](crate::diag::PathError::InvalidDimension)   : `waypoints` has zero rows
311    /// - [`PathError::NotEnoughWaypoints`](crate::diag::PathError::NotEnoughWaypoints) : fewer than 2 columns
312    /// - [`PathError::InvalidOrder`](crate::diag::PathError::InvalidOrder)       : `order < 3`
313    /// - [`PathError::InvalidRange`](crate::diag::PathError::InvalidRange)       : invalid parameter range
314    /// - [`PathError::SingularSystem`](crate::diag::PathError::SingularSystem)     : spline system is singular (extremely rare)
315    pub fn from_waypoints(waypoints: &DMatrix<f64>, cfg: SplineConfig) -> Result<Self, PathError> {
316        Self::from_waypoints_view(waypoints.as_view(), cfg)
317    }
318
319    /// Build a spline path from a borrowed waypoint matrix view.
320    ///
321    /// This accepts nalgebra views such as `waypoints.as_view()` and compatible
322    /// strided column-major views. See [`Path::from_waypoints`] for the full
323    /// interpolation semantics, configuration, and error conditions.
324    pub fn from_waypoints_view(
325        waypoints: DMatrixView<'_, f64>,
326        cfg: SplineConfig,
327    ) -> Result<Self, PathError> {
328        if waypoints.nrows() == 0 {
329            return Err(PathError::InvalidDimension {
330                dim: waypoints.nrows(),
331            });
332        }
333        if waypoints.ncols() < 2 {
334            return Err(PathError::NotEnoughWaypoints {
335                n: waypoints.ncols(),
336            });
337        }
338        if cfg.order < 3 {
339            return Err(PathError::InvalidOrder { order: cfg.order });
340        }
341        validate_range(cfg.s_min, cfg.s_max)?;
342
343        let spline = SplinePath::from_waypoints_view(waypoints, &cfg)?;
344
345        Ok(Self {
346            dim: waypoints.nrows(),
347            s_min: spline.s_min,
348            s_max: spline.s_max,
349            out_of_range_mode: spline.out_of_range_mode,
350            repr: PathRepr::Spline(spline),
351        })
352    }
353
354    /// Returns the spatial dimension (number of joints) of the path.
355    #[inline(always)]
356    pub fn dim(&self) -> usize {
357        self.dim
358    }
359
360    /// Returns the valid parameter range `(s_min, s_max)`.
361    #[inline(always)]
362    pub fn s_range(&self) -> (f64, f64) {
363        (self.s_min, self.s_max)
364    }
365
366    /// Evaluate position `q` only at the query points (cheapest; no derivatives).
367    ///
368    /// # Arguments
369    /// - `s` : one-dimensional parameter samples (length `N`)
370    ///
371    /// # Returns
372    /// [`PathDerivatives`](crate::path::PathDerivatives) with `dq / ddq / dddq` all `None`;
373    /// `q` has shape `(dim, N)`.
374    ///
375    /// # Errors
376    /// - [`PathError::OutOfRangeS`] : a query value is out of range (only in `Error` mode)
377    pub fn evaluate_q(&self, s: &[f64]) -> Result<PathDerivatives, PathError> {
378        self.evaluate_impl(s, Order::Zero)
379    }
380
381    /// Evaluate position, velocity, and acceleration (`q`, `dq`, `ddq`); jerk is not computed.
382    ///
383    /// # Arguments
384    /// - `s` : one-dimensional parameter samples (length `N`)
385    ///
386    /// # Returns
387    /// [`PathDerivatives`](crate::path::PathDerivatives) with `dddq = None`;
388    /// `q / dq / ddq` each have shape `(dim, N)`.
389    pub fn evaluate_up_to_2nd(&self, s: &[f64]) -> Result<PathDerivatives, PathError> {
390        self.evaluate_impl(s, Order::Two)
391    }
392
393    /// Evaluate position and all three derivative orders (`q`, `dq`, `ddq`, `dddq`).
394    ///
395    /// This is the most expensive evaluation method.  If jerk is not needed,
396    /// prefer [`evaluate_up_to_2nd`].
397    ///
398    /// # Arguments
399    /// - `s` : one-dimensional parameter samples (length `N`)
400    ///
401    /// # Returns
402    /// [`PathDerivatives`](crate::path::PathDerivatives) with all four fields populated; each matrix has shape `(dim, N)`.
403    ///
404    /// # Errors
405    /// - [`PathError::OutOfRangeS`] : a query value is out of range
406    ///
407    /// # Example
408    /// ```rust, no_run
409    /// use copp::path::{Path, sin, cos};
410    /// use copp::path::autodiff::Jet3;
411    ///
412    /// let path = Path::from_parametric(
413    ///     |s: Jet3| vec![sin(s), cos(s)],
414    ///     0.0, 1.0,
415    /// ).unwrap();
416    ///
417    /// let s = [0.0, 0.25, 0.5, 0.75, 1.0];
418    /// let out = path.evaluate_up_to_3rd(&s).unwrap();
419    ///
420    /// let dq   = out.dq.as_ref().unwrap();
421    /// let dddq = out.dddq.as_ref().unwrap();
422    /// // dim 0 is sin(s); its first derivative is cos(s)
423    /// assert!((dq[(0, 0)] - 1.0_f64.cos()).abs() < 1e-10);
424    /// // dim 1 is cos(s); its third derivative is sin(s)
425    /// assert!((dddq[(1, 0)] - 0.0_f64.sin()).abs() < 1e-10);
426    /// ```
427    ///
428    /// [`evaluate_up_to_2nd`]: Path::evaluate_up_to_2nd
429    pub fn evaluate_up_to_3rd(&self, s: &[f64]) -> Result<PathDerivatives, PathError> {
430        self.evaluate_impl(s, Order::Three)
431    }
432
433    // ── internal ─────────────────────────────────────────────────────────────
434
435    fn evaluate_impl(&self, s: &[f64], order: Order) -> Result<PathDerivatives, PathError> {
436        let n = s.len();
437        let dim = self.dim;
438
439        // Allocate output buffers; skip higher-order buffers when not needed.
440        let mut q = vec![0.0f64; dim * n];
441        let mut dq = (order != Order::Zero).then(|| vec![0.0f64; dim * n]);
442        let mut ddq = (order != Order::Zero).then(|| vec![0.0f64; dim * n]);
443        let mut dddq = (order == Order::Three).then(|| vec![0.0f64; dim * n]);
444
445        match &self.repr {
446            PathRepr::Parametric(eval_fn) => {
447                eval_parametric(
448                    eval_fn,
449                    self,
450                    dim,
451                    (s, &mut q, &mut dq, &mut ddq, &mut dddq),
452                )?;
453            }
454            PathRepr::Spline(spline) => {
455                eval_spline(spline, self, dim, (s, &mut q, &mut dq, &mut ddq, &mut dddq))?;
456            }
457            PathRepr::Evaluator2nd(evaluator) => {
458                eval_evaluator_2nd(
459                    evaluator.as_ref(),
460                    self,
461                    dim,
462                    (s, &mut q, &mut dq, &mut ddq, &mut dddq),
463                )?;
464            }
465            PathRepr::Evaluator3rd(evaluator) => {
466                eval_evaluator_3rd(
467                    evaluator.as_ref(),
468                    self,
469                    dim,
470                    (s, &mut q, &mut dq, &mut ddq, &mut dddq),
471                )?;
472            }
473        }
474
475        Ok(PathDerivatives {
476            q: DMatrix::from_vec(dim, n, q),
477            dq: dq.map(|v| DMatrix::from_vec(dim, n, v)),
478            ddq: ddq.map(|v| DMatrix::from_vec(dim, n, v)),
479            dddq: dddq.map(|v| DMatrix::from_vec(dim, n, v)),
480        })
481    }
482
483    #[inline(always)]
484    fn validate_s(&self, s: f64, index: usize) -> Result<f64, PathError> {
485        match self.out_of_range_mode {
486            OutOfRangeMode::Error => {
487                if s < self.s_min - EPS_RANGE || s > self.s_max + EPS_RANGE {
488                    return Err(PathError::OutOfRangeS {
489                        s_min: self.s_min,
490                        s_max: self.s_max,
491                        index,
492                        value: s,
493                    });
494                }
495                Ok(s.clamp(self.s_min, self.s_max))
496            }
497            OutOfRangeMode::Clamp => Ok(s.clamp(self.s_min, self.s_max)),
498        }
499    }
500}
501
502// ── free evaluation functions ─────────────────────────────────────────────────
503
504/// The input of evaluation functions.
505type EvalInput<'a> = (
506    &'a [f64],                // s
507    &'a mut [f64],            // q
508    &'a mut Option<Vec<f64>>, // dq
509    &'a mut Option<Vec<f64>>, // ddq
510    &'a mut Option<Vec<f64>>, // dddq
511);
512
513/// Evaluate a parametric path into pre-allocated column-major buffers.
514///
515/// Per-column parallelism via Rayon: validate + AD-evaluate + write in one pass.
516/// No intermediate `Vec<Vec<Jet3>>` allocation; results go directly into output buffers.
517fn eval_parametric(
518    eval_fn: &ParametricFn,
519    path: &Path,
520    dim: usize,
521    input_eval: EvalInput,
522) -> Result<(), PathError> {
523    let (s_values, q, dq, ddq, dddq) = input_eval;
524    let n = s_values.len();
525
526    // Chunk each output buffer by `dim` so column j maps to slice [j*dim .. (j+1)*dim].
527    // When a derivative level is not requested (`None`), we still need an
528    // `IndexedParallelIterator` of the same length for `zip`; a Vec<None> is the
529    // simplest way to satisfy Rayon's type constraints here.
530    let dq_chunks: Vec<Option<&mut [f64]>> = dq.as_deref_mut().map_or_else(
531        || (0..n).map(|_| None).collect(),
532        |v| v.chunks_mut(dim).map(Some).collect(),
533    );
534    let ddq_chunks: Vec<Option<&mut [f64]>> = ddq.as_deref_mut().map_or_else(
535        || (0..n).map(|_| None).collect(),
536        |v| v.chunks_mut(dim).map(Some).collect(),
537    );
538    let dddq_chunks: Vec<Option<&mut [f64]>> = dddq.as_deref_mut().map_or_else(
539        || (0..n).map(|_| None).collect(),
540        |v| v.chunks_mut(dim).map(Some).collect(),
541    );
542
543    s_values
544        .par_iter()
545        .enumerate()
546        .zip(q.par_chunks_mut(dim))
547        .zip(dq_chunks.into_par_iter())
548        .zip(ddq_chunks.into_par_iter())
549        .zip(dddq_chunks.into_par_iter())
550        .map(
551            |(((((j, &s_raw), q_col), mut dq_col), mut ddq_col), mut dddq_col)| {
552                let s_curr = path.validate_s(s_raw, j)?;
553                let vals = eval_fn(Jet3::seed(s_curr));
554                if vals.len() != dim {
555                    return Err(PathError::DimensionMismatch);
556                }
557                for (i, jet) in vals.iter().enumerate() {
558                    q_col[i] = jet.v;
559                    if let Some(ref mut b) = dq_col {
560                        b[i] = jet.d1;
561                    }
562                    if let Some(ref mut b) = ddq_col {
563                        b[i] = jet.d2;
564                    }
565                    if let Some(ref mut b) = dddq_col {
566                        b[i] = jet.d3;
567                    }
568                }
569                Ok(())
570            },
571        )
572        .collect()
573}
574
575/// Evaluate spline representation into pre-allocated column-major buffers.
576///
577/// Dispatches to [`SplinePath::eval_at`](crate::path::spline::SplinePath::eval_at)`::<ORDER>` with the minimum derivative
578/// order that satisfies the request: zero run-time branching per sample:
579///   - [`Order::Zero`](Order::Zero) => `eval_at::<0>` (q only)
580///   - [`Order::Two`](Order::Two) => `eval_at::<2>` (q, dq, ddq)
581///   - [`Order::Three`](Order::Three) => `eval_at::<3>` (q, dq, ddq, dddq)
582fn eval_spline(
583    spline: &SplinePath,
584    path: &Path,
585    dim: usize,
586    input_eval: EvalInput,
587) -> Result<(), PathError> {
588    let (s_values, q, dq, ddq, dddq) = input_eval;
589    match (dq.as_deref_mut(), ddq.as_deref_mut(), dddq.as_deref_mut()) {
590        // ── Order::Zero: q only ───────────────────────────────────────────
591        (None, None, None) => s_values
592            .par_iter()
593            .enumerate()
594            .zip(q.par_chunks_mut(dim))
595            .map(|((j, &s_raw), q_col)| -> Result<(), PathError> {
596                let s_curr = path.validate_s(s_raw, j)?;
597                // eval_at::<0> only writes q_col; the derivative slices are never
598                // accessed, so zero-length arrays satisfy the borrow checker.
599                let (mut no_dq, mut no_ddq, mut no_dddq): ([f64; 0], [f64; 0], [f64; 0]) =
600                    ([], [], []);
601                spline.eval_at::<0>(s_curr, dim, q_col, &mut no_dq, &mut no_ddq, &mut no_dddq);
602                Ok(())
603            })
604            .collect(),
605        // ── Order::Two: q, dq, ddq ────────────────────────────────────────
606        (Some(dq_buf), Some(ddq_buf), None) => {
607            let dq_chunks: Vec<&mut [f64]> = dq_buf.chunks_mut(dim).collect();
608            let ddq_chunks: Vec<&mut [f64]> = ddq_buf.chunks_mut(dim).collect();
609            s_values
610                .par_iter()
611                .enumerate()
612                .zip(q.par_chunks_mut(dim))
613                .zip(dq_chunks.into_par_iter())
614                .zip(ddq_chunks.into_par_iter())
615                .map(
616                    |((((j, &s_raw), q_col), dq_col), ddq_col)| -> Result<(), PathError> {
617                        let s_curr = path.validate_s(s_raw, j)?;
618                        let mut s4 = [];
619                        spline.eval_at::<2>(s_curr, dim, q_col, dq_col, ddq_col, &mut s4);
620                        Ok(())
621                    },
622                )
623                .collect()
624        }
625        // ── Order::Three: q, dq, ddq, dddq ───────────────────────────────
626        (Some(dq_buf), Some(ddq_buf), Some(dddq_buf)) => {
627            let dq_chunks: Vec<&mut [f64]> = dq_buf.chunks_mut(dim).collect();
628            let ddq_chunks: Vec<&mut [f64]> = ddq_buf.chunks_mut(dim).collect();
629            let dddq_chunks: Vec<&mut [f64]> = dddq_buf.chunks_mut(dim).collect();
630            s_values
631                .par_iter()
632                .enumerate()
633                .zip(q.par_chunks_mut(dim))
634                .zip(dq_chunks.into_par_iter())
635                .zip(ddq_chunks.into_par_iter())
636                .zip(dddq_chunks.into_par_iter())
637                .map(
638                    |(((((j, &s_raw), q_col), dq_col), ddq_col), dddq_col)| -> Result<(), PathError> {
639                        let s_curr = path.validate_s(s_raw, j)?;
640                        spline.eval_at::<3>(s_curr, dim, q_col, dq_col, ddq_col, dddq_col);
641                        Ok(())
642                    },
643                )
644                .collect()
645        }
646        // Unreachable: evaluate_impl only produces the three patterns above.
647        _ => unreachable!("unexpected dq/ddq/dddq combination"),
648    }
649}
650
651// ── helpers ───────────────────────────────────────────────────────────────────
652
653/// Evaluate a user-provided second-order explicit-derivative evaluator.
654///
655/// The evaluator receives already validated/clamped path parameters. It is
656/// called once for the whole batch so custom evaluators can use their own
657/// vectorized implementation.
658fn eval_evaluator_2nd(
659    evaluator: &dyn PathEvaluator2nd,
660    path: &Path,
661    dim: usize,
662    input_eval: EvalInput,
663) -> Result<(), PathError> {
664    let (s_values, q, dq, ddq, dddq) = input_eval;
665    let s_valid = s_values
666        .iter()
667        .enumerate()
668        .map(|(j, &s)| path.validate_s(s, j))
669        .collect::<Result<Vec<_>, _>>()?;
670
671    if evaluator.dim() != dim {
672        return Err(PathError::DimensionMismatch);
673    }
674
675    match (dq.as_deref_mut(), ddq.as_deref_mut(), dddq.as_deref_mut()) {
676        (None, None, None) => evaluator.evaluate_q(&s_valid, q),
677        (Some(dq_buf), Some(ddq_buf), None) => {
678            evaluator.evaluate_up_to_2nd(&s_valid, q, dq_buf, ddq_buf)
679        }
680        (Some(_), Some(_), Some(_)) => Err(PathError::UnsupportedDerivativeOrder {
681            requested: 3,
682            available: 2,
683        }),
684        // Unreachable: evaluate_impl only produces the three patterns above.
685        _ => unreachable!("unexpected dq/ddq/dddq combination"),
686    }
687}
688
689/// Evaluate a user-provided third-order explicit-derivative evaluator.
690///
691/// The evaluator receives already validated/clamped path parameters. It is
692/// called once for the whole batch so custom evaluators can use their own
693/// vectorized implementation.
694fn eval_evaluator_3rd(
695    evaluator: &dyn PathEvaluator3rd,
696    path: &Path,
697    dim: usize,
698    input_eval: EvalInput,
699) -> Result<(), PathError> {
700    let (s_values, q, dq, ddq, dddq) = input_eval;
701    let s_valid = s_values
702        .iter()
703        .enumerate()
704        .map(|(j, &s)| path.validate_s(s, j))
705        .collect::<Result<Vec<_>, _>>()?;
706
707    if evaluator.dim() != dim {
708        return Err(PathError::DimensionMismatch);
709    }
710
711    match (dq.as_deref_mut(), ddq.as_deref_mut(), dddq.as_deref_mut()) {
712        (None, None, None) => evaluator.evaluate_q(&s_valid, q),
713        (Some(dq_buf), Some(ddq_buf), None) => {
714            evaluator.evaluate_up_to_2nd(&s_valid, q, dq_buf, ddq_buf)
715        }
716        (Some(dq_buf), Some(ddq_buf), Some(dddq_buf)) => {
717            evaluator.evaluate_up_to_3rd(&s_valid, q, dq_buf, ddq_buf, dddq_buf)
718        }
719        // Unreachable: evaluate_impl only produces the three patterns above.
720        _ => unreachable!("unexpected dq/ddq/dddq combination"),
721    }
722}
723
724fn validate_range(s_min: f64, s_max: f64) -> Result<(), PathError> {
725    if !s_min.is_finite() || !s_max.is_finite() || s_max <= s_min {
726        return Err(PathError::InvalidRange { s_min, s_max });
727    }
728    Ok(())
729}
730
731#[cfg(test)]
732mod tests {
733    use super::PathDerivatives;
734    use crate::path::{
735        Jet3, Path as PathModel, PathError, PathEvaluator2nd, PathEvaluator3rd, SplineConfig, cos,
736        exp, sin,
737    };
738    use nalgebra::{Const, DMatrix, DMatrixView, Dyn};
739    use plotters::prelude::*;
740    use rand::RngExt;
741    use std::error::Error;
742    use std::fs::create_dir_all;
743    use std::hint::black_box;
744    use std::path::Path as StdPath;
745    use std::time::Instant;
746
747    const DIM: usize = 6;
748
749    fn make_s(n: usize) -> DMatrix<f64> {
750        DMatrix::<f64>::from_fn(1, n, |_, j| j as f64 / (n - 1) as f64)
751    }
752
753    fn make_parametric_path() -> Result<PathModel, PathError> {
754        PathModel::from_parametric(
755            |s: Jet3| {
756                vec![
757                    sin(s),
758                    cos(s),
759                    exp(0.3 * s) - 1.0,
760                    s + 0.1 * s * s - 0.01 * s * s * s * s,
761                    sin(2.0 * s) + 0.15 * cos(3.0 * s),
762                    sin(s) * cos(s),
763                ]
764            },
765            0.0,
766            1.0,
767        )
768    }
769
770    struct PolynomialEvaluator;
771    struct QuadraticEvaluator2nd;
772
773    impl PathEvaluator2nd for PolynomialEvaluator {
774        fn dim(&self) -> usize {
775            2
776        }
777
778        fn evaluate_up_to_2nd(
779            &self,
780            s: &[f64],
781            q: &mut [f64],
782            dq: &mut [f64],
783            ddq: &mut [f64],
784        ) -> Result<(), PathError> {
785            if q.len() != 2 * s.len() || dq.len() != q.len() || ddq.len() != q.len() {
786                return Err(PathError::DimensionMismatch);
787            }
788
789            for (j, &x) in s.iter().enumerate() {
790                let col = 2 * j;
791                q[col] = x * x * x;
792                dq[col] = 3.0 * x * x;
793                ddq[col] = 6.0 * x;
794
795                q[col + 1] = x * x + 1.0;
796                dq[col + 1] = 2.0 * x;
797                ddq[col + 1] = 2.0;
798            }
799            Ok(())
800        }
801    }
802
803    impl PathEvaluator3rd for PolynomialEvaluator {
804        fn evaluate_up_to_3rd(
805            &self,
806            s: &[f64],
807            q: &mut [f64],
808            dq: &mut [f64],
809            ddq: &mut [f64],
810            dddq: &mut [f64],
811        ) -> Result<(), PathError> {
812            self.evaluate_up_to_2nd(s, q, dq, ddq)?;
813            if dddq.len() != 2 * s.len() {
814                return Err(PathError::DimensionMismatch);
815            }
816
817            for j in 0..s.len() {
818                let col = 2 * j;
819                dddq[col] = 6.0;
820                dddq[col + 1] = 0.0;
821            }
822            Ok(())
823        }
824    }
825
826    impl PathEvaluator2nd for QuadraticEvaluator2nd {
827        fn dim(&self) -> usize {
828            1
829        }
830
831        fn evaluate_up_to_2nd(
832            &self,
833            s: &[f64],
834            q: &mut [f64],
835            dq: &mut [f64],
836            ddq: &mut [f64],
837        ) -> Result<(), PathError> {
838            if q.len() != s.len() || dq.len() != q.len() || ddq.len() != q.len() {
839                return Err(PathError::DimensionMismatch);
840            }
841
842            for (j, &x) in s.iter().enumerate() {
843                q[j] = x * x + 1.0;
844                dq[j] = 2.0 * x;
845                ddq[j] = 2.0;
846            }
847            Ok(())
848        }
849    }
850
851    fn make_waypoints(n_pts: usize) -> DMatrix<f64> {
852        let mut rng = rand::rng();
853        // Random-walk waypoints: each row is one DOF, each column is a waypoint.
854        let mut waypoints = DMatrix::<f64>::zeros(DIM, n_pts);
855        for mut row in waypoints.row_iter_mut() {
856            row[0] = rng.random_range(-1.0..1.0);
857            for j in 1..n_pts {
858                let step = rng.random_range(-0.35..0.35);
859                row[j] = row[j - 1] + step;
860            }
861        }
862        waypoints
863    }
864
865    #[test]
866    fn test_waypoints_view_interpolates_padded_column_major() -> Result<(), PathError> {
867        const DIM_LOCAL: usize = 2;
868        const N_PTS: usize = 4;
869        const LEADING_DIM: usize = 3;
870        let data = [
871            0.0, 1.0, -99.0, //
872            0.5, 1.5, -99.0, //
873            1.0, 2.0, -99.0, //
874            1.5, 2.5, -99.0,
875        ];
876        let waypoints = DMatrixView::from_slice_with_strides_generic(
877            &data,
878            Dyn(DIM_LOCAL),
879            Dyn(N_PTS),
880            Const::<1>,
881            Dyn(LEADING_DIM),
882        );
883
884        let path = PathModel::from_waypoints_view(waypoints, SplineConfig::default())?;
885        let s = [0.0, 1.0 / 3.0, 2.0 / 3.0, 1.0];
886        let out = path.evaluate_q(&s)?;
887
888        for j in 0..N_PTS {
889            assert!((out.q[(0, j)] - data[j * LEADING_DIM]).abs() < 1e-10);
890            assert!((out.q[(1, j)] - data[j * LEADING_DIM + 1]).abs() < 1e-10);
891        }
892
893        Ok(())
894    }
895
896    #[test]
897    fn test_evaluator_path_explicit_derivatives() -> Result<(), PathError> {
898        let path = PathModel::from_evaluator_3rd(PolynomialEvaluator, -1.0, 1.0)?;
899        let s = [-1.0, 0.0, 0.5];
900
901        let out = path.evaluate_up_to_3rd(&s)?;
902        let dq = out.dq.as_ref().unwrap();
903        let ddq = out.ddq.as_ref().unwrap();
904        let dddq = out.dddq.as_ref().unwrap();
905
906        for (j, &x) in s.iter().enumerate() {
907            assert!((out.q[(0, j)] - x.powi(3)).abs() < 1e-12);
908            assert!((dq[(0, j)] - 3.0 * x * x).abs() < 1e-12);
909            assert!((ddq[(0, j)] - 6.0 * x).abs() < 1e-12);
910            assert!((dddq[(0, j)] - 6.0).abs() < 1e-12);
911
912            assert!((out.q[(1, j)] - (x * x + 1.0)).abs() < 1e-12);
913            assert!((dq[(1, j)] - 2.0 * x).abs() < 1e-12);
914            assert!((ddq[(1, j)] - 2.0).abs() < 1e-12);
915            assert!(dddq[(1, j)].abs() < 1e-12);
916        }
917
918        let q_only = path.evaluate_q(&s)?;
919        assert!(q_only.dq.is_none());
920        assert!(q_only.ddq.is_none());
921        assert!(q_only.dddq.is_none());
922        assert!((q_only.q[(0, 2)] - 0.125).abs() < 1e-12);
923
924        Ok(())
925    }
926
927    #[test]
928    fn test_evaluator_path_2nd_does_not_require_3rd() -> Result<(), PathError> {
929        let path = PathModel::from_evaluator_2nd(QuadraticEvaluator2nd, -1.0, 1.0)?;
930        let s = [-1.0, 0.0, 0.5];
931
932        let out = path.evaluate_up_to_2nd(&s)?;
933        let dq = out.dq.as_ref().unwrap();
934        let ddq = out.ddq.as_ref().unwrap();
935
936        assert!(out.dddq.is_none());
937        assert!((out.q[(0, 2)] - 1.25).abs() < 1e-12);
938        assert!((dq[(0, 2)] - 1.0).abs() < 1e-12);
939        assert!((ddq[(0, 2)] - 2.0).abs() < 1e-12);
940
941        let err = path.evaluate_up_to_3rd(&s).unwrap_err();
942        match err {
943            PathError::UnsupportedDerivativeOrder {
944                requested: 3,
945                available: 2,
946            } => {}
947            other => panic!("unexpected error: {other}"),
948        }
949
950        Ok(())
951    }
952
953    #[test]
954    fn test_parametric_autodiff_dim6() -> Result<(), PathError> {
955        let path = make_parametric_path()?;
956
957        let n = 300;
958        let s = make_s(n);
959        let out = path.evaluate_up_to_3rd(s.as_slice())?;
960        let dq = out.dq.as_ref().unwrap();
961        let ddq = out.ddq.as_ref().unwrap();
962        let dddq = out.dddq.as_ref().unwrap();
963
964        // Build expected values for all query points and check all 4 derivative orders.
965        s.as_slice().iter().enumerate().for_each(|(j, &x)| {
966            let e03x = (0.3 * x).exp();
967            let expected_q = [
968                x.sin(),
969                x.cos(),
970                e03x - 1.0,
971                x + 0.1 * x * x - 0.01 * x * x * x * x,
972                (2.0 * x).sin() + 0.15 * (3.0 * x).cos(),
973                x.sin() * x.cos(),
974            ];
975            let expected_dq = [
976                x.cos(),
977                -x.sin(),
978                0.3 * e03x,
979                1.0 + 0.2 * x - 0.04 * x * x * x,
980                2.0 * (2.0 * x).cos() - 0.45 * (3.0 * x).sin(),
981                (2.0 * x).cos(),
982            ];
983            let expected_ddq = [
984                -x.sin(),
985                -x.cos(),
986                0.09 * e03x,
987                0.2 - 0.12 * x * x,
988                -4.0 * (2.0 * x).sin() - 1.35 * (3.0 * x).cos(),
989                -2.0 * (2.0 * x).sin(),
990            ];
991            let expected_dddq = [
992                -x.cos(),
993                x.sin(),
994                0.027 * e03x,
995                -0.24 * x,
996                -8.0 * (2.0 * x).cos() + 4.05 * (3.0 * x).sin(),
997                -4.0 * (2.0 * x).cos(),
998            ];
999
1000            for i in 0..DIM {
1001                assert!(
1002                    (out.q[(i, j)] - expected_q[i]).abs() < 1e-10,
1003                    "q    dim={i} idx={j}"
1004                );
1005                assert!(
1006                    (dq[(i, j)] - expected_dq[i]).abs() < 1e-10,
1007                    "dq   dim={i} idx={j}"
1008                );
1009                assert!(
1010                    (ddq[(i, j)] - expected_ddq[i]).abs() < 1e-10,
1011                    "ddq  dim={i} idx={j}"
1012                );
1013                assert!(
1014                    (dddq[(i, j)] - expected_dddq[i]).abs() < 1e-10,
1015                    "dddq dim={i} idx={j}"
1016                );
1017            }
1018        });
1019
1020        Ok(())
1021    }
1022
1023    #[test]
1024    fn test_evaluate_q_only() -> Result<(), PathError> {
1025        let path = make_parametric_path()?;
1026        let n = 100;
1027        let s = make_s(n);
1028        let out = path.evaluate_q(s.as_slice())?;
1029
1030        assert!(out.dq.is_none());
1031        assert!(out.ddq.is_none());
1032        assert!(out.dddq.is_none());
1033
1034        for j in 0..n {
1035            let x = s[(0, j)];
1036            assert!((out.q[(0, j)] - x.sin()).abs() < 1e-10);
1037            assert!((out.q[(1, j)] - x.cos()).abs() < 1e-10);
1038        }
1039
1040        Ok(())
1041    }
1042
1043    #[test]
1044    fn test_evaluate_up_to_2nd() -> Result<(), PathError> {
1045        let path = make_parametric_path()?;
1046        let n = 100;
1047        let s = make_s(n);
1048        let out = path.evaluate_up_to_2nd(s.as_slice())?;
1049        let dq = out.dq.as_ref().unwrap();
1050        let ddq = out.ddq.as_ref().unwrap();
1051
1052        assert!(out.dddq.is_none());
1053
1054        for j in 0..n {
1055            let x = s[(0, j)];
1056            assert!((out.q[(0, j)] - x.sin()).abs() < 1e-10);
1057            assert!((dq[(0, j)] - x.cos()).abs() < 1e-10);
1058            assert!((ddq[(0, j)] - (-x.sin())).abs() < 1e-10);
1059        }
1060
1061        Ok(())
1062    }
1063
1064    #[test]
1065    fn test_quintic_spline_interpolates_waypoints_dim6() -> Result<(), PathError> {
1066        let n_pts = 25;
1067        let waypoints = make_waypoints(n_pts);
1068
1069        let cfg = SplineConfig::default();
1070        let path = PathModel::from_waypoints(&waypoints, cfg)?;
1071
1072        let s = DMatrix::<f64>::from_fn(1, n_pts, |_, j| j as f64 / (n_pts - 1) as f64);
1073        let out = path.evaluate_up_to_3rd(s.as_slice())?;
1074        let dq = out.dq.as_ref().unwrap();
1075        let ddq = out.ddq.as_ref().unwrap();
1076        let dddq = out.dddq.as_ref().unwrap();
1077
1078        // The spline must interpolate every waypoint exactly (up to floating-point rounding)
1079        // and all derivatives must be finite (no blowup).
1080        for (i, j) in (0..DIM).flat_map(|i| (0..n_pts).map(move |j| (i, j))) {
1081            assert!((out.q[(i, j)] - waypoints[(i, j)]).abs() < 1e-8);
1082            assert!(dq[(i, j)].is_finite());
1083            assert!(ddq[(i, j)].is_finite());
1084            assert!(dddq[(i, j)].is_finite());
1085        }
1086
1087        Ok(())
1088    }
1089
1090    #[test]
1091    fn test_s_out_of_range_error_dim6() -> Result<(), PathError> {
1092        let waypoints = make_waypoints(12);
1093        let path = PathModel::from_waypoints(&waypoints, SplineConfig::default())?;
1094        let s = DMatrix::<f64>::from_row_slice(1, 3, &[-0.1, 0.5, 1.1]);
1095        let err = path.evaluate_up_to_3rd(s.as_slice()).unwrap_err();
1096        match err {
1097            PathError::OutOfRangeS { .. } => {}
1098            _ => panic!("expected OutOfRangeS"),
1099        }
1100        Ok(())
1101    }
1102
1103    #[test]
1104    fn test_benchmark_parametric_and_spline_dim6() -> Result<(), PathError> {
1105        let n_eval = 3000;
1106        let n_repeat = 8;
1107        let s = make_s(n_eval);
1108
1109        let start = Instant::now();
1110        let param_path = make_parametric_path()?;
1111        let tc_build_param = start.elapsed().as_secs_f64() * 1e3;
1112
1113        let start = Instant::now();
1114        for _ in 0..n_repeat {
1115            let out = param_path.evaluate_up_to_3rd(s.as_slice())?;
1116            black_box(out.q[(0, 0)]);
1117        }
1118        let tc_eval_param = start.elapsed().as_secs_f64() * 1e3 / n_repeat as f64;
1119        crate::verbosity_log!(
1120            crate::diag::Verbosity::Summary,
1121            "[bench][parametric][dim=6] build={tc_build_param:.3} ms eval={tc_eval_param:.3} ms (N={n_eval})"
1122        );
1123
1124        let n_waypoints_list = [16usize, 32, 64, 128, 192, 256, 512, 1024];
1125        for &n_pts in &n_waypoints_list {
1126            let waypoints = make_waypoints(n_pts);
1127            let start = Instant::now();
1128            let spline_path = PathModel::from_waypoints(&waypoints, SplineConfig::default())?;
1129            let tc_build = start.elapsed().as_secs_f64() * 1e3;
1130
1131            let start = Instant::now();
1132            for _ in 0..n_repeat {
1133                let out = spline_path.evaluate_up_to_3rd(s.as_slice())?;
1134                black_box(out.q[(0, 0)]);
1135            }
1136            let tc_eval = start.elapsed().as_secs_f64() * 1e3 / n_repeat as f64;
1137
1138            crate::verbosity_log!(
1139                crate::diag::Verbosity::Summary,
1140                "[bench][spline][dim=6][n_pts={n_pts}] build={tc_build:.3} ms eval={tc_eval:.3} ms"
1141            );
1142        }
1143
1144        Ok(())
1145    }
1146
1147    #[test]
1148    fn test_plot_parametric_and_spline_derivatives() -> Result<(), Box<dyn Error>> {
1149        let dir = "data/path_plots";
1150        create_dir_all(dir)?;
1151
1152        let n = 600;
1153        let s = make_s(n);
1154        let s_vec: Vec<f64> = (0..n).map(|j| s[(0, j)]).collect();
1155
1156        let param_path = make_parametric_path()?;
1157        let param_out = param_path.evaluate_up_to_3rd(s.as_slice())?;
1158        plot_grid_4x6(
1159            &format!("{dir}/parametric_dim6_grid.png"),
1160            "parametric dim=6",
1161            &s_vec,
1162            &param_out,
1163            None,
1164        )?;
1165
1166        let n_pts = 10;
1167        let waypoints = make_waypoints(n_pts);
1168        let spline_path = PathModel::from_waypoints(&waypoints, SplineConfig::default())?;
1169        let spline_out = spline_path.evaluate_up_to_3rd(s.as_slice())?;
1170        let wp_s: Vec<f64> = (0..n_pts).map(|j| j as f64 / (n_pts - 1) as f64).collect();
1171        plot_grid_4x6(
1172            &format!("{dir}/spline_order5_dim6_grid.png"),
1173            "spline order=5 dim=6",
1174            &s_vec,
1175            &spline_out,
1176            Some((&wp_s, &waypoints)),
1177        )?;
1178
1179        Ok(())
1180    }
1181
1182    fn plot_grid_4x6(
1183        file: &str,
1184        title: &str,
1185        s: &[f64],
1186        data: &PathDerivatives,
1187        waypoints: Option<(&[f64], &DMatrix<f64>)>,
1188    ) -> Result<(), Box<dyn Error>> {
1189        if let Some(parent) = StdPath::new(file).parent() {
1190            create_dir_all(parent)?;
1191        }
1192
1193        let root = BitMapBackend::new(file, (2400, 1400)).into_drawing_area();
1194        root.fill(&WHITE)?;
1195
1196        let empty = DMatrix::<f64>::zeros(0, 0);
1197        let dq = data.dq.as_ref().unwrap_or(&empty);
1198        let ddq = data.ddq.as_ref().unwrap_or(&empty);
1199        let dddq = data.dddq.as_ref().unwrap_or(&empty);
1200
1201        let areas = root.split_evenly((4, DIM));
1202        let mats = [&data.q, dq, ddq, dddq];
1203        let row_names = ["q", "dq", "ddq", "dddq"];
1204
1205        for row in 0..4 {
1206            for col in 0..DIM {
1207                let area = &areas[row * DIM + col];
1208                let series = mat_row(mats[row], col);
1209                let (mut y_min, mut y_max) = min_max_slice(&series);
1210                if (y_max - y_min).abs() < 1e-12 {
1211                    y_min -= 1.0;
1212                    y_max += 1.0;
1213                } else {
1214                    let pad = 0.08 * (y_max - y_min);
1215                    y_min -= pad;
1216                    y_max += pad;
1217                }
1218
1219                let mut chart = ChartBuilder::on(area)
1220                    .margin(8)
1221                    .caption(
1222                        format!("{} j{}", row_names[row], col + 1),
1223                        ("sans-serif", 16),
1224                    )
1225                    .x_label_area_size(24)
1226                    .y_label_area_size(38)
1227                    .build_cartesian_2d(s[0]..s[s.len() - 1], y_min..y_max)?;
1228
1229                chart
1230                    .configure_mesh()
1231                    .x_desc(if row == 3 { "s" } else { "" })
1232                    .y_desc("")
1233                    .draw()?;
1234
1235                chart.draw_series(LineSeries::new(
1236                    (0..s.len()).map(|j| (s[j], series[j])),
1237                    &BLUE,
1238                ))?;
1239
1240                if row == 0
1241                    && let Some((s_wp, q_wp)) = waypoints
1242                {
1243                    chart.draw_series(
1244                        s_wp.iter()
1245                            .zip(q_wp.row(col).iter())
1246                            .map(|(&xs, &ys)| Circle::new((xs, ys), 2, RED.filled())),
1247                    )?;
1248                }
1249            }
1250        }
1251
1252        root.titled(title, ("sans-serif", 28))?;
1253        root.present()?;
1254        Ok(())
1255    }
1256
1257    fn mat_row(mat: &DMatrix<f64>, row: usize) -> Vec<f64> {
1258        mat.row(row).iter().copied().collect()
1259    }
1260
1261    fn min_max_slice(data: &[f64]) -> (f64, f64) {
1262        data.iter()
1263            .copied()
1264            .fold((f64::INFINITY, f64::NEG_INFINITY), |(mn, mx), v| {
1265                (mn.min(v), mx.max(v))
1266            })
1267    }
1268}