Skip to main content

telar_motion_core/
lerp.rs

1use geometry_core::{BorderRadius, Color, Point, Rect, Transform};
2
3/// Interpolation plus the minimal vector-space operations the engine needs.
4///
5/// `lerp` powers tweens; `add`/`sub`/`scale`/`zero`/`magnitude_sq` let the spring
6/// integrate in value space (component-wise). Keeping both on one trait means
7/// `Animated<T>` needs only `T: Lerp` for tweens and springs alike. Spring velocity
8/// is stored as a `T` in value space, so f32 springs are exact physical springs and
9/// vector types integrate per component; `Color` springs therefore run in
10/// sRGB-component space while `Color` tweens use the perceptual Oklch path in `lerp`.
11pub trait Lerp: Clone {
12    /// Interpolate between `self` (t=0) and `other` (t=1).
13    fn lerp(&self, other: &Self, t: f32) -> Self;
14
15    /// Component-wise sum; the spring accumulates displacement and velocity here.
16    fn add(&self, other: &Self) -> Self;
17
18    /// Component-wise difference (displacement from `other` toward `self`).
19    fn sub(&self, other: &Self) -> Self;
20
21    /// Component-wise scalar multiply.
22    fn scale(&self, factor: f32) -> Self;
23
24    /// Additive identity, used as the initial spring velocity.
25    fn zero() -> Self;
26
27    /// Squared Euclidean magnitude, used for spring settle and change thresholds.
28    fn magnitude_sq(&self) -> f32;
29}
30
31impl Lerp for f32 {
32    fn lerp(&self, other: &Self, t: f32) -> Self {
33        self + (other - self) * t
34    }
35    fn add(&self, other: &Self) -> Self {
36        self + other
37    }
38    fn sub(&self, other: &Self) -> Self {
39        self - other
40    }
41    fn scale(&self, factor: f32) -> Self {
42        self * factor
43    }
44    fn zero() -> Self {
45        0.0
46    }
47    fn magnitude_sq(&self) -> f32 {
48        self * self
49    }
50}
51
52impl Lerp for Point {
53    fn lerp(&self, other: &Self, t: f32) -> Self {
54        Point::new(self.x.lerp(&other.x, t), self.y.lerp(&other.y, t))
55    }
56    fn add(&self, other: &Self) -> Self {
57        Point::new(self.x + other.x, self.y + other.y)
58    }
59    fn sub(&self, other: &Self) -> Self {
60        Point::new(self.x - other.x, self.y - other.y)
61    }
62    fn scale(&self, factor: f32) -> Self {
63        Point::new(self.x * factor, self.y * factor)
64    }
65    fn zero() -> Self {
66        Point::new(0.0, 0.0)
67    }
68    fn magnitude_sq(&self) -> f32 {
69        self.x * self.x + self.y * self.y
70    }
71}
72
73impl Lerp for Rect {
74    fn lerp(&self, other: &Self, t: f32) -> Self {
75        Rect::new(
76            self.x.lerp(&other.x, t),
77            self.y.lerp(&other.y, t),
78            self.width.lerp(&other.width, t),
79            self.height.lerp(&other.height, t),
80        )
81    }
82    fn add(&self, other: &Self) -> Self {
83        Rect::new(
84            self.x + other.x,
85            self.y + other.y,
86            self.width + other.width,
87            self.height + other.height,
88        )
89    }
90    fn sub(&self, other: &Self) -> Self {
91        Rect::new(
92            self.x - other.x,
93            self.y - other.y,
94            self.width - other.width,
95            self.height - other.height,
96        )
97    }
98    fn scale(&self, factor: f32) -> Self {
99        Rect::new(
100            self.x * factor,
101            self.y * factor,
102            self.width * factor,
103            self.height * factor,
104        )
105    }
106    fn zero() -> Self {
107        Rect::new(0.0, 0.0, 0.0, 0.0)
108    }
109    fn magnitude_sq(&self) -> f32 {
110        self.x * self.x + self.y * self.y + self.width * self.width + self.height * self.height
111    }
112}
113
114impl Lerp for Transform {
115    fn lerp(&self, other: &Self, t: f32) -> Self {
116        Transform {
117            a: self.a.lerp(&other.a, t),
118            b: self.b.lerp(&other.b, t),
119            c: self.c.lerp(&other.c, t),
120            d: self.d.lerp(&other.d, t),
121            e: self.e.lerp(&other.e, t),
122            f: self.f.lerp(&other.f, t),
123        }
124    }
125    fn add(&self, other: &Self) -> Self {
126        Transform {
127            a: self.a + other.a,
128            b: self.b + other.b,
129            c: self.c + other.c,
130            d: self.d + other.d,
131            e: self.e + other.e,
132            f: self.f + other.f,
133        }
134    }
135    fn sub(&self, other: &Self) -> Self {
136        Transform {
137            a: self.a - other.a,
138            b: self.b - other.b,
139            c: self.c - other.c,
140            d: self.d - other.d,
141            e: self.e - other.e,
142            f: self.f - other.f,
143        }
144    }
145    fn scale(&self, factor: f32) -> Self {
146        Transform {
147            a: self.a * factor,
148            b: self.b * factor,
149            c: self.c * factor,
150            d: self.d * factor,
151            e: self.e * factor,
152            f: self.f * factor,
153        }
154    }
155    fn zero() -> Self {
156        Transform {
157            a: 0.0,
158            b: 0.0,
159            c: 0.0,
160            d: 0.0,
161            e: 0.0,
162            f: 0.0,
163        }
164    }
165    fn magnitude_sq(&self) -> f32 {
166        self.a * self.a
167            + self.b * self.b
168            + self.c * self.c
169            + self.d * self.d
170            + self.e * self.e
171            + self.f * self.f
172    }
173}
174
175impl Lerp for BorderRadius {
176    fn lerp(&self, other: &Self, t: f32) -> Self {
177        BorderRadius {
178            top_left: self.top_left.lerp(&other.top_left, t),
179            top_right: self.top_right.lerp(&other.top_right, t),
180            bottom_right: self.bottom_right.lerp(&other.bottom_right, t),
181            bottom_left: self.bottom_left.lerp(&other.bottom_left, t),
182        }
183    }
184    fn add(&self, other: &Self) -> Self {
185        BorderRadius {
186            top_left: self.top_left + other.top_left,
187            top_right: self.top_right + other.top_right,
188            bottom_right: self.bottom_right + other.bottom_right,
189            bottom_left: self.bottom_left + other.bottom_left,
190        }
191    }
192    fn sub(&self, other: &Self) -> Self {
193        BorderRadius {
194            top_left: self.top_left - other.top_left,
195            top_right: self.top_right - other.top_right,
196            bottom_right: self.bottom_right - other.bottom_right,
197            bottom_left: self.bottom_left - other.bottom_left,
198        }
199    }
200    fn scale(&self, factor: f32) -> Self {
201        BorderRadius {
202            top_left: self.top_left * factor,
203            top_right: self.top_right * factor,
204            bottom_right: self.bottom_right * factor,
205            bottom_left: self.bottom_left * factor,
206        }
207    }
208    fn zero() -> Self {
209        BorderRadius::all(0.0)
210    }
211    fn magnitude_sq(&self) -> f32 {
212        self.top_left * self.top_left
213            + self.top_right * self.top_right
214            + self.bottom_right * self.bottom_right
215            + self.bottom_left * self.bottom_left
216    }
217}
218
219// Chroma below this (Oklch C) is treated as achromatic; such an endpoint carries the other's hue.
220const ACHROMATIC_EPS: f32 = 1e-4;
221
222// Interpolate hue along the shortest arc, carrying the chromatic endpoint's hue when one side is gray.
223fn lerp_hue(h1: f32, c1: f32, h2: f32, c2: f32, t: f32) -> f32 {
224    let gray1 = c1 < ACHROMATIC_EPS;
225    let gray2 = c2 < ACHROMATIC_EPS;
226    if gray1 && gray2 {
227        return h1;
228    }
229    if gray1 {
230        return h2;
231    }
232    if gray2 {
233        return h1;
234    }
235    let mut delta = (h2 - h1).rem_euclid(360.0);
236    if delta > 180.0 {
237        delta -= 360.0;
238    }
239    (h1 + delta * t).rem_euclid(360.0)
240}
241
242impl Lerp for Color {
243    fn lerp(&self, other: &Self, t: f32) -> Self {
244        // Endpoints return exactly to avoid Oklch round-trip drift at t=0/1.
245        if t <= 0.0 {
246            return *self;
247        }
248        if t >= 1.0 {
249            return *other;
250        }
251        let (l1, c1, h1, a1) = self.to_oklcha();
252        let (l2, c2, h2, a2) = other.to_oklcha();
253        let l = l1.lerp(&l2, t);
254        let c = c1.lerp(&c2, t);
255        let a = a1.lerp(&a2, t);
256        let h = lerp_hue(h1, c1, h2, c2, t);
257        Color::from_oklcha(l, c, h, a)
258    }
259    // Spring integration for Color runs in sRGB-component space (see trait docs).
260    fn add(&self, other: &Self) -> Self {
261        Color::rgba(
262            self.r + other.r,
263            self.g + other.g,
264            self.b + other.b,
265            self.a + other.a,
266        )
267    }
268    fn sub(&self, other: &Self) -> Self {
269        Color::rgba(
270            self.r - other.r,
271            self.g - other.g,
272            self.b - other.b,
273            self.a - other.a,
274        )
275    }
276    fn scale(&self, factor: f32) -> Self {
277        Color::rgba(
278            self.r * factor,
279            self.g * factor,
280            self.b * factor,
281            self.a * factor,
282        )
283    }
284    fn zero() -> Self {
285        Color::rgba(0.0, 0.0, 0.0, 0.0)
286    }
287    fn magnitude_sq(&self) -> f32 {
288        self.r * self.r + self.g * self.g + self.b * self.b + self.a * self.a
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn f32_lerp_midpoint() {
298        assert!((2.0f32.lerp(&4.0, 0.5) - 3.0).abs() < 1e-6);
299    }
300
301    #[test]
302    fn point_lerp_is_component_wise() {
303        let mid = Point::new(0.0, 10.0).lerp(&Point::new(4.0, 20.0), 0.5);
304        assert_eq!(mid, Point::new(2.0, 15.0));
305    }
306
307    #[test]
308    fn rect_lerp_is_component_wise() {
309        let mid = Rect::new(0.0, 0.0, 10.0, 20.0).lerp(&Rect::new(2.0, 4.0, 30.0, 40.0), 0.5);
310        assert_eq!(mid, Rect::new(1.0, 2.0, 20.0, 30.0));
311    }
312
313    #[test]
314    fn border_radius_lerp_is_component_wise() {
315        let mid = BorderRadius::all(0.0).lerp(&BorderRadius::all(8.0), 0.25);
316        assert_eq!(mid, BorderRadius::all(2.0));
317    }
318
319    #[test]
320    fn color_lerp_endpoints_are_exact() {
321        let red = Color::RED;
322        let green = Color::GREEN;
323        assert_eq!(red.lerp(&green, 0.0), red);
324        assert_eq!(red.lerp(&green, 1.0), green);
325    }
326
327    #[test]
328    fn color_lerp_gray_to_gray_stays_neutral() {
329        let mid = Color::rgb(0.2, 0.2, 0.2).lerp(&Color::rgb(0.8, 0.8, 0.8), 0.5);
330        assert!((mid.r - mid.g).abs() < 2e-3, "{mid:?}");
331        assert!((mid.g - mid.b).abs() < 2e-3, "{mid:?}");
332    }
333
334    #[test]
335    fn color_lerp_hue_takes_short_arc() {
336        // Red (h~29 deg) to yellow (h~110 deg): the midpoint hue must land in the orange arc between them, not swing the long way through blue.
337        let mid = Color::RED.lerp(&Color::rgb(1.0, 1.0, 0.0), 0.5);
338        let (_, chroma, hue, _) = mid.to_oklcha();
339        assert!(
340            chroma > ACHROMATIC_EPS,
341            "midpoint unexpectedly gray: {mid:?}"
342        );
343        assert!(
344            (29.0..=110.0).contains(&hue),
345            "hue {hue} left the short arc"
346        );
347    }
348
349    #[test]
350    fn color_lerp_achromatic_endpoint_carries_the_other_hue() {
351        // Gray -> red: intermediate stays on red's hue instead of a spurious swing.
352        let mid = Color::rgb(0.5, 0.5, 0.5).lerp(&Color::RED, 0.5);
353        let (_, chroma, hue, _) = mid.to_oklcha();
354        let (_, _, red_hue, _) = Color::RED.to_oklcha();
355        assert!(chroma > ACHROMATIC_EPS);
356        assert!(
357            (hue - red_hue).abs() < 1.0,
358            "hue {hue} != red hue {red_hue}"
359        );
360    }
361}