atagen

monolith

public
Star0
Watch0
dbdff401f909ec8649991d1ed46386943b4bbea5
1
ssh://brewery@pls.lobotomise.me:33/atagen/monolith.git
crates/motion/src/curve.rsRust123 lines3.5 KB
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 matched_slope_is_honoured() {
103
        let c = CubicBezier::EASE.with_initial_slope(2.0);
104
        assert!((c.initial_slope() - 2.0).abs() < 1e-9);
105
        let dt = 1e-4;
106
        assert!((c.sample(dt) / dt - 2.0).abs() < 0.05);
107
    }
108
109
    proptest! {
110
        #[test]
111
        fn never_overshoots(slope in 0.0f64..50.0, t in 0.0f64..=1.0) {
112
            let c = CubicBezier::EASE.with_initial_slope(slope);
113
            let p = c.sample(t);
114
            prop_assert!((-1e-9..=1.0 + 1e-9).contains(&p));
115
        }
116
117
        #[test]
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) {
119
            let c = CubicBezier::new(x1, y1, x2, y2);
120
            prop_assert!(c.sample(t + 0.01) >= c.sample(t) - 1e-6);
121
        }
122
    }
123
}