BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / tests / test_body_assets.rs

test_body_assets.rs

test_body_assets.rs 12.8 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 let label = extras["label"].as_str().unwrap();
208 assert!(
209 label.contains("geen definitief karakter"),
210 "label must say this is not a final character: {label}"
211 );
212 assert!(
213 json["buffers"][0].get("uri").is_none(),
214 "GLB must be self-contained"
215 );
216
217 let joints = json["skins"][0]["joints"].as_array().unwrap();
218 assert_eq!(joints.len(), JOINTS.len());
219 for (expected, joint) in JOINTS.iter().zip(joints) {
220 let index = joint.as_u64().unwrap() as usize;
221 assert_eq!(json["nodes"][index]["name"], *expected);
222 }
223
224 assert_eq!(parent_name(json, "Hips"), None);
225 assert_eq!(
226 parent_name(json, "LeftUpperArm").as_deref(),
227 Some("LeftShoulder")
228 );
229 assert_eq!(
230 parent_name(json, "LeftHand").as_deref(),
231 Some("LeftLowerArm")
232 );
233 assert_eq!(
234 parent_name(json, "LeftFoot").as_deref(),
235 Some("LeftLowerLeg")
236 );
237 assert_eq!(
238 parent_name(json, "RightUpperArm").as_deref(),
239 Some("RightShoulder")
240 );
241
242 let primitive = &json["meshes"][0]["primitives"][0];
243 assert!(primitive["attributes"]["POSITION"].is_number());
244 assert!(primitive["attributes"]["JOINTS_0"].is_number());
245 assert!(primitive["attributes"]["WEIGHTS_0"].is_number());
246 let positions = accessor_f32(
247 json,
248 bin,
249 primitive["attributes"]["POSITION"].as_u64().unwrap() as usize,
250 );
251 let weights = accessor_f32(
252 json,
253 bin,
254 primitive["attributes"]["WEIGHTS_0"].as_u64().unwrap() as usize,
255 );
256 assert!(!positions.is_empty());
257 assert_eq!(weights.len(), (positions.len() / 3) * 4);
258 assert!(
259 weights.chunks(4).all(|weight| {
260 (weight[0] - 1.0).abs() < 1e-6
261 && weight[1].abs() < 1e-6
262 && weight[2].abs() < 1e-6
263 && weight[3].abs() < 1e-6
264 }),
265 "test bodies use one rigid weight per vertex"
266 );
267
268 let inverse_bind = accessor_f32(
269 json,
270 bin,
271 json["skins"][0]["inverseBindMatrices"].as_u64().unwrap() as usize,
272 );
273 assert_eq!(inverse_bind.len(), JOINTS.len() * 16);
274 for (index, name) in JOINTS.iter().enumerate() {
275 let mut world = [0.0_f64; 3];
276 let mut current = (*name).to_string();
277 for _ in 0..=JOINTS.len() {
278 let local = translation(json, &current);
279 world[0] += local[0];
280 world[1] += local[1];
281 world[2] += local[2];
282 if current == "Hips" {
283 break;
284 }
285 current = parent_name(json, &current)
286 .unwrap_or_else(|| panic!("{name} is not connected to Hips"));
287 }
288 assert_eq!(current, "Hips", "{name} is not under Hips");
289 let matrix = &inverse_bind[index * 16..index * 16 + 16];
290 for diagonal in [0, 5, 10, 15] {
291 assert!(
292 (matrix[diagonal] - 1.0).abs() < 1e-5,
293 "{name} bind rotation is not identity"
294 );
295 }
296 assert!(
297 (f64::from(matrix[12]) + world[0]).abs() < 1e-4,
298 "{name} inverse bind x"
299 );
300 assert!(
301 (f64::from(matrix[13]) + world[1]).abs() < 1e-4,
302 "{name} inverse bind y"
303 );
304 assert!(
305 (f64::from(matrix[14]) + world[2]).abs() < 1e-4,
306 "{name} inverse bind z"
307 );
308 }
309
310 let names = json["animations"]
311 .as_array()
312 .unwrap()
313 .iter()
314 .map(|clip| clip["name"].as_str().unwrap().to_string())
315 .collect::<Vec<_>>();
316 assert!(names.contains(&"walk".to_string()));
317 assert!(names.contains(&"attack".to_string()));
318 assert!((clip_duration(json, bin, "walk") - 1.0).abs() < 1e-4);
319 assert!((clip_duration(json, bin, "attack") - 0.8).abs() < 1e-4);
320 let walk = channel_targets(json, "walk");
321 for joint in [
322 "LeftUpperLeg",
323 "RightUpperLeg",
324 "LeftUpperArm",
325 "RightUpperArm",
326 ] {
327 assert!(walk.iter().any(|name| name == joint), "walk misses {joint}");
328 }
329 let attack = channel_targets(json, "attack");
330 assert!(attack.iter().any(|name| name == "RightUpperArm"));
331 assert_quaternions_are_unit_and_loop(json, bin, "walk");
332 assert_quaternions_are_unit_and_loop(json, bin, "attack");
333}
334
335#[test]
336fn male_and_female_test_bodies_have_a_skeleton_and_both_clips() {
337 let (male, male_bin) = read_glb(&fixture("male"));
338 let (female, female_bin) = read_glb(&fixture("female"));
339 assert_body(&male, &male_bin, "male");
340 assert_body(&female, &female_bin, "female");
341
342 let male_shoulder = translation(&male, "LeftShoulder")[0];
343 let female_shoulder = translation(&female, "LeftShoulder")[0];
344 assert!(
345 male_shoulder < female_shoulder,
346 "male shoulders must be wider ({male_shoulder} vs {female_shoulder})"
347 );
348 assert!(translation(&male, "Hips")[1] > translation(&female, "Hips")[1]);
349 assert!(translation(&female, "LeftUpperLeg")[0] < translation(&male, "LeftUpperLeg")[0]);
350
351 let readme =
352 std::fs::read_to_string(repo_root().join("tests/fixtures/bodies/README.md")).unwrap();
353 assert!(readme.contains("geen definitieve karakters"));
354 let license =
355 std::fs::read_to_string(repo_root().join("tests/fixtures/bodies/LICENSE")).unwrap();
356 assert!(license.contains("CC0 1.0"));
357}
358
359#[test]
360fn committed_test_bodies_match_the_generator() {
361 let dir = std::env::temp_dir().join(format!("paperdoll-test-bodies-{}", std::process::id()));
362 let _ = std::fs::remove_dir_all(&dir);
363 std::fs::create_dir_all(&dir).unwrap();
364 let status = Command::new("python3")
365 .arg(repo_root().join("scripts/generate_test_bodies.py"))
366 .arg("--output")
367 .arg(&dir)
368 .status()
369 .expect("python3 is required to regenerate the test bodies");
370 assert!(status.success(), "generator failed");
371 for sex in ["male", "female"] {
372 let committed = std::fs::read(fixture(sex)).unwrap();
373 let generated = std::fs::read(dir.join(format!("test-body-{sex}.glb"))).unwrap();
374 assert_eq!(
375 committed, generated,
376 "test-body-{sex}.glb drifted from scripts/generate_test_bodies.py"
377 );
378 }
379 let _ = std::fs::remove_dir_all(&dir);
380}