Skip to main content

repose_trajectory/
cartesian_blend.rs

1//! Sampled Cartesian TCP corner blending for serial joint-space paths.
2//!
3//! Each positive zone trims its incoming and outgoing Cartesian lines and
4//! joins them with a quadratic Bézier curve contained in the TCP zone. Tool
5//! orientation follows shortest-arc spherical interpolation. IK and discrete
6//! joint-space collision checks are applied at every generated point and edge.
7//!
8//! The path is geometry only. Time it with any profile through
9//! [`parameterize_cartesian_path`](crate::parameterize_cartesian_path).
10
11use bincode::Decode;
12use bincode::Encode;
13use cu_spatial_payloads::Transform3D;
14use cu29::prelude::*;
15use cu29::units::si::angle::radian;
16use cu29::units::si::f64::Angle;
17use cu29::units::si::f64::Length;
18use cu29::units::si::length::meter;
19use repose_collision::CuCollisionEnv;
20use repose_collision::CuRobotCollisionModel;
21use repose_kinematics::CuIkParams;
22use repose_kinematics::CuIkStatus;
23use repose_kinematics::CuJointPosition;
24use repose_kinematics::CuJointResolution;
25use repose_kinematics::CuKinematicChain;
26use serde::Deserialize;
27use serde::Serialize;
28
29/// A programmed joint waypoint and its TCP zone radius.
30///
31/// A zero `zone` is an exact stop; a positive `zone` is a fly-by zone around
32/// the waypoint's TCP position. The first and last waypoints of a path must
33/// be exact stops.
34#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct CuCartesianWaypoint<const N: usize> {
36    pub positions: [CuJointPosition; N],
37    /// TCP zone radius around the waypoint (zero = exact stop).
38    pub zone: Length,
39}
40
41impl<const N: usize> CuCartesianWaypoint<N> {
42    /// Waypoint at `positions` with TCP zone radius `zone` (zero = exact stop).
43    pub const fn new(positions: [CuJointPosition; N], zone: Length) -> Self {
44        Self { positions, zone }
45    }
46}
47
48/// Programmed TCP zone of one waypoint, in the chain base frame.
49#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct CuTcpZone {
51    /// Index of the programmed waypoint.
52    pub waypoint: usize,
53    /// Programmed TCP position of the waypoint.
54    pub center: [Length; 3],
55    pub radius: Length,
56}
57
58/// A sampled joint configuration and whether it is a required stop.
59#[derive(Debug, Clone, Copy)]
60pub struct CuCartesianPathPoint<const N: usize> {
61    pub positions: [CuJointPosition; N],
62    /// True at the start, end, and every zero-radius interior waypoint.
63    pub stop: bool,
64    /// The TCP zone that contains this point, when blending.
65    pub zone: Option<CuTcpZone>,
66}
67impl<const N: usize> Default for CuCartesianPathPoint<N> {
68    fn default() -> Self {
69        Self {
70            positions: [CuJointPosition::default(); N],
71            stop: false,
72            zone: None,
73        }
74    }
75}
76
77/// Fixed-capacity scratch path. Only `points[..len]` are valid.
78#[derive(Debug, Clone)]
79pub struct CuCartesianBlendPath<const N: usize, const CAP: usize> {
80    pub points: [CuCartesianPathPoint<N>; CAP],
81    pub len: usize,
82}
83impl<const N: usize, const CAP: usize> Default for CuCartesianBlendPath<N, CAP> {
84    fn default() -> Self {
85        Self {
86            points: [CuCartesianPathPoint::default(); CAP],
87            len: 0,
88        }
89    }
90}
91impl<const N: usize, const CAP: usize> CuCartesianBlendPath<N, CAP> {
92    pub fn points(&self) -> &[CuCartesianPathPoint<N>] {
93        &self.points[..self.len]
94    }
95    pub fn clear(&mut self) {
96        self.len = 0;
97    }
98}
99
100/// Sampling bounds and IK parameters of [`blend_cartesian_path`].
101///
102/// ```ron
103/// {
104///     "translation_step": 0.01,
105///     "rotation_step": 0.05,
106///     "collision_step": {"angular": 0.08, "linear": 0.01},
107///     "ik": {"max_iterations": 256},
108/// }
109/// ```
110#[derive(Debug, Clone, Copy, PartialEq, Encode, Decode, Serialize, Deserialize, Reflect)]
111#[serde(default, deny_unknown_fields)]
112pub struct CuCartesianBlendParams {
113    /// Maximum TCP translation between Cartesian targets.
114    pub translation_step: Length,
115    /// Maximum tool rotation between Cartesian targets.
116    pub rotation_step: Angle,
117    /// Joint interpolation resolution for collision checks between IK solutions.
118    pub collision_step: CuJointResolution,
119    pub ik: CuIkParams,
120}
121impl Default for CuCartesianBlendParams {
122    fn default() -> Self {
123        Self {
124            translation_step: Length::new::<meter>(0.01),
125            rotation_step: Angle::new::<radian>(0.05),
126            collision_step: CuJointResolution::default(),
127            ik: CuIkParams::default(),
128        }
129    }
130}
131impl CuCartesianBlendParams {
132    /// Checks that both steps are positive and finite, the collision
133    /// resolution is valid, and the IK parameters are valid.
134    pub fn validate(&self) -> CuResult<()> {
135        let translation = self.translation_step.get::<meter>();
136        let rotation = self.rotation_step.get::<radian>();
137        if !translation.is_finite()
138            || translation <= 0.0
139            || !rotation.is_finite()
140            || rotation <= 0.0
141        {
142            return Err(CuError::from(
143                "Cartesian blend steps must be positive and finite",
144            ));
145        }
146        self.collision_step.metric().validate()?;
147        self.ik.validate()
148    }
149}
150
151/// Controlled outcome. The output path is cleared on any non-success outcome.
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, Serialize, Deserialize, Reflect)]
153#[serde(rename_all = "snake_case")]
154pub enum CuCartesianBlendStatus {
155    Success,
156    InvalidInput,
157    /// IK found no solution at sample `step` of `steps` on the move toward
158    /// `waypoint`: on the rounded corner inside its zone when `corner`,
159    /// otherwise on the straight TCP line leading to it (or to its zone).
160    InverseKinematicsFailed {
161        waypoint: usize,
162        corner: bool,
163        step: usize,
164        steps: usize,
165    },
166    Collision {
167        waypoint: usize,
168    },
169    ZoneExceeded {
170        waypoint: usize,
171    },
172    CapacityExceeded,
173}
174
175#[derive(Clone, Copy)]
176struct Pose {
177    p: [f64; 3],
178    q: [f64; 4],
179}
180impl Default for Pose {
181    fn default() -> Self {
182        Self {
183            p: [0.0; 3],
184            q: [1.0, 0.0, 0.0, 0.0],
185        }
186    }
187}
188impl Pose {
189    fn from_transform(t: Transform3D<f64>) -> Self {
190        let m = t.to_matrix();
191        let trace = m[0][0] + m[1][1] + m[2][2];
192        let q = if trace > 0.0 {
193            let s = libm::sqrt(trace + 1.0) * 2.0;
194            [
195                s / 4.0,
196                (m[2][1] - m[1][2]) / s,
197                (m[0][2] - m[2][0]) / s,
198                (m[1][0] - m[0][1]) / s,
199            ]
200        } else if m[0][0] > m[1][1] && m[0][0] > m[2][2] {
201            let s = libm::sqrt(1.0 + m[0][0] - m[1][1] - m[2][2]) * 2.0;
202            [
203                (m[2][1] - m[1][2]) / s,
204                s / 4.0,
205                (m[0][1] + m[1][0]) / s,
206                (m[0][2] + m[2][0]) / s,
207            ]
208        } else if m[1][1] > m[2][2] {
209            let s = libm::sqrt(1.0 + m[1][1] - m[0][0] - m[2][2]) * 2.0;
210            [
211                (m[0][2] - m[2][0]) / s,
212                (m[0][1] + m[1][0]) / s,
213                s / 4.0,
214                (m[1][2] + m[2][1]) / s,
215            ]
216        } else {
217            let s = libm::sqrt(1.0 + m[2][2] - m[0][0] - m[1][1]) * 2.0;
218            [
219                (m[1][0] - m[0][1]) / s,
220                (m[0][2] + m[2][0]) / s,
221                (m[1][2] + m[2][1]) / s,
222                s / 4.0,
223            ]
224        };
225        Self {
226            p: [m[0][3], m[1][3], m[2][3]],
227            q,
228        }
229    }
230    fn transform(self) -> Transform3D<f64> {
231        let [w, x, y, z] = self.q;
232        Transform3D::<f64>::from_matrix([
233            [
234                1.0 - 2.0 * (y * y + z * z),
235                2.0 * (x * y - z * w),
236                2.0 * (x * z + y * w),
237                self.p[0],
238            ],
239            [
240                2.0 * (x * y + z * w),
241                1.0 - 2.0 * (x * x + z * z),
242                2.0 * (y * z - x * w),
243                self.p[1],
244            ],
245            [
246                2.0 * (x * z - y * w),
247                2.0 * (y * z + x * w),
248                1.0 - 2.0 * (x * x + y * y),
249                self.p[2],
250            ],
251            [0.0, 0.0, 0.0, 1.0],
252        ])
253    }
254    fn lerp(self, other: Self, t: f64) -> Self {
255        Self {
256            p: core::array::from_fn(|i| self.p[i] + (other.p[i] - self.p[i]) * t),
257            q: slerp(self.q, other.q, t),
258        }
259    }
260}
261fn distance(a: [f64; 3], b: [f64; 3]) -> f64 {
262    let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
263    libm::sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2])
264}
265fn slerp(a: [f64; 4], mut b: [f64; 4], t: f64) -> [f64; 4] {
266    let mut dot = (0..4).map(|i| a[i] * b[i]).sum::<f64>();
267    if dot < 0.0 {
268        b = b.map(|v| -v);
269        dot = -dot;
270    }
271    let (wa, wb) = if dot > 0.9995 {
272        (1.0 - t, t)
273    } else {
274        let theta = libm::acos(dot.clamp(-1.0, 1.0));
275        let denom = libm::sin(theta);
276        (
277            libm::sin((1.0 - t) * theta) / denom,
278            libm::sin(t * theta) / denom,
279        )
280    };
281    let mut q = core::array::from_fn(|i| wa * a[i] + wb * b[i]);
282    let norm = libm::sqrt(q.iter().map(|v| v * v).sum::<f64>());
283    for v in &mut q {
284        *v /= norm;
285    }
286    q
287}
288fn angle(a: [f64; 4], b: [f64; 4]) -> f64 {
289    let dot = (0..4).map(|i| a[i] * b[i]).sum::<f64>();
290    2.0 * libm::acos(dot.abs().clamp(0.0, 1.0))
291}
292
293/// Position of a generated sample on the move toward `waypoint`.
294#[derive(Clone, Copy)]
295struct Progress {
296    waypoint: usize,
297    step: usize,
298    steps: usize,
299}
300
301struct Builder<'a, const N: usize, const M: usize, const CAP: usize> {
302    chain: &'a CuKinematicChain<N>,
303    model: &'a CuRobotCollisionModel<N, M>,
304    env: &'a CuCollisionEnv<'a>,
305    config: &'a CuCartesianBlendParams,
306    out: &'a mut CuCartesianBlendPath<N, CAP>,
307}
308impl<const N: usize, const M: usize, const CAP: usize> Builder<'_, N, M, CAP> {
309    fn append(
310        &mut self,
311        target: Pose,
312        exact: Option<[CuJointPosition; N]>,
313        stop: bool,
314        zone: Option<(usize, [f64; 3], f64)>,
315        progress: Progress,
316    ) -> CuResult<CuCartesianBlendStatus> {
317        let waypoint = progress.waypoint;
318        if self.out.len >= CAP {
319            return Ok(CuCartesianBlendStatus::CapacityExceeded);
320        }
321        let previous = self.out.points[self.out.len - 1].positions;
322        let mut q = previous;
323        if let Some(exact) = exact {
324            q = exact;
325        } else {
326            let status = self.chain.inverse_kinematics_dls(
327                &target.transform(),
328                &previous,
329                &self.config.ik,
330                &mut q,
331            )?;
332            if !matches!(status, CuIkStatus::Converged { .. }) {
333                return Ok(CuCartesianBlendStatus::InverseKinematicsFailed {
334                    waypoint,
335                    corner: zone.is_some(),
336                    step: progress.step,
337                    steps: progress.steps,
338                });
339            }
340        }
341        if !self
342            .model
343            .is_configuration_collision_free(self.chain, &q, self.env)?
344            || !self.model.is_segment_collision_free(
345                self.chain,
346                &previous,
347                &q,
348                self.config.collision_step,
349                self.env,
350            )?
351        {
352            return Ok(CuCartesianBlendStatus::Collision { waypoint });
353        }
354        if let Some((index, center, radius)) = zone {
355            let mut actual = Transform3D::<f64>::identity();
356            self.chain.forward_kinematics(&q, &mut actual)?;
357            if distance(Pose::from_transform(actual).p, center) > radius {
358                return Ok(CuCartesianBlendStatus::ZoneExceeded { waypoint: index });
359            }
360        }
361        // IK can legitimately return its seed for targets already within tolerance.
362        // Coalesce identical samples so they do not create artificial pauses.
363        if previous
364            .iter()
365            .zip(q.iter())
366            .all(|(a, b)| (a.si_value() - b.si_value()).abs() <= 1e-12)
367        {
368            if stop {
369                self.out.points[self.out.len - 1].stop = true;
370            }
371            return Ok(CuCartesianBlendStatus::Success);
372        }
373        self.out.points[self.out.len] = CuCartesianPathPoint {
374            positions: q,
375            stop,
376            zone: zone.map(|(waypoint, center, radius)| CuTcpZone {
377                waypoint,
378                center: center.map(Length::new::<meter>),
379                radius: Length::new::<meter>(radius),
380            }),
381        };
382        self.out.len += 1;
383        Ok(CuCartesianBlendStatus::Success)
384    }
385    fn steps(&self, dist: f64, rotation: f64) -> usize {
386        let count = libm::ceil(
387            (dist / self.config.translation_step.get::<meter>())
388                .max(rotation / self.config.rotation_step.get::<radian>()),
389        )
390        .max(1.0);
391        if !count.is_finite() || count > CAP as f64 {
392            CAP.saturating_add(1)
393        } else {
394            count as usize
395        }
396    }
397    fn line(
398        &mut self,
399        from: Pose,
400        to: Pose,
401        exact: Option<[CuJointPosition; N]>,
402        stop: bool,
403        waypoint: usize,
404    ) -> CuResult<CuCartesianBlendStatus> {
405        let steps = self.steps(distance(from.p, to.p), angle(from.q, to.q));
406        if steps > CAP.saturating_sub(self.out.len) {
407            return Ok(CuCartesianBlendStatus::CapacityExceeded);
408        }
409        for i in 1..=steps {
410            let final_point = i == steps;
411            let status = self.append(
412                from.lerp(to, i as f64 / steps as f64),
413                if final_point { exact } else { None },
414                stop && final_point,
415                None,
416                Progress {
417                    waypoint,
418                    step: i,
419                    steps,
420                },
421            )?;
422            if status != CuCartesianBlendStatus::Success {
423                return Ok(status);
424            }
425        }
426        Ok(CuCartesianBlendStatus::Success)
427    }
428}
429
430/// Construct a Cartesian path through joint waypoints with per-waypoint TCP zones.
431///
432/// Each waypoint carries its zone radius. First and last must be zero. A
433/// zero interior radius is an exact stop; a positive radius is a fly-by zone.
434/// Trimming is capped to 49% of each adjacent straight segment to avoid
435/// overlapping zones. Degenerate corners and zones too small relative to IK
436/// tolerance are rejected, as are invalid `params`. The input joint
437/// configurations define TCP position *and orientation* through FK.
438/// Collisions are checked against `env` (pass [`CuCollisionEnv::EMPTY`] for
439/// self-collision only); checking is discrete, not swept-volume continuous
440/// collision detection;
441/// [`parameterize_cartesian_path`](crate::parameterize_cartesian_path)
442/// rechecks the interpolated path when timing it.
443///
444/// All output and scratch storage is const-sized; failure clears the output.
445pub fn blend_cartesian_path<const N: usize, const M: usize, const CAP: usize>(
446    chain: &CuKinematicChain<N>,
447    model: &CuRobotCollisionModel<N, M>,
448    env: &CuCollisionEnv<'_>,
449    waypoints: &[CuCartesianWaypoint<N>],
450    params: &CuCartesianBlendParams,
451    out: &mut CuCartesianBlendPath<N, CAP>,
452) -> CuResult<CuCartesianBlendStatus> {
453    out.clear();
454    if waypoints.len() < 2
455        || waypoints.len() > CAP
456        || waypoints
457            .first()
458            .is_none_or(|w| w.zone.get::<meter>() != 0.0)
459        || waypoints
460            .last()
461            .is_none_or(|w| w.zone.get::<meter>() != 0.0)
462        || params.validate().is_err()
463    {
464        return Ok(CuCartesianBlendStatus::InvalidInput);
465    }
466    let config = params;
467    let mut poses = [Pose::default(); CAP];
468    for i in 0..waypoints.len() {
469        let radius = waypoints[i].zone.get::<meter>();
470        if !radius.is_finite() || radius < 0.0 || !chain.is_within_limits(&waypoints[i].positions) {
471            return Ok(CuCartesianBlendStatus::InvalidInput);
472        }
473        let mut transform = Transform3D::<f64>::identity();
474        chain.forward_kinematics(&waypoints[i].positions, &mut transform)?;
475        poses[i] = Pose::from_transform(transform);
476    }
477    if !model.is_configuration_collision_free(chain, &waypoints[0].positions, env)? {
478        return Ok(CuCartesianBlendStatus::Collision { waypoint: 0 });
479    }
480    out.points[0] = CuCartesianPathPoint {
481        positions: waypoints[0].positions,
482        stop: true,
483        zone: None,
484    };
485    out.len = 1;
486    let result = (|| -> CuResult<CuCartesianBlendStatus> {
487        let mut builder = Builder {
488            chain,
489            model,
490            env,
491            config,
492            out,
493        };
494        let mut current = poses[0];
495        for i in 1..waypoints.len() - 1 {
496            let r = waypoints[i].zone.get::<meter>();
497            if r == 0.0 {
498                let s = builder.line(current, poses[i], Some(waypoints[i].positions), true, i)?;
499                if s != CuCartesianBlendStatus::Success {
500                    return Ok(s);
501                }
502                current = poses[i];
503                continue;
504            }
505            let before = distance(poses[i - 1].p, poses[i].p);
506            let after = distance(poses[i].p, poses[i + 1].p);
507            if before <= 1e-12
508                || after <= 1e-12
509                || r <= 2.0 * config.ik.position_tolerance.get::<meter>()
510            {
511                return Ok(CuCartesianBlendStatus::InvalidInput);
512            }
513            let incoming =
514                core::array::from_fn::<_, 3, _>(|j| (poses[i].p[j] - poses[i - 1].p[j]) / before);
515            let outgoing =
516                core::array::from_fn::<_, 3, _>(|j| (poses[i + 1].p[j] - poses[i].p[j]) / after);
517            if (0..3).map(|j| incoming[j] * outgoing[j]).sum::<f64>() < -0.9999 {
518                return Ok(CuCartesianBlendStatus::InvalidInput);
519            }
520            let trim = r.min(before * 0.49).min(after * 0.49) * 0.9;
521            let entry = poses[i - 1].lerp(poses[i], 1.0 - trim / before);
522            let exit = poses[i].lerp(poses[i + 1], trim / after);
523            let status = builder.line(current, entry, None, false, i)?;
524            if status != CuCartesianBlendStatus::Success {
525                return Ok(status);
526            }
527            let steps = builder.steps(
528                2.0 * trim,
529                angle(entry.q, poses[i].q) + angle(poses[i].q, exit.q),
530            );
531            if steps > CAP.saturating_sub(builder.out.len) {
532                return Ok(CuCartesianBlendStatus::CapacityExceeded);
533            }
534            for k in 1..=steps {
535                let t = k as f64 / steps as f64;
536                let pose = entry.lerp(poses[i], t).lerp(poses[i].lerp(exit, t), t);
537                let progress = Progress {
538                    waypoint: i,
539                    step: k,
540                    steps,
541                };
542                let status =
543                    builder.append(pose, None, false, Some((i, poses[i].p, r)), progress)?;
544                if status != CuCartesianBlendStatus::Success {
545                    return Ok(status);
546                }
547            }
548            current = exit;
549        }
550        let last = waypoints.len() - 1;
551        let status = builder.line(
552            current,
553            poses[last],
554            Some(waypoints[last].positions),
555            true,
556            last,
557        )?;
558        if status != CuCartesianBlendStatus::Success {
559            return Ok(status);
560        }
561        for (i, waypoint) in waypoints.iter().enumerate().take(last).skip(1) {
562            if waypoint.zone.get::<meter>() > 0.0
563                && !builder
564                    .out
565                    .points()
566                    .iter()
567                    .any(|p| p.zone.is_some_and(|z| z.waypoint == i))
568            {
569                return Ok(CuCartesianBlendStatus::InvalidInput);
570            }
571        }
572        Ok(CuCartesianBlendStatus::Success)
573    })();
574    if !matches!(result, Ok(CuCartesianBlendStatus::Success)) {
575        out.clear();
576    }
577    result
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583    use crate::test_support::obstacle;
584    use crate::test_support::q;
585    use crate::test_support::robot;
586    use crate::test_support::waypoints;
587
588    #[test]
589    fn zone_produces_a_corner_inside_tcp_radius() {
590        let (chain, model) = robot();
591        let points = [q(0.0, 0.0), q(0.3, 0.0), q(0.3, 0.3)];
592        let env = CuCollisionEnv::EMPTY;
593        let mut path = CuCartesianBlendPath::<3, 256>::default();
594        assert_eq!(
595            blend_cartesian_path(
596                &chain,
597                &model,
598                &env,
599                &waypoints(&points, [0.0, 0.1, 0.0]),
600                &CuCartesianBlendParams::default(),
601                &mut path
602            )
603            .unwrap(),
604            CuCartesianBlendStatus::Success
605        );
606        let mut in_zone = 0;
607        for p in path.points() {
608            if let Some(zone) = p.zone {
609                assert_eq!(zone.waypoint, 1);
610                assert_eq!(zone.radius, Length::new::<meter>(0.1));
611                in_zone += 1;
612                let mut pose = Transform3D::<f64>::identity();
613                chain.forward_kinematics(&p.positions, &mut pose).unwrap();
614                let m = pose.to_matrix();
615                let d = distance([m[0][3], m[1][3], m[2][3]], [0.3, 0.0, 0.0]);
616                assert!(d < 0.1 && d > 0.001);
617                assert!(!p.stop);
618            }
619        }
620        assert!(in_zone > 2);
621        assert!(!path.points().iter().any(|p| p.positions == points[1]));
622        assert!(path.points()[0].stop && path.points()[path.len - 1].stop);
623    }
624
625    #[test]
626    fn zero_zone_stops_exactly_and_collision_fails_closed() {
627        let (chain, model) = robot();
628        let points = [q(0.0, 0.0), q(0.3, 0.0), q(0.3, 0.3)];
629        let env = CuCollisionEnv::EMPTY;
630        let config = CuCartesianBlendParams::default();
631        let mut path = CuCartesianBlendPath::<3, 256>::default();
632        assert_eq!(
633            blend_cartesian_path(
634                &chain,
635                &model,
636                &env,
637                &waypoints(&points, [0.0; 3]),
638                &config,
639                &mut path
640            )
641            .unwrap(),
642            CuCartesianBlendStatus::Success
643        );
644        assert!(
645            path.points()
646                .iter()
647                .any(|p| p.stop && p.positions == points[1])
648        );
649        assert!(path.points().iter().all(|p| p.zone.is_none()));
650        let obstacles = [obstacle()];
651        let env = CuCollisionEnv::new(&obstacles);
652        assert!(matches!(
653            blend_cartesian_path(
654                &chain,
655                &model,
656                &env,
657                &waypoints(&points, [0.0, 0.1, 0.0]),
658                &config,
659                &mut path
660            )
661            .unwrap(),
662            CuCartesianBlendStatus::Collision { .. }
663        ));
664        assert_eq!(path.len, 0);
665    }
666
667    #[test]
668    fn ik_failure_reports_the_move_and_progress() {
669        let (chain, model) = robot();
670        let env = CuCollisionEnv::EMPTY;
671        // 0.1 m samples need several 0.025 m-clamped IK iterations, so a
672        // two-iteration budget fails on the first sample toward waypoint 1.
673        let mut config = CuCartesianBlendParams {
674            translation_step: Length::new::<meter>(0.1),
675            ..CuCartesianBlendParams::default()
676        };
677        config.ik.max_iterations = 2;
678        let mut path = CuCartesianBlendPath::<3, 64>::default();
679        let status = blend_cartesian_path(
680            &chain,
681            &model,
682            &env,
683            &waypoints(&[q(0.0, 0.0), q(0.3, 0.0)], [0.0, 0.0]),
684            &config,
685            &mut path,
686        )
687        .unwrap();
688        assert_eq!(
689            status,
690            CuCartesianBlendStatus::InverseKinematicsFailed {
691                waypoint: 1,
692                corner: false,
693                step: 1,
694                steps: 3,
695            }
696        );
697        assert_eq!(path.len, 0);
698    }
699
700    #[test]
701    fn invalid_and_capacity_cases_leave_no_partial_path() {
702        let (chain, model) = robot();
703        let points = [q(0.0, 0.0), q(0.3, 0.0), q(0.3, 0.3)];
704        let env = CuCollisionEnv::EMPTY;
705        let config = CuCartesianBlendParams::default();
706        let mut path = CuCartesianBlendPath::<3, 8>::default();
707        assert_eq!(
708            blend_cartesian_path(
709                &chain,
710                &model,
711                &env,
712                &waypoints(&points, [0.0, 0.1, 0.0]),
713                &config,
714                &mut path
715            )
716            .unwrap(),
717            CuCartesianBlendStatus::CapacityExceeded
718        );
719        assert_eq!(path.len, 0);
720        assert_eq!(
721            blend_cartesian_path(
722                &chain,
723                &model,
724                &env,
725                &waypoints(&points, [0.0, -0.1, 0.0]),
726                &config,
727                &mut path
728            )
729            .unwrap(),
730            CuCartesianBlendStatus::InvalidInput
731        );
732        assert_eq!(path.len, 0);
733    }
734}