use std::{ops::RangeInclusive, time::Duration};
use crate::{Hsla, Pixels, Rems, Rgba};
const CRITICAL_DAMPING_TOLERANCE: f32 = 1e-4;
const DEFAULT_SPRING_EPSILON: f32 = 0.001;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SpringConfig {
pub stiffness: f32,
pub damping: f32,
pub mass: f32,
}
impl SpringConfig {
pub const fn new(stiffness: f32, damping: f32, mass: f32) -> Self {
Self {
stiffness,
damping,
mass,
}
}
pub fn canonical(&self) -> (f32, f32) {
let natural_frequency = (self.stiffness / self.mass).sqrt();
let damping_ratio = self.damping / (2.0 * (self.stiffness * self.mass).sqrt());
(natural_frequency, damping_ratio)
}
pub fn step(&self, state: SpringState, target: f32, delta_time: f32) -> SpringState {
let propagator = self.propagator(delta_time);
let displacement = state.position - target;
SpringState {
position: target + propagator[0][0] * displacement + propagator[0][1] * state.velocity,
velocity: propagator[1][0] * displacement + propagator[1][1] * state.velocity,
}
}
pub fn step_ramp(
&self,
state: SpringState,
target: f32,
target_velocity: f32,
delta_time: f32,
) -> SpringState {
let (natural_frequency, damping_ratio) = self.canonical();
let steady_state_lag = -2.0 * damping_ratio * target_velocity / natural_frequency;
let displacement = state.position - target - steady_state_lag;
let velocity = state.velocity - target_velocity;
let propagator = self.propagator(delta_time);
let target = target + target_velocity * delta_time;
SpringState {
position: target
+ steady_state_lag
+ propagator[0][0] * displacement
+ propagator[0][1] * velocity,
velocity: target_velocity
+ propagator[1][0] * displacement
+ propagator[1][1] * velocity,
}
}
pub fn propagator(&self, delta_time: f32) -> [[f32; 2]; 2] {
let (natural_frequency, damping_ratio) = self.canonical();
if damping_ratio < 1.0 - CRITICAL_DAMPING_TOLERANCE {
let decay = damping_ratio * natural_frequency;
let damped_frequency = natural_frequency * (1.0 - damping_ratio * damping_ratio).sqrt();
let exponential = (-decay * delta_time).exp();
let (sine, cosine) = (damped_frequency * delta_time).sin_cos();
let sine_over_frequency = sine / damped_frequency;
[
[
exponential * (cosine + decay * sine_over_frequency),
exponential * sine_over_frequency,
],
[
-exponential * natural_frequency * natural_frequency * sine_over_frequency,
exponential * (cosine - decay * sine_over_frequency),
],
]
} else if damping_ratio > 1.0 + CRITICAL_DAMPING_TOLERANCE {
let root = (damping_ratio * damping_ratio - 1.0).sqrt();
let root_sum = damping_ratio + root;
let slow_root = -natural_frequency / root_sum;
let fast_root = -natural_frequency * root_sum;
let denominator = slow_root - fast_root;
let slow_exponential = (slow_root * delta_time).exp();
let fast_exponential = (fast_root * delta_time).exp();
[
[
(-fast_root * slow_exponential + slow_root * fast_exponential) / denominator,
(slow_exponential - fast_exponential) / denominator,
],
[
slow_root * fast_root * (fast_exponential - slow_exponential) / denominator,
(slow_root * slow_exponential - fast_root * fast_exponential) / denominator,
],
]
} else {
let exponential = (-natural_frequency * delta_time).exp();
[
[
exponential * (1.0 + natural_frequency * delta_time),
exponential * delta_time,
],
[
-exponential * natural_frequency * natural_frequency * delta_time,
exponential * (1.0 - natural_frequency * delta_time),
],
]
}
}
pub fn is_settled(&self, state: SpringState, target: f32, epsilon: f32) -> bool {
let (natural_frequency, _) = self.canonical();
epsilon.is_finite()
&& epsilon >= 0.0
&& (state.position - target).abs() <= epsilon
&& state.velocity.abs() <= epsilon * natural_frequency
}
pub fn settle_time(&self, state: SpringState, target: f32, epsilon: f32) -> Duration {
let displacement = state.position - target;
if displacement == 0.0 && state.velocity == 0.0 {
return Duration::ZERO;
}
let (natural_frequency, damping_ratio) = self.canonical();
if !natural_frequency.is_finite()
|| natural_frequency <= 0.0
|| !damping_ratio.is_finite()
|| damping_ratio <= 0.0
|| !epsilon.is_finite()
|| epsilon <= 0.0
{
return Duration::MAX;
}
let velocity_threshold = epsilon * natural_frequency;
if damping_ratio < 1.0 - CRITICAL_DAMPING_TOLERANCE {
let decay = damping_ratio * natural_frequency;
let damped_frequency = natural_frequency * (1.0 - damping_ratio * damping_ratio).sqrt();
let sine_coefficient = (state.velocity + decay * displacement) / damped_frequency;
let position_envelope = displacement.hypot(sine_coefficient);
let velocity_cosine = damped_frequency * sine_coefficient - decay * displacement;
let velocity_sine = -damped_frequency * displacement - decay * sine_coefficient;
let velocity_envelope = velocity_cosine.hypot(velocity_sine);
find_settle_time(
epsilon,
velocity_threshold,
0.0,
natural_frequency,
move |time| {
let exponential = (-decay * time).exp();
(
position_envelope * exponential,
velocity_envelope * exponential,
)
},
)
} else if damping_ratio > 1.0 + CRITICAL_DAMPING_TOLERANCE {
let root = (damping_ratio * damping_ratio - 1.0).sqrt();
let root_sum = damping_ratio + root;
let slow_root = -natural_frequency / root_sum;
let fast_root = -natural_frequency * root_sum;
let denominator = slow_root - fast_root;
let slow_coefficient = (state.velocity - fast_root * displacement) / denominator;
let fast_coefficient = (slow_root * displacement - state.velocity) / denominator;
find_settle_time(
epsilon,
velocity_threshold,
0.0,
natural_frequency,
move |time| {
let slow_term = slow_coefficient.abs() * (slow_root * time).exp();
let fast_term = fast_coefficient.abs() * (fast_root * time).exp();
(
slow_term + fast_term,
slow_root.abs() * slow_term + fast_root.abs() * fast_term,
)
},
)
} else {
let linear_coefficient = state.velocity + natural_frequency * displacement;
let position_constant = displacement.abs();
let position_linear = linear_coefficient.abs();
let velocity_constant = (linear_coefficient - natural_frequency * displacement).abs();
let velocity_linear = natural_frequency * linear_coefficient.abs();
let position_decay_start =
envelope_decay_start(position_constant, position_linear, natural_frequency);
let velocity_decay_start =
envelope_decay_start(velocity_constant, velocity_linear, natural_frequency);
find_settle_time(
epsilon,
velocity_threshold,
position_decay_start.max(velocity_decay_start),
natural_frequency,
move |time| {
let exponential = (-natural_frequency * time).exp();
(
(position_constant + position_linear * time) * exponential,
(velocity_constant + velocity_linear * time) * exponential,
)
},
)
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct SpringState {
pub position: f32,
pub velocity: f32,
}
pub trait SpringTarget: 'static {
type Output;
fn target(&self) -> f32;
fn resolve(&self, value: f32) -> Self::Output;
}
impl SpringTarget for f32 {
type Output = f32;
fn target(&self) -> f32 {
*self
}
fn resolve(&self, value: f32) -> Self::Output {
value
}
}
impl SpringTarget for Pixels {
type Output = Pixels;
fn target(&self) -> f32 {
self.as_f32()
}
fn resolve(&self, value: f32) -> Self::Output {
Pixels::from(value)
}
}
impl SpringTarget for Rems {
type Output = Rems;
fn target(&self) -> f32 {
self.0
}
fn resolve(&self, value: f32) -> Self::Output {
Rems(value)
}
}
impl SpringTarget for bool {
type Output = AnimationPhase;
fn target(&self) -> f32 {
if *self { 1.0 } else { 0.0 }
}
fn resolve(&self, value: f32) -> Self::Output {
AnimationPhase(value)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, PartialOrd)]
pub struct AnimationPhase(
pub f32,
);
impl AnimationPhase {
pub fn clamp(self, range: RangeInclusive<f32>) -> Self {
let (first, second) = range.into_inner();
Self(self.0.clamp(first.min(second), first.max(second)))
}
pub fn interpolate<T: Interpolate>(self, from: T, to: T) -> T {
T::interpolate(from, to, self.0)
}
pub fn interpolate_clamped<T: Interpolate>(self, from: T, to: T) -> T {
T::interpolate(from, to, self.0.clamp(0.0, 1.0))
}
pub fn interpolate_between<T: Interpolate>(
self,
range: RangeInclusive<f32>,
from: T,
to: T,
) -> T {
let (start, end) = range.into_inner();
let phase = if start == end {
if self.0 < start { 0.0 } else { 1.0 }
} else {
(self.0 - start) / (end - start)
};
T::interpolate(from, to, phase)
}
pub fn interpolate_between_clamped<T: Interpolate>(
self,
range: RangeInclusive<f32>,
from: T,
to: T,
) -> T {
let (start, end) = range.into_inner();
let phase = if start == end {
if self.0 < start { 0.0 } else { 1.0 }
} else {
((self.0 - start) / (end - start)).clamp(0.0, 1.0)
};
T::interpolate(from, to, phase)
}
}
impl From<f32> for AnimationPhase {
fn from(value: f32) -> Self {
Self(value)
}
}
impl From<bool> for AnimationPhase {
fn from(value: bool) -> Self {
Self(if value { 1.0 } else { 0.0 })
}
}
impl SpringTarget for AnimationPhase {
type Output = AnimationPhase;
fn target(&self) -> f32 {
self.0
}
fn resolve(&self, value: f32) -> Self::Output {
Self(value)
}
}
pub trait Interpolate: Sized {
fn interpolate(from: Self, to: Self, phase: f32) -> Self;
}
impl Interpolate for f32 {
fn interpolate(from: Self, to: Self, phase: f32) -> Self {
from + (to - from) * phase
}
}
impl Interpolate for Pixels {
fn interpolate(from: Self, to: Self, phase: f32) -> Self {
from + (to - from) * phase
}
}
impl Interpolate for Rems {
fn interpolate(from: Self, to: Self, phase: f32) -> Self {
from + (to - from) * phase
}
}
impl Interpolate for Rgba {
fn interpolate(from: Self, to: Self, phase: f32) -> Self {
Self {
r: f32::interpolate(from.r, to.r, phase),
g: f32::interpolate(from.g, to.g, phase),
b: f32::interpolate(from.b, to.b, phase),
a: f32::interpolate(from.a, to.a, phase),
}
}
}
impl Interpolate for Hsla {
fn interpolate(from: Self, to: Self, phase: f32) -> Self {
let hue_delta = (to.h - from.h + 0.5).rem_euclid(1.0) - 0.5;
Self {
h: (from.h + hue_delta * phase).rem_euclid(1.0),
s: f32::interpolate(from.s, to.s, phase),
l: f32::interpolate(from.l, to.l, phase),
a: f32::interpolate(from.a, to.a, phase),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SpringPlayback {
#[default]
Running,
Paused,
Stopped,
Completed,
Cancelled,
}
#[derive(Clone, Debug)]
pub struct SpringAnimation<T = ()> {
pub(crate) config: SpringConfig,
pub(crate) target: T,
pub(crate) epsilon: f32,
pub(crate) initial: Option<f32>,
pub(crate) playback: SpringPlayback,
}
impl SpringAnimation<()> {
pub fn new(config: SpringConfig) -> Self {
Self {
config,
target: (),
epsilon: DEFAULT_SPRING_EPSILON,
initial: None,
playback: SpringPlayback::Running,
}
}
pub fn to<T: SpringTarget>(self, target: T) -> SpringAnimation<T> {
let SpringAnimation {
config,
target: (),
epsilon,
initial,
playback,
} = self;
SpringAnimation {
config,
target,
epsilon,
initial,
playback,
}
}
}
impl<T> SpringAnimation<T> {
pub fn with_epsilon(mut self, epsilon: f32) -> Self {
self.epsilon = epsilon;
self
}
pub fn playback(mut self, playback: SpringPlayback) -> Self {
self.playback = playback;
self
}
}
impl<T: SpringTarget> SpringAnimation<T> {
pub fn from(mut self, initial: T) -> Self {
self.initial = Some(initial.target());
self
}
}
pub fn sampled_easing(config: SpringConfig, epsilon: f32) -> (Duration, impl Fn(f32) -> f32) {
let initial_state = SpringState {
position: 0.0,
velocity: 0.0,
};
let duration = config.settle_time(initial_state, 1.0, epsilon);
let duration_seconds = duration.as_secs_f32();
(duration, move |progress| {
if progress <= 0.0 {
0.0
} else if progress >= 1.0 {
1.0
} else {
config
.step(initial_state, 1.0, progress * duration_seconds)
.position
}
})
}
fn envelope_decay_start(constant: f32, linear: f32, decay: f32) -> f32 {
if linear == 0.0 {
0.0
} else {
(1.0 / decay - constant / linear).max(0.0)
}
}
fn find_settle_time(
position_threshold: f32,
velocity_threshold: f32,
decay_start: f32,
natural_frequency: f32,
envelope: impl Fn(f32) -> (f32, f32),
) -> Duration {
let is_below_threshold = |time| {
let (position, velocity) = envelope(time);
position <= position_threshold && velocity <= velocity_threshold
};
if is_below_threshold(decay_start) {
return duration_from_secs(decay_start);
}
let mut lower_bound = decay_start;
let mut upper_bound = decay_start.max(natural_frequency.recip());
while !is_below_threshold(upper_bound) {
lower_bound = upper_bound;
upper_bound *= 2.0;
if !upper_bound.is_finite() {
return Duration::MAX;
}
}
for _ in 0..32 {
let midpoint = (lower_bound + upper_bound) / 2.0;
if is_below_threshold(midpoint) {
upper_bound = midpoint;
} else {
lower_bound = midpoint;
}
}
duration_from_secs(upper_bound)
}
fn duration_from_secs(seconds: f32) -> Duration {
if !seconds.is_finite() || seconds >= Duration::MAX.as_secs_f32() {
Duration::MAX
} else if seconds <= 0.0 {
Duration::ZERO
} else {
Duration::from_secs_f32(seconds)
}
}
#[cfg(test)]
mod tests {
use super::*;
const EPSILON: f32 = 1e-4;
#[test]
fn spring_targets_resolve_typed_outputs() {
assert_eq!(12.0_f32.target(), 12.0);
assert_eq!(12.0_f32.resolve(14.0), 14.0);
assert_eq!(Pixels::from(12.0).target(), 12.0);
assert_eq!(Pixels::from(12.0).resolve(14.0), Pixels::from(14.0));
assert_eq!(false.target(), 0.0);
assert_eq!(true.target(), 1.0);
assert_eq!(true.resolve(1.25), AnimationPhase(1.25));
}
#[test]
fn animation_phases_interpolate_over_arbitrary_ranges() {
let phase = AnimationPhase(1.5);
assert_eq!(phase.interpolate_between(1.0..=2.0, 10.0, 20.0), 15.0);
assert_eq!(
phase.interpolate_between_clamped(2.0..=3.0, 10.0, 20.0),
10.0
);
assert_eq!(
AnimationPhase(3.5).interpolate_between(2.0..=3.0, 10.0, 20.0),
25.0
);
}
#[test]
fn hsla_interpolation_takes_the_shortest_hue_path() {
let from = Hsla {
h: 0.9,
s: 0.5,
l: 0.5,
a: 1.0,
};
let to = Hsla {
h: 0.1,
s: 1.0,
l: 0.75,
a: 0.5,
};
let result = AnimationPhase(0.5).interpolate(from, to);
assert!(result.h < EPSILON || (1.0 - result.h) < EPSILON);
assert!((result.s - 0.75).abs() < EPSILON);
assert!((result.l - 0.625).abs() < EPSILON);
assert!((result.a - 0.75).abs() < EPSILON);
}
#[test]
fn propagators_compose_and_have_expected_determinant() {
for damping_ratio in [0.4, 1.0, 1.5] {
let natural_frequency = 12.0;
let config = SpringConfig::new(
natural_frequency * natural_frequency,
2.0 * damping_ratio * natural_frequency,
1.0,
);
let first = config.propagator(0.013);
let second = config.propagator(0.021);
let combined = multiply(second, first);
let direct = config.propagator(0.034);
for row in 0..2 {
for column in 0..2 {
assert!(
(combined[row][column] - direct[row][column]).abs() < 2e-4,
"{damping_ratio}: {combined:?} != {direct:?}"
);
}
}
let determinant = direct[0][0] * direct[1][1] - direct[0][1] * direct[1][0];
let expected = (-2.0 * damping_ratio * natural_frequency * 0.034).exp();
assert!((determinant - expected).abs() < 2e-4);
}
}
#[test]
fn step_preserves_semigroup_for_every_damping_regime() {
let state = SpringState {
position: -3.0,
velocity: 5.0,
};
for damping in [4.0, 20.0, 40.0] {
let config = SpringConfig::new(100.0, damping, 1.0);
let stepped = config.step(config.step(state, 7.0, 0.013), 7.0, 0.021);
let direct = config.step(state, 7.0, 0.034);
assert!((stepped.position - direct.position).abs() < 2e-4);
assert!((stepped.velocity - direct.velocity).abs() < 2e-4);
}
}
#[test]
fn ramp_tracks_steady_state_lag() {
let natural_frequency = 10.0;
let damping_ratio = 0.8;
let target_velocity = 3.0;
let config = SpringConfig::new(
natural_frequency * natural_frequency,
2.0 * damping_ratio * natural_frequency,
1.0,
);
let lag = -2.0 * damping_ratio * target_velocity / natural_frequency;
let state = SpringState {
position: lag,
velocity: target_velocity,
};
let next = config.step_ramp(state, 0.0, target_velocity, 0.25);
assert!((next.position - (target_velocity * 0.25 + lag)).abs() < EPSILON);
assert!((next.velocity - target_velocity).abs() < EPSILON);
}
#[test]
fn settling_requires_low_velocity() {
let config = SpringConfig::new(100.0, 10.0, 1.0);
assert!(!config.is_settled(
SpringState {
position: 1.0,
velocity: 1.0,
},
1.0,
0.01,
));
assert!(config.is_settled(
SpringState {
position: 1.005,
velocity: 0.05,
},
1.0,
0.01,
));
}
#[test]
fn settle_time_is_conservative_for_every_damping_regime() {
let initial_state = SpringState {
position: -2.0,
velocity: 4.0,
};
for damping in [4.0, 20.0, 40.0] {
let config = SpringConfig::new(100.0, damping, 1.0);
let duration = config.settle_time(initial_state, 3.0, 0.001);
assert_ne!(duration, Duration::MAX);
for additional_time in [0.0, 0.1, 1.0] {
let state =
config.step(initial_state, 3.0, duration.as_secs_f32() + additional_time);
assert!(
config.is_settled(state, 3.0, 0.001),
"{damping}: {duration:?} produced {state:?}"
);
}
}
}
#[test]
fn settle_time_accounts_for_motion_outside_an_instantaneous_tolerance() {
let config = SpringConfig::new(100.0, 2.0, 1.0);
let state = SpringState {
position: 1.125,
velocity: 1.25,
};
assert!(config.is_settled(state, 1.0, 0.125));
let duration = config.settle_time(state, 1.0, 0.125);
assert!(duration > Duration::ZERO);
assert!(config.is_settled(config.step(state, 1.0, duration.as_secs_f32()), 1.0, 0.125,));
}
#[test]
fn undamped_spring_never_settles() {
let config = SpringConfig::new(100.0, 0.0, 1.0);
assert_eq!(
config.settle_time(
SpringState {
position: 0.0,
velocity: 0.0,
},
1.0,
0.001,
),
Duration::MAX
);
}
#[test]
fn sampled_easing_has_exact_endpoints_and_can_overshoot() {
let config = SpringConfig::new(100.0, 6.0, 1.0);
let (duration, easing) = sampled_easing(config, 0.001);
assert_ne!(duration, Duration::MAX);
assert_eq!(easing(0.0), 0.0);
assert_eq!(easing(1.0), 1.0);
assert!((1..100).any(|step| easing(step as f32 / 100.0) > 1.0));
}
fn multiply(left: [[f32; 2]; 2], right: [[f32; 2]; 2]) -> [[f32; 2]; 2] {
[
[
left[0][0] * right[0][0] + left[0][1] * right[1][0],
left[0][0] * right[0][1] + left[0][1] * right[1][1],
],
[
left[1][0] * right[0][0] + left[1][1] * right[1][0],
left[1][0] * right[0][1] + left[1][1] * right[1][1],
],
]
}
}