use super::spring::SpringConfig;
use std::cmp::Ordering;
use std::sync::Arc;
#[derive(Clone)]
pub enum TimingFunction {
Linear,
EaseIn,
EaseOut,
EaseInOut,
CubicBezier(f32, f32, f32, f32),
Spring(SpringConfig),
Custom(CustomCurve),
}
#[derive(Clone)]
pub struct CustomCurve {
f: Arc<dyn Fn(f32) -> f32 + Send + Sync>,
excursion: f32,
}
impl CustomCurve {
fn evaluate(&self, t: f32) -> f32 {
(self.f)(t)
}
}
impl TimingFunction {
pub fn evaluate(&self, t: f32) -> f32 {
match self {
TimingFunction::Linear => t,
TimingFunction::EaseIn => ease_in(t),
TimingFunction::EaseOut => ease_out(t),
TimingFunction::EaseInOut => ease_in_out(t),
TimingFunction::CubicBezier(x1, y1, x2, y2) => cubic_bezier(t, *x1, *y1, *x2, *y2),
TimingFunction::Spring(_) => t, TimingFunction::Custom(curve) => curve.evaluate(t),
}
}
pub fn peak_overshoot(&self) -> f32 {
match self {
TimingFunction::Linear
| TimingFunction::EaseIn
| TimingFunction::EaseOut
| TimingFunction::EaseInOut => 0.0,
TimingFunction::CubicBezier(_, y1, _, y2) => {
let above = (y1.max(*y2) - 1.0).max(0.0);
let below = (-y1.min(*y2)).max(0.0);
above.max(below)
}
TimingFunction::Spring(config) => config.peak_overshoot(),
TimingFunction::Custom(curve) => curve.excursion,
}
}
const CUSTOM_SAMPLES: usize = 65;
pub fn custom<F>(f: F) -> Self
where
F: Fn(f32) -> f32 + Send + Sync + 'static,
{
let mut excursion = 0.0f32;
for i in 0..Self::CUSTOM_SAMPLES {
let v = f(i as f32 / (Self::CUSTOM_SAMPLES - 1) as f32);
excursion = excursion.max(v - 1.0).max(-v);
}
TimingFunction::Custom(CustomCurve {
f: Arc::new(f),
excursion: excursion.max(0.0),
})
}
}
impl std::fmt::Debug for TimingFunction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TimingFunction::Linear => write!(f, "Linear"),
TimingFunction::EaseIn => write!(f, "EaseIn"),
TimingFunction::EaseOut => write!(f, "EaseOut"),
TimingFunction::EaseInOut => write!(f, "EaseInOut"),
TimingFunction::CubicBezier(x1, y1, x2, y2) => {
write!(f, "CubicBezier({}, {}, {}, {})", x1, y1, x2, y2)
}
TimingFunction::Spring(config) => write!(f, "Spring({:?})", config),
TimingFunction::Custom(curve) => write!(f, "Custom(±{})", curve.excursion),
}
}
}
fn ease_in(t: f32) -> f32 {
t * t
}
fn ease_out(t: f32) -> f32 {
t * (2.0 - t)
}
fn ease_in_out(t: f32) -> f32 {
if t < 0.5 {
2.0 * t * t
} else {
-1.0 + (4.0 - 2.0 * t) * t
}
}
fn cubic_bezier(t: f32, x1: f32, y1: f32, x2: f32, y2: f32) -> f32 {
let x = Cubic::new(f64::from(x1), f64::from(x2));
let y = Cubic::new(f64::from(y1), f64::from(y2));
match x.parameter_at(f64::from(t)) {
Some((parameter, _)) => y.at(parameter) as f32,
None => f32::NAN,
}
}
const NEWTON_STEPS: u32 = 8;
#[derive(Clone, Copy)]
struct Cubic {
a: f64,
b: f64,
c: f64,
}
impl Cubic {
fn new(p1: f64, p2: f64) -> Self {
let c = 3.0 * p1;
let b = 3.0 * (p2 - p1) - c;
Self {
a: 1.0 - c - b,
b,
c,
}
}
fn at(self, t: f64) -> f64 {
((self.a * t + self.b) * t + self.c) * t
}
fn slope(self, t: f64) -> f64 {
(3.0 * self.a * t + 2.0 * self.b) * t + self.c
}
fn parameter_at(self, value: f64) -> Option<(f64, u32)> {
let (mut low, mut high) = (0.0, 1.0);
let mut parameter = value;
for step in 1..=NEWTON_STEPS {
let residual = self.at(parameter) - value;
match residual.partial_cmp(&0.0)? {
Ordering::Less => low = parameter,
Ordering::Greater => high = parameter,
Ordering::Equal => return Some((parameter, step)),
}
let newton = parameter - residual / self.slope(parameter);
if newton == parameter {
return Some((parameter, step));
}
let next = if strictly_between(newton, low, high) {
newton
} else {
0.5 * (low + high)
};
if !strictly_between(next, low, high) {
return Some((parameter, step));
}
parameter = next;
}
for halving in 1..=f64::MANTISSA_DIGITS {
let middle = 0.5 * (low + high);
if !strictly_between(middle, low, high) {
return Some((low, NEWTON_STEPS + halving));
}
match self.at(middle).partial_cmp(&value)? {
Ordering::Greater => high = middle,
_ => low = middle,
}
}
Some((low, NEWTON_STEPS + f64::MANTISSA_DIGITS))
}
}
fn strictly_between(value: f64, low: f64, high: f64) -> bool {
value.partial_cmp(&low) == Some(Ordering::Greater)
&& value.partial_cmp(&high) == Some(Ordering::Less)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bezier_samples_agree_with_de_casteljau_inversion() {
let sample = |u: f64, a: f64, b: f64| {
let lerp = |p, q| p + (q - p) * u;
lerp(
lerp(lerp(0.0, a), lerp(a, b)),
lerp(lerp(a, b), lerp(b, 1.0)),
)
};
for (x1, y1, x2, y2) in [
(0.0, 0.0, 0.0, 1.0),
(1.0, 0.0, 1.0, 1.0),
(1.0, 0.0, 0.0, 1.0),
(0.25, 0.1, 0.25, 1.0),
(0.42, 0.0, 0.58, 1.0),
(0.34, 1.56, 0.64, 1.0),
(0.5, -0.6, 0.5, 1.2),
(0.45, -2.8, 0.92, 0.6),
] {
let curve = TimingFunction::CubicBezier(x1, y1, x2, y2);
assert_eq!(curve.evaluate(0.0), 0.0);
assert_eq!(curve.evaluate(1.0), 1.0);
for step in 1..1000 {
let progress = step as f32 / 1000.0;
let (mut low, mut high) = (0.0, 1.0);
for _ in 0..50 {
let u = (low + high) * 0.5;
if sample(u, f64::from(x1), f64::from(x2)) < f64::from(progress) {
low = u;
} else {
high = u;
}
}
let expected = sample((low + high) * 0.5, f64::from(y1), f64::from(y2)) as f32;
let actual = curve.evaluate(progress);
assert!(
(actual - expected).abs() < 1e-5,
"{curve:?} at {progress}: expected {expected}, got {actual}"
);
}
}
}
#[test]
fn bezier_anticipation_and_overshoot_are_not_clamped() {
assert!(TimingFunction::CubicBezier(0.5, -0.6, 0.5, 1.2).evaluate(0.1) < 0.0);
assert!(TimingFunction::CubicBezier(0.34, 1.56, 0.64, 1.0).evaluate(0.7) > 1.0);
}
#[test]
fn bezier_nan_inputs_propagate_without_stalling() {
for (progress, x1, x2) in [
(f32::NAN, 0.0, 0.0),
(0.5, f32::NAN, 0.0),
(0.5, 0.0, f32::NAN),
] {
assert!(
TimingFunction::CubicBezier(x1, 0.0, x2, 1.0)
.evaluate(progress)
.is_nan()
);
}
}
#[test]
fn bezier_solves_take_the_steps_they_are_known_to() {
let steps = |x1: f32, x2: f32| {
let x = Cubic::new(f64::from(x1), f64::from(x2));
(0..=1000).map(move |k| x.parameter_at(f64::from(k as f32 / 1000.0)).unwrap().1)
};
for (x1, x2) in [(0.25, 0.25), (0.42, 0.58), (0.34, 0.64), (0.5, 0.5)] {
assert!(
steps(x1, x2).all(|taken| taken <= NEWTON_STEPS),
"x controls {x1},{x2} fell back to bisection"
);
}
let curves = [
(0.0, 0.0),
(1.0, 1.0),
(1.0, 0.0),
(0.25, 0.25),
(0.42, 0.58),
(0.34, 0.64),
(0.5, 0.5),
(0.45, 0.92),
(0.0, 1.0),
];
let total: u32 = curves
.iter()
.map(|&(x1, x2)| steps(x1, x2).sum::<u32>())
.sum();
assert_eq!(total, 56_740);
let (_, taken) = Cubic::new(0.0, 0.0)
.parameter_at(f64::from(1e-30_f32))
.unwrap();
assert_eq!(taken, NEWTON_STEPS + f64::MANTISSA_DIGITS);
}
#[test]
fn flat_bezier_slopes_stay_on_the_curve() {
let beside_half = [
f32::from_bits(0.5_f32.to_bits() - 1),
f32::from_bits(0.5_f32.to_bits() + 1),
];
type Inverse = fn(f64) -> f64;
let cases: [(f32, f32, Inverse, &[f32]); 3] = [
(0.0, 0.0, f64::cbrt, &[0.001, 0.01, 0.1, 0.5]),
(
1.0,
1.0,
|p| 1.0 - (1.0 - p).cbrt(),
&[0.5, 0.9, 0.99, 0.999],
),
(
1.0,
0.0,
|p| 0.5 + ((p - 0.5) / 4.0).cbrt(),
&[
0.49,
0.4999,
beside_half[0],
0.5,
beside_half[1],
0.5001,
0.51,
],
),
];
for (x1, x2, inverse, points) in cases {
let curve = TimingFunction::CubicBezier(x1, 0.0, x2, 1.0);
for &progress in points {
let u = inverse(f64::from(progress));
let expected = (3.0 * u * u - 2.0 * u * u * u) as f32;
let actual = curve.evaluate(progress);
assert!(
(actual - expected).abs() < 1e-5,
"{curve:?} at {progress}: expected {expected}, got {actual}"
);
}
}
}
#[test]
fn test_linear() {
assert_eq!(TimingFunction::Linear.evaluate(0.0), 0.0);
assert_eq!(TimingFunction::Linear.evaluate(0.5), 0.5);
assert_eq!(TimingFunction::Linear.evaluate(1.0), 1.0);
}
#[test]
fn test_ease_in() {
let result = TimingFunction::EaseIn.evaluate(0.5);
assert!(result < 0.5); }
#[test]
fn test_ease_out() {
let result = TimingFunction::EaseOut.evaluate(0.5);
assert!(result > 0.5); }
}
#[cfg(test)]
mod overshoot_bound_tests {
use super::*;
#[test]
fn a_custom_curve_is_measured_rather_than_clamped() {
let curve = TimingFunction::custom(|t| t * 2.0 - 0.5);
assert_eq!(curve.evaluate(1.0), 1.5, "the curve is left alone");
assert_eq!(curve.evaluate(0.0), -0.5);
assert!((curve.peak_overshoot() - 0.5).abs() < 1e-5);
for step in 0..=100 {
let t = step as f32 / 100.0;
let v = curve.evaluate(t);
let bound = curve.peak_overshoot();
assert!(
v <= 1.0 + bound + 1e-4 && v >= -bound - 1e-4,
"t = {t} evaluated to {v}, outside the bound it reports"
);
}
}
#[test]
fn a_custom_curve_that_only_eases_reports_no_excursion() {
let eased = TimingFunction::custom(|t| t * t);
assert_eq!(eased.peak_overshoot(), 0.0);
}
#[test]
fn the_ordinary_curves_report_what_they_do() {
assert_eq!(TimingFunction::Linear.peak_overshoot(), 0.0);
assert_eq!(TimingFunction::EaseInOut.peak_overshoot(), 0.0);
assert!(
(TimingFunction::CubicBezier(0.34, 1.56, 0.64, 1.0).peak_overshoot() - 0.56).abs()
< 1e-5
);
assert_eq!(
TimingFunction::CubicBezier(0.4, 0.0, 0.6, 1.0).peak_overshoot(),
0.0
);
assert!(
(TimingFunction::CubicBezier(0.5, -0.6, 0.5, 1.2).peak_overshoot() - 0.6).abs() < 1e-5
);
}
}