1use 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
17pub const MIN_STRIDE_STEP_M: f64 = 0.02;
21
22pub const ROOT_YAW_HALF_TURN_AMBIGUITY_DEG: f64 = 1.0e-4;
26
27#[derive(Debug)]
38pub struct MetricGrids<'a> {
39 doc: &'a Document,
40 grids: RefCell<BTreeMap<usize, Rc<PoseGrid>>>,
41}
42
43impl<'a> MetricGrids<'a> {
44 pub fn new(doc: &'a Document) -> Self {
46 Self {
47 doc,
48 grids: RefCell::new(BTreeMap::new()),
49 }
50 }
51
52 pub fn document(&self) -> &'a Document {
54 self.doc
55 }
56
57 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#[derive(Debug, Clone, PartialEq)]
75#[non_exhaustive]
76pub struct FootCycleMetrics {
77 pub loop_seam_ratio: Option<f64>,
89 pub has_real_stride: bool,
95 pub gait_phase: Option<f64>,
101 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#[derive(Debug, Clone, PartialEq)]
135#[non_exhaustive]
136pub struct BoneLoopContinuityMetrics {
137 pub position_delta_m: f64,
139 pub rotation_delta_deg: f64,
141 pub seam_velocity_delta_mps: f64,
144 pub seam_angular_velocity_delta_degps: f64,
147}
148
149#[derive(Debug, Clone, PartialEq)]
151#[non_exhaustive]
152pub struct RootTrajectoryMetrics {
153 pub translation: Option<RootTranslationMetrics>,
155 pub yaw: Option<RootYawMetrics>,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq)]
162#[non_exhaustive]
163pub struct RootTranslationMetrics {
164 pub horizontal_displacement_x_m: f64,
166 pub horizontal_displacement_z_m: f64,
168 pub horizontal_travel_m: f64,
170 pub vertical_displacement_m: f64,
172 pub vertical_min_displacement_m: f64,
174 pub vertical_max_displacement_m: f64,
176}
177
178#[derive(Debug, Clone, Copy, PartialEq)]
180#[non_exhaustive]
181pub struct RootYawMetrics {
182 pub heading_axis: RootYawHeadingAxis,
184 pub net_yaw_deg: f64,
187 pub unwrapped_yaw_deg: f64,
191 pub yaw_travel_deg: f64,
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "snake_case")]
201#[non_exhaustive]
202pub enum RootYawHeadingAxis {
203 PositiveZ,
205 PositiveY,
207 PositiveX,
209}
210
211impl RootYawHeadingAxis {
212 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
222pub(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
233pub(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
251pub(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
264pub 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 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
413fn 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
445pub 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
542pub 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 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 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 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
642pub 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
662pub 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
686pub 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
712pub 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
734pub 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 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 #[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 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 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}