use crate::config::SpringConfig;
use animato_core::Update;
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Integrator {
SemiImplicitEuler,
RungeKutta4,
}
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Spring {
pub config: SpringConfig,
position: f32,
velocity: f32,
target: f32,
integrator: Integrator,
}
impl Spring {
pub fn new(config: SpringConfig) -> Self {
Self {
config,
position: 0.0,
velocity: 0.0,
target: 0.0,
integrator: Integrator::SemiImplicitEuler,
}
}
pub fn set_target(&mut self, target: f32) {
self.target = target;
}
pub fn position(&self) -> f32 {
self.position
}
pub fn velocity(&self) -> f32 {
self.velocity
}
pub fn is_settled(&self) -> bool {
let eps = self.config.epsilon;
(self.position - self.target).abs() < eps && self.velocity.abs() < eps
}
pub fn snap_to(&mut self, pos: f32) {
self.position = pos;
self.velocity = 0.0;
}
pub fn use_rk4(mut self, yes: bool) -> Self {
self.integrator = if yes {
Integrator::RungeKutta4
} else {
Integrator::SemiImplicitEuler
};
self
}
#[inline]
fn acceleration(&self, position: f32, velocity: f32) -> f32 {
let displacement = position - self.target;
let spring_force = -self.config.stiffness * displacement;
let damping_force = -self.config.damping * velocity;
(spring_force + damping_force) / self.config.mass
}
fn step_euler(&mut self, dt: f32) {
let acc = self.acceleration(self.position, self.velocity);
self.velocity += acc * dt;
self.position += self.velocity * dt;
}
fn step_rk4(&mut self, dt: f32) {
let p0 = self.position;
let v0 = self.velocity;
let k1v = self.acceleration(p0, v0);
let k1p = v0;
let k2v = self.acceleration(p0 + k1p * dt / 2.0, v0 + k1v * dt / 2.0);
let k2p = v0 + k1v * dt / 2.0;
let k3v = self.acceleration(p0 + k2p * dt / 2.0, v0 + k2v * dt / 2.0);
let k3p = v0 + k2v * dt / 2.0;
let k4v = self.acceleration(p0 + k3p * dt, v0 + k3v * dt);
let k4p = v0 + k3v * dt;
self.position += (dt / 6.0) * (k1p + 2.0 * k2p + 2.0 * k3p + k4p);
self.velocity += (dt / 6.0) * (k1v + 2.0 * k2v + 2.0 * k3v + k4v);
}
}
impl Update for Spring {
fn update(&mut self, dt: f32) -> bool {
let dt = dt.max(0.0);
if dt == 0.0 || self.is_settled() {
return !self.is_settled();
}
if self.config.stiffness <= 0.0 {
self.position = self.target;
self.velocity = 0.0;
return false;
}
match self.integrator {
Integrator::SemiImplicitEuler => self.step_euler(dt),
Integrator::RungeKutta4 => self.step_rk4(dt),
}
!self.is_settled()
}
}
#[cfg(test)]
mod tests {
use super::*;
const DT: f32 = 1.0 / 60.0;
const MAX_STEPS: usize = 10_000;
fn run_to_settle(spring: &mut Spring) -> usize {
let mut steps = 0;
while !spring.is_settled() {
spring.update(DT);
steps += 1;
assert!(
steps < MAX_STEPS,
"Spring did not settle within {} steps",
MAX_STEPS
);
}
steps
}
#[test]
fn gentle_settles_to_target() {
let mut s = Spring::new(SpringConfig::gentle());
s.set_target(100.0);
run_to_settle(&mut s);
assert!((s.position() - 100.0).abs() < 0.01);
}
#[test]
fn wobbly_settles_to_target() {
let mut s = Spring::new(SpringConfig::wobbly());
s.set_target(50.0);
run_to_settle(&mut s);
assert!((s.position() - 50.0).abs() < 0.01);
}
#[test]
fn stiff_settles_to_target() {
let mut s = Spring::new(SpringConfig::stiff());
s.set_target(-30.0);
run_to_settle(&mut s);
assert!((s.position() - (-30.0)).abs() < 0.01);
}
#[test]
fn slow_settles_to_target() {
let mut s = Spring::new(SpringConfig::slow());
s.set_target(1.0);
run_to_settle(&mut s);
assert!((s.position() - 1.0).abs() < 0.01);
}
#[test]
fn snappy_settles_to_target() {
let mut s = Spring::new(SpringConfig::snappy());
s.set_target(200.0);
run_to_settle(&mut s);
assert!((s.position() - 200.0).abs() < 0.01);
}
#[test]
fn snappy_settles_faster_than_slow() {
let mut fast = Spring::new(SpringConfig::snappy());
fast.set_target(100.0);
let fast_steps = run_to_settle(&mut fast);
let mut slow = Spring::new(SpringConfig::slow());
slow.set_target(100.0);
let slow_steps = run_to_settle(&mut slow);
assert!(
fast_steps < slow_steps,
"snappy={} slow={}",
fast_steps,
slow_steps
);
}
#[test]
fn zero_damping_oscillates() {
let cfg = SpringConfig {
stiffness: 100.0,
damping: 0.0,
mass: 1.0,
epsilon: 0.001,
};
let mut s = Spring::new(cfg);
s.set_target(1.0);
for _ in 0..10_000 {
s.update(DT);
}
assert!(!s.is_settled());
}
#[test]
fn snap_to_teleports() {
let mut s = Spring::new(SpringConfig::default());
s.set_target(100.0);
s.snap_to(100.0);
assert_eq!(s.position(), 100.0);
assert_eq!(s.velocity(), 0.0);
assert!(s.is_settled());
}
#[test]
fn rk4_also_settles() {
let mut s = Spring::new(SpringConfig::wobbly()).use_rk4(true);
s.set_target(100.0);
run_to_settle(&mut s);
assert!((s.position() - 100.0).abs() < 0.01);
}
#[test]
fn zero_stiffness_snaps_immediately() {
let cfg = SpringConfig {
stiffness: 0.0,
..SpringConfig::default()
};
let mut s = Spring::new(cfg);
s.set_target(42.0);
s.update(DT);
assert_eq!(s.position(), 42.0);
assert!(s.is_settled());
}
#[test]
fn negative_dt_is_noop() {
let mut s = Spring::new(SpringConfig::default());
s.set_target(100.0);
let pos_before = s.position();
s.update(-1.0);
assert_eq!(s.position(), pos_before);
}
}