| 1 | /// A CSS-style cubic bezier from (0, 0) to (1, 1) with control points |
| 2 | /// (x1, y1) and (x2, y2). The x coordinate is time, y is progress. |
| 3 | #[derive(Clone, Copy, Debug, PartialEq)] |
| 4 | pub struct CubicBezier { |
| 5 | pub x1: f64, |
| 6 | pub y1: f64, |
| 7 | pub x2: f64, |
| 8 | pub y2: f64, |
| 9 | } |
| 10 | |
| 11 | impl CubicBezier { |
| 12 | pub const LINEAR: Self = Self::new(0.0, 0.0, 1.0, 1.0); |
| 13 | pub const EASE: Self = Self::new(0.45, 0.0, 0.2, 1.0); |
| 14 | |
| 15 | pub const fn new(x1: f64, y1: f64, x2: f64, y2: f64) -> Self { |
| 16 | Self { x1, y1, x2, y2 } |
| 17 | } |
| 18 | |
| 19 | /// Whether x is monotonic, which every valid easing curve needs. |
| 20 | pub fn is_valid(&self) -> bool { |
| 21 | (0.0..=1.0).contains(&self.x1) && (0.0..=1.0).contains(&self.x2) |
| 22 | } |
| 23 | |
| 24 | /// Progress at normalized time `t`, clamped to the phase. |
| 25 | pub fn sample(&self, t: f64) -> f64 { |
| 26 | if t <= 0.0 { |
| 27 | return 0.0; |
| 28 | } |
| 29 | if t >= 1.0 { |
| 30 | return 1.0; |
| 31 | } |
| 32 | let u = self.solve_x(t); |
| 33 | poly(self.y1, self.y2, u) |
| 34 | } |
| 35 | |
| 36 | /// Initial slope, d(progress)/d(time) at t = 0. |
| 37 | pub fn initial_slope(&self) -> f64 { |
| 38 | if self.x1 == 0.0 { |
| 39 | return if self.y1 == 0.0 { 0.0 } else { f64::INFINITY }; |
| 40 | } |
| 41 | self.y1 / self.x1 |
| 42 | } |
| 43 | |
| 44 | /// The same curve, starting at `slope` instead of its own initial slope. |
| 45 | /// |
| 46 | /// The first control point's height is clamped to 1. A cubic bezier stays |
| 47 | /// inside the convex hull of its control points, so with both heights in |
| 48 | /// [0, 1] the curve can never overshoot its target. |
| 49 | pub fn with_initial_slope(&self, slope: f64) -> Self { |
| 50 | let x1 = if self.x1 > 0.0 { self.x1 } else { 0.3 }; |
| 51 | let y1 = (x1 * slope.max(0.0)).min(1.0); |
| 52 | Self::new(x1, y1, self.x2, self.y2) |
| 53 | } |
| 54 | |
| 55 | fn solve_x(&self, t: f64) -> f64 { |
| 56 | let mut u = t; |
| 57 | for _ in 0..8 { |
| 58 | let err = poly(self.x1, self.x2, u) - t; |
| 59 | if err.abs() < 1e-9 { |
| 60 | return u; |
| 61 | } |
| 62 | let d = poly_derivative(self.x1, self.x2, u); |
| 63 | if d.abs() < 1e-9 { |
| 64 | break; |
| 65 | } |
| 66 | u = (u - err / d).clamp(0.0, 1.0); |
| 67 | } |
| 68 | let (mut lo, mut hi) = (0.0, 1.0); |
| 69 | u = t; |
| 70 | for _ in 0..60 { |
| 71 | if poly(self.x1, self.x2, u) < t { |
| 72 | lo = u; |
| 73 | } else { |
| 74 | hi = u; |
| 75 | } |
| 76 | u = (lo + hi) / 2.0; |
| 77 | } |
| 78 | u |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | fn poly(p1: f64, p2: f64, u: f64) -> f64 { |
| 83 | let c = 3.0 * p1; |
| 84 | let b = 3.0 * (p2 - p1) - c; |
| 85 | let a = 1.0 - c - b; |
| 86 | ((a * u + b) * u + c) * u |
| 87 | } |
| 88 | |
| 89 | fn poly_derivative(p1: f64, p2: f64, u: f64) -> f64 { |
| 90 | let c = 3.0 * p1; |
| 91 | let b = 3.0 * (p2 - p1) - c; |
| 92 | let a = 1.0 - c - b; |
| 93 | (3.0 * a * u + 2.0 * b) * u + c |
| 94 | } |
| 95 | |
| 96 | #[cfg(test)] |
| 97 | mod tests { |
| 98 | use super::*; |
| 99 | use proptest::prelude::*; |
| 100 | |
| 101 | #[test] |
| 102 | fn endpoints_are_exact() { |
| 103 | for c in [CubicBezier::LINEAR, CubicBezier::EASE] { |
| 104 | assert_eq!(c.sample(0.0), 0.0); |
| 105 | assert_eq!(c.sample(1.0), 1.0); |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | #[test] |
| 110 | fn linear_is_identity() { |
| 111 | for i in 0..=100 { |
| 112 | let t = f64::from(i) / 100.0; |
| 113 | assert!((CubicBezier::LINEAR.sample(t) - t).abs() < 1e-6); |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | #[test] |
| 118 | fn matched_slope_is_honoured() { |
| 119 | let c = CubicBezier::EASE.with_initial_slope(2.0); |
| 120 | assert!((c.initial_slope() - 2.0).abs() < 1e-9); |
| 121 | let dt = 1e-4; |
| 122 | assert!((c.sample(dt) / dt - 2.0).abs() < 0.05); |
| 123 | } |
| 124 | |
| 125 | proptest! { |
| 126 | #[test] |
| 127 | fn never_overshoots(slope in 0.0f64..50.0, t in 0.0f64..=1.0) { |
| 128 | let c = CubicBezier::EASE.with_initial_slope(slope); |
| 129 | let p = c.sample(t); |
| 130 | prop_assert!((-1e-9..=1.0 + 1e-9).contains(&p)); |
| 131 | } |
| 132 | |
| 133 | #[test] |
| 134 | fn monotonic(x1 in 0.0f64..=1.0, y1 in 0.0f64..=1.0, x2 in 0.0f64..=1.0, y2 in 0.0f64..=1.0, t in 0.0f64..0.99) { |
| 135 | let c = CubicBezier::new(x1, y1, x2, y2); |
| 136 | prop_assert!(c.sample(t + 0.01) >= c.sample(t) - 1e-6); |
| 137 | } |
| 138 | } |
| 139 | } |