BlueGit

golden-age paperdoll-renderer

Display and export static OBJ / unskinned glTF/FBX bodies

Bodies without a skin load as BodyRig::Static through load_body_file:
OBJ+MTL and skinless FBX via the rigid equipment readers (normalized once),
glTF without skins with node world transforms baked once (mirror flips
winding). No skeleton or clips are invented: live view and production draw
the stored pose, the batch renders clip000-bind x 36 views with the same
four maps, the pivot falls back to center Y (manual pivot still wins).
Clip picker/Play, bone tree and equipment render are disabled with an
explanation; production refuses equipment jobs on a static body.
Fixtures from scripts/generate_static_bodies.py, tests/static_body.rs and
GPU proof examples/static_body_fixture.rs.

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 7 days ago

Hash
74061a3ab062f42f46ad06eb014bb115c78c5690
Parent
f7bf41c3
19 files changed +2207 −128
README.md +15 −3
@@ -19,7 +19,7 @@ Het startvenster biedt **Project laden** en **Nieuw project**, beide met een sys
19 19
20 20 De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert een model. Een extern bestand wordt naar de projectmap gekopieerd; bij een bestaande naam krijgt de kopie een eigen naam. Modellen met bijbestanden kun je met hun relatieve mappenstructuur in de projectmap zetten en daar selecteren. **Importoverzicht** toont meshes, materialen, textures, joints en clips van glTF/GLB, FBX en OBJ+MTL. FBX-lichamen worden bij het laden één keer genormaliseerd (rechtshandig Y-up, meters) en daarna net als glTF/GLB in de live 3D-preview en de productierender getekend, met al hun animatiestacks als clips.
21 21
22 **Open man / Open vrouw** opent de body. Een geskind glTF/GLB- of FBX-model is in de live preview zichtbaar:
22 **Open man / Open vrouw** opent de body. Een geskind glTF/GLB- of FBX-model, of een statisch lichaam (zie hieronder), is in de live preview zichtbaar:
23 23
24 24 - Sleep om te orbiten. **Vast renderkader tonen** toont het gele orthografische exportkader; stel per man/vrouw **Vaste renderhoogte**, **Centrum Y** (beeldcentrum) en **Pivot Y** (orbitpunt) in en sla op. Zonder handmatige pivot gebruikt de editor de rustpose-nekhoogte (of een fallback met diagnose); **Auto nek** wist een handmatige override. Body en equipment delen exact dezelfde camera. Uitvinken geeft vrije perspectief-preview met scrollzoom; die zoom verandert de export niet. Een echte 24-bits depthbuffer houdt voorste oppervlakken vooraan.
25 25 - Kies een animatieclip en gebruik **Pauze / Speel**.
@@ -30,6 +30,16 @@ De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert
30 30
31 31 ![Bodyweergave met animatie- en camerabediening](docs/screenshots/body-live.png)
32 32
33 ### Statische lichamen (OBJ, glTF/FBX zonder skin)
34
35 Een lichaam zonder skin kan ook als man- of vrouwmodel gekozen worden: **OBJ+MTL** (met PNG-textures naast het model), **glTF/GLB zonder `skins`** en **FBX zonder skin deformer**. Zo'n lichaam wordt getoond en gerenderd zoals het is opgeslagen, zonder skelet of animatie:
36
37 - OBJ en FBX lopen door dezelfde rigide lezer als equipment (FBX met dezelfde as-/eenheidsnormalisatie, één keer); bij glTF worden de node-transformaties van de scène één keer in de geometrie verwerkt (gespiegelde transformaties draaien de driehoeksvolgorde om). Animaties in een glTF zonder skin worden niet toegepast; de melding noemt hoeveel.
38 - De bodyweergave meldt dat het lichaam statisch is. De animatiekeuze en **Speel/Pauze** zijn uitgeschakeld.
39 - **Pivot Y:** zonder nekbot gebruikt de camera **Centrum Y** als orbitpunt (diagnose *Statisch lichaam zonder skeleton*); een handmatige **Pivot Y** werkt zoals bij geskinde lichamen. Vaste renderhoogte/zoom per lichaam blijft gelijk.
40 - **Render alle hoeken** maakt één bindpose-sample `clip000-bind` × 36 hoeken met dezelfde vier maps (diffuse als onbelichte albedo, normal, depth, specular) op doelresolutie; de set opent via `export_set` en de 2D-preview.
41 - Equipment heeft een bot nodig: in de equipment-editor zijn **Skeleton / botten** en **Render equipment · alle hoeken** uitgeschakeld met uitleg, en de live view tekent geen equipment op een statisch lichaam. Een equipmentrender op een statisch lichaam wordt geweigerd.
42
33 43 ## 2D-preview
34 44
35 45 Vanuit **Body** of de bodyweergave opent **2D-preview** de gerenderde compositor over voltooide schema-3 exportsets in `renders/`:
@@ -116,7 +126,7 @@ let (maps, lit_rgba) = compose_relight_and_rim(&layers, &Light::default(), &RimP
116 126 ```
117 127
118 128
119 Highres is exact 8× beide assen; batch-targets zijn momenteel **1–256 pixels per as** (highres max 2048). Grotere projectinstellingen geven een duidelijke renderfout, geen stil verkleinde supersampling. Maximaal 500000 PNGs / 16 GiB geschatte ongecomprimeerde targetdata per run; output vereist hardlinks. Productie rendert de eerste skinned glTF/GLB-mesh met driehoekprimitieven, basiskleur/8-bit albedotextures en vertexkleuren. Een FBX-lichaam loopt via de genormaliseerde scene (`fbx_scene`) door dezelfde batch: alle animatiestacks × frames × 36 hoeken, dezelfde vaste camera, nekpivot en vaste zoom per lichaam, en dezelfde vier maps. Equipment hangt op de geanimeerde botten van dat FBX-skelet. Morph targets, tangent-normaltextures, volledige PBR en willekeurige transparante lagen zijn niet ondersteund. Live placement blijft een belichte, vereenvoudigde weergave.
129 Highres is exact 8× beide assen; batch-targets zijn momenteel **1–256 pixels per as** (highres max 2048). Grotere projectinstellingen geven een duidelijke renderfout, geen stil verkleinde supersampling. Maximaal 500000 PNGs / 16 GiB geschatte ongecomprimeerde targetdata per run; output vereist hardlinks. Productie rendert de eerste skinned glTF/GLB-mesh met driehoekprimitieven, basiskleur/8-bit albedotextures en vertexkleuren. Een FBX-lichaam loopt via de genormaliseerde scene (`fbx_scene`) door dezelfde batch: alle animatiestacks × frames × 36 hoeken, dezelfde vaste camera, nekpivot en vaste zoom per lichaam, en dezelfde vier maps. Equipment hangt op de geanimeerde botten van dat FBX-skelet. Statische lichamen renderen één bindpose-sample per hoek (zie *Statische lichamen*). Live placement blijft een belichte, vereenvoudigde weergave.
120 130
121 131 ## Openen met
122 132
@@ -142,6 +152,8 @@ xvfb-run -a cargo run --release --example resume_fixture --locked -- resume /tmp
142 152 cargo run --release --example resume_fixture --locked -- verify /tmp/resume
143 153 # FBX op echte GPU: live view (lichaam + FBX-zwaard) en volledige productie van body- en equipmentset
144 154 xvfb-run -a cargo run --example fbx_live_fixture --locked -- /tmp/fbx-live /tmp/fbx-live-proof 32 48 4
155 # Statische lichamen op echte GPU: OBJ-man en glTF-vrouw zonder skin, live view, equipment-weigering, productie en export_set
156 xvfb-run -a -s '-screen 0 1280x1024x24' cargo run --example static_body_fixture --locked -- /tmp/static-body /tmp/static-body-proof 32 48 4
145 157 ```
146 158
147 De testlichamen komen uit `scripts/generate_test_bodies.py`. Het FBX-testbestand wordt met Blender via `scripts/convert_test_body_to_fbx.py` gemaakt. De rigide FBX-zwaarden (`sword-fbx.fbx` met ingebedde PNG, `sword-fbx-external.fbx` met PNG ernaast) komen uit `scripts/make_fbx_equipment_fixture.py` (Blender, Z-up geëxporteerd).
159 De testlichamen komen uit `scripts/generate_test_bodies.py`. Het FBX-testbestand wordt met Blender via `scripts/convert_test_body_to_fbx.py` gemaakt. De rigide FBX-zwaarden (`sword-fbx.fbx` met ingebedde PNG, `sword-fbx-external.fbx` met PNG ernaast) komen uit `scripts/make_fbx_equipment_fixture.py` (Blender, Z-up geëxporteerd). De statische lichamen in `tests/fixtures/bodies/static/` (OBJ+MTL+PNG en een GLB zonder skin) komen uit `scripts/generate_static_bodies.py`.
@@ -19,7 +19,7 @@ Het startvenster biedt **Project laden** en **Nieuw project**, beide met een sys
19
19
20De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert een model. Een extern bestand wordt naar de projectmap gekopieerd; bij een bestaande naam krijgt de kopie een eigen naam. Modellen met bijbestanden kun je met hun relatieve mappenstructuur in de projectmap zetten en daar selecteren. **Importoverzicht** toont meshes, materialen, textures, joints en clips van glTF/GLB, FBX en OBJ+MTL. FBX-lichamen worden bij het laden één keer genormaliseerd (rechtshandig Y-up, meters) en daarna net als glTF/GLB in de live 3D-preview en de productierender getekend, met al hun animatiestacks als clips.
20De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert een model. Een extern bestand wordt naar de projectmap gekopieerd; bij een bestaande naam krijgt de kopie een eigen naam. Modellen met bijbestanden kun je met hun relatieve mappenstructuur in de projectmap zetten en daar selecteren. **Importoverzicht** toont meshes, materialen, textures, joints en clips van glTF/GLB, FBX en OBJ+MTL. FBX-lichamen worden bij het laden één keer genormaliseerd (rechtshandig Y-up, meters) en daarna net als glTF/GLB in de live 3D-preview en de productierender getekend, met al hun animatiestacks als clips.
21
21
22**Open man / Open vrouw** opent de body. Een geskind glTF/GLB- of FBX-model is in de live preview zichtbaar:
22**Open man / Open vrouw** opent de body. Een geskind glTF/GLB- of FBX-model, of een statisch lichaam (zie hieronder), is in de live preview zichtbaar:
23
23
24- Sleep om te orbiten. **Vast renderkader tonen** toont het gele orthografische exportkader; stel per man/vrouw **Vaste renderhoogte**, **Centrum Y** (beeldcentrum) en **Pivot Y** (orbitpunt) in en sla op. Zonder handmatige pivot gebruikt de editor de rustpose-nekhoogte (of een fallback met diagnose); **Auto nek** wist een handmatige override. Body en equipment delen exact dezelfde camera. Uitvinken geeft vrije perspectief-preview met scrollzoom; die zoom verandert de export niet. Een echte 24-bits depthbuffer houdt voorste oppervlakken vooraan.
24- Sleep om te orbiten. **Vast renderkader tonen** toont het gele orthografische exportkader; stel per man/vrouw **Vaste renderhoogte**, **Centrum Y** (beeldcentrum) en **Pivot Y** (orbitpunt) in en sla op. Zonder handmatige pivot gebruikt de editor de rustpose-nekhoogte (of een fallback met diagnose); **Auto nek** wist een handmatige override. Body en equipment delen exact dezelfde camera. Uitvinken geeft vrije perspectief-preview met scrollzoom; die zoom verandert de export niet. Een echte 24-bits depthbuffer houdt voorste oppervlakken vooraan.
25- Kies een animatieclip en gebruik **Pauze / Speel**.
25- Kies een animatieclip en gebruik **Pauze / Speel**.
@@ -30,6 +30,16 @@ De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert
30
30
31![Bodyweergave met animatie- en camerabediening](docs/screenshots/body-live.png)
31![Bodyweergave met animatie- en camerabediening](docs/screenshots/body-live.png)
32
32
33### Statische lichamen (OBJ, glTF/FBX zonder skin)
34
35Een lichaam zonder skin kan ook als man- of vrouwmodel gekozen worden: **OBJ+MTL** (met PNG-textures naast het model), **glTF/GLB zonder `skins`** en **FBX zonder skin deformer**. Zo'n lichaam wordt getoond en gerenderd zoals het is opgeslagen, zonder skelet of animatie:
36
37- OBJ en FBX lopen door dezelfde rigide lezer als equipment (FBX met dezelfde as-/eenheidsnormalisatie, één keer); bij glTF worden de node-transformaties van de scène één keer in de geometrie verwerkt (gespiegelde transformaties draaien de driehoeksvolgorde om). Animaties in een glTF zonder skin worden niet toegepast; de melding noemt hoeveel.
38- De bodyweergave meldt dat het lichaam statisch is. De animatiekeuze en **Speel/Pauze** zijn uitgeschakeld.
39- **Pivot Y:** zonder nekbot gebruikt de camera **Centrum Y** als orbitpunt (diagnose *Statisch lichaam zonder skeleton*); een handmatige **Pivot Y** werkt zoals bij geskinde lichamen. Vaste renderhoogte/zoom per lichaam blijft gelijk.
40- **Render alle hoeken** maakt één bindpose-sample `clip000-bind` × 36 hoeken met dezelfde vier maps (diffuse als onbelichte albedo, normal, depth, specular) op doelresolutie; de set opent via `export_set` en de 2D-preview.
41- Equipment heeft een bot nodig: in de equipment-editor zijn **Skeleton / botten** en **Render equipment · alle hoeken** uitgeschakeld met uitleg, en de live view tekent geen equipment op een statisch lichaam. Een equipmentrender op een statisch lichaam wordt geweigerd.
42
33## 2D-preview
43## 2D-preview
34
44
35Vanuit **Body** of de bodyweergave opent **2D-preview** de gerenderde compositor over voltooide schema-3 exportsets in `renders/`:
45Vanuit **Body** of de bodyweergave opent **2D-preview** de gerenderde compositor over voltooide schema-3 exportsets in `renders/`:
@@ -116,7 +126,7 @@ let (maps, lit_rgba) = compose_relight_and_rim(&layers, &Light::default(), &RimP
116```
126```
117
127
118
128
119Highres is exact 8× beide assen; batch-targets zijn momenteel **1–256 pixels per as** (highres max 2048). Grotere projectinstellingen geven een duidelijke renderfout, geen stil verkleinde supersampling. Maximaal 500000 PNGs / 16 GiB geschatte ongecomprimeerde targetdata per run; output vereist hardlinks. Productie rendert de eerste skinned glTF/GLB-mesh met driehoekprimitieven, basiskleur/8-bit albedotextures en vertexkleuren. Een FBX-lichaam loopt via de genormaliseerde scene (`fbx_scene`) door dezelfde batch: alle animatiestacks × frames × 36 hoeken, dezelfde vaste camera, nekpivot en vaste zoom per lichaam, en dezelfde vier maps. Equipment hangt op de geanimeerde botten van dat FBX-skelet. Morph targets, tangent-normaltextures, volledige PBR en willekeurige transparante lagen zijn niet ondersteund. Live placement blijft een belichte, vereenvoudigde weergave.
129Highres is exact 8× beide assen; batch-targets zijn momenteel **1–256 pixels per as** (highres max 2048). Grotere projectinstellingen geven een duidelijke renderfout, geen stil verkleinde supersampling. Maximaal 500000 PNGs / 16 GiB geschatte ongecomprimeerde targetdata per run; output vereist hardlinks. Productie rendert de eerste skinned glTF/GLB-mesh met driehoekprimitieven, basiskleur/8-bit albedotextures en vertexkleuren. Een FBX-lichaam loopt via de genormaliseerde scene (`fbx_scene`) door dezelfde batch: alle animatiestacks × frames × 36 hoeken, dezelfde vaste camera, nekpivot en vaste zoom per lichaam, en dezelfde vier maps. Equipment hangt op de geanimeerde botten van dat FBX-skelet. Statische lichamen renderen één bindpose-sample per hoek (zie *Statische lichamen*). Live placement blijft een belichte, vereenvoudigde weergave.
120
130
121## Openen met
131## Openen met
122
132
@@ -142,6 +152,8 @@ xvfb-run -a cargo run --release --example resume_fixture --locked -- resume /tmp
142cargo run --release --example resume_fixture --locked -- verify /tmp/resume
152cargo run --release --example resume_fixture --locked -- verify /tmp/resume
143# FBX op echte GPU: live view (lichaam + FBX-zwaard) en volledige productie van body- en equipmentset
153# FBX op echte GPU: live view (lichaam + FBX-zwaard) en volledige productie van body- en equipmentset
144xvfb-run -a cargo run --example fbx_live_fixture --locked -- /tmp/fbx-live /tmp/fbx-live-proof 32 48 4
154xvfb-run -a cargo run --example fbx_live_fixture --locked -- /tmp/fbx-live /tmp/fbx-live-proof 32 48 4
155# Statische lichamen op echte GPU: OBJ-man en glTF-vrouw zonder skin, live view, equipment-weigering, productie en export_set
156xvfb-run -a -s '-screen 0 1280x1024x24' cargo run --example static_body_fixture --locked -- /tmp/static-body /tmp/static-body-proof 32 48 4
145```
157```
146
158
147De testlichamen komen uit `scripts/generate_test_bodies.py`. Het FBX-testbestand wordt met Blender via `scripts/convert_test_body_to_fbx.py` gemaakt. De rigide FBX-zwaarden (`sword-fbx.fbx` met ingebedde PNG, `sword-fbx-external.fbx` met PNG ernaast) komen uit `scripts/make_fbx_equipment_fixture.py` (Blender, Z-up geëxporteerd).
159De testlichamen komen uit `scripts/generate_test_bodies.py`. Het FBX-testbestand wordt met Blender via `scripts/convert_test_body_to_fbx.py` gemaakt. De rigide FBX-zwaarden (`sword-fbx.fbx` met ingebedde PNG, `sword-fbx-external.fbx` met PNG ernaast) komen uit `scripts/make_fbx_equipment_fixture.py` (Blender, Z-up geëxporteerd). De statische lichamen in `tests/fixtures/bodies/static/` (OBJ+MTL+PNG en een GLB zonder skin) komen uit `scripts/generate_static_bodies.py`.
design.md +3 −1
@@ -72,7 +72,7 @@ FBX is read with the `ufbx` crate 0.11.5. That crate compiles the MIT-licensed u
72 72 | Format | Mesh | Materials/textures | Skeleton | Animation | Intended role / status |
73 73 |---|---|---|---|---|---|
74 74 | glTF 2.0 .gltf/.glb | Yes | PBR metallic/roughness and referenced/embedded textures | Skins/joints | Node/skeletal clips; morph support must be tested | Read by the `gltf` crate in this build: mesh, materials, textures, joints and clips. The live preview draws the skinned body mesh. |
75 | OBJ + MTL + texture files | Yes | MTL subset with explicit conversion and relative textures | No | No skeletal clips | Read by `tobj` in this build as a static mesh. The import states that OBJ has no skeletal clips. Not rendered. |
75 | OBJ + MTL + texture files | Yes | MTL subset with explicit conversion and relative textures | No | No skeletal clips | Read by `tobj` in this build as a static mesh. The import states that OBJ has no skeletal clips. Rigid equipment, and static bodies drawn in live view and production (one bind sample × 36 views). |
76 76 | FBX ASCII/binary | Yes | Diffuse/PBR color and file or embedded textures that ufbx reports | Skin clusters, bone parents, inverse-binds, rest/global transforms | Takes as animation stacks; baked TRS channels sampleable like glTF | Inventory via `import`; normalized drawable scene via `fbx_scene` drawn in the live viewport and production batch; rigid FBX equipment via `equipment::import_fbx`. Not every FBX dialect. |
77 77 | Collada .dae | Usually | Adapter-dependent | Possible | Possible | Optional Assimp-backed candidate, not guaranteed until fixtures pass |
78 78 | PLY / STL | Static geometry | PLY colors possible; STL no conventional textures | No | No | Optional static equipment only; low priority |
@@ -84,6 +84,8 @@ Import normalizes axes, handedness, units, winding, inverse-bind matrices and te
84 84
85 85 OBJ bodies can be displayed as static geometry, but must not pretend to have a skeleton or the requested animations. A UI must clearly disable unavailable bone/clip features. Separate equipment geometry from body rig; bone matching uses stable joint identity/path, not only an ambiguous display name.
86 86
87 **Implemented static bodies (remaining #9, 7 November 2026):** `preview::load_body_file` loads bodies without a skin as `BodyRig::Static` — `.obj` (OBJ+MTL+PNG), glTF/GLB with no `skins`, and FBX whose meshes have no skin deformer (`fbx_scene::load_fbx_drawable` returns `NO_SKINNED_MESH`; skinned FBX stays on `fbx_scene`). OBJ and FBX reuse the rigid equipment readers (`equipment::import_obj`/`import_fbx`, FBX ufbx normalization applied once there) with a body vertex budget of 1,000,000; the only extra conversion is the UV origin (bottom-left → top-left `PreviewCharacter` convention), applied once. Static glTF bakes each mesh node's rest world transform of the default scene into positions once, the inverse-transpose into normals, and flips winding for mirrored (negative-determinant) transforms; singular transforms are rejected. A static body has empty nodes/joints/inverse-binds and **no clips** (glTF node animations are not applied; the editor message names how many). Every skinning path resolves to identity (no bones, weight 1 on slot 0), so live view and production draw the stored pose. **Camera:** `camera::neck_height` does not guess a neck from geometry for a static body; `resolve` falls back to `center_y_milli` (`pivot_source` `fallback`, reason "Statisch lichaam zonder skeleton: geen nekbot"); a manual `pivot_y_milli` still wins; fixed per-sex height/zoom is unchanged. **Production:** the existing no-clip plan renders one `clip000-bind` sample × 36 views with the same six passes and only the four target maps, readable through `export_set`. **UI:** the body view shows the static reason; the clip picker and Play/Pause are disabled; the equipment editor disables the bone tree and **Render equipment**, explains why, and the live view draws no equipment layers on a static body; `production::contract` refuses an equipment job on a static body and `Preset::bind` has no bone to bind. The live viewport remains a lit base-colour placement view. Fixtures: `tests/fixtures/bodies/static/` (generated by `scripts/generate_static_bodies.py`; the GLB stores pre-transformed geometry under a translated/scaled parent and rotated child node plus one node animation) and the rigid `sword-fbx.fbx` as unskinned FBX; tests compare static geometry with the skinned GLB bind pose; GPU proof `examples/static_body_fixture.rs`.
88
87 89 Format acceptance gate: fixtures cover mesh topology, alpha materials, normal maps, external relative texture paths, one animated skinned body, multiple clips, bone-local transforms, malformed files and missing resources. A green fixture does not certify every exporter dialect.
88 90
89 91 ## 5. .ppd project and asset ownership
@@ -72,7 +72,7 @@ FBX is read with the `ufbx` crate 0.11.5. That crate compiles the MIT-licensed u
72| Format | Mesh | Materials/textures | Skeleton | Animation | Intended role / status |
72| Format | Mesh | Materials/textures | Skeleton | Animation | Intended role / status |
73|---|---|---|---|---|---|
73|---|---|---|---|---|---|
74| glTF 2.0 .gltf/.glb | Yes | PBR metallic/roughness and referenced/embedded textures | Skins/joints | Node/skeletal clips; morph support must be tested | Read by the `gltf` crate in this build: mesh, materials, textures, joints and clips. The live preview draws the skinned body mesh. |
74| glTF 2.0 .gltf/.glb | Yes | PBR metallic/roughness and referenced/embedded textures | Skins/joints | Node/skeletal clips; morph support must be tested | Read by the `gltf` crate in this build: mesh, materials, textures, joints and clips. The live preview draws the skinned body mesh. |
75| OBJ + MTL + texture files | Yes | MTL subset with explicit conversion and relative textures | No | No skeletal clips | Read by `tobj` in this build as a static mesh. The import states that OBJ has no skeletal clips. Not rendered. |
75| OBJ + MTL + texture files | Yes | MTL subset with explicit conversion and relative textures | No | No skeletal clips | Read by `tobj` in this build as a static mesh. The import states that OBJ has no skeletal clips. Rigid equipment, and static bodies drawn in live view and production (one bind sample × 36 views). |
76| FBX ASCII/binary | Yes | Diffuse/PBR color and file or embedded textures that ufbx reports | Skin clusters, bone parents, inverse-binds, rest/global transforms | Takes as animation stacks; baked TRS channels sampleable like glTF | Inventory via `import`; normalized drawable scene via `fbx_scene` drawn in the live viewport and production batch; rigid FBX equipment via `equipment::import_fbx`. Not every FBX dialect. |
76| FBX ASCII/binary | Yes | Diffuse/PBR color and file or embedded textures that ufbx reports | Skin clusters, bone parents, inverse-binds, rest/global transforms | Takes as animation stacks; baked TRS channels sampleable like glTF | Inventory via `import`; normalized drawable scene via `fbx_scene` drawn in the live viewport and production batch; rigid FBX equipment via `equipment::import_fbx`. Not every FBX dialect. |
77| Collada .dae | Usually | Adapter-dependent | Possible | Possible | Optional Assimp-backed candidate, not guaranteed until fixtures pass |
77| Collada .dae | Usually | Adapter-dependent | Possible | Possible | Optional Assimp-backed candidate, not guaranteed until fixtures pass |
78| PLY / STL | Static geometry | PLY colors possible; STL no conventional textures | No | No | Optional static equipment only; low priority |
78| PLY / STL | Static geometry | PLY colors possible; STL no conventional textures | No | No | Optional static equipment only; low priority |
@@ -84,6 +84,8 @@ Import normalizes axes, handedness, units, winding, inverse-bind matrices and te
84
84
85OBJ bodies can be displayed as static geometry, but must not pretend to have a skeleton or the requested animations. A UI must clearly disable unavailable bone/clip features. Separate equipment geometry from body rig; bone matching uses stable joint identity/path, not only an ambiguous display name.
85OBJ bodies can be displayed as static geometry, but must not pretend to have a skeleton or the requested animations. A UI must clearly disable unavailable bone/clip features. Separate equipment geometry from body rig; bone matching uses stable joint identity/path, not only an ambiguous display name.
86
86
87**Implemented static bodies (remaining #9, 7 November 2026):** `preview::load_body_file` loads bodies without a skin as `BodyRig::Static` — `.obj` (OBJ+MTL+PNG), glTF/GLB with no `skins`, and FBX whose meshes have no skin deformer (`fbx_scene::load_fbx_drawable` returns `NO_SKINNED_MESH`; skinned FBX stays on `fbx_scene`). OBJ and FBX reuse the rigid equipment readers (`equipment::import_obj`/`import_fbx`, FBX ufbx normalization applied once there) with a body vertex budget of 1,000,000; the only extra conversion is the UV origin (bottom-left → top-left `PreviewCharacter` convention), applied once. Static glTF bakes each mesh node's rest world transform of the default scene into positions once, the inverse-transpose into normals, and flips winding for mirrored (negative-determinant) transforms; singular transforms are rejected. A static body has empty nodes/joints/inverse-binds and **no clips** (glTF node animations are not applied; the editor message names how many). Every skinning path resolves to identity (no bones, weight 1 on slot 0), so live view and production draw the stored pose. **Camera:** `camera::neck_height` does not guess a neck from geometry for a static body; `resolve` falls back to `center_y_milli` (`pivot_source` `fallback`, reason "Statisch lichaam zonder skeleton: geen nekbot"); a manual `pivot_y_milli` still wins; fixed per-sex height/zoom is unchanged. **Production:** the existing no-clip plan renders one `clip000-bind` sample × 36 views with the same six passes and only the four target maps, readable through `export_set`. **UI:** the body view shows the static reason; the clip picker and Play/Pause are disabled; the equipment editor disables the bone tree and **Render equipment**, explains why, and the live view draws no equipment layers on a static body; `production::contract` refuses an equipment job on a static body and `Preset::bind` has no bone to bind. The live viewport remains a lit base-colour placement view. Fixtures: `tests/fixtures/bodies/static/` (generated by `scripts/generate_static_bodies.py`; the GLB stores pre-transformed geometry under a translated/scaled parent and rotated child node plus one node animation) and the rigid `sword-fbx.fbx` as unskinned FBX; tests compare static geometry with the skinned GLB bind pose; GPU proof `examples/static_body_fixture.rs`.
88
87Format acceptance gate: fixtures cover mesh topology, alpha materials, normal maps, external relative texture paths, one animated skinned body, multiple clips, bone-local transforms, malformed files and missing resources. A green fixture does not certify every exporter dialect.
89Format acceptance gate: fixtures cover mesh topology, alpha materials, normal maps, external relative texture paths, one animated skinned body, multiple clips, bone-local transforms, malformed files and missing resources. A green fixture does not certify every exporter dialect.
88
90
89## 5. .ppd project and asset ownership
91## 5. .ppd project and asset ownership
examples/static_body_fixture.rs added +418 −0
@@ -0,0 +1,418 @@
1 //! Real native GPU/GUI proof for static (unskinned) bodies (no input automation).
2 //! Usage: xvfb-run -a cargo run --example static_body_fixture -- ROOT OUTDIR [WIDTH HEIGHT FPS]
3 //! Builds a project with the static OBJ+MTL+PNG body as male and the unskinned
4 //! glTF body as female, screenshots the live viewport for both and the
5 //! equipment screen (bone binding unavailable), runs the production batch for
6 //! both bodies (one bind sample × 36 views × four maps), checks that an
7 //! equipment render on the static body is refused, verifies the runs with
8 //! `export_set`, and compares OBJ vs glTF coverage/depth view by view.
9 use eframe::App;
10 use paperdoll_renderer::{
11 editor::EditorApp,
12 equipment, export_set,
13 export_set::{MapImage, MapKind},
14 session::{adopt_file, Session, Sex},
15 };
16 use std::path::{Path, PathBuf};
17
18 const SETTLE_FRAMES: u64 = 40;
19
20 struct Fixture {
21 editor: EditorApp,
22 out: PathBuf,
23 phase: u8,
24 waited: u64,
25 shot_requested: bool,
26 results: Vec<serde_json::Value>,
27 last: u32,
28 }
29
30 fn save_png(path: &Path, w: usize, h: usize, rgba: &[u8]) {
31 let file = std::fs::File::create(path).unwrap();
32 let mut enc = png::Encoder::new(std::io::BufWriter::new(file), w as u32, h as u32);
33 enc.set_color(png::ColorType::Rgba);
34 enc.set_depth(png::BitDepth::Eight);
35 enc.write_header().unwrap().write_image_data(rgba).unwrap();
36 }
37
38 impl Fixture {
39 fn screenshot(&mut self, ctx: &eframe::egui::Context, name: &str) -> bool {
40 self.waited += 1;
41 if self.waited < SETTLE_FRAMES {
42 return false;
43 }
44 if !self.shot_requested {
45 self.shot_requested = true;
46 ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Screenshot);
47 return false;
48 }
49 let shot = ctx.input(|i| {
50 i.raw.events.iter().find_map(|e| match e {
51 eframe::egui::Event::Screenshot { image, .. } => Some(image.clone()),
52 _ => None,
53 })
54 });
55 let Some(image) = shot else { return false };
56 let [w, h] = image.size;
57 let rgba: Vec<u8> = image.pixels.iter().flat_map(|p| p.to_array()).collect();
58 let path = self.out.join(name);
59 save_png(&path, w, h, &rgba);
60 println!("SCREENSHOT {}", path.display());
61 self.waited = 0;
62 self.shot_requested = false;
63 true
64 }
65 fn render_done(&mut self, label: &str) -> bool {
66 let (completed, total, pass) = self.editor.render_progress();
67 if completed != self.last {
68 if completed % 50 == 0 || completed == total {
69 println!("PASS {label} {completed}/{total}: {pass}");
70 }
71 self.last = completed;
72 }
73 if self.editor.render_is_running() {
74 return false;
75 }
76 let error = self.editor.render_error().map(str::to_string);
77 let success = error.is_none() && total > 0 && completed == total;
78 self.results.push(serde_json::json!({
79 "set": label, "success": success, "completed": completed,
80 "total": total, "last_pass": pass, "error": error,
81 }));
82 self.last = 0;
83 true
84 }
85 }
86
87 impl App for Fixture {
88 fn update(&mut self, ctx: &eframe::egui::Context, frame: &mut eframe::Frame) {
89 self.editor.update(ctx, frame);
90 ctx.request_repaint();
91 match self.phase {
92 0 => {
93 if self.screenshot(ctx, "live-static-obj-body.png") {
94 self.editor.open_equipment_configuration(0, Sex::Male);
95 self.phase = 1;
96 }
97 }
98 1 => {
99 if self.screenshot(ctx, "static-equipment-unavailable.png") {
100 // The button is disabled in the UI; calling the action
101 // directly must still be refused, not rendered.
102 self.editor.render_all_angles();
103 self.phase = 2;
104 }
105 }
106 2 => {
107 if self.render_done("equipment-on-static") {
108 self.editor.open_body_configuration(Sex::Male);
109 self.editor.render_all_angles();
110 self.phase = 3;
111 }
112 }
113 3 => {
114 let done = self.render_done("male-obj-body");
115 if done {
116 self.phase = 4;
117 }
118 }
119 4 => {
120 if self.screenshot(ctx, "production-static-obj-ui.png") {
121 self.editor.open_body_configuration(Sex::Female);
122 self.editor.show_live_3d();
123 self.phase = 5;
124 }
125 }
126 5 => {
127 if self.screenshot(ctx, "live-static-gltf-body.png") {
128 self.editor.render_all_angles();
129 self.phase = 6;
130 }
131 }
132 6 => {
133 let done = self.render_done("female-gltf-body");
134 if done {
135 self.phase = 7;
136 ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Close);
137 }
138 }
139 _ => {}
140 }
141 }
142 fn on_exit(&mut self, gl: Option<&glow::Context>) {
143 let value = serde_json::Value::Array(self.results.clone());
144 std::fs::write(
145 self.out.join("gui-result.json"),
146 serde_json::to_vec_pretty(&value).unwrap(),
147 )
148 .unwrap();
149 self.editor.on_exit(gl);
150 }
151 }
152
153 fn walk_pngs(dir: &Path) -> Result<Vec<PathBuf>, String> {
154 let mut out = Vec::new();
155 let mut stack = vec![dir.to_path_buf()];
156 while let Some(d) = stack.pop() {
157 for entry in std::fs::read_dir(&d).map_err(|e| e.to_string())? {
158 let path = entry.map_err(|e| e.to_string())?.path();
159 if path.is_dir() {
160 stack.push(path);
161 } else if path.extension().is_some_and(|x| x == "png") {
162 out.push(path);
163 }
164 }
165 }
166 out.sort();
167 Ok(out)
168 }
169
170 struct RunCheck {
171 report: serde_json::Value,
172 run: export_set::ExportRun,
173 }
174
175 fn check_run(dir: &Path, width: u32, height: u32) -> Result<RunCheck, String> {
176 let run = export_set::ExportRun::open(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
177 let mut counts = std::collections::BTreeMap::<String, u32>::new();
178 for path in walk_pngs(dir)? {
179 let name = path.file_name().unwrap().to_string_lossy().to_string();
180 let map = name
181 .rsplit_once('_')
182 .map(|(_, m)| m.trim_end_matches(".png").to_string())
183 .unwrap_or_default();
184 if !["diffuse", "normal", "depth", "specular"].contains(&map.as_str()) {
185 return Err(format!("onverwachte map {name}"));
186 }
187 *counts.entry(map).or_default() += 1;
188 let decoder = png::Decoder::new(std::io::BufReader::new(
189 std::fs::File::open(&path).map_err(|e| e.to_string())?,
190 ));
191 let reader = decoder.read_info().map_err(|e| e.to_string())?;
192 let (w, h) = (reader.info().width, reader.info().height);
193 if (w, h) != (width, height) {
194 return Err(format!("{name}: {w}x{h} ≠ doel {width}x{height}"));
195 }
196 }
197 let report = serde_json::json!({
198 "dir": dir.display().to_string(),
199 "png_files": run.complete.png_files,
200 "maps": counts,
201 "body_model": run.render.body_model.path.clone(),
202 "set": run.render.set.clone(),
203 "sex": run.render.sex.clone(),
204 "clips": run.render.clips.iter().map(|c| c.token.clone()).collect::<Vec<_>>(),
205 "images": run.render.images,
206 "pivot_source": format!("{:?}", run.render.pivot_source),
207 });
208 Ok(RunCheck { report, run })
209 }
210
211 fn main() -> Result<(), Box<dyn std::error::Error>> {
212 let a = std::env::args().skip(1).collect::<Vec<_>>();
213 if a.len() < 2 {
214 return Err("ROOT OUTDIR [WIDTH HEIGHT FPS]".into());
215 }
216 let root = PathBuf::from(&a[0]);
217 let out = PathBuf::from(&a[1]);
218 std::fs::create_dir_all(&out)?;
219 let width: u32 = a.get(2).map(|v| v.parse()).transpose()?.unwrap_or(32);
220 let height: u32 = a.get(3).map(|v| v.parse()).transpose()?.unwrap_or(48);
221 let fps: u32 = a.get(4).map(|v| v.parse()).transpose()?.unwrap_or(4);
222 let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
223 let fixtures = repo.join("tests/fixtures/bodies/static");
224 let mut s = Session::new();
225 s.install_new_project(&root)?;
226 let mut obj = String::new();
227 for name in [
228 "static-body-male.obj",
229 "static-body-male.mtl",
230 "static-body-albedo.png",
231 ] {
232 let r = adopt_file(&root, &fixtures.join(name), "assets/body")?;
233 if name.ends_with(".obj") {
234 obj = r;
235 }
236 }
237 s.set_body_path(Sex::Male, obj);
238 let glb = adopt_file(&root, &fixtures.join("static-body-male.glb"), "assets/body")?;
239 s.set_body_path(Sex::Female, glb);
240 s.set_render(width, height, fps);
241 let mut preset = equipment::Preset::new("sword".into());
242 preset.provenance = "CC0-1.0 fixture".into();
243 equipment::import_model(
244 &mut preset,
245 &repo.join("tests/fixtures/equipment/sword.obj"),
246 )?;
247 let i = s.add_equipment();
248 s.equipment[i].slug = "sword".into();
249 s.equipment[i].preset = Some(preset);
250 s.save()?;
251 let (root2, out2) = (root.clone(), out.clone());
252 eframe::run_native(
253 "Paperdoll Static Body Fixture",
254 eframe::NativeOptions {
255 viewport: eframe::egui::ViewportBuilder::default().with_inner_size([1150., 820.]),
256 ..paperdoll_renderer::preview_gl::native_options()
257 },
258 Box::new(move |_| {
259 let mut editor = EditorApp::default();
260 editor.open_path(root2.join("character.ppd"));
261 editor.open_body_configuration(Sex::Male);
262 Ok(Box::new(Fixture {
263 editor,
264 out: out2,
265 phase: 0,
266 waited: 0,
267 shot_requested: false,
268 results: Vec::new(),
269 last: 0,
270 }))
271 }),
272 )?;
273 let gui: serde_json::Value =
274 serde_json::from_slice(&std::fs::read(out.join("gui-result.json"))?)?;
275 let mut dirs: Vec<PathBuf> = std::fs::read_dir(root.join("renders"))?
276 .filter_map(|e| e.ok().map(|e| e.path()))
277 .filter(|p| p.is_dir())
278 .collect();
279 dirs.sort();
280 let mut checks = Vec::new();
281 for dir in &dirs {
282 checks.push(check_run(dir, width, height)?);
283 }
284 let male = checks
285 .iter()
286 .find(|c| c.run.render.sex == "male")
287 .ok_or("male run ontbreekt")?;
288 let female = checks
289 .iter()
290 .find(|c| c.run.render.sex == "female")
291 .ok_or("female run ontbreekt")?;
292 // View-by-view: OBJ and unskinned glTF are the same geometry, so coverage
293 // and depth must match; diffuse must be the unlit albedo of each source.
294 let mut coverage_mismatch = 0u64;
295 let mut depth_max_delta = 0u32;
296 let mut covered = 0u64;
297 let mut obj_red = 0u64;
298 let mut obj_blue = 0u64;
299 let mut obj_other = 0u64;
300 let mut gltf_colors = std::collections::BTreeSet::new();
301 let mut normal_bad = 0u64;
302 for rel in male.run.complete.png_sha256.keys() {
303 if !rel.ends_with("_depth.png") {
304 continue;
305 }
306 let female_rel = |r: &str| r.replacen("male/", "female/", 1);
307 let dm = male
308 .run
309 .load_relative(rel, MapKind::Depth)
310 .map_err(|e| e.to_string())?;
311 let df = female
312 .run
313 .load_relative(&female_rel(rel), MapKind::Depth)
314 .map_err(|e| e.to_string())?;
315 let (MapImage::Depth16 { samples: a, .. }, MapImage::Depth16 { samples: b, .. }) =
316 (&dm, &df)
317 else {
318 return Err("depth-map verwacht".into());
319 };
320 for (x, y) in a.chunks_exact(2).zip(b.chunks_exact(2)) {
321 if (x[1] > 0) != (y[1] > 0) {
322 coverage_mismatch += 1;
323 } else if x[1] > 0 {
324 covered += 1;
325 depth_max_delta = depth_max_delta.max((x[0] as i32 - y[0] as i32).unsigned_abs());
326 }
327 }
328 let diffuse_rel = rel.replace("_depth.png", "_diffuse.png");
329 if let MapImage::Rgba8 { rgba, .. } = male
330 .run
331 .load_relative(&diffuse_rel, MapKind::Diffuse)
332 .map_err(|e| e.to_string())?
333 {
334 for px in rgba.chunks_exact(4).filter(|p| p[3] == 255) {
335 let near = |c: [u8; 3]| (0..3).all(|k| (px[k] as i32 - c[k] as i32).abs() <= 2);
336 if near([220, 40, 30]) {
337 obj_red += 1;
338 } else if near([30, 60, 220]) {
339 obj_blue += 1;
340 } else {
341 obj_other += 1;
342 }
343 }
344 }
345 if let MapImage::Rgba8 { rgba, .. } = female
346 .run
347 .load_relative(&female_rel(&diffuse_rel), MapKind::Diffuse)
348 .map_err(|e| e.to_string())?
349 {
350 for px in rgba.chunks_exact(4).filter(|p| p[3] == 255) {
351 gltf_colors.insert([px[0], px[1], px[2]]);
352 }
353 }
354 let normal_rel = rel.replace("_depth.png", "_normal.png");
355 if let MapImage::Rgba8 { rgba, .. } = male
356 .run
357 .load_relative(&normal_rel, MapKind::Normal)
358 .map_err(|e| e.to_string())?
359 {
360 for px in rgba.chunks_exact(4).filter(|p| p[3] > 0) {
361 let n = [0, 1, 2].map(|k| px[k] as f32 / 127.5 - 1.);
362 let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
363 if (len - 1.).abs() > 0.05 {
364 normal_bad += 1;
365 }
366 }
367 }
368 }
369 let equipment_runs = checks.iter().filter(|c| c.run.render.set != "body").count();
370 let report = serde_json::json!({
371 "gui": gui,
372 "runs": checks.iter().map(|c| c.report.clone()).collect::<Vec<_>>(),
373 "equipment_runs_on_static_body": equipment_runs,
374 "obj_vs_gltf": {
375 "covered_pixels": covered,
376 "coverage_mismatch_pixels": coverage_mismatch,
377 "depth_max_delta_u16": depth_max_delta,
378 },
379 "obj_diffuse": {"red_top": obj_red, "blue_bottom": obj_blue, "other": obj_other},
380 "gltf_diffuse_opaque_colors": gltf_colors.iter().map(|c| format!("{c:?}")).collect::<Vec<_>>(),
381 "obj_normal_non_unit_pixels": normal_bad,
382 });
383 std::fs::write(
384 out.join("static-body-proof.json"),
385 serde_json::to_vec_pretty(&report)?,
386 )?;
387 println!("REPORT {report}");
388 let gui_ok = gui.as_array().is_some_and(|g| {
389 g.len() == 3
390 && g[0]["success"] == false
391 && g[0]["error"]
392 .as_str()
393 .is_some_and(|e| e.contains("statisch"))
394 && g[1]["success"] == true
395 && g[2]["success"] == true
396 });
397 let runs_ok = checks.len() == 2
398 && equipment_runs == 0
399 && checks.iter().all(|c| {
400 c.run.complete.png_files == 144
401 && c.run.render.clips.len() == 1
402 && c.run.render.clips[0].token == "clip000-bind"
403 });
404 let maps_ok = covered > 0
405 && coverage_mismatch * 200 <= covered
406 && obj_red > 0
407 && obj_blue > 0
408 && obj_other * 20 <= obj_red + obj_blue
409 && gltf_colors.len() <= 2
410 && normal_bad == 0;
411 if !(gui_ok && runs_ok && maps_ok) {
412 return Err(format!(
413 "static-body-proof mislukt (gui {gui_ok}, runs {runs_ok}, maps {maps_ok}); zie static-body-proof.json"
414 )
415 .into());
416 }
417 Ok(())
418 }
@@ -0,0 +1,418 @@
1//! Real native GPU/GUI proof for static (unskinned) bodies (no input automation).
2//! Usage: xvfb-run -a cargo run --example static_body_fixture -- ROOT OUTDIR [WIDTH HEIGHT FPS]
3//! Builds a project with the static OBJ+MTL+PNG body as male and the unskinned
4//! glTF body as female, screenshots the live viewport for both and the
5//! equipment screen (bone binding unavailable), runs the production batch for
6//! both bodies (one bind sample × 36 views × four maps), checks that an
7//! equipment render on the static body is refused, verifies the runs with
8//! `export_set`, and compares OBJ vs glTF coverage/depth view by view.
9use eframe::App;
10use paperdoll_renderer::{
11 editor::EditorApp,
12 equipment, export_set,
13 export_set::{MapImage, MapKind},
14 session::{adopt_file, Session, Sex},
15};
16use std::path::{Path, PathBuf};
17
18const SETTLE_FRAMES: u64 = 40;
19
20struct Fixture {
21 editor: EditorApp,
22 out: PathBuf,
23 phase: u8,
24 waited: u64,
25 shot_requested: bool,
26 results: Vec<serde_json::Value>,
27 last: u32,
28}
29
30fn save_png(path: &Path, w: usize, h: usize, rgba: &[u8]) {
31 let file = std::fs::File::create(path).unwrap();
32 let mut enc = png::Encoder::new(std::io::BufWriter::new(file), w as u32, h as u32);
33 enc.set_color(png::ColorType::Rgba);
34 enc.set_depth(png::BitDepth::Eight);
35 enc.write_header().unwrap().write_image_data(rgba).unwrap();
36}
37
38impl Fixture {
39 fn screenshot(&mut self, ctx: &eframe::egui::Context, name: &str) -> bool {
40 self.waited += 1;
41 if self.waited < SETTLE_FRAMES {
42 return false;
43 }
44 if !self.shot_requested {
45 self.shot_requested = true;
46 ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Screenshot);
47 return false;
48 }
49 let shot = ctx.input(|i| {
50 i.raw.events.iter().find_map(|e| match e {
51 eframe::egui::Event::Screenshot { image, .. } => Some(image.clone()),
52 _ => None,
53 })
54 });
55 let Some(image) = shot else { return false };
56 let [w, h] = image.size;
57 let rgba: Vec<u8> = image.pixels.iter().flat_map(|p| p.to_array()).collect();
58 let path = self.out.join(name);
59 save_png(&path, w, h, &rgba);
60 println!("SCREENSHOT {}", path.display());
61 self.waited = 0;
62 self.shot_requested = false;
63 true
64 }
65 fn render_done(&mut self, label: &str) -> bool {
66 let (completed, total, pass) = self.editor.render_progress();
67 if completed != self.last {
68 if completed % 50 == 0 || completed == total {
69 println!("PASS {label} {completed}/{total}: {pass}");
70 }
71 self.last = completed;
72 }
73 if self.editor.render_is_running() {
74 return false;
75 }
76 let error = self.editor.render_error().map(str::to_string);
77 let success = error.is_none() && total > 0 && completed == total;
78 self.results.push(serde_json::json!({
79 "set": label, "success": success, "completed": completed,
80 "total": total, "last_pass": pass, "error": error,
81 }));
82 self.last = 0;
83 true
84 }
85}
86
87impl App for Fixture {
88 fn update(&mut self, ctx: &eframe::egui::Context, frame: &mut eframe::Frame) {
89 self.editor.update(ctx, frame);
90 ctx.request_repaint();
91 match self.phase {
92 0 => {
93 if self.screenshot(ctx, "live-static-obj-body.png") {
94 self.editor.open_equipment_configuration(0, Sex::Male);
95 self.phase = 1;
96 }
97 }
98 1 => {
99 if self.screenshot(ctx, "static-equipment-unavailable.png") {
100 // The button is disabled in the UI; calling the action
101 // directly must still be refused, not rendered.
102 self.editor.render_all_angles();
103 self.phase = 2;
104 }
105 }
106 2 => {
107 if self.render_done("equipment-on-static") {
108 self.editor.open_body_configuration(Sex::Male);
109 self.editor.render_all_angles();
110 self.phase = 3;
111 }
112 }
113 3 => {
114 let done = self.render_done("male-obj-body");
115 if done {
116 self.phase = 4;
117 }
118 }
119 4 => {
120 if self.screenshot(ctx, "production-static-obj-ui.png") {
121 self.editor.open_body_configuration(Sex::Female);
122 self.editor.show_live_3d();
123 self.phase = 5;
124 }
125 }
126 5 => {
127 if self.screenshot(ctx, "live-static-gltf-body.png") {
128 self.editor.render_all_angles();
129 self.phase = 6;
130 }
131 }
132 6 => {
133 let done = self.render_done("female-gltf-body");
134 if done {
135 self.phase = 7;
136 ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Close);
137 }
138 }
139 _ => {}
140 }
141 }
142 fn on_exit(&mut self, gl: Option<&glow::Context>) {
143 let value = serde_json::Value::Array(self.results.clone());
144 std::fs::write(
145 self.out.join("gui-result.json"),
146 serde_json::to_vec_pretty(&value).unwrap(),
147 )
148 .unwrap();
149 self.editor.on_exit(gl);
150 }
151}
152
153fn walk_pngs(dir: &Path) -> Result<Vec<PathBuf>, String> {
154 let mut out = Vec::new();
155 let mut stack = vec![dir.to_path_buf()];
156 while let Some(d) = stack.pop() {
157 for entry in std::fs::read_dir(&d).map_err(|e| e.to_string())? {
158 let path = entry.map_err(|e| e.to_string())?.path();
159 if path.is_dir() {
160 stack.push(path);
161 } else if path.extension().is_some_and(|x| x == "png") {
162 out.push(path);
163 }
164 }
165 }
166 out.sort();
167 Ok(out)
168}
169
170struct RunCheck {
171 report: serde_json::Value,
172 run: export_set::ExportRun,
173}
174
175fn check_run(dir: &Path, width: u32, height: u32) -> Result<RunCheck, String> {
176 let run = export_set::ExportRun::open(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
177 let mut counts = std::collections::BTreeMap::<String, u32>::new();
178 for path in walk_pngs(dir)? {
179 let name = path.file_name().unwrap().to_string_lossy().to_string();
180 let map = name
181 .rsplit_once('_')
182 .map(|(_, m)| m.trim_end_matches(".png").to_string())
183 .unwrap_or_default();
184 if !["diffuse", "normal", "depth", "specular"].contains(&map.as_str()) {
185 return Err(format!("onverwachte map {name}"));
186 }
187 *counts.entry(map).or_default() += 1;
188 let decoder = png::Decoder::new(std::io::BufReader::new(
189 std::fs::File::open(&path).map_err(|e| e.to_string())?,
190 ));
191 let reader = decoder.read_info().map_err(|e| e.to_string())?;
192 let (w, h) = (reader.info().width, reader.info().height);
193 if (w, h) != (width, height) {
194 return Err(format!("{name}: {w}x{h} ≠ doel {width}x{height}"));
195 }
196 }
197 let report = serde_json::json!({
198 "dir": dir.display().to_string(),
199 "png_files": run.complete.png_files,
200 "maps": counts,
201 "body_model": run.render.body_model.path.clone(),
202 "set": run.render.set.clone(),
203 "sex": run.render.sex.clone(),
204 "clips": run.render.clips.iter().map(|c| c.token.clone()).collect::<Vec<_>>(),
205 "images": run.render.images,
206 "pivot_source": format!("{:?}", run.render.pivot_source),
207 });
208 Ok(RunCheck { report, run })
209}
210
211fn main() -> Result<(), Box<dyn std::error::Error>> {
212 let a = std::env::args().skip(1).collect::<Vec<_>>();
213 if a.len() < 2 {
214 return Err("ROOT OUTDIR [WIDTH HEIGHT FPS]".into());
215 }
216 let root = PathBuf::from(&a[0]);
217 let out = PathBuf::from(&a[1]);
218 std::fs::create_dir_all(&out)?;
219 let width: u32 = a.get(2).map(|v| v.parse()).transpose()?.unwrap_or(32);
220 let height: u32 = a.get(3).map(|v| v.parse()).transpose()?.unwrap_or(48);
221 let fps: u32 = a.get(4).map(|v| v.parse()).transpose()?.unwrap_or(4);
222 let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
223 let fixtures = repo.join("tests/fixtures/bodies/static");
224 let mut s = Session::new();
225 s.install_new_project(&root)?;
226 let mut obj = String::new();
227 for name in [
228 "static-body-male.obj",
229 "static-body-male.mtl",
230 "static-body-albedo.png",
231 ] {
232 let r = adopt_file(&root, &fixtures.join(name), "assets/body")?;
233 if name.ends_with(".obj") {
234 obj = r;
235 }
236 }
237 s.set_body_path(Sex::Male, obj);
238 let glb = adopt_file(&root, &fixtures.join("static-body-male.glb"), "assets/body")?;
239 s.set_body_path(Sex::Female, glb);
240 s.set_render(width, height, fps);
241 let mut preset = equipment::Preset::new("sword".into());
242 preset.provenance = "CC0-1.0 fixture".into();
243 equipment::import_model(
244 &mut preset,
245 &repo.join("tests/fixtures/equipment/sword.obj"),
246 )?;
247 let i = s.add_equipment();
248 s.equipment[i].slug = "sword".into();
249 s.equipment[i].preset = Some(preset);
250 s.save()?;
251 let (root2, out2) = (root.clone(), out.clone());
252 eframe::run_native(
253 "Paperdoll Static Body Fixture",
254 eframe::NativeOptions {
255 viewport: eframe::egui::ViewportBuilder::default().with_inner_size([1150., 820.]),
256 ..paperdoll_renderer::preview_gl::native_options()
257 },
258 Box::new(move |_| {
259 let mut editor = EditorApp::default();
260 editor.open_path(root2.join("character.ppd"));
261 editor.open_body_configuration(Sex::Male);
262 Ok(Box::new(Fixture {
263 editor,
264 out: out2,
265 phase: 0,
266 waited: 0,
267 shot_requested: false,
268 results: Vec::new(),
269 last: 0,
270 }))
271 }),
272 )?;
273 let gui: serde_json::Value =
274 serde_json::from_slice(&std::fs::read(out.join("gui-result.json"))?)?;
275 let mut dirs: Vec<PathBuf> = std::fs::read_dir(root.join("renders"))?
276 .filter_map(|e| e.ok().map(|e| e.path()))
277 .filter(|p| p.is_dir())
278 .collect();
279 dirs.sort();
280 let mut checks = Vec::new();
281 for dir in &dirs {
282 checks.push(check_run(dir, width, height)?);
283 }
284 let male = checks
285 .iter()
286 .find(|c| c.run.render.sex == "male")
287 .ok_or("male run ontbreekt")?;
288 let female = checks
289 .iter()
290 .find(|c| c.run.render.sex == "female")
291 .ok_or("female run ontbreekt")?;
292 // View-by-view: OBJ and unskinned glTF are the same geometry, so coverage
293 // and depth must match; diffuse must be the unlit albedo of each source.
294 let mut coverage_mismatch = 0u64;
295 let mut depth_max_delta = 0u32;
296 let mut covered = 0u64;
297 let mut obj_red = 0u64;
298 let mut obj_blue = 0u64;
299 let mut obj_other = 0u64;
300 let mut gltf_colors = std::collections::BTreeSet::new();
301 let mut normal_bad = 0u64;
302 for rel in male.run.complete.png_sha256.keys() {
303 if !rel.ends_with("_depth.png") {
304 continue;
305 }
306 let female_rel = |r: &str| r.replacen("male/", "female/", 1);
307 let dm = male
308 .run
309 .load_relative(rel, MapKind::Depth)
310 .map_err(|e| e.to_string())?;
311 let df = female
312 .run
313 .load_relative(&female_rel(rel), MapKind::Depth)
314 .map_err(|e| e.to_string())?;
315 let (MapImage::Depth16 { samples: a, .. }, MapImage::Depth16 { samples: b, .. }) =
316 (&dm, &df)
317 else {
318 return Err("depth-map verwacht".into());
319 };
320 for (x, y) in a.chunks_exact(2).zip(b.chunks_exact(2)) {
321 if (x[1] > 0) != (y[1] > 0) {
322 coverage_mismatch += 1;
323 } else if x[1] > 0 {
324 covered += 1;
325 depth_max_delta = depth_max_delta.max((x[0] as i32 - y[0] as i32).unsigned_abs());
326 }
327 }
328 let diffuse_rel = rel.replace("_depth.png", "_diffuse.png");
329 if let MapImage::Rgba8 { rgba, .. } = male
330 .run
331 .load_relative(&diffuse_rel, MapKind::Diffuse)
332 .map_err(|e| e.to_string())?
333 {
334 for px in rgba.chunks_exact(4).filter(|p| p[3] == 255) {
335 let near = |c: [u8; 3]| (0..3).all(|k| (px[k] as i32 - c[k] as i32).abs() <= 2);
336 if near([220, 40, 30]) {
337 obj_red += 1;
338 } else if near([30, 60, 220]) {
339 obj_blue += 1;
340 } else {
341 obj_other += 1;
342 }
343 }
344 }
345 if let MapImage::Rgba8 { rgba, .. } = female
346 .run
347 .load_relative(&female_rel(&diffuse_rel), MapKind::Diffuse)
348 .map_err(|e| e.to_string())?
349 {
350 for px in rgba.chunks_exact(4).filter(|p| p[3] == 255) {
351 gltf_colors.insert([px[0], px[1], px[2]]);
352 }
353 }
354 let normal_rel = rel.replace("_depth.png", "_normal.png");
355 if let MapImage::Rgba8 { rgba, .. } = male
356 .run
357 .load_relative(&normal_rel, MapKind::Normal)
358 .map_err(|e| e.to_string())?
359 {
360 for px in rgba.chunks_exact(4).filter(|p| p[3] > 0) {
361 let n = [0, 1, 2].map(|k| px[k] as f32 / 127.5 - 1.);
362 let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
363 if (len - 1.).abs() > 0.05 {
364 normal_bad += 1;
365 }
366 }
367 }
368 }
369 let equipment_runs = checks.iter().filter(|c| c.run.render.set != "body").count();
370 let report = serde_json::json!({
371 "gui": gui,
372 "runs": checks.iter().map(|c| c.report.clone()).collect::<Vec<_>>(),
373 "equipment_runs_on_static_body": equipment_runs,
374 "obj_vs_gltf": {
375 "covered_pixels": covered,
376 "coverage_mismatch_pixels": coverage_mismatch,
377 "depth_max_delta_u16": depth_max_delta,
378 },
379 "obj_diffuse": {"red_top": obj_red, "blue_bottom": obj_blue, "other": obj_other},
380 "gltf_diffuse_opaque_colors": gltf_colors.iter().map(|c| format!("{c:?}")).collect::<Vec<_>>(),
381 "obj_normal_non_unit_pixels": normal_bad,
382 });
383 std::fs::write(
384 out.join("static-body-proof.json"),
385 serde_json::to_vec_pretty(&report)?,
386 )?;
387 println!("REPORT {report}");
388 let gui_ok = gui.as_array().is_some_and(|g| {
389 g.len() == 3
390 && g[0]["success"] == false
391 && g[0]["error"]
392 .as_str()
393 .is_some_and(|e| e.contains("statisch"))
394 && g[1]["success"] == true
395 && g[2]["success"] == true
396 });
397 let runs_ok = checks.len() == 2
398 && equipment_runs == 0
399 && checks.iter().all(|c| {
400 c.run.complete.png_files == 144
401 && c.run.render.clips.len() == 1
402 && c.run.render.clips[0].token == "clip000-bind"
403 });
404 let maps_ok = covered > 0
405 && coverage_mismatch * 200 <= covered
406 && obj_red > 0
407 && obj_blue > 0
408 && obj_other * 20 <= obj_red + obj_blue
409 && gltf_colors.len() <= 2
410 && normal_bad == 0;
411 if !(gui_ok && runs_ok && maps_ok) {
412 return Err(format!(
413 "static-body-proof mislukt (gui {gui_ok}, runs {runs_ok}, maps {maps_ok}); zie static-body-proof.json"
414 )
415 .into());
416 }
417 Ok(())
418}
scripts/generate_static_bodies.py added +162 −0
@@ -0,0 +1,162 @@
1 #!/usr/bin/env python3
2 """Generate the static (unskinned) male test body fixtures.
3
4 Same procedural boxes as scripts/generate_test_bodies.py in their bind pose,
5 but WITHOUT a skin or skeleton:
6
7 * static-body-male.obj + .mtl + static-body-albedo.png (OBJ/MTL/PNG). Boxes
8 whose center is above 1.2 m sample the top (red) half of the texture,
9 lower boxes the bottom (blue) half, so UV orientation is testable.
10 * static-body-male.glb: unskinned glTF. The mesh sits under a parent node
11 (translation + uniform scale 2) and a child node (rotation 90 deg about Y);
12 vertices/normals are stored pre-transformed so the baked world geometry
13 equals the skinned body's bind pose. One node animation is included and
14 must NOT be applied by the static loader.
15
16 Run from the repository root:
17
18 python3 scripts/generate_static_bodies.py
19 """
20
21 from __future__ import annotations
22
23 import math
24 import struct
25 import sys
26 import zlib
27 from pathlib import Path
28
29 sys.path.insert(0, str(Path(__file__).resolve().parent))
30 import generate_test_bodies as gen # noqa: E402
31
32 OUT = Path("tests/fixtures/bodies/static")
33 PARENT_T = (0.25, -0.1, 0.4)
34 PARENT_S = 2.0
35 RED = (220, 40, 30, 255)
36 BLUE = (30, 60, 220, 255)
37
38
39 def png_rgba(width: int, height: int, rows: list[list[tuple[int, int, int, int]]]) -> bytes:
40 raw = b"".join(b"\x00" + bytes(c for px in row for c in px) for row in rows)
41 def chunk(kind: bytes, data: bytes) -> bytes:
42 return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)
43 ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
44 return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"")
45
46
47 def boxes(sex: str):
48 """Yield (triangles, face normals, uv v) per box in world bind pose."""
49 spec = gen.skeleton(sex)
50 for joint_index, (name, _p, _l, size) in enumerate(spec):
51 center = gen.world_translation(spec, name)
52 positions: list[float] = []
53 indices: list[int] = []
54 gen.add_box(positions, indices, [], [], center, size, joint_index)
55 corners = [tuple(positions[i:i + 3]) for i in range(0, len(positions), 3)]
56 tris = [tuple(indices[i:i + 3]) for i in range(0, len(indices), 3)]
57 v = 0.85 if center[1] > 1.2 else 0.15
58 yield corners, tris, v
59
60
61 def face_normal(a, b, c):
62 u = [b[i] - a[i] for i in range(3)]
63 w = [c[i] - a[i] for i in range(3)]
64 n = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]]
65 length = math.sqrt(sum(x * x for x in n))
66 return tuple(x / length for x in n)
67
68
69 def write_obj(sex: str) -> None:
70 lines = [
71 "# Static CC0 test body (no skeleton). Generated by scripts/generate_static_bodies.py",
72 "mtllib static-body-male.mtl",
73 "usemtl body",
74 ]
75 vt = ["vt 0.5 0.85", "vt 0.5 0.15"]
76 lines += vt
77 v_count = 0
78 n_count = 0
79 faces = []
80 for corners, tris, v in boxes(sex):
81 for c in corners:
82 lines.append("v %.6f %.6f %.6f" % c)
83 uv = 1 if v > 0.5 else 2
84 for t in tris:
85 n = face_normal(corners[t[0]], corners[t[1]], corners[t[2]])
86 lines.append("vn %.6f %.6f %.6f" % n)
87 n_count += 1
88 faces.append("f " + " ".join(f"{v_count + i + 1}/{uv}/{n_count}" for i in t))
89 v_count += len(corners)
90 lines += faces
91 (OUT / "static-body-male.obj").write_text("\n".join(lines) + "\n")
92 (OUT / "static-body-male.mtl").write_text(
93 "# CC0 static test body material\nnewmtl body\nKd 1.0 1.0 1.0\nd 1.0\nmap_Kd static-body-albedo.png\n"
94 )
95 rows = [[RED] * 4, [RED] * 4, [BLUE] * 4, [BLUE] * 4]
96 (OUT / "static-body-albedo.png").write_bytes(png_rgba(4, 4, rows))
97
98
99 def rot_y(deg: float, p):
100 a = math.radians(deg)
101 c, s = math.cos(a), math.sin(a)
102 return (c * p[0] + s * p[2], p[1], -s * p[0] + c * p[2])
103
104
105 def write_glb(sex: str) -> None:
106 positions: list[float] = []
107 normals: list[float] = []
108 indices: list[int] = []
109 for corners, tris, _v in boxes(sex):
110 for t in tris:
111 a, b, c = (corners[i] for i in t)
112 n = face_normal(a, b, c)
113 for p in (a, b, c):
114 local = tuple((p[i] - PARENT_T[i]) / PARENT_S for i in range(3))
115 positions.extend(rot_y(-90.0, local))
116 normals.extend(rot_y(-90.0, n))
117 indices.append(len(indices))
118 blob = gen.Blob()
119 index_acc = blob.add_u16(indices, "SCALAR", len(indices))
120 pmin, pmax = gen.vec_min_max(positions, 3)
121 pos_acc = blob.add_f32(positions, "VEC3", len(positions) // 3, pmin, pmax)
122 nrm_acc = blob.add_f32(normals, "VEC3", len(normals) // 3)
123 times = blob.add_f32([0.0, 1.0], "SCALAR", 2, [0.0], [1.0])
124 half = math.radians(45.0)
125 rot_out = blob.add_f32([0, 0, 0, 1, 0, math.sin(half), 0, math.cos(half)], "VEC4", 2)
126 half90 = math.radians(45.0)
127 gltf = {
128 "asset": {
129 "version": "2.0",
130 "generator": "scripts/generate_static_bodies.py",
131 "extras": {"testAsset": True, "license": "CC0-1.0", "skin": False},
132 },
133 "scene": 0,
134 "scenes": [{"name": "static-body", "nodes": [0]}],
135 "nodes": [
136 {"name": "static-root", "translation": list(PARENT_T), "scale": [PARENT_S] * 3, "children": [1]},
137 {"name": "static-body-mesh", "rotation": [0.0, math.sin(half90), 0.0, math.cos(half90)], "mesh": 0},
138 ],
139 "meshes": [{"name": "static-body", "primitives": [{
140 "attributes": {"POSITION": pos_acc, "NORMAL": nrm_acc},
141 "indices": index_acc, "material": 0}]}],
142 "materials": [{"name": "static-body", "pbrMetallicRoughness": {
143 "baseColorFactor": [0.45, 0.55, 0.70, 1.0], "metallicFactor": 0.0, "roughnessFactor": 1.0}}],
144 "animations": [{"name": "spin-not-applied", "channels": [{"sampler": 0, "target": {"node": 0, "path": "rotation"}}],
145 "samplers": [{"input": times, "output": rot_out, "interpolation": "LINEAR"}]}],
146 "buffers": [{"byteLength": len(blob.data)}],
147 "bufferViews": blob.views,
148 "accessors": blob.accessors,
149 }
150 (OUT / "static-body-male.glb").write_bytes(gen.pack_glb(gltf, bytes(blob.data)))
151
152
153 def main() -> None:
154 OUT.mkdir(parents=True, exist_ok=True)
155 write_obj("male")
156 write_glb("male")
157 for p in sorted(OUT.iterdir()):
158 print(f"wrote {p} ({p.stat().st_size} bytes)")
159
160
161 if __name__ == "__main__":
162 main()
@@ -0,0 +1,162 @@
1#!/usr/bin/env python3
2"""Generate the static (unskinned) male test body fixtures.
3
4Same procedural boxes as scripts/generate_test_bodies.py in their bind pose,
5but WITHOUT a skin or skeleton:
6
7* static-body-male.obj + .mtl + static-body-albedo.png (OBJ/MTL/PNG). Boxes
8 whose center is above 1.2 m sample the top (red) half of the texture,
9 lower boxes the bottom (blue) half, so UV orientation is testable.
10* static-body-male.glb: unskinned glTF. The mesh sits under a parent node
11 (translation + uniform scale 2) and a child node (rotation 90 deg about Y);
12 vertices/normals are stored pre-transformed so the baked world geometry
13 equals the skinned body's bind pose. One node animation is included and
14 must NOT be applied by the static loader.
15
16Run from the repository root:
17
18 python3 scripts/generate_static_bodies.py
19"""
20
21from __future__ import annotations
22
23import math
24import struct
25import sys
26import zlib
27from pathlib import Path
28
29sys.path.insert(0, str(Path(__file__).resolve().parent))
30import generate_test_bodies as gen # noqa: E402
31
32OUT = Path("tests/fixtures/bodies/static")
33PARENT_T = (0.25, -0.1, 0.4)
34PARENT_S = 2.0
35RED = (220, 40, 30, 255)
36BLUE = (30, 60, 220, 255)
37
38
39def png_rgba(width: int, height: int, rows: list[list[tuple[int, int, int, int]]]) -> bytes:
40 raw = b"".join(b"\x00" + bytes(c for px in row for c in px) for row in rows)
41 def chunk(kind: bytes, data: bytes) -> bytes:
42 return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)
43 ihdr = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
44 return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"")
45
46
47def boxes(sex: str):
48 """Yield (triangles, face normals, uv v) per box in world bind pose."""
49 spec = gen.skeleton(sex)
50 for joint_index, (name, _p, _l, size) in enumerate(spec):
51 center = gen.world_translation(spec, name)
52 positions: list[float] = []
53 indices: list[int] = []
54 gen.add_box(positions, indices, [], [], center, size, joint_index)
55 corners = [tuple(positions[i:i + 3]) for i in range(0, len(positions), 3)]
56 tris = [tuple(indices[i:i + 3]) for i in range(0, len(indices), 3)]
57 v = 0.85 if center[1] > 1.2 else 0.15
58 yield corners, tris, v
59
60
61def face_normal(a, b, c):
62 u = [b[i] - a[i] for i in range(3)]
63 w = [c[i] - a[i] for i in range(3)]
64 n = [u[1] * w[2] - u[2] * w[1], u[2] * w[0] - u[0] * w[2], u[0] * w[1] - u[1] * w[0]]
65 length = math.sqrt(sum(x * x for x in n))
66 return tuple(x / length for x in n)
67
68
69def write_obj(sex: str) -> None:
70 lines = [
71 "# Static CC0 test body (no skeleton). Generated by scripts/generate_static_bodies.py",
72 "mtllib static-body-male.mtl",
73 "usemtl body",
74 ]
75 vt = ["vt 0.5 0.85", "vt 0.5 0.15"]
76 lines += vt
77 v_count = 0
78 n_count = 0
79 faces = []
80 for corners, tris, v in boxes(sex):
81 for c in corners:
82 lines.append("v %.6f %.6f %.6f" % c)
83 uv = 1 if v > 0.5 else 2
84 for t in tris:
85 n = face_normal(corners[t[0]], corners[t[1]], corners[t[2]])
86 lines.append("vn %.6f %.6f %.6f" % n)
87 n_count += 1
88 faces.append("f " + " ".join(f"{v_count + i + 1}/{uv}/{n_count}" for i in t))
89 v_count += len(corners)
90 lines += faces
91 (OUT / "static-body-male.obj").write_text("\n".join(lines) + "\n")
92 (OUT / "static-body-male.mtl").write_text(
93 "# CC0 static test body material\nnewmtl body\nKd 1.0 1.0 1.0\nd 1.0\nmap_Kd static-body-albedo.png\n"
94 )
95 rows = [[RED] * 4, [RED] * 4, [BLUE] * 4, [BLUE] * 4]
96 (OUT / "static-body-albedo.png").write_bytes(png_rgba(4, 4, rows))
97
98
99def rot_y(deg: float, p):
100 a = math.radians(deg)
101 c, s = math.cos(a), math.sin(a)
102 return (c * p[0] + s * p[2], p[1], -s * p[0] + c * p[2])
103
104
105def write_glb(sex: str) -> None:
106 positions: list[float] = []
107 normals: list[float] = []
108 indices: list[int] = []
109 for corners, tris, _v in boxes(sex):
110 for t in tris:
111 a, b, c = (corners[i] for i in t)
112 n = face_normal(a, b, c)
113 for p in (a, b, c):
114 local = tuple((p[i] - PARENT_T[i]) / PARENT_S for i in range(3))
115 positions.extend(rot_y(-90.0, local))
116 normals.extend(rot_y(-90.0, n))
117 indices.append(len(indices))
118 blob = gen.Blob()
119 index_acc = blob.add_u16(indices, "SCALAR", len(indices))
120 pmin, pmax = gen.vec_min_max(positions, 3)
121 pos_acc = blob.add_f32(positions, "VEC3", len(positions) // 3, pmin, pmax)
122 nrm_acc = blob.add_f32(normals, "VEC3", len(normals) // 3)
123 times = blob.add_f32([0.0, 1.0], "SCALAR", 2, [0.0], [1.0])
124 half = math.radians(45.0)
125 rot_out = blob.add_f32([0, 0, 0, 1, 0, math.sin(half), 0, math.cos(half)], "VEC4", 2)
126 half90 = math.radians(45.0)
127 gltf = {
128 "asset": {
129 "version": "2.0",
130 "generator": "scripts/generate_static_bodies.py",
131 "extras": {"testAsset": True, "license": "CC0-1.0", "skin": False},
132 },
133 "scene": 0,
134 "scenes": [{"name": "static-body", "nodes": [0]}],
135 "nodes": [
136 {"name": "static-root", "translation": list(PARENT_T), "scale": [PARENT_S] * 3, "children": [1]},
137 {"name": "static-body-mesh", "rotation": [0.0, math.sin(half90), 0.0, math.cos(half90)], "mesh": 0},
138 ],
139 "meshes": [{"name": "static-body", "primitives": [{
140 "attributes": {"POSITION": pos_acc, "NORMAL": nrm_acc},
141 "indices": index_acc, "material": 0}]}],
142 "materials": [{"name": "static-body", "pbrMetallicRoughness": {
143 "baseColorFactor": [0.45, 0.55, 0.70, 1.0], "metallicFactor": 0.0, "roughnessFactor": 1.0}}],
144 "animations": [{"name": "spin-not-applied", "channels": [{"sampler": 0, "target": {"node": 0, "path": "rotation"}}],
145 "samplers": [{"input": times, "output": rot_out, "interpolation": "LINEAR"}]}],
146 "buffers": [{"byteLength": len(blob.data)}],
147 "bufferViews": blob.views,
148 "accessors": blob.accessors,
149 }
150 (OUT / "static-body-male.glb").write_bytes(gen.pack_glb(gltf, bytes(blob.data)))
151
152
153def main() -> None:
154 OUT.mkdir(parents=True, exist_ok=True)
155 write_obj("male")
156 write_glb("male")
157 for p in sorted(OUT.iterdir()):
158 print(f"wrote {p} ({p.stat().st_size} bytes)")
159
160
161if __name__ == "__main__":
162 main()
src/camera.rs +6 −0
@@ -66,6 +66,11 @@ fn ancestor(body: &PreviewCharacter, parent: usize, child: usize) -> bool {
66 66 }
67 67 /// Select the base of one neck chain, not a similarly named unrelated skeleton.
68 68 pub fn neck_height(body: &PreviewCharacter) -> Result<(usize, i32), String> {
69 if body.is_static() {
70 // Documented fallback: a static body has no bones, so no neck joint
71 // is guessed from geometry; `resolve` uses center_y (or a manual pivot).
72 return Err("Statisch lichaam zonder skeleton: geen nekbot".into());
73 }
69 74 let candidates = body
70 75 .joint_names
71 76 .iter()
@@ -181,6 +186,7 @@ mod tests {
181 186 uvs: Vec::new(),
182 187 surfaces: Vec::new(),
183 188 clips: Vec::new(),
189 rig: preview::BodyRig::Skinned,
184 190 }
185 191 }
186 192
@@ -66,6 +66,11 @@ fn ancestor(body: &PreviewCharacter, parent: usize, child: usize) -> bool {
66}
66}
67/// Select the base of one neck chain, not a similarly named unrelated skeleton.
67/// Select the base of one neck chain, not a similarly named unrelated skeleton.
68pub fn neck_height(body: &PreviewCharacter) -> Result<(usize, i32), String> {
68pub fn neck_height(body: &PreviewCharacter) -> Result<(usize, i32), String> {
69 if body.is_static() {
70 // Documented fallback: a static body has no bones, so no neck joint
71 // is guessed from geometry; `resolve` uses center_y (or a manual pivot).
72 return Err("Statisch lichaam zonder skeleton: geen nekbot".into());
73 }
69 let candidates = body
74 let candidates = body
70 .joint_names
75 .joint_names
71 .iter()
76 .iter()
@@ -181,6 +186,7 @@ mod tests {
181 uvs: Vec::new(),
186 uvs: Vec::new(),
182 surfaces: Vec::new(),
187 surfaces: Vec::new(),
183 clips: Vec::new(),
188 clips: Vec::new(),
189 rig: preview::BodyRig::Skinned,
184 }
190 }
185 }
191 }
186
192
src/editor.rs +113 −6
@@ -385,6 +385,10 @@ impl EditorApp {
385 385 pub fn render_error(&self) -> Option<&str> {
386 386 self.render_ui.error.as_deref()
387 387 }
388 /// Same as the **Live 3D** button: leave the pass preview, show the viewport.
389 pub fn show_live_3d(&mut self) {
390 self.render_ui.preview = None;
391 }
388 392 pub fn render_all_angles(&mut self) {
389 393 self.start_render();
390 394 }
@@ -411,7 +415,7 @@ impl EditorApp {
411 415 }
412 416 let Some(character) = self.character().cloned() else {
413 417 self.session
414 .fail("dit lichaam heeft geen tekenbaar glTF/GLB- of FBX-model");
418 .fail("dit lichaam heeft geen tekenbaar glTF/GLB-, FBX- of OBJ-model");
415 419 return None;
416 420 };
417 421 let root = self.session.project_root()?;
@@ -737,7 +741,7 @@ impl EditorApp {
737 741 if let Some(resolved) = &diagnostic {
738 742 ui.label(resolved.description());
739 743 } else {
740 ui.label("Laad een glTF/GLB- of FBX-lichaam om de nekpivot te bepalen.");
744 ui.label("Laad een glTF/GLB-, FBX- of OBJ-lichaam om de nekpivot te bepalen.");
741 745 }
742 746 }
743 747 }
@@ -997,6 +1001,14 @@ impl EditorApp {
997 1001 self.open_preview2d();
998 1002 }
999 1003 });
1004 if let Some(reason) = self.character().and_then(PreviewCharacter::static_reason) {
1005 ui.colored_label(
1006 egui::Color32::from_rgb(230, 190, 90),
1007 format!(
1008 "{reason}. Render alle hoeken maakt één bindpose-sample (clip000-bind) × 36 hoeken."
1009 ),
1010 );
1011 }
1000 1012 self.framing_ui(ui, usize::from(sex == Sex::Female));
1001 1013 self.render_progress_ui(ui);
1002 1014 self.resume_ui(ui);
@@ -1012,7 +1024,7 @@ impl EditorApp {
1012 1024 self.viewport
1013 1025 .show_framed(ui, &mut self.controls, character, &[], framing);
1014 1026 } else {
1015 ui.label("Open een glTF/GLB- of FBX-lichaam voor de live 3D-weergave.");
1027 ui.label("Open een glTF/GLB-, FBX- of OBJ-lichaam voor de live 3D-weergave.");
1016 1028 }
1017 1029 }
1018 1030 });
@@ -1248,7 +1260,17 @@ impl EditorApp {
1248 1260 "Bot: {}",
1249 1261 selected.as_deref().unwrap_or("nog niet gekoppeld")
1250 1262 ));
1251 if let Some(body) = body {
1263 if let Some(reason) = body.and_then(PreviewCharacter::static_reason) {
1264 ui.add_enabled_ui(false, |ui| {
1265 ui.collapsing("Skeleton / botten", |ui| {
1266 ui.label("Geen botten");
1267 });
1268 });
1269 ui.colored_label(
1270 egui::Color32::LIGHT_RED,
1271 format!("{reason}. Botkoppeling is niet beschikbaar; kies een geskind glTF/GLB- of FBX-lichaam."),
1272 );
1273 } else if let Some(body) = body {
1252 1274 if preset.rigs[sex].as_ref()
1253 1275 != Some(&crate::equipment::RigSignature::of(body))
1254 1276 {
@@ -1297,11 +1319,15 @@ impl EditorApp {
1297 1319 ui.disable();
1298 1320 }
1299 1321 ui.horizontal_wrapped(|ui| {
1322 let static_body = self.character().is_some_and(PreviewCharacter::is_static);
1300 1323 if ui
1301 1324 .add_enabled(
1302 !self.render_ui.running,
1325 !self.render_ui.running && !static_body,
1303 1326 egui::Button::new("Render equipment · alle hoeken"),
1304 1327 )
1328 .on_disabled_hover_text(
1329 "Niet beschikbaar: statisch lichaam zonder skeleton (geen bot om aan te hangen)",
1330 )
1305 1331 .clicked()
1306 1332 {
1307 1333 self.start_render();
@@ -1330,7 +1356,18 @@ impl EditorApp {
1330 1356 .equipment
1331 1357 .get(index)
1332 1358 .and_then(|item| item.preset.as_ref());
1333 let presets: Vec<_> = preset.into_iter().collect();
1359 // A static body has no bones; equipment layers are not attached
1360 // to an invented skeleton, so the live view shows only the body.
1361 let presets: Vec<_> = preset
1362 .into_iter()
1363 .filter(|_| !body.is_some_and(PreviewCharacter::is_static))
1364 .collect();
1365 if let Some(reason) = body.and_then(PreviewCharacter::static_reason) {
1366 ui.colored_label(
1367 egui::Color32::from_rgb(230, 190, 90),
1368 format!("{reason}. Equipment koppelen en renderen is voor dit lichaam niet beschikbaar."),
1369 );
1370 }
1334 1371 self.viewport
1335 1372 .show_framed(ui, &mut self.controls, body, &presets, framing);
1336 1373 });
@@ -1923,6 +1960,76 @@ mod tests {
1923 1960 let _ = std::fs::remove_dir_all(root);
1924 1961 }
1925 1962
1963 #[test]
1964 fn static_obj_body_reaches_live_view_and_disables_bone_functions() {
1965 let root = root();
1966 let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
1967 let mut app = EditorApp::default();
1968 app.session.install_new_project(&root).unwrap();
1969 let mut relative = String::new();
1970 for name in [
1971 "static-body-male.obj",
1972 "static-body-male.mtl",
1973 "static-body-albedo.png",
1974 ] {
1975 let r = crate::session::adopt_file(
1976 &root,
1977 &fixtures.join("bodies/static").join(name),
1978 "assets/body",
1979 )
1980 .unwrap();
1981 if name.ends_with(".obj") {
1982 relative = r;
1983 }
1984 }
1985 app.session.set_body_path(Sex::Male, relative.clone());
1986 app.session.save().unwrap();
1987 app.open_path(root.join("character.ppd"));
1988 let male = app
1989 .preview_male
1990 .clone()
1991 .expect("statisch OBJ-lichaam in live view");
1992 assert!(male.is_static());
1993 assert!(male.clips.is_empty() && male.joint_names.is_empty());
1994 let inventory = app.inventories[0].as_ref().unwrap().as_ref().unwrap();
1995 assert!(inventory.joints.is_empty() && inventory.clips.is_empty());
1996 // Equipment can be imported but is never auto-bound to an invented bone.
1997 let index = app.session.add_equipment();
1998 app.session.equipment[index].slug = "sword".into();
1999 app.session.active_body = Some(Sex::Male);
2000 app.take_equipment(index, &fixtures.join("equipment/sword.obj"));
2001 let preset = app.session.equipment[index].preset.clone().expect("preset");
2002 assert!(preset.attachments[0].bones[0].is_none());
2003 assert!(preset.rigs[0].is_none());
2004 app.open_body_configuration(Sex::Male);
2005 let job = app.build_job().expect("render job for static body");
2006 assert_eq!(job.body_model.path, relative);
2007 assert!(job.character.is_static());
2008 let contract = crate::production::contract(job).unwrap();
2009 assert_eq!(contract.plan.images, 36);
2010 assert_eq!(contract.plan.clips[0].token, "clip000-bind");
2011 app.open_equipment_configuration(index, Sex::Male);
2012 let job = app.build_job().expect("equipment job object");
2013 assert!(crate::production::contract(job)
2014 .err()
2015 .unwrap()
2016 .contains("statisch"));
2017 // Headless UI frames for the body and equipment screens must not panic.
2018 let ctx = egui::Context::default();
2019 for screen in 0..2 {
2020 if screen == 0 {
2021 app.open_body_configuration(Sex::Male);
2022 } else {
2023 app.open_equipment_configuration(index, Sex::Male);
2024 }
2025 let _ = ctx.run(egui::RawInput::default(), |ctx| match screen {
2026 0 => app.show_body(ctx),
2027 _ => app.show_equipment(ctx),
2028 });
2029 }
2030 let _ = std::fs::remove_dir_all(root);
2031 }
2032
1926 2033 #[test]
1927 2034 fn open_with_project_path_loads_bodies_and_models() {
1928 2035 let root = root().join("project with spaces");
@@ -385,6 +385,10 @@ impl EditorApp {
385 pub fn render_error(&self) -> Option<&str> {
385 pub fn render_error(&self) -> Option<&str> {
386 self.render_ui.error.as_deref()
386 self.render_ui.error.as_deref()
387 }
387 }
388 /// Same as the **Live 3D** button: leave the pass preview, show the viewport.
389 pub fn show_live_3d(&mut self) {
390 self.render_ui.preview = None;
391 }
388 pub fn render_all_angles(&mut self) {
392 pub fn render_all_angles(&mut self) {
389 self.start_render();
393 self.start_render();
390 }
394 }
@@ -411,7 +415,7 @@ impl EditorApp {
411 }
415 }
412 let Some(character) = self.character().cloned() else {
416 let Some(character) = self.character().cloned() else {
413 self.session
417 self.session
414 .fail("dit lichaam heeft geen tekenbaar glTF/GLB- of FBX-model");
418 .fail("dit lichaam heeft geen tekenbaar glTF/GLB-, FBX- of OBJ-model");
415 return None;
419 return None;
416 };
420 };
417 let root = self.session.project_root()?;
421 let root = self.session.project_root()?;
@@ -737,7 +741,7 @@ impl EditorApp {
737 if let Some(resolved) = &diagnostic {
741 if let Some(resolved) = &diagnostic {
738 ui.label(resolved.description());
742 ui.label(resolved.description());
739 } else {
743 } else {
740 ui.label("Laad een glTF/GLB- of FBX-lichaam om de nekpivot te bepalen.");
744 ui.label("Laad een glTF/GLB-, FBX- of OBJ-lichaam om de nekpivot te bepalen.");
741 }
745 }
742 }
746 }
743 }
747 }
@@ -997,6 +1001,14 @@ impl EditorApp {
997 self.open_preview2d();
1001 self.open_preview2d();
998 }
1002 }
999 });
1003 });
1004 if let Some(reason) = self.character().and_then(PreviewCharacter::static_reason) {
1005 ui.colored_label(
1006 egui::Color32::from_rgb(230, 190, 90),
1007 format!(
1008 "{reason}. Render alle hoeken maakt één bindpose-sample (clip000-bind) × 36 hoeken."
1009 ),
1010 );
1011 }
1000 self.framing_ui(ui, usize::from(sex == Sex::Female));
1012 self.framing_ui(ui, usize::from(sex == Sex::Female));
1001 self.render_progress_ui(ui);
1013 self.render_progress_ui(ui);
1002 self.resume_ui(ui);
1014 self.resume_ui(ui);
@@ -1012,7 +1024,7 @@ impl EditorApp {
1012 self.viewport
1024 self.viewport
1013 .show_framed(ui, &mut self.controls, character, &[], framing);
1025 .show_framed(ui, &mut self.controls, character, &[], framing);
1014 } else {
1026 } else {
1015 ui.label("Open een glTF/GLB- of FBX-lichaam voor de live 3D-weergave.");
1027 ui.label("Open een glTF/GLB-, FBX- of OBJ-lichaam voor de live 3D-weergave.");
1016 }
1028 }
1017 }
1029 }
1018 });
1030 });
@@ -1248,7 +1260,17 @@ impl EditorApp {
1248 "Bot: {}",
1260 "Bot: {}",
1249 selected.as_deref().unwrap_or("nog niet gekoppeld")
1261 selected.as_deref().unwrap_or("nog niet gekoppeld")
1250 ));
1262 ));
1251 if let Some(body) = body {
1263 if let Some(reason) = body.and_then(PreviewCharacter::static_reason) {
1264 ui.add_enabled_ui(false, |ui| {
1265 ui.collapsing("Skeleton / botten", |ui| {
1266 ui.label("Geen botten");
1267 });
1268 });
1269 ui.colored_label(
1270 egui::Color32::LIGHT_RED,
1271 format!("{reason}. Botkoppeling is niet beschikbaar; kies een geskind glTF/GLB- of FBX-lichaam."),
1272 );
1273 } else if let Some(body) = body {
1252 if preset.rigs[sex].as_ref()
1274 if preset.rigs[sex].as_ref()
1253 != Some(&crate::equipment::RigSignature::of(body))
1275 != Some(&crate::equipment::RigSignature::of(body))
1254 {
1276 {
@@ -1297,11 +1319,15 @@ impl EditorApp {
1297 ui.disable();
1319 ui.disable();
1298 }
1320 }
1299 ui.horizontal_wrapped(|ui| {
1321 ui.horizontal_wrapped(|ui| {
1322 let static_body = self.character().is_some_and(PreviewCharacter::is_static);
1300 if ui
1323 if ui
1301 .add_enabled(
1324 .add_enabled(
1302 !self.render_ui.running,
1325 !self.render_ui.running && !static_body,
1303 egui::Button::new("Render equipment · alle hoeken"),
1326 egui::Button::new("Render equipment · alle hoeken"),
1304 )
1327 )
1328 .on_disabled_hover_text(
1329 "Niet beschikbaar: statisch lichaam zonder skeleton (geen bot om aan te hangen)",
1330 )
1305 .clicked()
1331 .clicked()
1306 {
1332 {
1307 self.start_render();
1333 self.start_render();
@@ -1330,7 +1356,18 @@ impl EditorApp {
1330 .equipment
1356 .equipment
1331 .get(index)
1357 .get(index)
1332 .and_then(|item| item.preset.as_ref());
1358 .and_then(|item| item.preset.as_ref());
1333 let presets: Vec<_> = preset.into_iter().collect();
1359 // A static body has no bones; equipment layers are not attached
1360 // to an invented skeleton, so the live view shows only the body.
1361 let presets: Vec<_> = preset
1362 .into_iter()
1363 .filter(|_| !body.is_some_and(PreviewCharacter::is_static))
1364 .collect();
1365 if let Some(reason) = body.and_then(PreviewCharacter::static_reason) {
1366 ui.colored_label(
1367 egui::Color32::from_rgb(230, 190, 90),
1368 format!("{reason}. Equipment koppelen en renderen is voor dit lichaam niet beschikbaar."),
1369 );
1370 }
1334 self.viewport
1371 self.viewport
1335 .show_framed(ui, &mut self.controls, body, &presets, framing);
1372 .show_framed(ui, &mut self.controls, body, &presets, framing);
1336 });
1373 });
@@ -1923,6 +1960,76 @@ mod tests {
1923 let _ = std::fs::remove_dir_all(root);
1960 let _ = std::fs::remove_dir_all(root);
1924 }
1961 }
1925
1962
1963 #[test]
1964 fn static_obj_body_reaches_live_view_and_disables_bone_functions() {
1965 let root = root();
1966 let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
1967 let mut app = EditorApp::default();
1968 app.session.install_new_project(&root).unwrap();
1969 let mut relative = String::new();
1970 for name in [
1971 "static-body-male.obj",
1972 "static-body-male.mtl",
1973 "static-body-albedo.png",
1974 ] {
1975 let r = crate::session::adopt_file(
1976 &root,
1977 &fixtures.join("bodies/static").join(name),
1978 "assets/body",
1979 )
1980 .unwrap();
1981 if name.ends_with(".obj") {
1982 relative = r;
1983 }
1984 }
1985 app.session.set_body_path(Sex::Male, relative.clone());
1986 app.session.save().unwrap();
1987 app.open_path(root.join("character.ppd"));
1988 let male = app
1989 .preview_male
1990 .clone()
1991 .expect("statisch OBJ-lichaam in live view");
1992 assert!(male.is_static());
1993 assert!(male.clips.is_empty() && male.joint_names.is_empty());
1994 let inventory = app.inventories[0].as_ref().unwrap().as_ref().unwrap();
1995 assert!(inventory.joints.is_empty() && inventory.clips.is_empty());
1996 // Equipment can be imported but is never auto-bound to an invented bone.
1997 let index = app.session.add_equipment();
1998 app.session.equipment[index].slug = "sword".into();
1999 app.session.active_body = Some(Sex::Male);
2000 app.take_equipment(index, &fixtures.join("equipment/sword.obj"));
2001 let preset = app.session.equipment[index].preset.clone().expect("preset");
2002 assert!(preset.attachments[0].bones[0].is_none());
2003 assert!(preset.rigs[0].is_none());
2004 app.open_body_configuration(Sex::Male);
2005 let job = app.build_job().expect("render job for static body");
2006 assert_eq!(job.body_model.path, relative);
2007 assert!(job.character.is_static());
2008 let contract = crate::production::contract(job).unwrap();
2009 assert_eq!(contract.plan.images, 36);
2010 assert_eq!(contract.plan.clips[0].token, "clip000-bind");
2011 app.open_equipment_configuration(index, Sex::Male);
2012 let job = app.build_job().expect("equipment job object");
2013 assert!(crate::production::contract(job)
2014 .err()
2015 .unwrap()
2016 .contains("statisch"));
2017 // Headless UI frames for the body and equipment screens must not panic.
2018 let ctx = egui::Context::default();
2019 for screen in 0..2 {
2020 if screen == 0 {
2021 app.open_body_configuration(Sex::Male);
2022 } else {
2023 app.open_equipment_configuration(index, Sex::Male);
2024 }
2025 let _ = ctx.run(egui::RawInput::default(), |ctx| match screen {
2026 0 => app.show_body(ctx),
2027 _ => app.show_equipment(ctx),
2028 });
2029 }
2030 let _ = std::fs::remove_dir_all(root);
2031 }
2032
1926 #[test]
2033 #[test]
1927 fn open_with_project_path_loads_bodies_and_models() {
2034 fn open_with_project_path_loads_bodies_and_models() {
1928 let root = root().join("project with spaces");
2035 let root = root().join("project with spaces");
src/equipment.rs +33 −7
@@ -19,6 +19,9 @@ const MAX_META: usize = 4 * 1024 * 1024;
19 19 const MAX_ASSET: usize = 8 * 1024 * 1024;
20 20 const MAX_ASSETS: usize = 32;
21 21 const MAX_VERTICES: usize = 80_000;
22 /// Vertex budget when the rigid readers load a static (unskinned) *body*;
23 /// same ceiling as the FBX body loader.
24 pub(crate) const MAX_STATIC_BODY_VERTICES: usize = 1_000_000;
22 25
23 26 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24 27 #[serde(deny_unknown_fields)]
@@ -112,6 +115,9 @@ impl Preset {
112 115 Ok(())
113 116 }
114 117 pub fn validate(&self) -> Result<(), String> {
118 self.validate_with(MAX_VERTICES)
119 }
120 fn validate_with(&self, max_vertices: usize) -> Result<(), String> {
115 121 if !valid_name(&self.name) {
116 122 return Err(
117 123 "equipmentnaam: kleine letters/cijfers/-/_, geen spaties, en niet 'body'".into(),
@@ -190,8 +196,8 @@ impl Preset {
190 196 }
191 197 for mesh in &attachment.meshes {
192 198 vertices += mesh.positions.len();
193 if vertices > MAX_VERTICES
194 || mesh.indices.len() > MAX_VERTICES * 6
199 if vertices > max_vertices
200 || mesh.indices.len() > max_vertices * 6
195 201 || mesh.indices.len() < 3
196 202 || mesh.indices.len() % 3 != 0
197 203 || mesh.positions.len() != mesh.normals.len()
@@ -477,7 +483,11 @@ pub struct TextureCache {
477 483 bytes: usize,
478 484 }
479 485 impl TextureCache {
480 fn get(&mut self, preset: &Preset, index: usize) -> Result<std::sync::Arc<Texture>, String> {
486 pub(crate) fn get(
487 &mut self,
488 preset: &Preset,
489 index: usize,
490 ) -> Result<std::sync::Arc<Texture>, String> {
481 491 let bytes = &preset.assets.get(index).ok_or("texture ontbreekt")?.bytes;
482 492 if let Some((_, texture)) = self.entries.iter().find(|(source, _)| source == bytes) {
483 493 return Ok(texture.clone());
@@ -553,6 +563,14 @@ pub fn layers_cached(
553 563 /// Import an OBJ and its declared MTL/PNG dependencies into the preset itself.
554 564 /// No extraction directories, absolute references or parent traversal allowed.
555 565 pub fn import_obj(preset: &mut Preset, source: &Path) -> Result<usize, String> {
566 import_obj_limited(preset, source, MAX_VERTICES)
567 }
568
569 pub(crate) fn import_obj_limited(
570 preset: &mut Preset,
571 source: &Path,
572 max_vertices: usize,
573 ) -> Result<usize, String> {
556 574 if source
557 575 .extension()
558 576 .and_then(|x| x.to_str())
@@ -670,7 +688,7 @@ pub fn import_obj(preset: &mut Preset, source: &Path) -> Result<usize, String> {
670 688 for model in models {
671 689 let source = model.mesh;
672 690 vertices += source.positions.len() / 3;
673 if vertices > MAX_VERTICES || source.indices.len() > MAX_VERTICES * 6 {
691 if vertices > max_vertices || source.indices.len() > max_vertices * 6 {
674 692 return Err("equipmentmesh is te groot".into());
675 693 }
676 694 let positions: Vec<[f32; 3]> = source
@@ -732,7 +750,7 @@ pub fn import_obj(preset: &mut Preset, source: &Path) -> Result<usize, String> {
732 750 shiny: None,
733 751 specular: 0.25,
734 752 });
735 candidate.validate()?;
753 candidate.validate_with(max_vertices)?;
736 754 *preset = candidate;
737 755 Ok(preset.attachments.len() - 1)
738 756 }
@@ -757,6 +775,14 @@ pub fn import_model(preset: &mut Preset, source: &Path) -> Result<usize, String>
757 775 /// Embedded PNG textures are copied into the preset; external PNG textures
758 776 /// must be relative files inside the model folder and are embedded too.
759 777 pub fn import_fbx(preset: &mut Preset, source: &Path) -> Result<usize, String> {
778 import_fbx_limited(preset, source, MAX_VERTICES)
779 }
780
781 pub(crate) fn import_fbx_limited(
782 preset: &mut Preset,
783 source: &Path,
784 max_vertices: usize,
785 ) -> Result<usize, String> {
760 786 if source
761 787 .extension()
762 788 .and_then(|x| x.to_str())
@@ -905,7 +931,7 @@ pub fn import_fbx(preset: &mut Preset, source: &Path) -> Result<usize, String> {
905 931 continue;
906 932 }
907 933 vertices += positions.len();
908 if vertices > MAX_VERTICES {
934 if vertices > max_vertices {
909 935 return Err("equipmentmesh is te groot".into());
910 936 }
911 937 if positions
@@ -1013,7 +1039,7 @@ pub fn import_fbx(preset: &mut Preset, source: &Path) -> Result<usize, String> {
1013 1039 shiny: None,
1014 1040 specular: 0.25,
1015 1041 });
1016 candidate.validate()?;
1042 candidate.validate_with(max_vertices)?;
1017 1043 *preset = candidate;
1018 1044 Ok(preset.attachments.len() - 1)
1019 1045 }
@@ -19,6 +19,9 @@ const MAX_META: usize = 4 * 1024 * 1024;
19const MAX_ASSET: usize = 8 * 1024 * 1024;
19const MAX_ASSET: usize = 8 * 1024 * 1024;
20const MAX_ASSETS: usize = 32;
20const MAX_ASSETS: usize = 32;
21const MAX_VERTICES: usize = 80_000;
21const MAX_VERTICES: usize = 80_000;
22/// Vertex budget when the rigid readers load a static (unskinned) *body*;
23/// same ceiling as the FBX body loader.
24pub(crate) const MAX_STATIC_BODY_VERTICES: usize = 1_000_000;
22
25
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
27#[serde(deny_unknown_fields)]
@@ -112,6 +115,9 @@ impl Preset {
112 Ok(())
115 Ok(())
113 }
116 }
114 pub fn validate(&self) -> Result<(), String> {
117 pub fn validate(&self) -> Result<(), String> {
118 self.validate_with(MAX_VERTICES)
119 }
120 fn validate_with(&self, max_vertices: usize) -> Result<(), String> {
115 if !valid_name(&self.name) {
121 if !valid_name(&self.name) {
116 return Err(
122 return Err(
117 "equipmentnaam: kleine letters/cijfers/-/_, geen spaties, en niet 'body'".into(),
123 "equipmentnaam: kleine letters/cijfers/-/_, geen spaties, en niet 'body'".into(),
@@ -190,8 +196,8 @@ impl Preset {
190 }
196 }
191 for mesh in &attachment.meshes {
197 for mesh in &attachment.meshes {
192 vertices += mesh.positions.len();
198 vertices += mesh.positions.len();
193 if vertices > MAX_VERTICES
199 if vertices > max_vertices
194 || mesh.indices.len() > MAX_VERTICES * 6
200 || mesh.indices.len() > max_vertices * 6
195 || mesh.indices.len() < 3
201 || mesh.indices.len() < 3
196 || mesh.indices.len() % 3 != 0
202 || mesh.indices.len() % 3 != 0
197 || mesh.positions.len() != mesh.normals.len()
203 || mesh.positions.len() != mesh.normals.len()
@@ -477,7 +483,11 @@ pub struct TextureCache {
477 bytes: usize,
483 bytes: usize,
478}
484}
479impl TextureCache {
485impl TextureCache {
480 fn get(&mut self, preset: &Preset, index: usize) -> Result<std::sync::Arc<Texture>, String> {
486 pub(crate) fn get(
487 &mut self,
488 preset: &Preset,
489 index: usize,
490 ) -> Result<std::sync::Arc<Texture>, String> {
481 let bytes = &preset.assets.get(index).ok_or("texture ontbreekt")?.bytes;
491 let bytes = &preset.assets.get(index).ok_or("texture ontbreekt")?.bytes;
482 if let Some((_, texture)) = self.entries.iter().find(|(source, _)| source == bytes) {
492 if let Some((_, texture)) = self.entries.iter().find(|(source, _)| source == bytes) {
483 return Ok(texture.clone());
493 return Ok(texture.clone());
@@ -553,6 +563,14 @@ pub fn layers_cached(
553/// Import an OBJ and its declared MTL/PNG dependencies into the preset itself.
563/// Import an OBJ and its declared MTL/PNG dependencies into the preset itself.
554/// No extraction directories, absolute references or parent traversal allowed.
564/// No extraction directories, absolute references or parent traversal allowed.
555pub fn import_obj(preset: &mut Preset, source: &Path) -> Result<usize, String> {
565pub fn import_obj(preset: &mut Preset, source: &Path) -> Result<usize, String> {
566 import_obj_limited(preset, source, MAX_VERTICES)
567}
568
569pub(crate) fn import_obj_limited(
570 preset: &mut Preset,
571 source: &Path,
572 max_vertices: usize,
573) -> Result<usize, String> {
556 if source
574 if source
557 .extension()
575 .extension()
558 .and_then(|x| x.to_str())
576 .and_then(|x| x.to_str())
@@ -670,7 +688,7 @@ pub fn import_obj(preset: &mut Preset, source: &Path) -> Result<usize, String> {
670 for model in models {
688 for model in models {
671 let source = model.mesh;
689 let source = model.mesh;
672 vertices += source.positions.len() / 3;
690 vertices += source.positions.len() / 3;
673 if vertices > MAX_VERTICES || source.indices.len() > MAX_VERTICES * 6 {
691 if vertices > max_vertices || source.indices.len() > max_vertices * 6 {
674 return Err("equipmentmesh is te groot".into());
692 return Err("equipmentmesh is te groot".into());
675 }
693 }
676 let positions: Vec<[f32; 3]> = source
694 let positions: Vec<[f32; 3]> = source
@@ -732,7 +750,7 @@ pub fn import_obj(preset: &mut Preset, source: &Path) -> Result<usize, String> {
732 shiny: None,
750 shiny: None,
733 specular: 0.25,
751 specular: 0.25,
734 });
752 });
735 candidate.validate()?;
753 candidate.validate_with(max_vertices)?;
736 *preset = candidate;
754 *preset = candidate;
737 Ok(preset.attachments.len() - 1)
755 Ok(preset.attachments.len() - 1)
738}
756}
@@ -757,6 +775,14 @@ pub fn import_model(preset: &mut Preset, source: &Path) -> Result<usize, String>
757/// Embedded PNG textures are copied into the preset; external PNG textures
775/// Embedded PNG textures are copied into the preset; external PNG textures
758/// must be relative files inside the model folder and are embedded too.
776/// must be relative files inside the model folder and are embedded too.
759pub fn import_fbx(preset: &mut Preset, source: &Path) -> Result<usize, String> {
777pub fn import_fbx(preset: &mut Preset, source: &Path) -> Result<usize, String> {
778 import_fbx_limited(preset, source, MAX_VERTICES)
779}
780
781pub(crate) fn import_fbx_limited(
782 preset: &mut Preset,
783 source: &Path,
784 max_vertices: usize,
785) -> Result<usize, String> {
760 if source
786 if source
761 .extension()
787 .extension()
762 .and_then(|x| x.to_str())
788 .and_then(|x| x.to_str())
@@ -905,7 +931,7 @@ pub fn import_fbx(preset: &mut Preset, source: &Path) -> Result<usize, String> {
905 continue;
931 continue;
906 }
932 }
907 vertices += positions.len();
933 vertices += positions.len();
908 if vertices > MAX_VERTICES {
934 if vertices > max_vertices {
909 return Err("equipmentmesh is te groot".into());
935 return Err("equipmentmesh is te groot".into());
910 }
936 }
911 if positions
937 if positions
@@ -1013,7 +1039,7 @@ pub fn import_fbx(preset: &mut Preset, source: &Path) -> Result<usize, String> {
1013 shiny: None,
1039 shiny: None,
1014 specular: 0.25,
1040 specular: 0.25,
1015 });
1041 });
1016 candidate.validate()?;
1042 candidate.validate_with(max_vertices)?;
1017 *preset = candidate;
1043 *preset = candidate;
1018 Ok(preset.attachments.len() - 1)
1044 Ok(preset.attachments.len() - 1)
1019}
1045}
src/fbx_scene.rs +7 −1
@@ -57,6 +57,11 @@ pub struct FbxTextureRef {
57 57 pub embedded: bool,
58 58 }
59 59
60 /// Error returned by [`load_fbx_drawable`] when the file has drawable meshes
61 /// but none of them is skinned; `preview::load_body_file` then loads it as a
62 /// static body instead of inventing a skeleton.
63 pub const NO_SKINNED_MESH: &str = "FBX bevat geen skinned mesh met driehoeken";
64
60 65 /// Normalized drawable FBX scene ready for the shared sampling API.
61 66 #[derive(Debug, Clone)]
62 67 pub struct FbxDrawableScene {
@@ -221,6 +226,7 @@ pub fn load_fbx_drawable(path: &Path) -> Result<FbxDrawableScene, String> {
221 226 uvs,
222 227 surfaces,
223 228 clips,
229 rig: crate::preview::BodyRig::Skinned,
224 230 },
225 231 provenance,
226 232 materials,
@@ -240,7 +246,7 @@ fn find_skinned_mesh(scene: &ufbx::Scene) -> Result<(usize, &ufbx::Mesh), String
240 246 return Ok((node.element.typed_id as usize, mesh));
241 247 }
242 248 }
243 Err("FBX bevat geen skinned mesh met driehoeken".into())
249 Err(NO_SKINNED_MESH.into())
244 250 }
245 251
246 252 fn build_nodes(scene: &ufbx::Scene) -> Result<Vec<SceneNode>, String> {
@@ -57,6 +57,11 @@ pub struct FbxTextureRef {
57 pub embedded: bool,
57 pub embedded: bool,
58}
58}
59
59
60/// Error returned by [`load_fbx_drawable`] when the file has drawable meshes
61/// but none of them is skinned; `preview::load_body_file` then loads it as a
62/// static body instead of inventing a skeleton.
63pub const NO_SKINNED_MESH: &str = "FBX bevat geen skinned mesh met driehoeken";
64
60/// Normalized drawable FBX scene ready for the shared sampling API.
65/// Normalized drawable FBX scene ready for the shared sampling API.
61#[derive(Debug, Clone)]
66#[derive(Debug, Clone)]
62pub struct FbxDrawableScene {
67pub struct FbxDrawableScene {
@@ -221,6 +226,7 @@ pub fn load_fbx_drawable(path: &Path) -> Result<FbxDrawableScene, String> {
221 uvs,
226 uvs,
222 surfaces,
227 surfaces,
223 clips,
228 clips,
229 rig: crate::preview::BodyRig::Skinned,
224 },
230 },
225 provenance,
231 provenance,
226 materials,
232 materials,
@@ -240,7 +246,7 @@ fn find_skinned_mesh(scene: &ufbx::Scene) -> Result<(usize, &ufbx::Mesh), String
240 return Ok((node.element.typed_id as usize, mesh));
246 return Ok((node.element.typed_id as usize, mesh));
241 }
247 }
242 }
248 }
243 Err("FBX bevat geen skinned mesh met driehoeken".into())
249 Err(NO_SKINNED_MESH.into())
244}
250}
245
251
246fn build_nodes(scene: &ufbx::Scene) -> Result<Vec<SceneNode>, String> {
252fn build_nodes(scene: &ufbx::Scene) -> Result<Vec<SceneNode>, String> {
src/lib.rs +2 −1
@@ -2,7 +2,8 @@
2 2 //!
3 3 //! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,
4 4 //! builds a normalized drawable FBX scene (`fbx_scene`) for CPU sampling,
5 //! draws skinned glTF body/equipment previews, and streams production target maps.
5 //! draws skinned glTF/FBX bodies, static (unskinned) OBJ/glTF/FBX bodies and
6 //! equipment previews, and streams production target maps.
6 7 //! Completed export runs are opened through `export_set` with checksum/identity
7 8 //! validation; `compositor` depth-composites compatible layers, relights them and applies optional rim; `preview2d` drives the rendered 2D GUI/tab/playback; `resume` recovers incomplete runs after contract and checksum verification.
8 9
@@ -2,7 +2,8 @@
2//!
2//!
3//! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,
3//! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,
4//! builds a normalized drawable FBX scene (`fbx_scene`) for CPU sampling,
4//! builds a normalized drawable FBX scene (`fbx_scene`) for CPU sampling,
5//! draws skinned glTF body/equipment previews, and streams production target maps.
5//! draws skinned glTF/FBX bodies, static (unskinned) OBJ/glTF/FBX bodies and
6//! equipment previews, and streams production target maps.
6//! Completed export runs are opened through `export_set` with checksum/identity
7//! Completed export runs are opened through `export_set` with checksum/identity
7//! validation; `compositor` depth-composites compatible layers, relights them and applies optional rim; `preview2d` drives the rendered 2D GUI/tab/playback; `resume` recovers incomplete runs after contract and checksum verification.
8//! validation; `compositor` depth-composites compatible layers, relights them and applies optional rim; `preview2d` drives the rendered 2D GUI/tab/playback; `resume` recovers incomplete runs after contract and checksum verification.
8
9
src/preview.rs +530 −102
@@ -97,6 +97,37 @@ pub struct PreviewCharacter {
97 97 pub uvs: Vec<[f32; 2]>,
98 98 pub surfaces: Vec<BodySurface>,
99 99 pub clips: Vec<PreviewClip>,
100 /// Skinned (skeleton + clips) or static geometry without a skeleton.
101 pub rig: BodyRig,
102 }
103
104 /// Whether a body has a real skin. A static body keeps an empty skeleton and
105 /// no clips; it is drawn in its stored (bind/static) pose and the production
106 /// batch renders the single `clip000-bind` sample. Nothing invents bones.
107 #[derive(Debug, Clone, PartialEq, Eq, Default)]
108 pub enum BodyRig {
109 #[default]
110 Skinned,
111 Static {
112 /// Source format label, e.g. `OBJ`, `glTF`, `FBX`.
113 format: String,
114 /// Human-readable (Dutch) explanation shown in the editor.
115 reason: String,
116 },
117 }
118
119 impl PreviewCharacter {
120 /// True when the body has no skin: no bones, no clips, static pose only.
121 pub fn is_static(&self) -> bool {
122 matches!(self.rig, BodyRig::Static { .. })
123 }
124 /// Editor explanation for a static body; `None` for a skinned body.
125 pub fn static_reason(&self) -> Option<&str> {
126 match &self.rig {
127 BodyRig::Static { reason, .. } => Some(reason),
128 BodyRig::Skinned => None,
129 }
130 }
100 131 }
101 132
102 133 #[derive(Debug, Clone)]
@@ -147,13 +178,16 @@ pub fn is_drawable_body(path: &Path) -> bool {
147 178 .and_then(|e| e.to_str())
148 179 .map(|e| e.to_ascii_lowercase())
149 180 .as_deref(),
150 Some("gltf" | "glb" | "fbx")
181 Some("gltf" | "glb" | "fbx" | "obj")
151 182 )
152 183 }
153 184
154 /// Load a skinned body for the live viewport and the production batch.
185 /// Load a body for the live viewport and the production batch.
155 186 /// glTF/GLB use the glTF reader; FBX goes through the normalized
156 187 /// `fbx_scene` loader (axis/unit conversion is applied there exactly once).
188 /// Bodies without a skin (OBJ+MTL, glTF without `skins`, FBX without a skin
189 /// deformer) load as [`BodyRig::Static`]: geometry in its stored pose, no
190 /// skeleton and no clips.
157 191 pub fn load_body_file(path: &Path) -> Result<PreviewCharacter, String> {
158 192 match path
159 193 .extension()
@@ -162,14 +196,180 @@ pub fn load_body_file(path: &Path) -> Result<PreviewCharacter, String> {
162 196 .as_deref()
163 197 {
164 198 Some("gltf" | "glb") => load_gltf_file(path),
165 Some("fbx") => crate::fbx_scene::load_fbx_drawable(path).map(|scene| scene.character),
199 Some("fbx") => match crate::fbx_scene::load_fbx_drawable(path) {
200 Ok(scene) => Ok(scene.character),
201 Err(error) if error == crate::fbx_scene::NO_SKINNED_MESH => load_static_rigid(
202 path,
203 "FBX",
204 "FBX zonder skin: statische rustpose, geen skeleton of animaties",
205 ),
206 Err(error) => Err(error),
207 },
208 Some("obj") => load_static_rigid(
209 path,
210 "OBJ",
211 "OBJ heeft geen skeleton of animaties: statische pose",
212 ),
166 213 _ => Err(format!(
167 "{} is geen tekenbaar lichaam; gebruik .glb, .gltf of .fbx",
214 "{} is geen tekenbaar lichaam; gebruik .glb, .gltf, .fbx of .obj",
168 215 path.display()
169 216 )),
170 217 }
171 218 }
172 219
220 /// Static OBJ/FBX body through the same rigid reader as equipment
221 /// (`equipment::import_obj`/`import_fbx`): OBJ is used as stored, FBX gets
222 /// the ufbx axis/unit/geometry-transform normalization once, there. The only
223 /// conversion here is the UV origin (bottom-left in OBJ/FBX, top-left in
224 /// `PreviewCharacter`), done exactly once.
225 fn load_static_rigid(path: &Path, format: &str, reason: &str) -> Result<PreviewCharacter, String> {
226 let mut preset = crate::equipment::Preset::new("static-body".into());
227 let part = match format {
228 "OBJ" => crate::equipment::import_obj_limited(
229 &mut preset,
230 path,
231 crate::equipment::MAX_STATIC_BODY_VERTICES,
232 ),
233 _ => crate::equipment::import_fbx_limited(
234 &mut preset,
235 path,
236 crate::equipment::MAX_STATIC_BODY_VERTICES,
237 ),
238 }
239 .map_err(|error| format!("statisch {format}-lichaam: {error}"))?;
240 let mut cache = crate::equipment::TextureCache::default();
241 let mut acc = StaticAcc::default();
242 for mesh in &preset.attachments[part].meshes {
243 let albedo = mesh
244 .albedo
245 .map(|index| cache.get(&preset, index))
246 .transpose()?;
247 let uvs: Vec<[f32; 2]> = mesh.uvs.iter().map(|[u, v]| [*u, 1. - *v]).collect();
248 acc.push(
249 &mesh.positions,
250 &mesh.normals,
251 &uvs,
252 None,
253 &mesh.indices,
254 mesh.color,
255 albedo,
256 0.001,
257 false,
258 )?;
259 }
260 acc.finish(format, reason.to_string())
261 }
262
263 /// Accumulates static-body primitives into one `PreviewCharacter` mesh.
264 #[derive(Default)]
265 struct StaticAcc {
266 positions: Vec<[f32; 3]>,
267 normals: Vec<[f32; 3]>,
268 uvs: Vec<[f32; 2]>,
269 colors: Vec<[f32; 4]>,
270 indices: Vec<u32>,
271 surfaces: Vec<BodySurface>,
272 normals_complete: bool,
273 any: bool,
274 }
275
276 impl StaticAcc {
277 #[allow(clippy::too_many_arguments)]
278 fn push(
279 &mut self,
280 positions: &[[f32; 3]],
281 normals: &[[f32; 3]],
282 uvs: &[[f32; 2]],
283 colors: Option<&[[f32; 4]]>,
284 indices: &[u32],
285 color: [f32; 4],
286 albedo: Option<std::sync::Arc<crate::equipment::Texture>>,
287 cutoff: f32,
288 flat_normals: bool,
289 ) -> Result<(), String> {
290 if positions.is_empty() || indices.len() < 3 {
291 return Ok(());
292 }
293 if !indices.len().is_multiple_of(3)
294 || indices.iter().any(|i| *i as usize >= positions.len())
295 {
296 return Err("statisch lichaam: ongeldige driehoeksindices".into());
297 }
298 if positions
299 .iter()
300 .chain(normals.iter())
301 .any(|v| v.iter().any(|x| !x.is_finite()))
302 {
303 return Err("statisch lichaam: niet-eindige posities of normalen".into());
304 }
305 let total = self.positions.len() + positions.len();
306 if total > crate::equipment::MAX_STATIC_BODY_VERTICES {
307 return Err("statisch lichaam heeft te veel vertices".into());
308 }
309 if !self.any {
310 self.normals_complete = true;
311 self.any = true;
312 }
313 self.normals_complete &= !flat_normals && normals.len() == positions.len();
314 let base = self.positions.len() as u32;
315 let first = self.indices.len();
316 self.indices.extend(indices.iter().map(|i| i + base));
317 self.positions.extend_from_slice(positions);
318 for i in 0..positions.len() {
319 self.normals
320 .push(normals.get(i).copied().unwrap_or([0., 1., 0.]));
321 self.uvs.push(uvs.get(i).copied().unwrap_or([0., 0.]));
322 self.colors
323 .push(colors.and_then(|c| c.get(i).copied()).unwrap_or([1.; 4]));
324 }
325 self.surfaces.push(BodySurface {
326 first,
327 count: self.indices.len() - first,
328 color,
329 albedo,
330 cutoff,
331 flat_normals,
332 });
333 Ok(())
334 }
335
336 fn finish(self, format: &str, reason: String) -> Result<PreviewCharacter, String> {
337 if self.positions.is_empty() || self.indices.len() < 3 {
338 return Err(format!(
339 "statisch {format}-lichaam heeft geen tekenbare driehoeken"
340 ));
341 }
342 let count = self.positions.len();
343 Ok(PreviewCharacter {
344 nodes: Vec::new(),
345 joint_nodes: Vec::new(),
346 joint_names: Vec::new(),
347 inverse_binds: Vec::new(),
348 positions: self.positions,
349 normals: self.normals,
350 source_normals_complete: self.normals_complete,
351 // Weight 1 on slot 0 with no bones: every skinning path resolves
352 // to identity (empty bone list), i.e. the stored pose.
353 joints: vec![[0; 4]; count],
354 weights: vec![[1., 0., 0., 0.]; count],
355 indices: self.indices,
356 base_color: self
357 .surfaces
358 .first()
359 .map(|s| s.color)
360 .unwrap_or([0.7, 0.7, 0.75, 1.0]),
361 colors: self.colors,
362 uvs: self.uvs,
363 surfaces: self.surfaces,
364 clips: Vec::new(),
365 rig: BodyRig::Static {
366 format: format.to_string(),
367 reason,
368 },
369 })
370 }
371 }
372
173 373 pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
174 374 let (document, buffers, images) = gltf::import(path).map_err(|err| err.to_string())?;
175 375 let mut nodes = Vec::new();
@@ -189,10 +389,9 @@ pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
189 389 scale,
190 390 });
191 391 }
192 let skin = document
193 .skins()
194 .next()
195 .ok_or_else(|| "het glTF-bestand heeft geen skeleton".to_string())?;
392 let Some(skin) = document.skins().next() else {
393 return load_static_gltf(&document, &buffers, &images, &nodes);
394 };
196 395 if skin.joints().len() > MAX_JOINTS {
197 396 return Err(format!(
198 397 "het skeleton heeft {} joints; de preview stopt bij {MAX_JOINTS}",
@@ -224,105 +423,26 @@ pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
224 423 let mut surfaces = Vec::new();
225 424 if let Some(mesh) = document.meshes().next() {
226 425 for primitive in mesh.primitives() {
227 if primitive.mode() != gltf::mesh::Mode::Triangles {
228 return Err("live/productie ondersteunt alleen driehoekprimitieven".into());
229 }
230 let reader = primitive.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
231 let prim_positions: Vec<[f32; 3]> = reader
232 .read_positions()
233 .map(|iter| iter.collect())
234 .unwrap_or_default();
235 source_normals_complete &= reader.read_normals().is_some();
236 let prim_normals: Vec<[f32; 3]> = reader
237 .read_normals()
238 .map(|iter| iter.collect())
239 .unwrap_or_else(|| vec![[0.0, 1.0, 0.0]; prim_positions.len()]);
240 let prim_joints: Vec<[u16; 4]> = reader
241 .read_joints(0)
242 .map(|iter| iter.into_u16().collect())
243 .unwrap_or_else(|| vec![[0, 0, 0, 0]; prim_positions.len()]);
244 let prim_weights: Vec<[f32; 4]> = reader
245 .read_weights(0)
246 .map(|iter| iter.into_f32().collect())
247 .unwrap_or_else(|| vec![[1.0, 0.0, 0.0, 0.0]; prim_positions.len()]);
248 let material = primitive.material();
249 let pbr = material.pbr_metallic_roughness();
250 let texture = pbr.base_color_texture();
251 let uvset = texture.as_ref().map(|t| t.tex_coord()).unwrap_or(0);
252 colors.extend(
253 reader
254 .read_colors(0)
255 .map(|v| v.into_rgba_f32().collect::<Vec<_>>())
256 .unwrap_or_else(|| vec![[1.; 4]; prim_positions.len()]),
257 );
258 uvs.extend(
259 reader
260 .read_tex_coords(uvset)
261 .map(|v| v.into_f32().collect::<Vec<_>>())
262 .unwrap_or_else(|| vec![[0.; 2]; prim_positions.len()]),
263 );
426 let prim = read_gltf_primitive(&primitive, &buffers, &images)?;
427 source_normals_complete &= prim.normals.is_some();
428 let count = prim.positions.len();
264 429 let first = indices.len();
265 430 let base = positions.len() as u32;
266 if let Some(read_indices) = reader.read_indices() {
267 indices.extend(read_indices.into_u32().map(|index| index + base));
268 } else {
269 indices.extend(base..base + prim_positions.len() as u32);
270 }
271 positions.extend(prim_positions);
272 normals.extend(prim_normals);
273 joints.extend(prim_joints);
274 weights.extend(prim_weights);
275 base_color = primitive
276 .material()
277 .pbr_metallic_roughness()
278 .base_color_factor();
279 let albedo = texture
280 .map(|t| {
281 let image = images
282 .get(t.texture().source().index())
283 .ok_or("ontbrekende bodytexture")?;
284 use gltf::image::Format;
285 let channels = match image.format {
286 Format::R8 => 1,
287 Format::R8G8 => 2,
288 Format::R8G8B8 => 3,
289 Format::R8G8B8A8 => 4,
290 _ => return Err("bodytexture moet 8-bit zijn".to_string()),
291 };
292 if image.width > 2048 || image.height > 2048 {
293 return Err("bodytexture groter dan 2048×2048".into());
294 }
295 let mut rgba = Vec::new();
296 for p in image.pixels.chunks_exact(channels) {
297 rgba.extend_from_slice(&match channels {
298 1 => [p[0], p[0], p[0], 255],
299 2 => [p[0], p[0], p[0], p[1]],
300 3 => [p[0], p[1], p[2], 255],
301 _ => [p[0], p[1], p[2], p[3]],
302 });
303 }
304 use std::hash::{Hash, Hasher};
305 let mut hash = std::collections::hash_map::DefaultHasher::new();
306 rgba.hash(&mut hash);
307 Ok(std::sync::Arc::new(crate::equipment::Texture {
308 content_hash: hash.finish(),
309 width: image.width,
310 height: image.height,
311 rgba,
312 }))
313 })
314 .transpose()?;
431 indices.extend(prim.indices.iter().map(|index| index + base));
432 positions.extend(prim.positions);
433 normals.extend(prim.normals.unwrap_or_else(|| vec![[0.0, 1.0, 0.0]; count]));
434 joints.extend(prim.joints);
435 weights.extend(prim.weights);
436 colors.extend(prim.colors);
437 uvs.extend(prim.uvs);
438 base_color = prim.color;
315 439 surfaces.push(BodySurface {
316 flat_normals: reader.read_normals().is_none(),
440 flat_normals: prim.flat_normals,
317 441 first,
318 442 count: indices.len() - first,
319 color: base_color,
320 albedo,
321 cutoff: if material.alpha_mode() == gltf::material::AlphaMode::Mask {
322 material.alpha_cutoff().unwrap_or(0.5)
323 } else {
324 0.001
325 },
443 color: prim.color,
444 albedo: prim.albedo,
445 cutoff: prim.cutoff,
326 446 });
327 447 }
328 448 }
@@ -387,9 +507,260 @@ pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
387 507 uvs,
388 508 surfaces,
389 509 clips,
510 rig: BodyRig::Skinned,
390 511 })
391 512 }
392 513
514 /// One glTF triangle primitive read in its mesh-local space.
515 struct GltfPrimitive {
516 positions: Vec<[f32; 3]>,
517 normals: Option<Vec<[f32; 3]>>,
518 joints: Vec<[u16; 4]>,
519 weights: Vec<[f32; 4]>,
520 colors: Vec<[f32; 4]>,
521 uvs: Vec<[f32; 2]>,
522 indices: Vec<u32>,
523 color: [f32; 4],
524 albedo: Option<std::sync::Arc<crate::equipment::Texture>>,
525 cutoff: f32,
526 flat_normals: bool,
527 }
528
529 fn read_gltf_primitive(
530 primitive: &gltf::Primitive<'_>,
531 buffers: &[gltf::buffer::Data],
532 images: &[gltf::image::Data],
533 ) -> Result<GltfPrimitive, String> {
534 if primitive.mode() != gltf::mesh::Mode::Triangles {
535 return Err("live/productie ondersteunt alleen driehoekprimitieven".into());
536 }
537 let reader = primitive.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
538 let positions: Vec<[f32; 3]> = reader
539 .read_positions()
540 .map(|iter| iter.collect())
541 .unwrap_or_default();
542 let normals: Option<Vec<[f32; 3]>> = reader.read_normals().map(|iter| iter.collect());
543 let joints: Vec<[u16; 4]> = reader
544 .read_joints(0)
545 .map(|iter| iter.into_u16().collect())
546 .unwrap_or_else(|| vec![[0, 0, 0, 0]; positions.len()]);
547 let weights: Vec<[f32; 4]> = reader
548 .read_weights(0)
549 .map(|iter| iter.into_f32().collect())
550 .unwrap_or_else(|| vec![[1.0, 0.0, 0.0, 0.0]; positions.len()]);
551 let material = primitive.material();
552 let pbr = material.pbr_metallic_roughness();
553 let texture = pbr.base_color_texture();
554 let uvset = texture.as_ref().map(|t| t.tex_coord()).unwrap_or(0);
555 let colors = reader
556 .read_colors(0)
557 .map(|v| v.into_rgba_f32().collect::<Vec<_>>())
558 .unwrap_or_else(|| vec![[1.; 4]; positions.len()]);
559 let uvs = reader
560 .read_tex_coords(uvset)
561 .map(|v| v.into_f32().collect::<Vec<_>>())
562 .unwrap_or_else(|| vec![[0.; 2]; positions.len()]);
563 let indices: Vec<u32> = match reader.read_indices() {
564 Some(read_indices) => read_indices.into_u32().collect(),
565 None => (0..positions.len() as u32).collect(),
566 };
567 let albedo = texture
568 .map(|t| {
569 let image = images
570 .get(t.texture().source().index())
571 .ok_or("ontbrekende bodytexture")?;
572 use gltf::image::Format;
573 let channels = match image.format {
574 Format::R8 => 1,
575 Format::R8G8 => 2,
576 Format::R8G8B8 => 3,
577 Format::R8G8B8A8 => 4,
578 _ => return Err("bodytexture moet 8-bit zijn".to_string()),
579 };
580 if image.width > 2048 || image.height > 2048 {
581 return Err("bodytexture groter dan 2048×2048".into());
582 }
583 let mut rgba = Vec::new();
584 for p in image.pixels.chunks_exact(channels) {
585 rgba.extend_from_slice(&match channels {
586 1 => [p[0], p[0], p[0], 255],
587 2 => [p[0], p[0], p[0], p[1]],
588 3 => [p[0], p[1], p[2], 255],
589 _ => [p[0], p[1], p[2], p[3]],
590 });
591 }
592 use std::hash::{Hash, Hasher};
593 let mut hash = std::collections::hash_map::DefaultHasher::new();
594 rgba.hash(&mut hash);
595 Ok(std::sync::Arc::new(crate::equipment::Texture {
596 content_hash: hash.finish(),
597 width: image.width,
598 height: image.height,
599 rgba,
600 }))
601 })
602 .transpose()?;
603 Ok(GltfPrimitive {
604 flat_normals: normals.is_none(),
605 positions,
606 normals,
607 joints,
608 weights,
609 colors,
610 uvs,
611 indices,
612 color: pbr.base_color_factor(),
613 albedo,
614 cutoff: if material.alpha_mode() == gltf::material::AlphaMode::Mask {
615 material.alpha_cutoff().unwrap_or(0.5)
616 } else {
617 0.001
618 },
619 })
620 }
621
622 /// glTF without a skin: every mesh instance of the default scene, with its
623 /// node's rest world transform baked into positions (and the inverse-
624 /// transpose into normals) exactly once; mirrored transforms flip winding.
625 /// Animations are not applied (no skeleton is invented).
626 fn load_static_gltf(
627 document: &gltf::Document,
628 buffers: &[gltf::buffer::Data],
629 images: &[gltf::image::Data],
630 nodes: &[SceneNode],
631 ) -> Result<PreviewCharacter, String> {
632 let mut roots: Vec<usize> = match document
633 .default_scene()
634 .or_else(|| document.scenes().next())
635 {
636 Some(scene) => scene.nodes().map(|n| n.index()).collect(),
637 None => (0..nodes.len())
638 .filter(|i| nodes[*i].parent.is_none())
639 .collect(),
640 };
641 let children: Vec<Vec<usize>> = document
642 .nodes()
643 .map(|n| n.children().map(|c| c.index()).collect())
644 .collect();
645 let mut acc = StaticAcc::default();
646 let mut stack: Vec<(usize, Mat4, usize)> =
647 roots.drain(..).rev().map(|r| (r, identity(), 0)).collect();
648 let mut visited = 0usize;
649 while let Some((index, parent, depth)) = stack.pop() {
650 visited += 1;
651 if depth > nodes.len() || visited > nodes.len() * 4 + 16 {
652 return Err("glTF-nodehiërarchie bevat een cyclus".into());
653 }
654 let node = nodes.get(index).ok_or("ongeldige glTF-node")?;
655 let world = mul(parent, trs(node.translation, node.rotation, node.scale));
656 if !world.iter().all(|x| x.is_finite()) {
657 return Err("glTF-node heeft een niet-eindige transformatie".into());
658 }
659 if let Some(mesh) = document.nodes().nth(index).and_then(|n| n.mesh()) {
660 let normal_matrix =
661 normal_matrix(&world).ok_or("glTF-node heeft een singuliere transformatie")?;
662 let mirrored = det3(&world) < 0.0;
663 for primitive in mesh.primitives() {
664 let prim = read_gltf_primitive(&primitive, buffers, images)?;
665 let positions: Vec<[f32; 3]> = prim
666 .positions
667 .iter()
668 .map(|p| transform_point3(&world, *p))
669 .collect();
670 let normals: Vec<[f32; 3]> = match &prim.normals {
671 Some(normals) => normals
672 .iter()
673 .map(|n| normalize3(mat3_mul(&normal_matrix, *n)))
674 .collect(),
675 None => vec![[0., 1., 0.]; positions.len()],
676 };
677 let mut indices = prim.indices.clone();
678 if !indices.len().is_multiple_of(3) {
679 return Err("glTF-primitive heeft een onvolledige driehoek".into());
680 }
681 if mirrored {
682 for tri in indices.chunks_exact_mut(3) {
683 tri.swap(1, 2);
684 }
685 }
686 acc.push(
687 &positions,
688 &normals,
689 &prim.uvs,
690 Some(&prim.colors),
691 &indices,
692 prim.color,
693 prim.albedo,
694 prim.cutoff,
695 prim.flat_normals,
696 )?;
697 }
698 }
699 for child in children.get(index).into_iter().flatten().rev() {
700 stack.push((*child, world, depth + 1));
701 }
702 }
703 let animations = document.animations().count();
704 let reason = if animations == 0 {
705 "glTF zonder skin: statische pose, geen skeleton of animaties".to_string()
706 } else {
707 format!(
708 "glTF zonder skin: statische pose; {animations} animatie(s) niet toegepast (geen skeleton)"
709 )
710 };
711 acc.finish("glTF", reason)
712 }
713
714 fn transform_point3(m: &Mat4, p: [f32; 3]) -> [f32; 3] {
715 [
716 m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
717 m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],
718 m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14],
719 ]
720 }
721
722 fn det3(m: &Mat4) -> f32 {
723 m[0] * (m[5] * m[10] - m[9] * m[6]) - m[4] * (m[1] * m[10] - m[9] * m[2])
724 + m[8] * (m[1] * m[6] - m[5] * m[2])
725 }
726
727 /// Inverse-transpose of the upper 3×3 (row-major result for `mat3_mul`).
728 fn normal_matrix(m: &Mat4) -> Option<[[f32; 3]; 3]> {
729 let det = det3(m);
730 if !det.is_finite() || det.abs() < 1e-12 {
731 return None;
732 }
733 // a[r][c] = element at row r, column c of the column-major matrix.
734 let a = |r: usize, c: usize| m[c * 4 + r];
735 let cof = |r: usize, c: usize| {
736 let rows: Vec<usize> = (0..3).filter(|x| *x != r).collect();
737 let cols: Vec<usize> = (0..3).filter(|x| *x != c).collect();
738 let minor =
739 a(rows[0], cols[0]) * a(rows[1], cols[1]) - a(rows[0], cols[1]) * a(rows[1], cols[0]);
740 if (r + c).is_multiple_of(2) {
741 minor
742 } else {
743 -minor
744 }
745 };
746 // inverse-transpose = cofactor matrix / det.
747 let mut out = [[0.; 3]; 3];
748 for (r, row) in out.iter_mut().enumerate() {
749 for (c, value) in row.iter_mut().enumerate() {
750 *value = cof(r, c) / det;
751 }
752 }
753 Some(out)
754 }
755
756 fn mat3_mul(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
757 [
758 m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
759 m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
760 m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
761 ]
762 }
763
393 764 pub fn sample_skin_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {
394 765 let locals = sample_local_matrices(character, clip_name, time);
395 766 let worlds = world_matrices(&character.nodes, &locals);
@@ -859,6 +1230,63 @@ mod tests {
859 1230 dot(d, d).sqrt()
860 1231 }
861 1232
1233 #[test]
1234 fn mirrored_static_gltf_node_flips_winding_and_keeps_outward_normals() {
1235 let dir = std::env::temp_dir().join(format!(
1236 "paperdoll-static-mirror-{}-{:?}",
1237 std::process::id(),
1238 std::thread::current().id()
1239 ));
1240 std::fs::create_dir_all(&dir).unwrap();
1241 // One triangle facing -Z (counter-clockwise seen from -Z), normal -Z.
1242 let pos: [[f32; 3]; 3] = [[0., 0., 0.], [0., 1., 0.], [1., 0., 0.]];
1243 let mut bin = Vec::new();
1244 for p in pos {
1245 for v in p {
1246 bin.extend(v.to_le_bytes());
1247 }
1248 }
1249 for _ in 0..3 {
1250 for v in [0f32, 0., -1.] {
1251 bin.extend(v.to_le_bytes());
1252 }
1253 }
1254 std::fs::write(dir.join("tri.bin"), &bin).unwrap();
1255 let gltf = serde_json::json!({
1256 "asset": {"version": "2.0"},
1257 "scene": 0,
1258 "scenes": [{"nodes": [0]}],
1259 "nodes": [{"mesh": 0, "scale": [-2.0, 1.0, 1.0], "translation": [0.0, 0.5, 0.0]}],
1260 "meshes": [{"primitives": [{"attributes": {"POSITION": 0, "NORMAL": 1}}]}],
1261 "buffers": [{"uri": "tri.bin", "byteLength": bin.len()}],
1262 "bufferViews": [
1263 {"buffer": 0, "byteOffset": 0, "byteLength": 36},
1264 {"buffer": 0, "byteOffset": 36, "byteLength": 36}
1265 ],
1266 "accessors": [
1267 {"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3",
1268 "min": [0.0, 0.0, 0.0], "max": [1.0, 1.0, 0.0]},
1269 {"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC3"}
1270 ]
1271 });
1272 std::fs::write(dir.join("tri.gltf"), gltf.to_string()).unwrap();
1273 let body = load_body_file(&dir.join("tri.gltf")).unwrap();
1274 std::fs::remove_dir_all(&dir).ok();
1275 assert!(body.is_static());
1276 assert_eq!(body.positions[2], [-2., 0.5, 0.]);
1277 assert_eq!(body.indices, vec![0, 2, 1], "mirror must flip winding");
1278 let [a, b, c] = [0, 2, 1].map(|i| body.positions[i]);
1279 let n = cross(
1280 [b[0] - a[0], b[1] - a[1], b[2] - a[2]],
1281 [c[0] - a[0], c[1] - a[1], c[2] - a[2]],
1282 );
1283 assert!(n[2] < 0., "winding still faces -Z: {n:?}");
1284 for normal in &body.normals {
1285 assert!((normal[2] + 1.).abs() < 1e-6, "{normal:?}");
1286 }
1287 assert!(normal_matrix(&trs([0.; 3], [0., 0., 0., 1.], [0., 1., 1.])).is_none());
1288 }
1289
862 1290 #[test]
863 1291 fn walk_and_attack_move_the_matching_bones() {
864 1292 let character = male();
@@ -97,6 +97,37 @@ pub struct PreviewCharacter {
97 pub uvs: Vec<[f32; 2]>,
97 pub uvs: Vec<[f32; 2]>,
98 pub surfaces: Vec<BodySurface>,
98 pub surfaces: Vec<BodySurface>,
99 pub clips: Vec<PreviewClip>,
99 pub clips: Vec<PreviewClip>,
100 /// Skinned (skeleton + clips) or static geometry without a skeleton.
101 pub rig: BodyRig,
102}
103
104/// Whether a body has a real skin. A static body keeps an empty skeleton and
105/// no clips; it is drawn in its stored (bind/static) pose and the production
106/// batch renders the single `clip000-bind` sample. Nothing invents bones.
107#[derive(Debug, Clone, PartialEq, Eq, Default)]
108pub enum BodyRig {
109 #[default]
110 Skinned,
111 Static {
112 /// Source format label, e.g. `OBJ`, `glTF`, `FBX`.
113 format: String,
114 /// Human-readable (Dutch) explanation shown in the editor.
115 reason: String,
116 },
117}
118
119impl PreviewCharacter {
120 /// True when the body has no skin: no bones, no clips, static pose only.
121 pub fn is_static(&self) -> bool {
122 matches!(self.rig, BodyRig::Static { .. })
123 }
124 /// Editor explanation for a static body; `None` for a skinned body.
125 pub fn static_reason(&self) -> Option<&str> {
126 match &self.rig {
127 BodyRig::Static { reason, .. } => Some(reason),
128 BodyRig::Skinned => None,
129 }
130 }
100}
131}
101
132
102#[derive(Debug, Clone)]
133#[derive(Debug, Clone)]
@@ -147,13 +178,16 @@ pub fn is_drawable_body(path: &Path) -> bool {
147 .and_then(|e| e.to_str())
178 .and_then(|e| e.to_str())
148 .map(|e| e.to_ascii_lowercase())
179 .map(|e| e.to_ascii_lowercase())
149 .as_deref(),
180 .as_deref(),
150 Some("gltf" | "glb" | "fbx")
181 Some("gltf" | "glb" | "fbx" | "obj")
151 )
182 )
152}
183}
153
184
154/// Load a skinned body for the live viewport and the production batch.
185/// Load a body for the live viewport and the production batch.
155/// glTF/GLB use the glTF reader; FBX goes through the normalized
186/// glTF/GLB use the glTF reader; FBX goes through the normalized
156/// `fbx_scene` loader (axis/unit conversion is applied there exactly once).
187/// `fbx_scene` loader (axis/unit conversion is applied there exactly once).
188/// Bodies without a skin (OBJ+MTL, glTF without `skins`, FBX without a skin
189/// deformer) load as [`BodyRig::Static`]: geometry in its stored pose, no
190/// skeleton and no clips.
157pub fn load_body_file(path: &Path) -> Result<PreviewCharacter, String> {
191pub fn load_body_file(path: &Path) -> Result<PreviewCharacter, String> {
158 match path
192 match path
159 .extension()
193 .extension()
@@ -162,14 +196,180 @@ pub fn load_body_file(path: &Path) -> Result<PreviewCharacter, String> {
162 .as_deref()
196 .as_deref()
163 {
197 {
164 Some("gltf" | "glb") => load_gltf_file(path),
198 Some("gltf" | "glb") => load_gltf_file(path),
165 Some("fbx") => crate::fbx_scene::load_fbx_drawable(path).map(|scene| scene.character),
199 Some("fbx") => match crate::fbx_scene::load_fbx_drawable(path) {
200 Ok(scene) => Ok(scene.character),
201 Err(error) if error == crate::fbx_scene::NO_SKINNED_MESH => load_static_rigid(
202 path,
203 "FBX",
204 "FBX zonder skin: statische rustpose, geen skeleton of animaties",
205 ),
206 Err(error) => Err(error),
207 },
208 Some("obj") => load_static_rigid(
209 path,
210 "OBJ",
211 "OBJ heeft geen skeleton of animaties: statische pose",
212 ),
166 _ => Err(format!(
213 _ => Err(format!(
167 "{} is geen tekenbaar lichaam; gebruik .glb, .gltf of .fbx",
214 "{} is geen tekenbaar lichaam; gebruik .glb, .gltf, .fbx of .obj",
168 path.display()
215 path.display()
169 )),
216 )),
170 }
217 }
171}
218}
172
219
220/// Static OBJ/FBX body through the same rigid reader as equipment
221/// (`equipment::import_obj`/`import_fbx`): OBJ is used as stored, FBX gets
222/// the ufbx axis/unit/geometry-transform normalization once, there. The only
223/// conversion here is the UV origin (bottom-left in OBJ/FBX, top-left in
224/// `PreviewCharacter`), done exactly once.
225fn load_static_rigid(path: &Path, format: &str, reason: &str) -> Result<PreviewCharacter, String> {
226 let mut preset = crate::equipment::Preset::new("static-body".into());
227 let part = match format {
228 "OBJ" => crate::equipment::import_obj_limited(
229 &mut preset,
230 path,
231 crate::equipment::MAX_STATIC_BODY_VERTICES,
232 ),
233 _ => crate::equipment::import_fbx_limited(
234 &mut preset,
235 path,
236 crate::equipment::MAX_STATIC_BODY_VERTICES,
237 ),
238 }
239 .map_err(|error| format!("statisch {format}-lichaam: {error}"))?;
240 let mut cache = crate::equipment::TextureCache::default();
241 let mut acc = StaticAcc::default();
242 for mesh in &preset.attachments[part].meshes {
243 let albedo = mesh
244 .albedo
245 .map(|index| cache.get(&preset, index))
246 .transpose()?;
247 let uvs: Vec<[f32; 2]> = mesh.uvs.iter().map(|[u, v]| [*u, 1. - *v]).collect();
248 acc.push(
249 &mesh.positions,
250 &mesh.normals,
251 &uvs,
252 None,
253 &mesh.indices,
254 mesh.color,
255 albedo,
256 0.001,
257 false,
258 )?;
259 }
260 acc.finish(format, reason.to_string())
261}
262
263/// Accumulates static-body primitives into one `PreviewCharacter` mesh.
264#[derive(Default)]
265struct StaticAcc {
266 positions: Vec<[f32; 3]>,
267 normals: Vec<[f32; 3]>,
268 uvs: Vec<[f32; 2]>,
269 colors: Vec<[f32; 4]>,
270 indices: Vec<u32>,
271 surfaces: Vec<BodySurface>,
272 normals_complete: bool,
273 any: bool,
274}
275
276impl StaticAcc {
277 #[allow(clippy::too_many_arguments)]
278 fn push(
279 &mut self,
280 positions: &[[f32; 3]],
281 normals: &[[f32; 3]],
282 uvs: &[[f32; 2]],
283 colors: Option<&[[f32; 4]]>,
284 indices: &[u32],
285 color: [f32; 4],
286 albedo: Option<std::sync::Arc<crate::equipment::Texture>>,
287 cutoff: f32,
288 flat_normals: bool,
289 ) -> Result<(), String> {
290 if positions.is_empty() || indices.len() < 3 {
291 return Ok(());
292 }
293 if !indices.len().is_multiple_of(3)
294 || indices.iter().any(|i| *i as usize >= positions.len())
295 {
296 return Err("statisch lichaam: ongeldige driehoeksindices".into());
297 }
298 if positions
299 .iter()
300 .chain(normals.iter())
301 .any(|v| v.iter().any(|x| !x.is_finite()))
302 {
303 return Err("statisch lichaam: niet-eindige posities of normalen".into());
304 }
305 let total = self.positions.len() + positions.len();
306 if total > crate::equipment::MAX_STATIC_BODY_VERTICES {
307 return Err("statisch lichaam heeft te veel vertices".into());
308 }
309 if !self.any {
310 self.normals_complete = true;
311 self.any = true;
312 }
313 self.normals_complete &= !flat_normals && normals.len() == positions.len();
314 let base = self.positions.len() as u32;
315 let first = self.indices.len();
316 self.indices.extend(indices.iter().map(|i| i + base));
317 self.positions.extend_from_slice(positions);
318 for i in 0..positions.len() {
319 self.normals
320 .push(normals.get(i).copied().unwrap_or([0., 1., 0.]));
321 self.uvs.push(uvs.get(i).copied().unwrap_or([0., 0.]));
322 self.colors
323 .push(colors.and_then(|c| c.get(i).copied()).unwrap_or([1.; 4]));
324 }
325 self.surfaces.push(BodySurface {
326 first,
327 count: self.indices.len() - first,
328 color,
329 albedo,
330 cutoff,
331 flat_normals,
332 });
333 Ok(())
334 }
335
336 fn finish(self, format: &str, reason: String) -> Result<PreviewCharacter, String> {
337 if self.positions.is_empty() || self.indices.len() < 3 {
338 return Err(format!(
339 "statisch {format}-lichaam heeft geen tekenbare driehoeken"
340 ));
341 }
342 let count = self.positions.len();
343 Ok(PreviewCharacter {
344 nodes: Vec::new(),
345 joint_nodes: Vec::new(),
346 joint_names: Vec::new(),
347 inverse_binds: Vec::new(),
348 positions: self.positions,
349 normals: self.normals,
350 source_normals_complete: self.normals_complete,
351 // Weight 1 on slot 0 with no bones: every skinning path resolves
352 // to identity (empty bone list), i.e. the stored pose.
353 joints: vec![[0; 4]; count],
354 weights: vec![[1., 0., 0., 0.]; count],
355 indices: self.indices,
356 base_color: self
357 .surfaces
358 .first()
359 .map(|s| s.color)
360 .unwrap_or([0.7, 0.7, 0.75, 1.0]),
361 colors: self.colors,
362 uvs: self.uvs,
363 surfaces: self.surfaces,
364 clips: Vec::new(),
365 rig: BodyRig::Static {
366 format: format.to_string(),
367 reason,
368 },
369 })
370 }
371}
372
173pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
373pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
174 let (document, buffers, images) = gltf::import(path).map_err(|err| err.to_string())?;
374 let (document, buffers, images) = gltf::import(path).map_err(|err| err.to_string())?;
175 let mut nodes = Vec::new();
375 let mut nodes = Vec::new();
@@ -189,10 +389,9 @@ pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
189 scale,
389 scale,
190 });
390 });
191 }
391 }
192 let skin = document
392 let Some(skin) = document.skins().next() else {
193 .skins()
393 return load_static_gltf(&document, &buffers, &images, &nodes);
194 .next()
394 };
195 .ok_or_else(|| "het glTF-bestand heeft geen skeleton".to_string())?;
196 if skin.joints().len() > MAX_JOINTS {
395 if skin.joints().len() > MAX_JOINTS {
197 return Err(format!(
396 return Err(format!(
198 "het skeleton heeft {} joints; de preview stopt bij {MAX_JOINTS}",
397 "het skeleton heeft {} joints; de preview stopt bij {MAX_JOINTS}",
@@ -224,105 +423,26 @@ pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
224 let mut surfaces = Vec::new();
423 let mut surfaces = Vec::new();
225 if let Some(mesh) = document.meshes().next() {
424 if let Some(mesh) = document.meshes().next() {
226 for primitive in mesh.primitives() {
425 for primitive in mesh.primitives() {
227 if primitive.mode() != gltf::mesh::Mode::Triangles {
426 let prim = read_gltf_primitive(&primitive, &buffers, &images)?;
228 return Err("live/productie ondersteunt alleen driehoekprimitieven".into());
427 source_normals_complete &= prim.normals.is_some();
229 }
428 let count = prim.positions.len();
230 let reader = primitive.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
231 let prim_positions: Vec<[f32; 3]> = reader
232 .read_positions()
233 .map(|iter| iter.collect())
234 .unwrap_or_default();
235 source_normals_complete &= reader.read_normals().is_some();
236 let prim_normals: Vec<[f32; 3]> = reader
237 .read_normals()
238 .map(|iter| iter.collect())
239 .unwrap_or_else(|| vec![[0.0, 1.0, 0.0]; prim_positions.len()]);
240 let prim_joints: Vec<[u16; 4]> = reader
241 .read_joints(0)
242 .map(|iter| iter.into_u16().collect())
243 .unwrap_or_else(|| vec![[0, 0, 0, 0]; prim_positions.len()]);
244 let prim_weights: Vec<[f32; 4]> = reader
245 .read_weights(0)
246 .map(|iter| iter.into_f32().collect())
247 .unwrap_or_else(|| vec![[1.0, 0.0, 0.0, 0.0]; prim_positions.len()]);
248 let material = primitive.material();
249 let pbr = material.pbr_metallic_roughness();
250 let texture = pbr.base_color_texture();
251 let uvset = texture.as_ref().map(|t| t.tex_coord()).unwrap_or(0);
252 colors.extend(
253 reader
254 .read_colors(0)
255 .map(|v| v.into_rgba_f32().collect::<Vec<_>>())
256 .unwrap_or_else(|| vec![[1.; 4]; prim_positions.len()]),
257 );
258 uvs.extend(
259 reader
260 .read_tex_coords(uvset)
261 .map(|v| v.into_f32().collect::<Vec<_>>())
262 .unwrap_or_else(|| vec![[0.; 2]; prim_positions.len()]),
263 );
264 let first = indices.len();
429 let first = indices.len();
265 let base = positions.len() as u32;
430 let base = positions.len() as u32;
266 if let Some(read_indices) = reader.read_indices() {
431 indices.extend(prim.indices.iter().map(|index| index + base));
267 indices.extend(read_indices.into_u32().map(|index| index + base));
432 positions.extend(prim.positions);
268 } else {
433 normals.extend(prim.normals.unwrap_or_else(|| vec![[0.0, 1.0, 0.0]; count]));
269 indices.extend(base..base + prim_positions.len() as u32);
434 joints.extend(prim.joints);
270 }
435 weights.extend(prim.weights);
271 positions.extend(prim_positions);
436 colors.extend(prim.colors);
272 normals.extend(prim_normals);
437 uvs.extend(prim.uvs);
273 joints.extend(prim_joints);
438 base_color = prim.color;
274 weights.extend(prim_weights);
275 base_color = primitive
276 .material()
277 .pbr_metallic_roughness()
278 .base_color_factor();
279 let albedo = texture
280 .map(|t| {
281 let image = images
282 .get(t.texture().source().index())
283 .ok_or("ontbrekende bodytexture")?;
284 use gltf::image::Format;
285 let channels = match image.format {
286 Format::R8 => 1,
287 Format::R8G8 => 2,
288 Format::R8G8B8 => 3,
289 Format::R8G8B8A8 => 4,
290 _ => return Err("bodytexture moet 8-bit zijn".to_string()),
291 };
292 if image.width > 2048 || image.height > 2048 {
293 return Err("bodytexture groter dan 2048×2048".into());
294 }
295 let mut rgba = Vec::new();
296 for p in image.pixels.chunks_exact(channels) {
297 rgba.extend_from_slice(&match channels {
298 1 => [p[0], p[0], p[0], 255],
299 2 => [p[0], p[0], p[0], p[1]],
300 3 => [p[0], p[1], p[2], 255],
301 _ => [p[0], p[1], p[2], p[3]],
302 });
303 }
304 use std::hash::{Hash, Hasher};
305 let mut hash = std::collections::hash_map::DefaultHasher::new();
306 rgba.hash(&mut hash);
307 Ok(std::sync::Arc::new(crate::equipment::Texture {
308 content_hash: hash.finish(),
309 width: image.width,
310 height: image.height,
311 rgba,
312 }))
313 })
314 .transpose()?;
315 surfaces.push(BodySurface {
439 surfaces.push(BodySurface {
316 flat_normals: reader.read_normals().is_none(),
440 flat_normals: prim.flat_normals,
317 first,
441 first,
318 count: indices.len() - first,
442 count: indices.len() - first,
319 color: base_color,
443 color: prim.color,
320 albedo,
444 albedo: prim.albedo,
321 cutoff: if material.alpha_mode() == gltf::material::AlphaMode::Mask {
445 cutoff: prim.cutoff,
322 material.alpha_cutoff().unwrap_or(0.5)
323 } else {
324 0.001
325 },
326 });
446 });
327 }
447 }
328 }
448 }
@@ -387,9 +507,260 @@ pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
387 uvs,
507 uvs,
388 surfaces,
508 surfaces,
389 clips,
509 clips,
510 rig: BodyRig::Skinned,
390 })
511 })
391}
512}
392
513
514/// One glTF triangle primitive read in its mesh-local space.
515struct GltfPrimitive {
516 positions: Vec<[f32; 3]>,
517 normals: Option<Vec<[f32; 3]>>,
518 joints: Vec<[u16; 4]>,
519 weights: Vec<[f32; 4]>,
520 colors: Vec<[f32; 4]>,
521 uvs: Vec<[f32; 2]>,
522 indices: Vec<u32>,
523 color: [f32; 4],
524 albedo: Option<std::sync::Arc<crate::equipment::Texture>>,
525 cutoff: f32,
526 flat_normals: bool,
527}
528
529fn read_gltf_primitive(
530 primitive: &gltf::Primitive<'_>,
531 buffers: &[gltf::buffer::Data],
532 images: &[gltf::image::Data],
533) -> Result<GltfPrimitive, String> {
534 if primitive.mode() != gltf::mesh::Mode::Triangles {
535 return Err("live/productie ondersteunt alleen driehoekprimitieven".into());
536 }
537 let reader = primitive.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
538 let positions: Vec<[f32; 3]> = reader
539 .read_positions()
540 .map(|iter| iter.collect())
541 .unwrap_or_default();
542 let normals: Option<Vec<[f32; 3]>> = reader.read_normals().map(|iter| iter.collect());
543 let joints: Vec<[u16; 4]> = reader
544 .read_joints(0)
545 .map(|iter| iter.into_u16().collect())
546 .unwrap_or_else(|| vec![[0, 0, 0, 0]; positions.len()]);
547 let weights: Vec<[f32; 4]> = reader
548 .read_weights(0)
549 .map(|iter| iter.into_f32().collect())
550 .unwrap_or_else(|| vec![[1.0, 0.0, 0.0, 0.0]; positions.len()]);
551 let material = primitive.material();
552 let pbr = material.pbr_metallic_roughness();
553 let texture = pbr.base_color_texture();
554 let uvset = texture.as_ref().map(|t| t.tex_coord()).unwrap_or(0);
555 let colors = reader
556 .read_colors(0)
557 .map(|v| v.into_rgba_f32().collect::<Vec<_>>())
558 .unwrap_or_else(|| vec![[1.; 4]; positions.len()]);
559 let uvs = reader
560 .read_tex_coords(uvset)
561 .map(|v| v.into_f32().collect::<Vec<_>>())
562 .unwrap_or_else(|| vec![[0.; 2]; positions.len()]);
563 let indices: Vec<u32> = match reader.read_indices() {
564 Some(read_indices) => read_indices.into_u32().collect(),
565 None => (0..positions.len() as u32).collect(),
566 };
567 let albedo = texture
568 .map(|t| {
569 let image = images
570 .get(t.texture().source().index())
571 .ok_or("ontbrekende bodytexture")?;
572 use gltf::image::Format;
573 let channels = match image.format {
574 Format::R8 => 1,
575 Format::R8G8 => 2,
576 Format::R8G8B8 => 3,
577 Format::R8G8B8A8 => 4,
578 _ => return Err("bodytexture moet 8-bit zijn".to_string()),
579 };
580 if image.width > 2048 || image.height > 2048 {
581 return Err("bodytexture groter dan 2048×2048".into());
582 }
583 let mut rgba = Vec::new();
584 for p in image.pixels.chunks_exact(channels) {
585 rgba.extend_from_slice(&match channels {
586 1 => [p[0], p[0], p[0], 255],
587 2 => [p[0], p[0], p[0], p[1]],
588 3 => [p[0], p[1], p[2], 255],
589 _ => [p[0], p[1], p[2], p[3]],
590 });
591 }
592 use std::hash::{Hash, Hasher};
593 let mut hash = std::collections::hash_map::DefaultHasher::new();
594 rgba.hash(&mut hash);
595 Ok(std::sync::Arc::new(crate::equipment::Texture {
596 content_hash: hash.finish(),
597 width: image.width,
598 height: image.height,
599 rgba,
600 }))
601 })
602 .transpose()?;
603 Ok(GltfPrimitive {
604 flat_normals: normals.is_none(),
605 positions,
606 normals,
607 joints,
608 weights,
609 colors,
610 uvs,
611 indices,
612 color: pbr.base_color_factor(),
613 albedo,
614 cutoff: if material.alpha_mode() == gltf::material::AlphaMode::Mask {
615 material.alpha_cutoff().unwrap_or(0.5)
616 } else {
617 0.001
618 },
619 })
620}
621
622/// glTF without a skin: every mesh instance of the default scene, with its
623/// node's rest world transform baked into positions (and the inverse-
624/// transpose into normals) exactly once; mirrored transforms flip winding.
625/// Animations are not applied (no skeleton is invented).
626fn load_static_gltf(
627 document: &gltf::Document,
628 buffers: &[gltf::buffer::Data],
629 images: &[gltf::image::Data],
630 nodes: &[SceneNode],
631) -> Result<PreviewCharacter, String> {
632 let mut roots: Vec<usize> = match document
633 .default_scene()
634 .or_else(|| document.scenes().next())
635 {
636 Some(scene) => scene.nodes().map(|n| n.index()).collect(),
637 None => (0..nodes.len())
638 .filter(|i| nodes[*i].parent.is_none())
639 .collect(),
640 };
641 let children: Vec<Vec<usize>> = document
642 .nodes()
643 .map(|n| n.children().map(|c| c.index()).collect())
644 .collect();
645 let mut acc = StaticAcc::default();
646 let mut stack: Vec<(usize, Mat4, usize)> =
647 roots.drain(..).rev().map(|r| (r, identity(), 0)).collect();
648 let mut visited = 0usize;
649 while let Some((index, parent, depth)) = stack.pop() {
650 visited += 1;
651 if depth > nodes.len() || visited > nodes.len() * 4 + 16 {
652 return Err("glTF-nodehiërarchie bevat een cyclus".into());
653 }
654 let node = nodes.get(index).ok_or("ongeldige glTF-node")?;
655 let world = mul(parent, trs(node.translation, node.rotation, node.scale));
656 if !world.iter().all(|x| x.is_finite()) {
657 return Err("glTF-node heeft een niet-eindige transformatie".into());
658 }
659 if let Some(mesh) = document.nodes().nth(index).and_then(|n| n.mesh()) {
660 let normal_matrix =
661 normal_matrix(&world).ok_or("glTF-node heeft een singuliere transformatie")?;
662 let mirrored = det3(&world) < 0.0;
663 for primitive in mesh.primitives() {
664 let prim = read_gltf_primitive(&primitive, buffers, images)?;
665 let positions: Vec<[f32; 3]> = prim
666 .positions
667 .iter()
668 .map(|p| transform_point3(&world, *p))
669 .collect();
670 let normals: Vec<[f32; 3]> = match &prim.normals {
671 Some(normals) => normals
672 .iter()
673 .map(|n| normalize3(mat3_mul(&normal_matrix, *n)))
674 .collect(),
675 None => vec![[0., 1., 0.]; positions.len()],
676 };
677 let mut indices = prim.indices.clone();
678 if !indices.len().is_multiple_of(3) {
679 return Err("glTF-primitive heeft een onvolledige driehoek".into());
680 }
681 if mirrored {
682 for tri in indices.chunks_exact_mut(3) {
683 tri.swap(1, 2);
684 }
685 }
686 acc.push(
687 &positions,
688 &normals,
689 &prim.uvs,
690 Some(&prim.colors),
691 &indices,
692 prim.color,
693 prim.albedo,
694 prim.cutoff,
695 prim.flat_normals,
696 )?;
697 }
698 }
699 for child in children.get(index).into_iter().flatten().rev() {
700 stack.push((*child, world, depth + 1));
701 }
702 }
703 let animations = document.animations().count();
704 let reason = if animations == 0 {
705 "glTF zonder skin: statische pose, geen skeleton of animaties".to_string()
706 } else {
707 format!(
708 "glTF zonder skin: statische pose; {animations} animatie(s) niet toegepast (geen skeleton)"
709 )
710 };
711 acc.finish("glTF", reason)
712}
713
714fn transform_point3(m: &Mat4, p: [f32; 3]) -> [f32; 3] {
715 [
716 m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
717 m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],
718 m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14],
719 ]
720}
721
722fn det3(m: &Mat4) -> f32 {
723 m[0] * (m[5] * m[10] - m[9] * m[6]) - m[4] * (m[1] * m[10] - m[9] * m[2])
724 + m[8] * (m[1] * m[6] - m[5] * m[2])
725}
726
727/// Inverse-transpose of the upper 3×3 (row-major result for `mat3_mul`).
728fn normal_matrix(m: &Mat4) -> Option<[[f32; 3]; 3]> {
729 let det = det3(m);
730 if !det.is_finite() || det.abs() < 1e-12 {
731 return None;
732 }
733 // a[r][c] = element at row r, column c of the column-major matrix.
734 let a = |r: usize, c: usize| m[c * 4 + r];
735 let cof = |r: usize, c: usize| {
736 let rows: Vec<usize> = (0..3).filter(|x| *x != r).collect();
737 let cols: Vec<usize> = (0..3).filter(|x| *x != c).collect();
738 let minor =
739 a(rows[0], cols[0]) * a(rows[1], cols[1]) - a(rows[0], cols[1]) * a(rows[1], cols[0]);
740 if (r + c).is_multiple_of(2) {
741 minor
742 } else {
743 -minor
744 }
745 };
746 // inverse-transpose = cofactor matrix / det.
747 let mut out = [[0.; 3]; 3];
748 for (r, row) in out.iter_mut().enumerate() {
749 for (c, value) in row.iter_mut().enumerate() {
750 *value = cof(r, c) / det;
751 }
752 }
753 Some(out)
754}
755
756fn mat3_mul(m: &[[f32; 3]; 3], v: [f32; 3]) -> [f32; 3] {
757 [
758 m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
759 m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
760 m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
761 ]
762}
763
393pub fn sample_skin_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {
764pub fn sample_skin_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {
394 let locals = sample_local_matrices(character, clip_name, time);
765 let locals = sample_local_matrices(character, clip_name, time);
395 let worlds = world_matrices(&character.nodes, &locals);
766 let worlds = world_matrices(&character.nodes, &locals);
@@ -859,6 +1230,63 @@ mod tests {
859 dot(d, d).sqrt()
1230 dot(d, d).sqrt()
860 }
1231 }
861
1232
1233 #[test]
1234 fn mirrored_static_gltf_node_flips_winding_and_keeps_outward_normals() {
1235 let dir = std::env::temp_dir().join(format!(
1236 "paperdoll-static-mirror-{}-{:?}",
1237 std::process::id(),
1238 std::thread::current().id()
1239 ));
1240 std::fs::create_dir_all(&dir).unwrap();
1241 // One triangle facing -Z (counter-clockwise seen from -Z), normal -Z.
1242 let pos: [[f32; 3]; 3] = [[0., 0., 0.], [0., 1., 0.], [1., 0., 0.]];
1243 let mut bin = Vec::new();
1244 for p in pos {
1245 for v in p {
1246 bin.extend(v.to_le_bytes());
1247 }
1248 }
1249 for _ in 0..3 {
1250 for v in [0f32, 0., -1.] {
1251 bin.extend(v.to_le_bytes());
1252 }
1253 }
1254 std::fs::write(dir.join("tri.bin"), &bin).unwrap();
1255 let gltf = serde_json::json!({
1256 "asset": {"version": "2.0"},
1257 "scene": 0,
1258 "scenes": [{"nodes": [0]}],
1259 "nodes": [{"mesh": 0, "scale": [-2.0, 1.0, 1.0], "translation": [0.0, 0.5, 0.0]}],
1260 "meshes": [{"primitives": [{"attributes": {"POSITION": 0, "NORMAL": 1}}]}],
1261 "buffers": [{"uri": "tri.bin", "byteLength": bin.len()}],
1262 "bufferViews": [
1263 {"buffer": 0, "byteOffset": 0, "byteLength": 36},
1264 {"buffer": 0, "byteOffset": 36, "byteLength": 36}
1265 ],
1266 "accessors": [
1267 {"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3",
1268 "min": [0.0, 0.0, 0.0], "max": [1.0, 1.0, 0.0]},
1269 {"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC3"}
1270 ]
1271 });
1272 std::fs::write(dir.join("tri.gltf"), gltf.to_string()).unwrap();
1273 let body = load_body_file(&dir.join("tri.gltf")).unwrap();
1274 std::fs::remove_dir_all(&dir).ok();
1275 assert!(body.is_static());
1276 assert_eq!(body.positions[2], [-2., 0.5, 0.]);
1277 assert_eq!(body.indices, vec![0, 2, 1], "mirror must flip winding");
1278 let [a, b, c] = [0, 2, 1].map(|i| body.positions[i]);
1279 let n = cross(
1280 [b[0] - a[0], b[1] - a[1], b[2] - a[2]],
1281 [c[0] - a[0], c[1] - a[1], c[2] - a[2]],
1282 );
1283 assert!(n[2] < 0., "winding still faces -Z: {n:?}");
1284 for normal in &body.normals {
1285 assert!((normal[2] + 1.).abs() < 1e-6, "{normal:?}");
1286 }
1287 assert!(normal_matrix(&trs([0.; 3], [0., 0., 0., 1.], [0., 1., 1.])).is_none());
1288 }
1289
862 #[test]
1290 #[test]
863 fn walk_and_attack_move_the_matching_bones() {
1291 fn walk_and_attack_move_the_matching_bones() {
864 let character = male();
1292 let character = male();
src/preview_gl.rs +19 −5
@@ -1,4 +1,4 @@
1 //! OpenGL viewport for a skinned glTF/GLB or FBX body and rigid textured equipment.
1 //! OpenGL viewport for a skinned glTF/GLB or FBX body, a static (unskinned) OBJ/glTF/FBX body, and rigid textured equipment.
2 2 //!
3 3 //! The shaded mesh is a placement preview. It is not the unlit paperdoll
4 4 //! diffuse/depth/normal export.
@@ -96,11 +96,25 @@ impl Viewport {
96 96 controls.select_clip(&clip.name);
97 97 }
98 98 }
99 if character.clips.is_empty() {
100 let why = if character.is_static() {
101 "Geen animaties: statisch lichaam zonder skeleton (bindpose)"
102 } else {
103 "Geen animaties: bindpose"
104 };
105 ui.add_enabled(false, egui::Button::new("Animatie"))
106 .on_disabled_hover_text(why);
107 ui.label(why);
108 }
99 109 }
110 let animated = character.is_some_and(|c| !c.clips.is_empty());
100 111 if ui
101 .selectable_label(
102 controls.playing,
103 if controls.playing { "Pauze" } else { "Speel" },
112 .add_enabled(
113 animated,
114 egui::SelectableLabel::new(
115 controls.playing && animated,
116 if controls.playing { "Pauze" } else { "Speel" },
117 ),
104 118 )
105 119 .clicked()
106 120 {
@@ -153,7 +167,7 @@ impl Viewport {
153 167 ui.painter().text(
154 168 rect.center(),
155 169 egui::Align2::CENTER_CENTER,
156 "Geen lichaam geladen. Open een project met een .glb/.gltf/.fbx-lichaam of start met --preview-fixture.",
170 "Geen lichaam geladen. Open een project met een .glb/.gltf/.fbx/.obj-lichaam of start met --preview-fixture.",
157 171 egui::FontId::proportional(14.0),
158 172 egui::Color32::from_rgb(210, 210, 210),
159 173 );
@@ -1,4 +1,4 @@
1//! OpenGL viewport for a skinned glTF/GLB or FBX body and rigid textured equipment.
1//! OpenGL viewport for a skinned glTF/GLB or FBX body, a static (unskinned) OBJ/glTF/FBX body, and rigid textured equipment.
2//!
2//!
3//! The shaded mesh is a placement preview. It is not the unlit paperdoll
3//! The shaded mesh is a placement preview. It is not the unlit paperdoll
4//! diffuse/depth/normal export.
4//! diffuse/depth/normal export.
@@ -96,11 +96,25 @@ impl Viewport {
96 controls.select_clip(&clip.name);
96 controls.select_clip(&clip.name);
97 }
97 }
98 }
98 }
99 if character.clips.is_empty() {
100 let why = if character.is_static() {
101 "Geen animaties: statisch lichaam zonder skeleton (bindpose)"
102 } else {
103 "Geen animaties: bindpose"
104 };
105 ui.add_enabled(false, egui::Button::new("Animatie"))
106 .on_disabled_hover_text(why);
107 ui.label(why);
108 }
99 }
109 }
110 let animated = character.is_some_and(|c| !c.clips.is_empty());
100 if ui
111 if ui
101 .selectable_label(
112 .add_enabled(
102 controls.playing,
113 animated,
103 if controls.playing { "Pauze" } else { "Speel" },
114 egui::SelectableLabel::new(
115 controls.playing && animated,
116 if controls.playing { "Pauze" } else { "Speel" },
117 ),
104 )
118 )
105 .clicked()
119 .clicked()
106 {
120 {
@@ -153,7 +167,7 @@ impl Viewport {
153 ui.painter().text(
167 ui.painter().text(
154 rect.center(),
168 rect.center(),
155 egui::Align2::CENTER_CENTER,
169 egui::Align2::CENTER_CENTER,
156 "Geen lichaam geladen. Open een project met een .glb/.gltf/.fbx-lichaam of start met --preview-fixture.",
170 "Geen lichaam geladen. Open een project met een .glb/.gltf/.fbx/.obj-lichaam of start met --preview-fixture.",
157 egui::FontId::proportional(14.0),
171 egui::FontId::proportional(14.0),
158 egui::Color32::from_rgb(210, 210, 210),
172 egui::Color32::from_rgb(210, 210, 210),
159 );
173 );
src/production.rs +5 −0
@@ -1312,6 +1312,11 @@ pub fn contract(mut job: RenderJob) -> Result<Contract, String> {
1312 1312 return Err("ongeldig geslacht".into());
1313 1313 }
1314 1314 if let Some(p) = &job.preset {
1315 if job.character.is_static() {
1316 return Err(
1317 "statisch lichaam zonder skeleton: equipment kan niet aan een bot hangen".into(),
1318 );
1319 }
1315 1320 p.validate()?;
1316 1321 if !p.applies[usize::from(job.sex == "female")] {
1317 1322 return Err("equipment niet geldig voor dit geslacht".into());
@@ -1312,6 +1312,11 @@ pub fn contract(mut job: RenderJob) -> Result<Contract, String> {
1312 return Err("ongeldig geslacht".into());
1312 return Err("ongeldig geslacht".into());
1313 }
1313 }
1314 if let Some(p) = &job.preset {
1314 if let Some(p) = &job.preset {
1315 if job.character.is_static() {
1316 return Err(
1317 "statisch lichaam zonder skeleton: equipment kan niet aan een bot hangen".into(),
1318 );
1319 }
1315 p.validate()?;
1320 p.validate()?;
1316 if !p.applies[usize::from(job.sex == "female")] {
1321 if !p.applies[usize::from(job.sex == "female")] {
1317 return Err("equipment niet geldig voor dit geslacht".into());
1322 return Err("equipment niet geldig voor dit geslacht".into());
tests/fbx_integration.rs +6 −2
@@ -82,7 +82,9 @@ fn apply(m: &Mat4, p: [f32; 3]) -> [f32; 3] {
82 82 fn body_loader_dispatches_by_extension_and_rejects_other_formats() {
83 83 assert!(preview::is_drawable_body(std::path::Path::new("a/B.FBX")));
84 84 assert!(preview::is_drawable_body(std::path::Path::new("b.glb")));
85 assert!(!preview::is_drawable_body(std::path::Path::new("c.obj")));
85 // OBJ is drawable as a static body (no skeleton); STL/PLY are not bodies.
86 assert!(preview::is_drawable_body(std::path::Path::new("c.obj")));
87 assert!(!preview::is_drawable_body(std::path::Path::new("d.stl")));
86 88 let fbx = fbx_body();
87 89 let direct = paperdoll_renderer::fbx_scene::load_fbx_drawable(&repo(
88 90 "tests/fixtures/bodies/test-body-male.fbx",
@@ -90,7 +92,9 @@ fn body_loader_dispatches_by_extension_and_rejects_other_formats() {
90 92 .unwrap();
91 93 assert_eq!(fbx.positions, direct.character.positions);
92 94 assert_eq!(fbx.clips.len(), 2);
93 let err = preview::load_body_file(&repo("tests/fixtures/equipment/sword.obj")).unwrap_err();
95 let obj = preview::load_body_file(&repo("tests/fixtures/equipment/sword.obj")).unwrap();
96 assert!(obj.is_static() && obj.clips.is_empty());
97 let err = preview::load_body_file(&repo("tests/fixtures/equipment/sword.ply")).unwrap_err();
94 98 assert!(err.contains("geen tekenbaar lichaam"), "{err}");
95 99 // A file named .glb that actually holds FBX bytes is not silently accepted.
96 100 let dir = scratch("renamed");
@@ -82,7 +82,9 @@ fn apply(m: &Mat4, p: [f32; 3]) -> [f32; 3] {
82fn body_loader_dispatches_by_extension_and_rejects_other_formats() {
82fn body_loader_dispatches_by_extension_and_rejects_other_formats() {
83 assert!(preview::is_drawable_body(std::path::Path::new("a/B.FBX")));
83 assert!(preview::is_drawable_body(std::path::Path::new("a/B.FBX")));
84 assert!(preview::is_drawable_body(std::path::Path::new("b.glb")));
84 assert!(preview::is_drawable_body(std::path::Path::new("b.glb")));
85 assert!(!preview::is_drawable_body(std::path::Path::new("c.obj")));
85 // OBJ is drawable as a static body (no skeleton); STL/PLY are not bodies.
86 assert!(preview::is_drawable_body(std::path::Path::new("c.obj")));
87 assert!(!preview::is_drawable_body(std::path::Path::new("d.stl")));
86 let fbx = fbx_body();
88 let fbx = fbx_body();
87 let direct = paperdoll_renderer::fbx_scene::load_fbx_drawable(&repo(
89 let direct = paperdoll_renderer::fbx_scene::load_fbx_drawable(&repo(
88 "tests/fixtures/bodies/test-body-male.fbx",
90 "tests/fixtures/bodies/test-body-male.fbx",
@@ -90,7 +92,9 @@ fn body_loader_dispatches_by_extension_and_rejects_other_formats() {
90 .unwrap();
92 .unwrap();
91 assert_eq!(fbx.positions, direct.character.positions);
93 assert_eq!(fbx.positions, direct.character.positions);
92 assert_eq!(fbx.clips.len(), 2);
94 assert_eq!(fbx.clips.len(), 2);
93 let err = preview::load_body_file(&repo("tests/fixtures/equipment/sword.obj")).unwrap_err();
95 let obj = preview::load_body_file(&repo("tests/fixtures/equipment/sword.obj")).unwrap();
96 assert!(obj.is_static() && obj.clips.is_empty());
97 let err = preview::load_body_file(&repo("tests/fixtures/equipment/sword.ply")).unwrap_err();
94 assert!(err.contains("geen tekenbaar lichaam"), "{err}");
98 assert!(err.contains("geen tekenbaar lichaam"), "{err}");
95 // A file named .glb that actually holds FBX bytes is not silently accepted.
99 // A file named .glb that actually holds FBX bytes is not silently accepted.
96 let dir = scratch("renamed");
100 let dir = scratch("renamed");
tests/fixtures/bodies/README.md +2 −0
@@ -23,3 +23,5 @@ python3 scripts/generate_test_bodies.py
23 23 Licentie van de gegenereerde meshes, rigs en clips: CC0-1.0. Zie `LICENSE` in deze map.
24 24
25 25 `test-body-male.fbx` is dezelfde man, omgezet met Blender (`scripts/convert_test_body_to_fbx.py`). Blender hoort niet bij de applicatie; het bestand ligt in de repository zodat de FBX-import zonder Blender getest wordt. De clipnamen krijgen daar het suffix van de skin (`walk_test-body-male-skin`, `attack_test-body-male-skin`).
26
27 `static/` bevat hetzelfde mannelijke testlichaam in bindpose **zonder skin**, gemaakt door `scripts/generate_static_bodies.py` (CC0-1.0, zie `LICENSE` in deze map): `static-body-male.obj` + `.mtl` + `static-body-albedo.png` (bovenste helft rood, onderste helft blauw, zodat de UV-oriëntatie testbaar is) en `static-body-male.glb` (geometrie onder een verschoven/geschaalde ouder-node en een geroteerde kind-node, plus één node-animatie die niet toegepast mag worden).
@@ -23,3 +23,5 @@ python3 scripts/generate_test_bodies.py
23Licentie van de gegenereerde meshes, rigs en clips: CC0-1.0. Zie `LICENSE` in deze map.
23Licentie van de gegenereerde meshes, rigs en clips: CC0-1.0. Zie `LICENSE` in deze map.
24
24
25`test-body-male.fbx` is dezelfde man, omgezet met Blender (`scripts/convert_test_body_to_fbx.py`). Blender hoort niet bij de applicatie; het bestand ligt in de repository zodat de FBX-import zonder Blender getest wordt. De clipnamen krijgen daar het suffix van de skin (`walk_test-body-male-skin`, `attack_test-body-male-skin`).
25`test-body-male.fbx` is dezelfde man, omgezet met Blender (`scripts/convert_test_body_to_fbx.py`). Blender hoort niet bij de applicatie; het bestand ligt in de repository zodat de FBX-import zonder Blender getest wordt. De clipnamen krijgen daar het suffix van de skin (`walk_test-body-male-skin`, `attack_test-body-male-skin`).
26
27`static/` bevat hetzelfde mannelijke testlichaam in bindpose **zonder skin**, gemaakt door `scripts/generate_static_bodies.py` (CC0-1.0, zie `LICENSE` in deze map): `static-body-male.obj` + `.mtl` + `static-body-albedo.png` (bovenste helft rood, onderste helft blauw, zodat de UV-oriëntatie testbaar is) en `static-body-male.glb` (geometrie onder een verschoven/geschaalde ouder-node en een geroteerde kind-node, plus één node-animatie die niet toegepast mag worden).
tests/fixtures/bodies/static/static-body-albedo.png added Binary file +0 −0

The contents of this binary file are not shown.

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

The contents of this binary file are not shown.

tests/fixtures/bodies/static/static-body-male.mtl added +5 −0
@@ -0,0 +1,5 @@
1 # CC0 static test body material
2 newmtl body
3 Kd 1.0 1.0 1.0
4 d 1.0
5 map_Kd static-body-albedo.png
@@ -0,0 +1,5 @@
1# CC0 static test body material
2newmtl body
3Kd 1.0 1.0 1.0
4d 1.0
5map_Kd static-body-albedo.png
tests/fixtures/bodies/static/static-body-male.obj added +613 −0
@@ -0,0 +1,613 @@
1 # Static CC0 test body (no skeleton). Generated by scripts/generate_static_bodies.py
2 mtllib static-body-male.mtl
3 usemtl body
4 vt 0.5 0.85
5 vt 0.5 0.15
6 v -0.140000 0.880000 -0.090000
7 v 0.140000 0.880000 -0.090000
8 v 0.140000 1.040000 -0.090000
9 v -0.140000 1.040000 -0.090000
10 v -0.140000 0.880000 0.090000
11 v 0.140000 0.880000 0.090000
12 v 0.140000 1.040000 0.090000
13 v -0.140000 1.040000 0.090000
14 vn 0.000000 0.000000 -1.000000
15 vn 0.000000 0.000000 -1.000000
16 vn 0.000000 0.000000 1.000000
17 vn 0.000000 -0.000000 1.000000
18 vn -1.000000 0.000000 0.000000
19 vn -1.000000 0.000000 0.000000
20 vn 1.000000 0.000000 0.000000
21 vn 1.000000 0.000000 0.000000
22 vn 0.000000 1.000000 0.000000
23 vn 0.000000 1.000000 0.000000
24 vn 0.000000 -1.000000 0.000000
25 vn 0.000000 -1.000000 0.000000
26 v -0.126000 1.020000 -0.080000
27 v 0.126000 1.020000 -0.080000
28 v 0.126000 1.180000 -0.080000
29 v -0.126000 1.180000 -0.080000
30 v -0.126000 1.020000 0.080000
31 v 0.126000 1.020000 0.080000
32 v 0.126000 1.180000 0.080000
33 v -0.126000 1.180000 0.080000
34 vn 0.000000 0.000000 -1.000000
35 vn 0.000000 0.000000 -1.000000
36 vn 0.000000 0.000000 1.000000
37 vn 0.000000 -0.000000 1.000000
38 vn -1.000000 0.000000 0.000000
39 vn -1.000000 0.000000 0.000000
40 vn 1.000000 0.000000 0.000000
41 vn 1.000000 0.000000 0.000000
42 vn 0.000000 1.000000 0.000000
43 vn 0.000000 1.000000 0.000000
44 vn 0.000000 -1.000000 0.000000
45 vn 0.000000 -1.000000 0.000000
46 v -0.180000 1.190000 -0.090000
47 v 0.180000 1.190000 -0.090000
48 v 0.180000 1.410000 -0.090000
49 v -0.180000 1.410000 -0.090000
50 v -0.180000 1.190000 0.090000
51 v 0.180000 1.190000 0.090000
52 v 0.180000 1.410000 0.090000
53 v -0.180000 1.410000 0.090000
54 vn 0.000000 0.000000 -1.000000
55 vn 0.000000 0.000000 -1.000000
56 vn 0.000000 0.000000 1.000000
57 vn 0.000000 -0.000000 1.000000
58 vn -1.000000 0.000000 0.000000
59 vn -1.000000 0.000000 0.000000
60 vn 1.000000 0.000000 0.000000
61 vn 1.000000 0.000000 0.000000
62 vn 0.000000 1.000000 0.000000
63 vn 0.000000 1.000000 0.000000
64 vn 0.000000 -1.000000 0.000000
65 vn 0.000000 -1.000000 0.000000
66 v -0.050000 1.420000 -0.050000
67 v 0.050000 1.420000 -0.050000
68 v 0.050000 1.500000 -0.050000
69 v -0.050000 1.500000 -0.050000
70 v -0.050000 1.420000 0.050000
71 v 0.050000 1.420000 0.050000
72 v 0.050000 1.500000 0.050000
73 v -0.050000 1.500000 0.050000
74 vn 0.000000 0.000000 -1.000000
75 vn 0.000000 0.000000 -1.000000
76 vn 0.000000 0.000000 1.000000
77 vn 0.000000 -0.000000 1.000000
78 vn -1.000000 0.000000 0.000000
79 vn -1.000000 0.000000 0.000000
80 vn 1.000000 0.000000 0.000000
81 vn 1.000000 0.000000 0.000000
82 vn 0.000000 1.000000 0.000000
83 vn 0.000000 1.000000 0.000000
84 vn 0.000000 -1.000000 0.000000
85 vn 0.000000 -1.000000 0.000000
86 v -0.090000 1.490000 -0.100000
87 v 0.090000 1.490000 -0.100000
88 v 0.090000 1.710000 -0.100000
89 v -0.090000 1.710000 -0.100000
90 v -0.090000 1.490000 0.100000
91 v 0.090000 1.490000 0.100000
92 v 0.090000 1.710000 0.100000
93 v -0.090000 1.710000 0.100000
94 vn 0.000000 0.000000 -1.000000
95 vn 0.000000 0.000000 -1.000000
96 vn 0.000000 0.000000 1.000000
97 vn 0.000000 -0.000000 1.000000
98 vn -1.000000 0.000000 0.000000
99 vn -1.000000 0.000000 0.000000
100 vn 1.000000 0.000000 0.000000
101 vn 1.000000 0.000000 0.000000
102 vn 0.000000 1.000000 0.000000
103 vn 0.000000 1.000000 0.000000
104 vn 0.000000 -1.000000 0.000000
105 vn 0.000000 -1.000000 0.000000
106 v -0.250000 1.340000 -0.040000
107 v -0.150000 1.340000 -0.040000
108 v -0.150000 1.420000 -0.040000
109 v -0.250000 1.420000 -0.040000
110 v -0.250000 1.340000 0.040000
111 v -0.150000 1.340000 0.040000
112 v -0.150000 1.420000 0.040000
113 v -0.250000 1.420000 0.040000
114 vn 0.000000 0.000000 -1.000000
115 vn 0.000000 0.000000 -1.000000
116 vn 0.000000 0.000000 1.000000
117 vn 0.000000 -0.000000 1.000000
118 vn -1.000000 0.000000 0.000000
119 vn -1.000000 0.000000 0.000000
120 vn 1.000000 0.000000 0.000000
121 vn 1.000000 0.000000 0.000000
122 vn 0.000000 1.000000 0.000000
123 vn 0.000000 1.000000 0.000000
124 vn 0.000000 -1.000000 0.000000
125 vn 0.000000 -1.000000 0.000000
126 v -0.320000 1.240000 -0.040000
127 v -0.240000 1.240000 -0.040000
128 v -0.240000 1.520000 -0.040000
129 v -0.320000 1.520000 -0.040000
130 v -0.320000 1.240000 0.040000
131 v -0.240000 1.240000 0.040000
132 v -0.240000 1.520000 0.040000
133 v -0.320000 1.520000 0.040000
134 vn 0.000000 0.000000 -1.000000
135 vn 0.000000 0.000000 -1.000000
136 vn 0.000000 0.000000 1.000000
137 vn 0.000000 -0.000000 1.000000
138 vn -1.000000 0.000000 0.000000
139 vn -1.000000 0.000000 0.000000
140 vn 1.000000 0.000000 0.000000
141 vn 1.000000 0.000000 0.000000
142 vn 0.000000 1.000000 0.000000
143 vn 0.000000 1.000000 0.000000
144 vn 0.000000 -1.000000 0.000000
145 vn 0.000000 -1.000000 0.000000
146 v -0.315000 0.970000 -0.035000
147 v -0.245000 0.970000 -0.035000
148 v -0.245000 1.230000 -0.035000
149 v -0.315000 1.230000 -0.035000
150 v -0.315000 0.970000 0.035000
151 v -0.245000 0.970000 0.035000
152 v -0.245000 1.230000 0.035000
153 v -0.315000 1.230000 0.035000
154 vn 0.000000 0.000000 -1.000000
155 vn 0.000000 0.000000 -1.000000
156 vn 0.000000 0.000000 1.000000
157 vn 0.000000 -0.000000 1.000000
158 vn -1.000000 0.000000 0.000000
159 vn -1.000000 0.000000 0.000000
160 vn 1.000000 0.000000 0.000000
161 vn 1.000000 0.000000 0.000000
162 vn 0.000000 1.000000 0.000000
163 vn 0.000000 1.000000 0.000000
164 vn 0.000000 -1.000000 0.000000
165 vn 0.000000 -1.000000 0.000000
166 v -0.320000 0.790000 -0.020000
167 v -0.240000 0.790000 -0.020000
168 v -0.240000 0.890000 -0.020000
169 v -0.320000 0.890000 -0.020000
170 v -0.320000 0.790000 0.020000
171 v -0.240000 0.790000 0.020000
172 v -0.240000 0.890000 0.020000
173 v -0.320000 0.890000 0.020000
174 vn 0.000000 0.000000 -1.000000
175 vn 0.000000 0.000000 -1.000000
176 vn 0.000000 0.000000 1.000000
177 vn 0.000000 -0.000000 1.000000
178 vn -1.000000 0.000000 0.000000
179 vn -1.000000 0.000000 0.000000
180 vn 1.000000 0.000000 0.000000
181 vn 1.000000 0.000000 0.000000
182 vn 0.000000 1.000000 0.000000
183 vn 0.000000 1.000000 0.000000
184 vn 0.000000 -1.000000 0.000000
185 vn 0.000000 -1.000000 0.000000
186 v 0.150000 1.340000 -0.040000
187 v 0.250000 1.340000 -0.040000
188 v 0.250000 1.420000 -0.040000
189 v 0.150000 1.420000 -0.040000
190 v 0.150000 1.340000 0.040000
191 v 0.250000 1.340000 0.040000
192 v 0.250000 1.420000 0.040000
193 v 0.150000 1.420000 0.040000
194 vn 0.000000 0.000000 -1.000000
195 vn 0.000000 0.000000 -1.000000
196 vn 0.000000 0.000000 1.000000
197 vn 0.000000 -0.000000 1.000000
198 vn -1.000000 0.000000 0.000000
199 vn -1.000000 0.000000 0.000000
200 vn 1.000000 0.000000 0.000000
201 vn 1.000000 0.000000 0.000000
202 vn 0.000000 1.000000 0.000000
203 vn 0.000000 1.000000 0.000000
204 vn 0.000000 -1.000000 0.000000
205 vn 0.000000 -1.000000 0.000000
206 v 0.240000 1.240000 -0.040000
207 v 0.320000 1.240000 -0.040000
208 v 0.320000 1.520000 -0.040000
209 v 0.240000 1.520000 -0.040000
210 v 0.240000 1.240000 0.040000
211 v 0.320000 1.240000 0.040000
212 v 0.320000 1.520000 0.040000
213 v 0.240000 1.520000 0.040000
214 vn 0.000000 0.000000 -1.000000
215 vn 0.000000 0.000000 -1.000000
216 vn 0.000000 0.000000 1.000000
217 vn 0.000000 -0.000000 1.000000
218 vn -1.000000 0.000000 0.000000
219 vn -1.000000 0.000000 0.000000
220 vn 1.000000 0.000000 0.000000
221 vn 1.000000 0.000000 0.000000
222 vn 0.000000 1.000000 0.000000
223 vn 0.000000 1.000000 0.000000
224 vn 0.000000 -1.000000 0.000000
225 vn 0.000000 -1.000000 0.000000
226 v 0.245000 0.970000 -0.035000
227 v 0.315000 0.970000 -0.035000
228 v 0.315000 1.230000 -0.035000
229 v 0.245000 1.230000 -0.035000
230 v 0.245000 0.970000 0.035000
231 v 0.315000 0.970000 0.035000
232 v 0.315000 1.230000 0.035000
233 v 0.245000 1.230000 0.035000
234 vn 0.000000 0.000000 -1.000000
235 vn 0.000000 0.000000 -1.000000
236 vn 0.000000 0.000000 1.000000
237 vn 0.000000 -0.000000 1.000000
238 vn -1.000000 0.000000 0.000000
239 vn -1.000000 0.000000 0.000000
240 vn 1.000000 0.000000 0.000000
241 vn 1.000000 0.000000 0.000000
242 vn 0.000000 1.000000 0.000000
243 vn 0.000000 1.000000 0.000000
244 vn 0.000000 -1.000000 0.000000
245 vn 0.000000 -1.000000 0.000000
246 v 0.240000 0.790000 -0.020000
247 v 0.320000 0.790000 -0.020000
248 v 0.320000 0.890000 -0.020000
249 v 0.240000 0.890000 -0.020000
250 v 0.240000 0.790000 0.020000
251 v 0.320000 0.790000 0.020000
252 v 0.320000 0.890000 0.020000
253 v 0.240000 0.890000 0.020000
254 vn 0.000000 0.000000 -1.000000
255 vn 0.000000 0.000000 -1.000000
256 vn 0.000000 0.000000 1.000000
257 vn 0.000000 -0.000000 1.000000
258 vn -1.000000 0.000000 0.000000
259 vn -1.000000 0.000000 0.000000
260 vn 1.000000 0.000000 0.000000
261 vn 1.000000 0.000000 0.000000
262 vn 0.000000 1.000000 0.000000
263 vn 0.000000 1.000000 0.000000
264 vn 0.000000 -1.000000 0.000000
265 vn 0.000000 -1.000000 0.000000
266 v -0.160000 0.690000 -0.060000
267 v -0.040000 0.690000 -0.060000
268 v -0.040000 1.110000 -0.060000
269 v -0.160000 1.110000 -0.060000
270 v -0.160000 0.690000 0.060000
271 v -0.040000 0.690000 0.060000
272 v -0.040000 1.110000 0.060000
273 v -0.160000 1.110000 0.060000
274 vn 0.000000 0.000000 -1.000000
275 vn 0.000000 0.000000 -1.000000
276 vn 0.000000 0.000000 1.000000
277 vn 0.000000 -0.000000 1.000000
278 vn -1.000000 0.000000 0.000000
279 vn -1.000000 0.000000 0.000000
280 vn 1.000000 0.000000 0.000000
281 vn 1.000000 0.000000 0.000000
282 vn 0.000000 1.000000 0.000000
283 vn 0.000000 1.000000 0.000000
284 vn 0.000000 -1.000000 0.000000
285 vn 0.000000 -1.000000 0.000000
286 v -0.145000 0.280000 -0.045000
287 v -0.055000 0.280000 -0.045000
288 v -0.055000 0.680000 -0.045000
289 v -0.145000 0.680000 -0.045000
290 v -0.145000 0.280000 0.045000
291 v -0.055000 0.280000 0.045000
292 v -0.055000 0.680000 0.045000
293 v -0.145000 0.680000 0.045000
294 vn 0.000000 0.000000 -1.000000
295 vn 0.000000 0.000000 -1.000000
296 vn 0.000000 0.000000 1.000000
297 vn 0.000000 -0.000000 1.000000
298 vn -1.000000 0.000000 0.000000
299 vn -1.000000 0.000000 0.000000
300 vn 1.000000 0.000000 0.000000
301 vn 1.000000 0.000000 0.000000
302 vn 0.000000 1.000000 0.000000
303 vn 0.000000 1.000000 0.000000
304 vn 0.000000 -1.000000 0.000000
305 vn 0.000000 -1.000000 0.000000
306 v -0.150000 0.050000 -0.170000
307 v -0.050000 0.050000 -0.170000
308 v -0.050000 0.110000 -0.170000
309 v -0.150000 0.110000 -0.170000
310 v -0.150000 0.050000 0.050000
311 v -0.050000 0.050000 0.050000
312 v -0.050000 0.110000 0.050000
313 v -0.150000 0.110000 0.050000
314 vn 0.000000 0.000000 -1.000000
315 vn 0.000000 0.000000 -1.000000
316 vn 0.000000 0.000000 1.000000
317 vn 0.000000 -0.000000 1.000000
318 vn -1.000000 0.000000 0.000000
319 vn -1.000000 0.000000 0.000000
320 vn 1.000000 0.000000 0.000000
321 vn 1.000000 0.000000 0.000000
322 vn 0.000000 1.000000 0.000000
323 vn 0.000000 1.000000 0.000000
324 vn 0.000000 -1.000000 0.000000
325 vn 0.000000 -1.000000 0.000000
326 v 0.040000 0.690000 -0.060000
327 v 0.160000 0.690000 -0.060000
328 v 0.160000 1.110000 -0.060000
329 v 0.040000 1.110000 -0.060000
330 v 0.040000 0.690000 0.060000
331 v 0.160000 0.690000 0.060000
332 v 0.160000 1.110000 0.060000
333 v 0.040000 1.110000 0.060000
334 vn 0.000000 0.000000 -1.000000
335 vn 0.000000 0.000000 -1.000000
336 vn 0.000000 0.000000 1.000000
337 vn 0.000000 -0.000000 1.000000
338 vn -1.000000 0.000000 0.000000
339 vn -1.000000 0.000000 0.000000
340 vn 1.000000 0.000000 0.000000
341 vn 1.000000 0.000000 0.000000
342 vn 0.000000 1.000000 0.000000
343 vn 0.000000 1.000000 0.000000
344 vn 0.000000 -1.000000 0.000000
345 vn 0.000000 -1.000000 0.000000
346 v 0.055000 0.280000 -0.045000
347 v 0.145000 0.280000 -0.045000
348 v 0.145000 0.680000 -0.045000
349 v 0.055000 0.680000 -0.045000
350 v 0.055000 0.280000 0.045000
351 v 0.145000 0.280000 0.045000
352 v 0.145000 0.680000 0.045000
353 v 0.055000 0.680000 0.045000
354 vn 0.000000 0.000000 -1.000000
355 vn 0.000000 0.000000 -1.000000
356 vn 0.000000 0.000000 1.000000
357 vn 0.000000 -0.000000 1.000000
358 vn -1.000000 0.000000 0.000000
359 vn -1.000000 0.000000 0.000000
360 vn 1.000000 0.000000 0.000000
361 vn 1.000000 0.000000 0.000000
362 vn 0.000000 1.000000 0.000000
363 vn 0.000000 1.000000 0.000000
364 vn 0.000000 -1.000000 0.000000
365 vn 0.000000 -1.000000 0.000000
366 v 0.050000 0.050000 -0.170000
367 v 0.150000 0.050000 -0.170000
368 v 0.150000 0.110000 -0.170000
369 v 0.050000 0.110000 -0.170000
370 v 0.050000 0.050000 0.050000
371 v 0.150000 0.050000 0.050000
372 v 0.150000 0.110000 0.050000
373 v 0.050000 0.110000 0.050000
374 vn 0.000000 0.000000 -1.000000
375 vn 0.000000 0.000000 -1.000000
376 vn 0.000000 0.000000 1.000000
377 vn 0.000000 -0.000000 1.000000
378 vn -1.000000 0.000000 0.000000
379 vn -1.000000 0.000000 0.000000
380 vn 1.000000 0.000000 0.000000
381 vn 1.000000 0.000000 0.000000
382 vn 0.000000 1.000000 0.000000
383 vn 0.000000 1.000000 0.000000
384 vn 0.000000 -1.000000 0.000000
385 vn 0.000000 -1.000000 0.000000
386 f 1/2/1 3/2/1 2/2/1
387 f 1/2/2 4/2/2 3/2/2
388 f 6/2/3 8/2/3 5/2/3
389 f 6/2/4 7/2/4 8/2/4
390 f 5/2/5 4/2/5 1/2/5
391 f 5/2/6 8/2/6 4/2/6
392 f 2/2/7 7/2/7 6/2/7
393 f 2/2/8 3/2/8 7/2/8
394 f 4/2/9 7/2/9 3/2/9
395 f 4/2/10 8/2/10 7/2/10
396 f 5/2/11 2/2/11 6/2/11
397 f 5/2/12 1/2/12 2/2/12
398 f 9/2/13 11/2/13 10/2/13
399 f 9/2/14 12/2/14 11/2/14
400 f 14/2/15 16/2/15 13/2/15
401 f 14/2/16 15/2/16 16/2/16
402 f 13/2/17 12/2/17 9/2/17
403 f 13/2/18 16/2/18 12/2/18
404 f 10/2/19 15/2/19 14/2/19
405 f 10/2/20 11/2/20 15/2/20
406 f 12/2/21 15/2/21 11/2/21
407 f 12/2/22 16/2/22 15/2/22
408 f 13/2/23 10/2/23 14/2/23
409 f 13/2/24 9/2/24 10/2/24
410 f 17/1/25 19/1/25 18/1/25
411 f 17/1/26 20/1/26 19/1/26
412 f 22/1/27 24/1/27 21/1/27
413 f 22/1/28 23/1/28 24/1/28
414 f 21/1/29 20/1/29 17/1/29
415 f 21/1/30 24/1/30 20/1/30
416 f 18/1/31 23/1/31 22/1/31
417 f 18/1/32 19/1/32 23/1/32
418 f 20/1/33 23/1/33 19/1/33
419 f 20/1/34 24/1/34 23/1/34
420 f 21/1/35 18/1/35 22/1/35
421 f 21/1/36 17/1/36 18/1/36
422 f 25/1/37 27/1/37 26/1/37
423 f 25/1/38 28/1/38 27/1/38
424 f 30/1/39 32/1/39 29/1/39
425 f 30/1/40 31/1/40 32/1/40
426 f 29/1/41 28/1/41 25/1/41
427 f 29/1/42 32/1/42 28/1/42
428 f 26/1/43 31/1/43 30/1/43
429 f 26/1/44 27/1/44 31/1/44
430 f 28/1/45 31/1/45 27/1/45
431 f 28/1/46 32/1/46 31/1/46
432 f 29/1/47 26/1/47 30/1/47
433 f 29/1/48 25/1/48 26/1/48
434 f 33/1/49 35/1/49 34/1/49
435 f 33/1/50 36/1/50 35/1/50
436 f 38/1/51 40/1/51 37/1/51
437 f 38/1/52 39/1/52 40/1/52
438 f 37/1/53 36/1/53 33/1/53
439 f 37/1/54 40/1/54 36/1/54
440 f 34/1/55 39/1/55 38/1/55
441 f 34/1/56 35/1/56 39/1/56
442 f 36/1/57 39/1/57 35/1/57
443 f 36/1/58 40/1/58 39/1/58
444 f 37/1/59 34/1/59 38/1/59
445 f 37/1/60 33/1/60 34/1/60
446 f 41/1/61 43/1/61 42/1/61
447 f 41/1/62 44/1/62 43/1/62
448 f 46/1/63 48/1/63 45/1/63
449 f 46/1/64 47/1/64 48/1/64
450 f 45/1/65 44/1/65 41/1/65
451 f 45/1/66 48/1/66 44/1/66
452 f 42/1/67 47/1/67 46/1/67
453 f 42/1/68 43/1/68 47/1/68
454 f 44/1/69 47/1/69 43/1/69
455 f 44/1/70 48/1/70 47/1/70
456 f 45/1/71 42/1/71 46/1/71
457 f 45/1/72 41/1/72 42/1/72
458 f 49/1/73 51/1/73 50/1/73
459 f 49/1/74 52/1/74 51/1/74
460 f 54/1/75 56/1/75 53/1/75
461 f 54/1/76 55/1/76 56/1/76
462 f 53/1/77 52/1/77 49/1/77
463 f 53/1/78 56/1/78 52/1/78
464 f 50/1/79 55/1/79 54/1/79
465 f 50/1/80 51/1/80 55/1/80
466 f 52/1/81 55/1/81 51/1/81
467 f 52/1/82 56/1/82 55/1/82
468 f 53/1/83 50/1/83 54/1/83
469 f 53/1/84 49/1/84 50/1/84
470 f 57/2/85 59/2/85 58/2/85
471 f 57/2/86 60/2/86 59/2/86
472 f 62/2/87 64/2/87 61/2/87
473 f 62/2/88 63/2/88 64/2/88
474 f 61/2/89 60/2/89 57/2/89
475 f 61/2/90 64/2/90 60/2/90
476 f 58/2/91 63/2/91 62/2/91
477 f 58/2/92 59/2/92 63/2/92
478 f 60/2/93 63/2/93 59/2/93
479 f 60/2/94 64/2/94 63/2/94
480 f 61/2/95 58/2/95 62/2/95
481 f 61/2/96 57/2/96 58/2/96
482 f 65/2/97 67/2/97 66/2/97
483 f 65/2/98 68/2/98 67/2/98
484 f 70/2/99 72/2/99 69/2/99
485 f 70/2/100 71/2/100 72/2/100
486 f 69/2/101 68/2/101 65/2/101
487 f 69/2/102 72/2/102 68/2/102
488 f 66/2/103 71/2/103 70/2/103
489 f 66/2/104 67/2/104 71/2/104
490 f 68/2/105 71/2/105 67/2/105
491 f 68/2/106 72/2/106 71/2/106
492 f 69/2/107 66/2/107 70/2/107
493 f 69/2/108 65/2/108 66/2/108
494 f 73/1/109 75/1/109 74/1/109
495 f 73/1/110 76/1/110 75/1/110
496 f 78/1/111 80/1/111 77/1/111
497 f 78/1/112 79/1/112 80/1/112
498 f 77/1/113 76/1/113 73/1/113
499 f 77/1/114 80/1/114 76/1/114
500 f 74/1/115 79/1/115 78/1/115
501 f 74/1/116 75/1/116 79/1/116
502 f 76/1/117 79/1/117 75/1/117
503 f 76/1/118 80/1/118 79/1/118
504 f 77/1/119 74/1/119 78/1/119
505 f 77/1/120 73/1/120 74/1/120
506 f 81/1/121 83/1/121 82/1/121
507 f 81/1/122 84/1/122 83/1/122
508 f 86/1/123 88/1/123 85/1/123
509 f 86/1/124 87/1/124 88/1/124
510 f 85/1/125 84/1/125 81/1/125
511 f 85/1/126 88/1/126 84/1/126
512 f 82/1/127 87/1/127 86/1/127
513 f 82/1/128 83/1/128 87/1/128
514 f 84/1/129 87/1/129 83/1/129
515 f 84/1/130 88/1/130 87/1/130
516 f 85/1/131 82/1/131 86/1/131
517 f 85/1/132 81/1/132 82/1/132
518 f 89/2/133 91/2/133 90/2/133
519 f 89/2/134 92/2/134 91/2/134
520 f 94/2/135 96/2/135 93/2/135
521 f 94/2/136 95/2/136 96/2/136
522 f 93/2/137 92/2/137 89/2/137
523 f 93/2/138 96/2/138 92/2/138
524 f 90/2/139 95/2/139 94/2/139
525 f 90/2/140 91/2/140 95/2/140
526 f 92/2/141 95/2/141 91/2/141
527 f 92/2/142 96/2/142 95/2/142
528 f 93/2/143 90/2/143 94/2/143
529 f 93/2/144 89/2/144 90/2/144
530 f 97/2/145 99/2/145 98/2/145
531 f 97/2/146 100/2/146 99/2/146
532 f 102/2/147 104/2/147 101/2/147
533 f 102/2/148 103/2/148 104/2/148
534 f 101/2/149 100/2/149 97/2/149
535 f 101/2/150 104/2/150 100/2/150
536 f 98/2/151 103/2/151 102/2/151
537 f 98/2/152 99/2/152 103/2/152
538 f 100/2/153 103/2/153 99/2/153
539 f 100/2/154 104/2/154 103/2/154
540 f 101/2/155 98/2/155 102/2/155
541 f 101/2/156 97/2/156 98/2/156
542 f 105/2/157 107/2/157 106/2/157
543 f 105/2/158 108/2/158 107/2/158
544 f 110/2/159 112/2/159 109/2/159
545 f 110/2/160 111/2/160 112/2/160
546 f 109/2/161 108/2/161 105/2/161
547 f 109/2/162 112/2/162 108/2/162
548 f 106/2/163 111/2/163 110/2/163
549 f 106/2/164 107/2/164 111/2/164
550 f 108/2/165 111/2/165 107/2/165
551 f 108/2/166 112/2/166 111/2/166
552 f 109/2/167 106/2/167 110/2/167
553 f 109/2/168 105/2/168 106/2/168
554 f 113/2/169 115/2/169 114/2/169
555 f 113/2/170 116/2/170 115/2/170
556 f 118/2/171 120/2/171 117/2/171
557 f 118/2/172 119/2/172 120/2/172
558 f 117/2/173 116/2/173 113/2/173
559 f 117/2/174 120/2/174 116/2/174
560 f 114/2/175 119/2/175 118/2/175
561 f 114/2/176 115/2/176 119/2/176
562 f 116/2/177 119/2/177 115/2/177
563 f 116/2/178 120/2/178 119/2/178
564 f 117/2/179 114/2/179 118/2/179
565 f 117/2/180 113/2/180 114/2/180
566 f 121/2/181 123/2/181 122/2/181
567 f 121/2/182 124/2/182 123/2/182
568 f 126/2/183 128/2/183 125/2/183
569 f 126/2/184 127/2/184 128/2/184
570 f 125/2/185 124/2/185 121/2/185
571 f 125/2/186 128/2/186 124/2/186
572 f 122/2/187 127/2/187 126/2/187
573 f 122/2/188 123/2/188 127/2/188
574 f 124/2/189 127/2/189 123/2/189
575 f 124/2/190 128/2/190 127/2/190
576 f 125/2/191 122/2/191 126/2/191
577 f 125/2/192 121/2/192 122/2/192
578 f 129/2/193 131/2/193 130/2/193
579 f 129/2/194 132/2/194 131/2/194
580 f 134/2/195 136/2/195 133/2/195
581 f 134/2/196 135/2/196 136/2/196
582 f 133/2/197 132/2/197 129/2/197
583 f 133/2/198 136/2/198 132/2/198
584 f 130/2/199 135/2/199 134/2/199
585 f 130/2/200 131/2/200 135/2/200
586 f 132/2/201 135/2/201 131/2/201
587 f 132/2/202 136/2/202 135/2/202
588 f 133/2/203 130/2/203 134/2/203
589 f 133/2/204 129/2/204 130/2/204
590 f 137/2/205 139/2/205 138/2/205
591 f 137/2/206 140/2/206 139/2/206
592 f 142/2/207 144/2/207 141/2/207
593 f 142/2/208 143/2/208 144/2/208
594 f 141/2/209 140/2/209 137/2/209
595 f 141/2/210 144/2/210 140/2/210
596 f 138/2/211 143/2/211 142/2/211
597 f 138/2/212 139/2/212 143/2/212
598 f 140/2/213 143/2/213 139/2/213
599 f 140/2/214 144/2/214 143/2/214
600 f 141/2/215 138/2/215 142/2/215
601 f 141/2/216 137/2/216 138/2/216
602 f 145/2/217 147/2/217 146/2/217
603 f 145/2/218 148/2/218 147/2/218
604 f 150/2/219 152/2/219 149/2/219
605 f 150/2/220 151/2/220 152/2/220
606 f 149/2/221 148/2/221 145/2/221
607 f 149/2/222 152/2/222 148/2/222
608 f 146/2/223 151/2/223 150/2/223
609 f 146/2/224 147/2/224 151/2/224
610 f 148/2/225 151/2/225 147/2/225
611 f 148/2/226 152/2/226 151/2/226
612 f 149/2/227 146/2/227 150/2/227
613 f 149/2/228 145/2/228 146/2/228
@@ -0,0 +1,613 @@
1# Static CC0 test body (no skeleton). Generated by scripts/generate_static_bodies.py
2mtllib static-body-male.mtl
3usemtl body
4vt 0.5 0.85
5vt 0.5 0.15
6v -0.140000 0.880000 -0.090000
7v 0.140000 0.880000 -0.090000
8v 0.140000 1.040000 -0.090000
9v -0.140000 1.040000 -0.090000
10v -0.140000 0.880000 0.090000
11v 0.140000 0.880000 0.090000
12v 0.140000 1.040000 0.090000
13v -0.140000 1.040000 0.090000
14vn 0.000000 0.000000 -1.000000
15vn 0.000000 0.000000 -1.000000
16vn 0.000000 0.000000 1.000000
17vn 0.000000 -0.000000 1.000000
18vn -1.000000 0.000000 0.000000
19vn -1.000000 0.000000 0.000000
20vn 1.000000 0.000000 0.000000
21vn 1.000000 0.000000 0.000000
22vn 0.000000 1.000000 0.000000
23vn 0.000000 1.000000 0.000000
24vn 0.000000 -1.000000 0.000000
25vn 0.000000 -1.000000 0.000000
26v -0.126000 1.020000 -0.080000
27v 0.126000 1.020000 -0.080000
28v 0.126000 1.180000 -0.080000
29v -0.126000 1.180000 -0.080000
30v -0.126000 1.020000 0.080000
31v 0.126000 1.020000 0.080000
32v 0.126000 1.180000 0.080000
33v -0.126000 1.180000 0.080000
34vn 0.000000 0.000000 -1.000000
35vn 0.000000 0.000000 -1.000000
36vn 0.000000 0.000000 1.000000
37vn 0.000000 -0.000000 1.000000
38vn -1.000000 0.000000 0.000000
39vn -1.000000 0.000000 0.000000
40vn 1.000000 0.000000 0.000000
41vn 1.000000 0.000000 0.000000
42vn 0.000000 1.000000 0.000000
43vn 0.000000 1.000000 0.000000
44vn 0.000000 -1.000000 0.000000
45vn 0.000000 -1.000000 0.000000
46v -0.180000 1.190000 -0.090000
47v 0.180000 1.190000 -0.090000
48v 0.180000 1.410000 -0.090000
49v -0.180000 1.410000 -0.090000
50v -0.180000 1.190000 0.090000
51v 0.180000 1.190000 0.090000
52v 0.180000 1.410000 0.090000
53v -0.180000 1.410000 0.090000
54vn 0.000000 0.000000 -1.000000
55vn 0.000000 0.000000 -1.000000
56vn 0.000000 0.000000 1.000000
57vn 0.000000 -0.000000 1.000000
58vn -1.000000 0.000000 0.000000
59vn -1.000000 0.000000 0.000000
60vn 1.000000 0.000000 0.000000
61vn 1.000000 0.000000 0.000000
62vn 0.000000 1.000000 0.000000
63vn 0.000000 1.000000 0.000000
64vn 0.000000 -1.000000 0.000000
65vn 0.000000 -1.000000 0.000000
66v -0.050000 1.420000 -0.050000
67v 0.050000 1.420000 -0.050000
68v 0.050000 1.500000 -0.050000
69v -0.050000 1.500000 -0.050000
70v -0.050000 1.420000 0.050000
71v 0.050000 1.420000 0.050000
72v 0.050000 1.500000 0.050000
73v -0.050000 1.500000 0.050000
74vn 0.000000 0.000000 -1.000000
75vn 0.000000 0.000000 -1.000000
76vn 0.000000 0.000000 1.000000
77vn 0.000000 -0.000000 1.000000
78vn -1.000000 0.000000 0.000000
79vn -1.000000 0.000000 0.000000
80vn 1.000000 0.000000 0.000000
81vn 1.000000 0.000000 0.000000
82vn 0.000000 1.000000 0.000000
83vn 0.000000 1.000000 0.000000
84vn 0.000000 -1.000000 0.000000
85vn 0.000000 -1.000000 0.000000
86v -0.090000 1.490000 -0.100000
87v 0.090000 1.490000 -0.100000
88v 0.090000 1.710000 -0.100000
89v -0.090000 1.710000 -0.100000
90v -0.090000 1.490000 0.100000
91v 0.090000 1.490000 0.100000
92v 0.090000 1.710000 0.100000
93v -0.090000 1.710000 0.100000
94vn 0.000000 0.000000 -1.000000
95vn 0.000000 0.000000 -1.000000
96vn 0.000000 0.000000 1.000000
97vn 0.000000 -0.000000 1.000000
98vn -1.000000 0.000000 0.000000
99vn -1.000000 0.000000 0.000000
100vn 1.000000 0.000000 0.000000
101vn 1.000000 0.000000 0.000000
102vn 0.000000 1.000000 0.000000
103vn 0.000000 1.000000 0.000000
104vn 0.000000 -1.000000 0.000000
105vn 0.000000 -1.000000 0.000000
106v -0.250000 1.340000 -0.040000
107v -0.150000 1.340000 -0.040000
108v -0.150000 1.420000 -0.040000
109v -0.250000 1.420000 -0.040000
110v -0.250000 1.340000 0.040000
111v -0.150000 1.340000 0.040000
112v -0.150000 1.420000 0.040000
113v -0.250000 1.420000 0.040000
114vn 0.000000 0.000000 -1.000000
115vn 0.000000 0.000000 -1.000000
116vn 0.000000 0.000000 1.000000
117vn 0.000000 -0.000000 1.000000
118vn -1.000000 0.000000 0.000000
119vn -1.000000 0.000000 0.000000
120vn 1.000000 0.000000 0.000000
121vn 1.000000 0.000000 0.000000
122vn 0.000000 1.000000 0.000000
123vn 0.000000 1.000000 0.000000
124vn 0.000000 -1.000000 0.000000
125vn 0.000000 -1.000000 0.000000
126v -0.320000 1.240000 -0.040000
127v -0.240000 1.240000 -0.040000
128v -0.240000 1.520000 -0.040000
129v -0.320000 1.520000 -0.040000
130v -0.320000 1.240000 0.040000
131v -0.240000 1.240000 0.040000
132v -0.240000 1.520000 0.040000
133v -0.320000 1.520000 0.040000
134vn 0.000000 0.000000 -1.000000
135vn 0.000000 0.000000 -1.000000
136vn 0.000000 0.000000 1.000000
137vn 0.000000 -0.000000 1.000000
138vn -1.000000 0.000000 0.000000
139vn -1.000000 0.000000 0.000000
140vn 1.000000 0.000000 0.000000
141vn 1.000000 0.000000 0.000000
142vn 0.000000 1.000000 0.000000
143vn 0.000000 1.000000 0.000000
144vn 0.000000 -1.000000 0.000000
145vn 0.000000 -1.000000 0.000000
146v -0.315000 0.970000 -0.035000
147v -0.245000 0.970000 -0.035000
148v -0.245000 1.230000 -0.035000
149v -0.315000 1.230000 -0.035000
150v -0.315000 0.970000 0.035000
151v -0.245000 0.970000 0.035000
152v -0.245000 1.230000 0.035000
153v -0.315000 1.230000 0.035000
154vn 0.000000 0.000000 -1.000000
155vn 0.000000 0.000000 -1.000000
156vn 0.000000 0.000000 1.000000
157vn 0.000000 -0.000000 1.000000
158vn -1.000000 0.000000 0.000000
159vn -1.000000 0.000000 0.000000
160vn 1.000000 0.000000 0.000000
161vn 1.000000 0.000000 0.000000
162vn 0.000000 1.000000 0.000000
163vn 0.000000 1.000000 0.000000
164vn 0.000000 -1.000000 0.000000
165vn 0.000000 -1.000000 0.000000
166v -0.320000 0.790000 -0.020000
167v -0.240000 0.790000 -0.020000
168v -0.240000 0.890000 -0.020000
169v -0.320000 0.890000 -0.020000
170v -0.320000 0.790000 0.020000
171v -0.240000 0.790000 0.020000
172v -0.240000 0.890000 0.020000
173v -0.320000 0.890000 0.020000
174vn 0.000000 0.000000 -1.000000
175vn 0.000000 0.000000 -1.000000
176vn 0.000000 0.000000 1.000000
177vn 0.000000 -0.000000 1.000000
178vn -1.000000 0.000000 0.000000
179vn -1.000000 0.000000 0.000000
180vn 1.000000 0.000000 0.000000
181vn 1.000000 0.000000 0.000000
182vn 0.000000 1.000000 0.000000
183vn 0.000000 1.000000 0.000000
184vn 0.000000 -1.000000 0.000000
185vn 0.000000 -1.000000 0.000000
186v 0.150000 1.340000 -0.040000
187v 0.250000 1.340000 -0.040000
188v 0.250000 1.420000 -0.040000
189v 0.150000 1.420000 -0.040000
190v 0.150000 1.340000 0.040000
191v 0.250000 1.340000 0.040000
192v 0.250000 1.420000 0.040000
193v 0.150000 1.420000 0.040000
194vn 0.000000 0.000000 -1.000000
195vn 0.000000 0.000000 -1.000000
196vn 0.000000 0.000000 1.000000
197vn 0.000000 -0.000000 1.000000
198vn -1.000000 0.000000 0.000000
199vn -1.000000 0.000000 0.000000
200vn 1.000000 0.000000 0.000000
201vn 1.000000 0.000000 0.000000
202vn 0.000000 1.000000 0.000000
203vn 0.000000 1.000000 0.000000
204vn 0.000000 -1.000000 0.000000
205vn 0.000000 -1.000000 0.000000
206v 0.240000 1.240000 -0.040000
207v 0.320000 1.240000 -0.040000
208v 0.320000 1.520000 -0.040000
209v 0.240000 1.520000 -0.040000
210v 0.240000 1.240000 0.040000
211v 0.320000 1.240000 0.040000
212v 0.320000 1.520000 0.040000
213v 0.240000 1.520000 0.040000
214vn 0.000000 0.000000 -1.000000
215vn 0.000000 0.000000 -1.000000
216vn 0.000000 0.000000 1.000000
217vn 0.000000 -0.000000 1.000000
218vn -1.000000 0.000000 0.000000
219vn -1.000000 0.000000 0.000000
220vn 1.000000 0.000000 0.000000
221vn 1.000000 0.000000 0.000000
222vn 0.000000 1.000000 0.000000
223vn 0.000000 1.000000 0.000000
224vn 0.000000 -1.000000 0.000000
225vn 0.000000 -1.000000 0.000000
226v 0.245000 0.970000 -0.035000
227v 0.315000 0.970000 -0.035000
228v 0.315000 1.230000 -0.035000
229v 0.245000 1.230000 -0.035000
230v 0.245000 0.970000 0.035000
231v 0.315000 0.970000 0.035000
232v 0.315000 1.230000 0.035000
233v 0.245000 1.230000 0.035000
234vn 0.000000 0.000000 -1.000000
235vn 0.000000 0.000000 -1.000000
236vn 0.000000 0.000000 1.000000
237vn 0.000000 -0.000000 1.000000
238vn -1.000000 0.000000 0.000000
239vn -1.000000 0.000000 0.000000
240vn 1.000000 0.000000 0.000000
241vn 1.000000 0.000000 0.000000
242vn 0.000000 1.000000 0.000000
243vn 0.000000 1.000000 0.000000
244vn 0.000000 -1.000000 0.000000
245vn 0.000000 -1.000000 0.000000
246v 0.240000 0.790000 -0.020000
247v 0.320000 0.790000 -0.020000
248v 0.320000 0.890000 -0.020000
249v 0.240000 0.890000 -0.020000
250v 0.240000 0.790000 0.020000
251v 0.320000 0.790000 0.020000
252v 0.320000 0.890000 0.020000
253v 0.240000 0.890000 0.020000
254vn 0.000000 0.000000 -1.000000
255vn 0.000000 0.000000 -1.000000
256vn 0.000000 0.000000 1.000000
257vn 0.000000 -0.000000 1.000000
258vn -1.000000 0.000000 0.000000
259vn -1.000000 0.000000 0.000000
260vn 1.000000 0.000000 0.000000
261vn 1.000000 0.000000 0.000000
262vn 0.000000 1.000000 0.000000
263vn 0.000000 1.000000 0.000000
264vn 0.000000 -1.000000 0.000000
265vn 0.000000 -1.000000 0.000000
266v -0.160000 0.690000 -0.060000
267v -0.040000 0.690000 -0.060000
268v -0.040000 1.110000 -0.060000
269v -0.160000 1.110000 -0.060000
270v -0.160000 0.690000 0.060000
271v -0.040000 0.690000 0.060000
272v -0.040000 1.110000 0.060000
273v -0.160000 1.110000 0.060000
274vn 0.000000 0.000000 -1.000000
275vn 0.000000 0.000000 -1.000000
276vn 0.000000 0.000000 1.000000
277vn 0.000000 -0.000000 1.000000
278vn -1.000000 0.000000 0.000000
279vn -1.000000 0.000000 0.000000
280vn 1.000000 0.000000 0.000000
281vn 1.000000 0.000000 0.000000
282vn 0.000000 1.000000 0.000000
283vn 0.000000 1.000000 0.000000
284vn 0.000000 -1.000000 0.000000
285vn 0.000000 -1.000000 0.000000
286v -0.145000 0.280000 -0.045000
287v -0.055000 0.280000 -0.045000
288v -0.055000 0.680000 -0.045000
289v -0.145000 0.680000 -0.045000
290v -0.145000 0.280000 0.045000
291v -0.055000 0.280000 0.045000
292v -0.055000 0.680000 0.045000
293v -0.145000 0.680000 0.045000
294vn 0.000000 0.000000 -1.000000
295vn 0.000000 0.000000 -1.000000
296vn 0.000000 0.000000 1.000000
297vn 0.000000 -0.000000 1.000000
298vn -1.000000 0.000000 0.000000
299vn -1.000000 0.000000 0.000000
300vn 1.000000 0.000000 0.000000
301vn 1.000000 0.000000 0.000000
302vn 0.000000 1.000000 0.000000
303vn 0.000000 1.000000 0.000000
304vn 0.000000 -1.000000 0.000000
305vn 0.000000 -1.000000 0.000000
306v -0.150000 0.050000 -0.170000
307v -0.050000 0.050000 -0.170000
308v -0.050000 0.110000 -0.170000
309v -0.150000 0.110000 -0.170000
310v -0.150000 0.050000 0.050000
311v -0.050000 0.050000 0.050000
312v -0.050000 0.110000 0.050000
313v -0.150000 0.110000 0.050000
314vn 0.000000 0.000000 -1.000000
315vn 0.000000 0.000000 -1.000000
316vn 0.000000 0.000000 1.000000
317vn 0.000000 -0.000000 1.000000
318vn -1.000000 0.000000 0.000000
319vn -1.000000 0.000000 0.000000
320vn 1.000000 0.000000 0.000000
321vn 1.000000 0.000000 0.000000
322vn 0.000000 1.000000 0.000000
323vn 0.000000 1.000000 0.000000
324vn 0.000000 -1.000000 0.000000
325vn 0.000000 -1.000000 0.000000
326v 0.040000 0.690000 -0.060000
327v 0.160000 0.690000 -0.060000
328v 0.160000 1.110000 -0.060000
329v 0.040000 1.110000 -0.060000
330v 0.040000 0.690000 0.060000
331v 0.160000 0.690000 0.060000
332v 0.160000 1.110000 0.060000
333v 0.040000 1.110000 0.060000
334vn 0.000000 0.000000 -1.000000
335vn 0.000000 0.000000 -1.000000
336vn 0.000000 0.000000 1.000000
337vn 0.000000 -0.000000 1.000000
338vn -1.000000 0.000000 0.000000
339vn -1.000000 0.000000 0.000000
340vn 1.000000 0.000000 0.000000
341vn 1.000000 0.000000 0.000000
342vn 0.000000 1.000000 0.000000
343vn 0.000000 1.000000 0.000000
344vn 0.000000 -1.000000 0.000000
345vn 0.000000 -1.000000 0.000000
346v 0.055000 0.280000 -0.045000
347v 0.145000 0.280000 -0.045000
348v 0.145000 0.680000 -0.045000
349v 0.055000 0.680000 -0.045000
350v 0.055000 0.280000 0.045000
351v 0.145000 0.280000 0.045000
352v 0.145000 0.680000 0.045000
353v 0.055000 0.680000 0.045000
354vn 0.000000 0.000000 -1.000000
355vn 0.000000 0.000000 -1.000000
356vn 0.000000 0.000000 1.000000
357vn 0.000000 -0.000000 1.000000
358vn -1.000000 0.000000 0.000000
359vn -1.000000 0.000000 0.000000
360vn 1.000000 0.000000 0.000000
361vn 1.000000 0.000000 0.000000
362vn 0.000000 1.000000 0.000000
363vn 0.000000 1.000000 0.000000
364vn 0.000000 -1.000000 0.000000
365vn 0.000000 -1.000000 0.000000
366v 0.050000 0.050000 -0.170000
367v 0.150000 0.050000 -0.170000
368v 0.150000 0.110000 -0.170000
369v 0.050000 0.110000 -0.170000
370v 0.050000 0.050000 0.050000
371v 0.150000 0.050000 0.050000
372v 0.150000 0.110000 0.050000
373v 0.050000 0.110000 0.050000
374vn 0.000000 0.000000 -1.000000
375vn 0.000000 0.000000 -1.000000
376vn 0.000000 0.000000 1.000000
377vn 0.000000 -0.000000 1.000000
378vn -1.000000 0.000000 0.000000
379vn -1.000000 0.000000 0.000000
380vn 1.000000 0.000000 0.000000
381vn 1.000000 0.000000 0.000000
382vn 0.000000 1.000000 0.000000
383vn 0.000000 1.000000 0.000000
384vn 0.000000 -1.000000 0.000000
385vn 0.000000 -1.000000 0.000000
386f 1/2/1 3/2/1 2/2/1
387f 1/2/2 4/2/2 3/2/2
388f 6/2/3 8/2/3 5/2/3
389f 6/2/4 7/2/4 8/2/4
390f 5/2/5 4/2/5 1/2/5
391f 5/2/6 8/2/6 4/2/6
392f 2/2/7 7/2/7 6/2/7
393f 2/2/8 3/2/8 7/2/8
394f 4/2/9 7/2/9 3/2/9
395f 4/2/10 8/2/10 7/2/10
396f 5/2/11 2/2/11 6/2/11
397f 5/2/12 1/2/12 2/2/12
398f 9/2/13 11/2/13 10/2/13
399f 9/2/14 12/2/14 11/2/14
400f 14/2/15 16/2/15 13/2/15
401f 14/2/16 15/2/16 16/2/16
402f 13/2/17 12/2/17 9/2/17
403f 13/2/18 16/2/18 12/2/18
404f 10/2/19 15/2/19 14/2/19
405f 10/2/20 11/2/20 15/2/20
406f 12/2/21 15/2/21 11/2/21
407f 12/2/22 16/2/22 15/2/22
408f 13/2/23 10/2/23 14/2/23
409f 13/2/24 9/2/24 10/2/24
410f 17/1/25 19/1/25 18/1/25
411f 17/1/26 20/1/26 19/1/26
412f 22/1/27 24/1/27 21/1/27
413f 22/1/28 23/1/28 24/1/28
414f 21/1/29 20/1/29 17/1/29
415f 21/1/30 24/1/30 20/1/30
416f 18/1/31 23/1/31 22/1/31
417f 18/1/32 19/1/32 23/1/32
418f 20/1/33 23/1/33 19/1/33
419f 20/1/34 24/1/34 23/1/34
420f 21/1/35 18/1/35 22/1/35
421f 21/1/36 17/1/36 18/1/36
422f 25/1/37 27/1/37 26/1/37
423f 25/1/38 28/1/38 27/1/38
424f 30/1/39 32/1/39 29/1/39
425f 30/1/40 31/1/40 32/1/40
426f 29/1/41 28/1/41 25/1/41
427f 29/1/42 32/1/42 28/1/42
428f 26/1/43 31/1/43 30/1/43
429f 26/1/44 27/1/44 31/1/44
430f 28/1/45 31/1/45 27/1/45
431f 28/1/46 32/1/46 31/1/46
432f 29/1/47 26/1/47 30/1/47
433f 29/1/48 25/1/48 26/1/48
434f 33/1/49 35/1/49 34/1/49
435f 33/1/50 36/1/50 35/1/50
436f 38/1/51 40/1/51 37/1/51
437f 38/1/52 39/1/52 40/1/52
438f 37/1/53 36/1/53 33/1/53
439f 37/1/54 40/1/54 36/1/54
440f 34/1/55 39/1/55 38/1/55
441f 34/1/56 35/1/56 39/1/56
442f 36/1/57 39/1/57 35/1/57
443f 36/1/58 40/1/58 39/1/58
444f 37/1/59 34/1/59 38/1/59
445f 37/1/60 33/1/60 34/1/60
446f 41/1/61 43/1/61 42/1/61
447f 41/1/62 44/1/62 43/1/62
448f 46/1/63 48/1/63 45/1/63
449f 46/1/64 47/1/64 48/1/64
450f 45/1/65 44/1/65 41/1/65
451f 45/1/66 48/1/66 44/1/66
452f 42/1/67 47/1/67 46/1/67
453f 42/1/68 43/1/68 47/1/68
454f 44/1/69 47/1/69 43/1/69
455f 44/1/70 48/1/70 47/1/70
456f 45/1/71 42/1/71 46/1/71
457f 45/1/72 41/1/72 42/1/72
458f 49/1/73 51/1/73 50/1/73
459f 49/1/74 52/1/74 51/1/74
460f 54/1/75 56/1/75 53/1/75
461f 54/1/76 55/1/76 56/1/76
462f 53/1/77 52/1/77 49/1/77
463f 53/1/78 56/1/78 52/1/78
464f 50/1/79 55/1/79 54/1/79
465f 50/1/80 51/1/80 55/1/80
466f 52/1/81 55/1/81 51/1/81
467f 52/1/82 56/1/82 55/1/82
468f 53/1/83 50/1/83 54/1/83
469f 53/1/84 49/1/84 50/1/84
470f 57/2/85 59/2/85 58/2/85
471f 57/2/86 60/2/86 59/2/86
472f 62/2/87 64/2/87 61/2/87
473f 62/2/88 63/2/88 64/2/88
474f 61/2/89 60/2/89 57/2/89
475f 61/2/90 64/2/90 60/2/90
476f 58/2/91 63/2/91 62/2/91
477f 58/2/92 59/2/92 63/2/92
478f 60/2/93 63/2/93 59/2/93
479f 60/2/94 64/2/94 63/2/94
480f 61/2/95 58/2/95 62/2/95
481f 61/2/96 57/2/96 58/2/96
482f 65/2/97 67/2/97 66/2/97
483f 65/2/98 68/2/98 67/2/98
484f 70/2/99 72/2/99 69/2/99
485f 70/2/100 71/2/100 72/2/100
486f 69/2/101 68/2/101 65/2/101
487f 69/2/102 72/2/102 68/2/102
488f 66/2/103 71/2/103 70/2/103
489f 66/2/104 67/2/104 71/2/104
490f 68/2/105 71/2/105 67/2/105
491f 68/2/106 72/2/106 71/2/106
492f 69/2/107 66/2/107 70/2/107
493f 69/2/108 65/2/108 66/2/108
494f 73/1/109 75/1/109 74/1/109
495f 73/1/110 76/1/110 75/1/110
496f 78/1/111 80/1/111 77/1/111
497f 78/1/112 79/1/112 80/1/112
498f 77/1/113 76/1/113 73/1/113
499f 77/1/114 80/1/114 76/1/114
500f 74/1/115 79/1/115 78/1/115
501f 74/1/116 75/1/116 79/1/116
502f 76/1/117 79/1/117 75/1/117
503f 76/1/118 80/1/118 79/1/118
504f 77/1/119 74/1/119 78/1/119
505f 77/1/120 73/1/120 74/1/120
506f 81/1/121 83/1/121 82/1/121
507f 81/1/122 84/1/122 83/1/122
508f 86/1/123 88/1/123 85/1/123
509f 86/1/124 87/1/124 88/1/124
510f 85/1/125 84/1/125 81/1/125
511f 85/1/126 88/1/126 84/1/126
512f 82/1/127 87/1/127 86/1/127
513f 82/1/128 83/1/128 87/1/128
514f 84/1/129 87/1/129 83/1/129
515f 84/1/130 88/1/130 87/1/130
516f 85/1/131 82/1/131 86/1/131
517f 85/1/132 81/1/132 82/1/132
518f 89/2/133 91/2/133 90/2/133
519f 89/2/134 92/2/134 91/2/134
520f 94/2/135 96/2/135 93/2/135
521f 94/2/136 95/2/136 96/2/136
522f 93/2/137 92/2/137 89/2/137
523f 93/2/138 96/2/138 92/2/138
524f 90/2/139 95/2/139 94/2/139
525f 90/2/140 91/2/140 95/2/140
526f 92/2/141 95/2/141 91/2/141
527f 92/2/142 96/2/142 95/2/142
528f 93/2/143 90/2/143 94/2/143
529f 93/2/144 89/2/144 90/2/144
530f 97/2/145 99/2/145 98/2/145
531f 97/2/146 100/2/146 99/2/146
532f 102/2/147 104/2/147 101/2/147
533f 102/2/148 103/2/148 104/2/148
534f 101/2/149 100/2/149 97/2/149
535f 101/2/150 104/2/150 100/2/150
536f 98/2/151 103/2/151 102/2/151
537f 98/2/152 99/2/152 103/2/152
538f 100/2/153 103/2/153 99/2/153
539f 100/2/154 104/2/154 103/2/154
540f 101/2/155 98/2/155 102/2/155
541f 101/2/156 97/2/156 98/2/156
542f 105/2/157 107/2/157 106/2/157
543f 105/2/158 108/2/158 107/2/158
544f 110/2/159 112/2/159 109/2/159
545f 110/2/160 111/2/160 112/2/160
546f 109/2/161 108/2/161 105/2/161
547f 109/2/162 112/2/162 108/2/162
548f 106/2/163 111/2/163 110/2/163
549f 106/2/164 107/2/164 111/2/164
550f 108/2/165 111/2/165 107/2/165
551f 108/2/166 112/2/166 111/2/166
552f 109/2/167 106/2/167 110/2/167
553f 109/2/168 105/2/168 106/2/168
554f 113/2/169 115/2/169 114/2/169
555f 113/2/170 116/2/170 115/2/170
556f 118/2/171 120/2/171 117/2/171
557f 118/2/172 119/2/172 120/2/172
558f 117/2/173 116/2/173 113/2/173
559f 117/2/174 120/2/174 116/2/174
560f 114/2/175 119/2/175 118/2/175
561f 114/2/176 115/2/176 119/2/176
562f 116/2/177 119/2/177 115/2/177
563f 116/2/178 120/2/178 119/2/178
564f 117/2/179 114/2/179 118/2/179
565f 117/2/180 113/2/180 114/2/180
566f 121/2/181 123/2/181 122/2/181
567f 121/2/182 124/2/182 123/2/182
568f 126/2/183 128/2/183 125/2/183
569f 126/2/184 127/2/184 128/2/184
570f 125/2/185 124/2/185 121/2/185
571f 125/2/186 128/2/186 124/2/186
572f 122/2/187 127/2/187 126/2/187
573f 122/2/188 123/2/188 127/2/188
574f 124/2/189 127/2/189 123/2/189
575f 124/2/190 128/2/190 127/2/190
576f 125/2/191 122/2/191 126/2/191
577f 125/2/192 121/2/192 122/2/192
578f 129/2/193 131/2/193 130/2/193
579f 129/2/194 132/2/194 131/2/194
580f 134/2/195 136/2/195 133/2/195
581f 134/2/196 135/2/196 136/2/196
582f 133/2/197 132/2/197 129/2/197
583f 133/2/198 136/2/198 132/2/198
584f 130/2/199 135/2/199 134/2/199
585f 130/2/200 131/2/200 135/2/200
586f 132/2/201 135/2/201 131/2/201
587f 132/2/202 136/2/202 135/2/202
588f 133/2/203 130/2/203 134/2/203
589f 133/2/204 129/2/204 130/2/204
590f 137/2/205 139/2/205 138/2/205
591f 137/2/206 140/2/206 139/2/206
592f 142/2/207 144/2/207 141/2/207
593f 142/2/208 143/2/208 144/2/208
594f 141/2/209 140/2/209 137/2/209
595f 141/2/210 144/2/210 140/2/210
596f 138/2/211 143/2/211 142/2/211
597f 138/2/212 139/2/212 143/2/212
598f 140/2/213 143/2/213 139/2/213
599f 140/2/214 144/2/214 143/2/214
600f 141/2/215 138/2/215 142/2/215
601f 141/2/216 137/2/216 138/2/216
602f 145/2/217 147/2/217 146/2/217
603f 145/2/218 148/2/218 147/2/218
604f 150/2/219 152/2/219 149/2/219
605f 150/2/220 151/2/220 152/2/220
606f 149/2/221 148/2/221 145/2/221
607f 149/2/222 152/2/222 148/2/222
608f 146/2/223 151/2/223 150/2/223
609f 146/2/224 147/2/224 151/2/224
610f 148/2/225 151/2/225 147/2/225
611f 148/2/226 152/2/226 151/2/226
612f 149/2/227 146/2/227 150/2/227
613f 149/2/228 145/2/228 146/2/228
tests/static_body.rs added +268 −0
@@ -0,0 +1,268 @@
1 //! Static (unskinned) bodies: OBJ+MTL, glTF without a skin, FBX without a skin.
2 //! They load in their stored pose with no skeleton and no clips, use the
3 //! documented center-Y pivot fallback, render one bind sample × 36 views and
4 //! refuse bone-dependent equipment instead of inventing a rig.
5 use paperdoll_renderer::{
6 camera, equipment, preview,
7 preview::BodyRig,
8 production::{self, RenderJob, SourceFingerprint},
9 project::CameraFraming,
10 };
11 use std::path::{Path, PathBuf};
12
13 fn repo(p: &str) -> PathBuf {
14 Path::new(env!("CARGO_MANIFEST_DIR")).join(p)
15 }
16 fn obj() -> preview::PreviewCharacter {
17 preview::load_body_file(&repo("tests/fixtures/bodies/static/static-body-male.obj")).unwrap()
18 }
19 fn static_glb() -> preview::PreviewCharacter {
20 preview::load_body_file(&repo("tests/fixtures/bodies/static/static-body-male.glb")).unwrap()
21 }
22 fn skinned_glb() -> preview::PreviewCharacter {
23 preview::load_body_file(&repo("tests/fixtures/bodies/test-body-male.glb")).unwrap()
24 }
25 fn bbox(points: &[[f32; 3]]) -> ([f32; 3], [f32; 3]) {
26 let mut lo = [f32::MAX; 3];
27 let mut hi = [f32::MIN; 3];
28 for p in points {
29 for i in 0..3 {
30 lo[i] = lo[i].min(p[i]);
31 hi[i] = hi[i].max(p[i]);
32 }
33 }
34 (lo, hi)
35 }
36 fn close(a: [f32; 3], b: [f32; 3], eps: f32) -> bool {
37 (0..3).all(|i| (a[i] - b[i]).abs() <= eps)
38 }
39 /// Every static vertex lies on a vertex of the skinned body's bind pose.
40 fn same_point_set(static_points: &[[f32; 3]], bind: &[[f32; 3]], eps: f32) {
41 for p in static_points {
42 assert!(
43 bind.iter().any(|q| close(*p, *q, eps)),
44 "static vertex {p:?} not in skinned bind pose"
45 );
46 }
47 for q in bind {
48 assert!(
49 static_points.iter().any(|p| close(*p, *q, eps)),
50 "bind vertex {q:?} missing from static body"
51 );
52 }
53 }
54 fn job(character: preview::PreviewCharacter, preset: Option<equipment::Preset>) -> RenderJob {
55 RenderJob {
56 character,
57 sex: "male".into(),
58 preset,
59 target_width: 32,
60 target_height: 48,
61 fps: 4,
62 framing: CameraFraming::default(),
63 output: std::env::temp_dir().join("paperdoll-static-body-test-unused"),
64 project_id: "test".into(),
65 project_name: "test".into(),
66 body_model: SourceFingerprint {
67 path: "assets/body/static.obj".into(),
68 sha256: "0".repeat(64),
69 },
70 preset_sha256: None,
71 }
72 }
73
74 #[test]
75 fn obj_body_is_static_bind_geometry_without_skeleton_or_clips() {
76 let body = obj();
77 assert!(body.is_static());
78 assert!(matches!(&body.rig, BodyRig::Static { format, .. } if format == "OBJ"));
79 assert!(body.static_reason().unwrap().contains("geen skeleton"));
80 assert!(body.joint_nodes.is_empty() && body.joint_names.is_empty());
81 assert!(body.inverse_binds.is_empty() && body.nodes.is_empty());
82 assert!(body.clips.is_empty());
83 assert!(preview::sample_skin_matrices(&body, "", 0.).is_empty());
84 assert_eq!(body.weights.len(), body.positions.len());
85 assert!(body.source_normals_complete);
86 // OBJ is used as stored: identical to the skinned GLB in bind pose.
87 same_point_set(&body.positions, &skinned_glb().positions, 1e-5);
88 assert_eq!(body.surfaces.len(), 1);
89 assert!(body.surfaces[0].albedo.is_some());
90 }
91
92 #[test]
93 fn obj_uv_origin_is_converted_once_to_top_left() {
94 let body = obj();
95 let tex = body.surfaces[0].albedo.clone().unwrap();
96 assert_eq!((tex.width, tex.height), (4, 4));
97 let texel = |uv: [f32; 2]| {
98 let x = ((uv[0] * tex.width as f32) as u32).min(tex.width - 1);
99 let y = ((uv[1] * tex.height as f32) as u32).min(tex.height - 1);
100 let i = ((y * tex.width + x) * 4) as usize;
101 [tex.rgba[i], tex.rgba[i + 1], tex.rgba[i + 2]]
102 };
103 let mut head = 0;
104 let mut feet = 0;
105 for (p, uv) in body.positions.iter().zip(&body.uvs) {
106 if p[1] > 1.6 {
107 assert_eq!(
108 texel(*uv),
109 [220, 40, 30],
110 "head must sample the red top half"
111 );
112 head += 1;
113 }
114 if p[1] < 0.2 {
115 assert_eq!(
116 texel(*uv),
117 [30, 60, 220],
118 "feet must sample the blue bottom half"
119 );
120 feet += 1;
121 }
122 }
123 assert!(head > 0 && feet > 0);
124 }
125
126 #[test]
127 fn unskinned_gltf_bakes_node_transforms_once_and_ignores_animation() {
128 let body = static_glb();
129 assert!(matches!(&body.rig, BodyRig::Static { format, .. } if format == "glTF"));
130 assert!(body.static_reason().unwrap().contains("1 animatie"));
131 assert!(body.clips.is_empty() && body.joint_nodes.is_empty());
132 // Parent T·S(2) and child R_y(90) baked once == skinned bind pose.
133 same_point_set(&body.positions, &skinned_glb().positions, 1e-4);
134 // Normals went through the inverse-transpose: outward unit face normals.
135 for tri in body.indices.chunks_exact(3) {
136 let [a, b, c] = [0, 1, 2].map(|k| body.positions[tri[k] as usize]);
137 let u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
138 let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
139 let n = [
140 u[1] * v[2] - u[2] * v[1],
141 u[2] * v[0] - u[0] * v[2],
142 u[0] * v[1] - u[1] * v[0],
143 ];
144 let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
145 let n = [n[0] / len, n[1] / len, n[2] / len];
146 let stored = body.normals[tri[0] as usize];
147 assert!(close(stored, n, 1e-4), "normal {stored:?} vs face {n:?}");
148 }
149 let (a, b) = (bbox(&body.positions), bbox(&obj().positions));
150 assert!(
151 close(a.0, b.0, 1e-4) && close(a.1, b.1, 1e-4),
152 "{a:?} vs {b:?}"
153 );
154 }
155
156 #[test]
157 fn unskinned_fbx_loads_as_static_body_with_one_normalization() {
158 let path = repo("tests/fixtures/equipment/sword-fbx.fbx");
159 let body = preview::load_body_file(&path).unwrap();
160 assert!(matches!(&body.rig, BodyRig::Static { format, .. } if format == "FBX"));
161 assert!(body.clips.is_empty() && body.joint_nodes.is_empty());
162 assert!(body.surfaces.iter().any(|s| s.albedo.is_some()));
163 // Same geometry as the rigid FBX equipment reader; only UV origin flipped.
164 let mut preset = equipment::Preset::new("probe".into());
165 equipment::import_fbx(&mut preset, &path).unwrap();
166 let meshes = &preset.attachments[0].meshes;
167 let positions: Vec<_> = meshes.iter().flat_map(|m| m.positions.clone()).collect();
168 let uvs: Vec<_> = meshes.iter().flat_map(|m| m.uvs.clone()).collect();
169 assert_eq!(body.positions, positions);
170 for (a, b) in body.uvs.iter().zip(&uvs) {
171 assert_eq!(a[0], b[0]);
172 assert!((a[1] - (1. - b[1])).abs() < 1e-6);
173 }
174 // The skinned FBX body still goes through fbx_scene with its skeleton.
175 let skinned =
176 preview::load_body_file(&repo("tests/fixtures/bodies/test-body-male.fbx")).unwrap();
177 assert_eq!(skinned.rig, BodyRig::Skinned);
178 assert!(!skinned.joint_names.is_empty());
179 }
180
181 #[test]
182 fn static_body_pivot_falls_back_to_center_y_and_manual_pivot_still_wins() {
183 let body = obj();
184 let framing = CameraFraming::default();
185 let resolved = camera::resolve(&body, framing);
186 assert_eq!(resolved.framing.pivot_y_milli, Some(framing.center_y_milli));
187 match &resolved.source {
188 camera::PivotSource::Fallback { reason } => assert!(reason.contains("Statisch")),
189 other => panic!("expected fallback, got {other:?}"),
190 }
191 let manual = camera::resolve(
192 &body,
193 CameraFraming {
194 pivot_y_milli: Some(1520),
195 ..framing
196 },
197 );
198 assert_eq!(manual.framing.pivot_y_milli, Some(1520));
199 assert_eq!(manual.source, camera::PivotSource::Manual);
200 }
201
202 #[test]
203 fn static_body_plans_one_bind_sample_times_36_views_and_four_maps() {
204 let plan = production::plan(&obj(), 12).unwrap();
205 assert_eq!(plan.clips.len(), 1);
206 assert_eq!(plan.clips[0].token, "clip000-bind");
207 assert_eq!(plan.clips[0].frames, 1);
208 assert_eq!(plan.images, 36);
209 assert_eq!(plan.files, 144);
210 let paths = production::planned_paths(&plan, "male", "body").unwrap();
211 assert_eq!(paths.len(), 144);
212 for map in ["diffuse", "normal", "depth", "specular"] {
213 assert_eq!(
214 paths
215 .iter()
216 .filter(|p| p.ends_with(&format!("_{map}.png")))
217 .count(),
218 36
219 );
220 }
221 let contract = production::contract(job(obj(), None)).unwrap();
222 assert_eq!(contract.plan, plan_with_fps(4));
223 assert_eq!(contract.render_doc["pivot_source"]["kind"], "fallback");
224 assert_eq!(contract.render_doc["clips"][0]["token"], "clip000-bind");
225 }
226 fn plan_with_fps(fps: u32) -> production::Plan {
227 production::plan(&obj(), fps).unwrap()
228 }
229
230 #[test]
231 fn static_production_scene_draws_stored_geometry_with_albedo() {
232 let body = obj();
233 let job = job(body.clone(), None);
234 let mut cache = equipment::TextureCache::default();
235 let scene = production::scene(&job, "", 0., 0., 0., &mut cache).unwrap();
236 assert_eq!(scene.draws.len(), 1);
237 assert_eq!(*scene.draws[0].positions, body.positions);
238 assert!(scene.draws[0].albedo.is_some());
239 assert_eq!(scene.draws[0].specular, 0.);
240 }
241
242 #[test]
243 fn bone_functions_are_refused_on_a_static_body() {
244 let body = obj();
245 let mut preset = equipment::Preset::new("sword".into());
246 equipment::import_obj(&mut preset, &repo("tests/fixtures/equipment/sword.obj")).unwrap();
247 assert!(preset.bind(0, &body, 0, 0).is_err());
248 let err = production::contract(job(body.clone(), Some(preset.clone())))
249 .err()
250 .unwrap();
251 assert!(err.contains("statisch"), "{err}");
252 // Layers cannot be attached without a bone either.
253 assert!(equipment::layers(&preset, 0, &body, &Default::default()).is_err());
254 }
255
256 #[test]
257 fn obj_without_mtl_dependency_fails_with_its_name() {
258 let dir = std::env::temp_dir().join(format!("paperdoll-static-missing-{}", std::process::id()));
259 std::fs::create_dir_all(&dir).unwrap();
260 std::fs::copy(
261 repo("tests/fixtures/bodies/static/static-body-male.obj"),
262 dir.join("static-body-male.obj"),
263 )
264 .unwrap();
265 let err = preview::load_body_file(&dir.join("static-body-male.obj")).unwrap_err();
266 std::fs::remove_dir_all(&dir).ok();
267 assert!(err.contains("statisch OBJ-lichaam"), "{err}");
268 }
@@ -0,0 +1,268 @@
1//! Static (unskinned) bodies: OBJ+MTL, glTF without a skin, FBX without a skin.
2//! They load in their stored pose with no skeleton and no clips, use the
3//! documented center-Y pivot fallback, render one bind sample × 36 views and
4//! refuse bone-dependent equipment instead of inventing a rig.
5use paperdoll_renderer::{
6 camera, equipment, preview,
7 preview::BodyRig,
8 production::{self, RenderJob, SourceFingerprint},
9 project::CameraFraming,
10};
11use std::path::{Path, PathBuf};
12
13fn repo(p: &str) -> PathBuf {
14 Path::new(env!("CARGO_MANIFEST_DIR")).join(p)
15}
16fn obj() -> preview::PreviewCharacter {
17 preview::load_body_file(&repo("tests/fixtures/bodies/static/static-body-male.obj")).unwrap()
18}
19fn static_glb() -> preview::PreviewCharacter {
20 preview::load_body_file(&repo("tests/fixtures/bodies/static/static-body-male.glb")).unwrap()
21}
22fn skinned_glb() -> preview::PreviewCharacter {
23 preview::load_body_file(&repo("tests/fixtures/bodies/test-body-male.glb")).unwrap()
24}
25fn bbox(points: &[[f32; 3]]) -> ([f32; 3], [f32; 3]) {
26 let mut lo = [f32::MAX; 3];
27 let mut hi = [f32::MIN; 3];
28 for p in points {
29 for i in 0..3 {
30 lo[i] = lo[i].min(p[i]);
31 hi[i] = hi[i].max(p[i]);
32 }
33 }
34 (lo, hi)
35}
36fn close(a: [f32; 3], b: [f32; 3], eps: f32) -> bool {
37 (0..3).all(|i| (a[i] - b[i]).abs() <= eps)
38}
39/// Every static vertex lies on a vertex of the skinned body's bind pose.
40fn same_point_set(static_points: &[[f32; 3]], bind: &[[f32; 3]], eps: f32) {
41 for p in static_points {
42 assert!(
43 bind.iter().any(|q| close(*p, *q, eps)),
44 "static vertex {p:?} not in skinned bind pose"
45 );
46 }
47 for q in bind {
48 assert!(
49 static_points.iter().any(|p| close(*p, *q, eps)),
50 "bind vertex {q:?} missing from static body"
51 );
52 }
53}
54fn job(character: preview::PreviewCharacter, preset: Option<equipment::Preset>) -> RenderJob {
55 RenderJob {
56 character,
57 sex: "male".into(),
58 preset,
59 target_width: 32,
60 target_height: 48,
61 fps: 4,
62 framing: CameraFraming::default(),
63 output: std::env::temp_dir().join("paperdoll-static-body-test-unused"),
64 project_id: "test".into(),
65 project_name: "test".into(),
66 body_model: SourceFingerprint {
67 path: "assets/body/static.obj".into(),
68 sha256: "0".repeat(64),
69 },
70 preset_sha256: None,
71 }
72}
73
74#[test]
75fn obj_body_is_static_bind_geometry_without_skeleton_or_clips() {
76 let body = obj();
77 assert!(body.is_static());
78 assert!(matches!(&body.rig, BodyRig::Static { format, .. } if format == "OBJ"));
79 assert!(body.static_reason().unwrap().contains("geen skeleton"));
80 assert!(body.joint_nodes.is_empty() && body.joint_names.is_empty());
81 assert!(body.inverse_binds.is_empty() && body.nodes.is_empty());
82 assert!(body.clips.is_empty());
83 assert!(preview::sample_skin_matrices(&body, "", 0.).is_empty());
84 assert_eq!(body.weights.len(), body.positions.len());
85 assert!(body.source_normals_complete);
86 // OBJ is used as stored: identical to the skinned GLB in bind pose.
87 same_point_set(&body.positions, &skinned_glb().positions, 1e-5);
88 assert_eq!(body.surfaces.len(), 1);
89 assert!(body.surfaces[0].albedo.is_some());
90}
91
92#[test]
93fn obj_uv_origin_is_converted_once_to_top_left() {
94 let body = obj();
95 let tex = body.surfaces[0].albedo.clone().unwrap();
96 assert_eq!((tex.width, tex.height), (4, 4));
97 let texel = |uv: [f32; 2]| {
98 let x = ((uv[0] * tex.width as f32) as u32).min(tex.width - 1);
99 let y = ((uv[1] * tex.height as f32) as u32).min(tex.height - 1);
100 let i = ((y * tex.width + x) * 4) as usize;
101 [tex.rgba[i], tex.rgba[i + 1], tex.rgba[i + 2]]
102 };
103 let mut head = 0;
104 let mut feet = 0;
105 for (p, uv) in body.positions.iter().zip(&body.uvs) {
106 if p[1] > 1.6 {
107 assert_eq!(
108 texel(*uv),
109 [220, 40, 30],
110 "head must sample the red top half"
111 );
112 head += 1;
113 }
114 if p[1] < 0.2 {
115 assert_eq!(
116 texel(*uv),
117 [30, 60, 220],
118 "feet must sample the blue bottom half"
119 );
120 feet += 1;
121 }
122 }
123 assert!(head > 0 && feet > 0);
124}
125
126#[test]
127fn unskinned_gltf_bakes_node_transforms_once_and_ignores_animation() {
128 let body = static_glb();
129 assert!(matches!(&body.rig, BodyRig::Static { format, .. } if format == "glTF"));
130 assert!(body.static_reason().unwrap().contains("1 animatie"));
131 assert!(body.clips.is_empty() && body.joint_nodes.is_empty());
132 // Parent T·S(2) and child R_y(90) baked once == skinned bind pose.
133 same_point_set(&body.positions, &skinned_glb().positions, 1e-4);
134 // Normals went through the inverse-transpose: outward unit face normals.
135 for tri in body.indices.chunks_exact(3) {
136 let [a, b, c] = [0, 1, 2].map(|k| body.positions[tri[k] as usize]);
137 let u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
138 let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
139 let n = [
140 u[1] * v[2] - u[2] * v[1],
141 u[2] * v[0] - u[0] * v[2],
142 u[0] * v[1] - u[1] * v[0],
143 ];
144 let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
145 let n = [n[0] / len, n[1] / len, n[2] / len];
146 let stored = body.normals[tri[0] as usize];
147 assert!(close(stored, n, 1e-4), "normal {stored:?} vs face {n:?}");
148 }
149 let (a, b) = (bbox(&body.positions), bbox(&obj().positions));
150 assert!(
151 close(a.0, b.0, 1e-4) && close(a.1, b.1, 1e-4),
152 "{a:?} vs {b:?}"
153 );
154}
155
156#[test]
157fn unskinned_fbx_loads_as_static_body_with_one_normalization() {
158 let path = repo("tests/fixtures/equipment/sword-fbx.fbx");
159 let body = preview::load_body_file(&path).unwrap();
160 assert!(matches!(&body.rig, BodyRig::Static { format, .. } if format == "FBX"));
161 assert!(body.clips.is_empty() && body.joint_nodes.is_empty());
162 assert!(body.surfaces.iter().any(|s| s.albedo.is_some()));
163 // Same geometry as the rigid FBX equipment reader; only UV origin flipped.
164 let mut preset = equipment::Preset::new("probe".into());
165 equipment::import_fbx(&mut preset, &path).unwrap();
166 let meshes = &preset.attachments[0].meshes;
167 let positions: Vec<_> = meshes.iter().flat_map(|m| m.positions.clone()).collect();
168 let uvs: Vec<_> = meshes.iter().flat_map(|m| m.uvs.clone()).collect();
169 assert_eq!(body.positions, positions);
170 for (a, b) in body.uvs.iter().zip(&uvs) {
171 assert_eq!(a[0], b[0]);
172 assert!((a[1] - (1. - b[1])).abs() < 1e-6);
173 }
174 // The skinned FBX body still goes through fbx_scene with its skeleton.
175 let skinned =
176 preview::load_body_file(&repo("tests/fixtures/bodies/test-body-male.fbx")).unwrap();
177 assert_eq!(skinned.rig, BodyRig::Skinned);
178 assert!(!skinned.joint_names.is_empty());
179}
180
181#[test]
182fn static_body_pivot_falls_back_to_center_y_and_manual_pivot_still_wins() {
183 let body = obj();
184 let framing = CameraFraming::default();
185 let resolved = camera::resolve(&body, framing);
186 assert_eq!(resolved.framing.pivot_y_milli, Some(framing.center_y_milli));
187 match &resolved.source {
188 camera::PivotSource::Fallback { reason } => assert!(reason.contains("Statisch")),
189 other => panic!("expected fallback, got {other:?}"),
190 }
191 let manual = camera::resolve(
192 &body,
193 CameraFraming {
194 pivot_y_milli: Some(1520),
195 ..framing
196 },
197 );
198 assert_eq!(manual.framing.pivot_y_milli, Some(1520));
199 assert_eq!(manual.source, camera::PivotSource::Manual);
200}
201
202#[test]
203fn static_body_plans_one_bind_sample_times_36_views_and_four_maps() {
204 let plan = production::plan(&obj(), 12).unwrap();
205 assert_eq!(plan.clips.len(), 1);
206 assert_eq!(plan.clips[0].token, "clip000-bind");
207 assert_eq!(plan.clips[0].frames, 1);
208 assert_eq!(plan.images, 36);
209 assert_eq!(plan.files, 144);
210 let paths = production::planned_paths(&plan, "male", "body").unwrap();
211 assert_eq!(paths.len(), 144);
212 for map in ["diffuse", "normal", "depth", "specular"] {
213 assert_eq!(
214 paths
215 .iter()
216 .filter(|p| p.ends_with(&format!("_{map}.png")))
217 .count(),
218 36
219 );
220 }
221 let contract = production::contract(job(obj(), None)).unwrap();
222 assert_eq!(contract.plan, plan_with_fps(4));
223 assert_eq!(contract.render_doc["pivot_source"]["kind"], "fallback");
224 assert_eq!(contract.render_doc["clips"][0]["token"], "clip000-bind");
225}
226fn plan_with_fps(fps: u32) -> production::Plan {
227 production::plan(&obj(), fps).unwrap()
228}
229
230#[test]
231fn static_production_scene_draws_stored_geometry_with_albedo() {
232 let body = obj();
233 let job = job(body.clone(), None);
234 let mut cache = equipment::TextureCache::default();
235 let scene = production::scene(&job, "", 0., 0., 0., &mut cache).unwrap();
236 assert_eq!(scene.draws.len(), 1);
237 assert_eq!(*scene.draws[0].positions, body.positions);
238 assert!(scene.draws[0].albedo.is_some());
239 assert_eq!(scene.draws[0].specular, 0.);
240}
241
242#[test]
243fn bone_functions_are_refused_on_a_static_body() {
244 let body = obj();
245 let mut preset = equipment::Preset::new("sword".into());
246 equipment::import_obj(&mut preset, &repo("tests/fixtures/equipment/sword.obj")).unwrap();
247 assert!(preset.bind(0, &body, 0, 0).is_err());
248 let err = production::contract(job(body.clone(), Some(preset.clone())))
249 .err()
250 .unwrap();
251 assert!(err.contains("statisch"), "{err}");
252 // Layers cannot be attached without a bone either.
253 assert!(equipment::layers(&preset, 0, &body, &Default::default()).is_err());
254}
255
256#[test]
257fn obj_without_mtl_dependency_fails_with_its_name() {
258 let dir = std::env::temp_dir().join(format!("paperdoll-static-missing-{}", std::process::id()));
259 std::fs::create_dir_all(&dir).unwrap();
260 std::fs::copy(
261 repo("tests/fixtures/bodies/static/static-body-male.obj"),
262 dir.join("static-body-male.obj"),
263 )
264 .unwrap();
265 let err = preview::load_body_file(&dir.join("static-body-male.obj")).unwrap_err();
266 std::fs::remove_dir_all(&dir).ok();
267 assert!(err.contains("statisch OBJ-lichaam"), "{err}");
268}