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