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>
@@ -0,0 +1,2 @@
1
*.glb binary
2
*.png binary
@@ -0,0 +1,2 @@1
*.glb binary2
*.png binary@@ -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]]1093
name = "itoa"1094
version = "1.0.18"1095
source = "registry+https://github.com/rust-lang/crates.io-index"1096
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"1097
1092
[[package]]1098
[[package]]1093
name = "jni"1099
name = "jni"1094
version = "0.22.4"1100
version = "0.22.4"@@ -1738,6 +1744,7 @@ version = "0.1.0"1738
dependencies = [1744
dependencies = [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]]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
[[package]]2086
[[package]]2067
name = "shlex"2087
name = "shlex"2068
version = "2.0.1"2088
version = "2.0.1"@@ -3172,3 +3192,9 @@ name = "zlib-rs"3172
version = "0.6.8"3192
version = "0.6.8"3173
source = "registry+https://github.com/rust-lang/crates.io-index"3193
source = "registry+https://github.com/rust-lang/crates.io-index"3174
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"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"@@ -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"6
publish = false6
publish = false7
description = "Foundation shell for the Paperdoll Renderer. Import and rendering are not implemented."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
[dependencies]12
[dependencies]10
eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }13
eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }11
libloading = "0.8"14
libloading = "0.8"@@ -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 run19
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
## Testlichamen23
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
```bash27
python3 scripts/generate_test_bodies.py28
```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 doet32
## Wat deze build niet doet23
33
24
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.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.@@ -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
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.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 bodies221
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
# Appendix A — Tim's original proposal (verbatim)228
# Appendix A — Tim's original proposal (verbatim)221
229
222
The following block preserves the original Dutch text including spelling and filename placeholders. It is not a paraphrase or implementation status.230
The following block preserves the original Dutch text including spelling and filename placeholders. It is not a paraphrase or implementation status.@@ -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 python32
"""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.py11
python3 scripts/generate_test_bodies.py --output /tmp/bodies12
"""13
14
from __future__ import annotations15
16
import argparse17
import json18
import math19
import struct20
from pathlib import Path21
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 spec104
105
106
def quat_x(degrees: float) -> tuple[float, float, float, float]:107
half = math.radians(degrees) * 0.5108
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.5113
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.0153
seen = 0154
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 = parent160
seen += 1161
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 = center177
base = len(positions) // 3178
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), # -Z195
(5, 4, 7, 6), # +Z196
(4, 0, 3, 7), # -X197
(1, 5, 6, 2), # +X198
(3, 2, 6, 7), # +Y199
(4, 5, 1, 0), # -Y200
]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 * vy210
ny = uz * vx - ux * vz211
nz = ux * vy - uy * vx212
mx = (p0[0] + p1[0] + p2[0]) / 3.0 - cx213
my = (p0[1] + p1[1] + p2[1]) / 3.0 - cy214
mz = (p0[2] + p1[2] + p2[2]) / 3.0 - cz215
if nx * mx + ny * my + nz * mz < 0.0:216
i1, i2 = i2, i1217
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"] = minimum251
accessor["max"] = maximum252
self.accessors.append(accessor)253
return len(self.accessors) - 1254
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) - 1270
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, maximum276
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"] = children297
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
continue344
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
continue434
if values[0] != values[-1]:435
return True436
return False437
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 = binary447
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_chunk454
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,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 @@1
Creative Commons Legal Code2
3
CC0 1.0 Universal4
5
The procedurally generated meshes, skeletons and animation clips in this6
directory (test-body-male.glb and test-body-female.glb) are dedicated to the7
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/legalcode11
12
The person who generated these files waives all copyright and related rights13
in them, to the extent allowed by law. They are synthetic test assets, not14
finished characters, and they depict no real person.@@ -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 testlichamen2
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 gelijk13
- `attack` — zwaai van de rechterarm naar voren, 0,8 seconde, eindigt weer in de bindpose14
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
```bash20
python3 scripts/generate_test_bodies.py21
```22
23
Licentie van de gegenereerde meshes, rigs en clips: CC0-1.0. Zie `LICENSE` in deze map.The contents of this binary file are not shown.
The contents of this binary file are not shown.
@@ -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, ¤t);
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, ¤t)
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 and4
//! 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
children112
.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 usize131
+ 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
values140
}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-6261
&& weight[1].abs() < 1e-6262
&& weight[2].abs() < 1e-6263
&& weight[3].abs() < 1e-6264
}),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, ¤t);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, ¤t)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
}