Skip to main content

animsmith_core/
metrics.rs

1//! Locomotion clip metrics: loop-seam ratio, gait phase, root-motion
2//! speed, and sampled root trajectory. The loop-seam, gait, and speed
3//! metrics were ported from a production game pipeline's reference
4//! implementation
5//! (verified there against Blender pose-matrix FK to <0.01×) — the
6//! algorithms are kept semantically identical so the numbers reproduce.
7
8use crate::model::{Clip, Document, Property, Track};
9use crate::profile::{ResolvedRoles, Role};
10use crate::sample::{PoseGrid, sample_clip};
11use glam::{DQuat, DVec3, Quat, Vec3};
12use serde::{Deserialize, Serialize};
13use std::cell::RefCell;
14use std::collections::BTreeMap;
15use std::rc::Rc;
16
17/// Below this per-frame foot move (metres), a clip has no real stride
18/// (idle / block / stationary action) and the seam ratio would be a
19/// divide-by-noise, so no ratio is reported.
20pub const MIN_STRIDE_STEP_M: f64 = 0.02;
21
22/// Half-turn tolerance used to refuse direction-ambiguous adjacent yaw steps
23/// and to preserve the unwrapped sign when canonicalizing multi-step endpoint
24/// results near ±180 degrees.
25pub const ROOT_YAW_HALF_TURN_AMBIGUITY_DEG: f64 = 1.0e-4;
26
27/// Lazily sampled metric pose grids for one document.
28///
29/// The check, measurement, and report pipelines all judge the same
30/// uniform metric grid. Sharing this owner lets callers run checks and
31/// then emit measurements or reports without sampling the same clip
32/// twice.
33///
34/// The cache uses `Rc` and `RefCell`, so it is intentionally neither
35/// `Send` nor `Sync`. Create one owner per document on each worker thread,
36/// then share it by reference among consumers on that thread.
37#[derive(Debug)]
38pub struct MetricGrids<'a> {
39    doc: &'a Document,
40    grids: RefCell<BTreeMap<usize, Rc<PoseGrid>>>,
41}
42
43impl<'a> MetricGrids<'a> {
44    /// Create a lazy metric-grid cache for `doc`.
45    pub fn new(doc: &'a Document) -> Self {
46        Self {
47            doc,
48            grids: RefCell::new(BTreeMap::new()),
49        }
50    }
51
52    /// The document these grids sample.
53    pub fn document(&self) -> &'a Document {
54        self.doc
55    }
56
57    /// The metric pose grid for clip `clip_index`, computed once and
58    /// shared. Returns `None` for an out-of-range index, non-positive
59    /// duration, or fewer than three keys on the longest track.
60    pub fn grid(&self, clip_index: usize) -> Option<Rc<PoseGrid>> {
61        let clip = self.doc.clips.get(clip_index)?;
62        let frames = metric_frame_count(clip)?;
63        Some(
64            self.grids
65                .borrow_mut()
66                .entry(clip_index)
67                .or_insert_with(|| Rc::new(sample_clip(&self.doc.skeleton, clip, frames)))
68                .clone(),
69        )
70    }
71}
72
73/// Foot-cycle metrics for one sampled clip.
74#[derive(Debug, Clone, PartialEq)]
75#[non_exhaustive]
76pub struct FootCycleMetrics {
77    /// Wrap discontinuity of the feet (relative to hips) over the max of
78    /// the two seam-adjacent in-clip steps. ≈1.0 for a clean cyclic
79    /// loop; well above 1 for a seam pop. `None` when the clip has no
80    /// real stride (see [`Self::has_real_stride`]), or when a real
81    /// stride exists but `seam / neighbour_step` is not finite. The
82    /// whole-clip position finiteness gate above only bounds individual
83    /// coordinates, not the `f32` squared-length arithmetic used to turn
84    /// two positions into a distance; a per-axis delta near `f32::MAX`
85    /// overflows that squaring to infinity even though every input
86    /// position was finite. That is the only known route to this case —
87    /// it requires magnitudes far outside any real animation.
88    pub loop_seam_ratio: Option<f64>,
89    /// Whether the seam-adjacent neighbour step met the configured
90    /// minimum stride threshold, i.e. whether the clip has a real
91    /// stride to normalize the seam against. `false` means
92    /// [`Self::loop_seam_ratio`]'s absence is an expected "no subject"
93    /// (a planted/idle clip), not a derivation failure.
94    pub has_real_stride: bool,
95    /// Cycle position `[0,1)` of the trough of the fundamental harmonic of the
96    /// left-minus-right foot-height signal — a stride-phase anchor encoding
97    /// handedness + cycle alignment. `None` when a side is missing, the
98    /// sampled signal has exact zero peak-to-peak swing, or the harmonic fit
99    /// fails.
100    pub gait_phase: Option<f64>,
101    /// Peak-to-peak swing of the L−R foot-height signal (metres); near
102    /// zero means no detectable alternation and the phase is noise.
103    pub lr_amplitude_m: f64,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq)]
107pub(crate) enum GaitPhaseOutcome {
108    MissingBilateralFootRoles,
109    NoFootHeightSwing,
110    Measured(f64),
111    Unavailable,
112}
113
114impl FootCycleMetrics {
115    pub(crate) fn gait_phase_outcome(&self, roles: &ResolvedRoles) -> GaitPhaseOutcome {
116        let has_left = roles.get(Role::LeftFoot).is_some() || roles.get(Role::LeftToe).is_some();
117        let has_right = roles.get(Role::RightFoot).is_some() || roles.get(Role::RightToe).is_some();
118        GaitPhaseOutcome::classify(self.gait_phase, self.lr_amplitude_m, has_left && has_right)
119    }
120}
121
122impl GaitPhaseOutcome {
123    fn classify(gait_phase: Option<f64>, lr_amplitude_m: f64, has_bilateral_roles: bool) -> Self {
124        match gait_phase {
125            _ if !has_bilateral_roles => Self::MissingBilateralFootRoles,
126            _ if lr_amplitude_m == 0.0 => Self::NoFootHeightSwing,
127            Some(phase) => Self::Measured(phase),
128            None => Self::Unavailable,
129        }
130    }
131}
132
133/// Model-space loop-continuity measurements for one skeleton bone.
134#[derive(Debug, Clone, PartialEq)]
135#[non_exhaustive]
136pub struct BoneLoopContinuityMetrics {
137    /// Last-sample to first-sample model-space position distance (metres).
138    pub position_delta_m: f64,
139    /// Shortest-path model-space rotation difference (degrees).
140    pub rotation_delta_deg: f64,
141    /// Difference between the model-space linear velocities immediately
142    /// before and after the wrap (metres per second).
143    pub seam_velocity_delta_mps: f64,
144    /// Difference between the model-space angular velocities immediately
145    /// before and after the wrap (degrees per second).
146    pub seam_angular_velocity_delta_degps: f64,
147}
148
149/// Sampled model-space translation and yaw for a selected Root/Hips bone.
150#[derive(Debug, Clone, PartialEq)]
151#[non_exhaustive]
152pub struct RootTrajectoryMetrics {
153    /// Translation facts when every selected-bone position is finite.
154    pub translation: Option<RootTranslationMetrics>,
155    /// Yaw facts when a fixed, deterministic horizontal heading witness remains
156    /// usable across the complete sampled trajectory.
157    pub yaw: Option<RootYawMetrics>,
158}
159
160/// Sampled model-space translation facts for a selected Root/Hips bone.
161#[derive(Debug, Clone, Copy, PartialEq)]
162#[non_exhaustive]
163pub struct RootTranslationMetrics {
164    /// Endpoint displacement along canonical model-space +X, in metres.
165    pub horizontal_displacement_x_m: f64,
166    /// Endpoint displacement along canonical model-space +Z, in metres.
167    pub horizontal_displacement_z_m: f64,
168    /// Sum of sampled model-space XZ step lengths, in metres.
169    pub horizontal_travel_m: f64,
170    /// Signed endpoint displacement along canonical model-space +Y, in metres.
171    pub vertical_displacement_m: f64,
172    /// Minimum signed +Y displacement from the initial sample, in metres.
173    pub vertical_min_displacement_m: f64,
174    /// Maximum signed +Y displacement from the initial sample, in metres.
175    pub vertical_max_displacement_m: f64,
176}
177
178/// Signed sampled yaw facts for a selected Root/Hips bone.
179#[derive(Debug, Clone, Copy, PartialEq)]
180#[non_exhaustive]
181pub struct RootYawMetrics {
182    /// Fixed local basis axis used as the horizontal heading witness.
183    pub heading_axis: RootYawHeadingAxis,
184    /// Shortest signed endpoint-equivalent yaw in `[-180, 180]` degrees.
185    /// An exact half turn retains the sign of [`Self::unwrapped_yaw_deg`].
186    pub net_yaw_deg: f64,
187    /// Signed first-to-last heading change after deterministic wrap crossing
188    /// unwrapping. Unlike endpoint orientation alone, a sampled full turn is
189    /// retained as approximately `+360` or `-360` degrees.
190    pub unwrapped_yaw_deg: f64,
191    /// Sum of absolute sampled unwrapped heading steps. This retains reversing
192    /// yaw motion that cancels in [`Self::unwrapped_yaw_deg`].
193    pub yaw_travel_deg: f64,
194}
195
196/// One local orientation witness retained for a complete model-space yaw
197/// measurement. Order is policy: conventional `+Z` wins an exact tie,
198/// followed by the common Z-up-source `+Y` convention and finally `+X`.
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "snake_case")]
201#[non_exhaustive]
202pub enum RootYawHeadingAxis {
203    /// Positive local Z axis.
204    PositiveZ,
205    /// Positive local Y axis.
206    PositiveY,
207    /// Positive local X axis.
208    PositiveX,
209}
210
211impl RootYawHeadingAxis {
212    /// Stable display label for the selected local heading witness.
213    pub const fn label(self) -> &'static str {
214        match self {
215            Self::PositiveZ => "+Z",
216            Self::PositiveY => "+Y",
217            Self::PositiveX => "+X",
218        }
219    }
220}
221
222/// Model-space horizontal `(x, z)` projection of one local unit basis axis.
223pub(crate) fn horizontal_heading(rotation: DQuat, axis: RootYawHeadingAxis) -> (f64, f64) {
224    let local_axis = match axis {
225        RootYawHeadingAxis::PositiveZ => DVec3::Z,
226        RootYawHeadingAxis::PositiveY => DVec3::Y,
227        RootYawHeadingAxis::PositiveX => DVec3::X,
228    };
229    let heading = rotation.mul_vec3(local_axis);
230    (heading.x, heading.z)
231}
232
233/// Select the best-conditioned local heading witness at sample zero. Exact
234/// ties retain the declared `+Z`, `+Y`, `+X` priority because replacement is
235/// strict rather than `>=`.
236pub(crate) fn select_horizontal_heading_axis(rotation: DQuat) -> RootYawHeadingAxis {
237    let mut selected = RootYawHeadingAxis::PositiveZ;
238    let (x, z) = horizontal_heading(rotation, selected);
239    let mut best_length = x.hypot(z);
240    for candidate in [RootYawHeadingAxis::PositiveY, RootYawHeadingAxis::PositiveX] {
241        let (x, z) = horizontal_heading(rotation, candidate);
242        let length = x.hypot(z);
243        if length > best_length {
244            selected = candidate;
245            best_length = length;
246        }
247    }
248    selected
249}
250
251/// Convert signed unwrapped yaw to its canonical endpoint-equivalent value.
252/// Endpoint-equivalent results within
253/// [`ROOT_YAW_HALF_TURN_AMBIGUITY_DEG`] of a half turn retain the sign of the
254/// unwrapped result so analytic `+180` and `-180` trajectories remain
255/// distinguishable despite binary32 quaternion roundoff.
256pub(crate) fn canonical_net_yaw_deg(unwrapped_yaw_deg: f64) -> f64 {
257    let mut net = (unwrapped_yaw_deg + 180.0).rem_euclid(360.0) - 180.0;
258    if (net.abs() - 180.0).abs() <= ROOT_YAW_HALF_TURN_AMBIGUITY_DEG {
259        net = 180.0f64.copysign(unwrapped_yaw_deg);
260    }
261    net
262}
263
264/// Measure model-space translation and sampled yaw for `bone`.
265///
266/// AnimSmith's metric domain is right-handed, +Y-up metres; horizontal
267/// displacement is therefore the signed X/Z endpoint vector and vertical
268/// evidence is signed +Y displacement from sample zero. The existing uniform
269/// [`PoseGrid`] is authoritative. Yaw chooses at sample zero whichever local
270/// `+Z`, `+Y`, or `+X` axis has the greatest horizontal projection, retains
271/// that witness for every sample, and unwraps crossings at ±180 degrees.
272/// Positive yaw increases `atan2(x, z)`; for a +Z-aligned witness this rotates
273/// +Z toward +X, the positive right-handed direction around normalized +Y. A multi-step
274/// result within [`ROOT_YAW_HALF_TURN_AMBIGUITY_DEG`] of a half turn is
275/// canonicalized to signed ±180 using the unwrapped sign.
276/// Exact 180-degree adjacent steps are ambiguous and make only yaw unavailable.
277///
278/// Returns `None` when the selected bone is outside the grid or fewer than two
279/// samples exist. Translation and yaw are derived independently: non-finite
280/// positions set [`RootTrajectoryMetrics::translation`] to `None`, while
281/// rotation or heading failures set [`RootTrajectoryMetrics::yaw`] to `None`.
282pub fn root_trajectory_metrics(grid: &PoseGrid, bone: usize) -> Option<RootTrajectoryMetrics> {
283    let frames = grid.frame_count();
284    if frames < 2 || bone >= grid.bone_count() {
285        return None;
286    }
287
288    let first = grid.model_position(0, bone);
289    let mut translation_valid = first.is_finite();
290    let first_x = f64::from(first.x);
291    let first_y = f64::from(first.y);
292    let first_z = f64::from(first.z);
293    let mut last_x = first_x;
294    let mut last_y = first_y;
295    let mut last_z = first_z;
296    let mut previous_x = first_x;
297    let mut previous_z = first_z;
298    let mut horizontal_travel_m = 0.0f64;
299    let mut vertical_min_displacement_m = 0.0f64;
300    let mut vertical_max_displacement_m = 0.0f64;
301
302    let mut yaw_valid = true;
303    let mut heading_axis = None;
304    let mut first_heading_deg: Option<f64> = None;
305    let mut previous_heading_deg: Option<f64> = None;
306    let mut winding_turns = 0i64;
307    let mut yaw_travel_deg = 0.0f64;
308
309    for frame in 0..frames {
310        let position = grid.model_position(frame, bone);
311        if !position.is_finite() {
312            translation_valid = false;
313        } else if translation_valid {
314            last_x = f64::from(position.x);
315            last_y = f64::from(position.y);
316            last_z = f64::from(position.z);
317            if frame > 0 {
318                horizontal_travel_m += (last_x - previous_x).hypot(last_z - previous_z);
319            }
320            previous_x = last_x;
321            previous_z = last_z;
322            let vertical = last_y - first_y;
323            vertical_min_displacement_m = vertical_min_displacement_m.min(vertical);
324            vertical_max_displacement_m = vertical_max_displacement_m.max(vertical);
325        }
326
327        if !yaw_valid {
328            continue;
329        }
330        let rotation = grid.model_rotation(frame, bone);
331        let length_squared = rotation.length_squared();
332        if !rotation.is_finite() || !length_squared.is_finite() || length_squared == 0.0 {
333            yaw_valid = false;
334            continue;
335        }
336        let rotation = rotation.as_dquat().normalize();
337        let axis = *heading_axis.get_or_insert_with(|| select_horizontal_heading_axis(rotation));
338        let (heading_x, heading_z) = horizontal_heading(rotation, axis);
339        let horizontal_length = heading_x.hypot(heading_z);
340        if !horizontal_length.is_finite() || horizontal_length <= f64::from(f32::EPSILON) {
341            yaw_valid = false;
342            continue;
343        }
344        let heading_deg = heading_x.atan2(heading_z).to_degrees();
345        if let Some(previous) = previous_heading_deg {
346            let raw_delta = heading_deg - previous;
347            if (raw_delta.abs() - 180.0).abs() <= ROOT_YAW_HALF_TURN_AMBIGUITY_DEG {
348                yaw_valid = false;
349                continue;
350            }
351            if raw_delta > 180.0 {
352                winding_turns -= 1;
353                yaw_travel_deg += (raw_delta - 360.0).abs();
354            } else if raw_delta < -180.0 {
355                winding_turns += 1;
356                yaw_travel_deg += (raw_delta + 360.0).abs();
357            } else {
358                yaw_travel_deg += raw_delta.abs();
359            }
360        } else {
361            first_heading_deg = Some(heading_deg);
362        }
363        previous_heading_deg = Some(heading_deg);
364    }
365
366    // `PoseGrid` positions are binary32 and the grid cannot exceed
367    // `usize::MAX` frames, so finite samples cannot overflow these widened
368    // binary64 endpoint, extrema, or accumulated-travel calculations. Keep
369    // the final filter as a fail-closed boundary if either representation
370    // changes; model-space FK overflow is rejected in the loop above.
371    let translation = translation_valid
372        .then_some(RootTranslationMetrics {
373            horizontal_displacement_x_m: last_x - first_x,
374            horizontal_displacement_z_m: last_z - first_z,
375            horizontal_travel_m,
376            vertical_displacement_m: last_y - first_y,
377            vertical_min_displacement_m,
378            vertical_max_displacement_m,
379        })
380        .filter(|translation| {
381            [
382                translation.horizontal_displacement_x_m,
383                translation.horizontal_displacement_z_m,
384                translation.horizontal_travel_m,
385                translation.vertical_displacement_m,
386                translation.vertical_min_displacement_m,
387                translation.vertical_max_displacement_m,
388            ]
389            .into_iter()
390            .all(f64::is_finite)
391        });
392
393    let yaw = yaw_valid.then(|| {
394        let unwrapped_yaw_deg = previous_heading_deg.expect("non-empty pose grid")
395            - first_heading_deg.expect("non-empty pose grid")
396            + winding_turns as f64 * 360.0;
397        RootYawMetrics {
398            heading_axis: heading_axis.expect("non-empty pose grid"),
399            net_yaw_deg: canonical_net_yaw_deg(unwrapped_yaw_deg),
400            unwrapped_yaw_deg,
401            yaw_travel_deg,
402        }
403    });
404    let yaw = yaw.filter(|yaw| {
405        yaw.net_yaw_deg.is_finite()
406            && yaw.unwrapped_yaw_deg.is_finite()
407            && yaw.yaw_travel_deg.is_finite()
408    });
409
410    Some(RootTrajectoryMetrics { translation, yaw })
411}
412
413/// Return the shortest-path model-space rotation vector from `from` to `to`.
414///
415/// The left-relative step (`to * from⁻¹`) expresses the angular direction in
416/// model space. Canonicalizing the quaternion hemisphere makes the result
417/// invariant to the equivalent `q`/`-q` representation. At exactly 180
418/// degrees, where `w` cannot choose a hemisphere, the first non-zero vector
419/// component breaks the tie deterministically.
420fn shortest_path_model_rotation_vector(from: Quat, to: Quat) -> Option<Vec3> {
421    let mut step = to * from.conjugate();
422    if !step.is_finite() {
423        return None;
424    }
425
426    let [x, y, z, w] = step.to_array();
427    if w < 0.0 || (w == 0.0 && (x < 0.0 || (x == 0.0 && (y < 0.0 || (y == 0.0 && z < 0.0))))) {
428        step = -step;
429    }
430
431    let vector = step.xyz();
432    let sin_half_angle = vector.length();
433    if !sin_half_angle.is_finite() {
434        return None;
435    }
436    if sin_half_angle == 0.0 {
437        return Some(Vec3::ZERO);
438    }
439
440    let angle_rad = 2.0 * sin_half_angle.atan2(step.w);
441    let rotation_vector = vector * (angle_rad / sin_half_angle);
442    rotation_vector.is_finite().then_some(rotation_vector)
443}
444
445/// Measure C0 pose closure plus C1 linear- and angular-velocity continuity
446/// independently for every bone.
447///
448/// The grid spans `[0, duration]`, including both endpoints. C1 continuity is
449/// therefore the difference between the in-clip step entering the last sample
450/// and the in-clip step leaving frame 0. Treating the last-to-first endpoint
451/// chord as a velocity would assign zero velocity to a perfectly closed loop.
452///
453/// Returns `None` when the shared grid has fewer than three frames, has no
454/// bones, or has an unusable seam-adjacent time step. A row is `None` only
455/// when that bone's seam-adjacent model-space evidence is unusable; one bad
456/// bone never suppresses finite evidence for another bone.
457pub fn loop_continuity_metrics(grid: &PoseGrid) -> Option<Vec<Option<BoneLoopContinuityMetrics>>> {
458    let frames = grid.frame_count();
459    if frames < 3 || grid.bone_count() == 0 {
460        return None;
461    }
462
463    let first_dt = f64::from(grid.times[1] - grid.times[0]);
464    let last_dt = f64::from(grid.times[frames - 1] - grid.times[frames - 2]);
465    if !first_dt.is_finite() || !last_dt.is_finite() || first_dt <= 0.0 || last_dt <= 0.0 {
466        return None;
467    }
468
469    Some(
470        (0..grid.bone_count())
471            .map(|bone| {
472                let first = grid.model_position(0, bone);
473                let next = grid.model_position(1, bone);
474                let previous = grid.model_position(frames - 2, bone);
475                let last = grid.model_position(frames - 1, bone);
476                if [first, next, previous, last]
477                    .iter()
478                    .any(|position| !position.is_finite())
479                {
480                    return None;
481                }
482
483                let rotations = [
484                    grid.model_rotation(0, bone),
485                    grid.model_rotation(1, bone),
486                    grid.model_rotation(frames - 2, bone),
487                    grid.model_rotation(frames - 1, bone),
488                ];
489                if rotations.iter().any(|rotation| {
490                    !rotation.is_finite()
491                        || !rotation.length_squared().is_finite()
492                        || rotation.length_squared() == 0.0
493                }) {
494                    return None;
495                }
496                let [
497                    first_rotation,
498                    next_rotation,
499                    previous_rotation,
500                    last_rotation,
501                ] = rotations.map(Quat::normalize);
502                let delta = first_rotation.conjugate() * last_rotation;
503                let [x, y, z, w] = delta.to_array();
504                let sin_half_angle = Vec3::new(x, y, z).length();
505                let rotation_delta_deg =
506                    f64::from(2.0 * sin_half_angle.atan2(w.abs()).to_degrees());
507                let position_delta_m = f64::from((last - first).length());
508                let outgoing_velocity = (next - first) / first_dt as f32;
509                let incoming_velocity = (last - previous) / last_dt as f32;
510                let seam_velocity_delta_mps =
511                    f64::from((outgoing_velocity - incoming_velocity).length());
512                let outgoing_angular_velocity =
513                    shortest_path_model_rotation_vector(first_rotation, next_rotation)?
514                        / first_dt as f32;
515                let incoming_angular_velocity =
516                    shortest_path_model_rotation_vector(previous_rotation, last_rotation)?
517                        / last_dt as f32;
518                let seam_angular_velocity_delta_degps = f64::from(
519                    (outgoing_angular_velocity - incoming_angular_velocity)
520                        .length()
521                        .to_degrees(),
522                );
523
524                if !position_delta_m.is_finite()
525                    || !rotation_delta_deg.is_finite()
526                    || !seam_velocity_delta_mps.is_finite()
527                    || !seam_angular_velocity_delta_degps.is_finite()
528                {
529                    return None;
530                }
531                Some(BoneLoopContinuityMetrics {
532                    position_delta_m,
533                    rotation_delta_deg,
534                    seam_velocity_delta_mps,
535                    seam_angular_velocity_delta_degps,
536                })
537            })
538            .collect(),
539    )
540}
541
542/// Measure the foot cycle of a clip from its pose grid. Requires the
543/// Hips role and at least one foot role; returns `None` otherwise (the
544/// caller decides which typed coverage gap represents the missing metric).
545///
546/// The grid must span `[0, duration]` — the wrap pair is
547/// `(last frame, frame 0)`. Grids under 3 frames carry no cycle.
548///
549/// # Panics
550///
551/// Panics if `roles` contains bone indices outside `grid`. Role
552/// resolutions produced by this crate are tied to the same skeleton that
553/// produced the grid; embedders that hand-build roles must preserve that
554/// relationship.
555pub fn foot_cycle_metrics(
556    grid: &PoseGrid,
557    roles: &ResolvedRoles,
558    min_stride_step_m: f64,
559) -> Option<FootCycleMetrics> {
560    if grid.frame_count() < 3 {
561        return None;
562    }
563    let hips = roles.get(Role::Hips)?;
564    let left: Vec<usize> = [Role::LeftFoot, Role::LeftToe]
565        .iter()
566        .filter_map(|&r| roles.get(r))
567        .collect();
568    let right: Vec<usize> = [Role::RightFoot, Role::RightToe]
569        .iter()
570        .filter_map(|&r| roles.get(r))
571        .collect();
572    let feet: Vec<usize> = left.iter().chain(right.iter()).copied().collect();
573    if feet.is_empty() {
574        return None;
575    }
576
577    let frames = grid.frame_count();
578    // Feet relative to hips: cancels the in-place root so we measure
579    // the leg cycle, not body travel.
580    let rel = |frame: usize, bone: usize| -> Vec3 {
581        grid.model_position(frame, bone) - grid.model_position(frame, hips)
582    };
583    if (0..frames).any(|frame| {
584        !grid.model_position(frame, hips).is_finite()
585            || feet.iter().any(|&foot| !rel(frame, foot).is_finite())
586    }) {
587        return None;
588    }
589
590    // Loop seam: the wrap chord vs its NEIGHBOURING in-clip steps (the
591    // step into the last frame and the step out of the first) — local
592    // continuity, because stride speed varies legitimately inside a
593    // cycle and the wrap may sit at an arbitrary cycle position. A real
594    // pop is discontinuous against its immediate neighbours too.
595    let max_foot_dist = |a: usize, b: usize| -> f64 {
596        feet.iter()
597            .map(|&f| (rel(a, f) - rel(b, f)).length() as f64)
598            .fold(0.0, f64::max)
599    };
600    let seam = max_foot_dist(frames - 1, 0);
601    let step_first = max_foot_dist(1, 0);
602    let step_last = max_foot_dist(frames - 1, frames - 2);
603    let neighbour_step = step_first.max(step_last);
604    let has_real_stride = neighbour_step > 0.0 && neighbour_step >= min_stride_step_m;
605    let loop_seam_ratio = if has_real_stride {
606        let ratio = seam / neighbour_step;
607        ratio.is_finite().then_some(ratio)
608    } else {
609        None
610    };
611
612    // Gait phase: fundamental-harmonic trough of the L−R foot-height
613    // signal over one cycle (the duplicate wrap frame excluded). The
614    // difference cancels common-mode pelvis bob and encodes handedness
615    // plus a stable cycle anchor.
616    let cycle = if frames > 3 { frames - 1 } else { frames };
617    let mut gait_phase = None;
618    let mut lr_amplitude_m = 0.0f64;
619    if !left.is_empty() && !right.is_empty() {
620        let avg_height = |frame: usize, bones: &[usize]| -> f64 {
621            bones.iter().map(|&b| rel(frame, b).y as f64).sum::<f64>() / bones.len() as f64
622        };
623        let diff: Vec<f64> = (0..cycle)
624            .map(|f| avg_height(f, &left) - avg_height(f, &right))
625            .collect();
626        let max = diff.iter().copied().fold(f64::MIN, f64::max);
627        let min = diff.iter().copied().fold(f64::MAX, f64::min);
628        lr_amplitude_m = max - min;
629        if lr_amplitude_m > 0.0 {
630            gait_phase = fundamental_trough_phase(&diff);
631        }
632    }
633
634    Some(FootCycleMetrics {
635        loop_seam_ratio,
636        has_real_stride,
637        gait_phase,
638        lr_amplitude_m,
639    })
640}
641
642/// Normalized cycle position `[0,1)` of the minimum of the signal's
643/// first Fourier harmonic. Robust to plateaus and per-frame noise: the
644/// minimum of `A·cos(2π·t/N − φ)` sits at `t/N = (φ/2π + 0.5) mod 1`.
645pub fn fundamental_trough_phase(signal: &[f64]) -> Option<f64> {
646    let n = signal.len();
647    if n < 2 || signal.iter().any(|value| !value.is_finite()) {
648        return None;
649    }
650    let mut re = 0.0f64;
651    let mut im = 0.0f64;
652    for (k, y) in signal.iter().enumerate() {
653        let angle = std::f64::consts::TAU * k as f64 / n as f64;
654        re += y * angle.cos();
655        im += y * angle.sin();
656    }
657    let phi = im.atan2(re);
658    let phase = (phi / std::f64::consts::TAU + 0.5).rem_euclid(1.0);
659    phase.is_finite().then_some(phase)
660}
661
662/// Horizontal (XZ-plane) root displacement over the clip, divided by
663/// duration. Uses the Root role whenever it resolves and falls back to Hips
664/// only when Root is unresolved (clips without a dedicated root bone carry
665/// travel on the hips). Returns `None` rather than falling back when the
666/// selected role index is outside `grid`, the grid is too short, duration is
667/// non-positive, or the derived speed is non-finite.
668pub fn root_motion_speed_mps(grid: &PoseGrid, roles: &ResolvedRoles) -> Option<f64> {
669    let bone = roles.get(Role::Root).or_else(|| roles.get(Role::Hips))?;
670    let frames = grid.frame_count();
671    if frames < 2 || bone >= grid.bone_count() {
672        return None;
673    }
674    let duration = *grid.times.last()? as f64;
675    if duration <= 0.0 {
676        return None;
677    }
678    let a = grid.model_position(0, bone);
679    let b = grid.model_position(frames - 1, bone);
680    let dx = (b.x - a.x) as f64;
681    let dz = (b.z - a.z) as f64;
682    let speed = dx.hypot(dz) / duration;
683    speed.is_finite().then_some(speed)
684}
685
686/// Maximum angular deviation (degrees) of a rotation track from its
687/// first keyed rotation.
688pub fn rotation_range_deg(track: &Track) -> Option<f64> {
689    if track.property != Property::Rotation {
690        return None;
691    }
692    let first = track.key_quat(0)?;
693    if !first.is_finite() || first.length_squared() == 0.0 {
694        return None;
695    }
696    let first = first.normalize();
697    let mut max_deg = 0.0f64;
698    for k in 1..track.key_count() {
699        if let Some(q) = track.key_quat(k)
700            && q.is_finite()
701            && q.length_squared() > 0.0
702        {
703            let deg = first.angle_between(q.normalize()).to_degrees() as f64;
704            if deg.is_finite() {
705                max_deg = max_deg.max(deg);
706            }
707        }
708    }
709    Some(max_deg)
710}
711
712/// Maximum circular distance (in cycle fraction, `[0, 0.5]`) of a set of
713/// normalized phases from their circular mean. Phases live on a ring, so
714/// a naive max−min would over-report a cluster straddling the 0/1 wrap.
715pub fn circular_phase_spread(phases: &[f64]) -> f64 {
716    use std::f64::consts::{PI, TAU};
717    let (mut sin_sum, mut cos_sum) = (0.0f64, 0.0f64);
718    for p in phases {
719        sin_sum += (p * TAU).sin();
720        cos_sum += (p * TAU).cos();
721    }
722    let mean = sin_sum.atan2(cos_sum);
723    let mut max_dev = 0.0f64;
724    for p in phases {
725        let mut d = (p * TAU - mean).abs() % TAU;
726        if d > PI {
727            d = TAU - d;
728        }
729        max_dev = max_dev.max(d / TAU);
730    }
731    max_dev
732}
733
734/// The metric sampling grid for a clip: uniform, resolution = max key
735/// count (mirroring how the runtime loops a clip over `[0, duration]`,
736/// wrapping duration→0 at render times unaligned with authored keys).
737/// `None` for clips too short to carry a cycle (< 3 keys), matching the
738/// reference implementation.
739pub fn metric_frame_count(clip: &Clip) -> Option<usize> {
740    let n = crate::sample::default_frame_count(clip);
741    if clip.duration_s <= 0.0 || n < 3 {
742        None
743    } else {
744        Some(n)
745    }
746}
747
748#[cfg(test)]
749mod tests {
750    use super::*;
751    use crate::check::CheckCtx;
752    use crate::config::Config;
753    use crate::measure::{RootTrajectorySourceRole, measure_document};
754    use crate::model::{
755        Bone, Clip, Document, Interpolation, Property, Skeleton, Track, TrackValues, Transform,
756    };
757    use crate::profile::{ResolvedRoles, Role};
758    use glam::{EulerRot, Mat3, Quat, Vec3};
759    use std::rc::Rc;
760
761    #[test]
762    fn gait_phase_outcome_retains_the_defensive_derivation_failure_state() {
763        assert_eq!(
764            GaitPhaseOutcome::classify(None, 0.01, true),
765            GaitPhaseOutcome::Unavailable
766        );
767        assert_eq!(
768            GaitPhaseOutcome::classify(Some(0.25), 0.01, true),
769            GaitPhaseOutcome::Measured(0.25)
770        );
771        assert_eq!(
772            GaitPhaseOutcome::classify(None, 0.0, true),
773            GaitPhaseOutcome::NoFootHeightSwing
774        );
775        assert_eq!(
776            GaitPhaseOutcome::classify(None, 0.0, false),
777            GaitPhaseOutcome::MissingBilateralFootRoles
778        );
779    }
780
781    fn document_with_metric_clip() -> Document {
782        Document {
783            skeleton: Skeleton {
784                bones: vec![Bone {
785                    name: "root".into(),
786                    parent: None,
787                    rest: Transform::IDENTITY,
788                    inverse_bind: None,
789                }],
790            },
791            clips: vec![Clip {
792                name: "walk".into(),
793                duration_s: 1.0,
794                tracks: vec![Track {
795                    bone: 0,
796                    property: Property::Rotation,
797                    interpolation: Interpolation::Linear,
798                    times: vec![0.0, 0.5, 1.0],
799                    values: TrackValues::Quats(vec![
800                        Quat::IDENTITY,
801                        Quat::from_rotation_y(0.1),
802                        Quat::from_rotation_y(0.2),
803                    ]),
804                }],
805            }],
806            ..Document::default()
807        }
808    }
809
810    fn document_with_grid_inputs(duration_s: f64, times: Vec<f32>) -> Document {
811        let values = vec![Quat::IDENTITY; times.len()];
812        Document {
813            skeleton: Skeleton {
814                bones: vec![Bone {
815                    name: "root".into(),
816                    parent: None,
817                    rest: Transform::IDENTITY,
818                    inverse_bind: None,
819                }],
820            },
821            clips: vec![Clip {
822                name: "probe".into(),
823                duration_s,
824                tracks: vec![Track {
825                    bone: 0,
826                    property: Property::Rotation,
827                    interpolation: Interpolation::Linear,
828                    times,
829                    values: TrackValues::Quats(values),
830                }],
831            }],
832            ..Document::default()
833        }
834    }
835
836    #[test]
837    fn metric_grids_are_shared_by_checks_and_measurements() {
838        let doc = document_with_metric_clip();
839        let roles = ResolvedRoles::default();
840        let config = Config::default();
841        let grids = MetricGrids::new(&doc);
842
843        let ctx = CheckCtx::new(&grids, &roles, &config);
844        let from_ctx = ctx.grid(0).expect("metric grid");
845        let from_owner = grids.grid(0).expect("same metric grid");
846        assert!(Rc::ptr_eq(&from_ctx, &from_owner));
847
848        let measurements = measure_document(&grids, &roles, &config);
849        assert!(measurements.contains_key("walk"));
850        let fresh_grids = MetricGrids::new(&doc);
851        assert_eq!(
852            serde_json::to_value(&measurements).expect("shared measurements serialize"),
853            serde_json::to_value(measure_document(&fresh_grids, &roles, &config))
854                .expect("plain measurements serialize")
855        );
856    }
857
858    #[test]
859    fn grid_returns_none_for_each_documented_invalid_request() {
860        let valid = document_with_grid_inputs(1.0, vec![0.0, 0.5, 1.0]);
861        let valid_grids = MetricGrids::new(&valid);
862        assert!(valid_grids.grid(0).is_some());
863        for clip_index in [1, 2, usize::MAX] {
864            assert!(valid_grids.grid(clip_index).is_none());
865        }
866
867        for duration_s in [0.0, -1.0] {
868            let non_positive = document_with_grid_inputs(duration_s, vec![0.0, 0.5, 1.0]);
869            assert!(MetricGrids::new(&non_positive).grid(0).is_none());
870        }
871
872        for times in [vec![], vec![0.0], vec![0.0, 1.0]] {
873            let too_few_keys = document_with_grid_inputs(1.0, times);
874            assert!(MetricGrids::new(&too_few_keys).grid(0).is_none());
875        }
876    }
877
878    #[test]
879    fn grid_uses_longest_track_for_resolution() {
880        // The first track is too short by itself; the later translation
881        // track selects the grid's three-frame resolution.
882        let mut doc = document_with_grid_inputs(1.0, vec![0.0, 1.0]);
883        doc.clips[0].tracks.push(Track {
884            bone: 0,
885            property: Property::Translation,
886            interpolation: Interpolation::Linear,
887            times: vec![0.0, 0.5, 1.0],
888            values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::X, 2.0 * Vec3::X]),
889        });
890
891        let grid = MetricGrids::new(&doc)
892            .grid(0)
893            .expect("later longest track supplies a metric grid");
894        assert_eq!(grid.frame_count(), 3);
895    }
896
897    #[test]
898    fn unrelated_dense_track_changes_shared_sampled_trajectory_not_the_analytic_curve() {
899        let skeleton = Skeleton {
900            bones: vec![
901                Bone {
902                    name: "root".into(),
903                    parent: None,
904                    rest: Transform::IDENTITY,
905                    inverse_bind: None,
906                },
907                Bone {
908                    name: "unrelated".into(),
909                    parent: None,
910                    rest: Transform::IDENTITY,
911                    inverse_bind: None,
912                },
913            ],
914        };
915        let target_tracks = vec![
916            Track {
917                bone: 0,
918                property: Property::Translation,
919                interpolation: Interpolation::Linear,
920                times: vec![0.0, 0.25, 1.0],
921                values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::new(1.0, 2.0, 0.0), Vec3::ZERO]),
922            },
923            Track {
924                bone: 0,
925                property: Property::Rotation,
926                interpolation: Interpolation::Linear,
927                times: vec![0.0, 0.25, 1.0],
928                values: TrackValues::Quats(
929                    [0.0_f32, 170.0, 340.0]
930                        .map(|degrees| Quat::from_rotation_y(degrees.to_radians()))
931                        .to_vec(),
932                ),
933            },
934        ];
935        let document = Document {
936            skeleton: skeleton.clone(),
937            clips: vec![Clip {
938                name: "aliased".into(),
939                duration_s: 1.0,
940                tracks: target_tracks.clone(),
941            }],
942            ..Document::default()
943        };
944        let coarse_grid = MetricGrids::new(&document)
945            .grid(0)
946            .expect("three-sample grid");
947        assert_eq!(coarse_grid.frame_count(), 3);
948        let coarse = root_trajectory_metrics(&coarse_grid, 0).expect("coarse trajectory");
949        let coarse_translation = coarse.translation.expect("coarse translation");
950        let coarse_yaw = coarse.yaw.expect("coarse yaw");
951
952        let analytic_peak_y = 2.0;
953        assert!((coarse_translation.horizontal_travel_m - 4.0 / 3.0).abs() < 1.0e-5);
954        assert!((coarse_translation.vertical_max_displacement_m - 4.0 / 3.0).abs() < 1.0e-5);
955        assert!(coarse_translation.vertical_max_displacement_m < analytic_peak_y);
956        assert!((coarse_yaw.net_yaw_deg - -20.0).abs() < 1.0e-4);
957        assert!((coarse_yaw.unwrapped_yaw_deg - -20.0).abs() < 1.0e-4);
958        assert!((coarse_yaw.yaw_travel_deg - 740.0 / 3.0).abs() < 1.0e-3);
959
960        let mut dense_document = Document {
961            skeleton,
962            clips: vec![Clip {
963                name: "aliased".into(),
964                duration_s: 1.0,
965                tracks: target_tracks,
966            }],
967            ..Document::default()
968        };
969        dense_document.clips[0].tracks.push(Track {
970            bone: 1,
971            property: Property::Scale,
972            interpolation: Interpolation::Linear,
973            times: vec![0.0, 0.25, 0.5, 0.75, 1.0],
974            values: TrackValues::Vec3s(vec![Vec3::ONE; 5]),
975        });
976        let dense_grid = MetricGrids::new(&dense_document)
977            .grid(0)
978            .expect("unrelated track selects five-sample grid");
979        assert_eq!(dense_grid.frame_count(), 5);
980        let dense = root_trajectory_metrics(&dense_grid, 0).expect("dense trajectory");
981        let dense_translation = dense.translation.expect("dense translation");
982        let dense_yaw = dense.yaw.expect("dense yaw");
983
984        assert!((dense_translation.horizontal_travel_m - 2.0).abs() < 1.0e-5);
985        assert_eq!(
986            dense_translation.vertical_max_displacement_m,
987            analytic_peak_y
988        );
989        assert!((dense_yaw.net_yaw_deg - -20.0).abs() < 1.0e-4);
990        assert!((dense_yaw.unwrapped_yaw_deg - 340.0).abs() < 1.0e-3);
991        assert!((dense_yaw.yaw_travel_deg - 340.0).abs() < 1.0e-3);
992
993        let coarse_roles =
994            ResolvedRoles::from_names(&document.skeleton, [(Role::Root, "root".into())]);
995        let coarse_measurements = measure_document(
996            &MetricGrids::new(&document),
997            &coarse_roles,
998            &Config::default(),
999        );
1000        let coarse_published = coarse_measurements["aliased"]
1001            .root_trajectory
1002            .as_ref()
1003            .expect("public coarse Root trajectory");
1004        assert_eq!(coarse_published.source_role, RootTrajectorySourceRole::Root);
1005        let coarse_published_translation = coarse_published.translation.unwrap();
1006        let coarse_published_yaw = coarse_published.yaw.unwrap();
1007        assert!((coarse_published_translation.horizontal_travel_m - 4.0 / 3.0).abs() < 1.0e-5);
1008        assert!(
1009            (coarse_published_translation.vertical_max_displacement_m - 4.0 / 3.0).abs() < 1.0e-5
1010        );
1011        assert!((coarse_published_yaw.unwrapped_yaw_deg - -20.0).abs() < 1.0e-4);
1012        assert!((coarse_published_yaw.yaw_travel_deg - 740.0 / 3.0).abs() < 1.0e-3);
1013
1014        let dense_roles =
1015            ResolvedRoles::from_names(&dense_document.skeleton, [(Role::Root, "root".into())]);
1016        let dense_measurements = measure_document(
1017            &MetricGrids::new(&dense_document),
1018            &dense_roles,
1019            &Config::default(),
1020        );
1021        let dense_published = dense_measurements["aliased"]
1022            .root_trajectory
1023            .as_ref()
1024            .expect("public dense Root trajectory");
1025        assert_eq!(dense_published.source_role, RootTrajectorySourceRole::Root);
1026        let dense_published_translation = dense_published.translation.unwrap();
1027        let dense_published_yaw = dense_published.yaw.unwrap();
1028        assert!((dense_published_translation.horizontal_travel_m - 2.0).abs() < 1.0e-5);
1029        assert_eq!(
1030            dense_published_translation.vertical_max_displacement_m,
1031            analytic_peak_y
1032        );
1033        assert!((dense_published_yaw.unwrapped_yaw_deg - 340.0).abs() < 1.0e-3);
1034        assert!((dense_published_yaw.yaw_travel_deg - 340.0).abs() < 1.0e-3);
1035        assert_ne!(
1036            coarse_published_translation.horizontal_travel_m,
1037            dense_published_translation.horizontal_travel_m
1038        );
1039        assert_ne!(
1040            coarse_published_translation.vertical_max_displacement_m,
1041            dense_published_translation.vertical_max_displacement_m
1042        );
1043        assert_ne!(
1044            coarse_published_yaw.unwrapped_yaw_deg,
1045            dense_published_yaw.unwrapped_yaw_deg
1046        );
1047    }
1048
1049    #[test]
1050    fn foot_metrics_reject_finite_positions_whose_relative_subtraction_overflows() {
1051        let mut doc = document_with_metric_clip();
1052        doc.skeleton.bones = vec![
1053            Bone {
1054                name: "hips".into(),
1055                parent: None,
1056                rest: Transform {
1057                    translation: Vec3::splat(-f32::MAX),
1058                    ..Transform::IDENTITY
1059                },
1060                inverse_bind: None,
1061            },
1062            Bone {
1063                name: "left".into(),
1064                parent: None,
1065                rest: Transform {
1066                    translation: Vec3::splat(f32::MAX),
1067                    ..Transform::IDENTITY
1068                },
1069                inverse_bind: None,
1070            },
1071        ];
1072        doc.clips[0].tracks[0].bone = 0;
1073        let roles = ResolvedRoles::from_names(
1074            &doc.skeleton,
1075            [
1076                (Role::Hips, "hips".to_string()),
1077                (Role::LeftFoot, "left".to_string()),
1078            ],
1079        );
1080        let grid = MetricGrids::new(&doc).grid(0).expect("metric grid");
1081
1082        assert!(grid.model_position(0, 0).is_finite());
1083        assert!(grid.model_position(0, 1).is_finite());
1084        assert!(foot_cycle_metrics(&grid, &roles, MIN_STRIDE_STEP_M).is_none());
1085    }
1086
1087    /// A real stride can still leave `loop_seam_ratio` `None`: the seam
1088    /// (frame `frames - 1` vs frame `0`) is a single point-to-point
1089    /// distance, unconstrained by either neighbour step, so it can sit at
1090    /// a per-axis delta near `f32::MAX` while both neighbour steps stay at
1091    /// the smallest representable positive `f32` value (comfortably over
1092    /// the configured floor). `f32` squares that delta while computing the
1093    /// distance, overflowing to infinity even though every input position
1094    /// was finite — the ratio then divides out non-finite, not "no
1095    /// subject". This is the only known route to
1096    /// [`FootCycleMetrics::loop_seam_ratio`]'s "real stride but
1097    /// underivable" `None`, and it requires magnitudes far outside any
1098    /// real animation.
1099    #[test]
1100    fn foot_metrics_real_stride_with_seam_beyond_f32_squaring_range_has_no_ratio() {
1101        let mut doc = document_with_metric_clip();
1102        doc.skeleton.bones = vec![
1103            Bone {
1104                name: "hips".into(),
1105                parent: None,
1106                rest: Transform::IDENTITY,
1107                inverse_bind: None,
1108            },
1109            Bone {
1110                name: "left".into(),
1111                parent: Some(0),
1112                rest: Transform::IDENTITY,
1113                inverse_bind: None,
1114            },
1115        ];
1116        doc.clips[0].tracks = vec![Track {
1117            bone: 1,
1118            property: Property::Translation,
1119            interpolation: Interpolation::Linear,
1120            times: vec![0.0, 0.25, 0.5, 1.0],
1121            values: TrackValues::Vec3s(vec![
1122                Vec3::ZERO,
1123                Vec3::new(f32::MIN_POSITIVE, 0.0, 0.0),
1124                Vec3::new(f32::MAX - f32::MIN_POSITIVE, 0.0, 0.0),
1125                Vec3::new(f32::MAX, 0.0, 0.0),
1126            ]),
1127        }];
1128        let roles = ResolvedRoles::from_names(
1129            &doc.skeleton,
1130            [
1131                (Role::Hips, "hips".to_string()),
1132                (Role::LeftFoot, "left".to_string()),
1133            ],
1134        );
1135        let grid = MetricGrids::new(&doc).grid(0).expect("metric grid");
1136
1137        let metrics = foot_cycle_metrics(&grid, &roles, f64::from(f32::MIN_POSITIVE))
1138            .expect("hips + one foot role with enough frames yields metrics");
1139
1140        assert!(
1141            metrics.has_real_stride,
1142            "the neighbour step met the (tiny) configured floor"
1143        );
1144        assert_eq!(
1145            metrics.loop_seam_ratio, None,
1146            "the seam distance overflowed f32 squaring to infinity, so the \
1147             ratio is non-finite despite a real stride"
1148        );
1149    }
1150
1151    fn trajectory_grid_with_rotations(positions: Vec<Vec3>, rotations: Vec<Quat>) -> PoseGrid {
1152        assert_eq!(positions.len(), rotations.len());
1153        assert!(positions.len() >= 2);
1154        let last = positions.len() - 1;
1155        let times = (0..=last)
1156            .map(|index| index as f32 / last as f32)
1157            .collect::<Vec<_>>();
1158        let skeleton = Skeleton {
1159            bones: vec![Bone {
1160                name: "root".into(),
1161                parent: None,
1162                rest: Transform::IDENTITY,
1163                inverse_bind: None,
1164            }],
1165        };
1166        let clip = Clip {
1167            name: "trajectory".into(),
1168            duration_s: 1.0,
1169            tracks: vec![
1170                Track {
1171                    bone: 0,
1172                    property: Property::Translation,
1173                    interpolation: Interpolation::Linear,
1174                    times: times.clone(),
1175                    values: TrackValues::Vec3s(positions),
1176                },
1177                Track {
1178                    bone: 0,
1179                    property: Property::Rotation,
1180                    interpolation: Interpolation::Linear,
1181                    times,
1182                    values: TrackValues::Quats(rotations),
1183                },
1184            ],
1185        };
1186        sample_clip(&skeleton, &clip, last + 1)
1187    }
1188
1189    fn trajectory_grid(positions: Vec<Vec3>, yaw_degrees: Vec<f32>) -> PoseGrid {
1190        trajectory_grid_with_rotations(
1191            positions,
1192            yaw_degrees
1193                .into_iter()
1194                .map(|degrees| Quat::from_rotation_y(degrees.to_radians()))
1195                .collect(),
1196        )
1197    }
1198
1199    fn assert_stationary_translation(trajectory: &RootTrajectoryMetrics) {
1200        let translation = trajectory
1201            .translation
1202            .expect("finite stationary translation");
1203        assert_eq!(translation.horizontal_displacement_x_m, 0.0);
1204        assert_eq!(translation.horizontal_displacement_z_m, 0.0);
1205        assert_eq!(translation.horizontal_travel_m, 0.0);
1206        assert_eq!(translation.vertical_displacement_m, 0.0);
1207        assert_eq!(translation.vertical_min_displacement_m, 0.0);
1208        assert_eq!(translation.vertical_max_displacement_m, 0.0);
1209    }
1210
1211    #[test]
1212    fn root_trajectory_retains_direction_travel_and_vertical_extrema() {
1213        let grid = trajectory_grid(
1214            vec![
1215                Vec3::ZERO,
1216                Vec3::new(1.0, 2.0, 0.0),
1217                Vec3::new(1.0, 1.0, -1.0),
1218            ],
1219            vec![0.0, 45.0, 90.0],
1220        );
1221        let trajectory = root_trajectory_metrics(&grid, 0).expect("valid selected bone");
1222        let translation = trajectory.translation.expect("finite translation");
1223
1224        assert_eq!(translation.horizontal_displacement_x_m, 1.0);
1225        assert_eq!(translation.horizontal_displacement_z_m, -1.0);
1226        assert_eq!(translation.horizontal_travel_m, 2.0);
1227        assert!(
1228            translation.horizontal_travel_m
1229                > translation
1230                    .horizontal_displacement_x_m
1231                    .hypot(translation.horizontal_displacement_z_m)
1232        );
1233        assert_eq!(translation.vertical_displacement_m, 1.0);
1234        assert_eq!(translation.vertical_min_displacement_m, 0.0);
1235        assert_eq!(translation.vertical_max_displacement_m, 2.0);
1236        let yaw = trajectory.yaw.expect("finite yaw");
1237        assert!((yaw.net_yaw_deg - 90.0).abs() < 1.0e-4);
1238        assert!((yaw.unwrapped_yaw_deg - 90.0).abs() < 1.0e-4);
1239
1240        let out_and_back =
1241            trajectory_grid(vec![Vec3::ZERO, Vec3::X, Vec3::ZERO], vec![0.0, 0.0, 0.0]);
1242        let translation = root_trajectory_metrics(&out_and_back, 0)
1243            .unwrap()
1244            .translation
1245            .unwrap();
1246        assert_eq!(translation.horizontal_displacement_x_m, 0.0);
1247        assert_eq!(translation.horizontal_travel_m, 2.0);
1248
1249        for (positions, expected_x, expected_z, expected_travel) in [
1250            (vec![Vec3::ZERO, -Vec3::Z, -Vec3::Z * 2.0], 0.0, -2.0, 2.0),
1251            (vec![Vec3::ZERO, Vec3::X * 0.5, Vec3::X], 1.0, 0.0, 1.0),
1252        ] {
1253            let grid = trajectory_grid(positions, vec![0.0; 3]);
1254            let translation = root_trajectory_metrics(&grid, 0)
1255                .unwrap()
1256                .translation
1257                .unwrap();
1258            assert_eq!(translation.horizontal_displacement_x_m, expected_x);
1259            assert_eq!(translation.horizontal_displacement_z_m, expected_z);
1260            assert_eq!(translation.horizontal_travel_m, expected_travel);
1261        }
1262
1263        for (positions, expected_net, expected_min, expected_max) in [
1264            (vec![Vec3::ZERO, Vec3::Y, Vec3::Y * 2.0], 2.0, 0.0, 2.0),
1265            (vec![Vec3::ZERO, -Vec3::Y, -Vec3::Y * 2.0], -2.0, -2.0, 0.0),
1266            (vec![Vec3::ZERO, Vec3::Y * 2.0, Vec3::ZERO], 0.0, 0.0, 2.0),
1267        ] {
1268            let grid = trajectory_grid(positions, vec![0.0; 3]);
1269            let translation = root_trajectory_metrics(&grid, 0)
1270                .unwrap()
1271                .translation
1272                .unwrap();
1273            assert_eq!(translation.vertical_displacement_m, expected_net);
1274            assert_eq!(translation.vertical_min_displacement_m, expected_min);
1275            assert_eq!(translation.vertical_max_displacement_m, expected_max);
1276        }
1277    }
1278
1279    #[test]
1280    fn root_yaw_retains_signed_half_and_full_turns_and_reversing_travel() {
1281        assert_eq!(canonical_net_yaw_deg(179.99995), 180.0);
1282        assert_eq!(canonical_net_yaw_deg(-179.99995), -180.0);
1283        let cases = [
1284            (vec![0.0, 45.0, 90.0], 90.0, 90.0, 90.0),
1285            (vec![0.0, -45.0, -90.0], -90.0, -90.0, 90.0),
1286            (vec![0.0, 90.0, 180.0], 180.0, 180.0, 180.0),
1287            (vec![0.0, -90.0, -180.0], -180.0, -180.0, 180.0),
1288            (
1289                vec![0.0, 60.0, 120.0, 179.99995],
1290                180.0,
1291                179.99995,
1292                179.99995,
1293            ),
1294            (
1295                vec![0.0, -60.0, -120.0, -179.99995],
1296                -180.0,
1297                -179.99995,
1298                179.99995,
1299            ),
1300            (vec![0.0, 90.0, 180.0, 270.0, 360.0], 0.0, 360.0, 360.0),
1301            (vec![0.0, -90.0, -180.0, -270.0, -360.0], 0.0, -360.0, 360.0),
1302            (vec![0.0, 90.0, 0.0], 0.0, 0.0, 180.0),
1303        ];
1304
1305        for (angles, expected_net, expected_unwrapped, expected_travel) in cases {
1306            let grid = trajectory_grid(vec![Vec3::ZERO; angles.len()], angles);
1307            let trajectory = root_trajectory_metrics(&grid, 0).unwrap();
1308            assert_stationary_translation(&trajectory);
1309            let yaw = trajectory.yaw.expect("yaw with sub-half-turn steps");
1310            assert_eq!(yaw.heading_axis, RootYawHeadingAxis::PositiveZ);
1311            if expected_net == 180.0 || expected_net == -180.0 {
1312                assert_eq!(yaw.net_yaw_deg, expected_net, "{yaw:?}");
1313            } else {
1314                assert!((yaw.net_yaw_deg - expected_net).abs() < 1.0e-4, "{yaw:?}");
1315            }
1316            assert!(
1317                (yaw.unwrapped_yaw_deg - expected_unwrapped).abs() < 1.0e-4,
1318                "{yaw:?}"
1319            );
1320            assert!(
1321                (yaw.yaw_travel_deg - expected_travel).abs() < 1.0e-4,
1322                "{yaw:?}"
1323            );
1324        }
1325    }
1326
1327    #[test]
1328    fn root_trajectory_translation_and_yaw_fail_independently() {
1329        let bad_position = trajectory_grid(
1330            vec![Vec3::ZERO, Vec3::new(f32::NAN, 0.0, 0.0), Vec3::ZERO],
1331            vec![0.0, 45.0, 90.0],
1332        );
1333        let measured = root_trajectory_metrics(&bad_position, 0).unwrap();
1334        assert!(measured.translation.is_none());
1335        assert!(measured.yaw.is_some());
1336
1337        let bad_rotation = trajectory_grid_with_rotations(
1338            vec![Vec3::ZERO, Vec3::X, Vec3::X * 2.0],
1339            vec![
1340                Quat::IDENTITY,
1341                Quat::from_xyzw(f32::NAN, 0.0, 0.0, 1.0),
1342                Quat::IDENTITY,
1343            ],
1344        );
1345        let measured = root_trajectory_metrics(&bad_rotation, 0).unwrap();
1346        let translation = measured
1347            .translation
1348            .expect("translation remains measurable");
1349        assert_eq!(translation.horizontal_displacement_x_m, 2.0);
1350        assert_eq!(translation.horizontal_displacement_z_m, 0.0);
1351        assert_eq!(translation.horizontal_travel_m, 2.0);
1352        assert_eq!(translation.vertical_displacement_m, 0.0);
1353        assert_eq!(translation.vertical_min_displacement_m, 0.0);
1354        assert_eq!(translation.vertical_max_displacement_m, 0.0);
1355        assert!(measured.yaw.is_none());
1356
1357        let zero_rotation = trajectory_grid_with_rotations(
1358            vec![Vec3::ZERO, Vec3::X, Vec3::X * 2.0],
1359            vec![
1360                Quat::IDENTITY,
1361                Quat::from_xyzw(0.0, 0.0, 0.0, 0.0),
1362                Quat::IDENTITY,
1363            ],
1364        );
1365        let measured = root_trajectory_metrics(&zero_rotation, 0).unwrap();
1366        let translation = measured
1367            .translation
1368            .expect("zero quaternion only makes rotation decomposition unavailable");
1369        assert_eq!(translation.horizontal_displacement_x_m, 2.0);
1370        assert_eq!(translation.horizontal_displacement_z_m, 0.0);
1371        assert_eq!(translation.horizontal_travel_m, 2.0);
1372        assert_eq!(translation.vertical_displacement_m, 0.0);
1373        assert_eq!(translation.vertical_min_displacement_m, 0.0);
1374        assert_eq!(translation.vertical_max_displacement_m, 0.0);
1375        assert!(measured.yaw.is_none());
1376    }
1377
1378    #[test]
1379    fn root_trajectory_extrema_widen_finite_samples_and_reject_model_overflow() {
1380        let finite_extremes = trajectory_grid(
1381            vec![-Vec3::Y * f32::MAX, Vec3::Y * f32::MAX],
1382            vec![0.0, 0.0],
1383        );
1384        let translation = root_trajectory_metrics(&finite_extremes, 0)
1385            .expect("selected bone exists")
1386            .translation
1387            .expect("finite binary32 samples have finite widened extrema");
1388        let full_binary32_span = 2.0 * f64::from(f32::MAX);
1389        assert!(full_binary32_span.is_finite());
1390        assert_eq!(translation.vertical_displacement_m, full_binary32_span);
1391        assert_eq!(translation.vertical_min_displacement_m, 0.0);
1392        assert_eq!(translation.vertical_max_displacement_m, full_binary32_span);
1393
1394        let skeleton = Skeleton {
1395            bones: vec![
1396                Bone {
1397                    name: "ancestor".into(),
1398                    parent: None,
1399                    rest: Transform {
1400                        translation: Vec3::Y * f32::MAX,
1401                        ..Transform::IDENTITY
1402                    },
1403                    inverse_bind: None,
1404                },
1405                Bone {
1406                    name: "root".into(),
1407                    parent: Some(0),
1408                    rest: Transform {
1409                        translation: Vec3::Y * f32::MAX,
1410                        ..Transform::IDENTITY
1411                    },
1412                    inverse_bind: None,
1413                },
1414            ],
1415        };
1416        let clip = Clip {
1417            name: "overflow".into(),
1418            duration_s: 1.0,
1419            tracks: vec![Track {
1420                bone: 1,
1421                property: Property::Rotation,
1422                interpolation: Interpolation::Linear,
1423                times: vec![0.0, 1.0],
1424                values: TrackValues::Quats(vec![Quat::IDENTITY; 2]),
1425            }],
1426        };
1427        let grid = sample_clip(&skeleton, &clip, 2);
1428
1429        assert!(
1430            skeleton
1431                .bones
1432                .iter()
1433                .all(|bone| bone.rest.translation.is_finite()
1434                    && bone.rest.rotation.is_finite()
1435                    && bone.rest.scale.is_finite())
1436        );
1437        assert!(
1438            !grid.model_position(0, 1).is_finite(),
1439            "finite local translations overflow while composing model space"
1440        );
1441        let trajectory = root_trajectory_metrics(&grid, 1).expect("selected bone exists");
1442        assert_eq!(trajectory.translation, None);
1443        let yaw = trajectory
1444            .yaw
1445            .expect("translation overflow does not erase finite yaw");
1446        assert_eq!(yaw.net_yaw_deg, 0.0);
1447        assert_eq!(yaw.unwrapped_yaw_deg, 0.0);
1448        assert_eq!(yaw.yaw_travel_deg, 0.0);
1449    }
1450
1451    #[test]
1452    fn root_yaw_publishes_fixed_basis_and_refuses_ambiguous_steps() {
1453        let in_place_grid =
1454            trajectory_grid(vec![Vec3::new(3.0, 2.0, -1.0); 3], vec![0.0, 45.0, 90.0]);
1455        let in_place = root_trajectory_metrics(&in_place_grid, 0).unwrap();
1456        assert_stationary_translation(&in_place);
1457
1458        let positive_x_rotation = Quat::from_rotation_x(std::f32::consts::FRAC_PI_6);
1459        let positive_x_length = {
1460            let (x, z) = horizontal_heading(
1461                positive_x_rotation.as_dquat(),
1462                RootYawHeadingAxis::PositiveX,
1463            );
1464            x.hypot(z)
1465        };
1466        for other_axis in [RootYawHeadingAxis::PositiveZ, RootYawHeadingAxis::PositiveY] {
1467            let (x, z) = horizontal_heading(positive_x_rotation.as_dquat(), other_axis);
1468            assert!(positive_x_length > x.hypot(z));
1469        }
1470        let positive_x_grid =
1471            trajectory_grid_with_rotations(vec![Vec3::ZERO; 3], vec![positive_x_rotation; 3]);
1472        let positive_x = root_trajectory_metrics(&positive_x_grid, 0).unwrap();
1473        assert_stationary_translation(&positive_x);
1474        assert_eq!(
1475            positive_x.yaw.unwrap().heading_axis,
1476            RootYawHeadingAxis::PositiveX
1477        );
1478
1479        // Analytic tilted basis, built directly from orthonormal columns rather
1480        // than Euler angles. Local +Y has world heading 30 degrees and a 0.5
1481        // vertical component; +X and +Z each have a smaller horizontal
1482        // projection, so +Y is the strict fixed witness.
1483        let sqrt_three = 3.0_f32.sqrt();
1484        let positive_y = Vec3::new(sqrt_three / 4.0, 0.5, 0.75);
1485        let horizontal_perpendicular = Vec3::new(sqrt_three / 2.0, 0.0, -0.5);
1486        let tilt_tangent = Vec3::new(-0.25, sqrt_three / 2.0, -sqrt_three / 4.0);
1487        let positive_x =
1488            (horizontal_perpendicular + tilt_tangent) * std::f32::consts::FRAC_1_SQRT_2;
1489        let positive_z = positive_x.cross(positive_y);
1490        let tilted = Quat::from_mat3(&Mat3::from_cols(positive_x, positive_y, positive_z));
1491        let (heading_x, heading_z) =
1492            horizontal_heading(tilted.as_dquat(), RootYawHeadingAxis::PositiveY);
1493        assert!((heading_x.atan2(heading_z).to_degrees() - 30.0).abs() < 1.0e-5);
1494        let rotations = [0.0_f32, 20.0, 40.0]
1495            .map(|degrees| Quat::from_axis_angle(Vec3::Y, degrees.to_radians()) * tilted)
1496            .to_vec();
1497        let basis_grid = trajectory_grid_with_rotations(vec![Vec3::ZERO; 3], rotations);
1498        let basis = root_trajectory_metrics(&basis_grid, 0).unwrap();
1499        assert_stationary_translation(&basis);
1500        let yaw = basis.yaw.unwrap();
1501        assert_eq!(yaw.heading_axis, RootYawHeadingAxis::PositiveY);
1502        assert!((yaw.net_yaw_deg - 40.0).abs() < 1.0e-4);
1503        assert!((yaw.unwrapped_yaw_deg - 40.0).abs() < 1.0e-4);
1504        assert!((yaw.yaw_travel_deg - 40.0).abs() < 1.0e-4);
1505
1506        // A local-Y twist changes a Y-first Euler decomposition but cannot
1507        // change the retained local +Y witness. This is the concrete case an
1508        // Euler-component shortcut gets wrong.
1509        let local_half_turn = Quat::from_xyzw(0.0, 1.0, 0.0, 0.0);
1510        let fixed_witness_rotations = vec![tilted, tilted * local_half_turn, tilted];
1511        let first_euler_y = fixed_witness_rotations[0].to_euler(EulerRot::YXZ).0;
1512        let twisted_euler_y = fixed_witness_rotations[1].to_euler(EulerRot::YXZ).0;
1513        assert!(
1514            (twisted_euler_y - first_euler_y).abs() > 1.0,
1515            "Y-first Euler shortcut must observe a misleading change"
1516        );
1517        let expected_heading = horizontal_heading(
1518            fixed_witness_rotations[0].as_dquat(),
1519            RootYawHeadingAxis::PositiveY,
1520        );
1521        for rotation in &fixed_witness_rotations[1..] {
1522            let heading = horizontal_heading(rotation.as_dquat(), RootYawHeadingAxis::PositiveY);
1523            assert!((heading.0 - expected_heading.0).abs() < 1.0e-7);
1524            assert!((heading.1 - expected_heading.1).abs() < 1.0e-7);
1525        }
1526
1527        let stationary_fixed_witness =
1528            trajectory_grid_with_rotations(vec![Vec3::ZERO; 3], fixed_witness_rotations.clone());
1529        let misleading_translation_fixed_witness = trajectory_grid_with_rotations(
1530            vec![Vec3::ZERO, Vec3::X * 100.0, Vec3::Z * 100.0],
1531            fixed_witness_rotations,
1532        );
1533        let stationary = root_trajectory_metrics(&stationary_fixed_witness, 0).unwrap();
1534        assert_stationary_translation(&stationary);
1535        let misleading = root_trajectory_metrics(&misleading_translation_fixed_witness, 0)
1536            .expect("translation content does not select yaw/up");
1537        assert!(misleading.translation.unwrap().horizontal_travel_m > 200.0);
1538        let stationary_yaw = stationary.yaw.unwrap();
1539        let misleading_yaw = misleading.yaw.unwrap();
1540        assert_eq!(stationary_yaw.heading_axis, RootYawHeadingAxis::PositiveY);
1541        assert_eq!(misleading_yaw, stationary_yaw);
1542        assert_eq!(stationary_yaw.net_yaw_deg, 0.0);
1543        assert_eq!(stationary_yaw.unwrapped_yaw_deg, 0.0);
1544        assert_eq!(stationary_yaw.yaw_travel_deg, 0.0);
1545
1546        let becomes_vertical = trajectory_grid_with_rotations(
1547            vec![Vec3::ZERO; 3],
1548            vec![
1549                Quat::IDENTITY,
1550                Quat::from_rotation_x(std::f32::consts::FRAC_PI_4),
1551                Quat::from_rotation_x(std::f32::consts::FRAC_PI_2),
1552            ],
1553        );
1554        assert!(
1555            root_trajectory_metrics(&becomes_vertical, 0)
1556                .unwrap()
1557                .yaw
1558                .is_none()
1559        );
1560
1561        let half_turn_step = trajectory_grid(vec![Vec3::ZERO; 2], vec![0.0, 180.0]);
1562        assert!(
1563            root_trajectory_metrics(&half_turn_step, 0)
1564                .unwrap()
1565                .yaw
1566                .is_none()
1567        );
1568    }
1569
1570    #[test]
1571    fn root_yaw_uses_animated_ancestor_model_rotation_for_fixed_child_local_pose() {
1572        let skeleton = Skeleton {
1573            bones: vec![
1574                Bone {
1575                    name: "ancestor".into(),
1576                    parent: None,
1577                    rest: Transform::IDENTITY,
1578                    inverse_bind: None,
1579                },
1580                Bone {
1581                    name: "root".into(),
1582                    parent: Some(0),
1583                    rest: Transform::IDENTITY,
1584                    inverse_bind: None,
1585                },
1586            ],
1587        };
1588        let clip = Clip {
1589            name: "inherited_yaw".into(),
1590            duration_s: 1.0,
1591            tracks: vec![
1592                Track {
1593                    bone: 0,
1594                    property: Property::Rotation,
1595                    interpolation: Interpolation::Linear,
1596                    times: vec![0.0, 0.5, 1.0],
1597                    values: TrackValues::Quats(
1598                        [0.0_f32, 45.0, 90.0]
1599                            .map(|degrees| Quat::from_rotation_y(degrees.to_radians()))
1600                            .to_vec(),
1601                    ),
1602                },
1603                Track {
1604                    bone: 1,
1605                    property: Property::Rotation,
1606                    interpolation: Interpolation::Linear,
1607                    times: vec![0.0, 0.5, 1.0],
1608                    values: TrackValues::Quats(vec![Quat::IDENTITY; 3]),
1609                },
1610            ],
1611        };
1612        let grid = sample_clip(&skeleton, &clip, 3);
1613        for frame in 0..grid.frame_count() {
1614            assert_eq!(grid.local(frame, 1).rotation, Quat::IDENTITY);
1615        }
1616
1617        let trajectory = root_trajectory_metrics(&grid, 1).expect("child trajectory");
1618        assert_stationary_translation(&trajectory);
1619        let yaw = trajectory.yaw.expect("ancestor supplies model-space yaw");
1620        assert_eq!(yaw.heading_axis, RootYawHeadingAxis::PositiveZ);
1621        assert!((yaw.net_yaw_deg - 90.0).abs() < 1.0e-4);
1622        assert!((yaw.unwrapped_yaw_deg - 90.0).abs() < 1.0e-4);
1623        assert!((yaw.yaw_travel_deg - 90.0).abs() < 1.0e-4);
1624    }
1625}