Skip to main content

animsmith_core/
measure.rs

1//! Measurements: the raw per-clip metric map that `measure` emits and
2//! `lint` judges. Kept separate from findings so pipelines (e.g. a
3//! bake's measured sidecar) can pin their own contracts to the numbers.
4
5use 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
12/// Rotation ranges below this are not recorded (matches the incubating
13/// pipeline's convention).
14pub const MIN_RECORDED_ROTATION_DEG: f64 = 0.1;
15
16/// Axis-aligned bounding box of a mesh's positions, in scene units
17/// (metres, Y-up — the converted space every loader hands over).
18#[derive(Debug, Clone, Serialize, Deserialize)]
19#[non_exhaustive]
20pub struct Aabb {
21    /// Minimum XYZ corner.
22    pub min: [f32; 3],
23    /// Maximum XYZ corner.
24    pub max: [f32; 3],
25}
26
27/// Static (animation-independent) measurements of one mesh carried in
28/// [`SceneAssets`]. Emitted by `measure` when the input carried geometry
29/// (both the FBX and glTF loaders fill `SceneAssets`). Vertex data is
30/// read as authored — indexed meshes count their unique vertices,
31/// unindexed meshes count every triangle corner.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33#[non_exhaustive]
34pub struct MeshMeasurements {
35    /// Mesh name.
36    pub name: String,
37    /// Total position count across the mesh's primitives.
38    pub vertex_count: u32,
39    /// Bounding box over every primitive position; `None` for a mesh
40    /// with no positions.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub aabb: Option<Aabb>,
43    /// Highest number of non-zero skin influences on any single vertex
44    /// (`0` for an unskinned mesh).
45    pub max_joints_per_vertex: u32,
46    /// Min/max of the per-vertex skin-weight sums (≈1.0 for a
47    /// well-formed skin); `None` for an unskinned mesh.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub weight_sum_min: Option<f64>,
50    /// Maximum finite per-vertex skin-weight sum; `None` for an
51    /// unskinned mesh.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub weight_sum_max: Option<f64>,
54}
55
56/// Measure every mesh in the document's [`SceneAssets`], in document
57/// order. Returns an empty vector when no geometry was loaded (the
58/// lint/inspect path and asset-less files), so callers that don't carry
59/// assets emit nothing extra. Hostile geometry never crashes or leaks a
60/// non-finite bound: non-finite positions and weight sums are dropped
61/// from the AABB / weight-sum stats (and still flow to the `nan` check),
62/// so the emitted numbers are always finite.
63pub 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                    // Only finite vertices contribute to the box; a NaN
82                    // or infinite coordinate is garbage geometry (it
83                    // still lints via the `nan` check) and must never
84                    // reach the output — a non-finite bound serializes to
85                    // JSON `null`, which violates the numeric schema.
86                    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/// Role-dependent gait metrics for one clip.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120#[non_exhaustive]
121pub struct GaitMeasurement {
122    /// Stride-anchor phase in `[0,1)`; see
123    /// [`crate::metrics::FootCycleMetrics::gait_phase`].
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub phase: Option<f64>,
126    /// Peak-to-peak L−R foot-height swing (metres).
127    pub lr_amplitude_m: f64,
128}
129
130/// Measurements for one clip in the `measure` output map.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132#[non_exhaustive]
133pub struct ClipMeasurements {
134    /// Clip duration in seconds.
135    pub duration_s: f64,
136    /// Keyframe count of the longest channel. This also selects the uniform
137    /// metric-grid resolution, but it is not an authored frame-rate value.
138    pub frame_count: u32,
139    /// Bones with at least one keyframed channel, sorted.
140    pub animated_bones: Vec<String>,
141    /// Max rotation deviation (degrees) of each bone from its first
142    /// keyed rotation. Bones under [`MIN_RECORDED_ROTATION_DEG`] are
143    /// omitted.
144    pub bone_rotation_range_deg: BTreeMap<String, f64>,
145    /// Loop wrap discontinuity ratio; needs hips + foot roles and a
146    /// real stride. See [`crate::metrics::FootCycleMetrics`].
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub loop_seam_ratio: Option<f64>,
149    /// Gait stride anchor; needs a left and a right foot role.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub gait: Option<GaitMeasurement>,
152    /// Horizontal root displacement ÷ duration (m/s); needs the Root
153    /// (or Hips) role.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub speed_mps: Option<f64>,
156}
157
158/// Measure every clip using shared metric pose grids. Role-dependent
159/// metrics (loop seam, gait, root-motion speed) are present only where
160/// the roles resolve; pass an empty [`ResolvedRoles`] to skip them.
161///
162/// This returns clip measurements only. Call [`measure_meshes`] separately
163/// when the pipeline also needs geometry measurements. Clip names are map
164/// keys and therefore must be unique; a later duplicate replaces an earlier
165/// entry.
166pub 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        // Four positions with an analytic AABB of (0,0,0)..(2,3,4).
250        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            // Influence counts 1, 2, 3, 3 → max 3; weight sums 1.0, 1.0,
258            // 1.0, 0.9 → min 0.9, max 1.0.
259            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        // f32 weights summed in f64 carry rounding; compare with tolerance.
277        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        // A vertex with any non-finite coordinate is garbage geometry:
306        // it is dropped whole (not folded per-axis), so the box stays
307        // the finite extent — and never emits a non-finite bound.
308        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        // Only the two finite vertices contribute; the NaN/Inf rows drop
320        // out, so their 5.0 / 9.0 do NOT reach the box.
321        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        // Every vertex non-finite ⇒ no finite contribution ⇒ `aabb` is
332        // omitted, not an inf/-inf box that serializes to JSON `null`.
333        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        // A NaN weight makes its sum non-finite; it must not surface as a
348        // JSON-null weight-sum bound.
349        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        // The one finite sum (1.0) is kept; the NaN sum is skipped.
356        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        // Every weight sum non-finite ⇒ no finite contribution ⇒ both
363        // bounds omitted, not an inf/-inf pair that serializes to `null`.
364        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        // max_joints_per_vertex still counts the non-zero influences.
373        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}