eazy_core/easing/bezier/
cubic_bezier.rs

1//! # The Cubic Bézier Curve.
2//!
3//! CSS-compliant cubic bezier easing using Newton-Raphson iteration.
4//! More information [here](https://drafts.csswg.org/css-easing/#cubic-bezier-easing-functions).
5
6use crate::easing::Curve;
7
8/// Sample table size for initial guess optimization.
9const SAMPLE_TABLE_SIZE: usize = 11;
10
11/// Newton-Raphson iteration count.
12const NEWTON_ITERATIONS: usize = 4;
13
14/// Minimum slope for Newton-Raphson (below this, use subdivision).
15const NEWTON_MIN_SLOPE: f32 = 0.001;
16
17/// Binary subdivision precision.
18const SUBDIVISION_PRECISION: f32 = 0.0000001;
19
20/// Maximum binary subdivision iterations.
21const SUBDIVISION_MAX_ITERATIONS: usize = 10;
22
23/// The [`CubicBezier`] Easing Function.
24///
25/// Implements CSS `cubic-bezier()` timing function.
26/// Given an input x (progress), solves for t where bezier_x(t) = x,
27/// then returns bezier_y(t).
28///
29/// #### notes.
30///
31/// See also [`crate::easing::Easing`].
32#[derive(Clone, Copy, Debug)]
33pub struct CubicBezier {
34  /// Control point 1 x.
35  p1x: f32,
36  /// Control point 1 y.
37  p1y: f32,
38  /// Control point 2 x.
39  p2x: f32,
40  /// Control point 2 y.
41  p2y: f32,
42  /// Precomputed sample table for x values.
43  samples: [f32; SAMPLE_TABLE_SIZE],
44}
45
46impl CubicBezier {
47  // Bezier coefficient A.
48  #[inline(always)]
49  fn a(p1: f32, p2: f32) -> f32 {
50    1.0 - 3.0 * p2 + 3.0 * p1
51  }
52
53  // Bezier coefficient B.
54  #[inline(always)]
55  fn b(p1: f32, p2: f32) -> f32 {
56    3.0 * p2 - 6.0 * p1
57  }
58
59  // Bezier coefficient C.
60  #[inline(always)]
61  fn c(p1: f32) -> f32 {
62    3.0 * p1
63  }
64
65  // Evaluate bezier at parameter t.
66  #[inline(always)]
67  fn bezier_at(t: f32, p1: f32, p2: f32) -> f32 {
68    ((Self::a(p1, p2) * t + Self::b(p1, p2)) * t + Self::c(p1)) * t
69  }
70
71  // Evaluate bezier derivative at parameter t.
72  #[inline(always)]
73  fn bezier_slope(t: f32, p1: f32, p2: f32) -> f32 {
74    3.0 * Self::a(p1, p2) * t * t + 2.0 * Self::b(p1, p2) * t + Self::c(p1)
75  }
76
77  // Newton-Raphson iteration to find t for given x.
78  #[inline(always)]
79  fn newton_raphson(&self, x: f32, guess: f32) -> f32 {
80    let mut t = guess;
81
82    for _ in 0..NEWTON_ITERATIONS {
83      let slope = Self::bezier_slope(t, self.p1x, self.p2x);
84
85      if slope == 0.0 {
86        return t;
87      }
88
89      let current_x = Self::bezier_at(t, self.p1x, self.p2x) - x;
90
91      t -= current_x / slope;
92    }
93
94    t
95  }
96
97  // Binary subdivision to find t for given x.
98  #[inline(always)]
99  fn binary_subdivide(&self, x: f32, mut a: f32, mut b: f32) -> f32 {
100    let mut t = 0.0;
101
102    for _ in 0..SUBDIVISION_MAX_ITERATIONS {
103      t = a + (b - a) / 2.0;
104
105      let current_x = Self::bezier_at(t, self.p1x, self.p2x) - x;
106
107      if current_x.abs() < SUBDIVISION_PRECISION {
108        return t;
109      }
110
111      if current_x > 0.0 {
112        b = t;
113      } else {
114        a = t;
115      }
116    }
117
118    t
119  }
120
121  // Find parameter t for given x using sample table + refinement.
122  #[inline(always)]
123  fn t_for_x(&self, x: f32) -> f32 {
124    let sample_step = 1.0 / (SAMPLE_TABLE_SIZE - 1) as f32;
125
126    // Find interval in sample table.
127    let mut interval_start = 0.0;
128    let mut current_sample = 1;
129
130    while current_sample < SAMPLE_TABLE_SIZE - 1
131      && self.samples[current_sample] <= x
132    {
133      interval_start += sample_step;
134      current_sample += 1;
135    }
136
137    current_sample -= 1;
138
139    // Linear interpolation for initial guess.
140    let dist = (x - self.samples[current_sample])
141      / (self.samples[current_sample + 1] - self.samples[current_sample]);
142    let guess = interval_start + dist * sample_step;
143
144    // Refine using Newton-Raphson or binary subdivision.
145    let initial_slope = Self::bezier_slope(guess, self.p1x, self.p2x);
146
147    if initial_slope >= NEWTON_MIN_SLOPE {
148      self.newton_raphson(x, guess)
149    } else if initial_slope == 0.0 {
150      guess
151    } else {
152      self.binary_subdivide(x, interval_start, interval_start + sample_step)
153    }
154  }
155
156  /// Creates a [`CubicBezier`] curve from two control points.
157  ///
158  /// `p0` and `p3` are fixed to (0,0) and (1,1). Control points `p1` and `p2`
159  /// define the curve shape.
160  ///
161  /// #### params.
162  ///
163  /// |       |                                              |
164  /// |:------|----------------------------------------------|
165  /// | `p1x` | The position of the `x-axis` of `p1` control |
166  /// | `p1y` | The position of the `y-axis` of `p1` control |
167  /// | `p2x` | The position of the `x-axis` of `p2` control |
168  /// | `p2y` | The position of the `y-axis` of `p2` control |
169  ///
170  /// #### examples.
171  ///
172  /// ```rust
173  /// use eazy::Curve;
174  /// use eazy::bezier::cubic::CubicBezier;
175  ///
176  /// // CSS ease timing function
177  /// let ease = CubicBezier::curve(0.25, 0.1, 0.25, 1.0);
178  ///
179  /// assert_eq!(ease.y(0.0), 0.0);
180  /// assert_eq!(ease.y(1.0), 1.0);
181  /// // At x=0.5, CSS ease returns ~0.8 (steep middle)
182  /// ```
183  #[inline(always)]
184  pub fn curve(p1x: f32, p1y: f32, p2x: f32, p2y: f32) -> Self {
185    // Precompute sample table for x values.
186    let mut samples = [0.0; SAMPLE_TABLE_SIZE];
187
188    for (i, sample) in samples.iter_mut().enumerate() {
189      let t = i as f32 / (SAMPLE_TABLE_SIZE - 1) as f32;
190
191      *sample = Self::bezier_at(t, p1x, p2x);
192    }
193
194    Self {
195      p1x,
196      p1y,
197      p2x,
198      p2y,
199      samples,
200    }
201  }
202
203  /// Creates a [`CubicBezier::curve`] based on CSS `ease`.
204  /// Equivalent to `cubic-bezier(0.25, 0.1, 0.25, 1.0)`.
205  #[inline(always)]
206  pub fn ease() -> Self {
207    Self::curve(0.25, 0.1, 0.25, 1.0)
208  }
209
210  /// Creates a [`CubicBezier::curve`] based on CSS `ease-in`.
211  /// Equivalent to `cubic-bezier(0.42, 0, 1, 1)`.
212  #[inline(always)]
213  pub fn in_ease() -> Self {
214    Self::curve(0.42, 0.0, 1.0, 1.0)
215  }
216
217  /// Creates a [`CubicBezier::curve`] based on CSS `ease-out`.
218  /// Equivalent to `cubic-bezier(0, 0, 0.58, 1)`.
219  #[inline(always)]
220  pub fn out_ease() -> Self {
221    Self::curve(0.0, 0.0, 0.58, 1.0)
222  }
223
224  /// Creates a [`CubicBezier::curve`] based on CSS `ease-in-out`.
225  /// Equivalent to `cubic-bezier(0.42, 0, 0.58, 1)`.
226  #[inline(always)]
227  pub fn in_out_ease() -> Self {
228    Self::curve(0.42, 0.0, 0.58, 1.0)
229  }
230}
231
232impl Curve for CubicBezier {
233  #[inline(always)]
234  fn y(&self, x: f32) -> f32 {
235    // Handle edge cases.
236    if x <= 0.0 {
237      return 0.0;
238    }
239
240    if x >= 1.0 {
241      return 1.0;
242    }
243
244    // Linear case: p1x == p1y && p2x == p2y.
245    if self.p1x == self.p1y && self.p2x == self.p2y {
246      return x;
247    }
248
249    // Find t for given x, then evaluate y at that t.
250    let t = self.t_for_x(x);
251
252    Self::bezier_at(t, self.p1y, self.p2y)
253  }
254}
255
256#[cfg(test)]
257mod tests {
258  use super::*;
259
260  #[test]
261  fn test_cubic_bezier_endpoints() {
262    let ease = CubicBezier::ease();
263
264    assert_eq!(ease.y(0.0), 0.0);
265    assert_eq!(ease.y(1.0), 1.0);
266  }
267
268  #[test]
269  fn test_cubic_bezier_ease_midpoint() {
270    // CSS ease at x=0.5 should be approximately 0.8
271    // (the curve accelerates quickly then decelerates).
272    let ease = CubicBezier::ease();
273    let y = ease.y(0.5);
274
275    assert!(y > 0.75 && y < 0.85, "ease(0.5) = {y}, expected ~0.8");
276  }
277
278  #[test]
279  fn test_cubic_bezier_linear() {
280    // Linear: cubic-bezier(0, 0, 1, 1).
281    let linear = CubicBezier::curve(0.0, 0.0, 1.0, 1.0);
282
283    assert!((linear.y(0.25) - 0.25).abs() < 0.01);
284    assert!((linear.y(0.5) - 0.5).abs() < 0.01);
285    assert!((linear.y(0.75) - 0.75).abs() < 0.01);
286  }
287
288  #[test]
289  fn test_cubic_bezier_ease_in() {
290    // ease-in starts slow, ends fast.
291    let ease_in = CubicBezier::in_ease();
292
293    // At x=0.5, y should be less than 0.5.
294    assert!(ease_in.y(0.5) < 0.5);
295  }
296
297  #[test]
298  fn test_cubic_bezier_ease_out() {
299    // ease-out starts fast, ends slow.
300    let ease_out = CubicBezier::out_ease();
301
302    // At x=0.5, y should be greater than 0.5.
303    assert!(ease_out.y(0.5) > 0.5);
304  }
305
306  #[test]
307  fn test_cubic_bezier_ease_in_out() {
308    // ease-in-out is symmetric around 0.5.
309    let ease_in_out = CubicBezier::in_out_ease();
310    let y = ease_in_out.y(0.5);
311
312    // Should be close to 0.5 at midpoint.
313    assert!((y - 0.5).abs() < 0.05, "ease-in-out(0.5) = {y}");
314  }
315}