Skip to main content

deke_linear/
retimer.rs

1//! Stage C — time-parameterise a joint path at constant TCP speed.
2//!
3//! This is a CNC-style constant-feedrate planner, not a TOPP retimer. The
4//! feasible-speed ceiling along the path (the "maximum velocity curve") comes from
5//! the per-joint v/a/j limits projected onto the path tangent `q'(s)`. The
6//! commanded speed `tcp.speed` is held flat wherever that ceiling allows; near a
7//! singularity `|q'(s)| → ∞` so the ceiling collapses and the feedrate dips to zero
8//! smoothly instead of demanding infinite joint speed. The profile is built by a
9//! backward+forward acceleration-bounded pass (zero speed at both ends) followed by
10//! a forward jerk-limited time integration that tracks it.
11//!
12//! Joint velocity is enforced exactly; acceleration and jerk are enforced through
13//! the tangent projection — the `q''(s)·ṡ²` curvature cross-term is a deliberate
14//! first-pass approximation, softened by the jerk-limited integrator.
15
16use std::time::Duration;
17
18use deke_types::glam::DVec3;
19use deke_types::{
20    ContinuousFKChain, DekeError, DekeResult, Retimer, SRobotPath, SRobotQ, SRobotTraj, Validator,
21};
22
23use crate::constraints::LinearConstraints;
24use crate::diagnostic::LinearRetimerDiagnostic;
25use crate::error::LinearError;
26
27const BIG: f64 = 1e9;
28
29/// Safety derating applied to every joint and TCP accel/jerk limit the solver
30/// plans against. The integrator bounds the *continuous* v/a/j exactly, but the
31/// discrete finite differences a controller reconstructs from the sampled output
32/// read a little higher (half-step integration, the secant-vs-tangent gap across
33/// knots); planning at `margin·limit` keeps those reconstructions under the true
34/// limit. It does not derate the commanded TCP speed, which is a target rather
35/// than a ceiling to retreat from.
36const LIMIT_MARGIN: f64 = 0.95;
37
38/// Constant-feedrate, jerk-limited retimer over a joint path.
39#[derive(Clone, Debug)]
40pub struct ConstantSpeedRetimer<'a, const N: usize, FK> {
41    fk: &'a FK,
42}
43
44impl<'a, const N: usize, FK> ConstantSpeedRetimer<'a, N, FK>
45where
46    FK: ContinuousFKChain<N, f64>,
47{
48    pub fn new(fk: &'a FK) -> Self {
49        Self { fk }
50    }
51
52    pub(crate) fn retime_path(
53        &self,
54        c: &LinearConstraints<N>,
55        path: &SRobotPath<N, f64>,
56        run_idx: usize,
57    ) -> Result<(SRobotTraj<N, f64>, LinearRetimerDiagnostic), LinearError> {
58        let raw: Vec<SRobotQ<N, f64>> = path.iter().copied().collect();
59        let q = match c.corner_smoothing {
60            Some(res) => spline_resample(&raw, res),
61            None => raw,
62        };
63        let m = q.len();
64        let dt = c.output_dt.as_secs_f64().max(1e-6);
65
66        // Plan against limits derated by `LIMIT_MARGIN`, leaving the headroom the
67        // sampled output needs: the integrator bounds the continuous v/a/j, but
68        // the discrete finite differences a controller reconstructs read a little
69        // higher (half-step integration, the secant-vs-tangent gap between knots).
70        // Planning at `margin·limit` keeps those within the true limit. The
71        // commanded `tcp.speed` is a target, not a ceiling to back off from, so it
72        // is left underated.
73        let v_max = c.joint.v_max * LIMIT_MARGIN;
74        let a_max = c.joint.a_max * LIMIT_MARGIN;
75        let j_max = c.joint.j_max * LIMIT_MARGIN;
76        let tcp_accel = c.tcp.accel.map(|x| x * LIMIT_MARGIN);
77        let tcp_jerk = c.tcp.jerk.map(|x| x * LIMIT_MARGIN);
78
79        // Cartesian arc length from FK end positions (true metres for `tcp.speed`).
80        let pos: Vec<DVec3> = q
81            .iter()
82            .map(|qi| self.fk.fk_end(qi).map(|t| t.translation))
83            .collect::<Result<_, DekeError>>()?;
84        let mut s = vec![0.0f64; m];
85        for i in 1..m {
86            s[i] = s[i - 1] + pos[i].distance(pos[i - 1]);
87        }
88        let total = s[m - 1];
89        if m < 2 || total < 1e-9 {
90            let traj = SRobotTraj::new(c.output_dt, path.clone());
91            return Ok((
92                traj,
93                LinearRetimerDiagnostic {
94                    output_samples: m,
95                    duration: Duration::from_secs_f64((m.saturating_sub(1)) as f64 * dt),
96                    arc_length: total,
97                    commanded_speed: c.tcp.speed,
98                    peak_speed: 0.0,
99                    peak_joint_accel: 0.0,
100                    peak_joint_jerk: 0.0,
101                },
102            ));
103        }
104
105        // Path derivatives wrt arc length by central difference: q'(s), q''(s),
106        // q'''(s). The higher derivatives carry the joint-space path curvature
107        // that turns Cartesian motion into joint accel/jerk via the chain rule
108        //   q̇  = q'·v
109        //   q̈  = q'·a + q''·v²
110        //   q⃛ = q'·j_s + 3·q''·a·v + q'''·v³
111        // so a straight-Cartesian line can still load the joints when q bends.
112        let central = |arr: &[SRobotQ<N, f64>], i: usize| -> SRobotQ<N, f64> {
113            let (lo, hi) = if i == 0 {
114                (0, 1)
115            } else if i == m - 1 {
116                (m - 2, m - 1)
117            } else {
118                (i - 1, i + 1)
119            };
120            let ds = (s[hi] - s[lo]).max(1e-12);
121            (arr[hi] - arr[lo]) * (1.0 / ds)
122        };
123        let qp: Vec<SRobotQ<N, f64>> = (0..m).map(|i| central(&q, i)).collect();
124        let qpp: Vec<SRobotQ<N, f64>> = (0..m).map(|i| central(&qp, i)).collect();
125        let qppp: Vec<SRobotQ<N, f64>> = (0..m).map(|i| central(&qpp, i)).collect();
126
127        // Velocity-limit curve: the joint velocity limit plus the centripetal
128        // caps where path curvature alone (at zero tangential accel/jerk) would
129        // breach a joint's accel/jerk limit — `|q''|·v² ≤ a_max` and
130        // `|q'''|·v³ ≤ j_max` — all intersected with the commanded TCP speed.
131        // Holds the speed down through joint-space bends.
132        let v_ceiling = |i: usize| {
133            project_min(&qp[i], &v_max)
134                .min(project_min(&qpp[i], &a_max).sqrt())
135                .min(project_min(&qppp[i], &j_max).cbrt())
136                .min(c.tcp.speed)
137        };
138
139        // An interior dip below the command is forced by the joint v/a/j limits
140        // and path curvature (a shallow corner or near-singular patch) — distinct
141        // from the temporal rest ramps the profile adds at the ends. With
142        // `forbid_interior_dips` the caller would rather fail than slow, so report
143        // the worst offending sample against the full feasible-speed ceiling.
144        if c.forbid_interior_dips {
145            let mut worst: Option<(usize, f64)> = None;
146            #[allow(clippy::needless_range_loop)]
147            for i in 1..m - 1 {
148                let g = v_ceiling(i);
149                if g < c.tcp.speed * (1.0 - 1e-3) && worst.is_none_or(|(_, gw)| g < gw) {
150                    worst = Some((i, g));
151                }
152            }
153            if let Some((i, g)) = worst {
154                return Err(LinearError::SpeedDipRequired {
155                    run: run_idx,
156                    s: s[i],
157                    feasible_speed: g,
158                    commanded: c.tcp.speed,
159                });
160            }
161        }
162
163        let a_path: Vec<f64> = (0..m).map(|i| project_min(&qp[i], &a_max)).collect();
164        let j_path: Vec<f64> = (0..m).map(|i| project_min(&qp[i], &j_max)).collect();
165
166        // Acceleration-bounded velocity ceiling for interior corners. The end is
167        // NOT pinned to rest here: pinning it to 0 makes the in-segment linear
168        // interpolation decelerate `v` to rest across the entire final segment
169        // (an unbounded-time crawl on a coarse segment). The terminal stop is
170        // instead enforced per step by the jerk-limited `jerk_stop_speed`
171        // ceiling, which holds cruise until the physical stopping distance.
172        // Start-from-rest is the integrator's initial condition (v = 0).
173        let mut vc: Vec<f64> = (0..m).map(v_ceiling).collect();
174        for i in (0..m - 1).rev() {
175            let ds = s[i + 1] - s[i];
176            vc[i] = vc[i].min((vc[i + 1] * vc[i + 1] + 2.0 * a_path[i] * ds).sqrt());
177        }
178        for i in 1..m {
179            let ds = s[i] - s[i - 1];
180            vc[i] = vc[i].min((vc[i - 1] * vc[i - 1] + 2.0 * a_path[i - 1] * ds).sqrt());
181        }
182
183        // Per-segment reciprocal lengths and value slopes. Precomputing these
184        // turns every inner-loop lookup into a fused `base + slope·f` — no
185        // division and no subtraction in the hot path — and the joint sample
186        // becomes `q[i] + dq[i]·f`.
187        let seg_n = m - 1;
188        let mut inv_ds = vec![0.0f64; seg_n];
189        let mut vc_d = vec![0.0f64; seg_n];
190        let mut a_d = vec![0.0f64; seg_n];
191        let mut j_d = vec![0.0f64; seg_n];
192        let mut dq = vec![SRobotQ::<N, f64>::zeros(); seg_n];
193        let mut qp_d = vec![SRobotQ::<N, f64>::zeros(); seg_n];
194        let mut qpp_d = vec![SRobotQ::<N, f64>::zeros(); seg_n];
195        let mut qppp_d = vec![SRobotQ::<N, f64>::zeros(); seg_n];
196        for i in 0..seg_n {
197            let ds = s[i + 1] - s[i];
198            inv_ds[i] = if ds > 0.0 { 1.0 / ds } else { 0.0 };
199            vc_d[i] = vc[i + 1] - vc[i];
200            a_d[i] = a_path[i + 1] - a_path[i];
201            j_d[i] = j_path[i + 1] - j_path[i];
202            dq[i] = q[i + 1] - q[i];
203            qp_d[i] = qp[i + 1] - qp[i];
204            qpp_d[i] = qpp[i + 1] - qpp[i];
205            qppp_d[i] = qppp[i + 1] - qppp[i];
206        }
207
208        // Forward jerk-limited time integration tracking the ceiling. The flat
209        // estimate `total / (tcp.speed·dt)` is a lower bound on the step count
210        // (real speed never exceeds the command); doubling it covers the rest
211        // ramps so the buffer almost never reallocates mid-sweep.
212        let est = (total / (c.tcp.speed.max(1e-6) * dt)) as usize;
213        let mut samples: Vec<SRobotQ<N, f64>> = Vec::with_capacity(est * 2 + 16);
214        samples.push(q[0]);
215        let mut sx = 0.0f64;
216        let mut v = 0.0f64;
217        let mut a = 0.0f64;
218        let mut peak = 0.0f64;
219        let mut pk_ja = 0.0f64;
220        let mut pk_jj = 0.0f64;
221        // Worst per-joint limit overrun against the *true* (un-derated) limits,
222        // `(ratio, value, limit, arc_length, joint, kind)`. Tracked so a run the
223        // curvature drives past a velocity/accel/jerk limit fails rather than
224        // emitting a trajectory the arm cannot execute. `LIMIT_MARGIN` keeps the
225        // common case clear; this catches what the margin cannot.
226        let mut overrun: Option<(f64, f64, f64, f64, usize, &'static str)> = None;
227        let max_iters = (est + m) * 8 + 100_000;
228
229        // `sx` only ever advances, so a single forward cursor (`seg`) brackets
230        // every lookup in amortised O(1). The bracket landed on at the end of a
231        // step is exactly where the next step's ceiling is read, so it is carried
232        // across iterations — one `seg` call per step serves both the sample and
233        // the next ceiling read.
234        let mut cur = 0usize;
235        let mut i = 0usize;
236        let mut f = 0.0f64;
237        let mut iters = 0usize;
238        while sx < total - 1e-9 {
239            iters += 1;
240            if iters > max_iters {
241                return Err(LinearError::Stalled {
242                    run: run_idx,
243                    s: sx,
244                });
245            }
246            let alim = a_path[i] + a_d[i] * f;
247            let jlim = j_path[i] + j_d[i] * f;
248            // Effective tangential ceilings: the joint-projected scalar bound
249            // intersected with the optional Cartesian TCP accel/jerk caps. These
250            // shape the terminal stop envelope and the emergency fallbacks below.
251            let alim_eff = tcp_accel.map_or(alim, |t| alim.min(t));
252            let jlim_eff = tcp_jerk.map_or(jlim, |t| jlim.min(t));
253
254            // Interior corner ceiling (`vc`) intersected with the jerk-limited
255            // stopping envelope to the path end, so the terminal decel takes the
256            // physical S-curve distance rather than the whole final segment. The
257            // stop is planned at `STOP_JERK_FRACTION` of the available jerk.
258            let vlim = (vc[i] + vc_d[i] * f)
259                .min(jerk_stop_speed(total - sx, alim_eff, STOP_JERK_FRACTION * jlim_eff))
260                .max(0.0);
261
262            // Joint dynamics: bound the path accel `a` (= s̈) and path jerk `j_s`
263            // (= s⃛) so the chain-rule joint accel `q'·a + q''·v²` and joint jerk
264            // `q'·j_s + 3·q''·a·v + q'''·v³` stay within the per-joint limits, then
265            // tighten by the optional Cartesian TCP accel/jerk caps (`s̈`/`s⃛` are
266            // the tangential TCP accel/jerk, since `s` is Cartesian arc length).
267            // The velocity-limit curve keeps `a = 0` joint-feasible; under extreme
268            // curvature the jerk interval can pin, in which case slew `a` back
269            // toward zero as hard as the (capped) jerk allows.
270            let qp_c = qp[i] + qp_d[i] * f;
271            let qpp_c = qpp[i] + qpp_d[i] * f;
272            let qppp_c = qppp[i] + qppp_d[i] * f;
273            let (aj_lo, aj_hi) = feasible_interval(&qp_c, &(qpp_c * (v * v)), &a_max);
274            let (a_lo, a_hi) = cap_interval(aj_lo, aj_hi, tcp_accel);
275            let (a_lo, a_hi) = if a_lo <= a_hi {
276                (a_lo, a_hi)
277            } else if aj_lo <= aj_hi {
278                // Joints feasible but the TCP cap excludes the whole interval: the
279                // joint limit is hard, so take the joint endpoint nearest zero and
280                // accept the TCP-cap overshoot rather than stalling.
281                let a_edge = if aj_lo > 0.0 { aj_lo } else { aj_hi };
282                (a_edge, a_edge)
283            } else {
284                (-alim, -alim)
285            };
286
287            let jc = qpp_c * (3.0 * a * v) + qppp_c * (v * v * v);
288            let (jj_lo, jj_hi) = feasible_interval(&qp_c, &jc, &j_max);
289            let (js_lo, js_hi) = cap_interval(jj_lo, jj_hi, c.tcp.jerk);
290
291            let a_des = ((vlim - v) / dt).clamp(a_lo, a_hi);
292            let j_s = if js_lo <= js_hi {
293                ((a_des - a) / dt).clamp(js_lo, js_hi)
294            } else {
295                (-a / dt).clamp(-jlim_eff, jlim_eff)
296            };
297            a = (a + j_s * dt).clamp(a_lo, a_hi);
298            v = (v + a * dt).clamp(0.0, vlim);
299            peak = peak.max(v);
300
301            // Continuous chain-rule joint accel/jerk actually realized this step
302            // — bounded by the limits by construction of the interval clamps.
303            let jv = qp_c * v;
304            let ja = qp_c * a + qpp_c * (v * v);
305            let jj = qp_c * j_s + qpp_c * (3.0 * a * v) + qppp_c * (v * v * v);
306            pk_ja = pk_ja.max(ja.0.iter().fold(0.0, |m, &x| m.max(x.abs())));
307            pk_jj = pk_jj.max(jj.0.iter().fold(0.0, |m, &x| m.max(x.abs())));
308            for k in 0..N {
309                for (value, limit, kind) in [
310                    (jv.0[k].abs(), c.joint.v_max.0[k], "velocity"),
311                    (ja.0[k].abs(), c.joint.a_max.0[k], "acceleration"),
312                    (jj.0[k].abs(), c.joint.j_max.0[k], "jerk"),
313                ] {
314                    if value > limit * (1.0 + 1e-6) {
315                        let ratio = value / limit;
316                        if overrun.is_none_or(|(w, ..)| ratio > w) {
317                            overrun = Some((ratio, value, limit, sx, k, kind));
318                        }
319                    }
320                }
321            }
322            sx += v * dt;
323            (i, f) = seg(&s, &inv_ds, &mut cur, sx.min(total));
324            samples.push(q[i] + dq[i] * f);
325
326            // Terminal decel has bled to rest within a sub-sample of the end:
327            // the `vc[m-1] = 0` ceiling drives `v → 0` slightly before `sx`
328            // reaches `total`, after which `sx += v·dt` only crawls the geometric
329            // tail toward the `total - 1e-9` margin, emitting hundreds of
330            // effectively-stationary samples. Stop; the exact endpoint is
331            // appended below. Bounded to the end (`total - sx` small) so a
332            // mid-path singularity still trips the stall guard.
333            if v < 1e-6 && total - sx < c.tcp.speed.max(1e-6) * dt {
334                break;
335            }
336
337            // Guard against a stall at a vanishing ceiling (true singularity).
338            if v < 1e-9 && vlim < 1e-9 && sx < total - 1e-6 {
339                return Err(LinearError::Stalled {
340                    run: run_idx,
341                    s: sx,
342                });
343            }
344        }
345        if let Some((_, value, limit, s_at, joint, kind)) = overrun {
346            return Err(LinearError::LimitExceeded {
347                run: run_idx,
348                s: s_at,
349                joint,
350                kind,
351                value,
352                limit,
353            });
354        }
355
356        if samples.last().map(|l| l.distance(&q[m - 1])).unwrap_or(1.0) > 1e-9 {
357            samples.push(q[m - 1]);
358        }
359
360        let out_samples = samples.len();
361        let path_out = SRobotPath::try_new(samples).map_err(LinearError::from)?;
362        let traj = SRobotTraj::new(c.output_dt, path_out);
363        Ok((
364            traj,
365            LinearRetimerDiagnostic {
366                output_samples: out_samples,
367                duration: Duration::from_secs_f64((out_samples.saturating_sub(1)) as f64 * dt),
368                arc_length: total,
369                commanded_speed: c.tcp.speed,
370                peak_speed: peak,
371                peak_joint_accel: pk_ja,
372                peak_joint_jerk: pk_jj,
373            },
374        ))
375    }
376}
377
378impl<'a, const N: usize, FK> Retimer<N, f64> for ConstantSpeedRetimer<'a, N, FK>
379where
380    FK: ContinuousFKChain<N, f64>,
381{
382    type Diagnostic = LinearRetimerDiagnostic;
383    type Constraints = LinearConstraints<N>;
384
385    fn retime<V: Validator<N, (), f64>>(
386        &self,
387        constraints: &Self::Constraints,
388        path: &SRobotPath<N, f64>,
389        validator: &V,
390        ctx: &V::Context<'_>,
391    ) -> (DekeResult<SRobotTraj<N, f64>>, Self::Diagnostic) {
392        match self.retime_path(constraints, path, 0) {
393            Ok((traj, diag)) => {
394                let samples: Vec<SRobotQ<N, f64>> = traj.path().iter().copied().collect();
395                if let Err(e) = validator.validate_motion(&samples, ctx) {
396                    return (Err(e), diag);
397                }
398                (Ok(traj), diag)
399            }
400            Err(e) => (
401                Err(e.into()),
402                LinearRetimerDiagnostic {
403                    output_samples: 0,
404                    duration: Duration::ZERO,
405                    arc_length: 0.0,
406                    commanded_speed: constraints.tcp.speed,
407                    peak_speed: 0.0,
408                    peak_joint_accel: 0.0,
409                    peak_joint_jerk: 0.0,
410                },
411            ),
412        }
413    }
414}
415
416/// `min_j limit_j / |qp_j|` over axes that actually move; `BIG` if none do.
417fn project_min<const N: usize>(qp: &SRobotQ<N, f64>, limit: &SRobotQ<N, f64>) -> f64 {
418    let mut m = BIG;
419    let mut any = false;
420    for j in 0..N {
421        let g = qp.0[j].abs();
422        if g > 1e-9 {
423            any = true;
424            m = m.min(limit.0[j] / g);
425        }
426    }
427    if any { m } else { BIG }
428}
429
430/// Feasible interval for a scalar path-rate control `x` under the per-joint
431/// affine constraints `|qp_k·x + c_k| ≤ lim_k` (the chain-rule joint accel or
432/// jerk written as `qp·x + const`). Returns `(lo, hi)`; `lo > hi` signals that
433/// the constant terms alone already breach a limit — the caller backs off.
434#[inline]
435fn feasible_interval<const N: usize>(
436    qp: &SRobotQ<N, f64>,
437    c: &SRobotQ<N, f64>,
438    lim: &SRobotQ<N, f64>,
439) -> (f64, f64) {
440    let mut lo = f64::NEG_INFINITY;
441    let mut hi = f64::INFINITY;
442    for k in 0..N {
443        let g = qp.0[k];
444        let l = -lim.0[k] - c.0[k]; // qp_k·x ≥ l
445        let h = lim.0[k] - c.0[k]; //  qp_k·x ≤ h
446        if g > 1e-9 {
447            lo = lo.max(l / g);
448            hi = hi.min(h / g);
449        } else if g < -1e-9 {
450            lo = lo.max(h / g);
451            hi = hi.min(l / g);
452        } else if l > 0.0 || h < 0.0 {
453            // qp_k ≈ 0 and 0 ∉ [l, h]: |c_k| > lim_k, infeasible at this speed.
454            return (1.0, -1.0);
455        }
456    }
457    (lo, hi)
458}
459
460/// Intersect a feasible interval `[lo, hi]` with the symmetric cap `[-c, c]`
461/// when `cap` is `Some(c)`; pass it through unchanged when `None`. The result
462/// may come back empty (`lo > hi`) if the cap excludes the whole interval — the
463/// caller decides how to back off.
464#[inline]
465fn cap_interval(lo: f64, hi: f64, cap: Option<f64>) -> (f64, f64) {
466    match cap {
467        Some(c) => (lo.max(-c), hi.min(c)),
468        None => (lo, hi),
469    }
470}
471
472/// Fraction of the available jerk used when planning the terminal stop, so the
473/// deceleration is ~80% of the time-optimal jerk and the integrator keeps a
474/// margin to the joint jerk limit instead of riding it.
475const STOP_JERK_FRACTION: f64 = 0.8;
476
477/// Highest speed from which a jerk- and acceleration-limited deceleration can
478/// reach rest within distance `d`. This is the closed-form inverse of the
479/// S-curve stopping distance under limits `a`, `j`:
480///
481/// - `Δv ≤ a²/j` (triangular accel profile, never saturating `a`):
482///   `d = v^{3/2} / √j` ⇒ `v = ∛(d²·j)`.
483/// - otherwise (a trapezoidal profile with a constant-`a` phase):
484///   `d = v²/(2a) + v·a/(2j)` ⇒ the positive root below.
485///
486/// Used as a per-step velocity ceiling toward the path end so the decel takes
487/// the physical jerk-limited distance instead of being dragged to rest across
488/// a whole (possibly coarse) input segment.
489#[inline]
490fn jerk_stop_speed(d: f64, a: f64, j: f64) -> f64 {
491    let a = a.max(1e-9);
492    let j = j.max(1e-9);
493    let v_tri = (d * d * j).cbrt();
494    if v_tri <= a * a / j {
495        v_tri
496    } else {
497        let aj = a * a / j;
498        0.5 * (-aj + (aj * aj + 8.0 * a * d).sqrt())
499    }
500}
501
502/// Resample a joint path with a natural cubic spline through the waypoints,
503/// emitting points no more than `res` apart in joint-space chord length. The
504/// spline interpolates the inputs (zero deviation at the waypoints) and is C²,
505/// so the densely-sampled curve has continuous curvature — bounded joint jerk
506/// once retimed — and tracks the intended smooth path more closely than the raw
507/// piecewise-linear polyline. Endpoints are preserved exactly.
508fn spline_resample<const N: usize>(raw: &[SRobotQ<N, f64>], res: f64) -> Vec<SRobotQ<N, f64>> {
509    if raw.len() < 3 || res <= 0.0 {
510        return raw.to_vec();
511    }
512    // Drop coincident knots first. A zero-length chord makes the natural cubic
513    // spline's tridiagonal system singular (its RHS carries a `1/h` term), and
514    // the interpolant then bows wildly off the path — quadrupling the executed
515    // arc length on an otherwise straight run. Duplicates arise where the
516    // planner samples a segment boundary twice.
517    let mut q: Vec<SRobotQ<N, f64>> = Vec::with_capacity(raw.len());
518    q.push(raw[0]);
519    for &p in &raw[1..] {
520        if p.distance(q.last().unwrap()) > 1e-9 {
521            q.push(p);
522        }
523    }
524    let m = q.len();
525    if m < 3 {
526        return q;
527    }
528    // Parameterize by cumulative joint-space chord length.
529    let mut u = vec![0.0f64; m];
530    for i in 1..m {
531        u[i] = u[i - 1] + q[i].distance(&q[i - 1]);
532    }
533    if u[m - 1] < 1e-12 {
534        return q;
535    }
536    let h: Vec<f64> = (0..m - 1).map(|i| (u[i + 1] - u[i]).max(1e-12)).collect();
537    // Natural cubic spline second derivatives via the Thomas algorithm. The
538    // tridiagonal coefficients are scalar (shared by every joint); only the RHS
539    // is a vector, so one sweep solves all dimensions. M[0] = M[m-1] = 0.
540    let mut cp = vec![0.0f64; m];
541    let mut dp = vec![SRobotQ::<N, f64>::zeros(); m];
542    for i in 1..m - 1 {
543        let (a, b, cc) = (h[i - 1], 2.0 * (h[i - 1] + h[i]), h[i]);
544        let rhs = ((q[i + 1] - q[i]) * (1.0 / h[i]) - (q[i] - q[i - 1]) * (1.0 / h[i - 1])) * 6.0;
545        let denom = b - a * cp[i - 1];
546        cp[i] = cc / denom;
547        dp[i] = (rhs - dp[i - 1] * a) * (1.0 / denom);
548    }
549    let mut mm = vec![SRobotQ::<N, f64>::zeros(); m];
550    for i in (1..m - 1).rev() {
551        mm[i] = dp[i] - mm[i + 1] * cp[i];
552    }
553    let eval = |i: usize, uu: f64| -> SRobotQ<N, f64> {
554        let a = (u[i + 1] - uu) / h[i];
555        let b = (uu - u[i]) / h[i];
556        q[i] * a
557            + q[i + 1] * b
558            + (mm[i] * (a * a * a - a) + mm[i + 1] * (b * b * b - b)) * (h[i] * h[i] / 6.0)
559    };
560    let mut out = Vec::with_capacity(m + (u[m - 1] / res) as usize + 1);
561    out.push(q[0]);
562    for i in 0..m - 1 {
563        let k = ((h[i] / res).ceil() as usize).max(1);
564        for ss in 1..=k {
565            out.push(eval(i, u[i] + h[i] * (ss as f64) / (k as f64)));
566        }
567    }
568    out
569}
570
571/// Bracket `x` against the ascending grid `s`, advancing the forward-only cursor
572/// `cur` (kept in `0..s.len()-1`). Returns the segment index `i` with
573/// `s[i] <= x <= s[i+1]` and the in-segment fraction `f`, both clamped to the
574/// grid range. `inv_ds[i]` is the reciprocal segment length, so the fraction
575/// costs a multiply, not a divide. Amortised O(1) over the monotonic sweep.
576#[inline]
577fn seg(s: &[f64], inv_ds: &[f64], cur: &mut usize, x: f64) -> (usize, f64) {
578    let last = s.len() - 1;
579    let x = x.clamp(s[0], s[last]);
580    while *cur < last - 1 && s[*cur + 1] <= x {
581        *cur += 1;
582    }
583    let f = (x - s[*cur]) * inv_ds[*cur];
584    (*cur, f)
585}