Skip to main content

alpha2_model/
easing.rs

1//! Timing curves shared by keyframe interpolation and transitions.
2//!
3//! The one parametric curve is CSS Easing Functions Level 1 `cubic-bezier(x1, y1, x2, y2)`,
4//! the same definition web animation, Lottie and motion tools use. Named presets (`ease`,
5//! `ease-in`, ...) are values of it kept by hosts, not the model.
6//!
7//! Evaluation uses only `f64` add, subtract, multiply and divide (no transcendental
8//! functions, no fused multiply-add) with a fixed iteration budget, so wasm and native
9//! produce identical bits.
10
11use serde::{Deserialize, Serialize};
12
13/// A CSS `cubic-bezier(x1, y1, x2, y2)` timing curve.
14///
15/// `x1` and `x2` lie in `0..=1`, which keeps the curve a function of its input. `y1` and `y2`
16/// are unbounded, so a curve may overshoot.
17#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Serialize)]
18#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
19#[serde(deny_unknown_fields)]
20pub struct CubicBezier {
21    /// Horizontal coordinate of the first control point, in `0..=1`.
22    pub x1: f32,
23    /// Vertical coordinate of the first control point.
24    pub y1: f32,
25    /// Horizontal coordinate of the second control point, in `0..=1`.
26    pub x2: f32,
27    /// Vertical coordinate of the second control point.
28    pub y2: f32,
29}
30
31/// Newton steps tried before bisection takes over.
32const NEWTON_STEPS: usize = 8;
33/// Bisection halvings; 2^-64 is far below the tolerance.
34const BISECTION_STEPS: usize = 64;
35/// Accepted distance between the curve's `x` and the requested progress.
36const TOLERANCE: f64 = 1e-12;
37/// A slope this flat would send a Newton step far outside the curve.
38const MIN_SLOPE: f64 = 1e-9;
39
40impl CubicBezier {
41    /// Creates a curve from its two inner control points.
42    #[must_use]
43    pub const fn new(x1: f32, y1: f32, x2: f32, y2: f32) -> Self {
44        Self { x1, y1, x2, y2 }
45    }
46
47    /// Whether every coordinate is finite and `x1`, `x2` lie in `0..=1`.
48    #[must_use]
49    pub fn is_valid(self) -> bool {
50        [self.x1, self.y1, self.x2, self.y2]
51            .into_iter()
52            .all(f32::is_finite)
53            && (0.0..=1.0).contains(&self.x1)
54            && (0.0..=1.0).contains(&self.x2)
55    }
56
57    /// The curve's output at linear `progress`, which is clamped to `0..=1`.
58    ///
59    /// 0 maps exactly to 0 and 1 to 1. Between them the output may leave `0..=1` when `y1` or
60    /// `y2` does.
61    #[must_use]
62    pub fn apply(self, progress: f64) -> f64 {
63        let x = progress.clamp(0.0, 1.0);
64        if x <= 0.0 {
65            return 0.0;
66        }
67        if x >= 1.0 {
68            return 1.0;
69        }
70        let parameter = self.parameter_at(x);
71        component(parameter, f64::from(self.y1), f64::from(self.y2))
72    }
73
74    /// The Bezier parameter whose `x` equals `x`, for `x` in `(0, 1)`.
75    fn parameter_at(self, x: f64) -> f64 {
76        self.newton(x).unwrap_or_else(|| self.bisection(x))
77    }
78
79    /// Newton's method from `t = x`, if it lands within `NEWTON_STEPS` without leaving `0..=1`.
80    fn newton(self, x: f64) -> Option<f64> {
81        let (x1, x2) = (f64::from(self.x1), f64::from(self.x2));
82        let mut parameter = x;
83        for _ in 0..NEWTON_STEPS {
84            let error = component(parameter, x1, x2) - x;
85            if error.abs() < TOLERANCE {
86                return (0.0..=1.0).contains(&parameter).then_some(parameter);
87            }
88            let slope = slope(parameter, x1, x2);
89            if slope.abs() < MIN_SLOPE {
90                return None;
91            }
92            parameter -= error / slope;
93        }
94        None
95    }
96
97    /// Bisection on `0..=1`, where `x(t)` is non-decreasing because `x1` and `x2` lie in `0..=1`.
98    /// It converges where Newton fails on flat parts.
99    fn bisection(self, x: f64) -> f64 {
100        let (x1, x2) = (f64::from(self.x1), f64::from(self.x2));
101        let (mut low, mut high) = (0.0_f64, 1.0_f64);
102        let mut parameter = x;
103        for _ in 0..BISECTION_STEPS {
104            let value = component(parameter, x1, x2);
105            if (value - x).abs() < TOLERANCE {
106                break;
107            }
108            if value < x {
109                low = parameter;
110            } else {
111                high = parameter;
112            }
113            parameter = low + (high - low) * 0.5;
114        }
115        parameter
116    }
117}
118
119/// One coordinate of the curve at parameter `t` for inner control coordinates `p1`, `p2`:
120/// `3 (1-t)^2 t p1 + 3 (1-t) t^2 p2 + t^3`, in Horner form.
121fn component(t: f64, p1: f64, p2: f64) -> f64 {
122    let c = 3.0 * p1;
123    let b = 3.0 * (p2 - p1) - c;
124    let a = 1.0 - c - b;
125    ((a * t + b) * t + c) * t
126}
127
128/// The derivative of [`component`] with respect to `t`.
129fn slope(t: f64, p1: f64, p2: f64) -> f64 {
130    let c = 3.0 * p1;
131    let b = 3.0 * (p2 - p1) - c;
132    let a = 1.0 - c - b;
133    (3.0 * a * t + 2.0 * b) * t + c
134}
135
136/// A timing curve from linear progress to eased progress, without a hold.
137///
138/// Transitions use it directly; keyframe [`crate::Interpolation`] adds `hold`.
139#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Serialize)]
140#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
141#[serde(tag = "type", rename_all = "snake_case", from = "EasingWire")]
142pub enum Easing {
143    /// Progress advances at a constant rate.
144    Linear,
145    /// Progress follows a CSS `cubic-bezier()` curve.
146    CubicBezier(CubicBezier),
147}
148
149/// The wire form of [`Easing`].
150///
151/// serde lets an internally tagged unit variant ignore extra fields even with
152/// `deny_unknown_fields`; empty struct variants reject them, as the published schema does.
153#[derive(Deserialize)]
154#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
155#[cfg_attr(feature = "schema", schemars(rename = "Easing"))]
156#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
157pub(crate) enum EasingWire {
158    /// Progress advances at a constant rate.
159    Linear {},
160    /// Progress follows a CSS `cubic-bezier()` curve.
161    CubicBezier(CubicBezier),
162}
163
164impl From<EasingWire> for Easing {
165    fn from(wire: EasingWire) -> Self {
166        match wire {
167            EasingWire::Linear {} => Self::Linear,
168            EasingWire::CubicBezier(curve) => Self::CubicBezier(curve),
169        }
170    }
171}
172
173impl Easing {
174    /// Whether the curve's parameters are usable.
175    #[must_use]
176    pub fn is_valid(self) -> bool {
177        match self {
178            Self::Linear => true,
179            Self::CubicBezier(curve) => curve.is_valid(),
180        }
181    }
182
183    /// Eased progress for linear `progress`, which is clamped to `0..=1`.
184    #[must_use]
185    pub fn apply(self, progress: f64) -> f64 {
186        match self {
187            Self::Linear => progress.clamp(0.0, 1.0),
188            Self::CubicBezier(curve) => curve.apply(progress),
189        }
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::{CubicBezier, Easing};
196
197    const EASE: CubicBezier = CubicBezier::new(0.25, 0.1, 0.25, 1.0);
198
199    #[test]
200    fn css_ease_matches_its_reference_value() {
201        // CSS `ease` at x = 0.5 is 0.80240338758485699 at t = 0.69957243870638562, computed
202        // independently by 200-step bisection in 50-digit decimal arithmetic. The f32 control
203        // points move it by under 1e-9.
204        assert!((EASE.apply(0.5) - 0.802_403_387_584_857).abs() < 1e-9);
205        assert!((EASE.parameter_at(0.5) - 0.699_572_438_706_385_6).abs() < 1e-9);
206        let ease_in_out = CubicBezier::new(0.42, 0.0, 0.58, 1.0);
207        // 0.42 and 0.58 are not exact in f32, so the symmetry is to ~1e-8.
208        assert!((ease_in_out.apply(0.5) - 0.5).abs() < 1e-7);
209        for x in [0.1, 0.25, 0.4] {
210            let mirrored = 1.0 - ease_in_out.apply(1.0 - x);
211            assert!((ease_in_out.apply(x) - mirrored).abs() < 1e-7);
212        }
213    }
214
215    #[test]
216    fn ends_are_exact_and_input_is_clamped() {
217        for curve in [EASE, CubicBezier::new(0.0, -2.0, 1.0, 3.0)] {
218            assert_eq!(curve.apply(0.0).to_bits(), 0.0_f64.to_bits());
219            assert_eq!(curve.apply(1.0).to_bits(), 1.0_f64.to_bits());
220            assert_eq!(curve.apply(-4.0).to_bits(), 0.0_f64.to_bits());
221            assert_eq!(curve.apply(9.0).to_bits(), 1.0_f64.to_bits());
222        }
223    }
224
225    #[test]
226    fn the_linear_curve_is_the_identity() {
227        let linear = CubicBezier::new(0.0, 0.0, 1.0, 1.0);
228        for index in 0..=64 {
229            let x = f64::from(index) / 64.0;
230            assert!((linear.apply(x) - x).abs() < 1e-12, "{x}");
231            assert!((Easing::Linear.apply(x) - x).abs() < f64::EPSILON);
232        }
233    }
234
235    #[test]
236    fn flat_curves_fall_back_to_bisection_and_stay_accurate() {
237        // x(t) = t^3 is flat at t = 0 and x(t) = 1 - (1 - t)^3 at t = 1, so Newton from t = x
238        // overshoots on the first and has no slope on the second.
239        let cases = [
240            (CubicBezier::new(0.0, 0.0, 0.0, 0.0), 0.001, 0.1),
241            (CubicBezier::new(1.0, 1.0, 1.0, 1.0), 0.999, 0.9),
242        ];
243        for (curve, x, parameter) in cases {
244            assert_eq!(curve.newton(x), None, "Newton must give up on {curve:?}");
245            let found = curve.parameter_at(x);
246            assert!((found - parameter).abs() < 1e-9, "{curve:?}: {found}");
247            let x_back = super::component(found, f64::from(curve.x1), f64::from(curve.x2));
248            assert!((x_back - x).abs() < 1e-12);
249        }
250    }
251
252    #[test]
253    fn overshooting_curves_leave_the_unit_interval_between_the_ends() {
254        let back = CubicBezier::new(0.34, 1.56, 0.64, 1.0);
255        let peak = (1..100)
256            .map(|index| back.apply(f64::from(index) / 100.0))
257            .fold(f64::MIN, f64::max);
258        assert!(peak > 1.05, "ease-out-back overshoots: {peak}");
259        assert!((Easing::CubicBezier(back).apply(1.0) - 1.0).abs() < f64::EPSILON);
260    }
261
262    #[test]
263    fn control_points_outside_the_unit_square_are_rejected_horizontally_only() {
264        assert!(CubicBezier::new(0.0, -5.0, 1.0, 5.0).is_valid());
265        assert!(!CubicBezier::new(-0.1, 0.0, 1.0, 1.0).is_valid());
266        assert!(!CubicBezier::new(0.0, 0.0, 1.1, 1.0).is_valid());
267        assert!(!CubicBezier::new(0.0, f32::NAN, 1.0, 1.0).is_valid());
268        assert!(!Easing::CubicBezier(CubicBezier::new(0.0, 0.0, 2.0, 1.0)).is_valid());
269    }
270
271    #[test]
272    fn the_wire_form_is_a_tagged_object() {
273        let easing: Easing =
274            serde_json::from_str(r#"{"type":"cubic_bezier","x1":0.42,"y1":0,"x2":0.58,"y2":1}"#)
275                .expect("cubic bezier easing");
276        assert_eq!(
277            easing,
278            Easing::CubicBezier(CubicBezier::new(0.42, 0.0, 0.58, 1.0))
279        );
280        let linear: Easing = serde_json::from_str(r#"{"type":"linear"}"#).expect("linear");
281        assert_eq!(linear, Easing::Linear);
282        assert!(serde_json::from_str::<Easing>(r#""linear""#).is_err());
283        assert!(serde_json::from_str::<Easing>(r#"{"type":"linear","x1":0}"#).is_err());
284    }
285}