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<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
538pub 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 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 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 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
638pub 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
658pub 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
682pub 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
708pub 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
730pub 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 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 #[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 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 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}