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::{Quat, 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/// Model-space loop-continuity measurements for one skeleton bone.
86#[derive(Debug, Clone, PartialEq)]
87#[non_exhaustive]
88pub struct BoneLoopContinuityMetrics {
89    /// Last-sample to first-sample model-space position distance (metres).
90    pub position_delta_m: f64,
91    /// Shortest-path model-space rotation difference (degrees).
92    pub rotation_delta_deg: f64,
93    /// Difference between the model-space linear velocities immediately
94    /// before and after the wrap (metres per second).
95    pub seam_velocity_delta_mps: f64,
96    /// Difference between the model-space angular velocities immediately
97    /// before and after the wrap (degrees per second).
98    pub seam_angular_velocity_delta_degps: f64,
99}
100
101/// Return the shortest-path model-space rotation vector from `from` to `to`.
102///
103/// The left-relative step (`to * from⁻¹`) expresses the angular direction in
104/// model space. Canonicalizing the quaternion hemisphere makes the result
105/// invariant to the equivalent `q`/`-q` representation. At exactly 180
106/// degrees, where `w` cannot choose a hemisphere, the first non-zero vector
107/// component breaks the tie deterministically.
108fn shortest_path_model_rotation_vector(from: Quat, to: Quat) -> Option<Vec3> {
109    let mut step = to * from.conjugate();
110    if !step.is_finite() {
111        return None;
112    }
113
114    let [x, y, z, w] = step.to_array();
115    if w < 0.0 || (w == 0.0 && (x < 0.0 || (x == 0.0 && (y < 0.0 || (y == 0.0 && z < 0.0))))) {
116        step = -step;
117    }
118
119    let vector = step.xyz();
120    let sin_half_angle = vector.length();
121    if !sin_half_angle.is_finite() {
122        return None;
123    }
124    if sin_half_angle == 0.0 {
125        return Some(Vec3::ZERO);
126    }
127
128    let angle_rad = 2.0 * sin_half_angle.atan2(step.w);
129    let rotation_vector = vector * (angle_rad / sin_half_angle);
130    rotation_vector.is_finite().then_some(rotation_vector)
131}
132
133/// Measure C0 pose closure plus C1 linear- and angular-velocity continuity
134/// for every bone.
135///
136/// The grid spans `[0, duration]`, including both endpoints. C1 continuity is
137/// therefore the difference between the in-clip step entering the last sample
138/// and the in-clip step leaving frame 0. Treating the last-to-first endpoint
139/// chord as a velocity would assign zero velocity to a perfectly closed loop.
140///
141/// Returns `None` when the grid has fewer than three frames, has no bones, has
142/// an unusable seam-adjacent time step, or contains a non-finite model-space
143/// position or rotation needed by the measurement.
144pub fn loop_continuity_metrics(grid: &PoseGrid) -> Option<Vec<BoneLoopContinuityMetrics>> {
145    let frames = grid.frame_count();
146    if frames < 3 || grid.bone_count() == 0 {
147        return None;
148    }
149
150    let first_dt = f64::from(grid.times[1] - grid.times[0]);
151    let last_dt = f64::from(grid.times[frames - 1] - grid.times[frames - 2]);
152    if !first_dt.is_finite() || !last_dt.is_finite() || first_dt <= 0.0 || last_dt <= 0.0 {
153        return None;
154    }
155
156    (0..grid.bone_count())
157        .map(|bone| {
158            let first = grid.model_position(0, bone);
159            let next = grid.model_position(1, bone);
160            let previous = grid.model_position(frames - 2, bone);
161            let last = grid.model_position(frames - 1, bone);
162            if [first, next, previous, last]
163                .iter()
164                .any(|position| !position.is_finite())
165            {
166                return None;
167            }
168
169            let rotations = [
170                grid.model_rotation(0, bone),
171                grid.model_rotation(1, bone),
172                grid.model_rotation(frames - 2, bone),
173                grid.model_rotation(frames - 1, bone),
174            ];
175            if rotations.iter().any(|rotation| {
176                !rotation.is_finite()
177                    || !rotation.length_squared().is_finite()
178                    || rotation.length_squared() == 0.0
179            }) {
180                return None;
181            }
182            let [
183                first_rotation,
184                next_rotation,
185                previous_rotation,
186                last_rotation,
187            ] = rotations.map(Quat::normalize);
188            let delta = first_rotation.conjugate() * last_rotation;
189            let [x, y, z, w] = delta.to_array();
190            let sin_half_angle = Vec3::new(x, y, z).length();
191            let rotation_delta_deg = f64::from(2.0 * sin_half_angle.atan2(w.abs()).to_degrees());
192            let position_delta_m = f64::from((last - first).length());
193            let outgoing_velocity = (next - first) / first_dt as f32;
194            let incoming_velocity = (last - previous) / last_dt as f32;
195            let seam_velocity_delta_mps =
196                f64::from((outgoing_velocity - incoming_velocity).length());
197            let outgoing_angular_velocity =
198                shortest_path_model_rotation_vector(first_rotation, next_rotation)?
199                    / first_dt as f32;
200            let incoming_angular_velocity =
201                shortest_path_model_rotation_vector(previous_rotation, last_rotation)?
202                    / last_dt as f32;
203            let seam_angular_velocity_delta_degps = f64::from(
204                (outgoing_angular_velocity - incoming_angular_velocity)
205                    .length()
206                    .to_degrees(),
207            );
208
209            if !position_delta_m.is_finite()
210                || !rotation_delta_deg.is_finite()
211                || !seam_velocity_delta_mps.is_finite()
212                || !seam_angular_velocity_delta_degps.is_finite()
213            {
214                return None;
215            }
216            Some(BoneLoopContinuityMetrics {
217                position_delta_m,
218                rotation_delta_deg,
219                seam_velocity_delta_mps,
220                seam_angular_velocity_delta_degps,
221            })
222        })
223        .collect()
224}
225
226/// Measure the foot cycle of a clip from its pose grid. Requires the
227/// Hips role and at least one foot role; returns `None` otherwise (the
228/// caller decides which typed coverage gap represents the missing metric).
229///
230/// The grid must span `[0, duration]` — the wrap pair is
231/// `(last frame, frame 0)`. Grids under 3 frames carry no cycle.
232///
233/// # Panics
234///
235/// Panics if `roles` contains bone indices outside `grid`. Role
236/// resolutions produced by this crate are tied to the same skeleton that
237/// produced the grid; embedders that hand-build roles must preserve that
238/// relationship.
239pub fn foot_cycle_metrics(
240    grid: &PoseGrid,
241    roles: &ResolvedRoles,
242    min_stride_step_m: f64,
243) -> Option<FootCycleMetrics> {
244    if grid.frame_count() < 3 {
245        return None;
246    }
247    let hips = roles.get(Role::Hips)?;
248    let left: Vec<usize> = [Role::LeftFoot, Role::LeftToe]
249        .iter()
250        .filter_map(|&r| roles.get(r))
251        .collect();
252    let right: Vec<usize> = [Role::RightFoot, Role::RightToe]
253        .iter()
254        .filter_map(|&r| roles.get(r))
255        .collect();
256    let feet: Vec<usize> = left.iter().chain(right.iter()).copied().collect();
257    if feet.is_empty() {
258        return None;
259    }
260
261    let frames = grid.frame_count();
262    // Feet relative to hips: cancels the in-place root so we measure
263    // the leg cycle, not body travel.
264    let rel = |frame: usize, bone: usize| -> Vec3 {
265        grid.model_position(frame, bone) - grid.model_position(frame, hips)
266    };
267    if (0..frames).any(|frame| {
268        !grid.model_position(frame, hips).is_finite()
269            || feet.iter().any(|&foot| !rel(frame, foot).is_finite())
270    }) {
271        return None;
272    }
273
274    // Loop seam: the wrap chord vs its NEIGHBOURING in-clip steps (the
275    // step into the last frame and the step out of the first) — local
276    // continuity, because stride speed varies legitimately inside a
277    // cycle and the wrap may sit at an arbitrary cycle position. A real
278    // pop is discontinuous against its immediate neighbours too.
279    let max_foot_dist = |a: usize, b: usize| -> f64 {
280        feet.iter()
281            .map(|&f| (rel(a, f) - rel(b, f)).length() as f64)
282            .fold(0.0, f64::max)
283    };
284    let seam = max_foot_dist(frames - 1, 0);
285    let step_first = max_foot_dist(1, 0);
286    let step_last = max_foot_dist(frames - 1, frames - 2);
287    let neighbour_step = step_first.max(step_last);
288    let loop_seam_ratio = if neighbour_step > 0.0 && neighbour_step >= min_stride_step_m {
289        let ratio = seam / neighbour_step;
290        ratio.is_finite().then_some(ratio)
291    } else {
292        None
293    };
294
295    // Gait phase: fundamental-harmonic trough of the L−R foot-height
296    // signal over one cycle (the duplicate wrap frame excluded). The
297    // difference cancels common-mode pelvis bob and encodes handedness
298    // plus a stable cycle anchor.
299    let cycle = if frames > 3 { frames - 1 } else { frames };
300    let mut gait_phase = None;
301    let mut lr_amplitude_m = 0.0f64;
302    if !left.is_empty() && !right.is_empty() {
303        let avg_height = |frame: usize, bones: &[usize]| -> f64 {
304            bones.iter().map(|&b| rel(frame, b).y as f64).sum::<f64>() / bones.len() as f64
305        };
306        let diff: Vec<f64> = (0..cycle)
307            .map(|f| avg_height(f, &left) - avg_height(f, &right))
308            .collect();
309        let max = diff.iter().copied().fold(f64::MIN, f64::max);
310        let min = diff.iter().copied().fold(f64::MAX, f64::min);
311        lr_amplitude_m = max - min;
312        gait_phase = fundamental_trough_phase(&diff);
313    }
314
315    Some(FootCycleMetrics {
316        loop_seam_ratio,
317        gait_phase,
318        lr_amplitude_m,
319    })
320}
321
322/// Normalized cycle position `[0,1)` of the minimum of the signal's
323/// first Fourier harmonic. Robust to plateaus and per-frame noise: the
324/// minimum of `A·cos(2π·t/N − φ)` sits at `t/N = (φ/2π + 0.5) mod 1`.
325pub fn fundamental_trough_phase(signal: &[f64]) -> Option<f64> {
326    let n = signal.len();
327    if n < 2 || signal.iter().any(|value| !value.is_finite()) {
328        return None;
329    }
330    let mut re = 0.0f64;
331    let mut im = 0.0f64;
332    for (k, y) in signal.iter().enumerate() {
333        let angle = std::f64::consts::TAU * k as f64 / n as f64;
334        re += y * angle.cos();
335        im += y * angle.sin();
336    }
337    let phi = im.atan2(re);
338    let phase = (phi / std::f64::consts::TAU + 0.5).rem_euclid(1.0);
339    phase.is_finite().then_some(phase)
340}
341
342/// Horizontal (XZ-plane) root displacement over the clip, divided by
343/// duration. Uses the Root role, falling back to Hips (clips without a
344/// dedicated root bone carry travel on the hips).
345///
346/// # Panics
347///
348/// Panics if the resolved Root or Hips bone id is outside `grid`.
349pub fn root_motion_speed_mps(grid: &PoseGrid, roles: &ResolvedRoles) -> Option<f64> {
350    let bone = roles.get(Role::Root).or_else(|| roles.get(Role::Hips))?;
351    let frames = grid.frame_count();
352    if frames < 2 {
353        return None;
354    }
355    let duration = *grid.times.last()? as f64;
356    if duration <= 0.0 {
357        return None;
358    }
359    let a = grid.model_position(0, bone);
360    let b = grid.model_position(frames - 1, bone);
361    let dx = (b.x - a.x) as f64;
362    let dz = (b.z - a.z) as f64;
363    let speed = dx.hypot(dz) / duration;
364    speed.is_finite().then_some(speed)
365}
366
367/// Maximum angular deviation (degrees) of a rotation track from its
368/// first keyed rotation.
369pub fn rotation_range_deg(track: &Track) -> Option<f64> {
370    if track.property != Property::Rotation {
371        return None;
372    }
373    let first = track.key_quat(0)?;
374    if !first.is_finite() || first.length_squared() == 0.0 {
375        return None;
376    }
377    let first = first.normalize();
378    let mut max_deg = 0.0f64;
379    for k in 1..track.key_count() {
380        if let Some(q) = track.key_quat(k)
381            && q.is_finite()
382            && q.length_squared() > 0.0
383        {
384            let deg = first.angle_between(q.normalize()).to_degrees() as f64;
385            if deg.is_finite() {
386                max_deg = max_deg.max(deg);
387            }
388        }
389    }
390    Some(max_deg)
391}
392
393/// Maximum circular distance (in cycle fraction, `[0, 0.5]`) of a set of
394/// normalized phases from their circular mean. Phases live on a ring, so
395/// a naive max−min would over-report a cluster straddling the 0/1 wrap.
396pub fn circular_phase_spread(phases: &[f64]) -> f64 {
397    use std::f64::consts::{PI, TAU};
398    let (mut sin_sum, mut cos_sum) = (0.0f64, 0.0f64);
399    for p in phases {
400        sin_sum += (p * TAU).sin();
401        cos_sum += (p * TAU).cos();
402    }
403    let mean = sin_sum.atan2(cos_sum);
404    let mut max_dev = 0.0f64;
405    for p in phases {
406        let mut d = (p * TAU - mean).abs() % TAU;
407        if d > PI {
408            d = TAU - d;
409        }
410        max_dev = max_dev.max(d / TAU);
411    }
412    max_dev
413}
414
415/// The metric sampling grid for a clip: uniform, resolution = max key
416/// count (mirroring how the runtime loops a clip over `[0, duration]`,
417/// wrapping duration→0 at render times unaligned with authored keys).
418/// `None` for clips too short to carry a cycle (< 3 keys), matching the
419/// reference implementation.
420pub fn metric_frame_count(clip: &Clip) -> Option<usize> {
421    let n = crate::sample::default_frame_count(clip);
422    if clip.duration_s <= 0.0 || n < 3 {
423        None
424    } else {
425        Some(n)
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use crate::check::CheckCtx;
433    use crate::config::Config;
434    use crate::measure::measure_document;
435    use crate::model::{
436        Bone, Clip, Document, Interpolation, Property, Skeleton, Track, TrackValues, Transform,
437    };
438    use crate::profile::{ResolvedRoles, Role};
439    use glam::{Quat, Vec3};
440    use std::rc::Rc;
441
442    fn document_with_metric_clip() -> Document {
443        Document {
444            skeleton: Skeleton {
445                bones: vec![Bone {
446                    name: "root".into(),
447                    parent: None,
448                    rest: Transform::IDENTITY,
449                    inverse_bind: None,
450                }],
451            },
452            clips: vec![Clip {
453                name: "walk".into(),
454                duration_s: 1.0,
455                tracks: vec![Track {
456                    bone: 0,
457                    property: Property::Rotation,
458                    interpolation: Interpolation::Linear,
459                    times: vec![0.0, 0.5, 1.0],
460                    values: TrackValues::Quats(vec![
461                        Quat::IDENTITY,
462                        Quat::from_rotation_y(0.1),
463                        Quat::from_rotation_y(0.2),
464                    ]),
465                }],
466            }],
467            ..Document::default()
468        }
469    }
470
471    fn document_with_grid_inputs(duration_s: f64, times: Vec<f32>) -> Document {
472        let values = vec![Quat::IDENTITY; times.len()];
473        Document {
474            skeleton: Skeleton {
475                bones: vec![Bone {
476                    name: "root".into(),
477                    parent: None,
478                    rest: Transform::IDENTITY,
479                    inverse_bind: None,
480                }],
481            },
482            clips: vec![Clip {
483                name: "probe".into(),
484                duration_s,
485                tracks: vec![Track {
486                    bone: 0,
487                    property: Property::Rotation,
488                    interpolation: Interpolation::Linear,
489                    times,
490                    values: TrackValues::Quats(values),
491                }],
492            }],
493            ..Document::default()
494        }
495    }
496
497    #[test]
498    fn metric_grids_are_shared_by_checks_and_measurements() {
499        let doc = document_with_metric_clip();
500        let roles = ResolvedRoles::default();
501        let config = Config::default();
502        let grids = MetricGrids::new(&doc);
503
504        let ctx = CheckCtx::new(&grids, &roles, &config);
505        let from_ctx = ctx.grid(0).expect("metric grid");
506        let from_owner = grids.grid(0).expect("same metric grid");
507        assert!(Rc::ptr_eq(&from_ctx, &from_owner));
508
509        let measurements = measure_document(&grids, &roles, &config);
510        assert!(measurements.contains_key("walk"));
511        let fresh_grids = MetricGrids::new(&doc);
512        assert_eq!(
513            serde_json::to_value(&measurements).expect("shared measurements serialize"),
514            serde_json::to_value(measure_document(&fresh_grids, &roles, &config))
515                .expect("plain measurements serialize")
516        );
517    }
518
519    #[test]
520    fn grid_returns_none_for_each_documented_invalid_request() {
521        let valid = document_with_grid_inputs(1.0, vec![0.0, 0.5, 1.0]);
522        let valid_grids = MetricGrids::new(&valid);
523        assert!(valid_grids.grid(0).is_some());
524        for clip_index in [1, 2, usize::MAX] {
525            assert!(valid_grids.grid(clip_index).is_none());
526        }
527
528        for duration_s in [0.0, -1.0] {
529            let non_positive = document_with_grid_inputs(duration_s, vec![0.0, 0.5, 1.0]);
530            assert!(MetricGrids::new(&non_positive).grid(0).is_none());
531        }
532
533        for times in [vec![], vec![0.0], vec![0.0, 1.0]] {
534            let too_few_keys = document_with_grid_inputs(1.0, times);
535            assert!(MetricGrids::new(&too_few_keys).grid(0).is_none());
536        }
537    }
538
539    #[test]
540    fn grid_uses_longest_track_for_resolution() {
541        // The first track is too short by itself; the later translation
542        // track selects the grid's three-frame resolution.
543        let mut doc = document_with_grid_inputs(1.0, vec![0.0, 1.0]);
544        doc.clips[0].tracks.push(Track {
545            bone: 0,
546            property: Property::Translation,
547            interpolation: Interpolation::Linear,
548            times: vec![0.0, 0.5, 1.0],
549            values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::X, 2.0 * Vec3::X]),
550        });
551
552        let grid = MetricGrids::new(&doc)
553            .grid(0)
554            .expect("later longest track supplies a metric grid");
555        assert_eq!(grid.frame_count(), 3);
556    }
557
558    #[test]
559    fn foot_metrics_reject_finite_positions_whose_relative_subtraction_overflows() {
560        let mut doc = document_with_metric_clip();
561        doc.skeleton.bones = vec![
562            Bone {
563                name: "hips".into(),
564                parent: None,
565                rest: Transform {
566                    translation: Vec3::splat(-f32::MAX),
567                    ..Transform::IDENTITY
568                },
569                inverse_bind: None,
570            },
571            Bone {
572                name: "left".into(),
573                parent: None,
574                rest: Transform {
575                    translation: Vec3::splat(f32::MAX),
576                    ..Transform::IDENTITY
577                },
578                inverse_bind: None,
579            },
580        ];
581        doc.clips[0].tracks[0].bone = 0;
582        let roles = ResolvedRoles::from_names(
583            &doc.skeleton,
584            [
585                (Role::Hips, "hips".to_string()),
586                (Role::LeftFoot, "left".to_string()),
587            ],
588        );
589        let grid = MetricGrids::new(&doc).grid(0).expect("metric grid");
590
591        assert!(grid.model_position(0, 0).is_finite());
592        assert!(grid.model_position(0, 1).is_finite());
593        assert!(foot_cycle_metrics(&grid, &roles, MIN_STRIDE_STEP_M).is_none());
594    }
595}