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::smoothing::{SmoothedSpline, SmoothingConfig, SmoothingReport};
5use crate::path::spline::{SplineConfig, SplinePath};
6use nalgebra::{DMatrix, DMatrixView};
7use rayon::prelude::*;
8use std::sync::Arc;
9
10const EPS_RANGE: f64 = 1e-12;
11
12/// Shared analytic path function used by [`Path::from_parametric`].
13///
14/// The input is a seeded [`Jet3`] scalar representing path parameter `s`, and
15/// the returned vector contains one [`Jet3`] per path dimension.
16pub type ParametricFn = Arc<dyn Fn(Jet3) -> Vec<Jet3> + Send + Sync>;
17
18/// User-provided path evaluator with explicit derivatives up to second order.
19///
20/// Implement this trait when path derivatives are already available from an
21/// external model, library, or hand-written analytic formula. Unlike
22/// [`Path::from_parametric`], no automatic differentiation is performed: the
23/// evaluator writes `q`, `dq`, and `ddq` directly into pre-allocated
24/// column-major buffers.
25///
26/// Buffer layout is always `dim x s.len()` in column-major order:
27/// `buffer[row + col * dim]` corresponds to path dimension `row` at sample
28/// `s[col]`. Empty `s` slices are valid no-ops.
29///
30/// See [`Path::from_evaluator_2nd`] for a complete constructor example.
31pub trait PathEvaluator2nd: Send + Sync {
32    /// Return the path dimension.
33    ///
34    /// The dimension must remain stable for the lifetime of the evaluator and
35    /// must be greater than zero.
36    fn dim(&self) -> usize;
37
38    /// Evaluate position only.
39    ///
40    /// The default implementation calls [`PathEvaluator2nd::evaluate_up_to_2nd`]
41    /// with temporary derivative buffers. Override this method if computing
42    /// only `q` is substantially cheaper for the evaluator.
43    fn evaluate_q(&self, s: &[f64], q: &mut [f64]) -> Result<(), PathError> {
44        let mut dq = vec![0.0; q.len()];
45        let mut ddq = vec![0.0; q.len()];
46        self.evaluate_up_to_2nd(s, q, &mut dq, &mut ddq)
47    }
48
49    /// Evaluate `q`, `dq`, and `ddq` at all supplied path parameters.
50    ///
51    /// All output buffers have length `dim() * s.len()` and use column-major
52    /// layout.
53    fn evaluate_up_to_2nd(
54        &self,
55        s: &[f64],
56        q: &mut [f64],
57        dq: &mut [f64],
58        ddq: &mut [f64],
59    ) -> Result<(), PathError>;
60}
61
62/// User-provided path evaluator with explicit derivatives up to third order.
63///
64/// This extends [`PathEvaluator2nd`] with jerk-level path derivatives. Use it
65/// when the path will be sampled by TOPP3/COPP3 workflows or any API that calls
66/// [`Path::evaluate_up_to_3rd`].
67///
68/// See [`Path::from_evaluator_3rd`] for a complete constructor example.
69pub trait PathEvaluator3rd: PathEvaluator2nd {
70    /// Evaluate `q`, `dq`, `ddq`, and `dddq` at all supplied path parameters.
71    ///
72    /// All output buffers have length `dim() * s.len()` and use column-major
73    /// layout.
74    fn evaluate_up_to_3rd(
75        &self,
76        s: &[f64],
77        q: &mut [f64],
78        dq: &mut [f64],
79        ddq: &mut [f64],
80        dddq: &mut [f64],
81    ) -> Result<(), PathError>;
82}
83
84/// Compatibility alias for third-order explicit path evaluators.
85///
86/// New code should prefer [`PathEvaluator2nd`] or [`PathEvaluator3rd`] to make
87/// the supported derivative order explicit.
88pub trait PathEvaluator: PathEvaluator3rd {}
89
90impl<T: PathEvaluator3rd + ?Sized> PathEvaluator for T {}
91
92/// Output of path evaluation.
93///
94/// `dq`, `ddq`, `dddq` are `None` when the evaluation did not request them
95/// (e.g. `evaluate_q` only fills `q`; `evaluate_up_to_2nd` fills `q/dq/ddq`).
96#[derive(Debug)]
97pub struct PathDerivatives {
98    /// Position samples with shape `(dim, s.len())`.
99    pub q: DMatrix<f64>,
100    /// First derivative samples `dq/ds`, populated by second- and third-order evaluation.
101    pub dq: Option<DMatrix<f64>>,
102    /// Second derivative samples `d^2q/ds^2`, populated by second- and third-order evaluation.
103    pub ddq: Option<DMatrix<f64>>,
104    /// Third derivative samples `d^3q/ds^3`, populated only by third-order evaluation.
105    pub dddq: Option<DMatrix<f64>>,
106}
107
108/// How many derivative orders to compute.
109#[derive(Clone, Copy, PartialEq, Eq)]
110enum Order {
111    Zero,  // q only
112    Two,   // q, dq, ddq
113    Three, // q, dq, ddq, dddq
114}
115
116/// Unified path abstraction over parametric, spline, and evaluator representations.
117///
118/// Construct via [`Path::from_parametric`](crate::path::Path::from_parametric),
119/// [`Path::from_waypoints_interpolating`](crate::path::Path::from_waypoints_interpolating),
120/// [`Path::from_waypoints_fitting`](crate::path::Path::from_waypoints_fitting),
121/// [`Path::from_evaluator_2nd`](crate::path::Path::from_evaluator_2nd), or
122/// [`Path::from_evaluator_3rd`](crate::path::Path::from_evaluator_3rd), then
123/// query a batch of parameter values with the `evaluate_*` family of methods.
124///
125/// The valid parameter domain is `[s_min, s_max]` (set at construction time).
126/// Out-of-range behaviour is controlled by [`OutOfRangeMode`](crate::path::OutOfRangeMode): the default is to
127/// return an error; it can be changed to silent clamping.
128pub struct Path {
129    dim: usize,
130    s_min: f64,
131    s_max: f64,
132    out_of_range_mode: OutOfRangeMode,
133    repr: PathRepr,
134}
135
136enum PathRepr {
137    /// Closure-based path evaluated via third-order forward AD.
138    Parametric(ParametricFn),
139    /// Piecewise-polynomial path built from waypoints.
140    Spline(SplinePath),
141    /// Tolerance-fitted selected axes plus waypoint interpolation of unselected axes.
142    Smoothed(Box<SmoothedSpline>),
143    /// User-provided path evaluator with explicit derivatives up to second order.
144    Evaluator2nd(Arc<dyn PathEvaluator2nd>),
145    /// User-provided path evaluator with explicit derivatives up to third order.
146    Evaluator3rd(Arc<dyn PathEvaluator3rd>),
147}
148
149impl Path {
150    /// Construct a tolerance-bounded `C4` quintic approximation of a waypoint polyline.
151    ///
152    /// This constructor is intended for sampled or programmed paths whose
153    /// selected coordinates may be denoised or rounded within explicit absolute
154    /// tolerances. Use
155    /// [`Path::from_waypoints_interpolating`](crate::path::Path::from_waypoints_interpolating)
156    /// when every waypoint must be interpolated.
157    /// For a borrowed compatible strided column-major matrix, use
158    /// [`Path::from_waypoints_fitting_view`].
159    ///
160    /// # API stability
161    ///
162    /// This constructor is currently **unstable**. Its name, signature,
163    /// configuration, and algorithm-selection interface may change as additional
164    /// waypoint-fitting methods are introduced.
165    ///
166    /// # Reference path and tolerances
167    ///
168    /// `waypoints` has shape `(dim, n_points)` and each **column** is one ordered
169    /// waypoint. A common parameter is assigned to every column by
170    /// [`SmoothingConfig::parameters`](crate::path::SmoothingConfig::parameters),
171    /// or uniformly on `[0, 1]` by default. Adjacent columns are linearly
172    /// interpolated at that common parameter to form the reference polyline.
173    ///
174    /// For each selected axis `j`, successful construction numerically audits
175    /// the complete-domain contract
176    ///
177    /// $$
178    /// \max_s |q_j(s)-r_j(s)| \le \varepsilon_j.
179    /// $$
180    ///
181    /// This is a same-parameter, per-axis absolute bound in input-coordinate
182    /// units. It is not merely a waypoint check, a nearest-distance/Hausdorff
183    /// bound, or a Cartesian error after forward kinematics.
184    ///
185    /// # Continuity and derivatives
186    ///
187    /// Selected axes use one adaptive nonuniform quintic B-spline with simple
188    /// interior knots, giving `C4` continuity. Their first and last positions
189    /// are fixed to the input, while endpoint derivatives are determined by the
190    /// fit. Unselected axes use a separate quintic `C4` interpolant through all
191    /// input columns, with zero first and second parameter derivatives at both
192    /// endpoints. Positional endpoint equalities are subject only to ordinary
193    /// floating-point roundoff.
194    ///
195    /// Returned derivatives are with respect to the configured path parameter
196    /// `s`, not time. Smoothing therefore does not replace time parameterization
197    /// or impose physical velocity, acceleration, or jerk limits.
198    ///
199    /// # Construction method
200    ///
201    /// 1. Parameters are mapped to `[0, 1]`; every selected coordinate is
202    ///    shifted by its first value and divided by its tolerance.
203    /// 2. A straight-chord fast path is tried first. Otherwise a shared slope
204    ///    corridor seeds nonuniform knots near changes in direction.
205    /// 3. An open degree-five B-spline is fitted to the reference polyline with
206    ///    a span-scaled third-parameter-derivative penalty. Compact banded normal
207    ///    equations exploit the six-function local support.
208    /// 4. Every spline-span/reference-segment intersection is converted to a
209    ///    degree-five Bernstein error polynomial. Its coefficients provide an
210    ///    exact-arithmetic sufficient bound over the complete intersection.
211    /// 5. Failed regions receive new knots and local control-point refits until
212    ///    the computed bounds pass or a configured budget is exhausted.
213    /// 6. Coordinates are restored to physical units and the entire domain is
214    ///    audited again before the path is returned.
215    ///
216    /// The least-squares fit does not directly impose hard error constraints;
217    /// the tolerance behavior comes from this audit/refinement/rejection loop.
218    /// Bernstein bounds are computed with ordinary `f64` arithmetic and are
219    /// therefore numerical audit results, not outward-rounded formal proofs.
220    ///
221    /// # Arguments
222    ///
223    /// - `waypoints`: finite `(dim, n_points)` matrix with `dim > 0` and
224    ///   `n_points >= 2`; each column is one waypoint;
225    /// - `cfg`: selected axes, their tolerances, common waypoint parameters,
226    ///   refinement budgets, and out-of-range query behavior.
227    ///
228    /// # Returns
229    ///
230    /// A unified [`Path`] that supports position and derivatives through
231    /// [`Path::evaluate_up_to_3rd`]. Construction diagnostics and final error
232    /// bounds are available from [`Path::smoothing_report`].
233    ///
234    /// # Errors
235    ///
236    /// Returns [`PathError::Smoothing`](crate::diag::PathError::Smoothing) when:
237    ///
238    /// - waypoint values, dimensions, axis indices, tolerances, or parameters
239    ///   violate their input contracts;
240    /// - the selected-axis span count exceeds the configured segment budget;
241    /// - the banded fit encounters an unsupported or non-finite numerical system;
242    /// - refinement exhausts its pass budget or parameter resolution; or
243    /// - the final physical-unit whole-interval audit does not satisfy tolerance.
244    ///
245    /// An unchecked or silently relaxed approximation is never returned.
246    ///
247    /// # Limitations
248    ///
249    /// The adaptive knot layout is heuristic and does not guarantee the minimum
250    /// span count. Bernstein bounds use ordinary floating-point arithmetic, not
251    /// outward-rounded interval arithmetic. No guarantee is made for forward
252    /// kinematics, collision avoidance, monotonicity, dynamics, or traversal
253    /// time.
254    ///
255    /// # Example
256    ///
257    /// ```rust
258    /// # fn main() -> Result<(), copp::diag::PathError> {
259    /// use copp::path::{Path, SmoothingConfig, SmoothingTolerance};
260    /// use nalgebra::DMatrix;
261    ///
262    /// let waypoints = DMatrix::from_row_slice(
263    ///     2,
264    ///     3,
265    ///     &[
266    ///         0.0, 0.5, 1.0,
267    ///         0.0, 1.0, 2.0,
268    ///     ],
269    /// );
270    /// let config = SmoothingConfig {
271    ///     axes: Some(vec![0, 1]),
272    ///     tolerance: SmoothingTolerance::PerAxis(vec![0.001, 0.01]),
273    ///     parameters: Some(vec![2.0, 3.0, 4.0]),
274    ///     ..Default::default()
275    /// };
276    ///
277    /// let path = Path::from_waypoints_fitting(&waypoints, config)?;
278    /// let values = path.evaluate_up_to_3rd(&[2.0, 3.0, 4.0])?;
279    /// let report = path.smoothing_report().unwrap();
280    ///
281    /// assert_eq!(values.q.shape(), (2, 3));
282    /// assert_eq!(report.axes, vec![0, 1]);
283    /// assert!(report.max_errors[0] <= 0.001);
284    /// assert!(report.max_errors[1] <= 0.01);
285    /// # Ok(())
286    /// # }
287    /// ```
288    pub fn from_waypoints_fitting(
289        waypoints: &DMatrix<f64>,
290        cfg: SmoothingConfig,
291    ) -> Result<Self, PathError> {
292        Self::from_waypoints_fitting_view(waypoints.as_view(), cfg)
293    }
294
295    /// Construct a tolerance-bounded `C4` quintic approximation from a borrowed
296    /// waypoint matrix view.
297    ///
298    /// This accepts nalgebra views such as `waypoints.as_view()` and compatible
299    /// strided column-major views. Construction reads the view and stores an
300    /// owned fitted representation in the returned [`Path`], which does not
301    /// borrow the input afterwards. See [`Path::from_waypoints_fitting`] for the
302    /// complete fitting semantics, configuration, errors, and limitations.
303    ///
304    /// # API stability
305    ///
306    /// This view constructor has the same unstable API status as
307    /// [`Path::from_waypoints_fitting`].
308    pub fn from_waypoints_fitting_view(
309        waypoints: DMatrixView<'_, f64>,
310        cfg: SmoothingConfig,
311    ) -> Result<Self, PathError> {
312        let dim = waypoints.nrows();
313        let spline = SmoothedSpline::build(waypoints, &cfg)?;
314        Ok(Self {
315            dim,
316            s_min: spline.start,
317            s_max: spline.end,
318            out_of_range_mode: cfg.out_of_range_mode,
319            repr: PathRepr::Smoothed(Box::new(spline)),
320        })
321    }
322
323    /// Return construction diagnostics for a tolerance-fitted waypoint path.
324    ///
325    /// # Returns
326    ///
327    /// - `Some(report)` for a path successfully constructed by either
328    ///   [`Path::from_waypoints_fitting`] or [`Path::from_waypoints_fitting_view`];
329    /// - `None` for analytic, strictly interpolated, and user-evaluator paths.
330    ///
331    /// [`SmoothingReport::max_errors`](crate::path::SmoothingReport::max_errors)
332    /// contains Bernstein-derived whole-domain numerical bounds in physical input
333    /// units and follows the order of
334    /// [`SmoothingReport::axes`](crate::path::SmoothingReport::axes).
335    /// `refinements`, `fitting_rows`, and `checked_intervals` are cumulative work
336    /// counters; axis and segment fields describe the final representation.
337    /// None of these values is an optimality or run-time guarantee.
338    pub fn smoothing_report(&self) -> Option<&SmoothingReport> {
339        match &self.repr {
340            PathRepr::Smoothed(spline) => Some(&spline.report),
341            _ => None,
342        }
343    }
344
345    /// Build a parametric path from an analytic closure.
346    ///
347    /// Derivatives up to third order are computed automatically via [`Jet3`](crate::path::Jet3)
348    /// forward-mode AD.  The closure only needs to express `q(s)` symbolically;
349    /// no manual differentiation is required.
350    ///
351    /// # Arguments
352    /// - `q_fn`  : closure mapping scalar `s` to a `dim`-dimensional position vector
353    /// - `s_min` : lower bound of the path parameter
354    /// - `s_max` : upper bound of the path parameter (`s_max > s_min` required)
355    ///
356    /// # Errors
357    /// - [`PathError::InvalidRange`](crate::diag::PathError::InvalidRange)     : `s_min >= s_max` or either value is non-finite
358    /// - [`PathError::InvalidDimension`](crate::diag::PathError::InvalidDimension) : closure returned an empty vector
359    ///
360    /// # Example
361    /// The example below builds a two-dimensional analytic path and evaluates
362    /// derivatives produced by automatic differentiation.
363    ///
364    /// ```rust
365    /// # fn main() -> Result<(), copp::diag::CoppError> {
366    /// use copp::path::autodiff::Jet3;
367    /// use copp::path::{cos, sin, Path};
368    ///
369    /// let path = Path::from_parametric(|s: Jet3| vec![sin(s), cos(s)], 0.0, 1.0)?;
370    ///
371    /// let s = [0.0, 0.25, 0.5, 0.75, 1.0];
372    /// let out = path.evaluate_up_to_3rd(s.as_slice())?;
373    /// assert_eq!(out.q.shape(), (2, 5));
374    /// assert!(out.dddq.is_some());
375    /// # Ok(())
376    /// # }
377    /// ```
378    pub fn from_parametric<F>(q_fn: F, s_min: f64, s_max: f64) -> Result<Self, PathError>
379    where
380        F: Fn(Jet3) -> Vec<Jet3> + Send + Sync + 'static,
381    {
382        validate_range(s_min, s_max)?;
383
384        let sample = q_fn(Jet3::constant((s_min + s_max) * 0.5));
385        if sample.is_empty() {
386            return Err(PathError::InvalidDimension { dim: 0 });
387        }
388        let dim = sample.len();
389
390        Ok(Self {
391            dim,
392            s_min,
393            s_max,
394            out_of_range_mode: OutOfRangeMode::Error,
395            repr: PathRepr::Parametric(Arc::new(q_fn)),
396        })
397    }
398
399    /// Build a path from an evaluator that provides explicit derivatives up to second order.
400    ///
401    /// Use this constructor when derivatives are already available from an
402    /// external source and automatic differentiation is not desired. The
403    /// evaluator is owned by the returned [`Path`] through an internal
404    /// [`Arc`], so the path can be passed around without borrowing the original
405    /// value.
406    ///
407    /// # Arguments
408    /// - `evaluator`: object that writes column-major derivative buffers
409    /// - `s_min`: lower bound of the path parameter
410    /// - `s_max`: upper bound of the path parameter (`s_max > s_min` required)
411    ///
412    /// # Errors
413    /// - [`PathError::InvalidRange`](crate::diag::PathError::InvalidRange): `s_min >= s_max` or either value is non-finite
414    /// - [`PathError::InvalidDimension`](crate::diag::PathError::InvalidDimension): evaluator dimension is zero
415    ///
416    /// # Example
417    /// The example below wraps a two-dimensional external evaluator that writes
418    /// explicit `q/dq/ddq` buffers in column-major order.
419    ///
420    /// ```rust
421    /// # fn main() -> Result<(), copp::diag::CoppError> {
422    /// use copp::diag::PathError;
423    /// use copp::path::{Path, PathEvaluator2nd};
424    ///
425    /// struct NormalizedEvaluator2nd;
426    ///
427    /// impl PathEvaluator2nd for NormalizedEvaluator2nd {
428    ///     fn dim(&self) -> usize {
429    ///         2
430    ///     }
431    ///
432    ///     fn evaluate_up_to_2nd(
433    ///         &self,
434    ///         s: &[f64],
435    ///         q: &mut [f64],
436    ///         dq: &mut [f64],
437    ///         ddq: &mut [f64],
438    ///     ) -> Result<(), PathError> {
439    ///         for (col, &sj) in s.iter().enumerate() {
440    ///             let row0 = 2 * col;
441    ///             q[row0] = 0.5 * sj * sj;
442    ///             q[row0 + 1] = sj;
443    ///             dq[row0] = sj;
444    ///             dq[row0 + 1] = 1.0;
445    ///             ddq[row0] = 1.0;
446    ///             ddq[row0 + 1] = 0.0;
447    ///         }
448    ///         Ok(())
449    ///     }
450    /// }
451    ///
452    /// let path = Path::from_evaluator_2nd(NormalizedEvaluator2nd, 0.0, 1.0)?;
453    /// let out = path.evaluate_up_to_2nd(&[0.0, 0.5])?;
454    /// assert_eq!(out.q.shape(), (2, 2));
455    /// assert_eq!(out.q[(0, 1)], 0.125);
456    /// assert!(out.dddq.is_none());
457    /// # Ok(())
458    /// # }
459    /// ```
460    pub fn from_evaluator_2nd<E>(evaluator: E, s_min: f64, s_max: f64) -> Result<Self, PathError>
461    where
462        E: PathEvaluator2nd + 'static,
463    {
464        Self::from_shared_evaluator_2nd(Arc::new(evaluator), s_min, s_max)
465    }
466
467    /// Build a path from a shared explicit-derivative evaluator up to second order.
468    ///
469    /// This is the same representation as [`Path::from_evaluator_2nd`], but accepts
470    /// an already shared evaluator. It is useful when multiple paths or
471    /// application components need to hold the same evaluator object.
472    ///
473    /// # Errors
474    /// - [`PathError::InvalidRange`](crate::diag::PathError::InvalidRange): `s_min >= s_max` or either value is non-finite
475    /// - [`PathError::InvalidDimension`](crate::diag::PathError::InvalidDimension): evaluator dimension is zero
476    pub fn from_shared_evaluator_2nd(
477        evaluator: Arc<dyn PathEvaluator2nd>,
478        s_min: f64,
479        s_max: f64,
480    ) -> Result<Self, PathError> {
481        validate_range(s_min, s_max)?;
482
483        let dim = evaluator.dim();
484        if dim == 0 {
485            return Err(PathError::InvalidDimension { dim });
486        }
487
488        Ok(Self {
489            dim,
490            s_min,
491            s_max,
492            out_of_range_mode: OutOfRangeMode::Error,
493            repr: PathRepr::Evaluator2nd(evaluator),
494        })
495    }
496
497    /// Build a path from an evaluator that provides explicit derivatives up to third order.
498    ///
499    /// This is the constructor to use when the path will be evaluated by
500    /// third-order APIs such as TOPP3/COPP3 sampling.  For TOPP2/COPP2-only
501    /// usage, [`Path::from_evaluator_2nd`] avoids requiring a third derivative
502    /// implementation.
503    ///
504    /// # Errors
505    /// - [`PathError::InvalidRange`](crate::diag::PathError::InvalidRange): `s_min >= s_max` or either value is non-finite
506    /// - [`PathError::InvalidDimension`](crate::diag::PathError::InvalidDimension): evaluator dimension is zero
507    ///
508    /// # Example
509    /// The example below extends a two-dimensional explicit evaluator to third
510    /// order by writing jerk samples into the `dddq` buffer.
511    ///
512    /// ```rust
513    /// # fn main() -> Result<(), copp::diag::CoppError> {
514    /// use copp::diag::PathError;
515    /// use copp::path::{Path, PathEvaluator2nd, PathEvaluator3rd};
516    ///
517    /// struct NormalizedEvaluator3rd;
518    ///
519    /// impl PathEvaluator2nd for NormalizedEvaluator3rd {
520    ///     fn dim(&self) -> usize {
521    ///         2
522    ///     }
523    ///
524    ///     fn evaluate_up_to_2nd(
525    ///         &self,
526    ///         s: &[f64],
527    ///         q: &mut [f64],
528    ///         dq: &mut [f64],
529    ///         ddq: &mut [f64],
530    ///     ) -> Result<(), PathError> {
531    ///         for (col, &sj) in s.iter().enumerate() {
532    ///             let row0 = 2 * col;
533    ///             q[row0] = 0.5 * sj * sj;
534    ///             q[row0 + 1] = sj;
535    ///             dq[row0] = sj;
536    ///             dq[row0 + 1] = 1.0;
537    ///             ddq[row0] = 1.0;
538    ///             ddq[row0 + 1] = 0.0;
539    ///         }
540    ///         Ok(())
541    ///     }
542    /// }
543    ///
544    /// impl PathEvaluator3rd for NormalizedEvaluator3rd {
545    ///     fn evaluate_up_to_3rd(
546    ///         &self,
547    ///         s: &[f64],
548    ///         q: &mut [f64],
549    ///         dq: &mut [f64],
550    ///         ddq: &mut [f64],
551    ///         dddq: &mut [f64],
552    ///     ) -> Result<(), PathError> {
553    ///         self.evaluate_up_to_2nd(s, q, dq, ddq)?;
554    ///         dddq.fill(0.0);
555    ///         Ok(())
556    ///     }
557    /// }
558    ///
559    /// let path = Path::from_evaluator_3rd(NormalizedEvaluator3rd, 0.0, 1.0)?;
560    /// let out = path.evaluate_up_to_3rd(&[0.0, 0.5])?;
561    /// assert_eq!(out.q.shape(), (2, 2));
562    /// assert_eq!(out.q[(0, 1)], 0.125);
563    /// assert!(out.dddq.is_some());
564    /// # Ok(())
565    /// # }
566    /// ```
567    pub fn from_evaluator_3rd<E>(evaluator: E, s_min: f64, s_max: f64) -> Result<Self, PathError>
568    where
569        E: PathEvaluator3rd + 'static,
570    {
571        Self::from_shared_evaluator_3rd(Arc::new(evaluator), s_min, s_max)
572    }
573
574    /// Build a path from a shared explicit-derivative evaluator up to third order.
575    ///
576    /// # Errors
577    /// - [`PathError::InvalidRange`](crate::diag::PathError::InvalidRange): `s_min >= s_max` or either value is non-finite
578    /// - [`PathError::InvalidDimension`](crate::diag::PathError::InvalidDimension): evaluator dimension is zero
579    pub fn from_shared_evaluator_3rd(
580        evaluator: Arc<dyn PathEvaluator3rd>,
581        s_min: f64,
582        s_max: f64,
583    ) -> Result<Self, PathError> {
584        validate_range(s_min, s_max)?;
585
586        let dim = evaluator.dim();
587        if dim == 0 {
588            return Err(PathError::InvalidDimension { dim });
589        }
590
591        Ok(Self {
592            dim,
593            s_min,
594            s_max,
595            out_of_range_mode: OutOfRangeMode::Error,
596            repr: PathRepr::Evaluator3rd(evaluator),
597        })
598    }
599
600    /// Build a path from a third-order explicit-derivative evaluator.
601    ///
602    /// This compatibility constructor is equivalent to
603    /// [`Path::from_evaluator_3rd`]. New code should prefer
604    /// [`Path::from_evaluator_2nd`] or [`Path::from_evaluator_3rd`] to make the
605    /// supported derivative order explicit.
606    pub fn from_evaluator<E>(evaluator: E, s_min: f64, s_max: f64) -> Result<Self, PathError>
607    where
608        E: PathEvaluator3rd + 'static,
609    {
610        Self::from_evaluator_3rd(evaluator, s_min, s_max)
611    }
612
613    /// Build a path from a shared third-order explicit-derivative evaluator.
614    ///
615    /// This compatibility constructor is equivalent to
616    /// [`Path::from_shared_evaluator_3rd`].
617    pub fn from_shared_evaluator(
618        evaluator: Arc<dyn PathEvaluator3rd>,
619        s_min: f64,
620        s_max: f64,
621    ) -> Result<Self, PathError> {
622        Self::from_shared_evaluator_3rd(evaluator, s_min, s_max)
623    }
624
625    /// Build a spline path that interpolates every waypoint in a matrix.
626    ///
627    /// Every input column is a hard positional interpolation condition: at that
628    /// waypoint's assigned parameter, the returned path passes through the
629    /// column up to floating-point roundoff. This differs from
630    /// [`Path::from_waypoints_fitting`], which may deviate from interior
631    /// waypoints within configured tolerances.
632    ///
633    /// # API stability
634    ///
635    /// This constructor is currently **unstable**. Its name, signature,
636    /// configuration, and algorithm-selection interface may change as additional
637    /// waypoint-interpolation methods are introduced.
638    ///
639    /// Internally, the current algorithm solves the Hermite spline system with an O(N) block-Thomas
640    /// algorithm; all dimensions are solved in parallel.
641    /// The default configuration ([`SplineConfig::default`](crate::path::SplineConfig::default)) uses a quintic
642    /// (order-5) spline with `s in [0, 1]`.
643    ///
644    /// # Arguments
645    /// - `waypoints` : matrix of shape `(dim, n_points)`; each column is one waypoint
646    /// - `cfg`       : spline configuration (order, parameter range, boundary derivatives, out-of-range mode)
647    ///
648    /// # Errors
649    /// - [`PathError::InvalidDimension`](crate::diag::PathError::InvalidDimension)   : `waypoints` has zero rows
650    /// - [`PathError::NotEnoughWaypoints`](crate::diag::PathError::NotEnoughWaypoints) : fewer than 2 columns
651    /// - [`PathError::InvalidOrder`](crate::diag::PathError::InvalidOrder)       : `order < 3`
652    /// - [`PathError::InvalidRange`](crate::diag::PathError::InvalidRange)       : invalid parameter range
653    /// - [`PathError::SingularSystem`](crate::diag::PathError::SingularSystem)     : spline system is singular (extremely rare)
654    ///
655    /// # Example
656    /// The example below builds a spline through two-dimensional waypoints and
657    /// evaluates position samples.
658    ///
659    /// ```rust
660    /// # fn main() -> Result<(), copp::diag::CoppError> {
661    /// use copp::path::{Path, SplineConfig};
662    /// use nalgebra::DMatrix;
663    ///
664    /// let waypoints = DMatrix::from_row_slice(
665    ///     2,
666    ///     5,
667    ///     &[
668    ///         0.0, 0.25, 0.5, 0.75, 1.0,
669    ///         0.0, 0.1, -0.1, 0.2, 0.0,
670    ///     ],
671    /// );
672    /// let path = Path::from_waypoints_interpolating(&waypoints, SplineConfig::default())?;
673    ///
674    /// let s = [0.0, 0.5, 1.0];
675    /// let out = path.evaluate_q(s.as_slice())?;
676    /// assert_eq!(out.q.shape(), (2, 3));
677    /// assert!(out.dq.is_none());
678    /// # Ok(())
679    /// # }
680    /// ```
681    pub fn from_waypoints_interpolating(
682        waypoints: &DMatrix<f64>,
683        cfg: SplineConfig,
684    ) -> Result<Self, PathError> {
685        Self::from_waypoints_interpolating_view(waypoints.as_view(), cfg)
686    }
687
688    /// Build a waypoint-interpolating spline from a borrowed matrix view.
689    ///
690    /// This accepts nalgebra views such as `waypoints.as_view()` and compatible
691    /// strided column-major views. See [`Path::from_waypoints_interpolating`] for
692    /// the full interpolation semantics, configuration, error conditions, and
693    /// unstable API status.
694    pub fn from_waypoints_interpolating_view(
695        waypoints: DMatrixView<'_, f64>,
696        cfg: SplineConfig,
697    ) -> Result<Self, PathError> {
698        if waypoints.nrows() == 0 {
699            return Err(PathError::InvalidDimension {
700                dim: waypoints.nrows(),
701            });
702        }
703        if waypoints.ncols() < 2 {
704            return Err(PathError::NotEnoughWaypoints {
705                n: waypoints.ncols(),
706            });
707        }
708        if cfg.order < 3 {
709            return Err(PathError::InvalidOrder { order: cfg.order });
710        }
711        validate_range(cfg.s_min, cfg.s_max)?;
712
713        let spline = SplinePath::from_waypoints_view(waypoints, &cfg)?;
714
715        Ok(Self {
716            dim: waypoints.nrows(),
717            s_min: spline.s_min,
718            s_max: spline.s_max,
719            out_of_range_mode: spline.out_of_range_mode,
720            repr: PathRepr::Spline(spline),
721        })
722    }
723
724    /// Deprecated compatibility alias for [`Path::from_waypoints_interpolating`].
725    #[doc(hidden)]
726    #[deprecated(since = "0.2.3", note = "use Path::from_waypoints_interpolating")]
727    pub fn from_waypoints(waypoints: &DMatrix<f64>, cfg: SplineConfig) -> Result<Self, PathError> {
728        Self::from_waypoints_interpolating(waypoints, cfg)
729    }
730
731    /// Deprecated compatibility alias for [`Path::from_waypoints_interpolating_view`].
732    #[doc(hidden)]
733    #[deprecated(since = "0.2.3", note = "use Path::from_waypoints_interpolating_view")]
734    pub fn from_waypoints_view(
735        waypoints: DMatrixView<'_, f64>,
736        cfg: SplineConfig,
737    ) -> Result<Self, PathError> {
738        Self::from_waypoints_interpolating_view(waypoints, cfg)
739    }
740
741    /// Returns the spatial dimension (number of joints) of the path.
742    #[inline(always)]
743    pub fn dim(&self) -> usize {
744        self.dim
745    }
746
747    /// Returns the valid parameter range `(s_min, s_max)`.
748    #[inline(always)]
749    pub fn s_range(&self) -> (f64, f64) {
750        (self.s_min, self.s_max)
751    }
752
753    /// Evaluate position `q` only at the query points (cheapest; no derivatives).
754    ///
755    /// # Arguments
756    /// - `s` : one-dimensional parameter samples (length `N`)
757    ///
758    /// # Returns
759    /// [`PathDerivatives`](crate::path::PathDerivatives) with `dq / ddq / dddq` all `None`;
760    /// `q` has shape `(dim, N)`.
761    ///
762    /// # Errors
763    /// - [`PathError::OutOfRangeS`] : a query value is out of range (only in `Error` mode)
764    pub fn evaluate_q(&self, s: &[f64]) -> Result<PathDerivatives, PathError> {
765        self.evaluate_impl(s, Order::Zero)
766    }
767
768    /// Evaluate position, velocity, and acceleration (`q`, `dq`, `ddq`); jerk is not computed.
769    ///
770    /// # Arguments
771    /// - `s` : one-dimensional parameter samples (length `N`)
772    ///
773    /// # Returns
774    /// [`PathDerivatives`](crate::path::PathDerivatives) with `dddq = None`;
775    /// `q / dq / ddq` each have shape `(dim, N)`.
776    pub fn evaluate_up_to_2nd(&self, s: &[f64]) -> Result<PathDerivatives, PathError> {
777        self.evaluate_impl(s, Order::Two)
778    }
779
780    /// Evaluate position and all three derivative orders (`q`, `dq`, `ddq`, `dddq`).
781    ///
782    /// This is the most expensive evaluation method.  If jerk is not needed,
783    /// prefer [`evaluate_up_to_2nd`].
784    ///
785    /// # Arguments
786    /// - `s` : one-dimensional parameter samples (length `N`)
787    ///
788    /// # Returns
789    /// [`PathDerivatives`](crate::path::PathDerivatives) with all four fields populated; each matrix has shape `(dim, N)`.
790    ///
791    /// # Errors
792    /// - [`PathError::OutOfRangeS`] : a query value is out of range
793    ///
794    /// # Example
795    /// The example below evaluates an analytic path up to jerk using automatic
796    /// differentiation.
797    ///
798    /// ```rust, no_run
799    /// use copp::path::{Path, sin, cos};
800    /// use copp::path::autodiff::Jet3;
801    ///
802    /// let path = Path::from_parametric(
803    ///     |s: Jet3| vec![sin(s), cos(s)],
804    ///     0.0, 1.0,
805    /// ).unwrap();
806    ///
807    /// let s = [0.0, 0.25, 0.5, 0.75, 1.0];
808    /// let out = path.evaluate_up_to_3rd(&s).unwrap();
809    ///
810    /// let dq   = out.dq.as_ref().unwrap();
811    /// let dddq = out.dddq.as_ref().unwrap();
812    /// // dim 0 is sin(s); its first derivative is cos(s)
813    /// assert!((dq[(0, 0)] - 1.0_f64.cos()).abs() < 1e-10);
814    /// // dim 1 is cos(s); its third derivative is sin(s)
815    /// assert!((dddq[(1, 0)] - 0.0_f64.sin()).abs() < 1e-10);
816    /// ```
817    ///
818    /// [`evaluate_up_to_2nd`]: Path::evaluate_up_to_2nd
819    pub fn evaluate_up_to_3rd(&self, s: &[f64]) -> Result<PathDerivatives, PathError> {
820        self.evaluate_impl(s, Order::Three)
821    }
822
823    // ── internal ─────────────────────────────────────────────────────────────
824
825    fn evaluate_impl(&self, s: &[f64], order: Order) -> Result<PathDerivatives, PathError> {
826        let n = s.len();
827        let dim = self.dim;
828
829        // Allocate output buffers; skip higher-order buffers when not needed.
830        let mut q = vec![0.0f64; dim * n];
831        let mut dq = (order != Order::Zero).then(|| vec![0.0f64; dim * n]);
832        let mut ddq = (order != Order::Zero).then(|| vec![0.0f64; dim * n]);
833        let mut dddq = (order == Order::Three).then(|| vec![0.0f64; dim * n]);
834
835        match &self.repr {
836            PathRepr::Parametric(eval_fn) => {
837                eval_parametric(
838                    eval_fn,
839                    self,
840                    dim,
841                    (s, &mut q, &mut dq, &mut ddq, &mut dddq),
842                )?;
843            }
844            PathRepr::Spline(spline) => {
845                eval_spline(spline, self, dim, (s, &mut q, &mut dq, &mut ddq, &mut dddq))?;
846            }
847            PathRepr::Smoothed(spline) => {
848                // Nonuniform fitted spans are located per query. Validate or
849                // clamp each public parameter first; `SmoothedSpline::evaluate`
850                // then normalizes it and applies derivative chain-rule scaling.
851                for (j, &value) in s.iter().enumerate() {
852                    if !value.is_finite() {
853                        return Err(PathError::OutOfRangeS {
854                            s_min: self.s_min,
855                            s_max: self.s_max,
856                            index: j,
857                            value,
858                        });
859                    }
860                    let x = self.validate_s(value, j)?;
861                    let range = j * dim..(j + 1) * dim;
862                    spline.evaluate(
863                        x,
864                        &mut q[range.clone()],
865                        dq.as_mut().map(|v| &mut v[range.clone()]),
866                        ddq.as_mut().map(|v| &mut v[range.clone()]),
867                        dddq.as_mut().map(|v| &mut v[range]),
868                    );
869                }
870            }
871            PathRepr::Evaluator2nd(evaluator) => {
872                eval_evaluator_2nd(
873                    evaluator.as_ref(),
874                    self,
875                    dim,
876                    (s, &mut q, &mut dq, &mut ddq, &mut dddq),
877                )?;
878            }
879            PathRepr::Evaluator3rd(evaluator) => {
880                eval_evaluator_3rd(
881                    evaluator.as_ref(),
882                    self,
883                    dim,
884                    (s, &mut q, &mut dq, &mut ddq, &mut dddq),
885                )?;
886            }
887        }
888
889        Ok(PathDerivatives {
890            q: DMatrix::from_vec(dim, n, q),
891            dq: dq.map(|v| DMatrix::from_vec(dim, n, v)),
892            ddq: ddq.map(|v| DMatrix::from_vec(dim, n, v)),
893            dddq: dddq.map(|v| DMatrix::from_vec(dim, n, v)),
894        })
895    }
896
897    #[inline(always)]
898    fn validate_s(&self, s: f64, index: usize) -> Result<f64, PathError> {
899        match self.out_of_range_mode {
900            OutOfRangeMode::Error => {
901                if s < self.s_min - EPS_RANGE || s > self.s_max + EPS_RANGE {
902                    return Err(PathError::OutOfRangeS {
903                        s_min: self.s_min,
904                        s_max: self.s_max,
905                        index,
906                        value: s,
907                    });
908                }
909                Ok(s.clamp(self.s_min, self.s_max))
910            }
911            OutOfRangeMode::Clamp => Ok(s.clamp(self.s_min, self.s_max)),
912        }
913    }
914}
915
916// ── free evaluation functions ─────────────────────────────────────────────────
917
918/// The input of evaluation functions.
919type EvalInput<'a> = (
920    &'a [f64],                // s
921    &'a mut [f64],            // q
922    &'a mut Option<Vec<f64>>, // dq
923    &'a mut Option<Vec<f64>>, // ddq
924    &'a mut Option<Vec<f64>>, // dddq
925);
926
927/// Evaluate a parametric path into pre-allocated column-major buffers.
928///
929/// Per-column parallelism via Rayon: validate + AD-evaluate + write in one pass.
930/// No intermediate `Vec<Vec<Jet3>>` allocation; results go directly into output buffers.
931fn eval_parametric(
932    eval_fn: &ParametricFn,
933    path: &Path,
934    dim: usize,
935    input_eval: EvalInput,
936) -> Result<(), PathError> {
937    let (s_values, q, dq, ddq, dddq) = input_eval;
938    let n = s_values.len();
939
940    // Chunk each output buffer by `dim` so column j maps to slice [j*dim .. (j+1)*dim].
941    // When a derivative level is not requested (`None`), we still need an
942    // `IndexedParallelIterator` of the same length for `zip`; a Vec<None> is the
943    // simplest way to satisfy Rayon's type constraints here.
944    let dq_chunks: Vec<Option<&mut [f64]>> = dq.as_deref_mut().map_or_else(
945        || (0..n).map(|_| None).collect(),
946        |v| v.chunks_mut(dim).map(Some).collect(),
947    );
948    let ddq_chunks: Vec<Option<&mut [f64]>> = ddq.as_deref_mut().map_or_else(
949        || (0..n).map(|_| None).collect(),
950        |v| v.chunks_mut(dim).map(Some).collect(),
951    );
952    let dddq_chunks: Vec<Option<&mut [f64]>> = dddq.as_deref_mut().map_or_else(
953        || (0..n).map(|_| None).collect(),
954        |v| v.chunks_mut(dim).map(Some).collect(),
955    );
956
957    s_values
958        .par_iter()
959        .enumerate()
960        .zip(q.par_chunks_mut(dim))
961        .zip(dq_chunks.into_par_iter())
962        .zip(ddq_chunks.into_par_iter())
963        .zip(dddq_chunks.into_par_iter())
964        .map(
965            |(((((j, &s_raw), q_col), mut dq_col), mut ddq_col), mut dddq_col)| {
966                let s_curr = path.validate_s(s_raw, j)?;
967                let vals = eval_fn(Jet3::seed(s_curr));
968                if vals.len() != dim {
969                    return Err(PathError::DimensionMismatch);
970                }
971                for (i, jet) in vals.iter().enumerate() {
972                    q_col[i] = jet.v;
973                    if let Some(ref mut b) = dq_col {
974                        b[i] = jet.d1;
975                    }
976                    if let Some(ref mut b) = ddq_col {
977                        b[i] = jet.d2;
978                    }
979                    if let Some(ref mut b) = dddq_col {
980                        b[i] = jet.d3;
981                    }
982                }
983                Ok(())
984            },
985        )
986        .collect()
987}
988
989/// Evaluate spline representation into pre-allocated column-major buffers.
990///
991/// Dispatches to [`SplinePath::eval_at`](crate::path::spline::SplinePath::eval_at)`::<ORDER>` with the minimum derivative
992/// order that satisfies the request: zero run-time branching per sample:
993///   - [`Order::Zero`](Order::Zero) => `eval_at::<0>` (q only)
994///   - [`Order::Two`](Order::Two) => `eval_at::<2>` (q, dq, ddq)
995///   - [`Order::Three`](Order::Three) => `eval_at::<3>` (q, dq, ddq, dddq)
996fn eval_spline(
997    spline: &SplinePath,
998    path: &Path,
999    dim: usize,
1000    input_eval: EvalInput,
1001) -> Result<(), PathError> {
1002    let (s_values, q, dq, ddq, dddq) = input_eval;
1003    match (dq.as_deref_mut(), ddq.as_deref_mut(), dddq.as_deref_mut()) {
1004        // ── Order::Zero: q only ───────────────────────────────────────────
1005        (None, None, None) => s_values
1006            .par_iter()
1007            .enumerate()
1008            .zip(q.par_chunks_mut(dim))
1009            .map(|((j, &s_raw), q_col)| -> Result<(), PathError> {
1010                let s_curr = path.validate_s(s_raw, j)?;
1011                // eval_at::<0> only writes q_col; the derivative slices are never
1012                // accessed, so zero-length arrays satisfy the borrow checker.
1013                let (mut no_dq, mut no_ddq, mut no_dddq): ([f64; 0], [f64; 0], [f64; 0]) =
1014                    ([], [], []);
1015                spline.eval_at::<0>(s_curr, dim, q_col, &mut no_dq, &mut no_ddq, &mut no_dddq);
1016                Ok(())
1017            })
1018            .collect(),
1019        // ── Order::Two: q, dq, ddq ────────────────────────────────────────
1020        (Some(dq_buf), Some(ddq_buf), None) => {
1021            let dq_chunks: Vec<&mut [f64]> = dq_buf.chunks_mut(dim).collect();
1022            let ddq_chunks: Vec<&mut [f64]> = ddq_buf.chunks_mut(dim).collect();
1023            s_values
1024                .par_iter()
1025                .enumerate()
1026                .zip(q.par_chunks_mut(dim))
1027                .zip(dq_chunks.into_par_iter())
1028                .zip(ddq_chunks.into_par_iter())
1029                .map(
1030                    |((((j, &s_raw), q_col), dq_col), ddq_col)| -> Result<(), PathError> {
1031                        let s_curr = path.validate_s(s_raw, j)?;
1032                        let mut s4 = [];
1033                        spline.eval_at::<2>(s_curr, dim, q_col, dq_col, ddq_col, &mut s4);
1034                        Ok(())
1035                    },
1036                )
1037                .collect()
1038        }
1039        // ── Order::Three: q, dq, ddq, dddq ───────────────────────────────
1040        (Some(dq_buf), Some(ddq_buf), Some(dddq_buf)) => {
1041            let dq_chunks: Vec<&mut [f64]> = dq_buf.chunks_mut(dim).collect();
1042            let ddq_chunks: Vec<&mut [f64]> = ddq_buf.chunks_mut(dim).collect();
1043            let dddq_chunks: Vec<&mut [f64]> = dddq_buf.chunks_mut(dim).collect();
1044            s_values
1045                .par_iter()
1046                .enumerate()
1047                .zip(q.par_chunks_mut(dim))
1048                .zip(dq_chunks.into_par_iter())
1049                .zip(ddq_chunks.into_par_iter())
1050                .zip(dddq_chunks.into_par_iter())
1051                .map(
1052                    |(((((j, &s_raw), q_col), dq_col), ddq_col), dddq_col)| -> Result<(), PathError> {
1053                        let s_curr = path.validate_s(s_raw, j)?;
1054                        spline.eval_at::<3>(s_curr, dim, q_col, dq_col, ddq_col, dddq_col);
1055                        Ok(())
1056                    },
1057                )
1058                .collect()
1059        }
1060        // Unreachable: evaluate_impl only produces the three patterns above.
1061        _ => unreachable!("unexpected dq/ddq/dddq combination"),
1062    }
1063}
1064
1065// ── helpers ───────────────────────────────────────────────────────────────────
1066
1067/// Evaluate a user-provided second-order explicit-derivative evaluator.
1068///
1069/// The evaluator receives already validated/clamped path parameters. It is
1070/// called once for the whole batch so custom evaluators can use their own
1071/// vectorized implementation.
1072fn eval_evaluator_2nd(
1073    evaluator: &dyn PathEvaluator2nd,
1074    path: &Path,
1075    dim: usize,
1076    input_eval: EvalInput,
1077) -> Result<(), PathError> {
1078    let (s_values, q, dq, ddq, dddq) = input_eval;
1079    let s_valid = s_values
1080        .iter()
1081        .enumerate()
1082        .map(|(j, &s)| path.validate_s(s, j))
1083        .collect::<Result<Vec<_>, _>>()?;
1084
1085    if evaluator.dim() != dim {
1086        return Err(PathError::DimensionMismatch);
1087    }
1088
1089    match (dq.as_deref_mut(), ddq.as_deref_mut(), dddq.as_deref_mut()) {
1090        (None, None, None) => evaluator.evaluate_q(&s_valid, q),
1091        (Some(dq_buf), Some(ddq_buf), None) => {
1092            evaluator.evaluate_up_to_2nd(&s_valid, q, dq_buf, ddq_buf)
1093        }
1094        (Some(_), Some(_), Some(_)) => Err(PathError::UnsupportedDerivativeOrder {
1095            requested: 3,
1096            available: 2,
1097        }),
1098        // Unreachable: evaluate_impl only produces the three patterns above.
1099        _ => unreachable!("unexpected dq/ddq/dddq combination"),
1100    }
1101}
1102
1103/// Evaluate a user-provided third-order explicit-derivative evaluator.
1104///
1105/// The evaluator receives already validated/clamped path parameters. It is
1106/// called once for the whole batch so custom evaluators can use their own
1107/// vectorized implementation.
1108fn eval_evaluator_3rd(
1109    evaluator: &dyn PathEvaluator3rd,
1110    path: &Path,
1111    dim: usize,
1112    input_eval: EvalInput,
1113) -> Result<(), PathError> {
1114    let (s_values, q, dq, ddq, dddq) = input_eval;
1115    let s_valid = s_values
1116        .iter()
1117        .enumerate()
1118        .map(|(j, &s)| path.validate_s(s, j))
1119        .collect::<Result<Vec<_>, _>>()?;
1120
1121    if evaluator.dim() != dim {
1122        return Err(PathError::DimensionMismatch);
1123    }
1124
1125    match (dq.as_deref_mut(), ddq.as_deref_mut(), dddq.as_deref_mut()) {
1126        (None, None, None) => evaluator.evaluate_q(&s_valid, q),
1127        (Some(dq_buf), Some(ddq_buf), None) => {
1128            evaluator.evaluate_up_to_2nd(&s_valid, q, dq_buf, ddq_buf)
1129        }
1130        (Some(dq_buf), Some(ddq_buf), Some(dddq_buf)) => {
1131            evaluator.evaluate_up_to_3rd(&s_valid, q, dq_buf, ddq_buf, dddq_buf)
1132        }
1133        // Unreachable: evaluate_impl only produces the three patterns above.
1134        _ => unreachable!("unexpected dq/ddq/dddq combination"),
1135    }
1136}
1137
1138fn validate_range(s_min: f64, s_max: f64) -> Result<(), PathError> {
1139    if !s_min.is_finite() || !s_max.is_finite() || s_max <= s_min {
1140        return Err(PathError::InvalidRange { s_min, s_max });
1141    }
1142    Ok(())
1143}
1144
1145#[cfg(test)]
1146mod tests {
1147    use super::PathDerivatives;
1148    use crate::path::{
1149        Jet3, Path as PathModel, PathError, PathEvaluator2nd, PathEvaluator3rd, SplineConfig, cos,
1150        exp, sin,
1151    };
1152    use nalgebra::{Const, DMatrix, DMatrixView, Dyn};
1153    use plotters::prelude::*;
1154    use rand::RngExt;
1155    use std::error::Error;
1156    use std::fs::create_dir_all;
1157    use std::hint::black_box;
1158    use std::path::Path as StdPath;
1159    use std::time::Instant;
1160
1161    const DIM: usize = 6;
1162
1163    fn make_s(n: usize) -> DMatrix<f64> {
1164        DMatrix::<f64>::from_fn(1, n, |_, j| j as f64 / (n - 1) as f64)
1165    }
1166
1167    fn make_parametric_path() -> Result<PathModel, PathError> {
1168        PathModel::from_parametric(
1169            |s: Jet3| {
1170                vec![
1171                    sin(s),
1172                    cos(s),
1173                    exp(0.3 * s) - 1.0,
1174                    s + 0.1 * s * s - 0.01 * s * s * s * s,
1175                    sin(2.0 * s) + 0.15 * cos(3.0 * s),
1176                    sin(s) * cos(s),
1177                ]
1178            },
1179            0.0,
1180            1.0,
1181        )
1182    }
1183
1184    struct PolynomialEvaluator;
1185    struct QuadraticEvaluator2nd;
1186
1187    impl PathEvaluator2nd for PolynomialEvaluator {
1188        fn dim(&self) -> usize {
1189            2
1190        }
1191
1192        fn evaluate_up_to_2nd(
1193            &self,
1194            s: &[f64],
1195            q: &mut [f64],
1196            dq: &mut [f64],
1197            ddq: &mut [f64],
1198        ) -> Result<(), PathError> {
1199            if q.len() != 2 * s.len() || dq.len() != q.len() || ddq.len() != q.len() {
1200                return Err(PathError::DimensionMismatch);
1201            }
1202
1203            for (j, &x) in s.iter().enumerate() {
1204                let col = 2 * j;
1205                q[col] = x * x * x;
1206                dq[col] = 3.0 * x * x;
1207                ddq[col] = 6.0 * x;
1208
1209                q[col + 1] = x * x + 1.0;
1210                dq[col + 1] = 2.0 * x;
1211                ddq[col + 1] = 2.0;
1212            }
1213            Ok(())
1214        }
1215    }
1216
1217    impl PathEvaluator3rd for PolynomialEvaluator {
1218        fn evaluate_up_to_3rd(
1219            &self,
1220            s: &[f64],
1221            q: &mut [f64],
1222            dq: &mut [f64],
1223            ddq: &mut [f64],
1224            dddq: &mut [f64],
1225        ) -> Result<(), PathError> {
1226            self.evaluate_up_to_2nd(s, q, dq, ddq)?;
1227            if dddq.len() != 2 * s.len() {
1228                return Err(PathError::DimensionMismatch);
1229            }
1230
1231            for j in 0..s.len() {
1232                let col = 2 * j;
1233                dddq[col] = 6.0;
1234                dddq[col + 1] = 0.0;
1235            }
1236            Ok(())
1237        }
1238    }
1239
1240    impl PathEvaluator2nd for QuadraticEvaluator2nd {
1241        fn dim(&self) -> usize {
1242            1
1243        }
1244
1245        fn evaluate_up_to_2nd(
1246            &self,
1247            s: &[f64],
1248            q: &mut [f64],
1249            dq: &mut [f64],
1250            ddq: &mut [f64],
1251        ) -> Result<(), PathError> {
1252            if q.len() != s.len() || dq.len() != q.len() || ddq.len() != q.len() {
1253                return Err(PathError::DimensionMismatch);
1254            }
1255
1256            for (j, &x) in s.iter().enumerate() {
1257                q[j] = x * x + 1.0;
1258                dq[j] = 2.0 * x;
1259                ddq[j] = 2.0;
1260            }
1261            Ok(())
1262        }
1263    }
1264
1265    fn make_waypoints(n_pts: usize) -> DMatrix<f64> {
1266        let mut rng = rand::rng();
1267        // Random-walk waypoints: each row is one DOF, each column is a waypoint.
1268        let mut waypoints = DMatrix::<f64>::zeros(DIM, n_pts);
1269        for mut row in waypoints.row_iter_mut() {
1270            row[0] = rng.random_range(-1.0..1.0);
1271            for j in 1..n_pts {
1272                let step = rng.random_range(-0.35..0.35);
1273                row[j] = row[j - 1] + step;
1274            }
1275        }
1276        waypoints
1277    }
1278
1279    #[test]
1280    fn test_waypoints_view_interpolates_padded_column_major() -> Result<(), PathError> {
1281        const DIM_LOCAL: usize = 2;
1282        const N_PTS: usize = 4;
1283        const LEADING_DIM: usize = 3;
1284        let data = [
1285            0.0, 1.0, -99.0, //
1286            0.5, 1.5, -99.0, //
1287            1.0, 2.0, -99.0, //
1288            1.5, 2.5, -99.0,
1289        ];
1290        let waypoints = DMatrixView::from_slice_with_strides_generic(
1291            &data,
1292            Dyn(DIM_LOCAL),
1293            Dyn(N_PTS),
1294            Const::<1>,
1295            Dyn(LEADING_DIM),
1296        );
1297
1298        let path =
1299            PathModel::from_waypoints_interpolating_view(waypoints, SplineConfig::default())?;
1300        let s = [0.0, 1.0 / 3.0, 2.0 / 3.0, 1.0];
1301        let out = path.evaluate_q(&s)?;
1302
1303        for j in 0..N_PTS {
1304            assert!((out.q[(0, j)] - data[j * LEADING_DIM]).abs() < 1e-10);
1305            assert!((out.q[(1, j)] - data[j * LEADING_DIM + 1]).abs() < 1e-10);
1306        }
1307
1308        Ok(())
1309    }
1310
1311    #[test]
1312    fn test_evaluator_path_explicit_derivatives() -> Result<(), PathError> {
1313        let path = PathModel::from_evaluator_3rd(PolynomialEvaluator, -1.0, 1.0)?;
1314        let s = [-1.0, 0.0, 0.5];
1315
1316        let out = path.evaluate_up_to_3rd(&s)?;
1317        let dq = out.dq.as_ref().unwrap();
1318        let ddq = out.ddq.as_ref().unwrap();
1319        let dddq = out.dddq.as_ref().unwrap();
1320
1321        for (j, &x) in s.iter().enumerate() {
1322            assert!((out.q[(0, j)] - x.powi(3)).abs() < 1e-12);
1323            assert!((dq[(0, j)] - 3.0 * x * x).abs() < 1e-12);
1324            assert!((ddq[(0, j)] - 6.0 * x).abs() < 1e-12);
1325            assert!((dddq[(0, j)] - 6.0).abs() < 1e-12);
1326
1327            assert!((out.q[(1, j)] - (x * x + 1.0)).abs() < 1e-12);
1328            assert!((dq[(1, j)] - 2.0 * x).abs() < 1e-12);
1329            assert!((ddq[(1, j)] - 2.0).abs() < 1e-12);
1330            assert!(dddq[(1, j)].abs() < 1e-12);
1331        }
1332
1333        let q_only = path.evaluate_q(&s)?;
1334        assert!(q_only.dq.is_none());
1335        assert!(q_only.ddq.is_none());
1336        assert!(q_only.dddq.is_none());
1337        assert!((q_only.q[(0, 2)] - 0.125).abs() < 1e-12);
1338
1339        Ok(())
1340    }
1341
1342    #[test]
1343    fn test_evaluator_path_2nd_does_not_require_3rd() -> Result<(), PathError> {
1344        let path = PathModel::from_evaluator_2nd(QuadraticEvaluator2nd, -1.0, 1.0)?;
1345        let s = [-1.0, 0.0, 0.5];
1346
1347        let out = path.evaluate_up_to_2nd(&s)?;
1348        let dq = out.dq.as_ref().unwrap();
1349        let ddq = out.ddq.as_ref().unwrap();
1350
1351        assert!(out.dddq.is_none());
1352        assert!((out.q[(0, 2)] - 1.25).abs() < 1e-12);
1353        assert!((dq[(0, 2)] - 1.0).abs() < 1e-12);
1354        assert!((ddq[(0, 2)] - 2.0).abs() < 1e-12);
1355
1356        let err = path.evaluate_up_to_3rd(&s).unwrap_err();
1357        match err {
1358            PathError::UnsupportedDerivativeOrder {
1359                requested: 3,
1360                available: 2,
1361            } => {}
1362            other => panic!("unexpected error: {other}"),
1363        }
1364
1365        Ok(())
1366    }
1367
1368    #[test]
1369    fn test_parametric_autodiff_dim6() -> Result<(), PathError> {
1370        let path = make_parametric_path()?;
1371
1372        let n = 300;
1373        let s = make_s(n);
1374        let out = path.evaluate_up_to_3rd(s.as_slice())?;
1375        let dq = out.dq.as_ref().unwrap();
1376        let ddq = out.ddq.as_ref().unwrap();
1377        let dddq = out.dddq.as_ref().unwrap();
1378
1379        // Build expected values for all query points and check all 4 derivative orders.
1380        s.as_slice().iter().enumerate().for_each(|(j, &x)| {
1381            let e03x = (0.3 * x).exp();
1382            let expected_q = [
1383                x.sin(),
1384                x.cos(),
1385                e03x - 1.0,
1386                x + 0.1 * x * x - 0.01 * x * x * x * x,
1387                (2.0 * x).sin() + 0.15 * (3.0 * x).cos(),
1388                x.sin() * x.cos(),
1389            ];
1390            let expected_dq = [
1391                x.cos(),
1392                -x.sin(),
1393                0.3 * e03x,
1394                1.0 + 0.2 * x - 0.04 * x * x * x,
1395                2.0 * (2.0 * x).cos() - 0.45 * (3.0 * x).sin(),
1396                (2.0 * x).cos(),
1397            ];
1398            let expected_ddq = [
1399                -x.sin(),
1400                -x.cos(),
1401                0.09 * e03x,
1402                0.2 - 0.12 * x * x,
1403                -4.0 * (2.0 * x).sin() - 1.35 * (3.0 * x).cos(),
1404                -2.0 * (2.0 * x).sin(),
1405            ];
1406            let expected_dddq = [
1407                -x.cos(),
1408                x.sin(),
1409                0.027 * e03x,
1410                -0.24 * x,
1411                -8.0 * (2.0 * x).cos() + 4.05 * (3.0 * x).sin(),
1412                -4.0 * (2.0 * x).cos(),
1413            ];
1414
1415            for i in 0..DIM {
1416                assert!(
1417                    (out.q[(i, j)] - expected_q[i]).abs() < 1e-10,
1418                    "q    dim={i} idx={j}"
1419                );
1420                assert!(
1421                    (dq[(i, j)] - expected_dq[i]).abs() < 1e-10,
1422                    "dq   dim={i} idx={j}"
1423                );
1424                assert!(
1425                    (ddq[(i, j)] - expected_ddq[i]).abs() < 1e-10,
1426                    "ddq  dim={i} idx={j}"
1427                );
1428                assert!(
1429                    (dddq[(i, j)] - expected_dddq[i]).abs() < 1e-10,
1430                    "dddq dim={i} idx={j}"
1431                );
1432            }
1433        });
1434
1435        Ok(())
1436    }
1437
1438    #[test]
1439    fn test_evaluate_q_only() -> Result<(), PathError> {
1440        let path = make_parametric_path()?;
1441        let n = 100;
1442        let s = make_s(n);
1443        let out = path.evaluate_q(s.as_slice())?;
1444
1445        assert!(out.dq.is_none());
1446        assert!(out.ddq.is_none());
1447        assert!(out.dddq.is_none());
1448
1449        for j in 0..n {
1450            let x = s[(0, j)];
1451            assert!((out.q[(0, j)] - x.sin()).abs() < 1e-10);
1452            assert!((out.q[(1, j)] - x.cos()).abs() < 1e-10);
1453        }
1454
1455        Ok(())
1456    }
1457
1458    #[test]
1459    fn test_evaluate_up_to_2nd() -> Result<(), PathError> {
1460        let path = make_parametric_path()?;
1461        let n = 100;
1462        let s = make_s(n);
1463        let out = path.evaluate_up_to_2nd(s.as_slice())?;
1464        let dq = out.dq.as_ref().unwrap();
1465        let ddq = out.ddq.as_ref().unwrap();
1466
1467        assert!(out.dddq.is_none());
1468
1469        for j in 0..n {
1470            let x = s[(0, j)];
1471            assert!((out.q[(0, j)] - x.sin()).abs() < 1e-10);
1472            assert!((dq[(0, j)] - x.cos()).abs() < 1e-10);
1473            assert!((ddq[(0, j)] - (-x.sin())).abs() < 1e-10);
1474        }
1475
1476        Ok(())
1477    }
1478
1479    #[test]
1480    fn test_quintic_spline_interpolates_waypoints_dim6() -> Result<(), PathError> {
1481        let n_pts = 25;
1482        let waypoints = make_waypoints(n_pts);
1483
1484        let cfg = SplineConfig::default();
1485        let path = PathModel::from_waypoints_interpolating(&waypoints, cfg)?;
1486
1487        let s = DMatrix::<f64>::from_fn(1, n_pts, |_, j| j as f64 / (n_pts - 1) as f64);
1488        let out = path.evaluate_up_to_3rd(s.as_slice())?;
1489        let dq = out.dq.as_ref().unwrap();
1490        let ddq = out.ddq.as_ref().unwrap();
1491        let dddq = out.dddq.as_ref().unwrap();
1492
1493        // The spline must interpolate every waypoint exactly (up to floating-point rounding)
1494        // and all derivatives must be finite (no blowup).
1495        for (i, j) in (0..DIM).flat_map(|i| (0..n_pts).map(move |j| (i, j))) {
1496            assert!((out.q[(i, j)] - waypoints[(i, j)]).abs() < 1e-8);
1497            assert!(dq[(i, j)].is_finite());
1498            assert!(ddq[(i, j)].is_finite());
1499            assert!(dddq[(i, j)].is_finite());
1500        }
1501
1502        Ok(())
1503    }
1504
1505    #[test]
1506    fn test_s_out_of_range_error_dim6() -> Result<(), PathError> {
1507        let waypoints = make_waypoints(12);
1508        let path = PathModel::from_waypoints_interpolating(&waypoints, SplineConfig::default())?;
1509        let s = DMatrix::<f64>::from_row_slice(1, 3, &[-0.1, 0.5, 1.1]);
1510        let err = path.evaluate_up_to_3rd(s.as_slice()).unwrap_err();
1511        match err {
1512            PathError::OutOfRangeS { .. } => {}
1513            _ => panic!("expected OutOfRangeS"),
1514        }
1515        Ok(())
1516    }
1517
1518    #[test]
1519    fn test_benchmark_parametric_and_spline_dim6() -> Result<(), PathError> {
1520        let n_eval = 3000;
1521        let n_repeat = 8;
1522        let s = make_s(n_eval);
1523
1524        let start = Instant::now();
1525        let param_path = make_parametric_path()?;
1526        let tc_build_param = start.elapsed().as_secs_f64() * 1e3;
1527
1528        let start = Instant::now();
1529        for _ in 0..n_repeat {
1530            let out = param_path.evaluate_up_to_3rd(s.as_slice())?;
1531            black_box(out.q[(0, 0)]);
1532        }
1533        let tc_eval_param = start.elapsed().as_secs_f64() * 1e3 / n_repeat as f64;
1534        crate::verbosity_log!(
1535            crate::diag::Verbosity::Summary,
1536            "[bench][parametric][dim=6] build={tc_build_param:.3} ms eval={tc_eval_param:.3} ms (N={n_eval})"
1537        );
1538
1539        let n_waypoints_list = [16usize, 32, 64, 128, 192, 256, 512, 1024];
1540        for &n_pts in &n_waypoints_list {
1541            let waypoints = make_waypoints(n_pts);
1542            let start = Instant::now();
1543            let spline_path =
1544                PathModel::from_waypoints_interpolating(&waypoints, SplineConfig::default())?;
1545            let tc_build = start.elapsed().as_secs_f64() * 1e3;
1546
1547            let start = Instant::now();
1548            for _ in 0..n_repeat {
1549                let out = spline_path.evaluate_up_to_3rd(s.as_slice())?;
1550                black_box(out.q[(0, 0)]);
1551            }
1552            let tc_eval = start.elapsed().as_secs_f64() * 1e3 / n_repeat as f64;
1553
1554            crate::verbosity_log!(
1555                crate::diag::Verbosity::Summary,
1556                "[bench][spline][dim=6][n_pts={n_pts}] build={tc_build:.3} ms eval={tc_eval:.3} ms"
1557            );
1558        }
1559
1560        Ok(())
1561    }
1562
1563    #[test]
1564    fn test_plot_parametric_and_spline_derivatives() -> Result<(), Box<dyn Error>> {
1565        let dir = "data/path_plots";
1566        create_dir_all(dir)?;
1567
1568        let n = 600;
1569        let s = make_s(n);
1570        let s_vec: Vec<f64> = (0..n).map(|j| s[(0, j)]).collect();
1571
1572        let param_path = make_parametric_path()?;
1573        let param_out = param_path.evaluate_up_to_3rd(s.as_slice())?;
1574        plot_grid_4x6(
1575            &format!("{dir}/parametric_dim6_grid.png"),
1576            "parametric dim=6",
1577            &s_vec,
1578            &param_out,
1579            None,
1580        )?;
1581
1582        let n_pts = 10;
1583        let waypoints = make_waypoints(n_pts);
1584        let spline_path =
1585            PathModel::from_waypoints_interpolating(&waypoints, SplineConfig::default())?;
1586        let spline_out = spline_path.evaluate_up_to_3rd(s.as_slice())?;
1587        let wp_s: Vec<f64> = (0..n_pts).map(|j| j as f64 / (n_pts - 1) as f64).collect();
1588        plot_grid_4x6(
1589            &format!("{dir}/spline_order5_dim6_grid.png"),
1590            "spline order=5 dim=6",
1591            &s_vec,
1592            &spline_out,
1593            Some((&wp_s, &waypoints)),
1594        )?;
1595
1596        Ok(())
1597    }
1598
1599    fn plot_grid_4x6(
1600        file: &str,
1601        title: &str,
1602        s: &[f64],
1603        data: &PathDerivatives,
1604        waypoints: Option<(&[f64], &DMatrix<f64>)>,
1605    ) -> Result<(), Box<dyn Error>> {
1606        if let Some(parent) = StdPath::new(file).parent() {
1607            create_dir_all(parent)?;
1608        }
1609
1610        let root = BitMapBackend::new(file, (2400, 1400)).into_drawing_area();
1611        root.fill(&WHITE)?;
1612
1613        let empty = DMatrix::<f64>::zeros(0, 0);
1614        let dq = data.dq.as_ref().unwrap_or(&empty);
1615        let ddq = data.ddq.as_ref().unwrap_or(&empty);
1616        let dddq = data.dddq.as_ref().unwrap_or(&empty);
1617
1618        let areas = root.split_evenly((4, DIM));
1619        let mats = [&data.q, dq, ddq, dddq];
1620        let row_names = ["q", "dq", "ddq", "dddq"];
1621
1622        for row in 0..4 {
1623            for col in 0..DIM {
1624                let area = &areas[row * DIM + col];
1625                let series = mat_row(mats[row], col);
1626                let (mut y_min, mut y_max) = min_max_slice(&series);
1627                if (y_max - y_min).abs() < 1e-12 {
1628                    y_min -= 1.0;
1629                    y_max += 1.0;
1630                } else {
1631                    let pad = 0.08 * (y_max - y_min);
1632                    y_min -= pad;
1633                    y_max += pad;
1634                }
1635
1636                let mut chart = ChartBuilder::on(area)
1637                    .margin(8)
1638                    .caption(
1639                        format!("{} j{}", row_names[row], col + 1),
1640                        ("sans-serif", 16),
1641                    )
1642                    .x_label_area_size(24)
1643                    .y_label_area_size(38)
1644                    .build_cartesian_2d(s[0]..s[s.len() - 1], y_min..y_max)?;
1645
1646                chart
1647                    .configure_mesh()
1648                    .x_desc(if row == 3 { "s" } else { "" })
1649                    .y_desc("")
1650                    .draw()?;
1651
1652                chart.draw_series(LineSeries::new(
1653                    (0..s.len()).map(|j| (s[j], series[j])),
1654                    &BLUE,
1655                ))?;
1656
1657                if row == 0
1658                    && let Some((s_wp, q_wp)) = waypoints
1659                {
1660                    chart.draw_series(
1661                        s_wp.iter()
1662                            .zip(q_wp.row(col).iter())
1663                            .map(|(&xs, &ys)| Circle::new((xs, ys), 2, RED.filled())),
1664                    )?;
1665                }
1666            }
1667        }
1668
1669        root.titled(title, ("sans-serif", 28))?;
1670        root.present()?;
1671        Ok(())
1672    }
1673
1674    fn mat_row(mat: &DMatrix<f64>, row: usize) -> Vec<f64> {
1675        mat.row(row).iter().copied().collect()
1676    }
1677
1678    fn min_max_slice(data: &[f64]) -> (f64, f64) {
1679        data.iter()
1680            .copied()
1681            .fold((f64::INFINITY, f64::NEG_INFINITY), |(mn, mx), v| {
1682                (mn.min(v), mx.max(v))
1683            })
1684    }
1685}