1use crate::config::Config;
6use crate::metrics::{MetricGrids, foot_cycle_metrics, root_motion_speed_mps, rotation_range_deg};
7use crate::model::SceneAssets;
8use crate::profile::ResolvedRoles;
9use serde::{Deserialize, Serialize};
10use std::collections::{BTreeMap, BTreeSet};
11
12pub const MIN_RECORDED_ROTATION_DEG: f64 = 0.1;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
19#[non_exhaustive]
20pub struct Aabb {
21 pub min: [f32; 3],
23 pub max: [f32; 3],
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
33#[non_exhaustive]
34pub struct MeshMeasurements {
35 pub name: String,
37 pub vertex_count: u32,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub aabb: Option<Aabb>,
43 pub max_joints_per_vertex: u32,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub weight_sum_min: Option<f64>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub weight_sum_max: Option<f64>,
54}
55
56pub fn measure_meshes(assets: &SceneAssets) -> Vec<MeshMeasurements> {
64 assets
65 .meshes
66 .iter()
67 .map(|mesh| {
68 let mut vertex_count = 0u32;
69 let mut min = [f32::INFINITY; 3];
70 let mut max = [f32::NEG_INFINITY; 3];
71 let mut any_finite_position = false;
72 let mut max_joints_per_vertex = 0u32;
73 let mut weight_sum_min = f64::INFINITY;
74 let mut weight_sum_max = f64::NEG_INFINITY;
75 let mut any_finite_weight = false;
76
77 for prim in &mesh.primitives {
78 vertex_count = vertex_count.saturating_add(prim.positions.len() as u32);
79 for p in &prim.positions {
80 let a = p.to_array();
81 if a.iter().all(|c| c.is_finite()) {
87 any_finite_position = true;
88 for i in 0..3 {
89 min[i] = min[i].min(a[i]);
90 max[i] = max[i].max(a[i]);
91 }
92 }
93 }
94 for w in &prim.weights {
95 let influences = w.iter().filter(|&&x| x > 0.0).count() as u32;
96 max_joints_per_vertex = max_joints_per_vertex.max(influences);
97 let sum: f64 = w.iter().map(|&x| x as f64).sum();
98 if sum.is_finite() {
99 any_finite_weight = true;
100 weight_sum_min = weight_sum_min.min(sum);
101 weight_sum_max = weight_sum_max.max(sum);
102 }
103 }
104 }
105
106 MeshMeasurements {
107 name: mesh.name.clone(),
108 vertex_count,
109 aabb: any_finite_position.then_some(Aabb { min, max }),
110 max_joints_per_vertex,
111 weight_sum_min: any_finite_weight.then_some(weight_sum_min),
112 weight_sum_max: any_finite_weight.then_some(weight_sum_max),
113 }
114 })
115 .collect()
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120#[non_exhaustive]
121pub struct GaitMeasurement {
122 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub phase: Option<f64>,
126 pub lr_amplitude_m: f64,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
132#[non_exhaustive]
133pub struct ClipMeasurements {
134 pub duration_s: f64,
136 pub frame_count: u32,
139 pub animated_bones: Vec<String>,
141 pub bone_rotation_range_deg: BTreeMap<String, f64>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub loop_seam_ratio: Option<f64>,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub gait: Option<GaitMeasurement>,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub speed_mps: Option<f64>,
156}
157
158pub fn measure_document(
167 grids: &MetricGrids<'_>,
168 roles: &ResolvedRoles,
169 config: &Config,
170) -> BTreeMap<String, ClipMeasurements> {
171 let doc = grids.document();
172 let min_stride_step_m = config.loop_seam_min_stride_step_m();
173 doc.clips
174 .iter()
175 .enumerate()
176 .map(|(clip_index, clip)| {
177 let mut animated: BTreeSet<String> = BTreeSet::new();
178 let mut rotation_range: BTreeMap<String, f64> = BTreeMap::new();
179 let mut frame_count = 0usize;
180
181 for track in &clip.tracks {
182 let Some(bone) = doc.skeleton.bones.get(track.bone) else {
183 continue;
184 };
185 if track.key_count() == 0 {
186 continue;
187 }
188 animated.insert(bone.name.clone());
189 frame_count = frame_count.max(track.key_count());
190
191 if let Some(max_deg) = rotation_range_deg(track)
192 && max_deg >= MIN_RECORDED_ROTATION_DEG
193 {
194 let entry = rotation_range.entry(bone.name.clone()).or_insert(0.0);
195 *entry = entry.max(max_deg);
196 }
197 }
198
199 let grid = grids.grid(clip_index);
200 let cycle = grid
201 .as_ref()
202 .and_then(|g| foot_cycle_metrics(g, roles, min_stride_step_m));
203 let speed_mps = grid.as_ref().and_then(|g| root_motion_speed_mps(g, roles));
204
205 (
206 clip.name.clone(),
207 ClipMeasurements {
208 duration_s: clip.duration_s,
209 frame_count: frame_count as u32,
210 animated_bones: animated.into_iter().collect(),
211 bone_rotation_range_deg: rotation_range,
212 loop_seam_ratio: cycle.as_ref().and_then(|c| c.loop_seam_ratio),
213 gait: cycle.map(|c| GaitMeasurement {
214 phase: c.gait_phase,
215 lr_amplitude_m: c.lr_amplitude_m,
216 }),
217 speed_mps,
218 },
219 )
220 })
221 .collect()
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use crate::model::{
228 Bone, Clip, Document, Interpolation, MeshAsset, Primitive, Property, Skeleton, Track,
229 TrackValues, Transform,
230 };
231 use crate::profile::Role;
232 use glam::{Quat, Vec3};
233
234 fn mesh(name: &str, primitives: Vec<Primitive>) -> SceneAssets {
235 SceneAssets {
236 meshes: vec![MeshAsset {
237 name: name.into(),
238 node: 0,
239 primitives,
240 skin_joints: vec![],
241 skin_ibms: vec![],
242 }],
243 materials: vec![],
244 }
245 }
246
247 #[test]
248 fn skinned_mesh_measures_bbox_joints_and_weight_sums() {
249 let prim = Primitive {
251 positions: vec![
252 Vec3::new(0.0, 0.0, 0.0),
253 Vec3::new(2.0, 0.0, 0.0),
254 Vec3::new(0.0, 3.0, 0.0),
255 Vec3::new(0.0, 0.0, 4.0),
256 ],
257 weights: vec![
260 [1.0, 0.0, 0.0, 0.0],
261 [0.5, 0.5, 0.0, 0.0],
262 [0.4, 0.3, 0.3, 0.0],
263 [0.3, 0.3, 0.3, 0.0],
264 ],
265 joints: vec![[0, 0, 0, 0]; 4],
266 ..Primitive::default()
267 };
268 let m = &measure_meshes(&mesh("body", vec![prim]))[0];
269
270 assert_eq!(m.name, "body");
271 assert_eq!(m.vertex_count, 4);
272 let aabb = m.aabb.as_ref().expect("positions present");
273 assert_eq!(aabb.min, [0.0, 0.0, 0.0]);
274 assert_eq!(aabb.max, [2.0, 3.0, 4.0]);
275 assert_eq!(m.max_joints_per_vertex, 3);
276 assert!((m.weight_sum_min.unwrap() - 0.9).abs() < 1e-6);
278 assert!((m.weight_sum_max.unwrap() - 1.0).abs() < 1e-6);
279 }
280
281 #[test]
282 fn unskinned_mesh_has_bbox_but_no_weight_stats() {
283 let prim = Primitive {
284 positions: vec![Vec3::new(-1.0, -2.0, -3.0), Vec3::new(1.0, 2.0, 3.0)],
285 ..Primitive::default()
286 };
287 let m = &measure_meshes(&mesh("prop", vec![prim]))[0];
288
289 assert_eq!(m.vertex_count, 2);
290 assert_eq!(m.aabb.as_ref().unwrap().min, [-1.0, -2.0, -3.0]);
291 assert_eq!(m.max_joints_per_vertex, 0);
292 assert_eq!(m.weight_sum_min, None, "no skin ⇒ no weight-sum");
293 assert_eq!(m.weight_sum_max, None);
294 }
295
296 #[test]
297 fn empty_mesh_reports_no_bbox() {
298 let m = &measure_meshes(&mesh("hollow", vec![Primitive::default()]))[0];
299 assert_eq!(m.vertex_count, 0);
300 assert!(m.aabb.is_none(), "no positions ⇒ no bounding box");
301 }
302
303 #[test]
304 fn non_finite_position_is_dropped_from_the_bbox() {
305 let prim = Primitive {
309 positions: vec![
310 Vec3::new(0.0, 0.0, 0.0),
311 Vec3::new(f32::NAN, 5.0, 0.0),
312 Vec3::new(f32::INFINITY, 9.0, 0.0),
313 Vec3::new(2.0, 3.0, 0.0),
314 ],
315 ..Primitive::default()
316 };
317 let m = &measure_meshes(&mesh("nan", vec![prim]))[0];
318 let aabb = m.aabb.as_ref().unwrap();
319 assert_eq!(aabb.min, [0.0, 0.0, 0.0]);
322 assert_eq!(aabb.max, [2.0, 3.0, 0.0]);
323 assert!(
324 aabb.min.iter().chain(&aabb.max).all(|c| c.is_finite()),
325 "no non-finite bound is ever emitted"
326 );
327 }
328
329 #[test]
330 fn all_non_finite_positions_yield_no_bbox() {
331 let prim = Primitive {
334 positions: vec![Vec3::splat(f32::NAN), Vec3::splat(f32::INFINITY)],
335 ..Primitive::default()
336 };
337 let m = &measure_meshes(&mesh("allnan", vec![prim]))[0];
338 assert_eq!(m.vertex_count, 2, "count still reflects the vertices");
339 assert!(
340 m.aabb.is_none(),
341 "no finite vertex ⇒ no box (never null bounds)"
342 );
343 }
344
345 #[test]
346 fn non_finite_weight_sum_is_omitted() {
347 let prim = Primitive {
350 positions: vec![Vec3::ZERO, Vec3::ONE],
351 weights: vec![[0.5, 0.5, 0.0, 0.0], [f32::NAN, 0.0, 0.0, 0.0]],
352 ..Primitive::default()
353 };
354 let m = &measure_meshes(&mesh("nanw", vec![prim]))[0];
355 assert_eq!(m.weight_sum_min, Some(1.0));
357 assert_eq!(m.weight_sum_max, Some(1.0));
358 }
359
360 #[test]
361 fn all_non_finite_weight_sums_yield_no_weight_stats() {
362 let prim = Primitive {
365 positions: vec![Vec3::ZERO, Vec3::ONE],
366 weights: vec![[f32::NAN, 0.0, 0.0, 0.0], [f32::INFINITY, 0.0, 0.0, 0.0]],
367 ..Primitive::default()
368 };
369 let m = &measure_meshes(&mesh("allnanw", vec![prim]))[0];
370 assert_eq!(m.weight_sum_min, None, "no finite weight sum ⇒ omitted");
371 assert_eq!(m.weight_sum_max, None);
372 assert_eq!(m.max_joints_per_vertex, 1);
374 }
375
376 #[test]
377 fn vertex_count_sums_across_primitives() {
378 let a = Primitive {
379 positions: vec![Vec3::ZERO; 3],
380 ..Primitive::default()
381 };
382 let b = Primitive {
383 positions: vec![Vec3::ONE; 5],
384 ..Primitive::default()
385 };
386 let m = &measure_meshes(&mesh("multi", vec![a, b]))[0];
387 assert_eq!(m.vertex_count, 8, "3 + 5 corners across two primitives");
388 }
389
390 #[test]
391 fn later_duplicate_clip_name_replaces_earlier_measurement() {
392 let earlier = Clip {
393 name: "duplicate".into(),
394 duration_s: 1.0,
395 tracks: vec![
396 Track {
397 bone: 0,
398 property: Property::Rotation,
399 interpolation: Interpolation::Linear,
400 times: vec![0.0, 0.5, 1.0],
401 values: TrackValues::Quats(vec![
402 Quat::IDENTITY,
403 Quat::from_rotation_x(0.25),
404 Quat::from_rotation_x(0.5),
405 ]),
406 },
407 Track {
408 bone: 0,
409 property: Property::Translation,
410 interpolation: Interpolation::Linear,
411 times: vec![0.0, 0.5, 1.0],
412 values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::Z * 0.5, Vec3::Z]),
413 },
414 Track {
415 bone: 1,
416 property: Property::Translation,
417 interpolation: Interpolation::Linear,
418 times: vec![0.0, 0.5, 1.0],
419 values: TrackValues::Vec3s(vec![
420 Vec3::new(-0.1, -1.0, 0.0),
421 Vec3::new(-0.1, -0.9, 0.15),
422 Vec3::new(-0.1, -1.0, 0.0),
423 ]),
424 },
425 Track {
426 bone: 2,
427 property: Property::Translation,
428 interpolation: Interpolation::Linear,
429 times: vec![0.0, 0.5, 1.0],
430 values: TrackValues::Vec3s(vec![
431 Vec3::new(0.1, -1.0, 0.0),
432 Vec3::new(0.1, -1.1, -0.15),
433 Vec3::new(0.1, -1.0, 0.0),
434 ]),
435 },
436 ],
437 };
438 let later = Clip {
439 name: "duplicate".into(),
440 duration_s: 2.0,
441 tracks: vec![Track {
442 bone: 0,
443 property: Property::Translation,
444 interpolation: Interpolation::Linear,
445 times: vec![0.0, 2.0],
446 values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::X]),
447 }],
448 };
449 let skeleton = Skeleton {
450 bones: vec![
451 Bone {
452 name: "hips".into(),
453 parent: None,
454 rest: Transform::IDENTITY,
455 inverse_bind: None,
456 },
457 Bone {
458 name: "left_foot".into(),
459 parent: Some(0),
460 rest: Transform::IDENTITY,
461 inverse_bind: None,
462 },
463 Bone {
464 name: "right_foot".into(),
465 parent: Some(0),
466 rest: Transform::IDENTITY,
467 inverse_bind: None,
468 },
469 ],
470 };
471 let roles = ResolvedRoles::from_names(
472 &skeleton,
473 [
474 (Role::Hips, "hips".into()),
475 (Role::LeftFoot, "left_foot".into()),
476 (Role::RightFoot, "right_foot".into()),
477 ],
478 );
479 let earlier_doc = Document {
480 skeleton: skeleton.clone(),
481 clips: vec![earlier.clone()],
482 ..Document::default()
483 };
484 let earlier_grids = MetricGrids::new(&earlier_doc);
485 let earlier_measurement =
486 &measure_document(&earlier_grids, &roles, &Config::default())["duplicate"];
487 assert!(earlier_measurement.loop_seam_ratio.is_some());
488 assert!(earlier_measurement.gait.is_some());
489 assert!(earlier_measurement.speed_mps.is_some());
490
491 let doc = Document {
492 skeleton,
493 clips: vec![earlier, later],
494 ..Document::default()
495 };
496 let grids = MetricGrids::new(&doc);
497 let measurements = measure_document(&grids, &roles, &Config::default());
498
499 assert_eq!(
500 serde_json::to_value(measurements).expect("duplicate measurements serialize"),
501 serde_json::json!({
502 "duplicate": {
503 "duration_s": 2.0,
504 "frame_count": 2,
505 "animated_bones": ["hips"],
506 "bone_rotation_range_deg": {},
507 }
508 })
509 );
510 }
511}