BlueGit

golden-age paperdoll-renderer

merge: synthetic male and female test body assets

Non-fast-forward merge of feature/test-body-assets into main.
Co-authored-by: tim <tim@timfalken.com>

timfa Cursor Agent · pushed by timfa

Authored 2026-11-06 · 7 days ago

Hash
075299e8b73315c1180b2490fd80c13f4de4bc03
11 files changed +942 −0
.gitattributes added +2 −0
@@ -0,0 +1,2 @@
1 *.glb binary
2 *.png binary
@@ -0,0 +1,2 @@
1*.glb binary
2*.png binary
Cargo.lock +26 −0
@@ -1089,6 +1089,12 @@ dependencies = [
1089 1089 "hashbrown 0.17.1",
1090 1090 ]
1091 1091
1092 [[package]]
1093 name = "itoa"
1094 version = "1.0.18"
1095 source = "registry+https://github.com/rust-lang/crates.io-index"
1096 checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
1097
1092 1098 [[package]]
1093 1099 name = "jni"
1094 1100 version = "0.22.4"
@@ -1738,6 +1744,7 @@ version = "0.1.0"
1738 1744 dependencies = [
1739 1745 "eframe",
1740 1746 "libloading",
1747 "serde_json",
1741 1748 ]
1742 1749
1743 1750 [[package]]
@@ -2063,6 +2070,19 @@ dependencies = [
2063 2070 "syn 3.0.9",
2064 2071 ]
2065 2072
2073 [[package]]
2074 name = "serde_json"
2075 version = "1.0.154"
2076 source = "registry+https://github.com/rust-lang/crates.io-index"
2077 checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
2078 dependencies = [
2079 "itoa",
2080 "memchr",
2081 "serde",
2082 "serde_core",
2083 "zmij",
2084 ]
2085
2066 2086 [[package]]
2067 2087 name = "shlex"
2068 2088 version = "2.0.1"
@@ -3172,3 +3192,9 @@ name = "zlib-rs"
3172 3192 version = "0.6.8"
3173 3193 source = "registry+https://github.com/rust-lang/crates.io-index"
3174 3194 checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
3195
3196 [[package]]
3197 name = "zmij"
3198 version = "1.0.23"
3199 source = "registry+https://github.com/rust-lang/crates.io-index"
3200 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
@@ -1089,6 +1089,12 @@ dependencies = [
1089 "hashbrown 0.17.1",
1089 "hashbrown 0.17.1",
1090]
1090]
1091
1091
1092[[package]]
1093name = "itoa"
1094version = "1.0.18"
1095source = "registry+https://github.com/rust-lang/crates.io-index"
1096checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
1097
1092[[package]]
1098[[package]]
1093name = "jni"
1099name = "jni"
1094version = "0.22.4"
1100version = "0.22.4"
@@ -1738,6 +1744,7 @@ version = "0.1.0"
1738dependencies = [
1744dependencies = [
1739 "eframe",
1745 "eframe",
1740 "libloading",
1746 "libloading",
1747 "serde_json",
1741]
1748]
1742
1749
1743[[package]]
1750[[package]]
@@ -2063,6 +2070,19 @@ dependencies = [
2063 "syn 3.0.9",
2070 "syn 3.0.9",
2064]
2071]
2065
2072
2073[[package]]
2074name = "serde_json"
2075version = "1.0.154"
2076source = "registry+https://github.com/rust-lang/crates.io-index"
2077checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
2078dependencies = [
2079 "itoa",
2080 "memchr",
2081 "serde",
2082 "serde_core",
2083 "zmij",
2084]
2085
2066[[package]]
2086[[package]]
2067name = "shlex"
2087name = "shlex"
2068version = "2.0.1"
2088version = "2.0.1"
@@ -3172,3 +3192,9 @@ name = "zlib-rs"
3172version = "0.6.8"
3192version = "0.6.8"
3173source = "registry+https://github.com/rust-lang/crates.io-index"
3193source = "registry+https://github.com/rust-lang/crates.io-index"
3174checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
3194checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
3195
3196[[package]]
3197name = "zmij"
3198version = "1.0.23"
3199source = "registry+https://github.com/rust-lang/crates.io-index"
3200checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
Cargo.toml +3 −0
@@ -6,6 +6,9 @@ license = "MIT"
6 6 publish = false
7 7 description = "Foundation shell for the Paperdoll Renderer. Import and rendering are not implemented."
8 8
9 [dev-dependencies]
10 serde_json = "1"
11
9 12 [dependencies]
10 13 eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }
11 14 libloading = "0.8"
@@ -6,6 +6,9 @@ license = "MIT"
6publish = false
6publish = false
7description = "Foundation shell for the Paperdoll Renderer. Import and rendering are not implemented."
7description = "Foundation shell for the Paperdoll Renderer. Import and rendering are not implemented."
8
8
9[dev-dependencies]
10serde_json = "1"
11
9[dependencies]
12[dependencies]
10eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }
13eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }
11libloading = "0.8"
14libloading = "0.8"
README.md +10 −0
@@ -19,6 +19,16 @@ cargo run
19 19
20 20 `--status` print de capability-lijst en opent geen venster. Een pad naar een model wordt geweigerd.
21 21
22 ## Testlichamen
23
24 `tests/fixtures/bodies/` bevat twee procedurele GLB-bestanden, man en vrouw, elk met een skeleton en de clips `walk` en `attack`. Ze zijn CC0, duidelijk als testasset gelabeld, en geen definitieve karakters. Opnieuw maken:
25
26 ```bash
27 python3 scripts/generate_test_bodies.py
28 ```
29
30 `cargo test --locked` controleert of de bestanden leesbaar zijn, het skeleton en beide clips bevatten, en nog gelijk zijn aan de generator. Het venster laadt ze niet.
31
22 32 ## Wat deze build niet doet
23 33
24 34 OBJ+MTL+textures, FBX, glTF/GLB, skeletanimatie, de 3D-attachmenteditor, paperdoll-render en de 2D-preview zijn niet gebouwd. Bestanden onder `tests/fixtures/`, als die er later bijkomen, worden door dit programma niet geladen.
@@ -19,6 +19,16 @@ cargo run
19
19
20`--status` print de capability-lijst en opent geen venster. Een pad naar een model wordt geweigerd.
20`--status` print de capability-lijst en opent geen venster. Een pad naar een model wordt geweigerd.
21
21
22## Testlichamen
23
24`tests/fixtures/bodies/` bevat twee procedurele GLB-bestanden, man en vrouw, elk met een skeleton en de clips `walk` en `attack`. Ze zijn CC0, duidelijk als testasset gelabeld, en geen definitieve karakters. Opnieuw maken:
25
26```bash
27python3 scripts/generate_test_bodies.py
28```
29
30`cargo test --locked` controleert of de bestanden leesbaar zijn, het skeleton en beide clips bevatten, en nog gelijk zijn aan de generator. Het venster laadt ze niet.
31
22## Wat deze build niet doet
32## Wat deze build niet doet
23
33
24OBJ+MTL+textures, FBX, glTF/GLB, skeletanimatie, de 3D-attachmenteditor, paperdoll-render en de 2D-preview zijn niet gebouwd. Bestanden onder `tests/fixtures/`, als die er later bijkomen, worden door dit programma niet geladen.
34OBJ+MTL+textures, FBX, glTF/GLB, skeletanimatie, de 3D-attachmenteditor, paperdoll-render en de 2D-preview zijn niet gebouwd. Bestanden onder `tests/fixtures/`, als die er later bijkomen, worden door dit programma niet geladen.
design.md +8 −0
@@ -217,6 +217,14 @@ Consequences for `blue-ci-cd.yml` in this repository:
217 217
218 218 The toolchain is Rust 1.96.0, edition 2021. Edition 2021 remains explicit; the crate does not opt into edition 2024. `Cargo.lock` is committed. `cargo fmt`, `cargo check`, `cargo clippy -D warnings` and `cargo test` are the test job. No GPU test is required for the foundation shell.
219 219
220 ## 16. Synthetic test bodies
221
222 `tests/fixtures/bodies/test-body-male.glb` and `test-body-female.glb` are generated by `scripts/generate_test_bodies.py`. No external model is downloaded. Both files are self-contained glTF 2.0 binary, CC0-1.0, and labelled in `asset.extras` as test assets with `finalCharacter: false`. They are not final characters.
223
224 Each file has one skin, 19 named joints, rigid skin weights, and two clips: `walk` (1 second, arms opposite the legs) and `attack` (0.8 seconds, the right arm swings forward). Bind rotations are identity. The documented axes are +Y up and forward -Z. Male and female differ in shoulder width, hip width and hip height so a later importer can tell them apart.
225
226 `tests/test_body_assets.rs` checks that both files exist, parse as GLB, contain that skeleton, contain both clips, and byte-match a fresh run of the generator. That test is a fixture check. It is not the product importer, it does not play the clips, and it does not render. The desktop shell still reports glTF/GLB as not built and does not open these files.
227
220 228 # Appendix A — Tim's original proposal (verbatim)
221 229
222 230 The following block preserves the original Dutch text including spelling and filename placeholders. It is not a paraphrase or implementation status.
@@ -217,6 +217,14 @@ Consequences for `blue-ci-cd.yml` in this repository:
217
217
218The toolchain is Rust 1.96.0, edition 2021. Edition 2021 remains explicit; the crate does not opt into edition 2024. `Cargo.lock` is committed. `cargo fmt`, `cargo check`, `cargo clippy -D warnings` and `cargo test` are the test job. No GPU test is required for the foundation shell.
218The toolchain is Rust 1.96.0, edition 2021. Edition 2021 remains explicit; the crate does not opt into edition 2024. `Cargo.lock` is committed. `cargo fmt`, `cargo check`, `cargo clippy -D warnings` and `cargo test` are the test job. No GPU test is required for the foundation shell.
219
219
220## 16. Synthetic test bodies
221
222`tests/fixtures/bodies/test-body-male.glb` and `test-body-female.glb` are generated by `scripts/generate_test_bodies.py`. No external model is downloaded. Both files are self-contained glTF 2.0 binary, CC0-1.0, and labelled in `asset.extras` as test assets with `finalCharacter: false`. They are not final characters.
223
224Each file has one skin, 19 named joints, rigid skin weights, and two clips: `walk` (1 second, arms opposite the legs) and `attack` (0.8 seconds, the right arm swings forward). Bind rotations are identity. The documented axes are +Y up and forward -Z. Male and female differ in shoulder width, hip width and hip height so a later importer can tell them apart.
225
226`tests/test_body_assets.rs` checks that both files exist, parse as GLB, contain that skeleton, contain both clips, and byte-match a fresh run of the generator. That test is a fixture check. It is not the product importer, it does not play the clips, and it does not render. The desktop shell still reports glTF/GLB as not built and does not open these files.
227
220# Appendix A — Tim's original proposal (verbatim)
228# Appendix A — Tim's original proposal (verbatim)
221
229
222The following block preserves the original Dutch text including spelling and filename placeholders. It is not a paraphrase or implementation status.
230The following block preserves the original Dutch text including spelling and filename placeholders. It is not a paraphrase or implementation status.
scripts/generate_test_bodies.py added +476 −0
@@ -0,0 +1,476 @@
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()
@@ -0,0 +1,476 @@
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()
tests/fixtures/bodies/LICENSE added +14 −0
@@ -0,0 +1,14 @@
1 Creative Commons Legal Code
2
3 CC0 1.0 Universal
4
5 The procedurally generated meshes, skeletons and animation clips in this
6 directory (test-body-male.glb and test-body-female.glb) are dedicated to the
7 public domain under CC0 1.0 Universal by their generator,
8 scripts/generate_test_bodies.py.
9
10 https://creativecommons.org/publicdomain/zero/1.0/legalcode
11
12 The person who generated these files waives all copyright and related rights
13 in them, to the extent allowed by law. They are synthetic test assets, not
14 finished characters, and they depict no real person.
@@ -0,0 +1,14 @@
1Creative Commons Legal Code
2
3CC0 1.0 Universal
4
5The procedurally generated meshes, skeletons and animation clips in this
6directory (test-body-male.glb and test-body-female.glb) are dedicated to the
7public domain under CC0 1.0 Universal by their generator,
8scripts/generate_test_bodies.py.
9
10https://creativecommons.org/publicdomain/zero/1.0/legalcode
11
12The person who generated these files waives all copyright and related rights
13in them, to the extent allowed by law. They are synthetic test assets, not
14finished characters, and they depict no real person.
tests/fixtures/bodies/README.md added +23 −0
@@ -0,0 +1,23 @@
1 # Synthetische testlichamen
2
3 Deze twee GLB-bestanden zijn procedureel gegenereerd door `scripts/generate_test_bodies.py`. Er is geen extern model gedownload. Het zijn testassets voor latere import-, skeleton-, animatie-, render- en previewtests. Het zijn geen definitieve karakters en het programma laadt ze nog niet.
4
5 | Bestand | Inhoud |
6 |---|---|
7 | `test-body-male.glb` | Bredere schouders, smallere heupen, hoger heuppunt |
8 | `test-body-female.glb` | Smallere schouders, bredere heupen, lager heuppunt |
9
10 Beide hebben een skin met 19 joints en twee clips:
11
12 - `walk` — loopcyclus van 1 seconde, armen tegengesteld aan de benen, eerste en laatste sample gelijk
13 - `attack` — zwaai van de rechterarm naar voren, 0,8 seconde, eindigt weer in de bindpose
14
15 De bindpose heeft identiteitsrotaties. glTF-assen: +Y is omhoog, het gezicht wijst naar -Z. Elke vertex hoort met gewicht 1 bij één joint. De inverse-bindmatrix is de inverse translatie van de wereldpositie in die bindpose.
16
17 Opnieuw genereren, vanaf de repositoryroot:
18
19 ```bash
20 python3 scripts/generate_test_bodies.py
21 ```
22
23 Licentie van de gegenereerde meshes, rigs en clips: CC0-1.0. Zie `LICENSE` in deze map.
@@ -0,0 +1,23 @@
1# Synthetische testlichamen
2
3Deze twee GLB-bestanden zijn procedureel gegenereerd door `scripts/generate_test_bodies.py`. Er is geen extern model gedownload. Het zijn testassets voor latere import-, skeleton-, animatie-, render- en previewtests. Het zijn geen definitieve karakters en het programma laadt ze nog niet.
4
5| Bestand | Inhoud |
6|---|---|
7| `test-body-male.glb` | Bredere schouders, smallere heupen, hoger heuppunt |
8| `test-body-female.glb` | Smallere schouders, bredere heupen, lager heuppunt |
9
10Beide hebben een skin met 19 joints en twee clips:
11
12- `walk` — loopcyclus van 1 seconde, armen tegengesteld aan de benen, eerste en laatste sample gelijk
13- `attack` — zwaai van de rechterarm naar voren, 0,8 seconde, eindigt weer in de bindpose
14
15De bindpose heeft identiteitsrotaties. glTF-assen: +Y is omhoog, het gezicht wijst naar -Z. Elke vertex hoort met gewicht 1 bij één joint. De inverse-bindmatrix is de inverse translatie van de wereldpositie in die bindpose.
16
17Opnieuw genereren, vanaf de repositoryroot:
18
19```bash
20python3 scripts/generate_test_bodies.py
21```
22
23Licentie van de gegenereerde meshes, rigs en clips: CC0-1.0. Zie `LICENSE` in deze map.
tests/fixtures/bodies/test-body-female.glb added Binary file +0 −0

The contents of this binary file are not shown.

tests/fixtures/bodies/test-body-male.glb added Binary file +0 −0

The contents of this binary file are not shown.

tests/test_body_assets.rs added +380 −0
@@ -0,0 +1,380 @@
1 //! Validation of the synthetic male and female test bodies.
2 //!
3 //! This reads the GLB fixtures directly. It is not the product importer and
4 //! it does not render anything.
5
6 use std::path::{Path, PathBuf};
7 use std::process::Command;
8
9 use serde_json::Value;
10
11 const JOINTS: [&str; 19] = [
12 "Hips",
13 "Spine",
14 "Chest",
15 "Neck",
16 "Head",
17 "LeftShoulder",
18 "LeftUpperArm",
19 "LeftLowerArm",
20 "LeftHand",
21 "RightShoulder",
22 "RightUpperArm",
23 "RightLowerArm",
24 "RightHand",
25 "LeftUpperLeg",
26 "LeftLowerLeg",
27 "LeftFoot",
28 "RightUpperLeg",
29 "RightLowerLeg",
30 "RightFoot",
31 ];
32
33 fn repo_root() -> PathBuf {
34 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
35 }
36
37 fn fixture(sex: &str) -> PathBuf {
38 repo_root().join(format!("tests/fixtures/bodies/test-body-{sex}.glb"))
39 }
40
41 fn read_glb(path: &Path) -> (Value, Vec<u8>) {
42 let bytes = std::fs::read(path).unwrap_or_else(|err| panic!("read {}: {err}", path.display()));
43 assert!(
44 bytes.len() > 20,
45 "{} is too small to be a GLB",
46 path.display()
47 );
48 assert_eq!(&bytes[0..4], b"glTF", "{}", path.display());
49 let version = u32::from_le_bytes(bytes[4..8].try_into().unwrap());
50 let length = u32::from_le_bytes(bytes[8..12].try_into().unwrap()) as usize;
51 assert_eq!(version, 2, "GLB version");
52 assert_eq!(length, bytes.len(), "GLB length prefix");
53
54 let mut offset = 12;
55 let mut json = None;
56 let mut binary = None;
57 while offset + 8 <= bytes.len() {
58 let chunk_len = u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()) as usize;
59 let chunk_type = &bytes[offset + 4..offset + 8];
60 let start = offset + 8;
61 let end = start + chunk_len;
62 assert!(end <= bytes.len(), "chunk exceeds file");
63 let data = &bytes[start..end];
64 if chunk_type == b"JSON" {
65 let text = std::str::from_utf8(data).expect("JSON chunk utf-8");
66 json = Some(serde_json::from_str::<Value>(text.trim_end()).expect("JSON chunk"));
67 } else if chunk_type == b"BIN\0" {
68 binary = Some(data.to_vec());
69 } else {
70 panic!("unexpected GLB chunk");
71 }
72 offset = end;
73 }
74 (json.expect("JSON chunk"), binary.expect("BIN chunk"))
75 }
76
77 fn node<'a>(json: &'a Value, name: &str) -> &'a Value {
78 json["nodes"]
79 .as_array()
80 .unwrap()
81 .iter()
82 .find(|node| node["name"] == name)
83 .unwrap_or_else(|| panic!("missing node {name}"))
84 }
85
86 fn translation(json: &Value, name: &str) -> [f64; 3] {
87 let values = node(json, name)["translation"]
88 .as_array()
89 .unwrap_or_else(|| panic!("{name} has no translation"));
90 assert_eq!(values.len(), 3);
91 [
92 values[0].as_f64().unwrap(),
93 values[1].as_f64().unwrap(),
94 values[2].as_f64().unwrap(),
95 ]
96 }
97
98 fn node_index(json: &Value, name: &str) -> usize {
99 json["nodes"]
100 .as_array()
101 .unwrap()
102 .iter()
103 .position(|node| node["name"] == name)
104 .unwrap_or_else(|| panic!("missing node {name}"))
105 }
106
107 fn parent_name(json: &Value, name: &str) -> Option<String> {
108 let index = node_index(json, name);
109 json["nodes"].as_array().unwrap().iter().find_map(|node| {
110 let children = node["children"].as_array()?;
111 children
112 .iter()
113 .any(|child| child.as_u64() == Some(index as u64))
114 .then(|| node["name"].as_str().unwrap().to_string())
115 })
116 }
117
118 fn accessor_f32(json: &Value, bin: &[u8], index: usize) -> Vec<f32> {
119 let accessor = &json["accessors"][index];
120 assert_eq!(accessor["componentType"].as_u64().unwrap(), 5126);
121 let width = match accessor["type"].as_str().unwrap() {
122 "SCALAR" => 1,
123 "VEC3" => 3,
124 "VEC4" => 4,
125 "MAT4" => 16,
126 other => panic!("unsupported accessor type {other}"),
127 };
128 let count = accessor["count"].as_u64().unwrap() as usize;
129 let view = &json["bufferViews"][accessor["bufferView"].as_u64().unwrap() as usize];
130 let offset = view["byteOffset"].as_u64().unwrap_or(0) as usize
131 + accessor["byteOffset"].as_u64().unwrap_or(0) as usize;
132 let mut values = Vec::with_capacity(count * width);
133 for item in 0..count * width {
134 let start = offset + item * 4;
135 values.push(f32::from_le_bytes(
136 bin[start..start + 4].try_into().unwrap(),
137 ));
138 }
139 values
140 }
141
142 fn animation<'a>(json: &'a Value, name: &str) -> &'a Value {
143 json["animations"]
144 .as_array()
145 .unwrap()
146 .iter()
147 .find(|clip| clip["name"] == name)
148 .unwrap_or_else(|| panic!("missing animation {name}"))
149 }
150
151 fn clip_duration(json: &Value, bin: &[u8], name: &str) -> f32 {
152 let clip = animation(json, name);
153 let input = clip["samplers"][0]["input"].as_u64().unwrap() as usize;
154 let times = accessor_f32(json, bin, input);
155 assert!(times.len() >= 4, "{name} needs several samples");
156 assert!(
157 times.windows(2).all(|pair| pair[1] >= pair[0]),
158 "{name} times go backwards"
159 );
160 *times.last().unwrap() - times[0]
161 }
162
163 fn channel_targets(json: &Value, name: &str) -> Vec<String> {
164 animation(json, name)["channels"]
165 .as_array()
166 .unwrap()
167 .iter()
168 .map(|channel| {
169 let index = channel["target"]["node"].as_u64().unwrap() as usize;
170 json["nodes"][index]["name"].as_str().unwrap().to_string()
171 })
172 .collect()
173 }
174
175 fn assert_quaternions_are_unit_and_loop(json: &Value, bin: &[u8], name: &str) {
176 let clip = animation(json, name);
177 for sampler in clip["samplers"].as_array().unwrap() {
178 assert_eq!(sampler["interpolation"], "LINEAR");
179 let output = accessor_f32(json, bin, sampler["output"].as_u64().unwrap() as usize);
180 assert_eq!(output.len() % 4, 0);
181 let samples = output.len() / 4;
182 for sample in 0..samples {
183 let q = &output[sample * 4..sample * 4 + 4];
184 let length = q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3];
185 assert!(
186 (length - 1.0).abs() < 1e-4,
187 "{name} quaternion {sample} length {length}"
188 );
189 }
190 let first = &output[0..4];
191 let last = &output[output.len() - 4..];
192 for (a, b) in first.iter().zip(last) {
193 assert!((a - b).abs() < 1e-5, "{name} does not loop");
194 }
195 }
196 }
197
198 fn assert_body(json: &Value, bin: &[u8], sex: &str) {
199 let extras = &json["asset"]["extras"];
200 assert_eq!(json["asset"]["version"], "2.0");
201 assert_eq!(extras["testAsset"], true);
202 assert_eq!(extras["finalCharacter"], false);
203 assert_eq!(extras["sex"], sex);
204 assert_eq!(extras["license"], "CC0-1.0");
205 assert_eq!(extras["up"], "+Y");
206 assert_eq!(extras["forward"], "-Z");
207 let label = extras["label"].as_str().unwrap();
208 assert!(
209 label.contains("geen definitief karakter"),
210 "label must say this is not a final character: {label}"
211 );
212 assert!(
213 json["buffers"][0].get("uri").is_none(),
214 "GLB must be self-contained"
215 );
216
217 let joints = json["skins"][0]["joints"].as_array().unwrap();
218 assert_eq!(joints.len(), JOINTS.len());
219 for (expected, joint) in JOINTS.iter().zip(joints) {
220 let index = joint.as_u64().unwrap() as usize;
221 assert_eq!(json["nodes"][index]["name"], *expected);
222 }
223
224 assert_eq!(parent_name(json, "Hips"), None);
225 assert_eq!(
226 parent_name(json, "LeftUpperArm").as_deref(),
227 Some("LeftShoulder")
228 );
229 assert_eq!(
230 parent_name(json, "LeftHand").as_deref(),
231 Some("LeftLowerArm")
232 );
233 assert_eq!(
234 parent_name(json, "LeftFoot").as_deref(),
235 Some("LeftLowerLeg")
236 );
237 assert_eq!(
238 parent_name(json, "RightUpperArm").as_deref(),
239 Some("RightShoulder")
240 );
241
242 let primitive = &json["meshes"][0]["primitives"][0];
243 assert!(primitive["attributes"]["POSITION"].is_number());
244 assert!(primitive["attributes"]["JOINTS_0"].is_number());
245 assert!(primitive["attributes"]["WEIGHTS_0"].is_number());
246 let positions = accessor_f32(
247 json,
248 bin,
249 primitive["attributes"]["POSITION"].as_u64().unwrap() as usize,
250 );
251 let weights = accessor_f32(
252 json,
253 bin,
254 primitive["attributes"]["WEIGHTS_0"].as_u64().unwrap() as usize,
255 );
256 assert!(!positions.is_empty());
257 assert_eq!(weights.len(), (positions.len() / 3) * 4);
258 assert!(
259 weights.chunks(4).all(|weight| {
260 (weight[0] - 1.0).abs() < 1e-6
261 && weight[1].abs() < 1e-6
262 && weight[2].abs() < 1e-6
263 && weight[3].abs() < 1e-6
264 }),
265 "test bodies use one rigid weight per vertex"
266 );
267
268 let inverse_bind = accessor_f32(
269 json,
270 bin,
271 json["skins"][0]["inverseBindMatrices"].as_u64().unwrap() as usize,
272 );
273 assert_eq!(inverse_bind.len(), JOINTS.len() * 16);
274 for (index, name) in JOINTS.iter().enumerate() {
275 let mut world = [0.0_f64; 3];
276 let mut current = (*name).to_string();
277 for _ in 0..=JOINTS.len() {
278 let local = translation(json, &current);
279 world[0] += local[0];
280 world[1] += local[1];
281 world[2] += local[2];
282 if current == "Hips" {
283 break;
284 }
285 current = parent_name(json, &current)
286 .unwrap_or_else(|| panic!("{name} is not connected to Hips"));
287 }
288 assert_eq!(current, "Hips", "{name} is not under Hips");
289 let matrix = &inverse_bind[index * 16..index * 16 + 16];
290 for diagonal in [0, 5, 10, 15] {
291 assert!(
292 (matrix[diagonal] - 1.0).abs() < 1e-5,
293 "{name} bind rotation is not identity"
294 );
295 }
296 assert!(
297 (f64::from(matrix[12]) + world[0]).abs() < 1e-4,
298 "{name} inverse bind x"
299 );
300 assert!(
301 (f64::from(matrix[13]) + world[1]).abs() < 1e-4,
302 "{name} inverse bind y"
303 );
304 assert!(
305 (f64::from(matrix[14]) + world[2]).abs() < 1e-4,
306 "{name} inverse bind z"
307 );
308 }
309
310 let names = json["animations"]
311 .as_array()
312 .unwrap()
313 .iter()
314 .map(|clip| clip["name"].as_str().unwrap().to_string())
315 .collect::<Vec<_>>();
316 assert!(names.contains(&"walk".to_string()));
317 assert!(names.contains(&"attack".to_string()));
318 assert!((clip_duration(json, bin, "walk") - 1.0).abs() < 1e-4);
319 assert!((clip_duration(json, bin, "attack") - 0.8).abs() < 1e-4);
320 let walk = channel_targets(json, "walk");
321 for joint in [
322 "LeftUpperLeg",
323 "RightUpperLeg",
324 "LeftUpperArm",
325 "RightUpperArm",
326 ] {
327 assert!(walk.iter().any(|name| name == joint), "walk misses {joint}");
328 }
329 let attack = channel_targets(json, "attack");
330 assert!(attack.iter().any(|name| name == "RightUpperArm"));
331 assert_quaternions_are_unit_and_loop(json, bin, "walk");
332 assert_quaternions_are_unit_and_loop(json, bin, "attack");
333 }
334
335 #[test]
336 fn male_and_female_test_bodies_have_a_skeleton_and_both_clips() {
337 let (male, male_bin) = read_glb(&fixture("male"));
338 let (female, female_bin) = read_glb(&fixture("female"));
339 assert_body(&male, &male_bin, "male");
340 assert_body(&female, &female_bin, "female");
341
342 let male_shoulder = translation(&male, "LeftShoulder")[0];
343 let female_shoulder = translation(&female, "LeftShoulder")[0];
344 assert!(
345 male_shoulder < female_shoulder,
346 "male shoulders must be wider ({male_shoulder} vs {female_shoulder})"
347 );
348 assert!(translation(&male, "Hips")[1] > translation(&female, "Hips")[1]);
349 assert!(translation(&female, "LeftUpperLeg")[0] < translation(&male, "LeftUpperLeg")[0]);
350
351 let readme =
352 std::fs::read_to_string(repo_root().join("tests/fixtures/bodies/README.md")).unwrap();
353 assert!(readme.contains("geen definitieve karakters"));
354 let license =
355 std::fs::read_to_string(repo_root().join("tests/fixtures/bodies/LICENSE")).unwrap();
356 assert!(license.contains("CC0 1.0"));
357 }
358
359 #[test]
360 fn committed_test_bodies_match_the_generator() {
361 let dir = std::env::temp_dir().join(format!("paperdoll-test-bodies-{}", std::process::id()));
362 let _ = std::fs::remove_dir_all(&dir);
363 std::fs::create_dir_all(&dir).unwrap();
364 let status = Command::new("python3")
365 .arg(repo_root().join("scripts/generate_test_bodies.py"))
366 .arg("--output")
367 .arg(&dir)
368 .status()
369 .expect("python3 is required to regenerate the test bodies");
370 assert!(status.success(), "generator failed");
371 for sex in ["male", "female"] {
372 let committed = std::fs::read(fixture(sex)).unwrap();
373 let generated = std::fs::read(dir.join(format!("test-body-{sex}.glb"))).unwrap();
374 assert_eq!(
375 committed, generated,
376 "test-body-{sex}.glb drifted from scripts/generate_test_bodies.py"
377 );
378 }
379 let _ = std::fs::remove_dir_all(&dir);
380 }
@@ -0,0 +1,380 @@
1//! Validation of the synthetic male and female test bodies.
2//!
3//! This reads the GLB fixtures directly. It is not the product importer and
4//! it does not render anything.
5
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use serde_json::Value;
10
11const JOINTS: [&str; 19] = [
12 "Hips",
13 "Spine",
14 "Chest",
15 "Neck",
16 "Head",
17 "LeftShoulder",
18 "LeftUpperArm",
19 "LeftLowerArm",
20 "LeftHand",
21 "RightShoulder",
22 "RightUpperArm",
23 "RightLowerArm",
24 "RightHand",
25 "LeftUpperLeg",
26 "LeftLowerLeg",
27 "LeftFoot",
28 "RightUpperLeg",
29 "RightLowerLeg",
30 "RightFoot",
31];
32
33fn repo_root() -> PathBuf {
34 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
35}
36
37fn fixture(sex: &str) -> PathBuf {
38 repo_root().join(format!("tests/fixtures/bodies/test-body-{sex}.glb"))
39}
40
41fn read_glb(path: &Path) -> (Value, Vec<u8>) {
42 let bytes = std::fs::read(path).unwrap_or_else(|err| panic!("read {}: {err}", path.display()));
43 assert!(
44 bytes.len() > 20,
45 "{} is too small to be a GLB",
46 path.display()
47 );
48 assert_eq!(&bytes[0..4], b"glTF", "{}", path.display());
49 let version = u32::from_le_bytes(bytes[4..8].try_into().unwrap());
50 let length = u32::from_le_bytes(bytes[8..12].try_into().unwrap()) as usize;
51 assert_eq!(version, 2, "GLB version");
52 assert_eq!(length, bytes.len(), "GLB length prefix");
53
54 let mut offset = 12;
55 let mut json = None;
56 let mut binary = None;
57 while offset + 8 <= bytes.len() {
58 let chunk_len = u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()) as usize;
59 let chunk_type = &bytes[offset + 4..offset + 8];
60 let start = offset + 8;
61 let end = start + chunk_len;
62 assert!(end <= bytes.len(), "chunk exceeds file");
63 let data = &bytes[start..end];
64 if chunk_type == b"JSON" {
65 let text = std::str::from_utf8(data).expect("JSON chunk utf-8");
66 json = Some(serde_json::from_str::<Value>(text.trim_end()).expect("JSON chunk"));
67 } else if chunk_type == b"BIN\0" {
68 binary = Some(data.to_vec());
69 } else {
70 panic!("unexpected GLB chunk");
71 }
72 offset = end;
73 }
74 (json.expect("JSON chunk"), binary.expect("BIN chunk"))
75}
76
77fn node<'a>(json: &'a Value, name: &str) -> &'a Value {
78 json["nodes"]
79 .as_array()
80 .unwrap()
81 .iter()
82 .find(|node| node["name"] == name)
83 .unwrap_or_else(|| panic!("missing node {name}"))
84}
85
86fn translation(json: &Value, name: &str) -> [f64; 3] {
87 let values = node(json, name)["translation"]
88 .as_array()
89 .unwrap_or_else(|| panic!("{name} has no translation"));
90 assert_eq!(values.len(), 3);
91 [
92 values[0].as_f64().unwrap(),
93 values[1].as_f64().unwrap(),
94 values[2].as_f64().unwrap(),
95 ]
96}
97
98fn node_index(json: &Value, name: &str) -> usize {
99 json["nodes"]
100 .as_array()
101 .unwrap()
102 .iter()
103 .position(|node| node["name"] == name)
104 .unwrap_or_else(|| panic!("missing node {name}"))
105}
106
107fn parent_name(json: &Value, name: &str) -> Option<String> {
108 let index = node_index(json, name);
109 json["nodes"].as_array().unwrap().iter().find_map(|node| {
110 let children = node["children"].as_array()?;
111 children
112 .iter()
113 .any(|child| child.as_u64() == Some(index as u64))
114 .then(|| node["name"].as_str().unwrap().to_string())
115 })
116}
117
118fn accessor_f32(json: &Value, bin: &[u8], index: usize) -> Vec<f32> {
119 let accessor = &json["accessors"][index];
120 assert_eq!(accessor["componentType"].as_u64().unwrap(), 5126);
121 let width = match accessor["type"].as_str().unwrap() {
122 "SCALAR" => 1,
123 "VEC3" => 3,
124 "VEC4" => 4,
125 "MAT4" => 16,
126 other => panic!("unsupported accessor type {other}"),
127 };
128 let count = accessor["count"].as_u64().unwrap() as usize;
129 let view = &json["bufferViews"][accessor["bufferView"].as_u64().unwrap() as usize];
130 let offset = view["byteOffset"].as_u64().unwrap_or(0) as usize
131 + accessor["byteOffset"].as_u64().unwrap_or(0) as usize;
132 let mut values = Vec::with_capacity(count * width);
133 for item in 0..count * width {
134 let start = offset + item * 4;
135 values.push(f32::from_le_bytes(
136 bin[start..start + 4].try_into().unwrap(),
137 ));
138 }
139 values
140}
141
142fn animation<'a>(json: &'a Value, name: &str) -> &'a Value {
143 json["animations"]
144 .as_array()
145 .unwrap()
146 .iter()
147 .find(|clip| clip["name"] == name)
148 .unwrap_or_else(|| panic!("missing animation {name}"))
149}
150
151fn clip_duration(json: &Value, bin: &[u8], name: &str) -> f32 {
152 let clip = animation(json, name);
153 let input = clip["samplers"][0]["input"].as_u64().unwrap() as usize;
154 let times = accessor_f32(json, bin, input);
155 assert!(times.len() >= 4, "{name} needs several samples");
156 assert!(
157 times.windows(2).all(|pair| pair[1] >= pair[0]),
158 "{name} times go backwards"
159 );
160 *times.last().unwrap() - times[0]
161}
162
163fn channel_targets(json: &Value, name: &str) -> Vec<String> {
164 animation(json, name)["channels"]
165 .as_array()
166 .unwrap()
167 .iter()
168 .map(|channel| {
169 let index = channel["target"]["node"].as_u64().unwrap() as usize;
170 json["nodes"][index]["name"].as_str().unwrap().to_string()
171 })
172 .collect()
173}
174
175fn assert_quaternions_are_unit_and_loop(json: &Value, bin: &[u8], name: &str) {
176 let clip = animation(json, name);
177 for sampler in clip["samplers"].as_array().unwrap() {
178 assert_eq!(sampler["interpolation"], "LINEAR");
179 let output = accessor_f32(json, bin, sampler["output"].as_u64().unwrap() as usize);
180 assert_eq!(output.len() % 4, 0);
181 let samples = output.len() / 4;
182 for sample in 0..samples {
183 let q = &output[sample * 4..sample * 4 + 4];
184 let length = q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3];
185 assert!(
186 (length - 1.0).abs() < 1e-4,
187 "{name} quaternion {sample} length {length}"
188 );
189 }
190 let first = &output[0..4];
191 let last = &output[output.len() - 4..];
192 for (a, b) in first.iter().zip(last) {
193 assert!((a - b).abs() < 1e-5, "{name} does not loop");
194 }
195 }
196}
197
198fn assert_body(json: &Value, bin: &[u8], sex: &str) {
199 let extras = &json["asset"]["extras"];
200 assert_eq!(json["asset"]["version"], "2.0");
201 assert_eq!(extras["testAsset"], true);
202 assert_eq!(extras["finalCharacter"], false);
203 assert_eq!(extras["sex"], sex);
204 assert_eq!(extras["license"], "CC0-1.0");
205 assert_eq!(extras["up"], "+Y");
206 assert_eq!(extras["forward"], "-Z");
207 let label = extras["label"].as_str().unwrap();
208 assert!(
209 label.contains("geen definitief karakter"),
210 "label must say this is not a final character: {label}"
211 );
212 assert!(
213 json["buffers"][0].get("uri").is_none(),
214 "GLB must be self-contained"
215 );
216
217 let joints = json["skins"][0]["joints"].as_array().unwrap();
218 assert_eq!(joints.len(), JOINTS.len());
219 for (expected, joint) in JOINTS.iter().zip(joints) {
220 let index = joint.as_u64().unwrap() as usize;
221 assert_eq!(json["nodes"][index]["name"], *expected);
222 }
223
224 assert_eq!(parent_name(json, "Hips"), None);
225 assert_eq!(
226 parent_name(json, "LeftUpperArm").as_deref(),
227 Some("LeftShoulder")
228 );
229 assert_eq!(
230 parent_name(json, "LeftHand").as_deref(),
231 Some("LeftLowerArm")
232 );
233 assert_eq!(
234 parent_name(json, "LeftFoot").as_deref(),
235 Some("LeftLowerLeg")
236 );
237 assert_eq!(
238 parent_name(json, "RightUpperArm").as_deref(),
239 Some("RightShoulder")
240 );
241
242 let primitive = &json["meshes"][0]["primitives"][0];
243 assert!(primitive["attributes"]["POSITION"].is_number());
244 assert!(primitive["attributes"]["JOINTS_0"].is_number());
245 assert!(primitive["attributes"]["WEIGHTS_0"].is_number());
246 let positions = accessor_f32(
247 json,
248 bin,
249 primitive["attributes"]["POSITION"].as_u64().unwrap() as usize,
250 );
251 let weights = accessor_f32(
252 json,
253 bin,
254 primitive["attributes"]["WEIGHTS_0"].as_u64().unwrap() as usize,
255 );
256 assert!(!positions.is_empty());
257 assert_eq!(weights.len(), (positions.len() / 3) * 4);
258 assert!(
259 weights.chunks(4).all(|weight| {
260 (weight[0] - 1.0).abs() < 1e-6
261 && weight[1].abs() < 1e-6
262 && weight[2].abs() < 1e-6
263 && weight[3].abs() < 1e-6
264 }),
265 "test bodies use one rigid weight per vertex"
266 );
267
268 let inverse_bind = accessor_f32(
269 json,
270 bin,
271 json["skins"][0]["inverseBindMatrices"].as_u64().unwrap() as usize,
272 );
273 assert_eq!(inverse_bind.len(), JOINTS.len() * 16);
274 for (index, name) in JOINTS.iter().enumerate() {
275 let mut world = [0.0_f64; 3];
276 let mut current = (*name).to_string();
277 for _ in 0..=JOINTS.len() {
278 let local = translation(json, &current);
279 world[0] += local[0];
280 world[1] += local[1];
281 world[2] += local[2];
282 if current == "Hips" {
283 break;
284 }
285 current = parent_name(json, &current)
286 .unwrap_or_else(|| panic!("{name} is not connected to Hips"));
287 }
288 assert_eq!(current, "Hips", "{name} is not under Hips");
289 let matrix = &inverse_bind[index * 16..index * 16 + 16];
290 for diagonal in [0, 5, 10, 15] {
291 assert!(
292 (matrix[diagonal] - 1.0).abs() < 1e-5,
293 "{name} bind rotation is not identity"
294 );
295 }
296 assert!(
297 (f64::from(matrix[12]) + world[0]).abs() < 1e-4,
298 "{name} inverse bind x"
299 );
300 assert!(
301 (f64::from(matrix[13]) + world[1]).abs() < 1e-4,
302 "{name} inverse bind y"
303 );
304 assert!(
305 (f64::from(matrix[14]) + world[2]).abs() < 1e-4,
306 "{name} inverse bind z"
307 );
308 }
309
310 let names = json["animations"]
311 .as_array()
312 .unwrap()
313 .iter()
314 .map(|clip| clip["name"].as_str().unwrap().to_string())
315 .collect::<Vec<_>>();
316 assert!(names.contains(&"walk".to_string()));
317 assert!(names.contains(&"attack".to_string()));
318 assert!((clip_duration(json, bin, "walk") - 1.0).abs() < 1e-4);
319 assert!((clip_duration(json, bin, "attack") - 0.8).abs() < 1e-4);
320 let walk = channel_targets(json, "walk");
321 for joint in [
322 "LeftUpperLeg",
323 "RightUpperLeg",
324 "LeftUpperArm",
325 "RightUpperArm",
326 ] {
327 assert!(walk.iter().any(|name| name == joint), "walk misses {joint}");
328 }
329 let attack = channel_targets(json, "attack");
330 assert!(attack.iter().any(|name| name == "RightUpperArm"));
331 assert_quaternions_are_unit_and_loop(json, bin, "walk");
332 assert_quaternions_are_unit_and_loop(json, bin, "attack");
333}
334
335#[test]
336fn male_and_female_test_bodies_have_a_skeleton_and_both_clips() {
337 let (male, male_bin) = read_glb(&fixture("male"));
338 let (female, female_bin) = read_glb(&fixture("female"));
339 assert_body(&male, &male_bin, "male");
340 assert_body(&female, &female_bin, "female");
341
342 let male_shoulder = translation(&male, "LeftShoulder")[0];
343 let female_shoulder = translation(&female, "LeftShoulder")[0];
344 assert!(
345 male_shoulder < female_shoulder,
346 "male shoulders must be wider ({male_shoulder} vs {female_shoulder})"
347 );
348 assert!(translation(&male, "Hips")[1] > translation(&female, "Hips")[1]);
349 assert!(translation(&female, "LeftUpperLeg")[0] < translation(&male, "LeftUpperLeg")[0]);
350
351 let readme =
352 std::fs::read_to_string(repo_root().join("tests/fixtures/bodies/README.md")).unwrap();
353 assert!(readme.contains("geen definitieve karakters"));
354 let license =
355 std::fs::read_to_string(repo_root().join("tests/fixtures/bodies/LICENSE")).unwrap();
356 assert!(license.contains("CC0 1.0"));
357}
358
359#[test]
360fn committed_test_bodies_match_the_generator() {
361 let dir = std::env::temp_dir().join(format!("paperdoll-test-bodies-{}", std::process::id()));
362 let _ = std::fs::remove_dir_all(&dir);
363 std::fs::create_dir_all(&dir).unwrap();
364 let status = Command::new("python3")
365 .arg(repo_root().join("scripts/generate_test_bodies.py"))
366 .arg("--output")
367 .arg(&dir)
368 .status()
369 .expect("python3 is required to regenerate the test bodies");
370 assert!(status.success(), "generator failed");
371 for sex in ["male", "female"] {
372 let committed = std::fs::read(fixture(sex)).unwrap();
373 let generated = std::fs::read(dir.join(format!("test-body-{sex}.glb"))).unwrap();
374 assert_eq!(
375 committed, generated,
376 "test-body-{sex}.glb drifted from scripts/generate_test_bodies.py"
377 );
378 }
379 let _ = std::fs::remove_dir_all(&dir);
380}