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