BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / tests / test_body_assets.rs

test_body_assets.rs

test_body_assets.rs 13.5 KiB Download Raw
1//! Validation of the synthetic male and female test bodies.
2//!
3//! This reads the GLB fixtures directly. It is not the product importer and
4//! it does not render anything.
5
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use serde_json::Value;
10
11const JOINTS: [&str; 19] = [
12 "Hips",
13 "Spine",
14 "Chest",
15 "Neck",
16 "Head",
17 "LeftShoulder",
18 "LeftUpperArm",
19 "LeftLowerArm",
20 "LeftHand",
21 "RightShoulder",
22 "RightUpperArm",
23 "RightLowerArm",
24 "RightHand",
25 "LeftUpperLeg",
26 "LeftLowerLeg",
27 "LeftFoot",
28 "RightUpperLeg",
29 "RightLowerLeg",
30 "RightFoot",
31];
32
33fn repo_root() -> PathBuf {
34 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
35}
36
37fn fixture(sex: &str) -> PathBuf {
38 repo_root().join(format!("tests/fixtures/bodies/test-body-{sex}.glb"))
39}
40
41fn read_glb(path: &Path) -> (Value, Vec<u8>) {
42 let bytes = std::fs::read(path).unwrap_or_else(|err| panic!("read {}: {err}", path.display()));
43 assert!(
44 bytes.len() > 20,
45 "{} is too small to be a GLB",
46 path.display()
47 );
48 assert_eq!(&bytes[0..4], b"glTF", "{}", path.display());
49 let version = u32::from_le_bytes(bytes[4..8].try_into().unwrap());
50 let length = u32::from_le_bytes(bytes[8..12].try_into().unwrap()) as usize;
51 assert_eq!(version, 2, "GLB version");
52 assert_eq!(length, bytes.len(), "GLB length prefix");
53
54 let mut offset = 12;
55 let mut json = None;
56 let mut binary = None;
57 while offset + 8 <= bytes.len() {
58 let chunk_len = u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()) as usize;
59 let chunk_type = &bytes[offset + 4..offset + 8];
60 let start = offset + 8;
61 let end = start + chunk_len;
62 assert!(end <= bytes.len(), "chunk exceeds file");
63 let data = &bytes[start..end];
64 if chunk_type == b"JSON" {
65 let text = std::str::from_utf8(data).expect("JSON chunk utf-8");
66 json = Some(serde_json::from_str::<Value>(text.trim_end()).expect("JSON chunk"));
67 } else if chunk_type == b"BIN\0" {
68 binary = Some(data.to_vec());
69 } else {
70 panic!("unexpected GLB chunk");
71 }
72 offset = end;
73 }
74 (json.expect("JSON chunk"), binary.expect("BIN chunk"))
75}
76
77fn node<'a>(json: &'a Value, name: &str) -> &'a Value {
78 json["nodes"]
79 .as_array()
80 .unwrap()
81 .iter()
82 .find(|node| node["name"] == name)
83 .unwrap_or_else(|| panic!("missing node {name}"))
84}
85
86fn translation(json: &Value, name: &str) -> [f64; 3] {
87 let values = node(json, name)["translation"]
88 .as_array()
89 .unwrap_or_else(|| panic!("{name} has no translation"));
90 assert_eq!(values.len(), 3);
91 [
92 values[0].as_f64().unwrap(),
93 values[1].as_f64().unwrap(),
94 values[2].as_f64().unwrap(),
95 ]
96}
97
98fn node_index(json: &Value, name: &str) -> usize {
99 json["nodes"]
100 .as_array()
101 .unwrap()
102 .iter()
103 .position(|node| node["name"] == name)
104 .unwrap_or_else(|| panic!("missing node {name}"))
105}
106
107fn parent_name(json: &Value, name: &str) -> Option<String> {
108 let index = node_index(json, name);
109 json["nodes"].as_array().unwrap().iter().find_map(|node| {
110 let children = node["children"].as_array()?;
111 children
112 .iter()
113 .any(|child| child.as_u64() == Some(index as u64))
114 .then(|| node["name"].as_str().unwrap().to_string())
115 })
116}
117
118fn accessor_f32(json: &Value, bin: &[u8], index: usize) -> Vec<f32> {
119 let accessor = &json["accessors"][index];
120 assert_eq!(accessor["componentType"].as_u64().unwrap(), 5126);
121 let width = match accessor["type"].as_str().unwrap() {
122 "SCALAR" => 1,
123 "VEC3" => 3,
124 "VEC4" => 4,
125 "MAT4" => 16,
126 other => panic!("unsupported accessor type {other}"),
127 };
128 let count = accessor["count"].as_u64().unwrap() as usize;
129 let view = &json["bufferViews"][accessor["bufferView"].as_u64().unwrap() as usize];
130 let offset = view["byteOffset"].as_u64().unwrap_or(0) as usize
131 + accessor["byteOffset"].as_u64().unwrap_or(0) as usize;
132 let mut values = Vec::with_capacity(count * width);
133 for item in 0..count * width {
134 let start = offset + item * 4;
135 values.push(f32::from_le_bytes(
136 bin[start..start + 4].try_into().unwrap(),
137 ));
138 }
139 values
140}
141
142fn animation<'a>(json: &'a Value, name: &str) -> &'a Value {
143 json["animations"]
144 .as_array()
145 .unwrap()
146 .iter()
147 .find(|clip| clip["name"] == name)
148 .unwrap_or_else(|| panic!("missing animation {name}"))
149}
150
151fn clip_duration(json: &Value, bin: &[u8], name: &str) -> f32 {
152 let clip = animation(json, name);
153 let input = clip["samplers"][0]["input"].as_u64().unwrap() as usize;
154 let times = accessor_f32(json, bin, input);
155 assert!(times.len() >= 4, "{name} needs several samples");
156 assert!(
157 times.windows(2).all(|pair| pair[1] >= pair[0]),
158 "{name} times go backwards"
159 );
160 *times.last().unwrap() - times[0]
161}
162
163fn channel_targets(json: &Value, name: &str) -> Vec<String> {
164 animation(json, name)["channels"]
165 .as_array()
166 .unwrap()
167 .iter()
168 .map(|channel| {
169 let index = channel["target"]["node"].as_u64().unwrap() as usize;
170 json["nodes"][index]["name"].as_str().unwrap().to_string()
171 })
172 .collect()
173}
174
175fn assert_quaternions_are_unit_and_loop(json: &Value, bin: &[u8], name: &str) {
176 let clip = animation(json, name);
177 for sampler in clip["samplers"].as_array().unwrap() {
178 assert_eq!(sampler["interpolation"], "LINEAR");
179 let output = accessor_f32(json, bin, sampler["output"].as_u64().unwrap() as usize);
180 assert_eq!(output.len() % 4, 0);
181 let samples = output.len() / 4;
182 for sample in 0..samples {
183 let q = &output[sample * 4..sample * 4 + 4];
184 let length = q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3];
185 assert!(
186 (length - 1.0).abs() < 1e-4,
187 "{name} quaternion {sample} length {length}"
188 );
189 }
190 let first = &output[0..4];
191 let last = &output[output.len() - 4..];
192 for (a, b) in first.iter().zip(last) {
193 assert!((a - b).abs() < 1e-5, "{name} does not loop");
194 }
195 }
196}
197
198fn assert_body(json: &Value, bin: &[u8], sex: &str) {
199 let extras = &json["asset"]["extras"];
200 assert_eq!(json["asset"]["version"], "2.0");
201 assert_eq!(extras["testAsset"], true);
202 assert_eq!(extras["finalCharacter"], false);
203 assert_eq!(extras["sex"], sex);
204 assert_eq!(extras["license"], "CC0-1.0");
205 assert_eq!(extras["up"], "+Y");
206 assert_eq!(extras["forward"], "+Z");
207 // glTF convention: front faces +Z, the character's left side is +X.
208 let node = |name: &str| {
209 json["nodes"]
210 .as_array()
211 .unwrap()
212 .iter()
213 .find(|n| n["name"] == name)
214 .unwrap_or_else(|| panic!("{name} missing"))
215 .clone()
216 };
217 assert!(
218 node("LeftFoot")["translation"][2].as_f64().unwrap() > 0.0,
219 "toes point to +Z"
220 );
221 assert!(
222 node("LeftUpperLeg")["translation"][0].as_f64().unwrap() > 0.0,
223 "left leg on +X"
224 );
225 assert!(
226 node("RightUpperLeg")["translation"][0].as_f64().unwrap() < 0.0,
227 "right leg on -X"
228 );
229 let label = extras["label"].as_str().unwrap();
230 assert!(
231 label.contains("geen definitief karakter"),
232 "label must say this is not a final character: {label}"
233 );
234 assert!(
235 json["buffers"][0].get("uri").is_none(),
236 "GLB must be self-contained"
237 );
238
239 let joints = json["skins"][0]["joints"].as_array().unwrap();
240 assert_eq!(joints.len(), JOINTS.len());
241 for (expected, joint) in JOINTS.iter().zip(joints) {
242 let index = joint.as_u64().unwrap() as usize;
243 assert_eq!(json["nodes"][index]["name"], *expected);
244 }
245
246 assert_eq!(parent_name(json, "Hips"), None);
247 assert_eq!(
248 parent_name(json, "LeftUpperArm").as_deref(),
249 Some("LeftShoulder")
250 );
251 assert_eq!(
252 parent_name(json, "LeftHand").as_deref(),
253 Some("LeftLowerArm")
254 );
255 assert_eq!(
256 parent_name(json, "LeftFoot").as_deref(),
257 Some("LeftLowerLeg")
258 );
259 assert_eq!(
260 parent_name(json, "RightUpperArm").as_deref(),
261 Some("RightShoulder")
262 );
263
264 let primitive = &json["meshes"][0]["primitives"][0];
265 assert!(primitive["attributes"]["POSITION"].is_number());
266 assert!(primitive["attributes"]["JOINTS_0"].is_number());
267 assert!(primitive["attributes"]["WEIGHTS_0"].is_number());
268 let positions = accessor_f32(
269 json,
270 bin,
271 primitive["attributes"]["POSITION"].as_u64().unwrap() as usize,
272 );
273 let weights = accessor_f32(
274 json,
275 bin,
276 primitive["attributes"]["WEIGHTS_0"].as_u64().unwrap() as usize,
277 );
278 assert!(!positions.is_empty());
279 assert_eq!(weights.len(), (positions.len() / 3) * 4);
280 assert!(
281 weights.chunks(4).all(|weight| {
282 (weight[0] - 1.0).abs() < 1e-6
283 && weight[1].abs() < 1e-6
284 && weight[2].abs() < 1e-6
285 && weight[3].abs() < 1e-6
286 }),
287 "test bodies use one rigid weight per vertex"
288 );
289
290 let inverse_bind = accessor_f32(
291 json,
292 bin,
293 json["skins"][0]["inverseBindMatrices"].as_u64().unwrap() as usize,
294 );
295 assert_eq!(inverse_bind.len(), JOINTS.len() * 16);
296 for (index, name) in JOINTS.iter().enumerate() {
297 let mut world = [0.0_f64; 3];
298 let mut current = (*name).to_string();
299 for _ in 0..=JOINTS.len() {
300 let local = translation(json, &current);
301 world[0] += local[0];
302 world[1] += local[1];
303 world[2] += local[2];
304 if current == "Hips" {
305 break;
306 }
307 current = parent_name(json, &current)
308 .unwrap_or_else(|| panic!("{name} is not connected to Hips"));
309 }
310 assert_eq!(current, "Hips", "{name} is not under Hips");
311 let matrix = &inverse_bind[index * 16..index * 16 + 16];
312 for diagonal in [0, 5, 10, 15] {
313 assert!(
314 (matrix[diagonal] - 1.0).abs() < 1e-5,
315 "{name} bind rotation is not identity"
316 );
317 }
318 assert!(
319 (f64::from(matrix[12]) + world[0]).abs() < 1e-4,
320 "{name} inverse bind x"
321 );
322 assert!(
323 (f64::from(matrix[13]) + world[1]).abs() < 1e-4,
324 "{name} inverse bind y"
325 );
326 assert!(
327 (f64::from(matrix[14]) + world[2]).abs() < 1e-4,
328 "{name} inverse bind z"
329 );
330 }
331
332 let names = json["animations"]
333 .as_array()
334 .unwrap()
335 .iter()
336 .map(|clip| clip["name"].as_str().unwrap().to_string())
337 .collect::<Vec<_>>();
338 assert!(names.contains(&"walk".to_string()));
339 assert!(names.contains(&"attack".to_string()));
340 assert!((clip_duration(json, bin, "walk") - 1.0).abs() < 1e-4);
341 assert!((clip_duration(json, bin, "attack") - 0.8).abs() < 1e-4);
342 let walk = channel_targets(json, "walk");
343 for joint in [
344 "LeftUpperLeg",
345 "RightUpperLeg",
346 "LeftUpperArm",
347 "RightUpperArm",
348 ] {
349 assert!(walk.iter().any(|name| name == joint), "walk misses {joint}");
350 }
351 let attack = channel_targets(json, "attack");
352 assert!(attack.iter().any(|name| name == "RightUpperArm"));
353 assert_quaternions_are_unit_and_loop(json, bin, "walk");
354 assert_quaternions_are_unit_and_loop(json, bin, "attack");
355}
356
357#[test]
358fn male_and_female_test_bodies_have_a_skeleton_and_both_clips() {
359 let (male, male_bin) = read_glb(&fixture("male"));
360 let (female, female_bin) = read_glb(&fixture("female"));
361 assert_body(&male, &male_bin, "male");
362 assert_body(&female, &female_bin, "female");
363
364 let male_shoulder = translation(&male, "LeftShoulder")[0].abs();
365 let female_shoulder = translation(&female, "LeftShoulder")[0].abs();
366 assert!(
367 male_shoulder > female_shoulder,
368 "male shoulders must be wider ({male_shoulder} vs {female_shoulder})"
369 );
370 assert!(translation(&male, "Hips")[1] > translation(&female, "Hips")[1]);
371 assert!(
372 translation(&female, "LeftUpperLeg")[0].abs() > translation(&male, "LeftUpperLeg")[0].abs()
373 );
374
375 let readme =
376 std::fs::read_to_string(repo_root().join("tests/fixtures/bodies/README.md")).unwrap();
377 assert!(readme.contains("geen definitieve karakters"));
378 let license =
379 std::fs::read_to_string(repo_root().join("tests/fixtures/bodies/LICENSE")).unwrap();
380 assert!(license.contains("CC0 1.0"));
381}
382
383#[test]
384fn committed_test_bodies_match_the_generator() {
385 let dir = std::env::temp_dir().join(format!("paperdoll-test-bodies-{}", std::process::id()));
386 let _ = std::fs::remove_dir_all(&dir);
387 std::fs::create_dir_all(&dir).unwrap();
388 let status = Command::new("python3")
389 .arg(repo_root().join("scripts/generate_test_bodies.py"))
390 .arg("--output")
391 .arg(&dir)
392 .status()
393 .expect("python3 is required to regenerate the test bodies");
394 assert!(status.success(), "generator failed");
395 for sex in ["male", "female"] {
396 let committed = std::fs::read(fixture(sex)).unwrap();
397 let generated = std::fs::read(dir.join(format!("test-body-{sex}.glb"))).unwrap();
398 assert_eq!(
399 committed, generated,
400 "test-body-{sex}.glb drifted from scripts/generate_test_bodies.py"
401 );
402 }
403 let _ = std::fs::remove_dir_all(&dir);
404}