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