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