Skip to main content

oxframe_core/
lerp.rs

1use kurbo::{Affine, Point, Vec2};
2
3use crate::Color;
4
5/// Linear interpolation; `t` is already eased and may leave `[0, 1]`
6/// (e.g. with back easings).
7pub trait Lerp: Sized {
8    /// Value at `t` between `self` (0) and `to` (1); `t` may leave 0..=1.
9    fn lerp(&self, to: &Self, t: f64) -> Self;
10}
11
12impl Lerp for f64 {
13    fn lerp(&self, to: &Self, t: f64) -> Self {
14        self + (to - self) * t
15    }
16}
17
18impl Lerp for f32 {
19    fn lerp(&self, to: &Self, t: f64) -> Self {
20        self + (to - self) * t as f32
21    }
22}
23
24impl Lerp for Vec2 {
25    fn lerp(&self, to: &Self, t: f64) -> Self {
26        Vec2::new(self.x.lerp(&to.x, t), self.y.lerp(&to.y, t))
27    }
28}
29
30impl Lerp for Point {
31    fn lerp(&self, to: &Self, t: f64) -> Self {
32        Point::new(self.x.lerp(&to.x, t), self.y.lerp(&to.y, t))
33    }
34}
35
36/// Interpolates in OKLab so that midpoints keep perceived lightness.
37impl Lerp for Color {
38    fn lerp(&self, to: &Self, t: f64) -> Self {
39        if self == to {
40            return *self;
41        }
42        let a = self.to_oklab();
43        let b = to.to_oklab();
44        let t32 = t as f32;
45        let mix = |i: usize| a[i] + (b[i] - a[i]) * t32;
46        Color::from_oklab(
47            [mix(0), mix(1), mix(2)],
48            self.a.lerp(&to.a, t).clamp(0.0, 1.0),
49        )
50    }
51}
52
53impl<T: Lerp> Lerp for Option<T>
54where
55    T: Clone,
56{
57    /// `None` on either side snaps at the midpoint.
58    fn lerp(&self, to: &Self, t: f64) -> Self {
59        match (self, to) {
60            (Some(a), Some(b)) => Some(a.lerp(b, t)),
61            _ if t < 0.5 => self.clone(),
62            _ => to.clone(),
63        }
64    }
65}
66
67/// Element-wise when lengths match; otherwise snaps at the midpoint.
68impl<T: Lerp + Clone> Lerp for Vec<T> {
69    fn lerp(&self, to: &Self, t: f64) -> Self {
70        if self.len() == to.len() {
71            self.iter().zip(to).map(|(a, b)| a.lerp(b, t)).collect()
72        } else if t < 0.5 {
73            self.clone()
74        } else {
75            to.clone()
76        }
77    }
78}
79
80/// Typewriter: keeps the common prefix, deletes the rest of `self` during
81/// the first part of the tween and types the rest of `to` afterwards. The
82/// time split is proportional to the number of characters.
83impl Lerp for String {
84    fn lerp(&self, to: &Self, t: f64) -> Self {
85        if t <= 0.0 {
86            return self.clone();
87        }
88        if t >= 1.0 {
89            return to.clone();
90        }
91        let a: Vec<char> = self.chars().collect();
92        let b: Vec<char> = to.chars().collect();
93        let common = a.iter().zip(&b).take_while(|(x, y)| x == y).count();
94        let (del, add) = (a.len() - common, b.len() - common);
95        let total = del + add;
96        if total == 0 {
97            return to.clone();
98        }
99        let steps = (t * total as f64).floor() as usize;
100        if steps < del {
101            a[..a.len() - steps].iter().collect()
102        } else {
103            b[..common + (steps - del)].iter().collect()
104        }
105    }
106}
107
108/// Decomposed 2D transform. Interpolates component-wise, so rotations turn
109/// instead of shearing through the matrix midpoint.
110#[derive(Debug, Clone, Copy, PartialEq)]
111pub struct Transform {
112    /// Offset.
113    pub translation: Vec2,
114    /// Radians, clockwise in screen space (y down).
115    pub rotation: f64,
116    /// Scale per axis.
117    pub scale: Vec2,
118}
119
120impl Default for Transform {
121    fn default() -> Self {
122        Self::IDENTITY
123    }
124}
125
126impl Transform {
127    /// No translation, rotation or scaling.
128    pub const IDENTITY: Transform = Transform {
129        translation: Vec2::ZERO,
130        rotation: 0.0,
131        scale: Vec2::new(1.0, 1.0),
132    };
133
134    /// The equivalent matrix: translate · rotate · scale.
135    pub fn to_affine(&self) -> Affine {
136        Affine::translate(self.translation)
137            * Affine::rotate(self.rotation)
138            * Affine::scale_non_uniform(self.scale.x, self.scale.y)
139    }
140}
141
142impl Lerp for Transform {
143    fn lerp(&self, to: &Self, t: f64) -> Self {
144        Transform {
145            translation: Lerp::lerp(&self.translation, &to.translation, t),
146            rotation: self.rotation.lerp(&to.rotation, t),
147            scale: Lerp::lerp(&self.scale, &to.scale, t),
148        }
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn scalars_and_vectors() {
158        assert_eq!(2.0f64.lerp(&4.0, 0.5), 3.0);
159        assert_eq!(
160            Lerp::lerp(&Vec2::new(0.0, 10.0), &Vec2::new(10.0, 0.0), 0.25),
161            Vec2::new(2.5, 7.5)
162        );
163    }
164
165    #[test]
166    fn color_endpoints_and_midpoint() {
167        let red = Color::from_hex("#ff0000").unwrap();
168        let blue = Color::from_hex("#0000ff").unwrap();
169        assert_eq!(red.lerp(&blue, 0.0).to_rgba8(), red.to_rgba8());
170        assert_eq!(red.lerp(&blue, 1.0).to_rgba8(), blue.to_rgba8());
171        let mid = red.lerp(&blue, 0.5).to_oklab();
172        let (a, b) = (red.to_oklab(), blue.to_oklab());
173        assert!((mid[0] - (a[0] + b[0]) / 2.0).abs() < 1e-3);
174    }
175
176    #[test]
177    fn string_typewriter() {
178        let a = String::from("Hello");
179        let b = String::from("Help me");
180        // common "Hel", delete "lo" (2), type "p me" (4): 6 steps.
181        assert_eq!(a.lerp(&b, 0.0), "Hello");
182        assert_eq!(a.lerp(&b, 1.0 / 6.0 + 1e-9), "Hell");
183        assert_eq!(a.lerp(&b, 2.0 / 6.0 + 1e-9), "Hel");
184        assert_eq!(a.lerp(&b, 3.0 / 6.0 + 1e-9), "Help");
185        assert_eq!(a.lerp(&b, 1.0), "Help me");
186        assert_eq!(String::new().lerp(&"Привет".to_string(), 0.5), "При");
187    }
188
189    #[test]
190    fn transform_rotates_through_angle() {
191        let a = Transform::IDENTITY;
192        let b = Transform {
193            rotation: std::f64::consts::PI,
194            ..a
195        };
196        let mid = a.lerp(&b, 0.5);
197        assert!((mid.rotation - std::f64::consts::FRAC_PI_2).abs() < 1e-12);
198        assert_eq!(mid.scale, Vec2::new(1.0, 1.0));
199    }
200}