retroglyph_ui/animate/easing.rs
1/// A normalized easing curve: reshapes a linear progress fraction (`0.0..=1.0`) into an eased
2/// one, the same named curves as CSS transitions and <https://easings.net>.
3///
4/// [`Linear`](Self::Linear) is the default. The `In` variants start slow, `Out` variants end
5/// slow, and `InOut` variants do both (matching the usual naming convention: "In" describes the
6/// *start* of the motion, not a direction).
7///
8/// [`EaseOutElastic`](Self::EaseOutElastic) and [`EaseOutBounce`](Self::EaseOutBounce) are the
9/// only curves in their families: both are used for a settle/overshoot effect at the *end* of a
10/// motion, and the in/in-out variants (the same shape mirrored to the start) are uncommon enough
11/// in practice that this curated set omits them.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
13#[non_exhaustive]
14pub enum Easing {
15 /// Constant speed: `t` unchanged.
16 ///
17 /// ```text
18 /// 1 | ,-'
19 /// | ,-'
20 /// | ,-'
21 /// | ,-'
22 /// 0 +-'------------
23 /// 0 1
24 /// ```
25 #[default]
26 Linear,
27 /// Starts slow, accelerates, quadratically.
28 ///
29 /// ```text
30 /// 1 | ,'
31 /// | ,'
32 /// | ,'
33 /// | __,-''
34 /// 0 +------''-------
35 /// 0 1
36 /// ```
37 EaseInQuad,
38 /// Starts fast, decelerates, quadratically.
39 ///
40 /// ```text
41 /// 1 | __,,-------
42 /// | ,-'
43 /// | ,'
44 /// | ,'
45 /// 0 +'---------------
46 /// 0 1
47 /// ```
48 EaseOutQuad,
49 /// Slow -> fast -> slow, quadratically.
50 ///
51 /// ```text
52 /// 1 | ___,,--
53 /// | ,-'
54 /// | ,'
55 /// | __,-'
56 /// 0 +-''-------------
57 /// 0 1
58 /// ```
59 EaseInOutQuad,
60 /// Starts slow, accelerates, cubically: a stronger version of [`EaseInQuad`](Self::EaseInQuad).
61 ///
62 /// ```text
63 /// 1 | ,'
64 /// | ,
65 /// | ,'
66 /// | ____,-'
67 /// 0 +-------''--------
68 /// 0 1
69 /// ```
70 EaseInCubic,
71 /// Starts fast, decelerates, cubically: a stronger version of [`EaseOutQuad`](Self::EaseOutQuad).
72 ///
73 /// ```text
74 /// 1 | __,,-----------
75 /// | ,'
76 /// |,
77 /// |'
78 /// 0 +-----------------
79 /// 0 1
80 /// ```
81 EaseOutCubic,
82 /// Slow -> fast -> slow, cubically: a stronger version of [`EaseInOutQuad`](Self::EaseInOutQuad).
83 ///
84 /// ```text
85 /// 1 | ___,---
86 /// | ,'
87 /// | ,
88 /// | __,'
89 /// 0 +-----''----------
90 /// 0 1
91 /// ```
92 EaseInOutCubic,
93 /// A gentle sine-shaped start.
94 ///
95 /// ```text
96 /// 1 | ,--'
97 /// | ,-'
98 /// | ,-'
99 /// | ,--'
100 /// 0 +--''-------------
101 /// 0 1
102 /// ```
103 EaseInSine,
104 /// A gentle sine-shaped end.
105 ///
106 /// ```text
107 /// 1 | ,--''''''''''''
108 /// | ,'
109 /// | ,'
110 /// |,'
111 /// 0 +-----------------
112 /// 0 1
113 /// ```
114 EaseOutSine,
115 /// A gentle sine-shaped start and end.
116 ///
117 /// ```text
118 /// 1 | ____,----
119 /// | ,'
120 /// | ,'
121 /// | __,'
122 /// 0 +--''--------------
123 /// 0 1
124 /// ```
125 EaseInOutSine,
126 /// Springs past the target and oscillates back before settling, going outside `0.0..=1.0`
127 /// for part of the curve.
128 ///
129 /// ```text
130 /// ,-.
131 /// 1 -+ \ ______________
132 /// | \ /
133 /// | `--'
134 /// 0 -+
135 /// 0 1
136 /// ```
137 EaseOutElastic,
138 /// Bounces (like a dropped ball) to a stop at the target.
139 ///
140 /// ```text
141 /// 1 | __ _ _.
142 /// | / \ / |_/ |
143 /// | / \ / |
144 /// | / \_/ |
145 /// 0 +----' |
146 /// 0 1
147 /// ```
148 EaseOutBounce,
149}
150
151impl Easing {
152 /// Applies this curve to `t` (clamped to `0.0..=1.0` first), returning the eased fraction.
153 #[must_use]
154 pub fn apply(self, t: f32) -> f32 {
155 let t = t.clamp(0.0, 1.0);
156 match self {
157 Self::Linear => t,
158 Self::EaseInQuad => t * t,
159 Self::EaseOutQuad => retroglyph_core::math::mul_add(t, -t, 2.0 * t),
160 Self::EaseInOutQuad => {
161 if t < 0.5 {
162 2.0 * t * t
163 } else {
164 let u = retroglyph_core::math::mul_add(-2.0, t, 2.0);
165 1.0 - u * u / 2.0
166 }
167 }
168 Self::EaseInCubic => t * t * t,
169 Self::EaseOutCubic => {
170 let u = 1.0 - t;
171 retroglyph_core::math::mul_add(u * u, -u, 1.0)
172 }
173 Self::EaseInOutCubic => {
174 if t < 0.5 {
175 4.0 * t * t * t
176 } else {
177 let u = retroglyph_core::math::mul_add(-2.0, t, 2.0);
178 1.0 - u * u * u / 2.0
179 }
180 }
181 Self::EaseInSine => 1.0 - retroglyph_core::math::cos(t * core::f32::consts::FRAC_PI_2),
182 Self::EaseOutSine => retroglyph_core::math::sin(t * core::f32::consts::FRAC_PI_2),
183 Self::EaseInOutSine => {
184 -(retroglyph_core::math::cos(core::f32::consts::PI * t) - 1.0) / 2.0
185 }
186 Self::EaseOutElastic => ease_out_elastic(t),
187 Self::EaseOutBounce => ease_out_bounce(t),
188 }
189 }
190}
191
192/// `2^(-10t)` scaling the sine of `(10*t - 0.75) * (2*pi/3)`, plus 1: an exponentially
193/// decaying oscillation that overshoots 1 and rings back down to settle exactly on it. See
194/// <https://easings.net/#easeOutElastic>.
195fn ease_out_elastic(t: f32) -> f32 {
196 const C4: f32 = 2.0 * core::f32::consts::PI / 3.0;
197
198 if t <= 0.0 {
199 return 0.0;
200 }
201 if t >= 1.0 {
202 return 1.0;
203 }
204 retroglyph_core::math::mul_add(
205 retroglyph_core::math::powf(2.0, -10.0 * t),
206 retroglyph_core::math::sin(retroglyph_core::math::mul_add(10.0, t, -0.75) * C4),
207 1.0,
208 )
209}
210
211/// Four piecewise quadratic segments, each bouncing to a smaller peak: see
212/// <https://easings.net/#easeOutBounce>.
213fn ease_out_bounce(t: f32) -> f32 {
214 // Standard easings.net bounce coefficients: D1 partitions t into four arcs (boundaries at
215 // 1/D1, 2/D1, 2.5/D1) and N1 = 121/16 fixes their shared steepness so each arc lands exactly
216 // on the next segment's floor. They are a matched set; changing one alone breaks continuity.
217 const N1: f32 = 7.5625;
218 const D1: f32 = 2.75;
219 if t < 1.0 / D1 {
220 N1 * t * t
221 } else if t < 2.0 / D1 {
222 let t = t - 1.5 / D1;
223 retroglyph_core::math::mul_add(N1 * t, t, 0.75)
224 } else if t < 2.5 / D1 {
225 let t = t - 2.25 / D1;
226 retroglyph_core::math::mul_add(N1 * t, t, 0.9375)
227 } else {
228 let t = t - 2.625 / D1;
229 retroglyph_core::math::mul_add(N1 * t, t, 0.984_375)
230 }
231}
232
233#[cfg(test)]
234#[allow(clippy::float_cmp)] // exact float equality is intentional throughout: every value
235// under test here is produced by simple, exactly-representable arithmetic (0.0, 1.0, halves),
236// not an accumulated or transcendental result where an epsilon comparison would be appropriate.
237mod tests {
238 use super::*;
239
240 #[test]
241 fn linear_is_identity() {
242 assert_eq!(Easing::Linear.apply(0.0), 0.0);
243 assert_eq!(Easing::Linear.apply(0.5), 0.5);
244 assert_eq!(Easing::Linear.apply(1.0), 1.0);
245 }
246
247 #[test]
248 fn every_curve_starts_at_0_and_ends_at_1() {
249 for easing in [
250 Easing::Linear,
251 Easing::EaseInQuad,
252 Easing::EaseOutQuad,
253 Easing::EaseInOutQuad,
254 Easing::EaseInCubic,
255 Easing::EaseOutCubic,
256 Easing::EaseInOutCubic,
257 Easing::EaseInSine,
258 Easing::EaseOutSine,
259 Easing::EaseInOutSine,
260 Easing::EaseOutElastic,
261 Easing::EaseOutBounce,
262 ] {
263 assert!(
264 (easing.apply(0.0) - 0.0).abs() < 1e-5,
265 "{easing:?} should start at 0"
266 );
267 assert!(
268 (easing.apply(1.0) - 1.0).abs() < 1e-5,
269 "{easing:?} should end at 1"
270 );
271 }
272 }
273
274 #[test]
275 fn ease_in_quad_starts_slower_than_linear() {
276 // "In" curves front-load less motion than linear during the first half.
277 assert!(Easing::EaseInQuad.apply(0.25) < 0.25);
278 }
279
280 #[test]
281 fn ease_out_quad_starts_faster_than_linear() {
282 assert!(Easing::EaseOutQuad.apply(0.25) > 0.25);
283 }
284
285 #[test]
286 fn out_of_range_input_is_clamped() {
287 assert_eq!(Easing::Linear.apply(-1.0), 0.0);
288 assert_eq!(Easing::Linear.apply(2.0), 1.0);
289 }
290
291 #[test]
292 fn ease_out_bounce_covers_its_middle_two_segments() {
293 // `every_curve_starts_at_0_and_ends_at_1` only samples t=0.0/t=1.0, which land in
294 // `ease_out_bounce`'s first and last piecewise segments (boundaries at 1/D1 ~= 0.364,
295 // 2/D1 ~= 0.727, 2.5/D1 ~= 0.909); these two values land in the second and third
296 // segments instead, so their `retroglyph_core::math::mul_add` calls get exercised too.
297 assert!((Easing::EaseOutBounce.apply(0.5) - 0.765_625).abs() < 1e-5);
298 assert!((Easing::EaseOutBounce.apply(0.8) - 0.94).abs() < 1e-5);
299 }
300
301 #[test]
302 #[allow(clippy::cast_precision_loss)] // i in 0..100 is always exactly representable in f32
303 fn elastic_overshoots_past_the_target() {
304 // The defining feature of an elastic curve: some t produces a value outside 0..=1.
305 let overshoots = (0..100)
306 .map(|i| Easing::EaseOutElastic.apply(i as f32 / 100.0))
307 .any(|v| !(0.0..=1.0).contains(&v));
308 assert!(overshoots);
309 }
310}