Skip to main content

proof_engine/tween/
keyframe.rs

1//! Keyframe tracks — time-stamped values with interpolated playback.
2//!
3//! A `KeyframeTrack<T>` stores a list of (time, value) pairs and provides
4//! continuous interpolation between them using configurable easing per segment.
5//!
6//! This is the foundation for cinematic cutscenes, camera paths, and
7//! math-driven animation curves exported from design tools.
8
9use super::{Lerp, Easing};
10use glam::{Vec2, Vec3, Vec4};
11
12// ── Keyframe ──────────────────────────────────────────────────────────────────
13
14/// A single keyframe: a value at a specific time.
15#[derive(Clone, Debug)]
16pub struct Keyframe<T: Lerp + Clone + std::fmt::Debug> {
17    pub time:  f32,
18    pub value: T,
19    /// Easing applied from this keyframe to the next.
20    pub easing_out: Easing,
21    /// Optional tangent scale for Hermite interpolation (1.0 = auto).
22    pub tangent: f32,
23}
24
25impl<T: Lerp + Clone + std::fmt::Debug> Keyframe<T> {
26    pub fn new(time: f32, value: T) -> Self {
27        Self { time, value, easing_out: Easing::EaseInOutCubic, tangent: 1.0 }
28    }
29
30    pub fn with_easing(mut self, easing: Easing) -> Self {
31        self.easing_out = easing;
32        self
33    }
34
35    pub fn linear(time: f32, value: T) -> Self {
36        Self { time, value, easing_out: Easing::Linear, tangent: 1.0 }
37    }
38
39    pub fn step(time: f32, value: T) -> Self {
40        Self { time, value, easing_out: Easing::Step, tangent: 0.0 }
41    }
42}
43
44// ── KeyframeTrack ─────────────────────────────────────────────────────────────
45
46/// An ordered list of keyframes with time-based interpolation.
47///
48/// Automatically sorts keyframes by time on construction and rebuilds
49/// segment lookup on insertion.
50pub struct KeyframeTrack<T: Lerp + Clone + std::fmt::Debug> {
51    pub frames:    Vec<Keyframe<T>>,
52    pub extrapolate: ExtrapolateMode,
53}
54
55/// How to handle time values outside the keyframe range.
56#[derive(Clone, Copy, Debug, PartialEq)]
57pub enum ExtrapolateMode {
58    /// Clamp to first/last keyframe value.
59    Clamp,
60    /// Loop the animation back to the start.
61    Loop,
62    /// Ping-pong between start and end.
63    PingPong,
64    /// Linearly extrapolate using the first/last two keyframes.
65    Linear,
66}
67
68impl<T: Lerp + Clone + std::fmt::Debug> KeyframeTrack<T> {
69    pub fn new(extrapolate: ExtrapolateMode) -> Self {
70        Self { frames: Vec::new(), extrapolate }
71    }
72
73    /// Create a track from a list of keyframes, sorted by time.
74    pub fn from_keyframes(mut frames: Vec<Keyframe<T>>, extrapolate: ExtrapolateMode) -> Self {
75        frames.sort_by(|a, b| a.time.partial_cmp(&b.time).unwrap_or(std::cmp::Ordering::Equal));
76        Self { frames, extrapolate }
77    }
78
79    /// Insert a keyframe, keeping the track sorted.
80    pub fn insert(&mut self, frame: Keyframe<T>) {
81        let pos = self.frames.partition_point(|f| f.time < frame.time);
82        self.frames.insert(pos, frame);
83    }
84
85    pub fn is_empty(&self) -> bool { self.frames.is_empty() }
86
87    /// Duration from first to last keyframe.
88    pub fn duration(&self) -> f32 {
89        if self.frames.len() < 2 { return 0.0; }
90        self.frames.last().unwrap().time - self.frames.first().unwrap().time
91    }
92
93    /// Start time of the track.
94    pub fn start_time(&self) -> f32 {
95        self.frames.first().map(|f| f.time).unwrap_or(0.0)
96    }
97
98    /// End time of the track.
99    pub fn end_time(&self) -> f32 {
100        self.frames.last().map(|f| f.time).unwrap_or(0.0)
101    }
102
103    /// Evaluate the track at the given time.
104    pub fn evaluate(&self, time: f32) -> T {
105        if self.frames.is_empty() { return T::zero(); }
106        if self.frames.len() == 1 { return self.frames[0].value.clone(); }
107
108        let (start, end) = (self.start_time(), self.end_time());
109        let span = (end - start).max(f32::EPSILON);
110
111        let local_t = match self.extrapolate {
112            ExtrapolateMode::Clamp => time.clamp(start, end),
113            ExtrapolateMode::Loop  => {
114                let offset = time - start;
115                start + ((offset % span) + span) % span
116            }
117            ExtrapolateMode::PingPong => {
118                let offset = (time - start) / span;
119                let cycle = offset.floor() as u32;
120                let frac  = offset - offset.floor();
121                start + if cycle % 2 == 0 { frac * span } else { (1.0 - frac) * span }
122            }
123            ExtrapolateMode::Linear => time,
124        };
125
126        // Binary search for the segment containing local_t
127        let right_idx = self.frames.partition_point(|f| f.time <= local_t);
128
129        if right_idx == 0 {
130            // Before first keyframe — return first value (or extrapolate)
131            if self.extrapolate == ExtrapolateMode::Linear && self.frames.len() >= 2 {
132                let a = &self.frames[0];
133                let b = &self.frames[1];
134                let seg = (b.time - a.time).max(f32::EPSILON);
135                let t = (local_t - a.time) / seg;
136                return T::lerp(&a.value, &b.value, t);
137            }
138            return self.frames[0].value.clone();
139        }
140
141        if right_idx >= self.frames.len() {
142            // After last keyframe
143            if self.extrapolate == ExtrapolateMode::Linear && self.frames.len() >= 2 {
144                let n = self.frames.len();
145                let a = &self.frames[n - 2];
146                let b = &self.frames[n - 1];
147                let seg = (b.time - a.time).max(f32::EPSILON);
148                let t = (local_t - a.time) / seg;
149                return T::lerp(&a.value, &b.value, t);
150            }
151            return self.frames.last().unwrap().value.clone();
152        }
153
154        // Interpolate between frames[right_idx - 1] and frames[right_idx]
155        let left  = &self.frames[right_idx - 1];
156        let right = &self.frames[right_idx];
157        let seg_duration = (right.time - left.time).max(f32::EPSILON);
158        let t = ((local_t - left.time) / seg_duration).clamp(0.0, 1.0);
159        let curved_t = left.easing_out.apply(t);
160        T::lerp(&left.value, &right.value, curved_t)
161    }
162
163    /// Return all times where the value reaches (approximately) a given threshold.
164    /// Used for detecting when an animation crosses a boundary.
165    pub fn crossing_times(&self, threshold: f32, steps_per_segment: u32) -> Vec<f32>
166    where T: Into<f32> + Copy,
167    {
168        let mut crossings = Vec::new();
169        if self.frames.len() < 2 { return crossings; }
170
171        for w in self.frames.windows(2) {
172            let a = &w[0];
173            let b = &w[1];
174            let seg_duration = (b.time - a.time).max(f32::EPSILON);
175            let dt = seg_duration / steps_per_segment as f32;
176
177            let mut prev_val: f32 = a.value.clone().into();
178            let mut prev_t = a.time;
179
180            for s in 1..=steps_per_segment {
181                let t = a.time + s as f32 * dt;
182                let v: f32 = self.evaluate(t).into();
183                if (prev_val < threshold) != (v < threshold) {
184                    // Linearly interpolate crossing time
185                    let cross_frac = (threshold - prev_val) / (v - prev_val).max(f32::EPSILON);
186                    crossings.push(prev_t + cross_frac * dt);
187                }
188                prev_val = v;
189                prev_t = t;
190            }
191        }
192        crossings
193    }
194
195    /// Sample the track at uniform time steps and return the values.
196    pub fn bake(&self, step: f32) -> Vec<(f32, T)> {
197        if self.frames.is_empty() { return Vec::new(); }
198        let start = self.start_time();
199        let end   = self.end_time();
200        let mut result = Vec::new();
201        let mut t = start;
202        while t <= end + f32::EPSILON {
203            result.push((t, self.evaluate(t)));
204            t += step;
205        }
206        result
207    }
208}
209
210// ── MultiTrack ────────────────────────────────────────────────────────────────
211
212/// A collection of named `KeyframeTrack<f32>` tracks played together.
213///
214/// Ideal for driving multiple engine parameters from a single timeline clock.
215pub struct MultiTrack {
216    pub tracks:  std::collections::HashMap<String, KeyframeTrack<f32>>,
217    pub elapsed: f32,
218    pub looping: bool,
219    duration:    f32,
220}
221
222impl MultiTrack {
223    pub fn new(looping: bool) -> Self {
224        Self { tracks: std::collections::HashMap::new(), elapsed: 0.0, looping, duration: 0.0 }
225    }
226
227    pub fn add(&mut self, name: impl Into<String>, track: KeyframeTrack<f32>) {
228        self.duration = self.duration.max(track.end_time());
229        self.tracks.insert(name.into(), track);
230    }
231
232    pub fn tick(&mut self, dt: f32) {
233        self.elapsed += dt;
234        if self.looping && self.elapsed > self.duration {
235            self.elapsed -= self.duration;
236        }
237    }
238
239    pub fn get(&self, name: &str) -> f32 {
240        self.tracks.get(name).map(|t| t.evaluate(self.elapsed)).unwrap_or(0.0)
241    }
242
243    pub fn is_done(&self) -> bool {
244        !self.looping && self.elapsed >= self.duration
245    }
246
247    pub fn reset(&mut self) { self.elapsed = 0.0; }
248
249    pub fn duration(&self) -> f32 { self.duration }
250}
251
252// ── Camera path ───────────────────────────────────────────────────────────────
253
254/// A smooth camera path through a list of positions and targets.
255///
256/// Uses `KeyframeTrack<Vec3>` internally for both position and look-at target.
257pub struct CameraPath {
258    pub positions: KeyframeTrack<Vec3>,
259    pub targets:   KeyframeTrack<Vec3>,
260    pub fov:       KeyframeTrack<f32>,
261    pub elapsed:   f32,
262    pub speed:     f32,
263    pub looping:   bool,
264}
265
266impl CameraPath {
267    pub fn new(speed: f32, looping: bool) -> Self {
268        Self {
269            positions: KeyframeTrack::new(
270                if looping { ExtrapolateMode::Loop } else { ExtrapolateMode::Clamp }
271            ),
272            targets: KeyframeTrack::new(
273                if looping { ExtrapolateMode::Loop } else { ExtrapolateMode::Clamp }
274            ),
275            fov: KeyframeTrack::new(ExtrapolateMode::Clamp),
276            elapsed: 0.0,
277            speed,
278            looping,
279        }
280    }
281
282    /// Add a camera waypoint: position, look-at target, fov at given time.
283    pub fn add_waypoint(&mut self, time: f32, position: Vec3, target: Vec3, fov: f32) {
284        self.positions.insert(Keyframe::new(time, position)
285            .with_easing(Easing::EaseInOutCubic));
286        self.targets.insert(Keyframe::new(time, target)
287            .with_easing(Easing::EaseInOutCubic));
288        self.fov.insert(Keyframe::new(time, fov)
289            .with_easing(Easing::EaseInOutSine));
290    }
291
292    pub fn tick(&mut self, dt: f32) {
293        self.elapsed += dt * self.speed;
294        let duration = self.positions.end_time();
295        if self.looping && self.elapsed > duration {
296            self.elapsed -= duration;
297        }
298    }
299
300    pub fn position(&self) -> Vec3 { self.positions.evaluate(self.elapsed) }
301    pub fn target(&self)   -> Vec3 { self.targets.evaluate(self.elapsed) }
302    pub fn fov(&self)      -> f32  { self.fov.evaluate(self.elapsed) }
303
304    pub fn is_done(&self) -> bool {
305        !self.looping && self.elapsed >= self.positions.end_time()
306    }
307
308    /// Build a cinematic orbit path around a center point.
309    pub fn orbit(center: Vec3, radius: f32, height: f32, duration: f32, fov: f32) -> Self {
310        let mut path = Self::new(1.0, true);
311        let steps = 16;
312        for i in 0..=steps {
313            let angle = (i as f32 / steps as f32) * std::f32::consts::TAU;
314            let pos = center + Vec3::new(angle.cos() * radius, height, angle.sin() * radius);
315            let t = (i as f32 / steps as f32) * duration;
316            path.add_waypoint(t, pos, center, fov);
317        }
318        path
319    }
320
321    /// Build a flythrough path through a list of points.
322    pub fn flythrough(waypoints: &[(Vec3, Vec3)], duration_each: f32, fov: f32) -> Self {
323        let mut path = Self::new(1.0, false);
324        for (i, (pos, target)) in waypoints.iter().enumerate() {
325            path.add_waypoint(i as f32 * duration_each, *pos, *target, fov);
326        }
327        path
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use glam::Vec3;
335
336    #[test]
337    fn track_clamp_extrapolation() {
338        let mut track: KeyframeTrack<f32> = KeyframeTrack::new(ExtrapolateMode::Clamp);
339        track.insert(Keyframe::linear(0.0, 0.0));
340        track.insert(Keyframe::linear(1.0, 1.0));
341        assert!((track.evaluate(-1.0) - 0.0).abs() < 1e-5, "before start should clamp to first");
342        assert!((track.evaluate(2.0) - 1.0).abs() < 1e-5, "after end should clamp to last");
343    }
344
345    #[test]
346    fn track_midpoint_linear() {
347        let mut track: KeyframeTrack<f32> = KeyframeTrack::new(ExtrapolateMode::Clamp);
348        track.insert(Keyframe::linear(0.0, 0.0));
349        track.insert(Keyframe::linear(2.0, 4.0));
350        let mid = track.evaluate(1.0);
351        assert!((mid - 2.0).abs() < 1e-4, "midpoint of linear should be 2.0, got {mid}");
352    }
353
354    #[test]
355    fn track_loop_wraps() {
356        let mut track: KeyframeTrack<f32> = KeyframeTrack::new(ExtrapolateMode::Loop);
357        track.insert(Keyframe::linear(0.0, 0.0));
358        track.insert(Keyframe::linear(1.0, 1.0));
359        let v = track.evaluate(1.5);
360        assert!(v >= 0.0 && v <= 1.0, "looped value should wrap: {v}");
361    }
362
363    #[test]
364    fn track_sorted_on_insert() {
365        let mut track: KeyframeTrack<f32> = KeyframeTrack::new(ExtrapolateMode::Clamp);
366        track.insert(Keyframe::linear(2.0, 2.0));
367        track.insert(Keyframe::linear(0.0, 0.0));
368        track.insert(Keyframe::linear(1.0, 1.0));
369        assert_eq!(track.frames[0].time, 0.0);
370        assert_eq!(track.frames[1].time, 1.0);
371        assert_eq!(track.frames[2].time, 2.0);
372    }
373
374    #[test]
375    fn vec3_track_interpolates() {
376        let mut track: KeyframeTrack<Vec3> = KeyframeTrack::new(ExtrapolateMode::Clamp);
377        track.insert(Keyframe::linear(0.0, Vec3::ZERO));
378        track.insert(Keyframe::linear(1.0, Vec3::ONE));
379        let mid = track.evaluate(0.5);
380        assert!((mid.x - 0.5).abs() < 1e-4);
381    }
382
383    #[test]
384    fn bake_returns_correct_count() {
385        let mut track: KeyframeTrack<f32> = KeyframeTrack::new(ExtrapolateMode::Clamp);
386        track.insert(Keyframe::linear(0.0, 0.0));
387        track.insert(Keyframe::linear(1.0, 1.0));
388        let baked = track.bake(0.1);
389        assert!(baked.len() >= 10 && baked.len() <= 12, "expected ~11 samples, got {}", baked.len());
390    }
391
392    #[test]
393    fn camera_path_orbit() {
394        let path = CameraPath::orbit(Vec3::ZERO, 5.0, 2.0, 10.0, 60.0);
395        let pos = path.position();
396        let dist = glam::Vec2::new(pos.x, pos.z).length();
397        assert!((dist - 5.0).abs() < 0.5, "orbit radius should be ~5, got {dist}");
398    }
399}