#!/usr/bin/env python3 """Generate two synthetic skinned test bodies as self-contained GLB files. The meshes, skeleton and clips are procedural. Nothing is downloaded. The figures are labelled test assets, not finished characters. glTF axes: Y is up, the character faces +Z (the glTF front), its left side is +X, bind rotations are identity. Run from the repository root: python3 scripts/generate_test_bodies.py python3 scripts/generate_test_bodies.py --output /tmp/bodies """ from __future__ import annotations import argparse import json import math import struct from pathlib import Path JOINT_ORDER = [ "Hips", "Spine", "Chest", "Neck", "Head", "LeftShoulder", "LeftUpperArm", "LeftLowerArm", "LeftHand", "RightShoulder", "RightUpperArm", "RightLowerArm", "RightHand", "LeftUpperLeg", "LeftLowerLeg", "LeftFoot", "RightUpperLeg", "RightLowerLeg", "RightFoot", ] def proportions(sex: str) -> dict[str, float]: if sex == "male": return { "hips_y": 0.96, "shoulder_x": 0.20, "hip_x": 0.10, "chest_w": 0.36, "hip_w": 0.28, "upper_arm": 0.28, "lower_arm": 0.26, "upper_leg": 0.42, "lower_leg": 0.40, } if sex == "female": return { "hips_y": 0.88, "shoulder_x": 0.16, "hip_x": 0.13, "chest_w": 0.30, "hip_w": 0.32, "upper_arm": 0.26, "lower_arm": 0.24, "upper_leg": 0.40, "lower_leg": 0.38, } raise SystemExit(f"unknown sex: {sex}") def skeleton(sex: str) -> list[tuple[str, str | None, tuple[float, float, float], tuple[float, float, float]]]: """Return (name, parent, local translation, box size) for one test body.""" p = proportions(sex) shoulder = p["shoulder_x"] hip = p["hip_x"] # name, parent, local xyz, box size xyz. The box is centered on the joint. spec = [ ("Hips", None, (0.0, p["hips_y"], 0.0), (p["hip_w"], 0.16, 0.18)), ("Spine", "Hips", (0.0, 0.14, 0.0), (p["hip_w"] * 0.9, 0.16, 0.16)), ("Chest", "Spine", (0.0, 0.20, 0.0), (p["chest_w"], 0.22, 0.18)), ("Neck", "Chest", (0.0, 0.16, 0.0), (0.10, 0.08, 0.10)), ("Head", "Neck", (0.0, 0.14, 0.0), (0.18, 0.22, 0.20)), ("LeftShoulder", "Chest", (shoulder, 0.08, 0.0), (0.10, 0.08, 0.08)), ("LeftUpperArm", "LeftShoulder", (0.08, 0.0, 0.0), (0.08, p["upper_arm"], 0.08)), ("LeftLowerArm", "LeftUpperArm", (0.0, -p["upper_arm"], 0.0), (0.07, p["lower_arm"], 0.07)), ("LeftHand", "LeftLowerArm", (0.0, -p["lower_arm"], 0.0), (0.08, 0.10, 0.04)), ("RightShoulder", "Chest", (-shoulder, 0.08, 0.0), (0.10, 0.08, 0.08)), ("RightUpperArm", "RightShoulder", (-0.08, 0.0, 0.0), (0.08, p["upper_arm"], 0.08)), ("RightLowerArm", "RightUpperArm", (0.0, -p["upper_arm"], 0.0), (0.07, p["lower_arm"], 0.07)), ("RightHand", "RightLowerArm", (0.0, -p["lower_arm"], 0.0), (0.08, 0.10, 0.04)), ("LeftUpperLeg", "Hips", (hip, -0.06, 0.0), (0.12, p["upper_leg"], 0.12)), ("LeftLowerLeg", "LeftUpperLeg", (0.0, -p["upper_leg"], 0.0), (0.09, p["lower_leg"], 0.09)), ("LeftFoot", "LeftLowerLeg", (0.0, -p["lower_leg"], 0.06), (0.10, 0.06, 0.22)), ("RightUpperLeg", "Hips", (-hip, -0.06, 0.0), (0.12, p["upper_leg"], 0.12)), ("RightLowerLeg", "RightUpperLeg", (0.0, -p["upper_leg"], 0.0), (0.09, p["lower_leg"], 0.09)), ("RightFoot", "RightLowerLeg", (0.0, -p["lower_leg"], 0.06), (0.10, 0.06, 0.22)), ] names = [item[0] for item in spec] if names != JOINT_ORDER: raise SystemExit(f"joint order drifted: {names}") return spec def quat_x(degrees: float) -> tuple[float, float, float, float]: half = math.radians(degrees) * 0.5 return (math.sin(half), 0.0, 0.0, math.cos(half)) def quat_y(degrees: float) -> tuple[float, float, float, float]: half = math.radians(degrees) * 0.5 return (0.0, math.sin(half), 0.0, math.cos(half)) def walk_clips() -> dict[str, list[tuple[float, float, float, float]]]: times = [0.0, 0.25, 0.5, 0.75, 1.0] curves = { "LeftUpperLeg": [-28.0, 0.0, 28.0, 0.0, -28.0], "RightUpperLeg": [28.0, 0.0, -28.0, 0.0, 28.0], "LeftLowerLeg": [12.0, 8.0, 12.0, 45.0, 12.0], "RightLowerLeg": [12.0, 45.0, 12.0, 8.0, 12.0], "LeftUpperArm": [22.0, 0.0, -22.0, 0.0, 22.0], "RightUpperArm": [-22.0, 0.0, 22.0, 0.0, -22.0], "Chest": [4.0, 0.0, -4.0, 0.0, 4.0], } return {name: [quat_x(-angle) for angle in angles] for name, angles in curves.items()} | { "_times": [(t, 0.0, 0.0, 0.0) for t in times] } def attack_clips() -> dict[str, list[tuple[float, float, float, float]]]: # Right arm swings forward toward +Z (positive X rotation). Chest twists about Y. arm = [quat_x(-a) for a in (0.0, -20.0, -100.0, -40.0, 0.0)] forearm = [quat_x(-a) for a in (0.0, -10.0, -35.0, -15.0, 0.0)] chest = [quat_y(a) for a in (0.0, 6.0, 18.0, 8.0, 0.0)] spine = [quat_x(-a) for a in (0.0, 4.0, 12.0, 4.0, 0.0)] return { "RightUpperArm": arm, "RightLowerArm": forearm, "Chest": chest, "Spine": spine, "_times": [(t, 0.0, 0.0, 0.0) for t in (0.0, 0.15, 0.35, 0.6, 0.8)], } def world_translation( spec: list[tuple[str, str | None, tuple[float, float, float], tuple[float, float, float]]], name: str, ) -> tuple[float, float, float]: by_name = {item[0]: item for item in spec} x = y = z = 0.0 seen = 0 while name is not None: _n, parent, local, _size = by_name[name] x += local[0] y += local[1] z += local[2] name = parent seen += 1 if seen > len(spec): raise SystemExit("cycle in skeleton") return (x, y, z) def add_box( positions: list[float], indices: list[int], joints: list[int], weights: list[float], center: tuple[float, float, float], size: tuple[float, float, float], joint_index: int, ) -> None: hx, hy, hz = (size[0] / 2.0, size[1] / 2.0, size[2] / 2.0) cx, cy, cz = center base = len(positions) // 3 corners = [ (cx - hx, cy - hy, cz - hz), (cx + hx, cy - hy, cz - hz), (cx + hx, cy + hy, cz - hz), (cx - hx, cy + hy, cz - hz), (cx - hx, cy - hy, cz + hz), (cx + hx, cy - hy, cz + hz), (cx + hx, cy + hy, cz + hz), (cx - hx, cy + hy, cz + hz), ] for corner in corners: positions.extend(corner) joints.extend((joint_index, 0, 0, 0)) weights.extend((1.0, 0.0, 0.0, 0.0)) # Twelve triangles. The generator checks that each normal points outward. quads = [ (0, 1, 2, 3), # -Z (5, 4, 7, 6), # +Z (4, 0, 3, 7), # -X (1, 5, 6, 2), # +X (3, 2, 6, 7), # +Y (4, 5, 1, 0), # -Y ] for a, b, c, d in quads: for tri in ((a, b, c), (a, c, d)): i0, i1, i2 = (base + tri[0], base + tri[1], base + tri[2]) p0 = corners[tri[0]] p1 = corners[tri[1]] p2 = corners[tri[2]] ux, uy, uz = (p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]) vx, vy, vz = (p2[0] - p0[0], p2[1] - p0[1], p2[2] - p0[2]) nx = uy * vz - uz * vy ny = uz * vx - ux * vz nz = ux * vy - uy * vx mx = (p0[0] + p1[0] + p2[0]) / 3.0 - cx my = (p0[1] + p1[1] + p2[1]) / 3.0 - cy mz = (p0[2] + p1[2] + p2[2]) / 3.0 - cz if nx * mx + ny * my + nz * mz < 0.0: i1, i2 = i2, i1 indices.extend((i0, i1, i2)) class Blob: def __init__(self) -> None: self.data = bytearray() self.views: list[dict[str, int]] = [] self.accessors: list[dict[str, object]] = [] def _align(self) -> None: while len(self.data) % 4: self.data.append(0) def add_f32( self, values: list[float], type_name: str, count: int, minimum: list[float] | None = None, maximum: list[float] | None = None, ) -> int: self._align() offset = len(self.data) self.data += struct.pack("<" + "f" * len(values), *values) view = len(self.views) self.views.append({"buffer": 0, "byteOffset": offset, "byteLength": len(values) * 4}) accessor: dict[str, object] = { "bufferView": view, "componentType": 5126, "count": count, "type": type_name, } if minimum is not None and maximum is not None: accessor["min"] = minimum accessor["max"] = maximum self.accessors.append(accessor) return len(self.accessors) - 1 def add_u16(self, values: list[int], type_name: str, count: int) -> int: self._align() offset = len(self.data) self.data += struct.pack("<" + "H" * len(values), *values) view = len(self.views) self.views.append({"buffer": 0, "byteOffset": offset, "byteLength": len(values) * 2}) self.accessors.append( { "bufferView": view, "componentType": 5123, "count": count, "type": type_name, } ) return len(self.accessors) - 1 def vec_min_max(values: list[float], width: int) -> tuple[list[float], list[float]]: minimum = [min(values[i::width]) for i in range(width)] maximum = [max(values[i::width]) for i in range(width)] return minimum, maximum def build_glb(sex: str) -> bytes: spec = skeleton(sex) positions: list[float] = [] indices: list[int] = [] joint_ids: list[int] = [] weights: list[float] = [] for joint_index, (name, _parent, _local, size) in enumerate(spec): center = world_translation(spec, name) add_box(positions, indices, joint_ids, weights, center, size, joint_index) nodes: list[dict[str, object]] = [] for name, _parent, local, _size in spec: children = [index for index, item in enumerate(spec) if item[1] == name] node: dict[str, object] = { "name": name, "translation": [local[0], local[1], local[2]], } if children: node["children"] = children nodes.append(node) mesh_index = len(nodes) nodes.append({"name": f"test-body-{sex}-mesh", "mesh": 0, "skin": 0}) inverse_bind: list[float] = [] for name, _parent, _local, _size in spec: x, y, z = world_translation(spec, name) inverse_bind.extend( [ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, -x, -y, -z, 1.0, ] ) blob = Blob() index_acc = blob.add_u16(indices, "SCALAR", len(indices)) pos_min, pos_max = vec_min_max(positions, 3) pos_acc = blob.add_f32(positions, "VEC3", len(positions) // 3, pos_min, pos_max) joint_acc = blob.add_u16(joint_ids, "VEC4", len(joint_ids) // 4) weight_acc = blob.add_f32(weights, "VEC4", len(weights) // 4) ibm_acc = blob.add_f32(inverse_bind, "MAT4", len(spec)) def add_times(times: list[float]) -> int: return blob.add_f32(times, "SCALAR", len(times), [times[0]], [times[-1]]) def add_outputs(samples: list[tuple[float, float, float, float]], width: int) -> int: flat: list[float] = [] for sample in samples: flat.extend(sample[:width]) type_name = "VEC4" if width == 4 else "VEC3" return blob.add_f32(flat, type_name, len(samples)) animations = [] for clip_name, dutch, samples_fn in ( ("walk", "lopen", walk_clips), ("attack", "aanvallen", attack_clips), ): samples = samples_fn() times = [item[0] for item in samples["_times"]] time_acc = add_times(times) channels = [] samplers = [] for joint_name, values in samples.items(): if joint_name == "_times": continue node = JOINT_ORDER.index(joint_name) output = add_outputs(values, 4) sampler_index = len(samplers) samplers.append( {"input": time_acc, "output": output, "interpolation": "LINEAR"} ) channels.append( {"sampler": sampler_index, "target": {"node": node, "path": "rotation"}} ) if times[0] != 0.0 or times[-1] <= times[0]: raise SystemExit(f"bad clip times for {clip_name}") if values_loop_broken(samples): raise SystemExit(f"{clip_name} does not return to its first sample") animations.append( { "name": clip_name, "channels": channels, "samplers": samplers, "extras": {"dutch": dutch, "testClip": True}, } ) color = [0.45, 0.55, 0.70, 1.0] if sex == "male" else [0.70, 0.52, 0.48, 1.0] gltf = { "asset": { "version": "2.0", "generator": "scripts/generate_test_bodies.py", "extras": { "testAsset": True, "finalCharacter": False, "label": "Synthetisch testlichaam, geen definitief karakter", "sex": sex, "license": "CC0-1.0", "generator": "scripts/generate_test_bodies.py", "up": "+Y", "forward": "+Z", "note": "Procedureel testlichaam. Geen motion capture en geen definitief karakter.", }, }, "scene": 0, "scenes": [{"name": f"test-body-{sex}", "nodes": [mesh_index, 0]}], "nodes": nodes, "skins": [ { "name": f"test-body-{sex}-skin", "skeleton": 0, "joints": list(range(len(spec))), "inverseBindMatrices": ibm_acc, } ], "meshes": [ { "name": f"test-body-{sex}-mesh", "primitives": [ { "attributes": { "POSITION": pos_acc, "JOINTS_0": joint_acc, "WEIGHTS_0": weight_acc, }, "indices": index_acc, "material": 0, } ], } ], "materials": [ { "name": f"test-body-{sex}-material", "doubleSided": True, "pbrMetallicRoughness": { "baseColorFactor": color, "metallicFactor": 0.0, "roughnessFactor": 1.0, }, "extras": {"testAsset": True}, } ], "animations": animations, "buffers": [{"byteLength": len(blob.data)}], "bufferViews": blob.views, "accessors": blob.accessors, } return pack_glb(gltf, bytes(blob.data)) def values_loop_broken(samples: dict[str, list[tuple[float, float, float, float]]]) -> bool: for name, values in samples.items(): if name == "_times": continue if values[0] != values[-1]: return True return False def pack_glb(gltf: dict[str, object], binary: bytes) -> bytes: text = json.dumps(gltf, sort_keys=True, separators=(",", ":")).encode("utf-8") while (len(text) + 0) % 4: # Pad the JSON chunk with spaces, including the chunk header alignment. text += b" " while len(text) % 4: text += b" " bin_chunk = binary while len(bin_chunk) % 4: bin_chunk += b"\x00" json_header = struct.pack(" None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--output", type=Path, default=Path("tests/fixtures/bodies"), help="directory for test-body-male.glb and test-body-female.glb", ) args = parser.parse_args() args.output.mkdir(parents=True, exist_ok=True) for sex in ("male", "female"): payload = build_glb(sex) if payload[:4] != b"glTF": raise SystemExit("GLB magic missing") path = args.output / f"test-body-{sex}.glb" path.write_bytes(payload) print(f"wrote {path} ({len(payload)} bytes)") if __name__ == "__main__": main()