use crate::config::Config;
use crate::metrics::{MetricGrids, foot_cycle_metrics, root_motion_speed_mps, rotation_range_deg};
use crate::model::SceneAssets;
use crate::profile::ResolvedRoles;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
pub const MIN_RECORDED_ROTATION_DEG: f64 = 0.1;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Aabb {
pub min: [f32; 3],
pub max: [f32; 3],
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct MeshMeasurements {
pub name: String,
pub vertex_count: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub aabb: Option<Aabb>,
pub max_joints_per_vertex: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub weight_sum_min: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub weight_sum_max: Option<f64>,
}
pub fn measure_meshes(assets: &SceneAssets) -> Vec<MeshMeasurements> {
assets
.meshes
.iter()
.map(|mesh| {
let mut vertex_count = 0u32;
let mut min = [f32::INFINITY; 3];
let mut max = [f32::NEG_INFINITY; 3];
let mut any_finite_position = false;
let mut max_joints_per_vertex = 0u32;
let mut weight_sum_min = f64::INFINITY;
let mut weight_sum_max = f64::NEG_INFINITY;
let mut any_finite_weight = false;
for prim in &mesh.primitives {
vertex_count = vertex_count.saturating_add(prim.positions.len() as u32);
for p in &prim.positions {
let a = p.to_array();
if a.iter().all(|c| c.is_finite()) {
any_finite_position = true;
for i in 0..3 {
min[i] = min[i].min(a[i]);
max[i] = max[i].max(a[i]);
}
}
}
for w in &prim.weights {
let influences = w.iter().filter(|&&x| x > 0.0).count() as u32;
max_joints_per_vertex = max_joints_per_vertex.max(influences);
let sum: f64 = w.iter().map(|&x| x as f64).sum();
if sum.is_finite() {
any_finite_weight = true;
weight_sum_min = weight_sum_min.min(sum);
weight_sum_max = weight_sum_max.max(sum);
}
}
}
MeshMeasurements {
name: mesh.name.clone(),
vertex_count,
aabb: any_finite_position.then_some(Aabb { min, max }),
max_joints_per_vertex,
weight_sum_min: any_finite_weight.then_some(weight_sum_min),
weight_sum_max: any_finite_weight.then_some(weight_sum_max),
}
})
.collect()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct GaitMeasurement {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phase: Option<f64>,
pub lr_amplitude_m: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ClipMeasurements {
pub duration_s: f64,
pub frame_count: u32,
pub animated_bones: Vec<String>,
pub bone_rotation_range_deg: BTreeMap<String, f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub loop_seam_ratio: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gait: Option<GaitMeasurement>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub speed_mps: Option<f64>,
}
pub fn measure_document(
grids: &MetricGrids<'_>,
roles: &ResolvedRoles,
config: &Config,
) -> BTreeMap<String, ClipMeasurements> {
let doc = grids.document();
let min_stride_step_m = config.loop_seam_min_stride_step_m();
doc.clips
.iter()
.enumerate()
.map(|(clip_index, clip)| {
let mut animated: BTreeSet<String> = BTreeSet::new();
let mut rotation_range: BTreeMap<String, f64> = BTreeMap::new();
let mut frame_count = 0usize;
for track in &clip.tracks {
let Some(bone) = doc.skeleton.bones.get(track.bone) else {
continue;
};
if track.key_count() == 0 {
continue;
}
animated.insert(bone.name.clone());
frame_count = frame_count.max(track.key_count());
if let Some(max_deg) = rotation_range_deg(track)
&& max_deg >= MIN_RECORDED_ROTATION_DEG
{
let entry = rotation_range.entry(bone.name.clone()).or_insert(0.0);
*entry = entry.max(max_deg);
}
}
let grid = grids.grid(clip_index);
let cycle = grid
.as_ref()
.and_then(|g| foot_cycle_metrics(g, roles, min_stride_step_m));
let speed_mps = grid.as_ref().and_then(|g| root_motion_speed_mps(g, roles));
(
clip.name.clone(),
ClipMeasurements {
duration_s: clip.duration_s,
frame_count: frame_count as u32,
animated_bones: animated.into_iter().collect(),
bone_rotation_range_deg: rotation_range,
loop_seam_ratio: cycle.as_ref().and_then(|c| c.loop_seam_ratio),
gait: cycle.map(|c| GaitMeasurement {
phase: c.gait_phase,
lr_amplitude_m: c.lr_amplitude_m,
}),
speed_mps,
},
)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{
Bone, Clip, Document, Interpolation, MeshAsset, Primitive, Property, Skeleton, Track,
TrackValues, Transform,
};
use crate::profile::Role;
use glam::{Quat, Vec3};
fn mesh(name: &str, primitives: Vec<Primitive>) -> SceneAssets {
SceneAssets {
meshes: vec![MeshAsset {
name: name.into(),
node: 0,
primitives,
skin_joints: vec![],
skin_ibms: vec![],
}],
materials: vec![],
}
}
#[test]
fn skinned_mesh_measures_bbox_joints_and_weight_sums() {
let prim = Primitive {
positions: vec![
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(2.0, 0.0, 0.0),
Vec3::new(0.0, 3.0, 0.0),
Vec3::new(0.0, 0.0, 4.0),
],
weights: vec![
[1.0, 0.0, 0.0, 0.0],
[0.5, 0.5, 0.0, 0.0],
[0.4, 0.3, 0.3, 0.0],
[0.3, 0.3, 0.3, 0.0],
],
joints: vec![[0, 0, 0, 0]; 4],
..Primitive::default()
};
let m = &measure_meshes(&mesh("body", vec![prim]))[0];
assert_eq!(m.name, "body");
assert_eq!(m.vertex_count, 4);
let aabb = m.aabb.as_ref().expect("positions present");
assert_eq!(aabb.min, [0.0, 0.0, 0.0]);
assert_eq!(aabb.max, [2.0, 3.0, 4.0]);
assert_eq!(m.max_joints_per_vertex, 3);
assert!((m.weight_sum_min.unwrap() - 0.9).abs() < 1e-6);
assert!((m.weight_sum_max.unwrap() - 1.0).abs() < 1e-6);
}
#[test]
fn unskinned_mesh_has_bbox_but_no_weight_stats() {
let prim = Primitive {
positions: vec![Vec3::new(-1.0, -2.0, -3.0), Vec3::new(1.0, 2.0, 3.0)],
..Primitive::default()
};
let m = &measure_meshes(&mesh("prop", vec![prim]))[0];
assert_eq!(m.vertex_count, 2);
assert_eq!(m.aabb.as_ref().unwrap().min, [-1.0, -2.0, -3.0]);
assert_eq!(m.max_joints_per_vertex, 0);
assert_eq!(m.weight_sum_min, None, "no skin ⇒ no weight-sum");
assert_eq!(m.weight_sum_max, None);
}
#[test]
fn empty_mesh_reports_no_bbox() {
let m = &measure_meshes(&mesh("hollow", vec![Primitive::default()]))[0];
assert_eq!(m.vertex_count, 0);
assert!(m.aabb.is_none(), "no positions ⇒ no bounding box");
}
#[test]
fn non_finite_position_is_dropped_from_the_bbox() {
let prim = Primitive {
positions: vec![
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(f32::NAN, 5.0, 0.0),
Vec3::new(f32::INFINITY, 9.0, 0.0),
Vec3::new(2.0, 3.0, 0.0),
],
..Primitive::default()
};
let m = &measure_meshes(&mesh("nan", vec![prim]))[0];
let aabb = m.aabb.as_ref().unwrap();
assert_eq!(aabb.min, [0.0, 0.0, 0.0]);
assert_eq!(aabb.max, [2.0, 3.0, 0.0]);
assert!(
aabb.min.iter().chain(&aabb.max).all(|c| c.is_finite()),
"no non-finite bound is ever emitted"
);
}
#[test]
fn all_non_finite_positions_yield_no_bbox() {
let prim = Primitive {
positions: vec![Vec3::splat(f32::NAN), Vec3::splat(f32::INFINITY)],
..Primitive::default()
};
let m = &measure_meshes(&mesh("allnan", vec![prim]))[0];
assert_eq!(m.vertex_count, 2, "count still reflects the vertices");
assert!(
m.aabb.is_none(),
"no finite vertex ⇒ no box (never null bounds)"
);
}
#[test]
fn non_finite_weight_sum_is_omitted() {
let prim = Primitive {
positions: vec![Vec3::ZERO, Vec3::ONE],
weights: vec![[0.5, 0.5, 0.0, 0.0], [f32::NAN, 0.0, 0.0, 0.0]],
..Primitive::default()
};
let m = &measure_meshes(&mesh("nanw", vec![prim]))[0];
assert_eq!(m.weight_sum_min, Some(1.0));
assert_eq!(m.weight_sum_max, Some(1.0));
}
#[test]
fn all_non_finite_weight_sums_yield_no_weight_stats() {
let prim = Primitive {
positions: vec![Vec3::ZERO, Vec3::ONE],
weights: vec![[f32::NAN, 0.0, 0.0, 0.0], [f32::INFINITY, 0.0, 0.0, 0.0]],
..Primitive::default()
};
let m = &measure_meshes(&mesh("allnanw", vec![prim]))[0];
assert_eq!(m.weight_sum_min, None, "no finite weight sum ⇒ omitted");
assert_eq!(m.weight_sum_max, None);
assert_eq!(m.max_joints_per_vertex, 1);
}
#[test]
fn vertex_count_sums_across_primitives() {
let a = Primitive {
positions: vec![Vec3::ZERO; 3],
..Primitive::default()
};
let b = Primitive {
positions: vec![Vec3::ONE; 5],
..Primitive::default()
};
let m = &measure_meshes(&mesh("multi", vec![a, b]))[0];
assert_eq!(m.vertex_count, 8, "3 + 5 corners across two primitives");
}
#[test]
fn later_duplicate_clip_name_replaces_earlier_measurement() {
let earlier = Clip {
name: "duplicate".into(),
duration_s: 1.0,
tracks: vec![
Track {
bone: 0,
property: Property::Rotation,
interpolation: Interpolation::Linear,
times: vec![0.0, 0.5, 1.0],
values: TrackValues::Quats(vec![
Quat::IDENTITY,
Quat::from_rotation_x(0.25),
Quat::from_rotation_x(0.5),
]),
},
Track {
bone: 0,
property: Property::Translation,
interpolation: Interpolation::Linear,
times: vec![0.0, 0.5, 1.0],
values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::Z * 0.5, Vec3::Z]),
},
Track {
bone: 1,
property: Property::Translation,
interpolation: Interpolation::Linear,
times: vec![0.0, 0.5, 1.0],
values: TrackValues::Vec3s(vec![
Vec3::new(-0.1, -1.0, 0.0),
Vec3::new(-0.1, -0.9, 0.15),
Vec3::new(-0.1, -1.0, 0.0),
]),
},
Track {
bone: 2,
property: Property::Translation,
interpolation: Interpolation::Linear,
times: vec![0.0, 0.5, 1.0],
values: TrackValues::Vec3s(vec![
Vec3::new(0.1, -1.0, 0.0),
Vec3::new(0.1, -1.1, -0.15),
Vec3::new(0.1, -1.0, 0.0),
]),
},
],
};
let later = Clip {
name: "duplicate".into(),
duration_s: 2.0,
tracks: vec![Track {
bone: 0,
property: Property::Translation,
interpolation: Interpolation::Linear,
times: vec![0.0, 2.0],
values: TrackValues::Vec3s(vec![Vec3::ZERO, Vec3::X]),
}],
};
let skeleton = Skeleton {
bones: vec![
Bone {
name: "hips".into(),
parent: None,
rest: Transform::IDENTITY,
inverse_bind: None,
},
Bone {
name: "left_foot".into(),
parent: Some(0),
rest: Transform::IDENTITY,
inverse_bind: None,
},
Bone {
name: "right_foot".into(),
parent: Some(0),
rest: Transform::IDENTITY,
inverse_bind: None,
},
],
};
let roles = ResolvedRoles::from_names(
&skeleton,
[
(Role::Hips, "hips".into()),
(Role::LeftFoot, "left_foot".into()),
(Role::RightFoot, "right_foot".into()),
],
);
let earlier_doc = Document {
skeleton: skeleton.clone(),
clips: vec![earlier.clone()],
..Document::default()
};
let earlier_grids = MetricGrids::new(&earlier_doc);
let earlier_measurement =
&measure_document(&earlier_grids, &roles, &Config::default())["duplicate"];
assert!(earlier_measurement.loop_seam_ratio.is_some());
assert!(earlier_measurement.gait.is_some());
assert!(earlier_measurement.speed_mps.is_some());
let doc = Document {
skeleton,
clips: vec![earlier, later],
..Document::default()
};
let grids = MetricGrids::new(&doc);
let measurements = measure_document(&grids, &roles, &Config::default());
assert_eq!(
serde_json::to_value(measurements).expect("duplicate measurements serialize"),
serde_json::json!({
"duplicate": {
"duration_s": 2.0,
"frame_count": 2,
"animated_bones": ["hips"],
"bone_rotation_range_deg": {},
}
})
);
}
}