Skip to main content

phyz_rigid/
ik.rs

1//! Inverse kinematics: solve for a configuration that puts bodies where you
2//! want them.
3//!
4//! Forward kinematics answers "given `q`, where is the hand?". This module
5//! answers the other direction — "what `q` puts the hand *there*?" — for an
6//! arbitrary set of position and orientation goals at once, on any model the
7//! rest of the crate can simulate.
8//!
9//! # Method
10//!
11//! Levenberg–Marquardt on the stacked task Jacobian. Each iteration builds the
12//! residual `e(q)` (goal minus current, `3` rows per goal) and its Jacobian
13//! `J = de/dqd` from [`point_jacobian`] and [`body_angular_jacobian`], then
14//! solves the damped normal equations
15//!
16//! ```text
17//! (JᵀJ + λ²I) δ = Jᵀe
18//! ```
19//!
20//! and applies `δ` through [`integrate_configuration`], so free and ball
21//! joints move on the manifold instead of having angles added into quaternion
22//! slots. λ adapts: halved on an accepted step, multiplied by ten on a
23//! rejected one. That is what makes this behave at a singularity — a plain
24//! pseudo-inverse blows up when the arm straightens, and the damping trades a
25//! little convergence rate for never doing that.
26//!
27//! # Redundancy, limits, and what "converged" means
28//!
29//! * More DOFs than task rows is fine and normal; damping picks the
30//!   minimum-norm step, so a redundant arm drifts as little as it can from the
31//!   seed you hand it. **The seed matters** — IK is not a function, and a
32//!   7-DOF arm has a continuum of answers.
33//! * Single-DOF joint limits are enforced by clamping after each accepted
34//!   step. That is a projection, not a constraint in the solve: with a goal
35//!   outside the reachable set the solver converges to a clamped configuration
36//!   and reports `converged == false` with the residual it got stuck at.
37//! * [`IkConfig::locked`] freezes DOFs entirely (a fixed torso, a gripper you
38//!   do not want IK to open), by dropping their columns from the solve.
39//!
40//! Solving is deterministic: same `(model, seed, goals, config)` in, same bits
41//! out, on every platform the rest of phyz is reproducible on.
42//!
43//! # Example
44//!
45//! ```
46//! use phyz_math::{Mat3, SpatialInertia, SpatialTransform, Vec3};
47//! use phyz_model::ModelBuilder;
48//! use phyz_rigid::ik::{IkConfig, IkGoal, solve_ik};
49//!
50//! let link = |m: f64| SpatialInertia::new(m, Vec3::zeros(), Mat3::identity() * 0.01);
51//! let model = ModelBuilder::new()
52//!     .dt(1e-3)
53//!     .add_revolute_body("shoulder", -1, SpatialTransform::identity(), link(1.0))
54//!     .add_revolute_body(
55//!         "elbow",
56//!         0,
57//!         SpatialTransform::new(Mat3::identity(), Vec3::new(1.0, 0.0, 0.0)),
58//!         link(1.0),
59//!     )
60//!     .build();
61//!
62//! // Put the tip of the second link at (1.0, 1.0, 0.0).
63//! let goals = [IkGoal::position(1, Vec3::new(1.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 0.0))];
64//! let seed = model.default_state().q;
65//! let sol = solve_ik(&model, seed.as_slice(), &goals, &IkConfig::default());
66//! assert!(sol.converged, "residual {}", sol.residual);
67//! ```
68
69use crate::{body_angular_jacobian, forward_kinematics, integrate_configuration, point_jacobian};
70use phyz_math::{DMat, DVec, Mat3, Quat, SpatialTransformExt, Vec3};
71use phyz_model::Model;
72
73/// A single kinematic goal.
74///
75/// Build with [`IkGoal::position`] or [`IkGoal::orientation`] and adjust
76/// `weight` to trade goals off against each other — weights scale the
77/// residual rows, so a goal at weight `10.0` is worth a hundred times one at
78/// weight `1.0` in the least-squares sense.
79#[derive(Debug, Clone, PartialEq)]
80pub struct IkGoal {
81    /// Body the goal applies to.
82    pub body: usize,
83    /// What is being asked of it.
84    pub kind: IkGoalKind,
85    /// Relative importance. Defaults to `1.0`.
86    pub weight: f64,
87}
88
89/// The two things a goal can ask for.
90#[derive(Debug, Clone, PartialEq)]
91pub enum IkGoalKind {
92    /// Put the body-frame point `local_point` at the world point `target`.
93    Position {
94        /// Point in **body** coordinates — the tool tip, the foot, the camera.
95        local_point: Vec3,
96        /// Where it should end up, in **world** coordinates.
97        target: Vec3,
98    },
99    /// Give the body the world→body rotation `target`.
100    ///
101    /// Same convention as [`phyz_math::SpatialTransform::rot`] and everything
102    /// [`forward_kinematics`] produces: `target` maps world directions into
103    /// the body frame, so its transpose is the body's orientation in the
104    /// world. Residual rows are the rotation vector (axis × angle, radians)
105    /// that would carry the current orientation onto the target, in world
106    /// axes.
107    Orientation {
108        /// Desired world→body rotation.
109        target: Mat3,
110    },
111}
112
113impl IkGoal {
114    /// A position goal at unit weight: put `local_point` (body frame) on
115    /// `target` (world frame).
116    pub fn position(body: usize, local_point: Vec3, target: Vec3) -> Self {
117        Self {
118            body,
119            kind: IkGoalKind::Position {
120                local_point,
121                target,
122            },
123            weight: 1.0,
124        }
125    }
126
127    /// An orientation goal at unit weight. `target` is the desired
128    /// **world→body** rotation; see [`IkGoalKind::Orientation`].
129    pub fn orientation(body: usize, target: Mat3) -> Self {
130        Self {
131            body,
132            kind: IkGoalKind::Orientation { target },
133            weight: 1.0,
134        }
135    }
136
137    /// Set the goal's weight, builder-style.
138    pub fn with_weight(mut self, weight: f64) -> Self {
139        self.weight = weight;
140        self
141    }
142}
143
144/// Solver settings. [`IkConfig::default`] is tuned for robot-arm-sized
145/// problems: metres and radians, goals within reach, a seed that is not
146/// exactly at a singularity.
147#[derive(Debug, Clone, PartialEq)]
148pub struct IkConfig {
149    /// Maximum Levenberg–Marquardt iterations, counting rejected trials.
150    pub max_iterations: usize,
151    /// Convergence threshold on the largest single residual component —
152    /// metres for position rows, radians for orientation rows.
153    pub tolerance: f64,
154    /// Initial damping λ. Larger is slower and steadier.
155    pub damping: f64,
156    /// Floor on λ. Keeps the normal equations conditioned near singularities;
157    /// zero is legal but asks for a numerically exact rank decision.
158    pub min_damping: f64,
159    /// Ceiling on λ. Once damping has to exceed this to make progress, the
160    /// solver has stalled and returns.
161    pub max_damping: f64,
162    /// Fraction of the computed step to take. `1.0` is the LM step; lower it
163    /// only when the model has joints whose Jacobian changes violently.
164    pub step_scale: f64,
165    /// Enforce single-DOF joint limits by clamping after each accepted step.
166    pub respect_limits: bool,
167    /// Velocity-index DOFs to hold fixed (indices into `0..model.nv`).
168    pub locked: Vec<usize>,
169}
170
171impl Default for IkConfig {
172    fn default() -> Self {
173        Self {
174            max_iterations: 100,
175            tolerance: 1e-6,
176            damping: 1e-3,
177            min_damping: 1e-9,
178            max_damping: 1e6,
179            step_scale: 1.0,
180            respect_limits: true,
181            locked: Vec::new(),
182        }
183    }
184}
185
186/// What [`solve_ik`] found.
187#[derive(Debug, Clone, PartialEq)]
188pub struct IkSolution {
189    /// The configuration, in the same layout as [`phyz_model::State::q`].
190    pub q: Vec<f64>,
191    /// Largest absolute residual component at `q` — metres or radians,
192    /// whichever goal is worst. This is the number to report, not the
193    /// least-squares cost.
194    pub residual: f64,
195    /// Whether `residual` fell below [`IkConfig::tolerance`].
196    pub converged: bool,
197    /// Iterations consumed, rejected trials included.
198    pub iterations: usize,
199    /// Final damping. A large value on return means the solver was fighting a
200    /// singularity or an unreachable goal.
201    pub damping: f64,
202}
203
204/// Solve for a configuration satisfying `goals`, starting from `seed`.
205///
206/// `seed` is a `q` vector in [`phyz_model::State::q`] layout; pass
207/// `model.default_state().q` for a cold start, or the previous solution when
208/// tracking a moving target (much faster, and it keeps a redundant arm from
209/// jumping between branches between frames).
210///
211/// Never panics on an unreachable or over-constrained goal set: it returns the
212/// least-squares compromise with `converged == false`. An empty goal list
213/// returns the seed.
214pub fn solve_ik(model: &Model, seed: &[f64], goals: &[IkGoal], config: &IkConfig) -> IkSolution {
215    let mut q = seed.to_vec();
216    q.resize(model.nq, 0.0);
217
218    if goals.is_empty() || model.nv == 0 {
219        return IkSolution {
220            q,
221            residual: 0.0,
222            converged: true,
223            iterations: 0,
224            damping: config.damping,
225        };
226    }
227
228    let rows = 3 * goals.len();
229    let mut lambda = config.damping.max(config.min_damping);
230    let mut residual = residual_vector(model, &q, goals);
231    let mut cost = residual.norm_sq();
232    let mut worst = max_abs(&residual);
233    let mut iterations = 0;
234
235    while iterations < config.max_iterations {
236        if worst <= config.tolerance {
237            break;
238        }
239        iterations += 1;
240
241        let jac = task_jacobian(model, &q, goals, &config.locked);
242
243        // Damped normal equations. `rows` may be smaller or larger than nv;
244        // the nv x nv form handles both, and with λ > 0 it is symmetric
245        // positive definite, so Cholesky is the right factorization.
246        let jt = jac.transpose();
247        let mut normal = &jt * &jac;
248        for i in 0..model.nv {
249            normal[(i, i)] += lambda * lambda;
250        }
251        // Locked DOFs already have zero columns in `jac`; give them a unit
252        // diagonal so the matrix stays non-singular and their step is zero.
253        for &d in &config.locked {
254            if d < model.nv {
255                for k in 0..model.nv {
256                    normal[(d, k)] = 0.0;
257                    normal[(k, d)] = 0.0;
258                }
259                normal[(d, d)] = 1.0;
260            }
261        }
262        let rhs = &jt * &residual;
263
264        let Some(delta) = solve_spd(&normal, &rhs) else {
265            // Factorization failed outright (λ far too small for a rank-
266            // deficient J). Crank the damping and retry rather than giving up.
267            lambda = (lambda * 10.0).max(config.min_damping * 10.0);
268            if lambda > config.max_damping {
269                break;
270            }
271            continue;
272        };
273
274        let mut trial = q.clone();
275        let step: Vec<f64> = (0..model.nv)
276            .map(|i| delta[i] * config.step_scale)
277            .collect();
278        integrate_configuration(model, trial.as_mut_slice(), &step, 1.0);
279        if config.respect_limits {
280            clamp_to_limits(model, &mut trial);
281        }
282
283        let trial_residual = residual_vector(model, &trial, goals);
284        let trial_cost = trial_residual.norm_sq();
285
286        if trial_cost < cost {
287            q = trial;
288            residual = trial_residual;
289            cost = trial_cost;
290            worst = max_abs(&residual);
291            lambda = (lambda * 0.5).max(config.min_damping);
292        } else {
293            // Reject and stiffen. A clamped step that cannot improve the cost
294            // at any damping is the unreachable-goal exit.
295            lambda *= 10.0;
296            if lambda > config.max_damping {
297                break;
298            }
299        }
300    }
301
302    debug_assert_eq!(rows, 3 * goals.len());
303
304    IkSolution {
305        q,
306        residual: worst,
307        converged: worst <= config.tolerance,
308        iterations,
309        damping: lambda,
310    }
311}
312
313/// Residual `e(q)`: for each goal, three rows of "how far, and which way".
314///
315/// Exposed because it is the honest way to score a configuration a solver
316/// handed back — and the thing to plot when IK is misbehaving.
317pub fn residual_vector(model: &Model, q: &[f64], goals: &[IkGoal]) -> DVec {
318    let mut state = model.default_state();
319    state.q = DVec::from_slice(q);
320    let (xforms, _) = forward_kinematics(model, &state);
321
322    let mut e = DVec::zeros(3 * goals.len());
323    for (g, goal) in goals.iter().enumerate() {
324        let xf = &xforms[goal.body];
325        let row = 3 * g;
326        let v = match &goal.kind {
327            IkGoalKind::Position {
328                local_point,
329                target,
330            } => *target - xf.body_to_world_point(*local_point),
331            IkGoalKind::Orientation { target } => {
332                // exp(ω) · B_current = B_target with B = Rᵀ (body→world),
333                // so exp(ω) = B_target · B_currentᵀ = Rᵀ_target · R_current.
334                Quat::from_matrix(&(target.transpose() * xf.rot)).log()
335            }
336        };
337        e[row] = v.x * goal.weight;
338        e[row + 1] = v.y * goal.weight;
339        e[row + 2] = v.z * goal.weight;
340    }
341    e
342}
343
344/// The stacked `3·goals x nv` task Jacobian at `q`, weighted to match
345/// [`residual_vector`], with locked DOF columns zeroed.
346fn task_jacobian(model: &Model, q: &[f64], goals: &[IkGoal], locked: &[usize]) -> DMat {
347    let mut state = model.default_state();
348    state.q = DVec::from_slice(q);
349    let (xforms, _) = forward_kinematics(model, &state);
350
351    let mut j = DMat::zeros(3 * goals.len(), model.nv);
352    for (g, goal) in goals.iter().enumerate() {
353        let xf = &xforms[goal.body];
354        let block = match &goal.kind {
355            IkGoalKind::Position { local_point, .. } => {
356                let world = xf.body_to_world_point(*local_point);
357                point_jacobian(model, &xforms, goal.body, world)
358            }
359            IkGoalKind::Orientation { .. } => body_angular_jacobian(model, &xforms, goal.body),
360        };
361        for r in 0..3 {
362            for c in 0..model.nv {
363                j[(3 * g + r, c)] = block[(r, c)] * goal.weight;
364            }
365        }
366    }
367    for &d in locked {
368        if d < model.nv {
369            for r in 0..j.nrows() {
370                j[(r, d)] = 0.0;
371            }
372        }
373    }
374    j
375}
376
377/// Clamp single-DOF joints to their declared limits.
378///
379/// Multi-DOF joints are skipped: `Joint::limits` is a scalar interval, which
380/// says nothing usable about a ball or free joint.
381fn clamp_to_limits(model: &Model, q: &mut [f64]) {
382    for (j, joint) in model.joints.iter().enumerate() {
383        let Some([lo, hi]) = joint.limits else {
384            continue;
385        };
386        if joint.ndof() != 1 {
387            continue;
388        }
389        let Some(&idx) = model.q_offsets.get(j) else {
390            continue;
391        };
392        if idx < q.len() {
393            q[idx] = q[idx].clamp(lo, hi);
394        }
395    }
396}
397
398/// Solve `A x = b` for the damped normal matrix, rejecting a non-finite
399/// result so the caller can stiffen the damping instead of propagating NaN.
400fn solve_spd(a: &DMat, b: &DVec) -> Option<DVec> {
401    a.clone()
402        .lu()
403        .solve(b)
404        .filter(|x: &DVec| x.as_slice().iter().all(|v: &f64| v.is_finite()))
405}
406
407/// Largest absolute component, **propagating non-finite values** instead of
408/// swallowing them.
409///
410/// `f64::max` returns the other operand when one side is NaN, so the natural
411/// `fold(0.0, f64::max)` over a diverged vector reports `0.0` — the smallest
412/// possible answer for the worst possible state. Here that would set
413/// `residual = 0.0` and `converged = true` on a solution full of NaN, which is
414/// the single worst thing this function could do: it is what decides whether
415/// the caller trusts the result.
416///
417/// A non-finite residual is not a small residual, so it reports NaN, and every
418/// `residual <= tolerance` comparison downstream is then false.
419fn max_abs(v: &DVec) -> f64 {
420    if v.as_slice().iter().any(|x| !x.is_finite()) {
421        return f64::NAN;
422    }
423    v.as_slice().iter().fold(0.0f64, |m, x| m.max(x.abs()))
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use phyz_math::{Mat3, SpatialInertia, SpatialTransform};
430    use phyz_model::ModelBuilder;
431
432    fn link(m: f64) -> SpatialInertia {
433        SpatialInertia::new(m, Vec3::zeros(), Mat3::identity() * 0.01)
434    }
435
436    /// Planar two-link arm, links along +X, revolute about +Z.
437    fn two_link() -> Model {
438        ModelBuilder::new()
439            .dt(1e-3)
440            .add_revolute_body("shoulder", -1, SpatialTransform::identity(), link(1.0))
441            .add_revolute_body(
442                "elbow",
443                0,
444                SpatialTransform::new(Mat3::identity(), Vec3::new(1.0, 0.0, 0.0)),
445                link(1.0),
446            )
447            .build()
448    }
449
450    fn tip(model: &Model, q: &[f64]) -> Vec3 {
451        let mut s = model.default_state();
452        s.q = DVec::from_slice(q);
453        let (xf, _) = forward_kinematics(model, &s);
454        xf[1].body_to_world_point(Vec3::new(1.0, 0.0, 0.0))
455    }
456
457    #[test]
458    fn reaches_a_point_in_the_workspace() {
459        let model = two_link();
460        let target = Vec3::new(1.0, 1.0, 0.0);
461        let goals = [IkGoal::position(1, Vec3::new(1.0, 0.0, 0.0), target)];
462        let sol = solve_ik(&model, &[0.1, 0.1], &goals, &IkConfig::default());
463
464        assert!(
465            sol.converged,
466            "residual {} after {} it",
467            sol.residual, sol.iterations
468        );
469        assert!(
470            (tip(&model, &sol.q) - target).norm() < 1e-6,
471            "tip {:?}",
472            tip(&model, &sol.q)
473        );
474    }
475
476    /// The seed is already the answer: zero iterations, zero residual.
477    #[test]
478    fn seed_on_target_is_a_no_op() {
479        let model = two_link();
480        let seed = [0.3, -0.4];
481        let goals = [IkGoal::position(
482            1,
483            Vec3::new(1.0, 0.0, 0.0),
484            tip(&model, &seed),
485        )];
486        let sol = solve_ik(&model, &seed, &goals, &IkConfig::default());
487
488        assert_eq!(sol.iterations, 0);
489        assert!(sol.converged);
490        for (a, b) in sol.q.iter().zip(seed.iter()) {
491            assert_eq!(a, b);
492        }
493    }
494
495    /// A goal outside the reachable set must not converge, must not diverge,
496    /// and must land on the closest point the arm can make: fully extended
497    /// along the direction of the target.
498    #[test]
499    fn unreachable_goal_stops_at_full_extension() {
500        let model = two_link();
501        let goals = [IkGoal::position(
502            1,
503            Vec3::new(1.0, 0.0, 0.0),
504            Vec3::new(10.0, 0.0, 0.0),
505        )];
506        let sol = solve_ik(&model, &[0.4, 0.4], &goals, &IkConfig::default());
507
508        assert!(!sol.converged);
509        let p = tip(&model, &sol.q);
510        assert!(
511            p.norm() > 2.0 - 1e-4,
512            "not extended: {p:?} (|p| = {})",
513            p.norm()
514        );
515        assert!(sol.residual.is_finite());
516    }
517
518    /// Singular seed: a straight arm has a rank-deficient Jacobian. Damping is
519    /// the whole reason this does not produce NaN or a step to infinity.
520    #[test]
521    fn straight_arm_singularity_is_survivable() {
522        let model = two_link();
523        let goals = [IkGoal::position(
524            1,
525            Vec3::new(1.0, 0.0, 0.0),
526            Vec3::new(1.4, 0.9, 0.0),
527        )];
528        let sol = solve_ik(&model, &[0.0, 0.0], &goals, &IkConfig::default());
529
530        assert!(sol.q.iter().all(|v| v.is_finite()), "q = {:?}", sol.q);
531        assert!(sol.converged, "residual {}", sol.residual);
532    }
533
534    #[test]
535    fn joint_limits_are_respected() {
536        let mut model = two_link();
537        model.joints[1].limits = Some([-0.2, 0.2]);
538        let goals = [IkGoal::position(
539            1,
540            Vec3::new(1.0, 0.0, 0.0),
541            Vec3::new(0.0, 1.5, 0.0),
542        )];
543        let sol = solve_ik(&model, &[0.2, 0.0], &goals, &IkConfig::default());
544
545        assert!(
546            sol.q[1] >= -0.2 - 1e-12 && sol.q[1] <= 0.2 + 1e-12,
547            "elbow escaped its limit: {}",
548            sol.q[1]
549        );
550    }
551
552    #[test]
553    fn locked_dofs_do_not_move() {
554        let model = two_link();
555        let config = IkConfig {
556            locked: vec![0],
557            ..Default::default()
558        };
559        let goals = [IkGoal::position(
560            1,
561            Vec3::new(1.0, 0.0, 0.0),
562            Vec3::new(1.2, 0.8, 0.0),
563        )];
564        let sol = solve_ik(&model, &[0.15, 0.15], &goals, &config);
565
566        assert_eq!(sol.q[0], 0.15, "locked shoulder moved");
567        assert_ne!(sol.q[1], 0.15, "elbow should still have moved");
568    }
569
570    /// Orientation goals: a free body must be able to rotate onto any target,
571    /// which also pins down the world→body convention.
572    #[test]
573    fn orientation_goal_on_a_free_body() {
574        let model = ModelBuilder::new()
575            .dt(1e-3)
576            .add_free_body(
577                "ball",
578                -1,
579                SpatialTransform::identity(),
580                SpatialInertia::sphere(1.0, 0.1),
581            )
582            .build();
583
584        let target = Quat::from_axis_angle(Vec3::new(0.3, 0.5, 0.8).normalize(), 0.9)
585            .to_matrix()
586            .transpose(); // world->body
587        let goals = [IkGoal::orientation(0, target)];
588        let sol = solve_ik(
589            &model,
590            model.default_state().q.as_slice(),
591            &goals,
592            &IkConfig::default(),
593        );
594
595        assert!(sol.converged, "residual {}", sol.residual);
596        let e = residual_vector(&model, &sol.q, &goals);
597        assert!(max_abs(&e) < 1e-6, "orientation residual {}", max_abs(&e));
598    }
599
600    /// Position and orientation together on the same body, which is the case
601    /// that actually matters for a gripper.
602    #[test]
603    fn position_and_orientation_together() {
604        let model = ModelBuilder::new()
605            .dt(1e-3)
606            .add_free_body(
607                "hand",
608                -1,
609                SpatialTransform::identity(),
610                SpatialInertia::sphere(1.0, 0.1),
611            )
612            .build();
613
614        let target_pos = Vec3::new(0.4, -0.2, 0.7);
615        let target_rot = Quat::from_axis_angle(Vec3::z(), 0.6)
616            .to_matrix()
617            .transpose();
618        let goals = [
619            IkGoal::position(0, Vec3::zeros(), target_pos),
620            IkGoal::orientation(0, target_rot),
621        ];
622        let sol = solve_ik(
623            &model,
624            model.default_state().q.as_slice(),
625            &goals,
626            &IkConfig::default(),
627        );
628
629        assert!(sol.converged, "residual {}", sol.residual);
630    }
631
632    /// Weights decide who wins when two goals conflict.
633    #[test]
634    fn weights_break_ties_between_conflicting_goals() {
635        let model = two_link();
636        let a = Vec3::new(1.0, 1.0, 0.0);
637        let b = Vec3::new(1.0, -1.0, 0.0);
638        let solve = |wa: f64, wb: f64| {
639            let goals = [
640                IkGoal::position(1, Vec3::new(1.0, 0.0, 0.0), a).with_weight(wa),
641                IkGoal::position(1, Vec3::new(1.0, 0.0, 0.0), b).with_weight(wb),
642            ];
643            tip(
644                &model,
645                &solve_ik(&model, &[0.1, 0.1], &goals, &IkConfig::default()).q,
646            )
647        };
648
649        // Heavily favouring `a` must land closer to `a` than to `b`, and the
650        // mirrored weighting must land closer to `b`.
651        let toward_a = solve(100.0, 1.0);
652        let toward_b = solve(1.0, 100.0);
653        assert!((toward_a - a).norm() < (toward_a - b).norm());
654        assert!((toward_b - b).norm() < (toward_b - a).norm());
655    }
656
657    /// Same inputs, same bits — the property the rest of phyz is built on.
658    #[test]
659    fn solving_is_deterministic() {
660        let model = two_link();
661        let goals = [IkGoal::position(
662            1,
663            Vec3::new(1.0, 0.0, 0.0),
664            Vec3::new(0.7, 1.1, 0.0),
665        )];
666        let a = solve_ik(&model, &[0.1, 0.2], &goals, &IkConfig::default());
667        let b = solve_ik(&model, &[0.1, 0.2], &goals, &IkConfig::default());
668        assert_eq!(a, b);
669    }
670
671    /// A non-finite seed must not be reported as a converged solution.
672    ///
673    /// `f64::max` ignores NaN, so a max-abs reduction written the obvious way
674    /// returns `0.0` for an all-NaN residual — the smallest possible number
675    /// for the worst possible state — and `converged` would be `true` on
676    /// garbage. This is the guard against that.
677    #[test]
678    fn a_non_finite_seed_is_never_reported_as_converged() {
679        let model = two_link();
680        let goals = [IkGoal::position(
681            1,
682            Vec3::new(1.0, 0.0, 0.0),
683            Vec3::new(1.0, 1.0, 0.0),
684        )];
685        let sol = solve_ik(&model, &[f64::NAN, 0.1], &goals, &IkConfig::default());
686        assert!(
687            !sol.converged,
688            "reported convergence with residual {} on a NaN seed",
689            sol.residual
690        );
691    }
692
693    #[test]
694    fn no_goals_returns_the_seed() {
695        let model = two_link();
696        let sol = solve_ik(&model, &[0.3, 0.4], &[], &IkConfig::default());
697        assert_eq!(sol.q, vec![0.3, 0.4]);
698        assert!(sol.converged);
699        assert_eq!(sol.iterations, 0);
700    }
701}