#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Spring {
pub stiffness: f32,
pub damping: f32,
}
impl Default for Spring {
fn default() -> Self {
Spring {
stiffness: 170.0,
damping: 22.0,
}
}
}
impl Spring {
pub fn new(stiffness: f32, damping: f32) -> Self {
Spring {
stiffness: stiffness.max(1.0),
damping: damping.max(0.0),
}
}
pub fn wobbly() -> Self {
Spring {
stiffness: 180.0,
damping: 12.0,
}
}
pub fn stiff() -> Self {
Spring {
stiffness: 210.0,
damping: 2.0 * 210.0_f32.sqrt(),
}
}
pub fn position(self, seconds: f32) -> f32 {
if seconds <= 0.0 {
return 0.0;
}
let w0 = self.stiffness.sqrt();
let zeta = self.damping / (2.0 * w0);
if zeta < 1.0 {
let wd = w0 * (1.0 - zeta * zeta).sqrt();
let decay = (-zeta * w0 * seconds).exp();
1.0 - decay * ((wd * seconds).cos() + (zeta * w0 / wd) * (wd * seconds).sin())
} else {
let decay = (-w0 * seconds).exp();
1.0 - decay * (1.0 + w0 * seconds)
}
}
pub fn settle_seconds(self) -> f32 {
let w0 = self.stiffness.sqrt();
let zeta = (self.damping / (2.0 * w0)).min(1.0);
let constants = 4.6 + 2.0 * zeta;
(constants / (zeta * w0).max(0.5)).min(10.0)
}
pub fn easing(self) -> impl Fn(f32) -> f32 + 'static {
let total = self.settle_seconds();
move |t: f32| {
if t >= 1.0 {
1.0
} else {
self.position(t * total)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn starts_at_zero_ends_at_one() {
for spring in [Spring::default(), Spring::wobbly(), Spring::stiff()] {
let ease = spring.easing();
assert_eq!(ease(0.0), 0.0);
assert_eq!(ease(1.0), 1.0);
assert!((ease(0.999) - 1.0).abs() < 0.02);
}
}
#[test]
fn underdamped_overshoots() {
let ease = Spring::wobbly().easing();
let max = (0..=1000)
.map(|i| ease(i as f32 / 1000.0))
.fold(f32::MIN, f32::max);
assert!(max > 1.01, "wobbly spring should ring past 1, got {max}");
}
#[test]
fn critically_damped_never_crosses_one() {
let ease = Spring::stiff().easing();
for i in 0..=1000 {
assert!(ease(i as f32 / 1000.0) <= 1.0 + 1e-4);
}
}
#[test]
fn position_is_monotone_toward_target_early_on() {
let spring = Spring::default();
let quarter = spring.position(spring.settle_seconds() * 0.25);
let half = spring.position(spring.settle_seconds() * 0.5);
assert!(quarter > 0.1);
assert!(half >= quarter * 0.9);
}
#[test]
fn settle_time_is_sane() {
for spring in [Spring::default(), Spring::wobbly(), Spring::stiff()] {
let s = spring.settle_seconds();
assert!(s > 0.05 && s < 10.0, "settle {s}");
}
}
}