Skip to main content

animsmith_core/
metrics.rs

1//! Locomotion clip metrics: loop-seam ratio, gait phase, root-motion
2//! speed. Ported from a production game pipeline's reference
3//! implementation
4//! (verified there against Blender pose-matrix FK to <0.01×) — the
5//! algorithms are kept semantically identical so the numbers reproduce.
6
7use crate::model::{Clip, Document, Property, Track};
8use crate::profile::{ResolvedRoles, Role};
9use crate::sample::{PoseGrid, sample_clip};
10use glam::Vec3;
11use std::cell::RefCell;
12use std::collections::BTreeMap;
13use std::rc::Rc;
14
15/// Below this per-frame foot move (metres), a clip has no real stride
16/// (idle / block / stationary action) and the seam ratio would be a
17/// divide-by-noise, so no ratio is reported.
18pub const MIN_STRIDE_STEP_M: f64 = 0.02;
19
20/// Lazily sampled metric pose grids for one document.
21///
22/// The check, measurement, and report pipelines all judge the same
23/// uniform metric grid. Sharing this owner lets callers run checks and
24/// then emit measurements or reports without sampling the same clip
25/// twice.
26///
27/// The cache uses `Rc` and `RefCell`, so it is intentionally neither
28/// `Send` nor `Sync`. Create one owner per document on each worker thread,
29/// then share it by reference among consumers on that thread.
30#[derive(Debug)]
31pub struct MetricGrids<'a> {
32    doc: &'a Document,
33    grids: RefCell<BTreeMap<usize, Rc<PoseGrid>>>,
34}
35
36impl<'a> MetricGrids<'a> {
37    /// Create a lazy metric-grid cache for `doc`.
38    pub fn new(doc: &'a Document) -> Self {
39        Self {
40            doc,
41            grids: RefCell::new(BTreeMap::new()),
42        }
43    }
44
45    /// The document these grids sample.
46    pub fn document(&self) -> &'a Document {
47        self.doc
48    }
49
50    /// The metric pose grid for clip `clip_index`, computed once and
51    /// shared. Returns `None` for an out-of-range index, non-positive
52    /// duration, or fewer than three keys on the longest track.
53    pub fn grid(&self, clip_index: usize) -> Option<Rc<PoseGrid>> {
54        let clip = self.doc.clips.get(clip_index)?;
55        let frames = metric_frame_count(clip)?;
56        Some(
57            self.grids
58                .borrow_mut()
59                .entry(clip_index)
60                .or_insert_with(|| Rc::new(sample_clip(&self.doc.skeleton, clip, frames)))
61                .clone(),
62        )
63    }
64}
65
66/// Foot-cycle metrics for one sampled clip.
67#[derive(Debug, Clone, PartialEq)]
68#[non_exhaustive]
69pub struct FootCycleMetrics {
70    /// Wrap discontinuity of the feet (relative to hips) over the max of
71    /// the two seam-adjacent in-clip steps. ≈1.0 for a clean cyclic
72    /// loop; well above 1 for a seam pop. `None` when the clip has no
73    /// real stride.
74    pub loop_seam_ratio: Option<f64>,
75    /// Cycle position `[0,1)` of the trough of the fundamental harmonic
76    /// of the left-minus-right foot-height signal — a stride-phase
77    /// anchor encoding handedness + cycle alignment. `None` when a side
78    /// is missing.
79    pub gait_phase: Option<f64>,
80    /// Peak-to-peak swing of the L−R foot-height signal (metres); near
81    /// zero means no detectable alternation and the phase is noise.
82    pub lr_amplitude_m: f64,
83}
84
85/// Measure the foot cycle of a clip from its pose grid. Requires the
86/// Hips role and at least one foot role; returns `None` otherwise (the
87/// caller decides whether that's a skip-note or nothing).
88///
89/// The grid must span `[0, duration]` — the wrap pair is
90/// `(last frame, frame 0)`. Grids under 3 frames carry no cycle.
91///
92/// # Panics
93///
94/// Panics if `roles` contains bone indices outside `grid`. Role
95/// resolutions produced by this crate are tied to the same skeleton that
96/// produced the grid; embedders that hand-build roles must preserve that
97/// relationship.
98pub fn foot_cycle_metrics(
99    grid: &PoseGrid,
100    roles: &ResolvedRoles,
101    min_stride_step_m: f64,
102) -> Option<FootCycleMetrics> {
103    if grid.frame_count() < 3 {
104        return None;
105    }
106    let hips = roles.get(Role::Hips)?;
107    let left: Vec<usize> = [Role::LeftFoot, Role::LeftToe]
108        .iter()
109        .filter_map(|&r| roles.get(r))
110        .collect();
111    let right: Vec<usize> = [Role::RightFoot, Role::RightToe]
112        .iter()
113        .filter_map(|&r| roles.get(r))
114        .collect();
115    let feet: Vec<usize> = left.iter().chain(right.iter()).copied().collect();
116    if feet.is_empty() {
117        return None;
118    }
119
120    let frames = grid.frame_count();
121    // Feet relative to hips: cancels the in-place root so we measure
122    // the leg cycle, not body travel.
123    let rel = |frame: usize, bone: usize| -> Vec3 {
124        grid.model_position(frame, bone) - grid.model_position(frame, hips)
125    };
126
127    // Loop seam: the wrap chord vs its NEIGHBOURING in-clip steps (the
128    // step into the last frame and the step out of the first) — local
129    // continuity, because stride speed varies legitimately inside a
130    // cycle and the wrap may sit at an arbitrary cycle position. A real
131    // pop is discontinuous against its immediate neighbours too.
132    let max_foot_dist = |a: usize, b: usize| -> f64 {
133        feet.iter()
134            .map(|&f| (rel(a, f) - rel(b, f)).length() as f64)
135            .fold(0.0, f64::max)
136    };
137    let seam = max_foot_dist(frames - 1, 0);
138    let step_first = max_foot_dist(1, 0);
139    let step_last = max_foot_dist(frames - 1, frames - 2);
140    let neighbour_step = step_first.max(step_last);
141    let loop_seam_ratio = if neighbour_step > 0.0 && neighbour_step >= min_stride_step_m {
142        Some(seam / neighbour_step)
143    } else {
144        None
145    };
146
147    // Gait phase: fundamental-harmonic trough of the L−R foot-height
148    // signal over one cycle (the duplicate wrap frame excluded). The
149    // difference cancels common-mode pelvis bob and encodes handedness
150    // plus a stable cycle anchor.
151    let cycle = if frames > 3 { frames - 1 } else { frames };
152    let mut gait_phase = None;
153    let mut lr_amplitude_m = 0.0f64;
154    if !left.is_empty() && !right.is_empty() {
155        let avg_height = |frame: usize, bones: &[usize]| -> f64 {
156            bones.iter().map(|&b| rel(frame, b).y as f64).sum::<f64>() / bones.len() as f64
157        };
158        let diff: Vec<f64> = (0..cycle)
159            .map(|f| avg_height(f, &left) - avg_height(f, &right))
160            .collect();
161        let max = diff.iter().copied().fold(f64::MIN, f64::max);
162        let min = diff.iter().copied().fold(f64::MAX, f64::min);
163        lr_amplitude_m = max - min;
164        gait_phase = fundamental_trough_phase(&diff);
165    }
166
167    Some(FootCycleMetrics {
168        loop_seam_ratio,
169        gait_phase,
170        lr_amplitude_m,
171    })
172}
173
174/// Normalized cycle position `[0,1)` of the minimum of the signal's
175/// first Fourier harmonic. Robust to plateaus and per-frame noise: the
176/// minimum of `A·cos(2π·t/N − φ)` sits at `t/N = (φ/2π + 0.5) mod 1`.
177pub fn fundamental_trough_phase(signal: &[f64]) -> Option<f64> {
178    let n = signal.len();
179    if n < 2 {
180        return None;
181    }
182    let mut re = 0.0f64;
183    let mut im = 0.0f64;
184    for (k, y) in signal.iter().enumerate() {
185        let angle = std::f64::consts::TAU * k as f64 / n as f64;
186        re += y * angle.cos();
187        im += y * angle.sin();
188    }
189    let phi = im.atan2(re);
190    Some((phi / std::f64::consts::TAU + 0.5).rem_euclid(1.0))
191}
192
193/// Horizontal (XZ-plane) root displacement over the clip, divided by
194/// duration. Uses the Root role, falling back to Hips (clips without a
195/// dedicated root bone carry travel on the hips).
196///
197/// # Panics
198///
199/// Panics if the resolved Root or Hips bone id is outside `grid`.
200pub fn root_motion_speed_mps(grid: &PoseGrid, roles: &ResolvedRoles) -> Option<f64> {
201    let bone = roles.get(Role::Root).or_else(|| roles.get(Role::Hips))?;
202    let frames = grid.frame_count();
203    if frames < 2 {
204        return None;
205    }
206    let duration = *grid.times.last()? as f64;
207    if duration <= 0.0 {
208        return None;
209    }
210    let a = grid.model_position(0, bone);
211    let b = grid.model_position(frames - 1, bone);
212    let dx = (b.x - a.x) as f64;
213    let dz = (b.z - a.z) as f64;
214    Some(dx.hypot(dz) / duration)
215}
216
217/// Maximum angular deviation (degrees) of a rotation track from its
218/// first keyed rotation.
219pub fn rotation_range_deg(track: &Track) -> Option<f64> {
220    if track.property != Property::Rotation {
221        return None;
222    }
223    let first = track.key_quat(0)?;
224    if !first.is_finite() || first.length_squared() == 0.0 {
225        return None;
226    }
227    let first = first.normalize();
228    let mut max_deg = 0.0f64;
229    for k in 1..track.key_count() {
230        if let Some(q) = track.key_quat(k)
231            && q.is_finite()
232            && q.length_squared() > 0.0
233        {
234            let deg = first.angle_between(q.normalize()).to_degrees() as f64;
235            max_deg = max_deg.max(deg);
236        }
237    }
238    Some(max_deg)
239}
240
241/// Maximum circular distance (in cycle fraction, `[0, 0.5]`) of a set of
242/// normalized phases from their circular mean. Phases live on a ring, so
243/// a naive max−min would over-report a cluster straddling the 0/1 wrap.
244pub fn circular_phase_spread(phases: &[f64]) -> f64 {
245    use std::f64::consts::{PI, TAU};
246    let (mut sin_sum, mut cos_sum) = (0.0f64, 0.0f64);
247    for p in phases {
248        sin_sum += (p * TAU).sin();
249        cos_sum += (p * TAU).cos();
250    }
251    let mean = sin_sum.atan2(cos_sum);
252    let mut max_dev = 0.0f64;
253    for p in phases {
254        let mut d = (p * TAU - mean).abs() % TAU;
255        if d > PI {
256            d = TAU - d;
257        }
258        max_dev = max_dev.max(d / TAU);
259    }
260    max_dev
261}
262
263/// The metric sampling grid for a clip: uniform, resolution = max key
264/// count (mirroring how the runtime loops a clip over `[0, duration]`,
265/// wrapping duration→0 at render times unaligned with authored keys).
266/// `None` for clips too short to carry a cycle (< 3 keys), matching the
267/// reference implementation.
268pub fn metric_frame_count(clip: &Clip) -> Option<usize> {
269    let n = crate::sample::default_frame_count(clip);
270    if clip.duration_s <= 0.0 || n < 3 {
271        None
272    } else {
273        Some(n)
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use crate::check::CheckCtx;
281    use crate::config::Config;
282    use crate::measure::measure_document;
283    use crate::model::{
284        Bone, Clip, Document, Interpolation, Property, Skeleton, Track, TrackValues, Transform,
285    };
286    use crate::profile::ResolvedRoles;
287    use glam::{Quat, Vec3};
288    use std::rc::Rc;
289
290    fn document_with_metric_clip() -> Document {
291        Document {
292            skeleton: Skeleton {
293                bones: vec![Bone {
294                    name: "root".into(),
295                    parent: None,
296                    rest: Transform::IDENTITY,
297                    inverse_bind: None,
298                }],
299            },
300            clips: vec![Clip {
301                name: "walk".into(),
302                duration_s: 1.0,
303                tracks: vec![Track {
304                    bone: 0,
305                    property: Property::Rotation,
306                    interpolation: Interpolation::Linear,
307                    times: vec![0.0, 0.5, 1.0],
308                    values: TrackValues::Quats(vec![
309                        Quat::IDENTITY,
310                        Quat::from_rotation_y(0.1),
311                        Quat::from_rotation_y(0.2),
312                    ]),
313                }],
314            }],
315            ..Document::default()
316        }
317    }
318
319    fn document_with_grid_inputs(duration_s: f64, times: Vec<f32>) -> Document {
320        let values = vec![Quat::IDENTITY; times.len()];
321        Document {
322            skeleton: Skeleton {
323                bones: vec![Bone {
324                    name: "root".into(),
325                    parent: None,
326                    rest: Transform::IDENTITY,
327                    inverse_bind: None,
328                }],
329            },
330            clips: vec![Clip {
331                name: "probe".into(),
332                duration_s,
333                tracks: vec![Track {
334                    bone: 0,
335                    property: Property::Rotation,
336                    interpolation: Interpolation::Linear,
337                    times,
338                    values: TrackValues::Quats(values),
339                }],
340            }],
341            ..Document::default()
342        }
343    }
344
345    #[test]
346    fn metric_grids_are_shared_by_checks_and_measurements() {
347        let doc = document_with_metric_clip();
348        let roles = ResolvedRoles::default();
349        let config = Config::default();
350        let grids = MetricGrids::new(&doc);
351
352        let ctx = CheckCtx::new(&grids, &roles, &config);
353        let from_ctx = ctx.grid(0).expect("metric grid");
354        let from_owner = grids.grid(0).expect("same metric grid");
355        assert!(Rc::ptr_eq(&from_ctx, &from_owner));
356
357        let measurements = measure_document(&grids, &roles, &config);
358        assert!(measurements.contains_key("walk"));
359        let fresh_grids = MetricGrids::new(&doc);
360        assert_eq!(
361            serde_json::to_value(&measurements).expect("shared measurements serialize"),
362            serde_json::to_value(measure_document(&fresh_grids, &roles, &config))
363                .expect("plain measurements serialize")
364        );
365    }
366
367    #[test]
368    fn grid_returns_none_for_each_documented_invalid_request() {
369        let valid = document_with_grid_inputs(1.0, vec![0.0, 0.5, 1.0]);
370        let valid_grids = MetricGrids::new(&valid);
371        assert!(valid_grids.grid(0).is_some());
372        for clip_index in [1, 2, usize::MAX] {
373            assert!(valid_grids.grid(clip_index).is_none());
374        }
375
376        for duration_s in [0.0, -1.0] {
377            let non_positive = document_with_grid_inputs(duration_s, vec![0.0, 0.5, 1.0]);
378            assert!(MetricGrids::new(&non_positive).grid(0).is_none());
379        }
380
381        for times in [vec![], vec![0.0], vec![0.0, 1.0]] {
382            let too_few_keys = document_with_grid_inputs(1.0, times);
383            assert!(MetricGrids::new(&too_few_keys).grid(0).is_none());
384        }
385    }
386
387    #[test]
388    fn grid_uses_longest_track_for_resolution() {
389        // The first track is too short by itself; the later translation
390        // track selects the grid's three-frame resolution.
391        let mut doc = document_with_grid_inputs(1.0, vec![0.0, 1.0]);
392        doc.clips[0].tracks.push(Track {
393            bone: 0,
394            property: Property::Translation,
395            interpolation: Interpolation::Linear,
396            times: vec![0.0, 0.5, 1.0],
397            values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::X, 2.0 * Vec3::X]),
398        });
399
400        let grid = MetricGrids::new(&doc)
401            .grid(0)
402            .expect("later longest track supplies a metric grid");
403        assert_eq!(grid.frame_count(), 3);
404    }
405}