| 1 | #!/usr/bin/env python3 |
| 2 | """Generate the static (unskinned) male test body fixtures. |
| 3 | |
| 4 | Same procedural boxes as scripts/generate_test_bodies.py in their bind pose, |
| 5 | but WITHOUT a skin or skeleton: |
| 6 | |
| 7 | * static-body-male.obj + .mtl + static-body-albedo.png (OBJ/MTL/PNG). Boxes |
| 8 | whose center is above 1.2 m sample the top (red) half of the texture, |
| 9 | lower boxes the bottom (blue) half, so UV orientation is testable. |
| 10 | * static-body-male.glb: unskinned glTF. The mesh sits under a parent node |
| 11 | (translation + uniform scale 2) and a child node (rotation 90 deg about Y); |
| 12 | vertices/normals are stored pre-transformed so the baked world geometry |
| 13 | equals the skinned body's bind pose. One node animation is included and |
| 14 | must NOT be applied by the static loader. |
| 15 | |
| 16 | Run from the repository root: |
| 17 | |
| 18 | python3 scripts/generate_static_bodies.py |
| 19 | """ |
| 20 | |
| 21 | from __future__ import annotations |
| 22 | |
| 23 | import math |
| 24 | import struct |
| 25 | import sys |
| 26 | import zlib |
| 27 | from pathlib import Path |
| 28 | |
| 29 | sys.path.insert(0, str(Path(__file__).resolve().parent)) |
| 30 | import generate_test_bodies as gen # noqa: E402 |
| 31 | |
| 32 | OUT = Path("tests/fixtures/bodies/static") |
| 33 | PARENT_T = (0.25, -0.1, 0.4) |
| 34 | PARENT_S = 2.0 |
| 35 | RED = (220, 40, 30, 255) |
| 36 | BLUE = (30, 60, 220, 255) |
| 37 | |
| 38 | |
| 39 | def png_rgba(width: int, height: int, rows: list[list[tuple[int, int, int, int]]]) -> bytes: |
| 40 | raw = b"".join(b"\x00" + bytes(c for px in row for c in px) for row in rows) |
| 41 | def chunk(kind: bytes, data: bytes) -> bytes: |
| 42 | return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF) |
| 43 | ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0) |
| 44 | return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"") |
| 45 | |
| 46 | |
| 47 | def boxes(sex: str): |
| 48 | """Yield (triangles, face normals, uv v) per box in world bind pose.""" |
| 49 | spec = gen.skeleton(sex) |
| 50 | for joint_index, (name, _p, _l, size) in enumerate(spec): |
| 51 | center = gen.world_translation(spec, name) |
| 52 | positions: list[float] = [] |
| 53 | indices: list[int] = [] |
| 54 | gen.add_box(positions, indices, [], [], center, size, joint_index) |
| 55 | corners = [tuple(positions[i:i + 3]) for i in range(0, len(positions), 3)] |
| 56 | tris = [tuple(indices[i:i + 3]) for i in range(0, len(indices), 3)] |
| 57 | v = 0.85 if center[1] > 1.2 else 0.15 |
| 58 | yield corners, tris, v |
| 59 | |
| 60 | |
| 61 | def face_normal(a, b, c): |
| 62 | u = [b[i] - a[i] for i in range(3)] |
| 63 | w = [c[i] - a[i] for i in range(3)] |
| 64 | n = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]] |
| 65 | length = math.sqrt(sum(x * x for x in n)) |
| 66 | return tuple(x / length for x in n) |
| 67 | |
| 68 | |
| 69 | def write_obj(sex: str) -> None: |
| 70 | lines = [ |
| 71 | "# Static CC0 test body (no skeleton). Generated by scripts/generate_static_bodies.py", |
| 72 | "mtllib static-body-male.mtl", |
| 73 | "usemtl body", |
| 74 | ] |
| 75 | vt = ["vt 0.5 0.85", "vt 0.5 0.15"] |
| 76 | lines += vt |
| 77 | v_count = 0 |
| 78 | n_count = 0 |
| 79 | faces = [] |
| 80 | for corners, tris, v in boxes(sex): |
| 81 | for c in corners: |
| 82 | lines.append("v %.6f %.6f %.6f" % c) |
| 83 | uv = 1 if v > 0.5 else 2 |
| 84 | for t in tris: |
| 85 | n = face_normal(corners[t[0]], corners[t[1]], corners[t[2]]) |
| 86 | lines.append("vn %.6f %.6f %.6f" % n) |
| 87 | n_count += 1 |
| 88 | faces.append("f " + " ".join(f"{v_count + i + 1}/{uv}/{n_count}" for i in t)) |
| 89 | v_count += len(corners) |
| 90 | lines += faces |
| 91 | (OUT / "static-body-male.obj").write_text("\n".join(lines) + "\n") |
| 92 | (OUT / "static-body-male.mtl").write_text( |
| 93 | "# CC0 static test body material\nnewmtl body\nKd 1.0 1.0 1.0\nd 1.0\nmap_Kd static-body-albedo.png\n" |
| 94 | ) |
| 95 | rows = [[RED] * 4, [RED] * 4, [BLUE] * 4, [BLUE] * 4] |
| 96 | (OUT / "static-body-albedo.png").write_bytes(png_rgba(4, 4, rows)) |
| 97 | |
| 98 | |
| 99 | def rot_y(deg: float, p): |
| 100 | a = math.radians(deg) |
| 101 | c, s = math.cos(a), math.sin(a) |
| 102 | return (c * p[0] + s * p[2], p[1], -s * p[0] + c * p[2]) |
| 103 | |
| 104 | |
| 105 | def write_glb(sex: str) -> None: |
| 106 | positions: list[float] = [] |
| 107 | normals: list[float] = [] |
| 108 | indices: list[int] = [] |
| 109 | for corners, tris, _v in boxes(sex): |
| 110 | for t in tris: |
| 111 | a, b, c = (corners[i] for i in t) |
| 112 | n = face_normal(a, b, c) |
| 113 | for p in (a, b, c): |
| 114 | local = tuple((p[i] - PARENT_T[i]) / PARENT_S for i in range(3)) |
| 115 | positions.extend(rot_y(-90.0, local)) |
| 116 | normals.extend(rot_y(-90.0, n)) |
| 117 | indices.append(len(indices)) |
| 118 | blob = gen.Blob() |
| 119 | index_acc = blob.add_u16(indices, "SCALAR", len(indices)) |
| 120 | pmin, pmax = gen.vec_min_max(positions, 3) |
| 121 | pos_acc = blob.add_f32(positions, "VEC3", len(positions) // 3, pmin, pmax) |
| 122 | nrm_acc = blob.add_f32(normals, "VEC3", len(normals) // 3) |
| 123 | times = blob.add_f32([0.0, 1.0], "SCALAR", 2, [0.0], [1.0]) |
| 124 | half = math.radians(45.0) |
| 125 | rot_out = blob.add_f32([0, 0, 0, 1, 0, math.sin(half), 0, math.cos(half)], "VEC4", 2) |
| 126 | half90 = math.radians(45.0) |
| 127 | gltf = { |
| 128 | "asset": { |
| 129 | "version": "2.0", |
| 130 | "generator": "scripts/generate_static_bodies.py", |
| 131 | "extras": {"testAsset": True, "license": "CC0-1.0", "skin": False}, |
| 132 | }, |
| 133 | "scene": 0, |
| 134 | "scenes": [{"name": "static-body", "nodes": [0]}], |
| 135 | "nodes": [ |
| 136 | {"name": "static-root", "translation": list(PARENT_T), "scale": [PARENT_S] * 3, "children": [1]}, |
| 137 | {"name": "static-body-mesh", "rotation": [0.0, math.sin(half90), 0.0, math.cos(half90)], "mesh": 0}, |
| 138 | ], |
| 139 | "meshes": [{"name": "static-body", "primitives": [{ |
| 140 | "attributes": {"POSITION": pos_acc, "NORMAL": nrm_acc}, |
| 141 | "indices": index_acc, "material": 0}]}], |
| 142 | "materials": [{"name": "static-body", "pbrMetallicRoughness": { |
| 143 | "baseColorFactor": [0.45, 0.55, 0.70, 1.0], "metallicFactor": 0.0, "roughnessFactor": 1.0}}], |
| 144 | "animations": [{"name": "spin-not-applied", "channels": [{"sampler": 0, "target": {"node": 0, "path": "rotation"}}], |
| 145 | "samplers": [{"input": times, "output": rot_out, "interpolation": "LINEAR"}]}], |
| 146 | "buffers": [{"byteLength": len(blob.data)}], |
| 147 | "bufferViews": blob.views, |
| 148 | "accessors": blob.accessors, |
| 149 | } |
| 150 | (OUT / "static-body-male.glb").write_bytes(gen.pack_glb(gltf, bytes(blob.data))) |
| 151 | |
| 152 | |
| 153 | def main() -> None: |
| 154 | OUT.mkdir(parents=True, exist_ok=True) |
| 155 | write_obj("male") |
| 156 | write_glb("male") |
| 157 | for p in sorted(OUT.iterdir()): |
| 158 | print(f"wrote {p} ({p.stat().st_size} bytes)") |
| 159 | |
| 160 | |
| 161 | if __name__ == "__main__": |
| 162 | main() |