use glam::DVec3;
pub trait HolonomicConstraint {
fn evaluate(&self, pos: DVec3) -> f64;
fn jacobian(&self, pos: DVec3) -> DVec3;
fn hessian_contribution(&self, pos: DVec3, vel: DVec3) -> f64;
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PendulumConstraint {
pub attachment_point: DVec3,
pub length: f64,
}
impl PendulumConstraint {
pub fn new(attachment_point: DVec3, length: f64) -> Self {
assert!(
length > 0.0,
"pendulum length must be positive, got {length}"
);
Self {
attachment_point,
length,
}
}
}
impl HolonomicConstraint for PendulumConstraint {
fn evaluate(&self, pos: DVec3) -> f64 {
let r = pos - self.attachment_point;
r.length_squared() - self.length * self.length
}
fn jacobian(&self, pos: DVec3) -> DVec3 {
2.0 * (pos - self.attachment_point)
}
fn hessian_contribution(&self, _pos: DVec3, vel: DVec3) -> f64 {
2.0 * vel.length_squared()
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BaumgarteSolver {
pub alpha: f64,
pub beta: f64,
}
impl BaumgarteSolver {
pub fn critically_damped(omega: f64) -> Self {
assert!(
omega.is_finite() && omega >= 0.0,
"Baumgarte omega must be finite and non-negative, got {omega}"
);
Self {
alpha: omega,
beta: omega,
}
}
}
impl Default for BaumgarteSolver {
fn default() -> Self {
Self::critically_damped(5.0)
}
}
const JACOBIAN_NORM_SQ_EPSILON: f64 = 1.0e-12;
pub fn apply_constraint<C: HolonomicConstraint + ?Sized>(
pos: DVec3,
vel: DVec3,
free_accel: DVec3,
constraint: &C,
solver: &BaumgarteSolver,
) -> DVec3 {
let g = constraint.evaluate(pos);
let j = constraint.jacobian(pos);
let h = constraint.hessian_contribution(pos, vel);
let jj = j.length_squared();
if !jj.is_finite() || jj <= JACOBIAN_NORM_SQ_EPSILON {
return free_accel;
}
let g_dot = j.dot(vel);
let j_a = j.dot(free_accel);
let mu = -(j_a + h + 2.0 * solver.alpha * g_dot + solver.beta * solver.beta * g) / jj;
if !mu.is_finite() {
return free_accel;
}
free_accel + mu * j
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pendulum_constraint_evaluates_to_zero_at_correct_distance() {
let c = PendulumConstraint::new(DVec3::ZERO, 2.0);
assert!(c.evaluate(DVec3::new(2.0, 0.0, 0.0)).abs() < 1e-15);
assert!(c.evaluate(DVec3::new(0.0, 2.0, 0.0)).abs() < 1e-15);
assert!(c.evaluate(DVec3::new(0.0, 0.0, -2.0)).abs() < 1e-15);
let off_axis = DVec3::new(1.0, 1.0, (2.0_f64).sqrt());
assert!(c.evaluate(off_axis).abs() < 1e-14);
}
#[test]
fn pendulum_constraint_nonzero_off_manifold() {
let c = PendulumConstraint::new(DVec3::ZERO, 1.0);
assert!(c.evaluate(DVec3::new(0.5, 0.0, 0.0)) < 0.0);
assert!(c.evaluate(DVec3::new(2.0, 0.0, 0.0)) > 0.0);
}
#[test]
fn pendulum_jacobian_points_radially_outward() {
let attachment = DVec3::new(1.0, 2.0, 3.0);
let c = PendulumConstraint::new(attachment, 5.0);
let pos = DVec3::new(4.0, 2.0, 3.0); let j = c.jacobian(pos);
assert!((j - DVec3::new(6.0, 0.0, 0.0)).length() < 1e-14);
}
#[test]
fn pendulum_hessian_contribution_matches_2_vsq() {
let c = PendulumConstraint::new(DVec3::ZERO, 1.0);
let vel = DVec3::new(3.0, 4.0, 0.0);
assert!((c.hessian_contribution(DVec3::X, vel) - 50.0).abs() < 1e-14);
}
#[test]
#[should_panic(expected = "length must be positive")]
fn pendulum_constraint_rejects_nonpositive_length() {
PendulumConstraint::new(DVec3::ZERO, 0.0);
}
#[test]
fn zero_free_accel_with_zero_velocity_returns_zero() {
let c = PendulumConstraint::new(DVec3::ZERO, 1.0);
let solver = BaumgarteSolver {
alpha: 0.0,
beta: 0.0,
};
let pos = DVec3::X;
let vel = DVec3::ZERO;
let free_accel = DVec3::ZERO;
let a = apply_constraint(pos, vel, free_accel, &c, &solver);
assert!(a.length() < 1e-14, "expected zero accel, got {a:?}");
}
#[test]
fn radial_free_accel_gets_projected_out() {
let c = PendulumConstraint::new(DVec3::ZERO, 1.0);
let solver = BaumgarteSolver {
alpha: 0.0,
beta: 0.0,
};
let pos = DVec3::X;
let vel = DVec3::ZERO;
let free_accel = DVec3::new(7.0, 0.0, 0.0); let a = apply_constraint(pos, vel, free_accel, &c, &solver);
assert!(a.length() < 1e-13, "expected zero, got {a:?}");
}
#[test]
fn tangential_free_accel_preserved_with_centripetal_added() {
let c = PendulumConstraint::new(DVec3::ZERO, 1.0);
let solver = BaumgarteSolver {
alpha: 0.0,
beta: 0.0,
};
let pos = DVec3::X;
let vel = DVec3::Y;
let free_accel = DVec3::new(0.0, -1.0, 0.0); let a = apply_constraint(pos, vel, free_accel, &c, &solver);
let expected = DVec3::new(-1.0, -1.0, 0.0);
assert!(
(a - expected).length() < 1e-13,
"expected {expected:?}, got {a:?}"
);
}
#[test]
fn non_finite_constraint_outputs_fall_back_to_free_accel() {
struct NaNConstraint;
impl HolonomicConstraint for NaNConstraint {
fn evaluate(&self, _pos: DVec3) -> f64 {
f64::NAN
}
fn jacobian(&self, pos: DVec3) -> DVec3 {
2.0 * pos
}
fn hessian_contribution(&self, _pos: DVec3, vel: DVec3) -> f64 {
2.0 * vel.length_squared()
}
}
let s = BaumgarteSolver::default();
let free = DVec3::new(1.0, 2.0, 3.0);
let a = apply_constraint(DVec3::X, DVec3::ZERO, free, &NaNConstraint, &s);
assert_eq!(a, free);
assert!(a.is_finite());
}
#[test]
fn non_finite_solver_params_fall_back_to_free_accel() {
let c = PendulumConstraint::new(DVec3::ZERO, 1.0);
let bad = BaumgarteSolver {
alpha: f64::NAN,
beta: 1.0,
};
let free = DVec3::new(0.0, -9.81, 0.0);
let a = apply_constraint(DVec3::X, DVec3::Y, free, &c, &bad);
assert_eq!(a, free);
}
#[test]
fn accepts_trait_object_constraint() {
let c = PendulumConstraint::new(DVec3::ZERO, 1.0);
let dyn_c: &dyn HolonomicConstraint = &c;
let solver = BaumgarteSolver {
alpha: 0.0,
beta: 0.0,
};
let a = apply_constraint(
DVec3::X,
DVec3::ZERO,
DVec3::new(7.0, 0.0, 0.0),
dyn_c,
&solver,
);
assert!(a.length() < 1e-13);
}
#[test]
fn zero_jacobian_returns_free_accel() {
struct Degenerate;
impl HolonomicConstraint for Degenerate {
fn evaluate(&self, _pos: DVec3) -> f64 {
0.0
}
fn jacobian(&self, _pos: DVec3) -> DVec3 {
DVec3::ZERO
}
fn hessian_contribution(&self, _pos: DVec3, _vel: DVec3) -> f64 {
0.0
}
}
let d = Degenerate;
let s = BaumgarteSolver::default();
let free = DVec3::new(1.0, 2.0, 3.0);
let a = apply_constraint(DVec3::ZERO, DVec3::ZERO, free, &d, &s);
assert_eq!(a, free);
}
fn step_constrained(
pos: DVec3,
vel: DVec3,
free_accel_fn: impl Fn(DVec3) -> DVec3,
constraint: &impl HolonomicConstraint,
solver: &BaumgarteSolver,
dt: f64,
) -> (DVec3, DVec3) {
let free_accel = free_accel_fn(pos);
let a = apply_constraint(pos, vel, free_accel, constraint, solver);
let new_vel = vel + a * dt;
let new_pos = pos + new_vel * dt;
(new_pos, new_vel)
}
#[test]
fn pendulum_constraint_preserves_length_over_integration() {
let length = 1.0_f64;
let g = 9.81_f64;
let c = PendulumConstraint::new(DVec3::ZERO, length);
let omega_b = (g / length).sqrt();
let solver = BaumgarteSolver::critically_damped(10.0 * omega_b);
let theta0 = 30.0_f64.to_radians();
let mut pos = DVec3::new(length * theta0.cos(), length * theta0.sin(), 0.0);
let mut vel = DVec3::ZERO;
let period = 2.0 * std::f64::consts::PI * (length / g).sqrt();
let total_time = 3.0 * period;
let dt = 5e-4;
let n_steps = (total_time / dt).round() as usize;
let mut max_length_err: f64 = 0.0;
for _ in 0..n_steps {
let (p, v) = step_constrained(
pos,
vel,
|_p| DVec3::new(g, 0.0, 0.0), &c,
&solver,
dt,
);
pos = p;
vel = v;
let len_err = (pos.length() - length).abs();
if len_err > max_length_err {
max_length_err = len_err;
}
}
assert!(
max_length_err < 5e-4,
"max length error over 3 periods: {max_length_err} m"
);
}
#[test]
fn pendulum_small_angle_period_matches_analytical() {
let length = 1.0_f64;
let g = 9.81_f64;
let c = PendulumConstraint::new(DVec3::ZERO, length);
let omega_n = (g / length).sqrt();
let solver = BaumgarteSolver::critically_damped(omega_n);
let theta0 = 1.0_f64.to_radians();
let mut pos = DVec3::new(length * theta0.cos(), length * theta0.sin(), 0.0);
let mut vel = DVec3::ZERO;
let dt = 5e-4;
let total_time = 3.0 * (2.0 * std::f64::consts::PI / omega_n);
let n_steps = (total_time / dt).round() as usize;
let mut last_y = pos.y;
let mut crossing_times: Vec<f64> = Vec::new();
for i in 1..n_steps {
let (p, v) = step_constrained(pos, vel, |_p| DVec3::new(g, 0.0, 0.0), &c, &solver, dt);
pos = p;
vel = v;
if last_y > 0.0 && pos.y <= 0.0 {
let t1 = (i as f64) * dt;
let t0 = t1 - dt;
let t_cross = t0 + dt * last_y / (last_y - pos.y);
crossing_times.push(t_cross);
}
last_y = pos.y;
}
assert!(
crossing_times.len() >= 3,
"only got {} zero crossings",
crossing_times.len()
);
let analytical_period = 2.0 * std::f64::consts::PI * (length / g).sqrt();
let measured_period = (crossing_times.last().unwrap() - crossing_times.first().unwrap())
/ (crossing_times.len() as f64 - 1.0);
let rel_err = (measured_period - analytical_period).abs() / analytical_period;
assert!(
rel_err < 0.01,
"measured period {measured_period} vs analytical {analytical_period} (rel err {rel_err})"
);
}
#[test]
fn constraint_drift_bounded_with_baumgarte() {
let length = 1.0_f64;
let g = 9.81_f64;
let c = PendulumConstraint::new(DVec3::ZERO, length);
let theta0 = 30.0_f64.to_radians();
let initial_pos = DVec3::new(length * theta0.cos(), length * theta0.sin(), 0.0);
let dt = 1e-3_f64;
let total_time = 10.0_f64; let n_steps = (total_time / dt).round() as usize;
let run = |solver: BaumgarteSolver| -> f64 {
let mut pos = initial_pos;
let mut vel = DVec3::ZERO;
let mut max_err: f64 = 0.0;
for _ in 0..n_steps {
let (p, v) =
step_constrained(pos, vel, |_p| DVec3::new(g, 0.0, 0.0), &c, &solver, dt);
pos = p;
vel = v;
let err = (pos.length() - length).abs();
if err > max_err {
max_err = err;
}
}
max_err
};
let no_stab = run(BaumgarteSolver {
alpha: 0.0,
beta: 0.0,
});
let with_stab = run(BaumgarteSolver::critically_damped(20.0));
assert!(
with_stab < 0.1 * no_stab,
"Baumgarte stabilization didn't help: no_stab={no_stab}, with_stab={with_stab}"
);
assert!(with_stab < 1e-3, "Baumgarte drift too large: {with_stab}");
}
}