//! Validation of the synthetic male and female test bodies. //! //! This reads the GLB fixtures directly. It is not the product importer and //! it does not render anything. use std::path::{Path, PathBuf}; use std::process::Command; use serde_json::Value; const JOINTS: [&str; 19] = [ "Hips", "Spine", "Chest", "Neck", "Head", "LeftShoulder", "LeftUpperArm", "LeftLowerArm", "LeftHand", "RightShoulder", "RightUpperArm", "RightLowerArm", "RightHand", "LeftUpperLeg", "LeftLowerLeg", "LeftFoot", "RightUpperLeg", "RightLowerLeg", "RightFoot", ]; fn repo_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) } fn fixture(sex: &str) -> PathBuf { repo_root().join(format!("tests/fixtures/bodies/test-body-{sex}.glb")) } fn read_glb(path: &Path) -> (Value, Vec) { let bytes = std::fs::read(path).unwrap_or_else(|err| panic!("read {}: {err}", path.display())); assert!( bytes.len() > 20, "{} is too small to be a GLB", path.display() ); assert_eq!(&bytes[0..4], b"glTF", "{}", path.display()); let version = u32::from_le_bytes(bytes[4..8].try_into().unwrap()); let length = u32::from_le_bytes(bytes[8..12].try_into().unwrap()) as usize; assert_eq!(version, 2, "GLB version"); assert_eq!(length, bytes.len(), "GLB length prefix"); let mut offset = 12; let mut json = None; let mut binary = None; while offset + 8 <= bytes.len() { let chunk_len = u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()) as usize; let chunk_type = &bytes[offset + 4..offset + 8]; let start = offset + 8; let end = start + chunk_len; assert!(end <= bytes.len(), "chunk exceeds file"); let data = &bytes[start..end]; if chunk_type == b"JSON" { let text = std::str::from_utf8(data).expect("JSON chunk utf-8"); json = Some(serde_json::from_str::(text.trim_end()).expect("JSON chunk")); } else if chunk_type == b"BIN\0" { binary = Some(data.to_vec()); } else { panic!("unexpected GLB chunk"); } offset = end; } (json.expect("JSON chunk"), binary.expect("BIN chunk")) } fn node<'a>(json: &'a Value, name: &str) -> &'a Value { json["nodes"] .as_array() .unwrap() .iter() .find(|node| node["name"] == name) .unwrap_or_else(|| panic!("missing node {name}")) } fn translation(json: &Value, name: &str) -> [f64; 3] { let values = node(json, name)["translation"] .as_array() .unwrap_or_else(|| panic!("{name} has no translation")); assert_eq!(values.len(), 3); [ values[0].as_f64().unwrap(), values[1].as_f64().unwrap(), values[2].as_f64().unwrap(), ] } fn node_index(json: &Value, name: &str) -> usize { json["nodes"] .as_array() .unwrap() .iter() .position(|node| node["name"] == name) .unwrap_or_else(|| panic!("missing node {name}")) } fn parent_name(json: &Value, name: &str) -> Option { let index = node_index(json, name); json["nodes"].as_array().unwrap().iter().find_map(|node| { let children = node["children"].as_array()?; children .iter() .any(|child| child.as_u64() == Some(index as u64)) .then(|| node["name"].as_str().unwrap().to_string()) }) } fn accessor_f32(json: &Value, bin: &[u8], index: usize) -> Vec { let accessor = &json["accessors"][index]; assert_eq!(accessor["componentType"].as_u64().unwrap(), 5126); let width = match accessor["type"].as_str().unwrap() { "SCALAR" => 1, "VEC3" => 3, "VEC4" => 4, "MAT4" => 16, other => panic!("unsupported accessor type {other}"), }; let count = accessor["count"].as_u64().unwrap() as usize; let view = &json["bufferViews"][accessor["bufferView"].as_u64().unwrap() as usize]; let offset = view["byteOffset"].as_u64().unwrap_or(0) as usize + accessor["byteOffset"].as_u64().unwrap_or(0) as usize; let mut values = Vec::with_capacity(count * width); for item in 0..count * width { let start = offset + item * 4; values.push(f32::from_le_bytes( bin[start..start + 4].try_into().unwrap(), )); } values } fn animation<'a>(json: &'a Value, name: &str) -> &'a Value { json["animations"] .as_array() .unwrap() .iter() .find(|clip| clip["name"] == name) .unwrap_or_else(|| panic!("missing animation {name}")) } fn clip_duration(json: &Value, bin: &[u8], name: &str) -> f32 { let clip = animation(json, name); let input = clip["samplers"][0]["input"].as_u64().unwrap() as usize; let times = accessor_f32(json, bin, input); assert!(times.len() >= 4, "{name} needs several samples"); assert!( times.windows(2).all(|pair| pair[1] >= pair[0]), "{name} times go backwards" ); *times.last().unwrap() - times[0] } fn channel_targets(json: &Value, name: &str) -> Vec { animation(json, name)["channels"] .as_array() .unwrap() .iter() .map(|channel| { let index = channel["target"]["node"].as_u64().unwrap() as usize; json["nodes"][index]["name"].as_str().unwrap().to_string() }) .collect() } fn assert_quaternions_are_unit_and_loop(json: &Value, bin: &[u8], name: &str) { let clip = animation(json, name); for sampler in clip["samplers"].as_array().unwrap() { assert_eq!(sampler["interpolation"], "LINEAR"); let output = accessor_f32(json, bin, sampler["output"].as_u64().unwrap() as usize); assert_eq!(output.len() % 4, 0); let samples = output.len() / 4; for sample in 0..samples { let q = &output[sample * 4..sample * 4 + 4]; let length = q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]; assert!( (length - 1.0).abs() < 1e-4, "{name} quaternion {sample} length {length}" ); } let first = &output[0..4]; let last = &output[output.len() - 4..]; for (a, b) in first.iter().zip(last) { assert!((a - b).abs() < 1e-5, "{name} does not loop"); } } } fn assert_body(json: &Value, bin: &[u8], sex: &str) { let extras = &json["asset"]["extras"]; assert_eq!(json["asset"]["version"], "2.0"); assert_eq!(extras["testAsset"], true); assert_eq!(extras["finalCharacter"], false); assert_eq!(extras["sex"], sex); assert_eq!(extras["license"], "CC0-1.0"); assert_eq!(extras["up"], "+Y"); assert_eq!(extras["forward"], "+Z"); // glTF convention: front faces +Z, the character's left side is +X. let node = |name: &str| { json["nodes"] .as_array() .unwrap() .iter() .find(|n| n["name"] == name) .unwrap_or_else(|| panic!("{name} missing")) .clone() }; assert!( node("LeftFoot")["translation"][2].as_f64().unwrap() > 0.0, "toes point to +Z" ); assert!( node("LeftUpperLeg")["translation"][0].as_f64().unwrap() > 0.0, "left leg on +X" ); assert!( node("RightUpperLeg")["translation"][0].as_f64().unwrap() < 0.0, "right leg on -X" ); let label = extras["label"].as_str().unwrap(); assert!( label.contains("geen definitief karakter"), "label must say this is not a final character: {label}" ); assert!( json["buffers"][0].get("uri").is_none(), "GLB must be self-contained" ); let joints = json["skins"][0]["joints"].as_array().unwrap(); assert_eq!(joints.len(), JOINTS.len()); for (expected, joint) in JOINTS.iter().zip(joints) { let index = joint.as_u64().unwrap() as usize; assert_eq!(json["nodes"][index]["name"], *expected); } assert_eq!(parent_name(json, "Hips"), None); assert_eq!( parent_name(json, "LeftUpperArm").as_deref(), Some("LeftShoulder") ); assert_eq!( parent_name(json, "LeftHand").as_deref(), Some("LeftLowerArm") ); assert_eq!( parent_name(json, "LeftFoot").as_deref(), Some("LeftLowerLeg") ); assert_eq!( parent_name(json, "RightUpperArm").as_deref(), Some("RightShoulder") ); let primitive = &json["meshes"][0]["primitives"][0]; assert!(primitive["attributes"]["POSITION"].is_number()); assert!(primitive["attributes"]["JOINTS_0"].is_number()); assert!(primitive["attributes"]["WEIGHTS_0"].is_number()); let positions = accessor_f32( json, bin, primitive["attributes"]["POSITION"].as_u64().unwrap() as usize, ); let weights = accessor_f32( json, bin, primitive["attributes"]["WEIGHTS_0"].as_u64().unwrap() as usize, ); assert!(!positions.is_empty()); assert_eq!(weights.len(), (positions.len() / 3) * 4); assert!( weights.chunks(4).all(|weight| { (weight[0] - 1.0).abs() < 1e-6 && weight[1].abs() < 1e-6 && weight[2].abs() < 1e-6 && weight[3].abs() < 1e-6 }), "test bodies use one rigid weight per vertex" ); let inverse_bind = accessor_f32( json, bin, json["skins"][0]["inverseBindMatrices"].as_u64().unwrap() as usize, ); assert_eq!(inverse_bind.len(), JOINTS.len() * 16); for (index, name) in JOINTS.iter().enumerate() { let mut world = [0.0_f64; 3]; let mut current = (*name).to_string(); for _ in 0..=JOINTS.len() { let local = translation(json, ¤t); world[0] += local[0]; world[1] += local[1]; world[2] += local[2]; if current == "Hips" { break; } current = parent_name(json, ¤t) .unwrap_or_else(|| panic!("{name} is not connected to Hips")); } assert_eq!(current, "Hips", "{name} is not under Hips"); let matrix = &inverse_bind[index * 16..index * 16 + 16]; for diagonal in [0, 5, 10, 15] { assert!( (matrix[diagonal] - 1.0).abs() < 1e-5, "{name} bind rotation is not identity" ); } assert!( (f64::from(matrix[12]) + world[0]).abs() < 1e-4, "{name} inverse bind x" ); assert!( (f64::from(matrix[13]) + world[1]).abs() < 1e-4, "{name} inverse bind y" ); assert!( (f64::from(matrix[14]) + world[2]).abs() < 1e-4, "{name} inverse bind z" ); } let names = json["animations"] .as_array() .unwrap() .iter() .map(|clip| clip["name"].as_str().unwrap().to_string()) .collect::>(); assert!(names.contains(&"walk".to_string())); assert!(names.contains(&"attack".to_string())); assert!((clip_duration(json, bin, "walk") - 1.0).abs() < 1e-4); assert!((clip_duration(json, bin, "attack") - 0.8).abs() < 1e-4); let walk = channel_targets(json, "walk"); for joint in [ "LeftUpperLeg", "RightUpperLeg", "LeftUpperArm", "RightUpperArm", ] { assert!(walk.iter().any(|name| name == joint), "walk misses {joint}"); } let attack = channel_targets(json, "attack"); assert!(attack.iter().any(|name| name == "RightUpperArm")); assert_quaternions_are_unit_and_loop(json, bin, "walk"); assert_quaternions_are_unit_and_loop(json, bin, "attack"); } #[test] fn male_and_female_test_bodies_have_a_skeleton_and_both_clips() { let (male, male_bin) = read_glb(&fixture("male")); let (female, female_bin) = read_glb(&fixture("female")); assert_body(&male, &male_bin, "male"); assert_body(&female, &female_bin, "female"); let male_shoulder = translation(&male, "LeftShoulder")[0].abs(); let female_shoulder = translation(&female, "LeftShoulder")[0].abs(); assert!( male_shoulder > female_shoulder, "male shoulders must be wider ({male_shoulder} vs {female_shoulder})" ); assert!(translation(&male, "Hips")[1] > translation(&female, "Hips")[1]); assert!( translation(&female, "LeftUpperLeg")[0].abs() > translation(&male, "LeftUpperLeg")[0].abs() ); let readme = std::fs::read_to_string(repo_root().join("tests/fixtures/bodies/README.md")).unwrap(); assert!(readme.contains("geen definitieve karakters")); let license = std::fs::read_to_string(repo_root().join("tests/fixtures/bodies/LICENSE")).unwrap(); assert!(license.contains("CC0 1.0")); } #[test] fn committed_test_bodies_match_the_generator() { let dir = std::env::temp_dir().join(format!("paperdoll-test-bodies-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let status = Command::new("python3") .arg(repo_root().join("scripts/generate_test_bodies.py")) .arg("--output") .arg(&dir) .status() .expect("python3 is required to regenerate the test bodies"); assert!(status.success(), "generator failed"); for sex in ["male", "female"] { let committed = std::fs::read(fixture(sex)).unwrap(); let generated = std::fs::read(dir.join(format!("test-body-{sex}.glb"))).unwrap(); assert_eq!( committed, generated, "test-body-{sex}.glb drifted from scripts/generate_test_bodies.py" ); } let _ = std::fs::remove_dir_all(&dir); }