| 1 | #!/usr/bin/env python3 |
| 2 | """Generate two synthetic skinned test bodies as self-contained GLB files. |
| 3 | |
| 4 | The meshes, skeleton and clips are procedural. Nothing is downloaded. |
| 5 | The figures are labelled test assets, not finished characters. glTF axes: |
| 6 | Y is up, the character faces -Z, bind rotations are identity. |
| 7 | |
| 8 | Run from the repository root: |
| 9 | |
| 10 | python3 scripts/generate_test_bodies.py |
| 11 | python3 scripts/generate_test_bodies.py --output /tmp/bodies |
| 12 | """ |
| 13 | |
| 14 | from __future__ import annotations |
| 15 | |
| 16 | import argparse |
| 17 | import json |
| 18 | import math |
| 19 | import struct |
| 20 | from pathlib import Path |
| 21 | |
| 22 | JOINT_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 | |
| 45 | def 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 | |
| 73 | def 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 | |
| 106 | def 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 | |
| 111 | def 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 | |
| 116 | def 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 | |
| 132 | def 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 | |
| 147 | def 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 | |
| 166 | def 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 | |
| 220 | class 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 | |
| 272 | def 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 | |
| 278 | def 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 | |
| 430 | def 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 | |
| 439 | def 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 | |
| 456 | def 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 | |
| 475 | if __name__ == "__main__": |
| 476 | main() |