use crate::{body_angular_jacobian, forward_kinematics, integrate_configuration, point_jacobian};
use phyz_math::{DMat, DVec, Mat3, Quat, SpatialTransformExt, Vec3};
use phyz_model::Model;
#[derive(Debug, Clone, PartialEq)]
pub struct IkGoal {
pub body: usize,
pub kind: IkGoalKind,
pub weight: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub enum IkGoalKind {
Position {
local_point: Vec3,
target: Vec3,
},
Orientation {
target: Mat3,
},
}
impl IkGoal {
pub fn position(body: usize, local_point: Vec3, target: Vec3) -> Self {
Self {
body,
kind: IkGoalKind::Position {
local_point,
target,
},
weight: 1.0,
}
}
pub fn orientation(body: usize, target: Mat3) -> Self {
Self {
body,
kind: IkGoalKind::Orientation { target },
weight: 1.0,
}
}
pub fn with_weight(mut self, weight: f64) -> Self {
self.weight = weight;
self
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct IkConfig {
pub max_iterations: usize,
pub tolerance: f64,
pub damping: f64,
pub min_damping: f64,
pub max_damping: f64,
pub step_scale: f64,
pub respect_limits: bool,
pub locked: Vec<usize>,
}
impl Default for IkConfig {
fn default() -> Self {
Self {
max_iterations: 100,
tolerance: 1e-6,
damping: 1e-3,
min_damping: 1e-9,
max_damping: 1e6,
step_scale: 1.0,
respect_limits: true,
locked: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct IkSolution {
pub q: Vec<f64>,
pub residual: f64,
pub converged: bool,
pub iterations: usize,
pub damping: f64,
}
pub fn solve_ik(model: &Model, seed: &[f64], goals: &[IkGoal], config: &IkConfig) -> IkSolution {
let mut q = seed.to_vec();
q.resize(model.nq, 0.0);
if goals.is_empty() || model.nv == 0 {
return IkSolution {
q,
residual: 0.0,
converged: true,
iterations: 0,
damping: config.damping,
};
}
let rows = 3 * goals.len();
let mut lambda = config.damping.max(config.min_damping);
let mut residual = residual_vector(model, &q, goals);
let mut cost = residual.norm_sq();
let mut worst = max_abs(&residual);
let mut iterations = 0;
while iterations < config.max_iterations {
if worst <= config.tolerance {
break;
}
iterations += 1;
let jac = task_jacobian(model, &q, goals, &config.locked);
let jt = jac.transpose();
let mut normal = &jt * &jac;
for i in 0..model.nv {
normal[(i, i)] += lambda * lambda;
}
for &d in &config.locked {
if d < model.nv {
for k in 0..model.nv {
normal[(d, k)] = 0.0;
normal[(k, d)] = 0.0;
}
normal[(d, d)] = 1.0;
}
}
let rhs = &jt * &residual;
let Some(delta) = solve_spd(&normal, &rhs) else {
lambda = (lambda * 10.0).max(config.min_damping * 10.0);
if lambda > config.max_damping {
break;
}
continue;
};
let mut trial = q.clone();
let step: Vec<f64> = (0..model.nv)
.map(|i| delta[i] * config.step_scale)
.collect();
integrate_configuration(model, trial.as_mut_slice(), &step, 1.0);
if config.respect_limits {
clamp_to_limits(model, &mut trial);
}
let trial_residual = residual_vector(model, &trial, goals);
let trial_cost = trial_residual.norm_sq();
if trial_cost < cost {
q = trial;
residual = trial_residual;
cost = trial_cost;
worst = max_abs(&residual);
lambda = (lambda * 0.5).max(config.min_damping);
} else {
lambda *= 10.0;
if lambda > config.max_damping {
break;
}
}
}
debug_assert_eq!(rows, 3 * goals.len());
IkSolution {
q,
residual: worst,
converged: worst <= config.tolerance,
iterations,
damping: lambda,
}
}
pub fn residual_vector(model: &Model, q: &[f64], goals: &[IkGoal]) -> DVec {
let mut state = model.default_state();
state.q = DVec::from_slice(q);
let (xforms, _) = forward_kinematics(model, &state);
let mut e = DVec::zeros(3 * goals.len());
for (g, goal) in goals.iter().enumerate() {
let xf = &xforms[goal.body];
let row = 3 * g;
let v = match &goal.kind {
IkGoalKind::Position {
local_point,
target,
} => *target - xf.body_to_world_point(*local_point),
IkGoalKind::Orientation { target } => {
Quat::from_matrix(&(target.transpose() * xf.rot)).log()
}
};
e[row] = v.x * goal.weight;
e[row + 1] = v.y * goal.weight;
e[row + 2] = v.z * goal.weight;
}
e
}
fn task_jacobian(model: &Model, q: &[f64], goals: &[IkGoal], locked: &[usize]) -> DMat {
let mut state = model.default_state();
state.q = DVec::from_slice(q);
let (xforms, _) = forward_kinematics(model, &state);
let mut j = DMat::zeros(3 * goals.len(), model.nv);
for (g, goal) in goals.iter().enumerate() {
let xf = &xforms[goal.body];
let block = match &goal.kind {
IkGoalKind::Position { local_point, .. } => {
let world = xf.body_to_world_point(*local_point);
point_jacobian(model, &xforms, goal.body, world)
}
IkGoalKind::Orientation { .. } => body_angular_jacobian(model, &xforms, goal.body),
};
for r in 0..3 {
for c in 0..model.nv {
j[(3 * g + r, c)] = block[(r, c)] * goal.weight;
}
}
}
for &d in locked {
if d < model.nv {
for r in 0..j.nrows() {
j[(r, d)] = 0.0;
}
}
}
j
}
fn clamp_to_limits(model: &Model, q: &mut [f64]) {
for (j, joint) in model.joints.iter().enumerate() {
let Some([lo, hi]) = joint.limits else {
continue;
};
if joint.ndof() != 1 {
continue;
}
let Some(&idx) = model.q_offsets.get(j) else {
continue;
};
if idx < q.len() {
q[idx] = q[idx].clamp(lo, hi);
}
}
}
fn solve_spd(a: &DMat, b: &DVec) -> Option<DVec> {
a.clone()
.lu()
.solve(b)
.filter(|x: &DVec| x.as_slice().iter().all(|v: &f64| v.is_finite()))
}
fn max_abs(v: &DVec) -> f64 {
if v.as_slice().iter().any(|x| !x.is_finite()) {
return f64::NAN;
}
v.as_slice().iter().fold(0.0f64, |m, x| m.max(x.abs()))
}
#[cfg(test)]
mod tests {
use super::*;
use phyz_math::{Mat3, SpatialInertia, SpatialTransform};
use phyz_model::ModelBuilder;
fn link(m: f64) -> SpatialInertia {
SpatialInertia::new(m, Vec3::zeros(), Mat3::identity() * 0.01)
}
fn two_link() -> Model {
ModelBuilder::new()
.dt(1e-3)
.add_revolute_body("shoulder", -1, SpatialTransform::identity(), link(1.0))
.add_revolute_body(
"elbow",
0,
SpatialTransform::new(Mat3::identity(), Vec3::new(1.0, 0.0, 0.0)),
link(1.0),
)
.build()
}
fn tip(model: &Model, q: &[f64]) -> Vec3 {
let mut s = model.default_state();
s.q = DVec::from_slice(q);
let (xf, _) = forward_kinematics(model, &s);
xf[1].body_to_world_point(Vec3::new(1.0, 0.0, 0.0))
}
#[test]
fn reaches_a_point_in_the_workspace() {
let model = two_link();
let target = Vec3::new(1.0, 1.0, 0.0);
let goals = [IkGoal::position(1, Vec3::new(1.0, 0.0, 0.0), target)];
let sol = solve_ik(&model, &[0.1, 0.1], &goals, &IkConfig::default());
assert!(
sol.converged,
"residual {} after {} it",
sol.residual, sol.iterations
);
assert!(
(tip(&model, &sol.q) - target).norm() < 1e-6,
"tip {:?}",
tip(&model, &sol.q)
);
}
#[test]
fn seed_on_target_is_a_no_op() {
let model = two_link();
let seed = [0.3, -0.4];
let goals = [IkGoal::position(
1,
Vec3::new(1.0, 0.0, 0.0),
tip(&model, &seed),
)];
let sol = solve_ik(&model, &seed, &goals, &IkConfig::default());
assert_eq!(sol.iterations, 0);
assert!(sol.converged);
for (a, b) in sol.q.iter().zip(seed.iter()) {
assert_eq!(a, b);
}
}
#[test]
fn unreachable_goal_stops_at_full_extension() {
let model = two_link();
let goals = [IkGoal::position(
1,
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(10.0, 0.0, 0.0),
)];
let sol = solve_ik(&model, &[0.4, 0.4], &goals, &IkConfig::default());
assert!(!sol.converged);
let p = tip(&model, &sol.q);
assert!(
p.norm() > 2.0 - 1e-4,
"not extended: {p:?} (|p| = {})",
p.norm()
);
assert!(sol.residual.is_finite());
}
#[test]
fn straight_arm_singularity_is_survivable() {
let model = two_link();
let goals = [IkGoal::position(
1,
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(1.4, 0.9, 0.0),
)];
let sol = solve_ik(&model, &[0.0, 0.0], &goals, &IkConfig::default());
assert!(sol.q.iter().all(|v| v.is_finite()), "q = {:?}", sol.q);
assert!(sol.converged, "residual {}", sol.residual);
}
#[test]
fn joint_limits_are_respected() {
let mut model = two_link();
model.joints[1].limits = Some([-0.2, 0.2]);
let goals = [IkGoal::position(
1,
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.0, 1.5, 0.0),
)];
let sol = solve_ik(&model, &[0.2, 0.0], &goals, &IkConfig::default());
assert!(
sol.q[1] >= -0.2 - 1e-12 && sol.q[1] <= 0.2 + 1e-12,
"elbow escaped its limit: {}",
sol.q[1]
);
}
#[test]
fn locked_dofs_do_not_move() {
let model = two_link();
let config = IkConfig {
locked: vec![0],
..Default::default()
};
let goals = [IkGoal::position(
1,
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(1.2, 0.8, 0.0),
)];
let sol = solve_ik(&model, &[0.15, 0.15], &goals, &config);
assert_eq!(sol.q[0], 0.15, "locked shoulder moved");
assert_ne!(sol.q[1], 0.15, "elbow should still have moved");
}
#[test]
fn orientation_goal_on_a_free_body() {
let model = ModelBuilder::new()
.dt(1e-3)
.add_free_body(
"ball",
-1,
SpatialTransform::identity(),
SpatialInertia::sphere(1.0, 0.1),
)
.build();
let target = Quat::from_axis_angle(Vec3::new(0.3, 0.5, 0.8).normalize(), 0.9)
.to_matrix()
.transpose(); let goals = [IkGoal::orientation(0, target)];
let sol = solve_ik(
&model,
model.default_state().q.as_slice(),
&goals,
&IkConfig::default(),
);
assert!(sol.converged, "residual {}", sol.residual);
let e = residual_vector(&model, &sol.q, &goals);
assert!(max_abs(&e) < 1e-6, "orientation residual {}", max_abs(&e));
}
#[test]
fn position_and_orientation_together() {
let model = ModelBuilder::new()
.dt(1e-3)
.add_free_body(
"hand",
-1,
SpatialTransform::identity(),
SpatialInertia::sphere(1.0, 0.1),
)
.build();
let target_pos = Vec3::new(0.4, -0.2, 0.7);
let target_rot = Quat::from_axis_angle(Vec3::z(), 0.6)
.to_matrix()
.transpose();
let goals = [
IkGoal::position(0, Vec3::zeros(), target_pos),
IkGoal::orientation(0, target_rot),
];
let sol = solve_ik(
&model,
model.default_state().q.as_slice(),
&goals,
&IkConfig::default(),
);
assert!(sol.converged, "residual {}", sol.residual);
}
#[test]
fn weights_break_ties_between_conflicting_goals() {
let model = two_link();
let a = Vec3::new(1.0, 1.0, 0.0);
let b = Vec3::new(1.0, -1.0, 0.0);
let solve = |wa: f64, wb: f64| {
let goals = [
IkGoal::position(1, Vec3::new(1.0, 0.0, 0.0), a).with_weight(wa),
IkGoal::position(1, Vec3::new(1.0, 0.0, 0.0), b).with_weight(wb),
];
tip(
&model,
&solve_ik(&model, &[0.1, 0.1], &goals, &IkConfig::default()).q,
)
};
let toward_a = solve(100.0, 1.0);
let toward_b = solve(1.0, 100.0);
assert!((toward_a - a).norm() < (toward_a - b).norm());
assert!((toward_b - b).norm() < (toward_b - a).norm());
}
#[test]
fn solving_is_deterministic() {
let model = two_link();
let goals = [IkGoal::position(
1,
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.7, 1.1, 0.0),
)];
let a = solve_ik(&model, &[0.1, 0.2], &goals, &IkConfig::default());
let b = solve_ik(&model, &[0.1, 0.2], &goals, &IkConfig::default());
assert_eq!(a, b);
}
#[test]
fn a_non_finite_seed_is_never_reported_as_converged() {
let model = two_link();
let goals = [IkGoal::position(
1,
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(1.0, 1.0, 0.0),
)];
let sol = solve_ik(&model, &[f64::NAN, 0.1], &goals, &IkConfig::default());
assert!(
!sol.converged,
"reported convergence with residual {} on a NaN seed",
sol.residual
);
}
#[test]
fn no_goals_returns_the_seed() {
let model = two_link();
let sol = solve_ik(&model, &[0.3, 0.4], &[], &IkConfig::default());
assert_eq!(sol.q, vec![0.3, 0.4]);
assert!(sol.converged);
assert_eq!(sol.iterations, 0);
}
}