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}