1use crate::model::{Clip, Document, Property, Track};
8use crate::profile::{ResolvedRoles, Role};
9use crate::sample::{PoseGrid, sample_clip};
10use glam::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
85pub fn foot_cycle_metrics(
99 grid: &PoseGrid,
100 roles: &ResolvedRoles,
101 min_stride_step_m: f64,
102) -> Option<FootCycleMetrics> {
103 if grid.frame_count() < 3 {
104 return None;
105 }
106 let hips = roles.get(Role::Hips)?;
107 let left: Vec<usize> = [Role::LeftFoot, Role::LeftToe]
108 .iter()
109 .filter_map(|&r| roles.get(r))
110 .collect();
111 let right: Vec<usize> = [Role::RightFoot, Role::RightToe]
112 .iter()
113 .filter_map(|&r| roles.get(r))
114 .collect();
115 let feet: Vec<usize> = left.iter().chain(right.iter()).copied().collect();
116 if feet.is_empty() {
117 return None;
118 }
119
120 let frames = grid.frame_count();
121 let rel = |frame: usize, bone: usize| -> Vec3 {
124 grid.model_position(frame, bone) - grid.model_position(frame, hips)
125 };
126
127 let max_foot_dist = |a: usize, b: usize| -> f64 {
133 feet.iter()
134 .map(|&f| (rel(a, f) - rel(b, f)).length() as f64)
135 .fold(0.0, f64::max)
136 };
137 let seam = max_foot_dist(frames - 1, 0);
138 let step_first = max_foot_dist(1, 0);
139 let step_last = max_foot_dist(frames - 1, frames - 2);
140 let neighbour_step = step_first.max(step_last);
141 let loop_seam_ratio = if neighbour_step > 0.0 && neighbour_step >= min_stride_step_m {
142 Some(seam / neighbour_step)
143 } else {
144 None
145 };
146
147 let cycle = if frames > 3 { frames - 1 } else { frames };
152 let mut gait_phase = None;
153 let mut lr_amplitude_m = 0.0f64;
154 if !left.is_empty() && !right.is_empty() {
155 let avg_height = |frame: usize, bones: &[usize]| -> f64 {
156 bones.iter().map(|&b| rel(frame, b).y as f64).sum::<f64>() / bones.len() as f64
157 };
158 let diff: Vec<f64> = (0..cycle)
159 .map(|f| avg_height(f, &left) - avg_height(f, &right))
160 .collect();
161 let max = diff.iter().copied().fold(f64::MIN, f64::max);
162 let min = diff.iter().copied().fold(f64::MAX, f64::min);
163 lr_amplitude_m = max - min;
164 gait_phase = fundamental_trough_phase(&diff);
165 }
166
167 Some(FootCycleMetrics {
168 loop_seam_ratio,
169 gait_phase,
170 lr_amplitude_m,
171 })
172}
173
174pub fn fundamental_trough_phase(signal: &[f64]) -> Option<f64> {
178 let n = signal.len();
179 if n < 2 {
180 return None;
181 }
182 let mut re = 0.0f64;
183 let mut im = 0.0f64;
184 for (k, y) in signal.iter().enumerate() {
185 let angle = std::f64::consts::TAU * k as f64 / n as f64;
186 re += y * angle.cos();
187 im += y * angle.sin();
188 }
189 let phi = im.atan2(re);
190 Some((phi / std::f64::consts::TAU + 0.5).rem_euclid(1.0))
191}
192
193pub fn root_motion_speed_mps(grid: &PoseGrid, roles: &ResolvedRoles) -> Option<f64> {
201 let bone = roles.get(Role::Root).or_else(|| roles.get(Role::Hips))?;
202 let frames = grid.frame_count();
203 if frames < 2 {
204 return None;
205 }
206 let duration = *grid.times.last()? as f64;
207 if duration <= 0.0 {
208 return None;
209 }
210 let a = grid.model_position(0, bone);
211 let b = grid.model_position(frames - 1, bone);
212 let dx = (b.x - a.x) as f64;
213 let dz = (b.z - a.z) as f64;
214 Some(dx.hypot(dz) / duration)
215}
216
217pub fn rotation_range_deg(track: &Track) -> Option<f64> {
220 if track.property != Property::Rotation {
221 return None;
222 }
223 let first = track.key_quat(0)?;
224 if !first.is_finite() || first.length_squared() == 0.0 {
225 return None;
226 }
227 let first = first.normalize();
228 let mut max_deg = 0.0f64;
229 for k in 1..track.key_count() {
230 if let Some(q) = track.key_quat(k)
231 && q.is_finite()
232 && q.length_squared() > 0.0
233 {
234 let deg = first.angle_between(q.normalize()).to_degrees() as f64;
235 max_deg = max_deg.max(deg);
236 }
237 }
238 Some(max_deg)
239}
240
241pub fn circular_phase_spread(phases: &[f64]) -> f64 {
245 use std::f64::consts::{PI, TAU};
246 let (mut sin_sum, mut cos_sum) = (0.0f64, 0.0f64);
247 for p in phases {
248 sin_sum += (p * TAU).sin();
249 cos_sum += (p * TAU).cos();
250 }
251 let mean = sin_sum.atan2(cos_sum);
252 let mut max_dev = 0.0f64;
253 for p in phases {
254 let mut d = (p * TAU - mean).abs() % TAU;
255 if d > PI {
256 d = TAU - d;
257 }
258 max_dev = max_dev.max(d / TAU);
259 }
260 max_dev
261}
262
263pub fn metric_frame_count(clip: &Clip) -> Option<usize> {
269 let n = crate::sample::default_frame_count(clip);
270 if clip.duration_s <= 0.0 || n < 3 {
271 None
272 } else {
273 Some(n)
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use crate::check::CheckCtx;
281 use crate::config::Config;
282 use crate::measure::measure_document;
283 use crate::model::{
284 Bone, Clip, Document, Interpolation, Property, Skeleton, Track, TrackValues, Transform,
285 };
286 use crate::profile::ResolvedRoles;
287 use glam::{Quat, Vec3};
288 use std::rc::Rc;
289
290 fn document_with_metric_clip() -> Document {
291 Document {
292 skeleton: Skeleton {
293 bones: vec![Bone {
294 name: "root".into(),
295 parent: None,
296 rest: Transform::IDENTITY,
297 inverse_bind: None,
298 }],
299 },
300 clips: vec![Clip {
301 name: "walk".into(),
302 duration_s: 1.0,
303 tracks: vec![Track {
304 bone: 0,
305 property: Property::Rotation,
306 interpolation: Interpolation::Linear,
307 times: vec![0.0, 0.5, 1.0],
308 values: TrackValues::Quats(vec![
309 Quat::IDENTITY,
310 Quat::from_rotation_y(0.1),
311 Quat::from_rotation_y(0.2),
312 ]),
313 }],
314 }],
315 ..Document::default()
316 }
317 }
318
319 fn document_with_grid_inputs(duration_s: f64, times: Vec<f32>) -> Document {
320 let values = vec![Quat::IDENTITY; times.len()];
321 Document {
322 skeleton: Skeleton {
323 bones: vec![Bone {
324 name: "root".into(),
325 parent: None,
326 rest: Transform::IDENTITY,
327 inverse_bind: None,
328 }],
329 },
330 clips: vec![Clip {
331 name: "probe".into(),
332 duration_s,
333 tracks: vec![Track {
334 bone: 0,
335 property: Property::Rotation,
336 interpolation: Interpolation::Linear,
337 times,
338 values: TrackValues::Quats(values),
339 }],
340 }],
341 ..Document::default()
342 }
343 }
344
345 #[test]
346 fn metric_grids_are_shared_by_checks_and_measurements() {
347 let doc = document_with_metric_clip();
348 let roles = ResolvedRoles::default();
349 let config = Config::default();
350 let grids = MetricGrids::new(&doc);
351
352 let ctx = CheckCtx::new(&grids, &roles, &config);
353 let from_ctx = ctx.grid(0).expect("metric grid");
354 let from_owner = grids.grid(0).expect("same metric grid");
355 assert!(Rc::ptr_eq(&from_ctx, &from_owner));
356
357 let measurements = measure_document(&grids, &roles, &config);
358 assert!(measurements.contains_key("walk"));
359 let fresh_grids = MetricGrids::new(&doc);
360 assert_eq!(
361 serde_json::to_value(&measurements).expect("shared measurements serialize"),
362 serde_json::to_value(measure_document(&fresh_grids, &roles, &config))
363 .expect("plain measurements serialize")
364 );
365 }
366
367 #[test]
368 fn grid_returns_none_for_each_documented_invalid_request() {
369 let valid = document_with_grid_inputs(1.0, vec![0.0, 0.5, 1.0]);
370 let valid_grids = MetricGrids::new(&valid);
371 assert!(valid_grids.grid(0).is_some());
372 for clip_index in [1, 2, usize::MAX] {
373 assert!(valid_grids.grid(clip_index).is_none());
374 }
375
376 for duration_s in [0.0, -1.0] {
377 let non_positive = document_with_grid_inputs(duration_s, vec![0.0, 0.5, 1.0]);
378 assert!(MetricGrids::new(&non_positive).grid(0).is_none());
379 }
380
381 for times in [vec![], vec![0.0], vec![0.0, 1.0]] {
382 let too_few_keys = document_with_grid_inputs(1.0, times);
383 assert!(MetricGrids::new(&too_few_keys).grid(0).is_none());
384 }
385 }
386
387 #[test]
388 fn grid_uses_longest_track_for_resolution() {
389 let mut doc = document_with_grid_inputs(1.0, vec![0.0, 1.0]);
392 doc.clips[0].tracks.push(Track {
393 bone: 0,
394 property: Property::Translation,
395 interpolation: Interpolation::Linear,
396 times: vec![0.0, 0.5, 1.0],
397 values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::X, 2.0 * Vec3::X]),
398 });
399
400 let grid = MetricGrids::new(&doc)
401 .grid(0)
402 .expect("later longest track supplies a metric grid");
403 assert_eq!(grid.frame_count(), 3);
404 }
405}