Skip to main content

waterui_core/animation_system/
easing.rs

1//! Unified easing system for `WaterUI` animations.
2//!
3//! This module provides the core easing curve types used across all `WaterUI`
4//! components for consistent animation behavior. GPU renderers can use these
5//! curves for shader-based interpolation, while native animations use them
6//! for system animation configuration.
7//!
8//! # Design
9//!
10//! The easing system uses only two variants:
11//! - `CubicBezier`: For all bezier-representable curves (linear, ease-in, ease-out, etc.)
12//! - `Spring`: For physics-based spring animations that overshoot and oscillate
13//!
14//! Standard curves are provided as constants with CSS-standard bezier control points.
15
16use core::time::Duration;
17
18/// Declarative easing curve with only two variants.
19///
20/// Standard curves (linear, ease-in, ease-out, ease-in-out) are cubic bezier
21/// curves with predefined control points. Custom bezier curves can be created
22/// with any control points.
23///
24/// Spring animations cannot be represented by bezier curves due to their
25/// oscillating nature, so they have a separate variant.
26#[derive(Debug, Clone, Copy, PartialEq)]
27#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
28pub enum EasingCurve {
29    /// Cubic bezier curve with control points (x1, y1, x2, y2).
30    ///
31    /// The curve starts at (0, 0) and ends at (1, 1). The control points
32    /// define the shape of the curve between these endpoints.
33    ///
34    /// Standard CSS bezier curves are provided as constants.
35    CubicBezier(f32, f32, f32, f32),
36    /// Spring physics animation.
37    ///
38    /// Spring animations cannot be represented by bezier curves because they
39    /// can overshoot the target value and oscillate before settling.
40    Spring {
41        /// Stiffness of the spring (higher = faster oscillation).
42        stiffness: f32,
43        /// Damping factor (higher = less bounce/oscillation).
44        damping: f32,
45    },
46}
47
48impl EasingCurve {
49    /// Linear interpolation - constant velocity from start to finish.
50    /// CSS: `linear` or `cubic-bezier(0, 0, 1, 1)`
51    pub const LINEAR: Self = Self::CubicBezier(0.0, 0.0, 1.0, 1.0);
52
53    /// Ease-in - starts slow and accelerates.
54    /// CSS: `ease-in` or `cubic-bezier(0.42, 0, 1, 1)`
55    pub const EASE_IN: Self = Self::CubicBezier(0.42, 0.0, 1.0, 1.0);
56
57    /// Ease-out - starts fast and decelerates.
58    /// CSS: `ease-out` or `cubic-bezier(0, 0, 0.58, 1)`
59    pub const EASE_OUT: Self = Self::CubicBezier(0.0, 0.0, 0.58, 1.0);
60
61    /// Ease-in-out - starts slow, speeds up, then slows down.
62    /// CSS: `ease-in-out` or `cubic-bezier(0.42, 0, 0.58, 1)`
63    pub const EASE_IN_OUT: Self = Self::CubicBezier(0.42, 0.0, 0.58, 1.0);
64
65    /// Default ease curve (CSS `ease`).
66    /// CSS: `ease` or `cubic-bezier(0.25, 0.1, 0.25, 1)`
67    pub const EASE: Self = Self::CubicBezier(0.25, 0.1, 0.25, 1.0);
68
69    /// Creates a custom cubic bezier curve.
70    #[must_use]
71    pub const fn bezier(x1: f32, y1: f32, x2: f32, y2: f32) -> Self {
72        Self::CubicBezier(x1, y1, x2, y2)
73    }
74
75    /// Creates a spring animation with the given stiffness and damping.
76    #[must_use]
77    pub const fn spring(stiffness: f32, damping: f32) -> Self {
78        Self::Spring { stiffness, damping }
79    }
80
81    /// Apply easing to a normalized time value t in [0, 1].
82    ///
83    /// Returns the eased progress value, which for most curves is also in [0, 1],
84    /// but spring animations may temporarily overshoot (return values > 1 or < 0).
85    #[must_use]
86    pub fn ease(&self, t: f32) -> f32 {
87        match self {
88            Self::CubicBezier(x1, y1, x2, y2) => cubic_bezier_ease(t, *x1, *y1, *x2, *y2),
89            Self::Spring { stiffness, damping } => spring_ease(t, *stiffness, *damping),
90        }
91    }
92
93    /// Returns `true` if this is a spring animation.
94    #[must_use]
95    pub const fn is_spring(&self) -> bool {
96        matches!(self, Self::Spring { .. })
97    }
98}
99
100impl Default for EasingCurve {
101    fn default() -> Self {
102        Self::EASE_IN_OUT
103    }
104}
105
106/// Cubic bezier easing implementation.
107///
108/// Uses Newton-Raphson iteration to find the t parameter for a given x,
109/// then evaluates the bezier curve at that t to get y.
110fn cubic_bezier_ease(t: f32, x1: f32, y1: f32, x2: f32, y2: f32) -> f32 {
111    const EPSILON: f32 = 0.0001;
112
113    // Handle edge cases
114    if t <= 0.0 {
115        return 0.0;
116    }
117    if t >= 1.0 {
118        return 1.0;
119    }
120
121    // Linear case - no need for iteration
122    if (x1 - y1).abs() < 0.0001 && (x2 - y2).abs() < 0.0001 {
123        return t;
124    }
125
126    // First try Newton-Raphson for fast convergence.
127    let mut guess = t;
128    let mut converged = false;
129    for _ in 0..8 {
130        let x = bezier_sample(guess, x1, x2) - t;
131        if x.abs() < EPSILON {
132            converged = true;
133            break;
134        }
135        let dx = bezier_derivative(guess, x1, x2);
136        if dx.abs() < 0.000_001 {
137            break;
138        }
139        let next = guess - x / dx;
140        if !(0.0..=1.0).contains(&next) {
141            break;
142        }
143        guess = next;
144    }
145
146    // Fall back to binary subdivision when Newton stalls (flat derivative,
147    // poor initial guess, or highly skewed control points).
148    if !converged {
149        let mut low = 0.0;
150        let mut high = 1.0;
151        guess = t.clamp(0.0, 1.0);
152        for _ in 0..16 {
153            let sample = bezier_sample(guess, x1, x2);
154            let delta = sample - t;
155            if delta.abs() < EPSILON {
156                break;
157            }
158            if delta > 0.0 {
159                high = guess;
160            } else {
161                low = guess;
162            }
163            guess = f32::midpoint(low, high);
164        }
165    }
166
167    // Clamp to valid range before sampling y.
168    guess = guess.clamp(0.0, 1.0);
169
170    // Return y value at the found t
171    bezier_sample(guess, y1, y2)
172}
173
174/// Sample a cubic bezier curve at parameter t.
175/// The curve goes through (0, 0) at t=0 and (1, 1) at t=1.
176#[inline]
177fn bezier_sample(t: f32, p1: f32, p2: f32) -> f32 {
178    // B(t) = 3(1-t)²t·P1 + 3(1-t)t²·P2 + t³
179    let t2 = t * t;
180    let t3 = t2 * t;
181    let mt = 1.0 - t;
182    let mt2 = mt * mt;
183    (3.0 * mt2 * t).mul_add(p1, (3.0 * mt * t2).mul_add(p2, t3))
184}
185
186/// Derivative of the bezier curve at parameter t.
187#[inline]
188fn bezier_derivative(t: f32, p1: f32, p2: f32) -> f32 {
189    // B'(t) = 3(1-t)²·P1 + 6(1-t)t·(P2-P1) + 3t²·(1-P2)
190    let t2 = t * t;
191    let mt = 1.0 - t;
192    let mt2 = mt * mt;
193    (3.0 * mt2).mul_add(p1, (6.0 * mt * t).mul_add(p2 - p1, 3.0 * t2 * (1.0 - p2)))
194}
195
196/// Spring easing implementation using damped harmonic oscillator.
197///
198/// The spring starts at 0 and settles toward 1, potentially overshooting
199/// and oscillating based on the stiffness and damping parameters.
200fn spring_ease(t: f32, stiffness: f32, damping: f32) -> f32 {
201    if t <= 0.0 {
202        return 0.0;
203    }
204    if t >= 1.0 {
205        return 1.0;
206    }
207    if !stiffness.is_finite() || !damping.is_finite() || stiffness <= 0.0 || damping < 0.0 {
208        // Invalid spring parameters should be rejected by constructors, but keep
209        // easing numerically stable for deserialized or externally-provided data.
210        return t;
211    }
212
213    // Damped harmonic oscillator
214    // x(t) = 1 - e^(-ζωt) * (cos(ωd*t) + (ζω/ωd)*sin(ωd*t))
215    // where ω = sqrt(stiffness), ζ = damping / (2*sqrt(stiffness))
216    // ωd = ω * sqrt(1 - ζ²) for underdamped case
217
218    let omega = stiffness.sqrt();
219    let zeta = damping / (2.0 * omega);
220
221    if zeta >= 1.0 {
222        // Critically damped or overdamped - no oscillation
223        let decay = (-omega * zeta * t).exp();
224        decay.mul_add(-(omega * zeta).mul_add(t, 1.0), 1.0)
225    } else {
226        // Underdamped - oscillates
227        let omega_d = omega * zeta.mul_add(-zeta, 1.0).sqrt();
228        let decay = (-zeta * omega * t).exp();
229        let cos_part = (omega_d * t).cos();
230        let sin_part = (zeta * omega / omega_d) * (omega_d * t).sin();
231        decay.mul_add(-(cos_part + sin_part), 1.0)
232    }
233}
234
235/// Trait for types that can be linearly interpolated.
236///
237/// This is used by the animation system to interpolate between values.
238/// Most numeric types implement this automatically via the blanket impl.
239pub trait Interpolatable: Clone {
240    /// Linear interpolation: `self + (other - self) * t`
241    #[must_use]
242    fn lerp(&self, other: &Self, t: f32) -> Self;
243}
244
245// Implement for f32
246impl Interpolatable for f32 {
247    fn lerp(&self, other: &Self, t: f32) -> Self {
248        self + (other - self) * t
249    }
250}
251
252// Implement for f64
253impl Interpolatable for f64 {
254    fn lerp(&self, other: &Self, t: f32) -> Self {
255        self + (other - self) * Self::from(t)
256    }
257}
258
259// Implement for tuples
260impl<A: Interpolatable, B: Interpolatable> Interpolatable for (A, B) {
261    fn lerp(&self, other: &Self, t: f32) -> Self {
262        (self.0.lerp(&other.0, t), self.1.lerp(&other.1, t))
263    }
264}
265
266impl<A: Interpolatable, B: Interpolatable, C: Interpolatable> Interpolatable for (A, B, C) {
267    fn lerp(&self, other: &Self, t: f32) -> Self {
268        (
269            self.0.lerp(&other.0, t),
270            self.1.lerp(&other.1, t),
271            self.2.lerp(&other.2, t),
272        )
273    }
274}
275
276impl<A: Interpolatable, B: Interpolatable, C: Interpolatable, D: Interpolatable> Interpolatable
277    for (A, B, C, D)
278{
279    fn lerp(&self, other: &Self, t: f32) -> Self {
280        (
281            self.0.lerp(&other.0, t),
282            self.1.lerp(&other.1, t),
283            self.2.lerp(&other.2, t),
284            self.3.lerp(&other.3, t),
285        )
286    }
287}
288
289// Implement for arrays
290impl<T: Interpolatable + Copy, const N: usize> Interpolatable for [T; N] {
291    fn lerp(&self, other: &Self, t: f32) -> Self {
292        let mut result = *self;
293        for i in 0..N {
294            result[i] = self[i].lerp(&other[i], t);
295        }
296        result
297    }
298}
299
300/// Animation segment with duration and easing curve.
301///
302/// Used to build multi-segment animations where different parts of the
303/// animation can have different easing curves.
304#[derive(Debug, Clone, PartialEq)]
305#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
306pub struct AnimationSegment {
307    /// Duration of this segment.
308    pub duration: Duration,
309    /// Easing curve for this segment.
310    pub curve: EasingCurve,
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn test_linear_easing() {
319        let curve = EasingCurve::LINEAR;
320        assert!((curve.ease(0.0) - 0.0).abs() < 0.001);
321        assert!((curve.ease(0.5) - 0.5).abs() < 0.001);
322        assert!((curve.ease(1.0) - 1.0).abs() < 0.001);
323    }
324
325    #[test]
326    fn test_ease_in() {
327        let curve = EasingCurve::EASE_IN;
328        assert!((curve.ease(0.0) - 0.0).abs() < 0.001);
329        assert!((curve.ease(1.0) - 1.0).abs() < 0.001);
330        // Ease-in should be slower at the start
331        assert!(curve.ease(0.5) < 0.5);
332    }
333
334    #[test]
335    fn test_ease_out() {
336        let curve = EasingCurve::EASE_OUT;
337        assert!((curve.ease(0.0) - 0.0).abs() < 0.001);
338        assert!((curve.ease(1.0) - 1.0).abs() < 0.001);
339        // Ease-out should be faster at the start
340        assert!(curve.ease(0.5) > 0.5);
341    }
342
343    #[test]
344    fn test_ease_in_out() {
345        let curve = EasingCurve::EASE_IN_OUT;
346        assert!((curve.ease(0.0) - 0.0).abs() < 0.001);
347        assert!((curve.ease(1.0) - 1.0).abs() < 0.001);
348        // Ease-in-out should be roughly 0.5 at 0.5
349        assert!((curve.ease(0.5) - 0.5).abs() < 0.1);
350    }
351
352    #[test]
353    fn test_spring_settles_to_one() {
354        let curve = EasingCurve::spring(100.0, 10.0);
355        assert!((curve.ease(0.0) - 0.0).abs() < 0.001);
356        assert!((curve.ease(1.0) - 1.0).abs() < 0.001);
357    }
358
359    #[test]
360    fn test_bezier_solver_handles_extreme_control_points() {
361        let curve = EasingCurve::bezier(0.0, 1.0, 1.0, 0.0);
362        for step in 0_u16..=100 {
363            let t = f32::from(step) / 100.0;
364            let eased = curve.ease(t);
365            assert!(eased.is_finite(), "eased must be finite at t={t}");
366            assert!(
367                (0.0..=1.0).contains(&eased),
368                "eased out of range at t={t}: {eased}"
369            );
370        }
371    }
372
373    #[test]
374    fn test_f32_lerp() {
375        let a = 0.0_f32;
376        let b = 10.0_f32;
377        assert!((a.lerp(&b, 0.0) - 0.0).abs() < 0.001);
378        assert!((a.lerp(&b, 0.5) - 5.0).abs() < 0.001);
379        assert!((a.lerp(&b, 1.0) - 10.0).abs() < 0.001);
380    }
381
382    #[test]
383    fn test_tuple_lerp() {
384        let a = (0.0_f32, 0.0_f32);
385        let b = (10.0_f32, 20.0_f32);
386        let mid = a.lerp(&b, 0.5);
387        assert!((mid.0 - 5.0).abs() < 0.001);
388        assert!((mid.1 - 10.0).abs() < 0.001);
389    }
390}