use phyz_contact::{ContactMaterial, ContactSolverConfig};
use phyz_diff::{
ConvexContactRollout, FinalStateObjective, convex_adjoint_gradient, convex_rollout_objective,
};
use phyz_math::{DVec, GRAVITY, Mat3, SpatialInertia, SpatialTransform, Vec3};
use phyz_model::{Geometry, Model, ModelBuilder};
const HALF: f64 = 0.05;
const RADIUS: f64 = 0.05;
const X: usize = 3;
const Z: usize = 5;
fn free_body(geometry: Geometry, mass: f64, inertia_scale: f64) -> Model {
let i = mass * inertia_scale;
let mut model = ModelBuilder::new()
.gravity(Vec3::new(0.0, 0.0, -GRAVITY))
.dt(1e-3)
.add_free_body(
"body",
-1,
SpatialTransform::identity(),
SpatialInertia::new(
mass,
Vec3::zeros(),
Mat3::from_diagonal(&Vec3::new(i, i, i)),
),
)
.build();
model.bodies[0].geometry = Some(geometry);
model
}
fn box_model() -> Model {
free_body(
Geometry::Box {
half_extents: Vec3::new(HALF, HALF, HALF),
},
1.0,
2.0 / 3.0 * HALF * HALF,
)
}
fn sphere_model() -> Model {
free_body(
Geometry::Sphere { radius: RADIUS },
1.0,
0.4 * RADIUS * RADIUS,
)
}
fn q_objective(i: usize) -> FinalStateObjective<'static> {
let value: &'static dyn Fn(&[f64], &[f64]) -> f64 =
Box::leak(Box::new(move |q: &[f64], _: &[f64]| q[i]));
type GradFn = dyn Fn(&[f64], &[f64]) -> (Vec<f64>, Vec<f64>);
let gradient: &'static GradFn = Box::leak(Box::new(move |q: &[f64], v: &[f64]| {
let mut gq = vec![0.0; q.len()];
gq[i] = 1.0;
(gq, vec![0.0; v.len()])
}));
FinalStateObjective { value, gradient }
}
struct Scene {
model: Model,
material: ContactMaterial,
q0: Vec<f64>,
v0: Vec<f64>,
steps: usize,
obj_index: usize,
}
fn rollout<'a>(
sc: &'a Scene,
material: &ContactMaterial,
ctrl: &'a dyn Fn(usize) -> DVec,
) -> ConvexContactRollout<'a> {
ConvexContactRollout {
model: &sc.model,
ground_height: 0.0,
material: material.clone(),
config: ContactSolverConfig::gradients(),
q0: DVec::from_slice(&sc.q0),
v0: DVec::from_slice(&sc.v0),
steps: sc.steps,
ctrl,
}
}
fn fd_material(sc: &Scene, set: impl Fn(&mut ContactMaterial, f64), h: f64) -> f64 {
let obj = q_objective(sc.obj_index);
let ctrl = |_t: usize| DVec::zeros(6);
let mut mp = sc.material.clone();
let mut mm = sc.material.clone();
set(&mut mp, h);
set(&mut mm, -h);
let fp = convex_rollout_objective(&rollout(sc, &mp, &ctrl), &obj);
let fm = convex_rollout_objective(&rollout(sc, &mm, &ctrl), &obj);
(fp - fm) / (2.0 * h)
}
fn adjoint(sc: &Scene) -> phyz_diff::ConvexAdjointGradients {
let obj = q_objective(sc.obj_index);
let ctrl = |_t: usize| DVec::zeros(6);
convex_adjoint_gradient(&rollout(sc, &sc.material, &ctrl), &obj)
.expect("the material gates run on converging trajectories")
}
fn sliding_box() -> Scene {
let mut v0 = vec![0.0; 6];
v0[X] = 1.0;
Scene {
model: box_model(),
material: ContactMaterial {
friction: 0.4,
..ContactMaterial::default()
},
q0: vec![0.0, 0.0, 0.0, 0.0, 0.0, HALF],
v0,
steps: 150,
obj_index: X,
}
}
#[test]
fn friction_gradient_matches_finite_difference_on_a_slide() {
let sc = sliding_box();
let g = adjoint(&sc);
let fd = fd_material(&sc, |m, d| m.friction += d, 1e-6);
let rel = (g.d_friction - fd).abs() / fd.abs().max(1e-4);
eprintln!(
"dJ/dmu: adjoint {:.6e} FD {:.6e} rel {rel:.3e}",
g.d_friction, fd
);
assert!(
fd < 0.0,
"sanity: more grip should mean less travel, got {fd:.3e}"
);
assert!(
rel <= 1e-3,
"dJ/dmu adjoint {} vs FD {fd} (rel {rel:.3e})",
g.d_friction
);
}
#[test]
fn friction_gradient_is_exactly_zero_when_nothing_slips() {
let sc = Scene {
model: box_model(),
material: ContactMaterial {
friction: 0.4,
..ContactMaterial::default()
},
q0: vec![0.0, 0.0, 0.0, 0.0, 0.0, HALF + 0.01],
v0: vec![0.0; 6],
steps: 200,
obj_index: X,
};
let g = adjoint(&sc);
assert_eq!(
g.d_friction, 0.0,
"a never-slipping trajectory must report structurally zero friction \
sensitivity, got {}",
g.d_friction
);
}
fn bouncing_sphere(e: f64) -> Scene {
Scene {
model: sphere_model(),
material: ContactMaterial {
restitution: e,
friction: 0.5,
..ContactMaterial::default()
},
q0: vec![0.0, 0.0, 0.0, 0.0, 0.0, RADIUS + 0.20],
v0: vec![0.0; 6],
steps: 260,
obj_index: Z,
}
}
#[test]
fn restitution_gradient_matches_finite_difference_through_a_bounce() {
let sc = bouncing_sphere(0.5);
let g = adjoint(&sc);
let fd = fd_material(&sc, |m, d| m.restitution += d, 1e-6);
let rel = (g.d_restitution - fd).abs() / fd.abs().max(1e-4);
eprintln!(
"dJ/de: adjoint {:.6e} FD {:.6e} rel {rel:.3e}",
g.d_restitution, fd
);
assert!(
fd > 0.0,
"sanity: more bounce should mean more height, got {fd:.3e}"
);
assert!(
rel <= 1e-3,
"dJ/de adjoint {} vs FD {fd} (rel {rel:.3e})",
g.d_restitution
);
}
#[test]
fn restitution_gradient_vanishes_below_the_low_speed_ramp() {
let sc = Scene {
model: sphere_model(),
material: ContactMaterial {
restitution: 0.8,
friction: 0.5,
..ContactMaterial::default()
},
q0: vec![0.0, 0.0, 0.0, 0.0, 0.0, RADIUS],
v0: vec![0.0; 6],
steps: 200,
obj_index: Z,
};
let g = adjoint(&sc);
let fd = fd_material(&sc, |m, d| m.restitution += d, 1e-6);
eprintln!(
"settled dJ/de: adjoint {:.3e} FD {:.3e}",
g.d_restitution, fd
);
assert!(
g.d_restitution.abs() < 1e-9,
"a contact below the restitution ramp must report ~0, got {}",
g.d_restitution
);
}