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/// 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 grid has fewer than three frames, has no bones, has
454/// an unusable seam-adjacent time step, or contains a non-finite model-space
455/// position or rotation needed by the measurement.
456pub fn loop_continuity_metrics(grid: &PoseGrid) -> Option<Vec<BoneLoopContinuityMetrics>> {
457    let frames = grid.frame_count();
458    if frames < 3 || grid.bone_count() == 0 {
459        return None;
460    }
461
462    let first_dt = f64::from(grid.times[1] - grid.times[0]);
463    let last_dt = f64::from(grid.times[frames - 1] - grid.times[frames - 2]);
464    if !first_dt.is_finite() || !last_dt.is_finite() || first_dt <= 0.0 || last_dt <= 0.0 {
465        return None;
466    }
467
468    (0..grid.bone_count())
469        .map(|bone| {
470            let first = grid.model_position(0, bone);
471            let next = grid.model_position(1, bone);
472            let previous = grid.model_position(frames - 2, bone);
473            let last = grid.model_position(frames - 1, bone);
474            if [first, next, previous, last]
475                .iter()
476                .any(|position| !position.is_finite())
477            {
478                return None;
479            }
480
481            let rotations = [
482                grid.model_rotation(0, bone),
483                grid.model_rotation(1, bone),
484                grid.model_rotation(frames - 2, bone),
485                grid.model_rotation(frames - 1, bone),
486            ];
487            if rotations.iter().any(|rotation| {
488                !rotation.is_finite()
489                    || !rotation.length_squared().is_finite()
490                    || rotation.length_squared() == 0.0
491            }) {
492                return None;
493            }
494            let [
495                first_rotation,
496                next_rotation,
497                previous_rotation,
498                last_rotation,
499            ] = rotations.map(Quat::normalize);
500            let delta = first_rotation.conjugate() * last_rotation;
501            let [x, y, z, w] = delta.to_array();
502            let sin_half_angle = Vec3::new(x, y, z).length();
503            let rotation_delta_deg = f64::from(2.0 * sin_half_angle.atan2(w.abs()).to_degrees());
504            let position_delta_m = f64::from((last - first).length());
505            let outgoing_velocity = (next - first) / first_dt as f32;
506            let incoming_velocity = (last - previous) / last_dt as f32;
507            let seam_velocity_delta_mps =
508                f64::from((outgoing_velocity - incoming_velocity).length());
509            let outgoing_angular_velocity =
510                shortest_path_model_rotation_vector(first_rotation, next_rotation)?
511                    / first_dt as f32;
512            let incoming_angular_velocity =
513                shortest_path_model_rotation_vector(previous_rotation, last_rotation)?
514                    / last_dt as f32;
515            let seam_angular_velocity_delta_degps = f64::from(
516                (outgoing_angular_velocity - incoming_angular_velocity)
517                    .length()
518                    .to_degrees(),
519            );
520
521            if !position_delta_m.is_finite()
522                || !rotation_delta_deg.is_finite()
523                || !seam_velocity_delta_mps.is_finite()
524                || !seam_angular_velocity_delta_degps.is_finite()
525            {
526                return None;
527            }
528            Some(BoneLoopContinuityMetrics {
529                position_delta_m,
530                rotation_delta_deg,
531                seam_velocity_delta_mps,
532                seam_angular_velocity_delta_degps,
533            })
534        })
535        .collect()
536}
537
538/// Measure the foot cycle of a clip from its pose grid. Requires the
539/// Hips role and at least one foot role; returns `None` otherwise (the
540/// caller decides which typed coverage gap represents the missing metric).
541///
542/// The grid must span `[0, duration]` — the wrap pair is
543/// `(last frame, frame 0)`. Grids under 3 frames carry no cycle.
544///
545/// # Panics
546///
547/// Panics if `roles` contains bone indices outside `grid`. Role
548/// resolutions produced by this crate are tied to the same skeleton that
549/// produced the grid; embedders that hand-build roles must preserve that
550/// relationship.
551pub fn foot_cycle_metrics(
552    grid: &PoseGrid,
553    roles: &ResolvedRoles,
554    min_stride_step_m: f64,
555) -> Option<FootCycleMetrics> {
556    if grid.frame_count() < 3 {
557        return None;
558    }
559    let hips = roles.get(Role::Hips)?;
560    let left: Vec<usize> = [Role::LeftFoot, Role::LeftToe]
561        .iter()
562        .filter_map(|&r| roles.get(r))
563        .collect();
564    let right: Vec<usize> = [Role::RightFoot, Role::RightToe]
565        .iter()
566        .filter_map(|&r| roles.get(r))
567        .collect();
568    let feet: Vec<usize> = left.iter().chain(right.iter()).copied().collect();
569    if feet.is_empty() {
570        return None;
571    }
572
573    let frames = grid.frame_count();
574    // Feet relative to hips: cancels the in-place root so we measure
575    // the leg cycle, not body travel.
576    let rel = |frame: usize, bone: usize| -> Vec3 {
577        grid.model_position(frame, bone) - grid.model_position(frame, hips)
578    };
579    if (0..frames).any(|frame| {
580        !grid.model_position(frame, hips).is_finite()
581            || feet.iter().any(|&foot| !rel(frame, foot).is_finite())
582    }) {
583        return None;
584    }
585
586    // Loop seam: the wrap chord vs its NEIGHBOURING in-clip steps (the
587    // step into the last frame and the step out of the first) — local
588    // continuity, because stride speed varies legitimately inside a
589    // cycle and the wrap may sit at an arbitrary cycle position. A real
590    // pop is discontinuous against its immediate neighbours too.
591    let max_foot_dist = |a: usize, b: usize| -> f64 {
592        feet.iter()
593            .map(|&f| (rel(a, f) - rel(b, f)).length() as f64)
594            .fold(0.0, f64::max)
595    };
596    let seam = max_foot_dist(frames - 1, 0);
597    let step_first = max_foot_dist(1, 0);
598    let step_last = max_foot_dist(frames - 1, frames - 2);
599    let neighbour_step = step_first.max(step_last);
600    let has_real_stride = neighbour_step > 0.0 && neighbour_step >= min_stride_step_m;
601    let loop_seam_ratio = if has_real_stride {
602        let ratio = seam / neighbour_step;
603        ratio.is_finite().then_some(ratio)
604    } else {
605        None
606    };
607
608    // Gait phase: fundamental-harmonic trough of the L−R foot-height
609    // signal over one cycle (the duplicate wrap frame excluded). The
610    // difference cancels common-mode pelvis bob and encodes handedness
611    // plus a stable cycle anchor.
612    let cycle = if frames > 3 { frames - 1 } else { frames };
613    let mut gait_phase = None;
614    let mut lr_amplitude_m = 0.0f64;
615    if !left.is_empty() && !right.is_empty() {
616        let avg_height = |frame: usize, bones: &[usize]| -> f64 {
617            bones.iter().map(|&b| rel(frame, b).y as f64).sum::<f64>() / bones.len() as f64
618        };
619        let diff: Vec<f64> = (0..cycle)
620            .map(|f| avg_height(f, &left) - avg_height(f, &right))
621            .collect();
622        let max = diff.iter().copied().fold(f64::MIN, f64::max);
623        let min = diff.iter().copied().fold(f64::MAX, f64::min);
624        lr_amplitude_m = max - min;
625        if lr_amplitude_m > 0.0 {
626            gait_phase = fundamental_trough_phase(&diff);
627        }
628    }
629
630    Some(FootCycleMetrics {
631        loop_seam_ratio,
632        has_real_stride,
633        gait_phase,
634        lr_amplitude_m,
635    })
636}
637
638/// Normalized cycle position `[0,1)` of the minimum of the signal's
639/// first Fourier harmonic. Robust to plateaus and per-frame noise: the
640/// minimum of `A·cos(2π·t/N − φ)` sits at `t/N = (φ/2π + 0.5) mod 1`.
641pub fn fundamental_trough_phase(signal: &[f64]) -> Option<f64> {
642    let n = signal.len();
643    if n < 2 || signal.iter().any(|value| !value.is_finite()) {
644        return None;
645    }
646    let mut re = 0.0f64;
647    let mut im = 0.0f64;
648    for (k, y) in signal.iter().enumerate() {
649        let angle = std::f64::consts::TAU * k as f64 / n as f64;
650        re += y * angle.cos();
651        im += y * angle.sin();
652    }
653    let phi = im.atan2(re);
654    let phase = (phi / std::f64::consts::TAU + 0.5).rem_euclid(1.0);
655    phase.is_finite().then_some(phase)
656}
657
658/// Horizontal (XZ-plane) root displacement over the clip, divided by
659/// duration. Uses the Root role whenever it resolves and falls back to Hips
660/// only when Root is unresolved (clips without a dedicated root bone carry
661/// travel on the hips). Returns `None` rather than falling back when the
662/// selected role index is outside `grid`, the grid is too short, duration is
663/// non-positive, or the derived speed is non-finite.
664pub fn root_motion_speed_mps(grid: &PoseGrid, roles: &ResolvedRoles) -> Option<f64> {
665    let bone = roles.get(Role::Root).or_else(|| roles.get(Role::Hips))?;
666    let frames = grid.frame_count();
667    if frames < 2 || bone >= grid.bone_count() {
668        return None;
669    }
670    let duration = *grid.times.last()? as f64;
671    if duration <= 0.0 {
672        return None;
673    }
674    let a = grid.model_position(0, bone);
675    let b = grid.model_position(frames - 1, bone);
676    let dx = (b.x - a.x) as f64;
677    let dz = (b.z - a.z) as f64;
678    let speed = dx.hypot(dz) / duration;
679    speed.is_finite().then_some(speed)
680}
681
682/// Maximum angular deviation (degrees) of a rotation track from its
683/// first keyed rotation.
684pub fn rotation_range_deg(track: &Track) -> Option<f64> {
685    if track.property != Property::Rotation {
686        return None;
687    }
688    let first = track.key_quat(0)?;
689    if !first.is_finite() || first.length_squared() == 0.0 {
690        return None;
691    }
692    let first = first.normalize();
693    let mut max_deg = 0.0f64;
694    for k in 1..track.key_count() {
695        if let Some(q) = track.key_quat(k)
696            && q.is_finite()
697            && q.length_squared() > 0.0
698        {
699            let deg = first.angle_between(q.normalize()).to_degrees() as f64;
700            if deg.is_finite() {
701                max_deg = max_deg.max(deg);
702            }
703        }
704    }
705    Some(max_deg)
706}
707
708/// Maximum circular distance (in cycle fraction, `[0, 0.5]`) of a set of
709/// normalized phases from their circular mean. Phases live on a ring, so
710/// a naive max−min would over-report a cluster straddling the 0/1 wrap.
711pub fn circular_phase_spread(phases: &[f64]) -> f64 {
712    use std::f64::consts::{PI, TAU};
713    let (mut sin_sum, mut cos_sum) = (0.0f64, 0.0f64);
714    for p in phases {
715        sin_sum += (p * TAU).sin();
716        cos_sum += (p * TAU).cos();
717    }
718    let mean = sin_sum.atan2(cos_sum);
719    let mut max_dev = 0.0f64;
720    for p in phases {
721        let mut d = (p * TAU - mean).abs() % TAU;
722        if d > PI {
723            d = TAU - d;
724        }
725        max_dev = max_dev.max(d / TAU);
726    }
727    max_dev
728}
729
730/// The metric sampling grid for a clip: uniform, resolution = max key
731/// count (mirroring how the runtime loops a clip over `[0, duration]`,
732/// wrapping duration→0 at render times unaligned with authored keys).
733/// `None` for clips too short to carry a cycle (< 3 keys), matching the
734/// reference implementation.
735pub fn metric_frame_count(clip: &Clip) -> Option<usize> {
736    let n = crate::sample::default_frame_count(clip);
737    if clip.duration_s <= 0.0 || n < 3 {
738        None
739    } else {
740        Some(n)
741    }
742}
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747    use crate::check::CheckCtx;
748    use crate::config::Config;
749    use crate::measure::{RootTrajectorySourceRole, measure_document};
750    use crate::model::{
751        Bone, Clip, Document, Interpolation, Property, Skeleton, Track, TrackValues, Transform,
752    };
753    use crate::profile::{ResolvedRoles, Role};
754    use glam::{EulerRot, Mat3, Quat, Vec3};
755    use std::rc::Rc;
756
757    #[test]
758    fn gait_phase_outcome_retains_the_defensive_derivation_failure_state() {
759        assert_eq!(
760            GaitPhaseOutcome::classify(None, 0.01, true),
761            GaitPhaseOutcome::Unavailable
762        );
763        assert_eq!(
764            GaitPhaseOutcome::classify(Some(0.25), 0.01, true),
765            GaitPhaseOutcome::Measured(0.25)
766        );
767        assert_eq!(
768            GaitPhaseOutcome::classify(None, 0.0, true),
769            GaitPhaseOutcome::NoFootHeightSwing
770        );
771        assert_eq!(
772            GaitPhaseOutcome::classify(None, 0.0, false),
773            GaitPhaseOutcome::MissingBilateralFootRoles
774        );
775    }
776
777    fn document_with_metric_clip() -> Document {
778        Document {
779            skeleton: Skeleton {
780                bones: vec![Bone {
781                    name: "root".into(),
782                    parent: None,
783                    rest: Transform::IDENTITY,
784                    inverse_bind: None,
785                }],
786            },
787            clips: vec![Clip {
788                name: "walk".into(),
789                duration_s: 1.0,
790                tracks: vec![Track {
791                    bone: 0,
792                    property: Property::Rotation,
793                    interpolation: Interpolation::Linear,
794                    times: vec![0.0, 0.5, 1.0],
795                    values: TrackValues::Quats(vec![
796                        Quat::IDENTITY,
797                        Quat::from_rotation_y(0.1),
798                        Quat::from_rotation_y(0.2),
799                    ]),
800                }],
801            }],
802            ..Document::default()
803        }
804    }
805
806    fn document_with_grid_inputs(duration_s: f64, times: Vec<f32>) -> Document {
807        let values = vec![Quat::IDENTITY; times.len()];
808        Document {
809            skeleton: Skeleton {
810                bones: vec![Bone {
811                    name: "root".into(),
812                    parent: None,
813                    rest: Transform::IDENTITY,
814                    inverse_bind: None,
815                }],
816            },
817            clips: vec![Clip {
818                name: "probe".into(),
819                duration_s,
820                tracks: vec![Track {
821                    bone: 0,
822                    property: Property::Rotation,
823                    interpolation: Interpolation::Linear,
824                    times,
825                    values: TrackValues::Quats(values),
826                }],
827            }],
828            ..Document::default()
829        }
830    }
831
832    #[test]
833    fn metric_grids_are_shared_by_checks_and_measurements() {
834        let doc = document_with_metric_clip();
835        let roles = ResolvedRoles::default();
836        let config = Config::default();
837        let grids = MetricGrids::new(&doc);
838
839        let ctx = CheckCtx::new(&grids, &roles, &config);
840        let from_ctx = ctx.grid(0).expect("metric grid");
841        let from_owner = grids.grid(0).expect("same metric grid");
842        assert!(Rc::ptr_eq(&from_ctx, &from_owner));
843
844        let measurements = measure_document(&grids, &roles, &config);
845        assert!(measurements.contains_key("walk"));
846        let fresh_grids = MetricGrids::new(&doc);
847        assert_eq!(
848            serde_json::to_value(&measurements).expect("shared measurements serialize"),
849            serde_json::to_value(measure_document(&fresh_grids, &roles, &config))
850                .expect("plain measurements serialize")
851        );
852    }
853
854    #[test]
855    fn grid_returns_none_for_each_documented_invalid_request() {
856        let valid = document_with_grid_inputs(1.0, vec![0.0, 0.5, 1.0]);
857        let valid_grids = MetricGrids::new(&valid);
858        assert!(valid_grids.grid(0).is_some());
859        for clip_index in [1, 2, usize::MAX] {
860            assert!(valid_grids.grid(clip_index).is_none());
861        }
862
863        for duration_s in [0.0, -1.0] {
864            let non_positive = document_with_grid_inputs(duration_s, vec![0.0, 0.5, 1.0]);
865            assert!(MetricGrids::new(&non_positive).grid(0).is_none());
866        }
867
868        for times in [vec![], vec![0.0], vec![0.0, 1.0]] {
869            let too_few_keys = document_with_grid_inputs(1.0, times);
870            assert!(MetricGrids::new(&too_few_keys).grid(0).is_none());
871        }
872    }
873
874    #[test]
875    fn grid_uses_longest_track_for_resolution() {
876        // The first track is too short by itself; the later translation
877        // track selects the grid's three-frame resolution.
878        let mut doc = document_with_grid_inputs(1.0, vec![0.0, 1.0]);
879        doc.clips[0].tracks.push(Track {
880            bone: 0,
881            property: Property::Translation,
882            interpolation: Interpolation::Linear,
883            times: vec![0.0, 0.5, 1.0],
884            values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::X, 2.0 * Vec3::X]),
885        });
886
887        let grid = MetricGrids::new(&doc)
888            .grid(0)
889            .expect("later longest track supplies a metric grid");
890        assert_eq!(grid.frame_count(), 3);
891    }
892
893    #[test]
894    fn unrelated_dense_track_changes_shared_sampled_trajectory_not_the_analytic_curve() {
895        let skeleton = Skeleton {
896            bones: vec![
897                Bone {
898                    name: "root".into(),
899                    parent: None,
900                    rest: Transform::IDENTITY,
901                    inverse_bind: None,
902                },
903                Bone {
904                    name: "unrelated".into(),
905                    parent: None,
906                    rest: Transform::IDENTITY,
907                    inverse_bind: None,
908                },
909            ],
910        };
911        let target_tracks = vec![
912            Track {
913                bone: 0,
914                property: Property::Translation,
915                interpolation: Interpolation::Linear,
916                times: vec![0.0, 0.25, 1.0],
917                values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::new(1.0, 2.0, 0.0), Vec3::ZERO]),
918            },
919            Track {
920                bone: 0,
921                property: Property::Rotation,
922                interpolation: Interpolation::Linear,
923                times: vec![0.0, 0.25, 1.0],
924                values: TrackValues::Quats(
925                    [0.0_f32, 170.0, 340.0]
926                        .map(|degrees| Quat::from_rotation_y(degrees.to_radians()))
927                        .to_vec(),
928                ),
929            },
930        ];
931        let document = Document {
932            skeleton: skeleton.clone(),
933            clips: vec![Clip {
934                name: "aliased".into(),
935                duration_s: 1.0,
936                tracks: target_tracks.clone(),
937            }],
938            ..Document::default()
939        };
940        let coarse_grid = MetricGrids::new(&document)
941            .grid(0)
942            .expect("three-sample grid");
943        assert_eq!(coarse_grid.frame_count(), 3);
944        let coarse = root_trajectory_metrics(&coarse_grid, 0).expect("coarse trajectory");
945        let coarse_translation = coarse.translation.expect("coarse translation");
946        let coarse_yaw = coarse.yaw.expect("coarse yaw");
947
948        let analytic_peak_y = 2.0;
949        assert!((coarse_translation.horizontal_travel_m - 4.0 / 3.0).abs() < 1.0e-5);
950        assert!((coarse_translation.vertical_max_displacement_m - 4.0 / 3.0).abs() < 1.0e-5);
951        assert!(coarse_translation.vertical_max_displacement_m < analytic_peak_y);
952        assert!((coarse_yaw.net_yaw_deg - -20.0).abs() < 1.0e-4);
953        assert!((coarse_yaw.unwrapped_yaw_deg - -20.0).abs() < 1.0e-4);
954        assert!((coarse_yaw.yaw_travel_deg - 740.0 / 3.0).abs() < 1.0e-3);
955
956        let mut dense_document = Document {
957            skeleton,
958            clips: vec![Clip {
959                name: "aliased".into(),
960                duration_s: 1.0,
961                tracks: target_tracks,
962            }],
963            ..Document::default()
964        };
965        dense_document.clips[0].tracks.push(Track {
966            bone: 1,
967            property: Property::Scale,
968            interpolation: Interpolation::Linear,
969            times: vec![0.0, 0.25, 0.5, 0.75, 1.0],
970            values: TrackValues::Vec3s(vec![Vec3::ONE; 5]),
971        });
972        let dense_grid = MetricGrids::new(&dense_document)
973            .grid(0)
974            .expect("unrelated track selects five-sample grid");
975        assert_eq!(dense_grid.frame_count(), 5);
976        let dense = root_trajectory_metrics(&dense_grid, 0).expect("dense trajectory");
977        let dense_translation = dense.translation.expect("dense translation");
978        let dense_yaw = dense.yaw.expect("dense yaw");
979
980        assert!((dense_translation.horizontal_travel_m - 2.0).abs() < 1.0e-5);
981        assert_eq!(
982            dense_translation.vertical_max_displacement_m,
983            analytic_peak_y
984        );
985        assert!((dense_yaw.net_yaw_deg - -20.0).abs() < 1.0e-4);
986        assert!((dense_yaw.unwrapped_yaw_deg - 340.0).abs() < 1.0e-3);
987        assert!((dense_yaw.yaw_travel_deg - 340.0).abs() < 1.0e-3);
988
989        let coarse_roles =
990            ResolvedRoles::from_names(&document.skeleton, [(Role::Root, "root".into())]);
991        let coarse_measurements = measure_document(
992            &MetricGrids::new(&document),
993            &coarse_roles,
994            &Config::default(),
995        );
996        let coarse_published = coarse_measurements["aliased"]
997            .root_trajectory
998            .as_ref()
999            .expect("public coarse Root trajectory");
1000        assert_eq!(coarse_published.source_role, RootTrajectorySourceRole::Root);
1001        let coarse_published_translation = coarse_published.translation.unwrap();
1002        let coarse_published_yaw = coarse_published.yaw.unwrap();
1003        assert!((coarse_published_translation.horizontal_travel_m - 4.0 / 3.0).abs() < 1.0e-5);
1004        assert!(
1005            (coarse_published_translation.vertical_max_displacement_m - 4.0 / 3.0).abs() < 1.0e-5
1006        );
1007        assert!((coarse_published_yaw.unwrapped_yaw_deg - -20.0).abs() < 1.0e-4);
1008        assert!((coarse_published_yaw.yaw_travel_deg - 740.0 / 3.0).abs() < 1.0e-3);
1009
1010        let dense_roles =
1011            ResolvedRoles::from_names(&dense_document.skeleton, [(Role::Root, "root".into())]);
1012        let dense_measurements = measure_document(
1013            &MetricGrids::new(&dense_document),
1014            &dense_roles,
1015            &Config::default(),
1016        );
1017        let dense_published = dense_measurements["aliased"]
1018            .root_trajectory
1019            .as_ref()
1020            .expect("public dense Root trajectory");
1021        assert_eq!(dense_published.source_role, RootTrajectorySourceRole::Root);
1022        let dense_published_translation = dense_published.translation.unwrap();
1023        let dense_published_yaw = dense_published.yaw.unwrap();
1024        assert!((dense_published_translation.horizontal_travel_m - 2.0).abs() < 1.0e-5);
1025        assert_eq!(
1026            dense_published_translation.vertical_max_displacement_m,
1027            analytic_peak_y
1028        );
1029        assert!((dense_published_yaw.unwrapped_yaw_deg - 340.0).abs() < 1.0e-3);
1030        assert!((dense_published_yaw.yaw_travel_deg - 340.0).abs() < 1.0e-3);
1031        assert_ne!(
1032            coarse_published_translation.horizontal_travel_m,
1033            dense_published_translation.horizontal_travel_m
1034        );
1035        assert_ne!(
1036            coarse_published_translation.vertical_max_displacement_m,
1037            dense_published_translation.vertical_max_displacement_m
1038        );
1039        assert_ne!(
1040            coarse_published_yaw.unwrapped_yaw_deg,
1041            dense_published_yaw.unwrapped_yaw_deg
1042        );
1043    }
1044
1045    #[test]
1046    fn foot_metrics_reject_finite_positions_whose_relative_subtraction_overflows() {
1047        let mut doc = document_with_metric_clip();
1048        doc.skeleton.bones = vec![
1049            Bone {
1050                name: "hips".into(),
1051                parent: None,
1052                rest: Transform {
1053                    translation: Vec3::splat(-f32::MAX),
1054                    ..Transform::IDENTITY
1055                },
1056                inverse_bind: None,
1057            },
1058            Bone {
1059                name: "left".into(),
1060                parent: None,
1061                rest: Transform {
1062                    translation: Vec3::splat(f32::MAX),
1063                    ..Transform::IDENTITY
1064                },
1065                inverse_bind: None,
1066            },
1067        ];
1068        doc.clips[0].tracks[0].bone = 0;
1069        let roles = ResolvedRoles::from_names(
1070            &doc.skeleton,
1071            [
1072                (Role::Hips, "hips".to_string()),
1073                (Role::LeftFoot, "left".to_string()),
1074            ],
1075        );
1076        let grid = MetricGrids::new(&doc).grid(0).expect("metric grid");
1077
1078        assert!(grid.model_position(0, 0).is_finite());
1079        assert!(grid.model_position(0, 1).is_finite());
1080        assert!(foot_cycle_metrics(&grid, &roles, MIN_STRIDE_STEP_M).is_none());
1081    }
1082
1083    /// A real stride can still leave `loop_seam_ratio` `None`: the seam
1084    /// (frame `frames - 1` vs frame `0`) is a single point-to-point
1085    /// distance, unconstrained by either neighbour step, so it can sit at
1086    /// a per-axis delta near `f32::MAX` while both neighbour steps stay at
1087    /// the smallest representable positive `f32` value (comfortably over
1088    /// the configured floor). `f32` squares that delta while computing the
1089    /// distance, overflowing to infinity even though every input position
1090    /// was finite — the ratio then divides out non-finite, not "no
1091    /// subject". This is the only known route to
1092    /// [`FootCycleMetrics::loop_seam_ratio`]'s "real stride but
1093    /// undirivable" `None`, and it requires magnitudes far outside any
1094    /// real animation.
1095    #[test]
1096    fn foot_metrics_real_stride_with_seam_beyond_f32_squaring_range_has_no_ratio() {
1097        let mut doc = document_with_metric_clip();
1098        doc.skeleton.bones = vec![
1099            Bone {
1100                name: "hips".into(),
1101                parent: None,
1102                rest: Transform::IDENTITY,
1103                inverse_bind: None,
1104            },
1105            Bone {
1106                name: "left".into(),
1107                parent: Some(0),
1108                rest: Transform::IDENTITY,
1109                inverse_bind: None,
1110            },
1111        ];
1112        doc.clips[0].tracks = vec![Track {
1113            bone: 1,
1114            property: Property::Translation,
1115            interpolation: Interpolation::Linear,
1116            times: vec![0.0, 0.25, 0.5, 1.0],
1117            values: TrackValues::Vec3s(vec![
1118                Vec3::ZERO,
1119                Vec3::new(f32::MIN_POSITIVE, 0.0, 0.0),
1120                Vec3::new(f32::MAX - f32::MIN_POSITIVE, 0.0, 0.0),
1121                Vec3::new(f32::MAX, 0.0, 0.0),
1122            ]),
1123        }];
1124        let roles = ResolvedRoles::from_names(
1125            &doc.skeleton,
1126            [
1127                (Role::Hips, "hips".to_string()),
1128                (Role::LeftFoot, "left".to_string()),
1129            ],
1130        );
1131        let grid = MetricGrids::new(&doc).grid(0).expect("metric grid");
1132
1133        let metrics = foot_cycle_metrics(&grid, &roles, f64::from(f32::MIN_POSITIVE))
1134            .expect("hips + one foot role with enough frames yields metrics");
1135
1136        assert!(
1137            metrics.has_real_stride,
1138            "the neighbour step met the (tiny) configured floor"
1139        );
1140        assert_eq!(
1141            metrics.loop_seam_ratio, None,
1142            "the seam distance overflowed f32 squaring to infinity, so the \
1143             ratio is non-finite despite a real stride"
1144        );
1145    }
1146
1147    fn trajectory_grid_with_rotations(positions: Vec<Vec3>, rotations: Vec<Quat>) -> PoseGrid {
1148        assert_eq!(positions.len(), rotations.len());
1149        assert!(positions.len() >= 2);
1150        let last = positions.len() - 1;
1151        let times = (0..=last)
1152            .map(|index| index as f32 / last as f32)
1153            .collect::<Vec<_>>();
1154        let skeleton = Skeleton {
1155            bones: vec![Bone {
1156                name: "root".into(),
1157                parent: None,
1158                rest: Transform::IDENTITY,
1159                inverse_bind: None,
1160            }],
1161        };
1162        let clip = Clip {
1163            name: "trajectory".into(),
1164            duration_s: 1.0,
1165            tracks: vec![
1166                Track {
1167                    bone: 0,
1168                    property: Property::Translation,
1169                    interpolation: Interpolation::Linear,
1170                    times: times.clone(),
1171                    values: TrackValues::Vec3s(positions),
1172                },
1173                Track {
1174                    bone: 0,
1175                    property: Property::Rotation,
1176                    interpolation: Interpolation::Linear,
1177                    times,
1178                    values: TrackValues::Quats(rotations),
1179                },
1180            ],
1181        };
1182        sample_clip(&skeleton, &clip, last + 1)
1183    }
1184
1185    fn trajectory_grid(positions: Vec<Vec3>, yaw_degrees: Vec<f32>) -> PoseGrid {
1186        trajectory_grid_with_rotations(
1187            positions,
1188            yaw_degrees
1189                .into_iter()
1190                .map(|degrees| Quat::from_rotation_y(degrees.to_radians()))
1191                .collect(),
1192        )
1193    }
1194
1195    fn assert_stationary_translation(trajectory: &RootTrajectoryMetrics) {
1196        let translation = trajectory
1197            .translation
1198            .expect("finite stationary translation");
1199        assert_eq!(translation.horizontal_displacement_x_m, 0.0);
1200        assert_eq!(translation.horizontal_displacement_z_m, 0.0);
1201        assert_eq!(translation.horizontal_travel_m, 0.0);
1202        assert_eq!(translation.vertical_displacement_m, 0.0);
1203        assert_eq!(translation.vertical_min_displacement_m, 0.0);
1204        assert_eq!(translation.vertical_max_displacement_m, 0.0);
1205    }
1206
1207    #[test]
1208    fn root_trajectory_retains_direction_travel_and_vertical_extrema() {
1209        let grid = trajectory_grid(
1210            vec![
1211                Vec3::ZERO,
1212                Vec3::new(1.0, 2.0, 0.0),
1213                Vec3::new(1.0, 1.0, -1.0),
1214            ],
1215            vec![0.0, 45.0, 90.0],
1216        );
1217        let trajectory = root_trajectory_metrics(&grid, 0).expect("valid selected bone");
1218        let translation = trajectory.translation.expect("finite translation");
1219
1220        assert_eq!(translation.horizontal_displacement_x_m, 1.0);
1221        assert_eq!(translation.horizontal_displacement_z_m, -1.0);
1222        assert_eq!(translation.horizontal_travel_m, 2.0);
1223        assert!(
1224            translation.horizontal_travel_m
1225                > translation
1226                    .horizontal_displacement_x_m
1227                    .hypot(translation.horizontal_displacement_z_m)
1228        );
1229        assert_eq!(translation.vertical_displacement_m, 1.0);
1230        assert_eq!(translation.vertical_min_displacement_m, 0.0);
1231        assert_eq!(translation.vertical_max_displacement_m, 2.0);
1232        let yaw = trajectory.yaw.expect("finite yaw");
1233        assert!((yaw.net_yaw_deg - 90.0).abs() < 1.0e-4);
1234        assert!((yaw.unwrapped_yaw_deg - 90.0).abs() < 1.0e-4);
1235
1236        let out_and_back =
1237            trajectory_grid(vec![Vec3::ZERO, Vec3::X, Vec3::ZERO], vec![0.0, 0.0, 0.0]);
1238        let translation = root_trajectory_metrics(&out_and_back, 0)
1239            .unwrap()
1240            .translation
1241            .unwrap();
1242        assert_eq!(translation.horizontal_displacement_x_m, 0.0);
1243        assert_eq!(translation.horizontal_travel_m, 2.0);
1244
1245        for (positions, expected_x, expected_z, expected_travel) in [
1246            (vec![Vec3::ZERO, -Vec3::Z, -Vec3::Z * 2.0], 0.0, -2.0, 2.0),
1247            (vec![Vec3::ZERO, Vec3::X * 0.5, Vec3::X], 1.0, 0.0, 1.0),
1248        ] {
1249            let grid = trajectory_grid(positions, vec![0.0; 3]);
1250            let translation = root_trajectory_metrics(&grid, 0)
1251                .unwrap()
1252                .translation
1253                .unwrap();
1254            assert_eq!(translation.horizontal_displacement_x_m, expected_x);
1255            assert_eq!(translation.horizontal_displacement_z_m, expected_z);
1256            assert_eq!(translation.horizontal_travel_m, expected_travel);
1257        }
1258
1259        for (positions, expected_net, expected_min, expected_max) in [
1260            (vec![Vec3::ZERO, Vec3::Y, Vec3::Y * 2.0], 2.0, 0.0, 2.0),
1261            (vec![Vec3::ZERO, -Vec3::Y, -Vec3::Y * 2.0], -2.0, -2.0, 0.0),
1262            (vec![Vec3::ZERO, Vec3::Y * 2.0, Vec3::ZERO], 0.0, 0.0, 2.0),
1263        ] {
1264            let grid = trajectory_grid(positions, vec![0.0; 3]);
1265            let translation = root_trajectory_metrics(&grid, 0)
1266                .unwrap()
1267                .translation
1268                .unwrap();
1269            assert_eq!(translation.vertical_displacement_m, expected_net);
1270            assert_eq!(translation.vertical_min_displacement_m, expected_min);
1271            assert_eq!(translation.vertical_max_displacement_m, expected_max);
1272        }
1273    }
1274
1275    #[test]
1276    fn root_yaw_retains_signed_half_and_full_turns_and_reversing_travel() {
1277        assert_eq!(canonical_net_yaw_deg(179.99995), 180.0);
1278        assert_eq!(canonical_net_yaw_deg(-179.99995), -180.0);
1279        let cases = [
1280            (vec![0.0, 45.0, 90.0], 90.0, 90.0, 90.0),
1281            (vec![0.0, -45.0, -90.0], -90.0, -90.0, 90.0),
1282            (vec![0.0, 90.0, 180.0], 180.0, 180.0, 180.0),
1283            (vec![0.0, -90.0, -180.0], -180.0, -180.0, 180.0),
1284            (
1285                vec![0.0, 60.0, 120.0, 179.99995],
1286                180.0,
1287                179.99995,
1288                179.99995,
1289            ),
1290            (
1291                vec![0.0, -60.0, -120.0, -179.99995],
1292                -180.0,
1293                -179.99995,
1294                179.99995,
1295            ),
1296            (vec![0.0, 90.0, 180.0, 270.0, 360.0], 0.0, 360.0, 360.0),
1297            (vec![0.0, -90.0, -180.0, -270.0, -360.0], 0.0, -360.0, 360.0),
1298            (vec![0.0, 90.0, 0.0], 0.0, 0.0, 180.0),
1299        ];
1300
1301        for (angles, expected_net, expected_unwrapped, expected_travel) in cases {
1302            let grid = trajectory_grid(vec![Vec3::ZERO; angles.len()], angles);
1303            let trajectory = root_trajectory_metrics(&grid, 0).unwrap();
1304            assert_stationary_translation(&trajectory);
1305            let yaw = trajectory.yaw.expect("yaw with sub-half-turn steps");
1306            assert_eq!(yaw.heading_axis, RootYawHeadingAxis::PositiveZ);
1307            if expected_net == 180.0 || expected_net == -180.0 {
1308                assert_eq!(yaw.net_yaw_deg, expected_net, "{yaw:?}");
1309            } else {
1310                assert!((yaw.net_yaw_deg - expected_net).abs() < 1.0e-4, "{yaw:?}");
1311            }
1312            assert!(
1313                (yaw.unwrapped_yaw_deg - expected_unwrapped).abs() < 1.0e-4,
1314                "{yaw:?}"
1315            );
1316            assert!(
1317                (yaw.yaw_travel_deg - expected_travel).abs() < 1.0e-4,
1318                "{yaw:?}"
1319            );
1320        }
1321    }
1322
1323    #[test]
1324    fn root_trajectory_translation_and_yaw_fail_independently() {
1325        let bad_position = trajectory_grid(
1326            vec![Vec3::ZERO, Vec3::new(f32::NAN, 0.0, 0.0), Vec3::ZERO],
1327            vec![0.0, 45.0, 90.0],
1328        );
1329        let measured = root_trajectory_metrics(&bad_position, 0).unwrap();
1330        assert!(measured.translation.is_none());
1331        assert!(measured.yaw.is_some());
1332
1333        let bad_rotation = trajectory_grid_with_rotations(
1334            vec![Vec3::ZERO, Vec3::X, Vec3::X * 2.0],
1335            vec![
1336                Quat::IDENTITY,
1337                Quat::from_xyzw(f32::NAN, 0.0, 0.0, 1.0),
1338                Quat::IDENTITY,
1339            ],
1340        );
1341        let measured = root_trajectory_metrics(&bad_rotation, 0).unwrap();
1342        let translation = measured
1343            .translation
1344            .expect("translation remains measurable");
1345        assert_eq!(translation.horizontal_displacement_x_m, 2.0);
1346        assert_eq!(translation.horizontal_displacement_z_m, 0.0);
1347        assert_eq!(translation.horizontal_travel_m, 2.0);
1348        assert_eq!(translation.vertical_displacement_m, 0.0);
1349        assert_eq!(translation.vertical_min_displacement_m, 0.0);
1350        assert_eq!(translation.vertical_max_displacement_m, 0.0);
1351        assert!(measured.yaw.is_none());
1352
1353        let zero_rotation = trajectory_grid_with_rotations(
1354            vec![Vec3::ZERO, Vec3::X, Vec3::X * 2.0],
1355            vec![
1356                Quat::IDENTITY,
1357                Quat::from_xyzw(0.0, 0.0, 0.0, 0.0),
1358                Quat::IDENTITY,
1359            ],
1360        );
1361        let measured = root_trajectory_metrics(&zero_rotation, 0).unwrap();
1362        let translation = measured
1363            .translation
1364            .expect("zero quaternion only makes rotation decomposition unavailable");
1365        assert_eq!(translation.horizontal_displacement_x_m, 2.0);
1366        assert_eq!(translation.horizontal_displacement_z_m, 0.0);
1367        assert_eq!(translation.horizontal_travel_m, 2.0);
1368        assert_eq!(translation.vertical_displacement_m, 0.0);
1369        assert_eq!(translation.vertical_min_displacement_m, 0.0);
1370        assert_eq!(translation.vertical_max_displacement_m, 0.0);
1371        assert!(measured.yaw.is_none());
1372    }
1373
1374    #[test]
1375    fn root_trajectory_extrema_widen_finite_samples_and_reject_model_overflow() {
1376        let finite_extremes = trajectory_grid(
1377            vec![-Vec3::Y * f32::MAX, Vec3::Y * f32::MAX],
1378            vec![0.0, 0.0],
1379        );
1380        let translation = root_trajectory_metrics(&finite_extremes, 0)
1381            .expect("selected bone exists")
1382            .translation
1383            .expect("finite binary32 samples have finite widened extrema");
1384        let full_binary32_span = 2.0 * f64::from(f32::MAX);
1385        assert!(full_binary32_span.is_finite());
1386        assert_eq!(translation.vertical_displacement_m, full_binary32_span);
1387        assert_eq!(translation.vertical_min_displacement_m, 0.0);
1388        assert_eq!(translation.vertical_max_displacement_m, full_binary32_span);
1389
1390        let skeleton = Skeleton {
1391            bones: vec![
1392                Bone {
1393                    name: "ancestor".into(),
1394                    parent: None,
1395                    rest: Transform {
1396                        translation: Vec3::Y * f32::MAX,
1397                        ..Transform::IDENTITY
1398                    },
1399                    inverse_bind: None,
1400                },
1401                Bone {
1402                    name: "root".into(),
1403                    parent: Some(0),
1404                    rest: Transform {
1405                        translation: Vec3::Y * f32::MAX,
1406                        ..Transform::IDENTITY
1407                    },
1408                    inverse_bind: None,
1409                },
1410            ],
1411        };
1412        let clip = Clip {
1413            name: "overflow".into(),
1414            duration_s: 1.0,
1415            tracks: vec![Track {
1416                bone: 1,
1417                property: Property::Rotation,
1418                interpolation: Interpolation::Linear,
1419                times: vec![0.0, 1.0],
1420                values: TrackValues::Quats(vec![Quat::IDENTITY; 2]),
1421            }],
1422        };
1423        let grid = sample_clip(&skeleton, &clip, 2);
1424
1425        assert!(
1426            skeleton
1427                .bones
1428                .iter()
1429                .all(|bone| bone.rest.translation.is_finite()
1430                    && bone.rest.rotation.is_finite()
1431                    && bone.rest.scale.is_finite())
1432        );
1433        assert!(
1434            !grid.model_position(0, 1).is_finite(),
1435            "finite local translations overflow while composing model space"
1436        );
1437        let trajectory = root_trajectory_metrics(&grid, 1).expect("selected bone exists");
1438        assert_eq!(trajectory.translation, None);
1439        let yaw = trajectory
1440            .yaw
1441            .expect("translation overflow does not erase finite yaw");
1442        assert_eq!(yaw.net_yaw_deg, 0.0);
1443        assert_eq!(yaw.unwrapped_yaw_deg, 0.0);
1444        assert_eq!(yaw.yaw_travel_deg, 0.0);
1445    }
1446
1447    #[test]
1448    fn root_yaw_publishes_fixed_basis_and_refuses_ambiguous_steps() {
1449        let in_place_grid =
1450            trajectory_grid(vec![Vec3::new(3.0, 2.0, -1.0); 3], vec![0.0, 45.0, 90.0]);
1451        let in_place = root_trajectory_metrics(&in_place_grid, 0).unwrap();
1452        assert_stationary_translation(&in_place);
1453
1454        let positive_x_rotation = Quat::from_rotation_x(std::f32::consts::FRAC_PI_6);
1455        let positive_x_length = {
1456            let (x, z) = horizontal_heading(
1457                positive_x_rotation.as_dquat(),
1458                RootYawHeadingAxis::PositiveX,
1459            );
1460            x.hypot(z)
1461        };
1462        for other_axis in [RootYawHeadingAxis::PositiveZ, RootYawHeadingAxis::PositiveY] {
1463            let (x, z) = horizontal_heading(positive_x_rotation.as_dquat(), other_axis);
1464            assert!(positive_x_length > x.hypot(z));
1465        }
1466        let positive_x_grid =
1467            trajectory_grid_with_rotations(vec![Vec3::ZERO; 3], vec![positive_x_rotation; 3]);
1468        let positive_x = root_trajectory_metrics(&positive_x_grid, 0).unwrap();
1469        assert_stationary_translation(&positive_x);
1470        assert_eq!(
1471            positive_x.yaw.unwrap().heading_axis,
1472            RootYawHeadingAxis::PositiveX
1473        );
1474
1475        // Analytic tilted basis, built directly from orthonormal columns rather
1476        // than Euler angles. Local +Y has world heading 30 degrees and a 0.5
1477        // vertical component; +X and +Z each have a smaller horizontal
1478        // projection, so +Y is the strict fixed witness.
1479        let sqrt_three = 3.0_f32.sqrt();
1480        let positive_y = Vec3::new(sqrt_three / 4.0, 0.5, 0.75);
1481        let horizontal_perpendicular = Vec3::new(sqrt_three / 2.0, 0.0, -0.5);
1482        let tilt_tangent = Vec3::new(-0.25, sqrt_three / 2.0, -sqrt_three / 4.0);
1483        let positive_x =
1484            (horizontal_perpendicular + tilt_tangent) * std::f32::consts::FRAC_1_SQRT_2;
1485        let positive_z = positive_x.cross(positive_y);
1486        let tilted = Quat::from_mat3(&Mat3::from_cols(positive_x, positive_y, positive_z));
1487        let (heading_x, heading_z) =
1488            horizontal_heading(tilted.as_dquat(), RootYawHeadingAxis::PositiveY);
1489        assert!((heading_x.atan2(heading_z).to_degrees() - 30.0).abs() < 1.0e-5);
1490        let rotations = [0.0_f32, 20.0, 40.0]
1491            .map(|degrees| Quat::from_axis_angle(Vec3::Y, degrees.to_radians()) * tilted)
1492            .to_vec();
1493        let basis_grid = trajectory_grid_with_rotations(vec![Vec3::ZERO; 3], rotations);
1494        let basis = root_trajectory_metrics(&basis_grid, 0).unwrap();
1495        assert_stationary_translation(&basis);
1496        let yaw = basis.yaw.unwrap();
1497        assert_eq!(yaw.heading_axis, RootYawHeadingAxis::PositiveY);
1498        assert!((yaw.net_yaw_deg - 40.0).abs() < 1.0e-4);
1499        assert!((yaw.unwrapped_yaw_deg - 40.0).abs() < 1.0e-4);
1500        assert!((yaw.yaw_travel_deg - 40.0).abs() < 1.0e-4);
1501
1502        // A local-Y twist changes a Y-first Euler decomposition but cannot
1503        // change the retained local +Y witness. This is the concrete case an
1504        // Euler-component shortcut gets wrong.
1505        let local_half_turn = Quat::from_xyzw(0.0, 1.0, 0.0, 0.0);
1506        let fixed_witness_rotations = vec![tilted, tilted * local_half_turn, tilted];
1507        let first_euler_y = fixed_witness_rotations[0].to_euler(EulerRot::YXZ).0;
1508        let twisted_euler_y = fixed_witness_rotations[1].to_euler(EulerRot::YXZ).0;
1509        assert!(
1510            (twisted_euler_y - first_euler_y).abs() > 1.0,
1511            "Y-first Euler shortcut must observe a misleading change"
1512        );
1513        let expected_heading = horizontal_heading(
1514            fixed_witness_rotations[0].as_dquat(),
1515            RootYawHeadingAxis::PositiveY,
1516        );
1517        for rotation in &fixed_witness_rotations[1..] {
1518            let heading = horizontal_heading(rotation.as_dquat(), RootYawHeadingAxis::PositiveY);
1519            assert!((heading.0 - expected_heading.0).abs() < 1.0e-7);
1520            assert!((heading.1 - expected_heading.1).abs() < 1.0e-7);
1521        }
1522
1523        let stationary_fixed_witness =
1524            trajectory_grid_with_rotations(vec![Vec3::ZERO; 3], fixed_witness_rotations.clone());
1525        let misleading_translation_fixed_witness = trajectory_grid_with_rotations(
1526            vec![Vec3::ZERO, Vec3::X * 100.0, Vec3::Z * 100.0],
1527            fixed_witness_rotations,
1528        );
1529        let stationary = root_trajectory_metrics(&stationary_fixed_witness, 0).unwrap();
1530        assert_stationary_translation(&stationary);
1531        let misleading = root_trajectory_metrics(&misleading_translation_fixed_witness, 0)
1532            .expect("translation content does not select yaw/up");
1533        assert!(misleading.translation.unwrap().horizontal_travel_m > 200.0);
1534        let stationary_yaw = stationary.yaw.unwrap();
1535        let misleading_yaw = misleading.yaw.unwrap();
1536        assert_eq!(stationary_yaw.heading_axis, RootYawHeadingAxis::PositiveY);
1537        assert_eq!(misleading_yaw, stationary_yaw);
1538        assert_eq!(stationary_yaw.net_yaw_deg, 0.0);
1539        assert_eq!(stationary_yaw.unwrapped_yaw_deg, 0.0);
1540        assert_eq!(stationary_yaw.yaw_travel_deg, 0.0);
1541
1542        let becomes_vertical = trajectory_grid_with_rotations(
1543            vec![Vec3::ZERO; 3],
1544            vec![
1545                Quat::IDENTITY,
1546                Quat::from_rotation_x(std::f32::consts::FRAC_PI_4),
1547                Quat::from_rotation_x(std::f32::consts::FRAC_PI_2),
1548            ],
1549        );
1550        assert!(
1551            root_trajectory_metrics(&becomes_vertical, 0)
1552                .unwrap()
1553                .yaw
1554                .is_none()
1555        );
1556
1557        let half_turn_step = trajectory_grid(vec![Vec3::ZERO; 2], vec![0.0, 180.0]);
1558        assert!(
1559            root_trajectory_metrics(&half_turn_step, 0)
1560                .unwrap()
1561                .yaw
1562                .is_none()
1563        );
1564    }
1565
1566    #[test]
1567    fn root_yaw_uses_animated_ancestor_model_rotation_for_fixed_child_local_pose() {
1568        let skeleton = Skeleton {
1569            bones: vec![
1570                Bone {
1571                    name: "ancestor".into(),
1572                    parent: None,
1573                    rest: Transform::IDENTITY,
1574                    inverse_bind: None,
1575                },
1576                Bone {
1577                    name: "root".into(),
1578                    parent: Some(0),
1579                    rest: Transform::IDENTITY,
1580                    inverse_bind: None,
1581                },
1582            ],
1583        };
1584        let clip = Clip {
1585            name: "inherited_yaw".into(),
1586            duration_s: 1.0,
1587            tracks: vec![
1588                Track {
1589                    bone: 0,
1590                    property: Property::Rotation,
1591                    interpolation: Interpolation::Linear,
1592                    times: vec![0.0, 0.5, 1.0],
1593                    values: TrackValues::Quats(
1594                        [0.0_f32, 45.0, 90.0]
1595                            .map(|degrees| Quat::from_rotation_y(degrees.to_radians()))
1596                            .to_vec(),
1597                    ),
1598                },
1599                Track {
1600                    bone: 1,
1601                    property: Property::Rotation,
1602                    interpolation: Interpolation::Linear,
1603                    times: vec![0.0, 0.5, 1.0],
1604                    values: TrackValues::Quats(vec![Quat::IDENTITY; 3]),
1605                },
1606            ],
1607        };
1608        let grid = sample_clip(&skeleton, &clip, 3);
1609        for frame in 0..grid.frame_count() {
1610            assert_eq!(grid.local(frame, 1).rotation, Quat::IDENTITY);
1611        }
1612
1613        let trajectory = root_trajectory_metrics(&grid, 1).expect("child trajectory");
1614        assert_stationary_translation(&trajectory);
1615        let yaw = trajectory.yaw.expect("ancestor supplies model-space yaw");
1616        assert_eq!(yaw.heading_axis, RootYawHeadingAxis::PositiveZ);
1617        assert!((yaw.net_yaw_deg - 90.0).abs() < 1.0e-4);
1618        assert!((yaw.unwrapped_yaw_deg - 90.0).abs() < 1.0e-4);
1619        assert!((yaw.yaw_travel_deg - 90.0).abs() < 1.0e-4);
1620    }
1621}