BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / scripts / generate_test_bodies.py

generate_test_bodies.py

generate_test_bodies.py 16.5 KiB Download Raw
1#!/usr/bin/env python3
2"""Generate two synthetic skinned test bodies as self-contained GLB files.
3
4The meshes, skeleton and clips are procedural. Nothing is downloaded.
5The figures are labelled test assets, not finished characters. glTF axes:
6Y is up, the character faces +Z (the glTF front), its left side is +X,
7bind rotations are identity.
8
9Run from the repository root:
10
11 python3 scripts/generate_test_bodies.py
12 python3 scripts/generate_test_bodies.py --output /tmp/bodies
13"""
14
15from __future__ import annotations
16
17import argparse
18import json
19import math
20import struct
21from pathlib import Path
22
23JOINT_ORDER = [
24 "Hips",
25 "Spine",
26 "Chest",
27 "Neck",
28 "Head",
29 "LeftShoulder",
30 "LeftUpperArm",
31 "LeftLowerArm",
32 "LeftHand",
33 "RightShoulder",
34 "RightUpperArm",
35 "RightLowerArm",
36 "RightHand",
37 "LeftUpperLeg",
38 "LeftLowerLeg",
39 "LeftFoot",
40 "RightUpperLeg",
41 "RightLowerLeg",
42 "RightFoot",
43]
44
45
46def proportions(sex: str) -> dict[str, float]:
47 if sex == "male":
48 return {
49 "hips_y": 0.96,
50 "shoulder_x": 0.20,
51 "hip_x": 0.10,
52 "chest_w": 0.36,
53 "hip_w": 0.28,
54 "upper_arm": 0.28,
55 "lower_arm": 0.26,
56 "upper_leg": 0.42,
57 "lower_leg": 0.40,
58 }
59 if sex == "female":
60 return {
61 "hips_y": 0.88,
62 "shoulder_x": 0.16,
63 "hip_x": 0.13,
64 "chest_w": 0.30,
65 "hip_w": 0.32,
66 "upper_arm": 0.26,
67 "lower_arm": 0.24,
68 "upper_leg": 0.40,
69 "lower_leg": 0.38,
70 }
71 raise SystemExit(f"unknown sex: {sex}")
72
73
74def skeleton(sex: str) -> list[tuple[str, str | None, tuple[float, float, float], tuple[float, float, float]]]:
75 """Return (name, parent, local translation, box size) for one test body."""
76 p = proportions(sex)
77 shoulder = p["shoulder_x"]
78 hip = p["hip_x"]
79 # name, parent, local xyz, box size xyz. The box is centered on the joint.
80 spec = [
81 ("Hips", None, (0.0, p["hips_y"], 0.0), (p["hip_w"], 0.16, 0.18)),
82 ("Spine", "Hips", (0.0, 0.14, 0.0), (p["hip_w"] * 0.9, 0.16, 0.16)),
83 ("Chest", "Spine", (0.0, 0.20, 0.0), (p["chest_w"], 0.22, 0.18)),
84 ("Neck", "Chest", (0.0, 0.16, 0.0), (0.10, 0.08, 0.10)),
85 ("Head", "Neck", (0.0, 0.14, 0.0), (0.18, 0.22, 0.20)),
86 ("LeftShoulder", "Chest", (shoulder, 0.08, 0.0), (0.10, 0.08, 0.08)),
87 ("LeftUpperArm", "LeftShoulder", (0.08, 0.0, 0.0), (0.08, p["upper_arm"], 0.08)),
88 ("LeftLowerArm", "LeftUpperArm", (0.0, -p["upper_arm"], 0.0), (0.07, p["lower_arm"], 0.07)),
89 ("LeftHand", "LeftLowerArm", (0.0, -p["lower_arm"], 0.0), (0.08, 0.10, 0.04)),
90 ("RightShoulder", "Chest", (-shoulder, 0.08, 0.0), (0.10, 0.08, 0.08)),
91 ("RightUpperArm", "RightShoulder", (-0.08, 0.0, 0.0), (0.08, p["upper_arm"], 0.08)),
92 ("RightLowerArm", "RightUpperArm", (0.0, -p["upper_arm"], 0.0), (0.07, p["lower_arm"], 0.07)),
93 ("RightHand", "RightLowerArm", (0.0, -p["lower_arm"], 0.0), (0.08, 0.10, 0.04)),
94 ("LeftUpperLeg", "Hips", (hip, -0.06, 0.0), (0.12, p["upper_leg"], 0.12)),
95 ("LeftLowerLeg", "LeftUpperLeg", (0.0, -p["upper_leg"], 0.0), (0.09, p["lower_leg"], 0.09)),
96 ("LeftFoot", "LeftLowerLeg", (0.0, -p["lower_leg"], 0.06), (0.10, 0.06, 0.22)),
97 ("RightUpperLeg", "Hips", (-hip, -0.06, 0.0), (0.12, p["upper_leg"], 0.12)),
98 ("RightLowerLeg", "RightUpperLeg", (0.0, -p["upper_leg"], 0.0), (0.09, p["lower_leg"], 0.09)),
99 ("RightFoot", "RightLowerLeg", (0.0, -p["lower_leg"], 0.06), (0.10, 0.06, 0.22)),
100 ]
101 names = [item[0] for item in spec]
102 if names != JOINT_ORDER:
103 raise SystemExit(f"joint order drifted: {names}")
104 return spec
105
106
107def quat_x(degrees: float) -> tuple[float, float, float, float]:
108 half = math.radians(degrees) * 0.5
109 return (math.sin(half), 0.0, 0.0, math.cos(half))
110
111
112def quat_y(degrees: float) -> tuple[float, float, float, float]:
113 half = math.radians(degrees) * 0.5
114 return (0.0, math.sin(half), 0.0, math.cos(half))
115
116
117def walk_clips() -> dict[str, list[tuple[float, float, float, float]]]:
118 times = [0.0, 0.25, 0.5, 0.75, 1.0]
119 curves = {
120 "LeftUpperLeg": [-28.0, 0.0, 28.0, 0.0, -28.0],
121 "RightUpperLeg": [28.0, 0.0, -28.0, 0.0, 28.0],
122 "LeftLowerLeg": [12.0, 8.0, 12.0, 45.0, 12.0],
123 "RightLowerLeg": [12.0, 45.0, 12.0, 8.0, 12.0],
124 "LeftUpperArm": [22.0, 0.0, -22.0, 0.0, 22.0],
125 "RightUpperArm": [-22.0, 0.0, 22.0, 0.0, -22.0],
126 "Chest": [4.0, 0.0, -4.0, 0.0, 4.0],
127 }
128 return {name: [quat_x(-angle) for angle in angles] for name, angles in curves.items()} | {
129 "_times": [(t, 0.0, 0.0, 0.0) for t in times]
130 }
131
132
133def attack_clips() -> dict[str, list[tuple[float, float, float, float]]]:
134 # Right arm swings forward toward +Z (positive X rotation). Chest twists about Y.
135 arm = [quat_x(-a) for a in (0.0, -20.0, -100.0, -40.0, 0.0)]
136 forearm = [quat_x(-a) for a in (0.0, -10.0, -35.0, -15.0, 0.0)]
137 chest = [quat_y(a) for a in (0.0, 6.0, 18.0, 8.0, 0.0)]
138 spine = [quat_x(-a) for a in (0.0, 4.0, 12.0, 4.0, 0.0)]
139 return {
140 "RightUpperArm": arm,
141 "RightLowerArm": forearm,
142 "Chest": chest,
143 "Spine": spine,
144 "_times": [(t, 0.0, 0.0, 0.0) for t in (0.0, 0.15, 0.35, 0.6, 0.8)],
145 }
146
147
148def world_translation(
149 spec: list[tuple[str, str | None, tuple[float, float, float], tuple[float, float, float]]],
150 name: str,
151) -> tuple[float, float, float]:
152 by_name = {item[0]: item for item in spec}
153 x = y = z = 0.0
154 seen = 0
155 while name is not None:
156 _n, parent, local, _size = by_name[name]
157 x += local[0]
158 y += local[1]
159 z += local[2]
160 name = parent
161 seen += 1
162 if seen > len(spec):
163 raise SystemExit("cycle in skeleton")
164 return (x, y, z)
165
166
167def add_box(
168 positions: list[float],
169 indices: list[int],
170 joints: list[int],
171 weights: list[float],
172 center: tuple[float, float, float],
173 size: tuple[float, float, float],
174 joint_index: int,
175) -> None:
176 hx, hy, hz = (size[0] / 2.0, size[1] / 2.0, size[2] / 2.0)
177 cx, cy, cz = center
178 base = len(positions) // 3
179 corners = [
180 (cx - hx, cy - hy, cz - hz),
181 (cx + hx, cy - hy, cz - hz),
182 (cx + hx, cy + hy, cz - hz),
183 (cx - hx, cy + hy, cz - hz),
184 (cx - hx, cy - hy, cz + hz),
185 (cx + hx, cy - hy, cz + hz),
186 (cx + hx, cy + hy, cz + hz),
187 (cx - hx, cy + hy, cz + hz),
188 ]
189 for corner in corners:
190 positions.extend(corner)
191 joints.extend((joint_index, 0, 0, 0))
192 weights.extend((1.0, 0.0, 0.0, 0.0))
193 # Twelve triangles. The generator checks that each normal points outward.
194 quads = [
195 (0, 1, 2, 3), # -Z
196 (5, 4, 7, 6), # +Z
197 (4, 0, 3, 7), # -X
198 (1, 5, 6, 2), # +X
199 (3, 2, 6, 7), # +Y
200 (4, 5, 1, 0), # -Y
201 ]
202 for a, b, c, d in quads:
203 for tri in ((a, b, c), (a, c, d)):
204 i0, i1, i2 = (base + tri[0], base + tri[1], base + tri[2])
205 p0 = corners[tri[0]]
206 p1 = corners[tri[1]]
207 p2 = corners[tri[2]]
208 ux, uy, uz = (p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2])
209 vx, vy, vz = (p2[0] - p0[0], p2[1] - p0[1], p2[2] - p0[2])
210 nx = uy * vz - uz * vy
211 ny = uz * vx - ux * vz
212 nz = ux * vy - uy * vx
213 mx = (p0[0] + p1[0] + p2[0]) / 3.0 - cx
214 my = (p0[1] + p1[1] + p2[1]) / 3.0 - cy
215 mz = (p0[2] + p1[2] + p2[2]) / 3.0 - cz
216 if nx * mx + ny * my + nz * mz < 0.0:
217 i1, i2 = i2, i1
218 indices.extend((i0, i1, i2))
219
220
221class Blob:
222 def __init__(self) -> None:
223 self.data = bytearray()
224 self.views: list[dict[str, int]] = []
225 self.accessors: list[dict[str, object]] = []
226
227 def _align(self) -> None:
228 while len(self.data) % 4:
229 self.data.append(0)
230
231 def add_f32(
232 self,
233 values: list[float],
234 type_name: str,
235 count: int,
236 minimum: list[float] | None = None,
237 maximum: list[float] | None = None,
238 ) -> int:
239 self._align()
240 offset = len(self.data)
241 self.data += struct.pack("<" + "f" * len(values), *values)
242 view = len(self.views)
243 self.views.append({"buffer": 0, "byteOffset": offset, "byteLength": len(values) * 4})
244 accessor: dict[str, object] = {
245 "bufferView": view,
246 "componentType": 5126,
247 "count": count,
248 "type": type_name,
249 }
250 if minimum is not None and maximum is not None:
251 accessor["min"] = minimum
252 accessor["max"] = maximum
253 self.accessors.append(accessor)
254 return len(self.accessors) - 1
255
256 def add_u16(self, values: list[int], type_name: str, count: int) -> int:
257 self._align()
258 offset = len(self.data)
259 self.data += struct.pack("<" + "H" * len(values), *values)
260 view = len(self.views)
261 self.views.append({"buffer": 0, "byteOffset": offset, "byteLength": len(values) * 2})
262 self.accessors.append(
263 {
264 "bufferView": view,
265 "componentType": 5123,
266 "count": count,
267 "type": type_name,
268 }
269 )
270 return len(self.accessors) - 1
271
272
273def vec_min_max(values: list[float], width: int) -> tuple[list[float], list[float]]:
274 minimum = [min(values[i::width]) for i in range(width)]
275 maximum = [max(values[i::width]) for i in range(width)]
276 return minimum, maximum
277
278
279def build_glb(sex: str) -> bytes:
280 spec = skeleton(sex)
281 positions: list[float] = []
282 indices: list[int] = []
283 joint_ids: list[int] = []
284 weights: list[float] = []
285 for joint_index, (name, _parent, _local, size) in enumerate(spec):
286 center = world_translation(spec, name)
287 add_box(positions, indices, joint_ids, weights, center, size, joint_index)
288
289 nodes: list[dict[str, object]] = []
290 for name, _parent, local, _size in spec:
291 children = [index for index, item in enumerate(spec) if item[1] == name]
292 node: dict[str, object] = {
293 "name": name,
294 "translation": [local[0], local[1], local[2]],
295 }
296 if children:
297 node["children"] = children
298 nodes.append(node)
299 mesh_index = len(nodes)
300 nodes.append({"name": f"test-body-{sex}-mesh", "mesh": 0, "skin": 0})
301
302 inverse_bind: list[float] = []
303 for name, _parent, _local, _size in spec:
304 x, y, z = world_translation(spec, name)
305 inverse_bind.extend(
306 [
307 1.0, 0.0, 0.0, 0.0,
308 0.0, 1.0, 0.0, 0.0,
309 0.0, 0.0, 1.0, 0.0,
310 -x, -y, -z, 1.0,
311 ]
312 )
313
314 blob = Blob()
315 index_acc = blob.add_u16(indices, "SCALAR", len(indices))
316 pos_min, pos_max = vec_min_max(positions, 3)
317 pos_acc = blob.add_f32(positions, "VEC3", len(positions) // 3, pos_min, pos_max)
318 joint_acc = blob.add_u16(joint_ids, "VEC4", len(joint_ids) // 4)
319 weight_acc = blob.add_f32(weights, "VEC4", len(weights) // 4)
320 ibm_acc = blob.add_f32(inverse_bind, "MAT4", len(spec))
321
322 def add_times(times: list[float]) -> int:
323 return blob.add_f32(times, "SCALAR", len(times), [times[0]], [times[-1]])
324
325 def add_outputs(samples: list[tuple[float, float, float, float]], width: int) -> int:
326 flat: list[float] = []
327 for sample in samples:
328 flat.extend(sample[:width])
329 type_name = "VEC4" if width == 4 else "VEC3"
330 return blob.add_f32(flat, type_name, len(samples))
331
332 animations = []
333 for clip_name, dutch, samples_fn in (
334 ("walk", "lopen", walk_clips),
335 ("attack", "aanvallen", attack_clips),
336 ):
337 samples = samples_fn()
338 times = [item[0] for item in samples["_times"]]
339 time_acc = add_times(times)
340 channels = []
341 samplers = []
342 for joint_name, values in samples.items():
343 if joint_name == "_times":
344 continue
345 node = JOINT_ORDER.index(joint_name)
346 output = add_outputs(values, 4)
347 sampler_index = len(samplers)
348 samplers.append(
349 {"input": time_acc, "output": output, "interpolation": "LINEAR"}
350 )
351 channels.append(
352 {"sampler": sampler_index, "target": {"node": node, "path": "rotation"}}
353 )
354 if times[0] != 0.0 or times[-1] <= times[0]:
355 raise SystemExit(f"bad clip times for {clip_name}")
356 if values_loop_broken(samples):
357 raise SystemExit(f"{clip_name} does not return to its first sample")
358 animations.append(
359 {
360 "name": clip_name,
361 "channels": channels,
362 "samplers": samplers,
363 "extras": {"dutch": dutch, "testClip": True},
364 }
365 )
366
367 color = [0.45, 0.55, 0.70, 1.0] if sex == "male" else [0.70, 0.52, 0.48, 1.0]
368 gltf = {
369 "asset": {
370 "version": "2.0",
371 "generator": "scripts/generate_test_bodies.py",
372 "extras": {
373 "testAsset": True,
374 "finalCharacter": False,
375 "label": "Synthetisch testlichaam, geen definitief karakter",
376 "sex": sex,
377 "license": "CC0-1.0",
378 "generator": "scripts/generate_test_bodies.py",
379 "up": "+Y",
380 "forward": "+Z",
381 "note": "Procedureel testlichaam. Geen motion capture en geen definitief karakter.",
382 },
383 },
384 "scene": 0,
385 "scenes": [{"name": f"test-body-{sex}", "nodes": [mesh_index, 0]}],
386 "nodes": nodes,
387 "skins": [
388 {
389 "name": f"test-body-{sex}-skin",
390 "skeleton": 0,
391 "joints": list(range(len(spec))),
392 "inverseBindMatrices": ibm_acc,
393 }
394 ],
395 "meshes": [
396 {
397 "name": f"test-body-{sex}-mesh",
398 "primitives": [
399 {
400 "attributes": {
401 "POSITION": pos_acc,
402 "JOINTS_0": joint_acc,
403 "WEIGHTS_0": weight_acc,
404 },
405 "indices": index_acc,
406 "material": 0,
407 }
408 ],
409 }
410 ],
411 "materials": [
412 {
413 "name": f"test-body-{sex}-material",
414 "doubleSided": True,
415 "pbrMetallicRoughness": {
416 "baseColorFactor": color,
417 "metallicFactor": 0.0,
418 "roughnessFactor": 1.0,
419 },
420 "extras": {"testAsset": True},
421 }
422 ],
423 "animations": animations,
424 "buffers": [{"byteLength": len(blob.data)}],
425 "bufferViews": blob.views,
426 "accessors": blob.accessors,
427 }
428 return pack_glb(gltf, bytes(blob.data))
429
430
431def values_loop_broken(samples: dict[str, list[tuple[float, float, float, float]]]) -> bool:
432 for name, values in samples.items():
433 if name == "_times":
434 continue
435 if values[0] != values[-1]:
436 return True
437 return False
438
439
440def pack_glb(gltf: dict[str, object], binary: bytes) -> bytes:
441 text = json.dumps(gltf, sort_keys=True, separators=(",", ":")).encode("utf-8")
442 while (len(text) + 0) % 4:
443 # Pad the JSON chunk with spaces, including the chunk header alignment.
444 text += b" "
445 while len(text) % 4:
446 text += b" "
447 bin_chunk = binary
448 while len(bin_chunk) % 4:
449 bin_chunk += b"\x00"
450 json_header = struct.pack("<II", len(text), 0x4E4F534A)
451 bin_header = struct.pack("<II", len(bin_chunk), 0x004E4942)
452 total = 12 + len(json_header) + len(text) + len(bin_header) + len(bin_chunk)
453 header = struct.pack("<III", 0x46546C67, 2, total)
454 return header + json_header + text + bin_header + bin_chunk
455
456
457def main() -> None:
458 parser = argparse.ArgumentParser(description=__doc__)
459 parser.add_argument(
460 "--output",
461 type=Path,
462 default=Path("tests/fixtures/bodies"),
463 help="directory for test-body-male.glb and test-body-female.glb",
464 )
465 args = parser.parse_args()
466 args.output.mkdir(parents=True, exist_ok=True)
467 for sex in ("male", "female"):
468 payload = build_glb(sex)
469 if payload[:4] != b"glTF":
470 raise SystemExit("GLB magic missing")
471 path = args.output / f"test-body-{sex}.glb"
472 path.write_bytes(payload)
473 print(f"wrote {path} ({len(payload)} bytes)")
474
475
476if __name__ == "__main__":
477 main()