1use crate::model::{Clip, Document, Property, Track};
8use crate::profile::{ResolvedRoles, Role};
9use crate::sample::{PoseGrid, sample_clip};
10use glam::{Quat, Vec3};
11use std::cell::RefCell;
12use std::collections::BTreeMap;
13use std::rc::Rc;
14
15pub const MIN_STRIDE_STEP_M: f64 = 0.02;
19
20#[derive(Debug)]
31pub struct MetricGrids<'a> {
32 doc: &'a Document,
33 grids: RefCell<BTreeMap<usize, Rc<PoseGrid>>>,
34}
35
36impl<'a> MetricGrids<'a> {
37 pub fn new(doc: &'a Document) -> Self {
39 Self {
40 doc,
41 grids: RefCell::new(BTreeMap::new()),
42 }
43 }
44
45 pub fn document(&self) -> &'a Document {
47 self.doc
48 }
49
50 pub fn grid(&self, clip_index: usize) -> Option<Rc<PoseGrid>> {
54 let clip = self.doc.clips.get(clip_index)?;
55 let frames = metric_frame_count(clip)?;
56 Some(
57 self.grids
58 .borrow_mut()
59 .entry(clip_index)
60 .or_insert_with(|| Rc::new(sample_clip(&self.doc.skeleton, clip, frames)))
61 .clone(),
62 )
63 }
64}
65
66#[derive(Debug, Clone, PartialEq)]
68#[non_exhaustive]
69pub struct FootCycleMetrics {
70 pub loop_seam_ratio: Option<f64>,
75 pub gait_phase: Option<f64>,
80 pub lr_amplitude_m: f64,
83}
84
85#[derive(Debug, Clone, PartialEq)]
87#[non_exhaustive]
88pub struct BoneLoopContinuityMetrics {
89 pub position_delta_m: f64,
91 pub rotation_delta_deg: f64,
93 pub seam_velocity_delta_mps: f64,
96 pub seam_angular_velocity_delta_degps: f64,
99}
100
101fn shortest_path_model_rotation_vector(from: Quat, to: Quat) -> Option<Vec3> {
109 let mut step = to * from.conjugate();
110 if !step.is_finite() {
111 return None;
112 }
113
114 let [x, y, z, w] = step.to_array();
115 if w < 0.0 || (w == 0.0 && (x < 0.0 || (x == 0.0 && (y < 0.0 || (y == 0.0 && z < 0.0))))) {
116 step = -step;
117 }
118
119 let vector = step.xyz();
120 let sin_half_angle = vector.length();
121 if !sin_half_angle.is_finite() {
122 return None;
123 }
124 if sin_half_angle == 0.0 {
125 return Some(Vec3::ZERO);
126 }
127
128 let angle_rad = 2.0 * sin_half_angle.atan2(step.w);
129 let rotation_vector = vector * (angle_rad / sin_half_angle);
130 rotation_vector.is_finite().then_some(rotation_vector)
131}
132
133pub fn loop_continuity_metrics(grid: &PoseGrid) -> Option<Vec<BoneLoopContinuityMetrics>> {
145 let frames = grid.frame_count();
146 if frames < 3 || grid.bone_count() == 0 {
147 return None;
148 }
149
150 let first_dt = f64::from(grid.times[1] - grid.times[0]);
151 let last_dt = f64::from(grid.times[frames - 1] - grid.times[frames - 2]);
152 if !first_dt.is_finite() || !last_dt.is_finite() || first_dt <= 0.0 || last_dt <= 0.0 {
153 return None;
154 }
155
156 (0..grid.bone_count())
157 .map(|bone| {
158 let first = grid.model_position(0, bone);
159 let next = grid.model_position(1, bone);
160 let previous = grid.model_position(frames - 2, bone);
161 let last = grid.model_position(frames - 1, bone);
162 if [first, next, previous, last]
163 .iter()
164 .any(|position| !position.is_finite())
165 {
166 return None;
167 }
168
169 let rotations = [
170 grid.model_rotation(0, bone),
171 grid.model_rotation(1, bone),
172 grid.model_rotation(frames - 2, bone),
173 grid.model_rotation(frames - 1, bone),
174 ];
175 if rotations.iter().any(|rotation| {
176 !rotation.is_finite()
177 || !rotation.length_squared().is_finite()
178 || rotation.length_squared() == 0.0
179 }) {
180 return None;
181 }
182 let [
183 first_rotation,
184 next_rotation,
185 previous_rotation,
186 last_rotation,
187 ] = rotations.map(Quat::normalize);
188 let delta = first_rotation.conjugate() * last_rotation;
189 let [x, y, z, w] = delta.to_array();
190 let sin_half_angle = Vec3::new(x, y, z).length();
191 let rotation_delta_deg = f64::from(2.0 * sin_half_angle.atan2(w.abs()).to_degrees());
192 let position_delta_m = f64::from((last - first).length());
193 let outgoing_velocity = (next - first) / first_dt as f32;
194 let incoming_velocity = (last - previous) / last_dt as f32;
195 let seam_velocity_delta_mps =
196 f64::from((outgoing_velocity - incoming_velocity).length());
197 let outgoing_angular_velocity =
198 shortest_path_model_rotation_vector(first_rotation, next_rotation)?
199 / first_dt as f32;
200 let incoming_angular_velocity =
201 shortest_path_model_rotation_vector(previous_rotation, last_rotation)?
202 / last_dt as f32;
203 let seam_angular_velocity_delta_degps = f64::from(
204 (outgoing_angular_velocity - incoming_angular_velocity)
205 .length()
206 .to_degrees(),
207 );
208
209 if !position_delta_m.is_finite()
210 || !rotation_delta_deg.is_finite()
211 || !seam_velocity_delta_mps.is_finite()
212 || !seam_angular_velocity_delta_degps.is_finite()
213 {
214 return None;
215 }
216 Some(BoneLoopContinuityMetrics {
217 position_delta_m,
218 rotation_delta_deg,
219 seam_velocity_delta_mps,
220 seam_angular_velocity_delta_degps,
221 })
222 })
223 .collect()
224}
225
226pub fn foot_cycle_metrics(
240 grid: &PoseGrid,
241 roles: &ResolvedRoles,
242 min_stride_step_m: f64,
243) -> Option<FootCycleMetrics> {
244 if grid.frame_count() < 3 {
245 return None;
246 }
247 let hips = roles.get(Role::Hips)?;
248 let left: Vec<usize> = [Role::LeftFoot, Role::LeftToe]
249 .iter()
250 .filter_map(|&r| roles.get(r))
251 .collect();
252 let right: Vec<usize> = [Role::RightFoot, Role::RightToe]
253 .iter()
254 .filter_map(|&r| roles.get(r))
255 .collect();
256 let feet: Vec<usize> = left.iter().chain(right.iter()).copied().collect();
257 if feet.is_empty() {
258 return None;
259 }
260
261 let frames = grid.frame_count();
262 let rel = |frame: usize, bone: usize| -> Vec3 {
265 grid.model_position(frame, bone) - grid.model_position(frame, hips)
266 };
267 if (0..frames).any(|frame| {
268 !grid.model_position(frame, hips).is_finite()
269 || feet.iter().any(|&foot| !rel(frame, foot).is_finite())
270 }) {
271 return None;
272 }
273
274 let max_foot_dist = |a: usize, b: usize| -> f64 {
280 feet.iter()
281 .map(|&f| (rel(a, f) - rel(b, f)).length() as f64)
282 .fold(0.0, f64::max)
283 };
284 let seam = max_foot_dist(frames - 1, 0);
285 let step_first = max_foot_dist(1, 0);
286 let step_last = max_foot_dist(frames - 1, frames - 2);
287 let neighbour_step = step_first.max(step_last);
288 let loop_seam_ratio = if neighbour_step > 0.0 && neighbour_step >= min_stride_step_m {
289 let ratio = seam / neighbour_step;
290 ratio.is_finite().then_some(ratio)
291 } else {
292 None
293 };
294
295 let cycle = if frames > 3 { frames - 1 } else { frames };
300 let mut gait_phase = None;
301 let mut lr_amplitude_m = 0.0f64;
302 if !left.is_empty() && !right.is_empty() {
303 let avg_height = |frame: usize, bones: &[usize]| -> f64 {
304 bones.iter().map(|&b| rel(frame, b).y as f64).sum::<f64>() / bones.len() as f64
305 };
306 let diff: Vec<f64> = (0..cycle)
307 .map(|f| avg_height(f, &left) - avg_height(f, &right))
308 .collect();
309 let max = diff.iter().copied().fold(f64::MIN, f64::max);
310 let min = diff.iter().copied().fold(f64::MAX, f64::min);
311 lr_amplitude_m = max - min;
312 gait_phase = fundamental_trough_phase(&diff);
313 }
314
315 Some(FootCycleMetrics {
316 loop_seam_ratio,
317 gait_phase,
318 lr_amplitude_m,
319 })
320}
321
322pub fn fundamental_trough_phase(signal: &[f64]) -> Option<f64> {
326 let n = signal.len();
327 if n < 2 || signal.iter().any(|value| !value.is_finite()) {
328 return None;
329 }
330 let mut re = 0.0f64;
331 let mut im = 0.0f64;
332 for (k, y) in signal.iter().enumerate() {
333 let angle = std::f64::consts::TAU * k as f64 / n as f64;
334 re += y * angle.cos();
335 im += y * angle.sin();
336 }
337 let phi = im.atan2(re);
338 let phase = (phi / std::f64::consts::TAU + 0.5).rem_euclid(1.0);
339 phase.is_finite().then_some(phase)
340}
341
342pub fn root_motion_speed_mps(grid: &PoseGrid, roles: &ResolvedRoles) -> Option<f64> {
350 let bone = roles.get(Role::Root).or_else(|| roles.get(Role::Hips))?;
351 let frames = grid.frame_count();
352 if frames < 2 {
353 return None;
354 }
355 let duration = *grid.times.last()? as f64;
356 if duration <= 0.0 {
357 return None;
358 }
359 let a = grid.model_position(0, bone);
360 let b = grid.model_position(frames - 1, bone);
361 let dx = (b.x - a.x) as f64;
362 let dz = (b.z - a.z) as f64;
363 let speed = dx.hypot(dz) / duration;
364 speed.is_finite().then_some(speed)
365}
366
367pub fn rotation_range_deg(track: &Track) -> Option<f64> {
370 if track.property != Property::Rotation {
371 return None;
372 }
373 let first = track.key_quat(0)?;
374 if !first.is_finite() || first.length_squared() == 0.0 {
375 return None;
376 }
377 let first = first.normalize();
378 let mut max_deg = 0.0f64;
379 for k in 1..track.key_count() {
380 if let Some(q) = track.key_quat(k)
381 && q.is_finite()
382 && q.length_squared() > 0.0
383 {
384 let deg = first.angle_between(q.normalize()).to_degrees() as f64;
385 if deg.is_finite() {
386 max_deg = max_deg.max(deg);
387 }
388 }
389 }
390 Some(max_deg)
391}
392
393pub fn circular_phase_spread(phases: &[f64]) -> f64 {
397 use std::f64::consts::{PI, TAU};
398 let (mut sin_sum, mut cos_sum) = (0.0f64, 0.0f64);
399 for p in phases {
400 sin_sum += (p * TAU).sin();
401 cos_sum += (p * TAU).cos();
402 }
403 let mean = sin_sum.atan2(cos_sum);
404 let mut max_dev = 0.0f64;
405 for p in phases {
406 let mut d = (p * TAU - mean).abs() % TAU;
407 if d > PI {
408 d = TAU - d;
409 }
410 max_dev = max_dev.max(d / TAU);
411 }
412 max_dev
413}
414
415pub fn metric_frame_count(clip: &Clip) -> Option<usize> {
421 let n = crate::sample::default_frame_count(clip);
422 if clip.duration_s <= 0.0 || n < 3 {
423 None
424 } else {
425 Some(n)
426 }
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432 use crate::check::CheckCtx;
433 use crate::config::Config;
434 use crate::measure::measure_document;
435 use crate::model::{
436 Bone, Clip, Document, Interpolation, Property, Skeleton, Track, TrackValues, Transform,
437 };
438 use crate::profile::{ResolvedRoles, Role};
439 use glam::{Quat, Vec3};
440 use std::rc::Rc;
441
442 fn document_with_metric_clip() -> Document {
443 Document {
444 skeleton: Skeleton {
445 bones: vec![Bone {
446 name: "root".into(),
447 parent: None,
448 rest: Transform::IDENTITY,
449 inverse_bind: None,
450 }],
451 },
452 clips: vec![Clip {
453 name: "walk".into(),
454 duration_s: 1.0,
455 tracks: vec![Track {
456 bone: 0,
457 property: Property::Rotation,
458 interpolation: Interpolation::Linear,
459 times: vec![0.0, 0.5, 1.0],
460 values: TrackValues::Quats(vec![
461 Quat::IDENTITY,
462 Quat::from_rotation_y(0.1),
463 Quat::from_rotation_y(0.2),
464 ]),
465 }],
466 }],
467 ..Document::default()
468 }
469 }
470
471 fn document_with_grid_inputs(duration_s: f64, times: Vec<f32>) -> Document {
472 let values = vec![Quat::IDENTITY; times.len()];
473 Document {
474 skeleton: Skeleton {
475 bones: vec![Bone {
476 name: "root".into(),
477 parent: None,
478 rest: Transform::IDENTITY,
479 inverse_bind: None,
480 }],
481 },
482 clips: vec![Clip {
483 name: "probe".into(),
484 duration_s,
485 tracks: vec![Track {
486 bone: 0,
487 property: Property::Rotation,
488 interpolation: Interpolation::Linear,
489 times,
490 values: TrackValues::Quats(values),
491 }],
492 }],
493 ..Document::default()
494 }
495 }
496
497 #[test]
498 fn metric_grids_are_shared_by_checks_and_measurements() {
499 let doc = document_with_metric_clip();
500 let roles = ResolvedRoles::default();
501 let config = Config::default();
502 let grids = MetricGrids::new(&doc);
503
504 let ctx = CheckCtx::new(&grids, &roles, &config);
505 let from_ctx = ctx.grid(0).expect("metric grid");
506 let from_owner = grids.grid(0).expect("same metric grid");
507 assert!(Rc::ptr_eq(&from_ctx, &from_owner));
508
509 let measurements = measure_document(&grids, &roles, &config);
510 assert!(measurements.contains_key("walk"));
511 let fresh_grids = MetricGrids::new(&doc);
512 assert_eq!(
513 serde_json::to_value(&measurements).expect("shared measurements serialize"),
514 serde_json::to_value(measure_document(&fresh_grids, &roles, &config))
515 .expect("plain measurements serialize")
516 );
517 }
518
519 #[test]
520 fn grid_returns_none_for_each_documented_invalid_request() {
521 let valid = document_with_grid_inputs(1.0, vec![0.0, 0.5, 1.0]);
522 let valid_grids = MetricGrids::new(&valid);
523 assert!(valid_grids.grid(0).is_some());
524 for clip_index in [1, 2, usize::MAX] {
525 assert!(valid_grids.grid(clip_index).is_none());
526 }
527
528 for duration_s in [0.0, -1.0] {
529 let non_positive = document_with_grid_inputs(duration_s, vec![0.0, 0.5, 1.0]);
530 assert!(MetricGrids::new(&non_positive).grid(0).is_none());
531 }
532
533 for times in [vec![], vec![0.0], vec![0.0, 1.0]] {
534 let too_few_keys = document_with_grid_inputs(1.0, times);
535 assert!(MetricGrids::new(&too_few_keys).grid(0).is_none());
536 }
537 }
538
539 #[test]
540 fn grid_uses_longest_track_for_resolution() {
541 let mut doc = document_with_grid_inputs(1.0, vec![0.0, 1.0]);
544 doc.clips[0].tracks.push(Track {
545 bone: 0,
546 property: Property::Translation,
547 interpolation: Interpolation::Linear,
548 times: vec![0.0, 0.5, 1.0],
549 values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::X, 2.0 * Vec3::X]),
550 });
551
552 let grid = MetricGrids::new(&doc)
553 .grid(0)
554 .expect("later longest track supplies a metric grid");
555 assert_eq!(grid.frame_count(), 3);
556 }
557
558 #[test]
559 fn foot_metrics_reject_finite_positions_whose_relative_subtraction_overflows() {
560 let mut doc = document_with_metric_clip();
561 doc.skeleton.bones = vec![
562 Bone {
563 name: "hips".into(),
564 parent: None,
565 rest: Transform {
566 translation: Vec3::splat(-f32::MAX),
567 ..Transform::IDENTITY
568 },
569 inverse_bind: None,
570 },
571 Bone {
572 name: "left".into(),
573 parent: None,
574 rest: Transform {
575 translation: Vec3::splat(f32::MAX),
576 ..Transform::IDENTITY
577 },
578 inverse_bind: None,
579 },
580 ];
581 doc.clips[0].tracks[0].bone = 0;
582 let roles = ResolvedRoles::from_names(
583 &doc.skeleton,
584 [
585 (Role::Hips, "hips".to_string()),
586 (Role::LeftFoot, "left".to_string()),
587 ],
588 );
589 let grid = MetricGrids::new(&doc).grid(0).expect("metric grid");
590
591 assert!(grid.model_position(0, 0).is_finite());
592 assert!(grid.model_position(0, 1).is_finite());
593 assert!(foot_cycle_metrics(&grid, &roles, MIN_STRIDE_STEP_M).is_none());
594 }
595}