#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Easing {
Linear,
EaseIn,
EaseOut,
EaseInOut,
CubicBezier {
x1: f32,
y1: f32,
x2: f32,
y2: f32,
},
}
impl Easing {
pub fn eval(&self, t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
match *self {
Easing::Linear => t,
Easing::EaseIn => cubic_bezier_eval(0.42, 0.0, 1.0, 1.0, t),
Easing::EaseOut => cubic_bezier_eval(0.0, 0.0, 0.58, 1.0, t),
Easing::EaseInOut => cubic_bezier_eval(0.42, 0.0, 0.58, 1.0, t),
Easing::CubicBezier { x1, y1, x2, y2 } => cubic_bezier_eval(x1, y1, x2, y2, t),
}
}
}
#[inline]
fn bezier_axis(c1: f32, c2: f32, u: f32) -> f32 {
let one_minus = 1.0 - u;
3.0 * one_minus * one_minus * u * c1 + 3.0 * one_minus * u * u * c2 + u * u * u
}
#[inline]
fn bezier_axis_deriv(c1: f32, c2: f32, u: f32) -> f32 {
let one_minus = 1.0 - u;
3.0 * one_minus * one_minus * c1 + 6.0 * one_minus * u * (c2 - c1) + 3.0 * u * u * (1.0 - c2)
}
fn cubic_bezier_eval(x1: f32, y1: f32, x2: f32, y2: f32, x: f32) -> f32 {
if x <= 0.0 {
return 0.0;
}
if x >= 1.0 {
return 1.0;
}
let u = solve_bezier_u_for_x(x1, x2, x);
bezier_axis(y1, y2, u)
}
fn solve_bezier_u_for_x(x1: f32, x2: f32, x: f32) -> f32 {
const NEWTON_ITERS: usize = 8;
const EPS: f32 = 1e-6;
let mut u = x;
for _ in 0..NEWTON_ITERS {
let fx = bezier_axis(x1, x2, u) - x;
if fx.abs() < EPS {
return u.clamp(0.0, 1.0);
}
let d = bezier_axis_deriv(x1, x2, u);
if d.abs() < 1e-6 {
break;
}
u -= fx / d;
u = u.clamp(0.0, 1.0);
}
let mut lo = 0.0_f32;
let mut hi = 1.0_f32;
let mut mid = u.clamp(lo, hi);
for _ in 0..32 {
mid = 0.5 * (lo + hi);
let fx = bezier_axis(x1, x2, mid);
if (fx - x).abs() < EPS {
return mid;
}
if fx < x {
lo = mid;
} else {
hi = mid;
}
}
mid
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Spring {
pub mass: f32,
pub stiffness: f32,
pub damping: f32,
}
impl Default for Spring {
fn default() -> Self {
Self {
mass: 1.0,
stiffness: 170.0,
damping: 26.0,
}
}
}
impl Spring {
pub fn new(mass: f32, stiffness: f32, damping: f32) -> Self {
Self {
mass,
stiffness,
damping,
}
}
pub fn from_frequency(omega0: f32, zeta: f32) -> Self {
let mass = 1.0;
let stiffness = omega0 * omega0 * mass;
let damping = 2.0 * zeta * omega0 * mass;
Self {
mass,
stiffness,
damping,
}
}
pub fn natural_frequency(&self) -> f32 {
(self.stiffness / self.mass.max(f32::EPSILON)).sqrt()
}
pub fn damping_ratio(&self) -> f32 {
let denom = 2.0 * (self.stiffness * self.mass).max(f32::EPSILON).sqrt();
self.damping / denom
}
pub fn position(&self, from: f32, to: f32, v0: f32, t: f32) -> f32 {
if t <= 0.0 {
return from;
}
let x0 = from - to; let omega0 = self.natural_frequency();
if omega0 <= f32::EPSILON {
return to + x0; }
let zeta = self.damping_ratio();
let offset = if (zeta - 1.0).abs() < 1e-4 {
let c2 = v0 + omega0 * x0;
(x0 + c2 * t) * (-omega0 * t).exp()
} else if zeta < 1.0 {
let omega_d = omega0 * (1.0 - zeta * zeta).sqrt();
let decay = (-zeta * omega0 * t).exp();
let a = x0;
let b = (v0 + zeta * omega0 * x0) / omega_d;
decay * (a * (omega_d * t).cos() + b * (omega_d * t).sin())
} else {
let disc = (zeta * zeta - 1.0).sqrt();
let r1 = -omega0 * (zeta - disc);
let r2 = -omega0 * (zeta + disc);
let c1 = (v0 - r2 * x0) / (r1 - r2);
let c2 = x0 - c1;
c1 * (r1 * t).exp() + c2 * (r2 * t).exp()
};
to + offset
}
pub fn is_settled(&self, from: f32, to: f32, v0: f32, t: f32, tolerance: f32) -> bool {
(self.position(from, to, v0, t) - to).abs() <= tolerance
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Transition {
pub duration: f32,
pub delay: f32,
pub easing: Easing,
}
impl Transition {
pub fn new(duration: f32, easing: Easing) -> Self {
Self {
duration,
delay: 0.0,
easing,
}
}
pub fn with_delay(mut self, delay: f32) -> Self {
self.delay = delay;
self
}
pub fn progress(&self, elapsed: f32) -> f32 {
let active = elapsed - self.delay;
if active <= 0.0 {
return 0.0;
}
if self.duration <= 0.0 || active >= self.duration {
return 1.0;
}
self.easing.eval(active / self.duration)
}
pub fn sample(&self, start: f32, end: f32, elapsed: f32) -> f32 {
let p = self.progress(elapsed);
start + (end - start) * p
}
pub fn is_finished(&self, elapsed: f32) -> bool {
elapsed >= self.delay + self.duration
}
}
#[derive(Clone, Copy, Debug)]
struct ActiveTransition {
key: u64,
start: f32,
end: f32,
transition: Transition,
elapsed: f32,
}
#[derive(Debug, Default)]
pub struct Animator {
active: Vec<ActiveTransition>,
}
impl Animator {
pub fn new() -> Self {
Self::default()
}
pub fn active_count(&self) -> usize {
self.active.len()
}
pub fn is_animating(&self) -> bool {
!self.active.is_empty()
}
pub fn start(&mut self, key: u64, start: f32, end: f32, transition: Transition) {
let entry = ActiveTransition {
key,
start,
end,
transition,
elapsed: 0.0,
};
if let Some(slot) = self.active.iter_mut().find(|a| a.key == key) {
*slot = entry;
} else {
self.active.push(entry);
}
}
pub fn value(&self, key: u64) -> Option<f32> {
self.active
.iter()
.find(|a| a.key == key)
.map(|a| a.transition.sample(a.start, a.end, a.elapsed))
}
pub fn advance(&mut self, dt: f32) -> usize {
for a in &mut self.active {
a.elapsed += dt;
}
self.active.retain(|a| !a.transition.is_finished(a.elapsed));
self.active.len()
}
pub fn cancel(&mut self, key: u64) -> bool {
let before = self.active.len();
self.active.retain(|a| a.key != key);
self.active.len() != before
}
}
#[cfg(test)]
mod tests {
use super::*;
fn close(a: f32, b: f32, eps: f32) -> bool {
(a - b).abs() <= eps
}
#[test]
fn linear_easing_endpoints_and_midpoint() {
assert_eq!(Easing::Linear.eval(0.0), 0.0);
assert_eq!(Easing::Linear.eval(1.0), 1.0);
assert!(close(Easing::Linear.eval(0.5), 0.5, 1e-6));
}
#[test]
fn ease_in_out_is_symmetric_about_half() {
let e = Easing::EaseInOut;
assert!(close(e.eval(0.0), 0.0, 1e-6));
assert!(close(e.eval(1.0), 1.0, 1e-6));
assert!(close(e.eval(0.5), 0.5, 1e-3), "got {}", e.eval(0.5));
for t in [0.1f32, 0.25, 0.4] {
assert!(close(e.eval(t) + e.eval(1.0 - t), 1.0, 2e-3), "t={t}");
}
}
#[test]
fn ease_in_starts_slow() {
let e = Easing::EaseIn;
assert!(
e.eval(0.25) < 0.25,
"ease-in should be below the diagonal early"
);
assert!(close(e.eval(1.0), 1.0, 1e-6));
}
#[test]
fn cubic_bezier_recovers_linear() {
let lin = Easing::CubicBezier {
x1: 0.25,
y1: 0.25,
x2: 0.75,
y2: 0.75,
};
for t in [0.0f32, 0.2, 0.5, 0.8, 1.0] {
assert!(close(lin.eval(t), t, 2e-3), "t={t} got {}", lin.eval(t));
}
}
#[test]
fn cubic_bezier_degenerate_does_not_nan() {
let e = Easing::CubicBezier {
x1: 0.0,
y1: 1.0,
x2: 1.0,
y2: 0.0,
};
for i in 0..=10 {
let t = i as f32 / 10.0;
let v = e.eval(t);
assert!(v.is_finite(), "value at t={t} must be finite, got {v}");
assert!((0.0..=1.0).contains(&v) || close(v, 0.0, 1e-3) || close(v, 1.0, 1e-3));
}
}
#[test]
fn spring_critically_damped_converges_without_overshoot() {
let s = Spring::from_frequency(20.0, 1.0); assert!(close(s.damping_ratio(), 1.0, 1e-3));
let mut prev = s.position(0.0, 1.0, 0.0, 0.0);
assert!(close(prev, 0.0, 1e-4));
for i in 1..=60 {
let t = i as f32 / 60.0;
let p = s.position(0.0, 1.0, 0.0, t);
assert!(p <= 1.0 + 1e-3, "overshoot at t={t}: {p}");
assert!(p >= prev - 1e-4, "should be monotone increasing at t={t}");
prev = p;
}
assert!(s.is_settled(0.0, 1.0, 0.0, 1.5, 1e-2));
}
#[test]
fn spring_underdamped_overshoots_then_settles() {
let s = Spring::from_frequency(30.0, 0.3); assert!(s.damping_ratio() < 1.0);
let mut max = f32::MIN;
for i in 0..=200 {
let t = i as f32 / 100.0;
max = max.max(s.position(0.0, 1.0, 0.0, t));
}
assert!(
max > 1.0,
"underdamped spring should overshoot the target, max={max}"
);
assert!(s.is_settled(0.0, 1.0, 0.0, 5.0, 2e-2));
}
#[test]
fn spring_overdamped_no_overshoot() {
let s = Spring::from_frequency(10.0, 2.0); assert!(s.damping_ratio() > 1.0);
for i in 0..=100 {
let t = i as f32 / 50.0;
let p = s.position(0.0, 1.0, 0.0, t);
assert!(
p <= 1.0 + 1e-3,
"overdamped must not overshoot, t={t} p={p}"
);
}
}
#[test]
fn transition_progress_respects_delay_and_duration() {
let tr = Transition::new(2.0, Easing::Linear).with_delay(1.0);
assert_eq!(tr.progress(0.5), 0.0); assert!(close(tr.progress(2.0), 0.5, 1e-6)); assert_eq!(tr.progress(3.0), 1.0); assert!(tr.is_finished(3.0));
assert!(!tr.is_finished(2.5));
assert!(close(tr.sample(10.0, 20.0, 2.0), 15.0, 1e-4));
}
#[test]
fn animator_tracks_and_drops_finished() {
let mut anim = Animator::new();
anim.start(1, 0.0, 100.0, Transition::new(1.0, Easing::Linear));
assert!(anim.is_animating());
assert!(close(anim.value(1).expect("active"), 0.0, 1e-4));
anim.advance(0.5);
assert!(close(anim.value(1).expect("active"), 50.0, 1e-3));
anim.start(1, 0.0, 100.0, Transition::new(1.0, Easing::Linear));
assert!(close(anim.value(1).expect("active"), 0.0, 1e-4));
anim.advance(1.5);
assert_eq!(anim.active_count(), 0);
assert!(anim.value(1).is_none());
}
#[test]
fn animator_cancel() {
let mut anim = Animator::new();
anim.start(7, 0.0, 1.0, Transition::new(1.0, Easing::Linear));
assert!(anim.cancel(7));
assert!(!anim.cancel(7));
}
}