Skip to main content

proof_engine/anim/
clips.rs

1//! Animation clips: keyframe tracks, sampling, clip registry, blend shapes, root motion.
2//!
3//! This module defines the raw data layer for skeletal animation:
4//! - [`AnimationChannel`] — a single keyframe track targeting one bone property
5//! - [`AnimationClip`] — a collection of channels with a name and duration
6//! - [`AnimationClipSampler`] — samples a clip at a given time to produce a [`Pose`]
7//! - [`ClipRegistry`] — named clip store with register/unregister/get
8//! - [`BlendShapeAnimator`] — drives blend-shape (morph target) weight tracks
9//! - [`RootMotion`] — extracts root bone deltas for locomotion
10
11use std::collections::HashMap;
12use glam::{Quat, Vec3};
13
14use super::skeleton::{BoneId, Pose, Skeleton, Transform3D};
15
16// ── ChannelTarget ─────────────────────────────────────────────────────────────
17
18/// The property animated by a single [`AnimationChannel`].
19#[derive(Debug, Clone, PartialEq, Eq, Hash)]
20pub enum ChannelTarget {
21    Translation,
22    Rotation,
23    Scale,
24    BlendShape(String),
25}
26
27// ── Keyframe types ────────────────────────────────────────────────────────────
28
29/// A Vec3 keyframe with cubic Hermite tangents.
30#[derive(Debug, Clone)]
31pub struct Vec3Key {
32    pub time:        f32,
33    pub value:       Vec3,
34    /// In-tangent (arriving slope).
35    pub in_tangent:  Vec3,
36    /// Out-tangent (leaving slope).
37    pub out_tangent: Vec3,
38}
39
40/// A quaternion keyframe (SQUAD interpolation).
41#[derive(Debug, Clone)]
42pub struct QuatKey {
43    pub time:  f32,
44    pub value: Quat,
45}
46
47/// A scalar keyframe with linear interpolation.
48#[derive(Debug, Clone)]
49pub struct F32Key {
50    pub time:  f32,
51    pub value: f32,
52}
53
54// ── Interpolation helpers ─────────────────────────────────────────────────────
55
56/// Cubic Hermite interpolation for Vec3.
57///
58/// Interpolates between `p0` and `p1` using Hermite basis polynomials with the
59/// given tangents. `t` is the normalised parameter in [0, 1].
60fn hermite_vec3(p0: Vec3, m0: Vec3, p1: Vec3, m1: Vec3, t: f32) -> Vec3 {
61    let t2 = t * t;
62    let t3 = t2 * t;
63    let h00 =  2.0 * t3 - 3.0 * t2 + 1.0;
64    let h10 =        t3 - 2.0 * t2 + t;
65    let h01 = -2.0 * t3 + 3.0 * t2;
66    let h11 =        t3 -       t2;
67    p0 * h00 + m0 * h10 + p1 * h01 + m1 * h11
68}
69
70/// SQUAD (Spherical Quadrangle) interpolation for quaternions.
71///
72/// SQUAD provides smooth quaternion interpolation by using two intermediate
73/// "inner" quaternions si and sj derived from adjacent keyframe quaternions.
74fn squad(q0: Quat, q1: Quat, s0: Quat, s1: Quat, t: f32) -> Quat {
75    let slerp_q = q0.slerp(q1, t);
76    let slerp_s = s0.slerp(s1, t);
77    slerp_q.slerp(slerp_s, 2.0 * t * (1.0 - t))
78}
79
80/// Compute the SQUAD inner control point for quaternion `q1` given neighbours.
81fn squad_inner(q_prev: Quat, q_curr: Quat, q_next: Quat) -> Quat {
82    let q_inv = q_curr.conjugate();
83    // log of q_inv * q_next
84    let a = q_inv * q_next;
85    // log of q_inv * q_prev
86    let b = q_inv * q_prev;
87    // Average the logs in the tangent space, then exp
88    let la = quat_log(a);
89    let lb = quat_log(b);
90    let avg = (la + lb) * (-0.25);
91    q_curr * quat_exp(avg)
92}
93
94/// Approximate quaternion logarithm.
95fn quat_log(q: Quat) -> Quat {
96    let v = Vec3::new(q.x, q.y, q.z);
97    let len = v.length();
98    if len < 1e-6 {
99        return Quat::from_xyzw(0.0, 0.0, 0.0, 0.0);
100    }
101    let angle = q.w.clamp(-1.0, 1.0).acos();
102    let coeff = if angle.abs() < 1e-6 { 1.0 } else { angle / len };
103    let v2 = v * coeff;
104    Quat::from_xyzw(v2.x, v2.y, v2.z, 0.0)
105}
106
107/// Approximate quaternion exponential.
108fn quat_exp(q: Quat) -> Quat {
109    let v = Vec3::new(q.x, q.y, q.z);
110    let theta = v.length();
111    if theta < 1e-6 {
112        return Quat::IDENTITY;
113    }
114    let sin_t = theta.sin();
115    let cos_t = theta.cos();
116    let coeff = sin_t / theta;
117    Quat::from_xyzw(v.x * coeff, v.y * coeff, v.z * coeff, cos_t).normalize()
118}
119
120/// Linear interpolation for f32 keyframes.
121fn lerp_f32(a: f32, b: f32, t: f32) -> f32 {
122    a + (b - a) * t
123}
124
125// ── AnimationChannel ──────────────────────────────────────────────────────────
126
127/// A single keyframe track targeting one property of one bone.
128#[derive(Debug, Clone)]
129pub struct AnimationChannel {
130    pub bone_id: BoneId,
131    pub target:  ChannelTarget,
132    pub data:    ChannelData,
133}
134
135/// Variant keyframe data for different property types.
136#[derive(Debug, Clone)]
137pub enum ChannelData {
138    Translation(Vec<Vec3Key>),
139    Rotation(Vec<QuatKey>),
140    Scale(Vec<Vec3Key>),
141    BlendShape(Vec<F32Key>),
142}
143
144impl AnimationChannel {
145    /// Create a translation channel.
146    pub fn translation(bone_id: BoneId, keys: Vec<Vec3Key>) -> Self {
147        Self { bone_id, target: ChannelTarget::Translation, data: ChannelData::Translation(keys) }
148    }
149
150    /// Create a rotation channel.
151    pub fn rotation(bone_id: BoneId, keys: Vec<QuatKey>) -> Self {
152        Self { bone_id, target: ChannelTarget::Rotation, data: ChannelData::Rotation(keys) }
153    }
154
155    /// Create a scale channel.
156    pub fn scale(bone_id: BoneId, keys: Vec<Vec3Key>) -> Self {
157        Self { bone_id, target: ChannelTarget::Scale, data: ChannelData::Scale(keys) }
158    }
159
160    /// Create a blend-shape weight channel.
161    pub fn blend_shape(bone_id: BoneId, shape_name: impl Into<String>, keys: Vec<F32Key>) -> Self {
162        Self {
163            bone_id,
164            target: ChannelTarget::BlendShape(shape_name.into()),
165            data: ChannelData::BlendShape(keys),
166        }
167    }
168
169    /// Sample the translation value at time `t` (seconds).
170    pub fn sample_translation(&self, t: f32) -> Option<Vec3> {
171        if let ChannelData::Translation(ref keys) = self.data {
172            Some(sample_vec3_hermite(keys, t))
173        } else {
174            None
175        }
176    }
177
178    /// Sample the rotation value at time `t` (seconds).
179    pub fn sample_rotation(&self, t: f32) -> Option<Quat> {
180        if let ChannelData::Rotation(ref keys) = self.data {
181            Some(sample_quat_squad(keys, t))
182        } else {
183            None
184        }
185    }
186
187    /// Sample the scale value at time `t` (seconds).
188    pub fn sample_scale(&self, t: f32) -> Option<Vec3> {
189        if let ChannelData::Scale(ref keys) = self.data {
190            Some(sample_vec3_hermite(keys, t))
191        } else {
192            None
193        }
194    }
195
196    /// Sample a blend-shape weight at time `t` (seconds).
197    pub fn sample_blend_shape(&self, t: f32) -> Option<f32> {
198        if let ChannelData::BlendShape(ref keys) = self.data {
199            Some(sample_f32_linear(keys, t))
200        } else {
201            None
202        }
203    }
204}
205
206// ── Sampling helpers ──────────────────────────────────────────────────────────
207
208fn sample_vec3_hermite(keys: &[Vec3Key], t: f32) -> Vec3 {
209    if keys.is_empty() { return Vec3::ZERO; }
210    if keys.len() == 1 { return keys[0].value; }
211    if t <= keys[0].time { return keys[0].value; }
212    if t >= keys.last().unwrap().time { return keys.last().unwrap().value; }
213
214    let idx = keys.partition_point(|k| k.time <= t);
215    let i = idx.saturating_sub(1);
216    let j = idx.min(keys.len() - 1);
217    let k0 = &keys[i];
218    let k1 = &keys[j];
219    let span = (k1.time - k0.time).max(1e-7);
220    let u = (t - k0.time) / span;
221    hermite_vec3(k0.value, k0.out_tangent * span, k1.value, k1.in_tangent * span, u)
222}
223
224fn sample_quat_squad(keys: &[QuatKey], t: f32) -> Quat {
225    if keys.is_empty() { return Quat::IDENTITY; }
226    if keys.len() == 1 { return keys[0].value; }
227    if t <= keys[0].time { return keys[0].value; }
228    if t >= keys.last().unwrap().time { return keys.last().unwrap().value; }
229
230    let idx = keys.partition_point(|k| k.time <= t);
231    let i = idx.saturating_sub(1);
232    let j = idx.min(keys.len() - 1);
233
234    let q0 = keys[i].value;
235    let q1 = keys[j].value;
236
237    // Build SQUAD control points
238    let q_prev = if i > 0 { keys[i - 1].value } else { q0 };
239    let q_next = if j + 1 < keys.len() { keys[j + 1].value } else { q1 };
240
241    let s0 = squad_inner(q_prev, q0, q1);
242    let s1 = squad_inner(q0, q1, q_next);
243
244    let span = (keys[j].time - keys[i].time).max(1e-7);
245    let u = (t - keys[i].time) / span;
246    squad(q0, q1, s0, s1, u).normalize()
247}
248
249fn sample_f32_linear(keys: &[F32Key], t: f32) -> f32 {
250    if keys.is_empty() { return 0.0; }
251    if keys.len() == 1 { return keys[0].value; }
252    if t <= keys[0].time { return keys[0].value; }
253    if t >= keys.last().unwrap().time { return keys.last().unwrap().value; }
254
255    let idx = keys.partition_point(|k| k.time <= t);
256    let i = idx.saturating_sub(1);
257    let j = idx.min(keys.len() - 1);
258    let span = (keys[j].time - keys[i].time).max(1e-7);
259    let u = (t - keys[i].time) / span;
260    lerp_f32(keys[i].value, keys[j].value, u)
261}
262
263// ── LoopMode ──────────────────────────────────────────────────────────────────
264
265/// How an animation clip behaves when time exceeds its duration.
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267pub enum LoopMode {
268    /// Play once then stop at the last frame.
269    Once,
270    /// Loop back to the beginning indefinitely.
271    Loop,
272    /// Alternate forward/backward.
273    PingPong,
274    /// Hold the last frame value forever.
275    ClampForever,
276}
277
278impl LoopMode {
279    /// Remap raw time `t` into the clip's local [0, duration] range.
280    pub fn remap(self, t: f32, duration: f32) -> f32 {
281        if duration < 1e-6 { return 0.0; }
282        match self {
283            LoopMode::Once | LoopMode::ClampForever => t.clamp(0.0, duration),
284            LoopMode::Loop => t.rem_euclid(duration),
285            LoopMode::PingPong => {
286                let period = duration * 2.0;
287                let local = t.rem_euclid(period);
288                if local <= duration { local } else { period - local }
289            }
290        }
291    }
292}
293
294// ── AnimationEvent ────────────────────────────────────────────────────────────
295
296/// An event embedded in a clip that fires when playback crosses its timestamp.
297#[derive(Debug, Clone)]
298pub struct AnimationEvent {
299    /// Time in seconds within the clip when this event fires.
300    pub time:    f32,
301    pub name:    String,
302    pub payload: String,
303}
304
305impl AnimationEvent {
306    pub fn new(time: f32, name: impl Into<String>, payload: impl Into<String>) -> Self {
307        Self { time, name: name.into(), payload: payload.into() }
308    }
309}
310
311// ── AnimationClip ─────────────────────────────────────────────────────────────
312
313/// A named sequence of keyframe channels over time.
314#[derive(Debug, Clone)]
315pub struct AnimationClip {
316    pub name:      String,
317    pub duration:  f32,
318    pub channels:  Vec<AnimationChannel>,
319    pub loop_mode: LoopMode,
320    pub events:    Vec<AnimationEvent>,
321}
322
323impl AnimationClip {
324    pub fn new(name: impl Into<String>, duration: f32) -> Self {
325        Self {
326            name: name.into(),
327            duration,
328            channels: Vec::new(),
329            loop_mode: LoopMode::ClampForever,
330            events: Vec::new(),
331        }
332    }
333
334    pub fn with_loop_mode(mut self, mode: LoopMode) -> Self {
335        self.loop_mode = mode;
336        self
337    }
338
339    pub fn with_channel(mut self, ch: AnimationChannel) -> Self {
340        self.channels.push(ch);
341        self
342    }
343
344    pub fn with_event(mut self, event: AnimationEvent) -> Self {
345        self.events.push(event);
346        self
347    }
348
349    /// Add a channel directly by mutation.
350    pub fn add_channel(&mut self, ch: AnimationChannel) {
351        self.channels.push(ch);
352    }
353
354    /// Add an event directly by mutation.
355    pub fn add_event(&mut self, event: AnimationEvent) {
356        self.events.push(event);
357    }
358
359    /// Collect events whose time falls in (prev_t, cur_t] (seconds, already wrapped).
360    pub fn events_in_range(&self, prev_t: f32, cur_t: f32) -> Vec<&AnimationEvent> {
361        self.events.iter()
362            .filter(|e| e.time > prev_t && e.time <= cur_t)
363            .collect()
364    }
365
366    /// Build constant-value pose channels from a single snapshot for testing.
367    pub fn constant_pose(name: impl Into<String>, duration: f32, snapshot: Vec<(BoneId, Transform3D)>) -> Self {
368        let mut clip = Self::new(name, duration);
369        for (bone_id, xform) in snapshot {
370            let t_keys = vec![Vec3Key {
371                time: 0.0,
372                value: xform.translation,
373                in_tangent: Vec3::ZERO,
374                out_tangent: Vec3::ZERO,
375            }];
376            let r_keys = vec![QuatKey { time: 0.0, value: xform.rotation }];
377            let s_keys = vec![Vec3Key {
378                time: 0.0,
379                value: xform.scale,
380                in_tangent: Vec3::ZERO,
381                out_tangent: Vec3::ZERO,
382            }];
383            clip.add_channel(AnimationChannel::translation(bone_id, t_keys));
384            clip.add_channel(AnimationChannel::rotation(bone_id, r_keys));
385            clip.add_channel(AnimationChannel::scale(bone_id, s_keys));
386        }
387        clip
388    }
389}
390
391// ── AnimationClipSampler ──────────────────────────────────────────────────────
392
393/// Samples an [`AnimationClip`] at a given playback time and applies it to a
394/// base [`Pose`], handling all loop modes.
395pub struct AnimationClipSampler<'a> {
396    pub clip:      &'a AnimationClip,
397    pub skeleton:  &'a Skeleton,
398    /// Accumulated playback time in seconds (not yet wrapped).
399    pub time:      f32,
400    /// Previous time (used for event detection).
401    prev_time:     f32,
402    pub speed:     f32,
403}
404
405impl<'a> AnimationClipSampler<'a> {
406    pub fn new(clip: &'a AnimationClip, skeleton: &'a Skeleton) -> Self {
407        Self {
408            clip,
409            skeleton,
410            time: 0.0,
411            prev_time: 0.0,
412            speed: 1.0,
413        }
414    }
415
416    pub fn with_speed(mut self, speed: f32) -> Self {
417        self.speed = speed;
418        self
419    }
420
421    /// Advance playback by `dt` seconds.
422    pub fn advance(&mut self, dt: f32) {
423        self.prev_time = self.time;
424        self.time += dt * self.speed;
425    }
426
427    /// Reset to the beginning.
428    pub fn reset(&mut self) {
429        self.prev_time = 0.0;
430        self.time = 0.0;
431    }
432
433    /// Whether the clip has finished (only meaningful for `Once` / `ClampForever`).
434    pub fn is_finished(&self) -> bool {
435        matches!(self.clip.loop_mode, LoopMode::Once | LoopMode::ClampForever)
436            && self.time >= self.clip.duration
437    }
438
439    /// Normalized playback position [0, 1].
440    pub fn normalized_time(&self) -> f32 {
441        let dur = self.clip.duration.max(1e-6);
442        (self.clip.loop_mode.remap(self.time, dur) / dur).clamp(0.0, 1.0)
443    }
444
445    /// Sample the clip at current time and merge into `base_pose`.
446    ///
447    /// Returns the events that fired since last call to `advance`.
448    pub fn sample_into(&self, base_pose: &mut Pose) -> Vec<&AnimationEvent> {
449        let t = self.clip.loop_mode.remap(self.time, self.clip.duration);
450        self.apply_channels(base_pose, t);
451
452        let prev_t = self.clip.loop_mode.remap(self.prev_time, self.clip.duration);
453        let cur_t  = t;
454        if cur_t >= prev_t {
455            self.clip.events_in_range(prev_t, cur_t)
456        } else {
457            // Loop wrapped — collect events from prev→end and 0→cur
458            let mut evts = self.clip.events_in_range(prev_t, self.clip.duration);
459            evts.extend(self.clip.events_in_range(0.0, cur_t));
460            evts
461        }
462    }
463
464    /// Sample the clip at an explicit time (seconds, pre-wrapping applied internally).
465    pub fn sample_at(&self, time_sec: f32) -> Pose {
466        let t = self.clip.loop_mode.remap(time_sec, self.clip.duration);
467        let mut pose = self.skeleton.rest_pose();
468        self.apply_channels(&mut pose, t);
469        pose
470    }
471
472    fn apply_channels(&self, pose: &mut Pose, t: f32) {
473        // Group channels by bone id so we can build full transforms.
474        // We apply each channel's contribution directly to the pose slot.
475        for ch in &self.clip.channels {
476            let idx = ch.bone_id.index();
477            if idx >= pose.local_transforms.len() { continue; }
478
479            match &ch.data {
480                ChannelData::Translation(keys) => {
481                    pose.local_transforms[idx].translation = sample_vec3_hermite(keys, t);
482                }
483                ChannelData::Rotation(keys) => {
484                    pose.local_transforms[idx].rotation = sample_quat_squad(keys, t);
485                }
486                ChannelData::Scale(keys) => {
487                    pose.local_transforms[idx].scale = sample_vec3_hermite(keys, t);
488                }
489                ChannelData::BlendShape(_) => {
490                    // Blend shapes are handled by BlendShapeAnimator
491                }
492            }
493        }
494    }
495}
496
497// ── ClipRegistry ──────────────────────────────────────────────────────────────
498
499/// A named store for [`AnimationClip`]s.
500#[derive(Debug, Default)]
501pub struct ClipRegistry {
502    clips: HashMap<String, AnimationClip>,
503}
504
505impl ClipRegistry {
506    pub fn new() -> Self { Self::default() }
507
508    /// Register a clip. Returns `true` if a clip with the same name was replaced.
509    pub fn register(&mut self, clip: AnimationClip) -> bool {
510        self.clips.insert(clip.name.clone(), clip).is_some()
511    }
512
513    /// Unregister a clip by name. Returns the removed clip if it existed.
514    pub fn unregister(&mut self, name: &str) -> Option<AnimationClip> {
515        self.clips.remove(name)
516    }
517
518    /// Look up a clip by name.
519    pub fn get(&self, name: &str) -> Option<&AnimationClip> {
520        self.clips.get(name)
521    }
522
523    /// Mutable access to a clip by name.
524    pub fn get_mut(&mut self, name: &str) -> Option<&mut AnimationClip> {
525        self.clips.get_mut(name)
526    }
527
528    /// Number of registered clips.
529    pub fn len(&self) -> usize { self.clips.len() }
530    pub fn is_empty(&self) -> bool { self.clips.is_empty() }
531
532    /// All registered clip names.
533    pub fn names(&self) -> impl Iterator<Item = &str> {
534        self.clips.keys().map(|s| s.as_str())
535    }
536
537    /// Iterate over all clips.
538    pub fn iter(&self) -> impl Iterator<Item = (&str, &AnimationClip)> {
539        self.clips.iter().map(|(k, v)| (k.as_str(), v))
540    }
541}
542
543// ── BlendShape ────────────────────────────────────────────────────────────────
544
545/// A morph-target (blend shape) storing per-vertex position deltas.
546#[derive(Debug, Clone)]
547pub struct BlendShape {
548    pub name:   String,
549    /// Per-vertex displacement from the base mesh.
550    pub deltas: Vec<Vec3>,
551}
552
553impl BlendShape {
554    pub fn new(name: impl Into<String>, deltas: Vec<Vec3>) -> Self {
555        Self { name: name.into(), deltas }
556    }
557
558    /// Apply this shape at `weight` to vertex positions.
559    pub fn apply(&self, positions: &mut [Vec3], weight: f32) {
560        for (pos, delta) in positions.iter_mut().zip(self.deltas.iter()) {
561            *pos += *delta * weight;
562        }
563    }
564}
565
566/// A set of blend shapes for a single mesh.
567#[derive(Debug, Clone, Default)]
568pub struct BlendShapeSet {
569    shapes: HashMap<String, BlendShape>,
570}
571
572impl BlendShapeSet {
573    pub fn new() -> Self { Self::default() }
574
575    pub fn add(&mut self, shape: BlendShape) {
576        self.shapes.insert(shape.name.clone(), shape);
577    }
578
579    pub fn get(&self, name: &str) -> Option<&BlendShape> {
580        self.shapes.get(name)
581    }
582
583    pub fn len(&self) -> usize { self.shapes.len() }
584    pub fn is_empty(&self) -> bool { self.shapes.is_empty() }
585
586    /// Apply all shapes with their given weights to a vertex buffer.
587    pub fn apply_all(&self, positions: &mut [Vec3], weights: &HashMap<String, f32>) {
588        for (name, shape) in &self.shapes {
589            let w = weights.get(name).copied().unwrap_or(0.0);
590            if w.abs() > 1e-6 {
591                shape.apply(positions, w);
592            }
593        }
594    }
595}
596
597// ── BlendShapeAnimator ────────────────────────────────────────────────────────
598
599/// Drives blend-shape weights via time-varying F32 keyframe tracks.
600#[derive(Debug, Default)]
601pub struct BlendShapeAnimator {
602    /// shape_name → keyframe track
603    tracks:    HashMap<String, Vec<F32Key>>,
604    /// Current playback time (seconds).
605    pub time:  f32,
606    pub speed: f32,
607}
608
609impl BlendShapeAnimator {
610    pub fn new() -> Self {
611        Self { tracks: HashMap::new(), time: 0.0, speed: 1.0 }
612    }
613
614    /// Add a weight track for a named blend shape.
615    pub fn add_track(&mut self, shape_name: impl Into<String>, keys: Vec<F32Key>) {
616        self.tracks.insert(shape_name.into(), keys);
617    }
618
619    /// Advance time by `dt` seconds.
620    pub fn advance(&mut self, dt: f32) {
621        self.time += dt * self.speed;
622    }
623
624    /// Evaluate all tracks at current time and return a weight map.
625    pub fn evaluate(&self) -> HashMap<String, f32> {
626        self.tracks.iter()
627            .map(|(name, keys)| (name.clone(), sample_f32_linear(keys, self.time)))
628            .collect()
629    }
630
631    /// Evaluate a single shape weight at current time.
632    pub fn weight_of(&self, shape_name: &str) -> f32 {
633        self.tracks.get(shape_name)
634            .map(|keys| sample_f32_linear(keys, self.time))
635            .unwrap_or(0.0)
636    }
637
638    /// Number of shape tracks.
639    pub fn track_count(&self) -> usize { self.tracks.len() }
640}
641
642// ── RootMotion ────────────────────────────────────────────────────────────────
643
644/// Root motion extracted from a clip's root bone.
645///
646/// Instead of moving the root bone in the pose, the delta is handed off to
647/// the game's character controller so it can be applied to the entity's
648/// world transform.
649#[derive(Debug, Clone, Default)]
650pub struct RootMotion {
651    pub delta_translation: Vec3,
652    pub delta_rotation:    Quat,
653}
654
655impl RootMotion {
656    pub fn zero() -> Self {
657        Self {
658            delta_translation: Vec3::ZERO,
659            delta_rotation:    Quat::IDENTITY,
660        }
661    }
662
663    /// Compute the root-motion delta between two times in a clip.
664    ///
665    /// `dt` seconds of motion are sampled from the clip's first channel that
666    /// targets the root bone (BoneId 0) translation and rotation.
667    pub fn extract_root_motion(clip: &AnimationClip, current_time: f32, dt: f32) -> Self {
668        let dur = clip.duration.max(1e-6);
669        let t0 = clip.loop_mode.remap(current_time, dur);
670        let t1 = clip.loop_mode.remap(current_time + dt, dur);
671
672        let mut pos0 = Vec3::ZERO;
673        let mut pos1 = Vec3::ZERO;
674        let mut rot0 = Quat::IDENTITY;
675        let mut rot1 = Quat::IDENTITY;
676
677        for ch in &clip.channels {
678            if ch.bone_id != BoneId(0) { continue; }
679            match &ch.data {
680                ChannelData::Translation(keys) => {
681                    pos0 = sample_vec3_hermite(keys, t0);
682                    pos1 = sample_vec3_hermite(keys, t1);
683                }
684                ChannelData::Rotation(keys) => {
685                    rot0 = sample_quat_squad(keys, t0);
686                    rot1 = sample_quat_squad(keys, t1);
687                }
688                _ => {}
689            }
690        }
691
692        // Delta rotation = rot0.conjugate() * rot1
693        let delta_rotation = (rot0.conjugate() * rot1).normalize();
694
695        Self {
696            delta_translation: pos1 - pos0,
697            delta_rotation,
698        }
699    }
700
701    /// Accumulate another root motion delta.
702    pub fn accumulate(&self, other: &RootMotion) -> RootMotion {
703        RootMotion {
704            delta_translation: self.delta_translation + other.delta_translation,
705            delta_rotation:    (self.delta_rotation * other.delta_rotation).normalize(),
706        }
707    }
708
709    pub fn is_zero(&self) -> bool {
710        self.delta_translation.length_squared() < 1e-10
711            && (self.delta_rotation.w - 1.0).abs() < 1e-6
712    }
713}
714
715// ── Tests ─────────────────────────────────────────────────────────────────────
716
717#[cfg(test)]
718mod tests {
719    use super::*;
720    use super::super::skeleton::SkeletonBuilder;
721
722    fn two_bone_skeleton() -> Skeleton {
723        SkeletonBuilder::new()
724            .add_bone("root",  None,          Transform3D::identity())
725            .add_bone("child", Some("root"),  Transform3D::new(Vec3::new(0.0, 1.0, 0.0), Quat::IDENTITY, Vec3::ONE))
726            .build()
727    }
728
729    fn linear_translation_clip(bone_id: BoneId, start: Vec3, end: Vec3, duration: f32) -> AnimationClip {
730        let keys = vec![
731            Vec3Key { time: 0.0, value: start, in_tangent: Vec3::ZERO, out_tangent: Vec3::ZERO },
732            Vec3Key { time: duration, value: end, in_tangent: Vec3::ZERO, out_tangent: Vec3::ZERO },
733        ];
734        AnimationClip::new("test", duration)
735            .with_channel(AnimationChannel::translation(bone_id, keys))
736    }
737
738    #[test]
739    fn test_loop_mode_remap_loop() {
740        let mode = LoopMode::Loop;
741        assert!((mode.remap(2.5, 2.0) - 0.5).abs() < 1e-5);
742    }
743
744    #[test]
745    fn test_loop_mode_remap_ping_pong() {
746        let mode = LoopMode::PingPong;
747        assert!((mode.remap(0.0, 1.0) - 0.0).abs() < 1e-5);
748        assert!((mode.remap(1.5, 1.0) - 0.5).abs() < 1e-5);
749        assert!((mode.remap(2.0, 1.0) - 0.0).abs() < 1e-5);
750    }
751
752    #[test]
753    fn test_loop_mode_clamp() {
754        let mode = LoopMode::ClampForever;
755        assert!((mode.remap(-1.0, 2.0) - 0.0).abs() < 1e-5);
756        assert!((mode.remap(5.0, 2.0) - 2.0).abs() < 1e-5);
757    }
758
759    #[test]
760    fn test_hermite_vec3_midpoint() {
761        let keys = vec![
762            Vec3Key { time: 0.0, value: Vec3::ZERO, in_tangent: Vec3::ZERO, out_tangent: Vec3::ZERO },
763            Vec3Key { time: 1.0, value: Vec3::new(1.0, 0.0, 0.0), in_tangent: Vec3::ZERO, out_tangent: Vec3::ZERO },
764        ];
765        let mid = sample_vec3_hermite(&keys, 0.5);
766        assert!((mid.x - 0.5).abs() < 0.01, "Expected ~0.5, got {}", mid.x);
767    }
768
769    #[test]
770    fn test_sampler_translation_at_endpoints() {
771        let skel = two_bone_skeleton();
772        let clip = linear_translation_clip(BoneId(0), Vec3::ZERO, Vec3::new(1.0, 0.0, 0.0), 1.0);
773        let sampler = AnimationClipSampler::new(&clip, &skel);
774
775        let pose_start = sampler.sample_at(0.0);
776        let pose_end   = sampler.sample_at(1.0);
777        assert!(pose_start.local_transforms[0].translation.x.abs() < 1e-5);
778        assert!((pose_end.local_transforms[0].translation.x - 1.0).abs() < 1e-5);
779    }
780
781    #[test]
782    fn test_sampler_advance_and_is_finished() {
783        let skel = two_bone_skeleton();
784        let mut clip = linear_translation_clip(BoneId(0), Vec3::ZERO, Vec3::X, 1.0);
785        clip.loop_mode = LoopMode::Once;
786        let mut sampler = AnimationClipSampler::new(&clip, &skel);
787        sampler.advance(2.0);
788        assert!(sampler.is_finished());
789    }
790
791    #[test]
792    fn test_clip_registry_register_get() {
793        let mut reg = ClipRegistry::new();
794        let clip = AnimationClip::new("idle", 1.5);
795        reg.register(clip);
796        assert!(reg.get("idle").is_some());
797        assert!(reg.get("walk").is_none());
798    }
799
800    #[test]
801    fn test_clip_registry_unregister() {
802        let mut reg = ClipRegistry::new();
803        reg.register(AnimationClip::new("run", 0.8));
804        let removed = reg.unregister("run");
805        assert!(removed.is_some());
806        assert!(reg.get("run").is_none());
807    }
808
809    #[test]
810    fn test_animation_event_in_range() {
811        let clip = AnimationClip::new("test", 2.0)
812            .with_event(AnimationEvent::new(0.5, "footstep", "left"))
813            .with_event(AnimationEvent::new(1.5, "footstep", "right"));
814        let evts = clip.events_in_range(0.0, 1.0);
815        assert_eq!(evts.len(), 1);
816        assert_eq!(evts[0].name, "footstep");
817    }
818
819    #[test]
820    fn test_blend_shape_apply() {
821        let deltas = vec![Vec3::new(0.1, 0.0, 0.0); 3];
822        let shape = BlendShape::new("smile", deltas);
823        let mut positions = vec![Vec3::ZERO; 3];
824        shape.apply(&mut positions, 0.5);
825        assert!((positions[0].x - 0.05).abs() < 1e-6);
826    }
827
828    #[test]
829    fn test_blend_shape_animator_evaluate() {
830        let mut animator = BlendShapeAnimator::new();
831        let keys = vec![
832            F32Key { time: 0.0, value: 0.0 },
833            F32Key { time: 1.0, value: 1.0 },
834        ];
835        animator.add_track("blink", keys);
836        animator.time = 0.5;
837        let weights = animator.evaluate();
838        let w = weights["blink"];
839        assert!((w - 0.5).abs() < 0.01);
840    }
841
842    #[test]
843    fn test_root_motion_extract_zero_for_static_clip() {
844        // Clip with identical start and end positions → zero delta
845        let keys = vec![
846            Vec3Key { time: 0.0, value: Vec3::new(1.0, 0.0, 0.0), in_tangent: Vec3::ZERO, out_tangent: Vec3::ZERO },
847            Vec3Key { time: 1.0, value: Vec3::new(1.0, 0.0, 0.0), in_tangent: Vec3::ZERO, out_tangent: Vec3::ZERO },
848        ];
849        let clip = AnimationClip::new("static", 1.0)
850            .with_channel(AnimationChannel::translation(BoneId(0), keys));
851        let rm = RootMotion::extract_root_motion(&clip, 0.0, 0.5);
852        assert!(rm.delta_translation.length() < 1e-4);
853    }
854
855    #[test]
856    fn test_root_motion_extract_moving_clip() {
857        let keys = vec![
858            Vec3Key { time: 0.0, value: Vec3::ZERO, in_tangent: Vec3::ZERO, out_tangent: Vec3::ZERO },
859            Vec3Key { time: 1.0, value: Vec3::new(2.0, 0.0, 0.0), in_tangent: Vec3::ZERO, out_tangent: Vec3::ZERO },
860        ];
861        let clip = AnimationClip::new("run", 1.0)
862            .with_channel(AnimationChannel::translation(BoneId(0), keys));
863        let rm = RootMotion::extract_root_motion(&clip, 0.0, 0.5);
864        // Should move approximately 1.0 units in X
865        assert!(rm.delta_translation.x > 0.5 && rm.delta_translation.x < 1.5,
866            "Expected ~1.0, got {}", rm.delta_translation.x);
867    }
868
869    #[test]
870    fn test_f32_key_linear_interp() {
871        let keys = vec![
872            F32Key { time: 0.0, value: 0.0 },
873            F32Key { time: 2.0, value: 4.0 },
874        ];
875        let v = sample_f32_linear(&keys, 1.0);
876        assert!((v - 2.0).abs() < 1e-5);
877    }
878}