use super::Signal;
use crate::{Duration, Interpolate, TICKS_PER_SECOND, Time, ValidationError};
#[derive(Clone, Debug)]
pub struct Spring<Value> {
from: Value,
to: Value,
response: Response,
}
impl<Value> Spring<Value> {
pub fn new(from: Value, to: Value) -> Self {
Self::builder(from, to)
.build()
.unwrap_or_else(|error| unreachable!("default spring parameters are valid: {error}"))
}
pub fn builder(from: Value, to: Value) -> SpringBuilder<Value> {
SpringBuilder {
from,
to,
parameters: Parameters::default(),
}
}
pub fn settle_duration(&self) -> Duration {
self.response.duration
}
}
#[derive(Clone, Debug)]
pub struct SpringBuilder<Value> {
from: Value,
to: Value,
parameters: Parameters,
}
impl<Value> SpringBuilder<Value> {
pub fn mass(mut self, mass: f64) -> Self {
self.parameters.mass = mass;
self
}
pub fn stiffness(mut self, stiffness: f64) -> Self {
self.parameters.stiffness = stiffness;
self
}
pub fn damping(mut self, damping: f64) -> Self {
self.parameters.damping = damping;
self
}
pub fn initial_velocity(mut self, velocity: f64) -> Self {
self.parameters.velocity = velocity;
self
}
pub fn settling_threshold(mut self, threshold: f64) -> Self {
self.parameters.threshold = threshold;
self
}
pub fn build(self) -> Result<Spring<Value>, ValidationError> {
let response = Response::try_new(self.parameters)?;
Ok(Spring {
from: self.from,
to: self.to,
response,
})
}
}
impl Default for Spring<f64> {
fn default() -> Self {
Self::new(0.0, 1.0)
}
}
impl<Value: Interpolate + Send + Sync> Signal<Value> for Spring<Value> {
fn sample(&self, time: Time) -> Value {
if time <= Time::ZERO {
return self.from.clone();
}
if time >= self.response.duration.after_origin() {
return self.to.clone();
}
self.from
.interpolate(&self.to, self.response.progress(time.as_seconds()))
}
}
#[derive(Clone, Copy, Debug)]
struct Parameters {
mass: f64,
stiffness: f64,
damping: f64,
velocity: f64,
threshold: f64,
}
impl Default for Parameters {
fn default() -> Self {
Self {
mass: 1.0,
stiffness: 100.0,
damping: 10.0,
velocity: 0.0,
threshold: 0.001,
}
}
}
#[derive(Clone, Copy, Debug)]
enum Regime {
Under { oscillation: f64 },
Critical,
Over { separation: f64 },
}
#[derive(Clone, Copy, Debug)]
struct Response {
regime: Regime,
rate: f64,
velocity: f64,
duration: Duration,
}
impl Response {
fn try_new(parameters: Parameters) -> Result<Self, ValidationError> {
if !(parameters.mass.is_finite() && parameters.mass > 0.0) {
return Err(spring_error(
"mass",
"spring mass must be finite and positive",
));
}
if !(parameters.stiffness.is_finite() && parameters.stiffness > 0.0) {
return Err(spring_error(
"stiffness",
"spring stiffness must be finite and positive",
));
}
if !(parameters.damping.is_finite() && parameters.damping > 0.0) {
return Err(spring_error(
"damping",
"spring damping must be finite and positive",
));
}
if !parameters.velocity.is_finite() {
return Err(spring_error(
"initial_velocity",
"spring velocity must be finite",
));
}
if !(parameters.threshold.is_finite()
&& parameters.threshold > 0.0
&& parameters.threshold < 1.0)
{
return Err(spring_error(
"settling_threshold",
"spring settling threshold must be finite and between zero and one",
));
}
let frequency = libm::sqrt(parameters.stiffness) / libm::sqrt(parameters.mass);
let quotient = parameters.damping / parameters.mass;
let decay = if quotient.is_finite() {
quotient * 0.5
} else {
(parameters.damping * 0.5) / parameters.mass
};
if !(frequency.is_finite() && frequency > 0.0 && decay.is_finite() && decay > 0.0) {
return Err(spring_error(
"parameters",
"spring derived rates must be finite and positive",
));
}
let (regime, rate) = if decay < frequency {
let oscillation =
frequency * libm::sqrt((frequency - decay) / frequency * (1.0 + decay / frequency));
if !(oscillation.is_finite() && oscillation > 0.0) {
return Err(spring_error(
"parameters",
"spring oscillation must be representable",
));
}
(Regime::Under { oscillation }, decay)
} else if decay == frequency {
(Regime::Critical, decay)
} else {
let root = decay * libm::sqrt((decay - frequency) / decay * (1.0 + frequency / decay));
let separation = 2.0 * root;
let slow = (frequency / decay) * frequency / (1.0 + root / decay);
if !(separation.is_finite() && separation > 0.0 && slow.is_finite() && slow > 0.0) {
return Err(spring_error(
"parameters",
"spring characteristic rates must be representable",
));
}
(Regime::Over { separation }, slow)
};
let velocity = parameters.velocity / rate;
if !velocity.is_finite() {
return Err(spring_error(
"initial_velocity",
"spring normalized velocity must be representable",
));
}
let duration = settling_duration(rate, (1.0 - velocity).abs(), parameters.threshold)?;
if let Regime::Under { oscillation } = regime
&& !(oscillation * duration.as_seconds()).is_finite()
{
return Err(spring_error(
"parameters",
"spring phase must be representable",
));
}
Ok(Self {
regime,
rate,
velocity,
duration,
})
}
fn progress(self, seconds: f64) -> f64 {
let elapsed = self.rate * seconds;
if elapsed.is_infinite() {
return 1.0;
}
let decay = libm::exp(-elapsed);
let weighted_time = elapsed * decay;
let base = critical_progress(elapsed, decay);
let progress = match self.regime {
Regime::Critical => base + self.velocity * weighted_time,
Regime::Under { oscillation } => {
let angle = oscillation * seconds;
let (sinc, remainder) = sine_ratio(angle);
let half_sine = libm::sin(angle * 0.5);
base + weighted_time * remainder
+ decay * (2.0 * half_sine * half_sine)
+ self.velocity * weighted_time * sinc
}
Regime::Over { separation } => {
let (ratio, remainder) = exponential_ratio(separation * seconds);
base + weighted_time * remainder + self.velocity * weighted_time * ratio
}
};
assert!(progress.is_finite(), "spring response must be finite");
progress
}
}
fn critical_progress(elapsed: f64, decay: f64) -> f64 {
if elapsed < 0.001 {
elapsed
* elapsed
* (0.5
+ elapsed
* (-1.0 / 3.0 + elapsed * (0.125 + elapsed * (-1.0 / 30.0 + elapsed / 144.0))))
} else {
-libm::expm1(-elapsed) - elapsed * decay
}
}
fn sine_ratio(angle: f64) -> (f64, f64) {
if angle.abs() < 0.001 {
let square = angle * angle;
let remainder = square * (1.0 / 6.0 + square * (-1.0 / 120.0 + square / 5_040.0));
(1.0 - remainder, remainder)
} else {
let ratio = libm::sin(angle) / angle;
(ratio, 1.0 - ratio)
}
}
fn exponential_ratio(elapsed: f64) -> (f64, f64) {
if elapsed.is_infinite() {
(0.0, 1.0)
} else if elapsed < 0.001 {
let remainder = elapsed
* (0.5
+ elapsed
* (-1.0 / 6.0
+ elapsed * (1.0 / 24.0 + elapsed * (-1.0 / 120.0 + elapsed / 720.0))));
(1.0 - remainder, remainder)
} else {
let ratio = -libm::expm1(-elapsed) / elapsed;
(ratio, 1.0 - ratio)
}
}
fn log_envelope(elapsed: f64, slope: f64) -> f64 {
if elapsed == 0.0 {
return 0.0;
}
if slope == 0.0 {
return -elapsed;
}
let product_log = libm::log(slope) + libm::log(elapsed);
let envelope_log = if product_log > 0.0 {
product_log + libm::log1p(libm::exp(-product_log))
} else {
libm::log1p(libm::exp(product_log))
};
envelope_log - elapsed
}
fn spring_error(field: &'static str, message: &'static str) -> ValidationError {
ValidationError::invalid("spring", field, message)
}
fn settling_duration(rate: f64, slope: f64, threshold: f64) -> Result<Duration, ValidationError> {
let target = libm::log(threshold);
let mut lower = 0.0;
let mut upper = 1.0;
while log_envelope(upper, slope) > target {
upper *= 2.0;
}
for _ in 0..80 {
let middle = lower + (upper - lower) * 0.5;
if middle == lower || middle == upper {
break;
}
if log_envelope(middle, slope) > target {
lower = middle;
} else {
upper = middle;
}
}
let seconds = (upper * (1.0 + 32.0 * f64::EPSILON)) / rate;
let ticks = libm::ceil(seconds * TICKS_PER_SECOND as f64).max(1.0);
if !(ticks.is_finite() && ticks < i64::MAX as f64) {
return Err(spring_error(
"parameters",
"spring settling duration exceeds the signed tick range",
));
}
Ok(Duration::from_ticks(ticks as u64))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Spring, TimeLiteral};
fn close(actual: f64, expected: f64) {
assert!(actual.is_finite() && expected.is_finite());
assert!(
(actual - expected).abs() <= 2e-12 * expected.abs().max(1e-12),
"{actual} != {expected}"
);
}
#[test]
fn all_damping_regimes_match_analytic_references_and_retain_first_tick_motion() {
for damping in [10.0, 20.0, 40.0] {
let response = Response::try_new(Parameters {
damping,
..Parameters::default()
})
.unwrap();
let decay = damping * 0.5;
for seconds in [0.01, 0.1, 0.25, 0.5] {
let expected = if damping < 20.0 {
let oscillation = libm::sqrt(100.0 - decay * decay);
1.0 - libm::exp(-decay * seconds)
* (libm::cos(oscillation * seconds)
+ decay / oscillation * libm::sin(oscillation * seconds))
} else if damping == 20.0 {
1.0 - libm::exp(-decay * seconds) * (1.0 + decay * seconds)
} else {
let root = libm::sqrt(decay * decay - 100.0);
1.0 - libm::exp(-decay * seconds)
* (libm::cosh(root * seconds) + decay / root * libm::sinh(root * seconds))
};
close(response.progress(seconds), expected);
}
let tick = 1.0 / TICKS_PER_SECOND as f64;
let expected = 50.0 * tick * tick;
assert!((response.progress(tick) / expected - 1.0).abs() < 1e-6);
}
}
#[test]
fn settling_envelope_stays_below_threshold_across_regimes_and_large_velocities() {
for damping in [0.1, 10.0, 20.0 - 1e-8, 20.0, 20.0 + 1e-8, 40.0, 1e8] {
for velocity in [-4.0, 0.0, 20.0, 1e300] {
let parameters = Parameters {
damping,
velocity,
..Parameters::default()
};
let response = Response::try_new(parameters).unwrap();
assert!(response.duration > Duration::ZERO);
for factor in [1.0, 1.1, 2.0, 10.0] {
let seconds = response.duration.as_seconds() * factor;
let error = (1.0 - response.progress(seconds)).abs();
assert!(
error <= parameters.threshold * (1.0 + 1e-10),
"damping={damping}, velocity={velocity}, error={error}"
);
assert!(
log_envelope(response.rate * seconds, (1.0 - response.velocity).abs())
<= libm::log(parameters.threshold) + 1e-12
);
}
}
}
}
#[test]
fn initial_velocity_and_mass_scaling_have_physical_units() {
let tick = Duration::from_ticks(1);
for velocity in [-3.0, 4.0] {
let spring = Spring::builder(0.0_f64, 1.0)
.initial_velocity(velocity)
.build()
.unwrap();
assert!(
(spring.sample(tick.after_origin()) / tick.as_seconds() - velocity).abs() < 1e-6
);
}
let base = Spring::new(0.0_f64, 1.0);
let scaled = Spring::builder(0.0_f64, 1.0)
.mass(2.0)
.stiffness(200.0)
.damping(20.0)
.build()
.unwrap();
for time in [100.milliseconds(), 300.milliseconds(), 500.milliseconds()] {
close(
base.sample(time.after_origin()),
scaled.sample(time.after_origin()),
);
}
assert!(
base.settle_duration()
.ticks()
.abs_diff(scaled.settle_duration().ticks())
<= 1
);
}
#[test]
fn near_critical_responses_and_durations_converge_without_coefficient_blowup() {
let critical = Spring::builder(0.0_f64, 1.0).damping(20.0).build().unwrap();
for damping in [20.0 - 1e-10, 20.0 + 1e-10] {
let nearby = Spring::builder(0.0_f64, 1.0)
.damping(damping)
.build()
.unwrap();
for time in [10.milliseconds(), 100.milliseconds(), 500.milliseconds()] {
assert!(
(nearby.sample(time.after_origin()) - critical.sample(time.after_origin()))
.abs()
< 1e-11
);
}
assert!(
(nearby.settle_duration().as_seconds() / critical.settle_duration().as_seconds()
- 1.0)
.abs()
< 1e-4
);
}
}
#[test]
fn default_threshold_and_random_order_sampling_pin_exact_endpoints() {
let spring = Spring::default();
let end = spring.settle_duration();
assert!(end > Duration::ZERO);
assert_eq!(spring.sample(Time::from_ticks(i64::MIN)), 0.0);
assert_eq!(spring.sample(Time::ZERO), 0.0);
assert_eq!(spring.sample(end.after_origin()), 1.0);
assert_eq!(spring.sample(Time::from_ticks(i64::MAX)), 1.0);
let expected = spring.sample(300.milliseconds().after_origin());
assert!(spring.sample(500.milliseconds().after_origin()) > 1.0);
for time in [
end,
Duration::ZERO,
300.milliseconds(),
100.milliseconds(),
300.milliseconds(),
] {
assert_eq!(
spring.sample(time.after_origin()),
spring.clone().sample(time.after_origin())
);
if time == 300.milliseconds() {
assert_eq!(spring.sample(time.after_origin()), expected);
}
}
let tighter = Spring::builder(0.0, 1.0)
.settling_threshold(1e-6)
.build()
.unwrap();
assert!(tighter.settle_duration() > end);
let smallest = Spring::builder(0.0, 1.0)
.settling_threshold(f64::from_bits(1))
.build()
.unwrap();
let response = smallest.response;
assert!(
log_envelope(
response.rate * response.duration.as_seconds(),
(1.0 - response.velocity).abs()
) <= libm::log(f64::from_bits(1))
);
}
#[test]
fn invalid_physical_parameters_and_unrepresentable_durations_fail_while_building() {
let build = |builder: SpringBuilder<f64>| builder.build().is_err();
let spring = || Spring::builder(0.0, 1.0);
for value in [f64::NAN, f64::INFINITY, -1.0, 0.0] {
assert!(build(spring().mass(value)));
assert!(build(spring().stiffness(value)));
assert!(build(spring().damping(value)));
}
for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert!(build(spring().initial_velocity(value)));
}
for value in [0.0, 1.0, -1.0, f64::NAN, f64::INFINITY] {
assert!(build(spring().settling_threshold(value)));
}
assert!(build(spring().damping(1e-15)));
assert!(build(spring().stiffness(1e-30)));
assert!(build(spring().mass(f64::from_bits(1))));
}
}