Merge feature/static-body: static OBJ / unskinned glTF/FBX body display and export
@@ -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

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 sys19
19
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.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
- 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** selecteert30
30
31
31
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
## 2D-preview43
## 2D-preview34
44
35
Vanuit **Body** of de bodyweergave opent **2D-preview** de gerenderde compositor over voltooide schema-3 exportsets in `renders/`: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(), &RimP116
```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
## Openen met131
## Openen met122
132
@@ -142,6 +152,8 @@ xvfb-run -a cargo run --release --example resume_fixture --locked -- resume /tmp142
cargo run --release --example resume_fixture --locked -- verify /tmp/resume152
cargo run --release --example resume_fixture --locked -- verify /tmp/resume143
# FBX op echte GPU: live view (lichaam + FBX-zwaard) en volledige productie van body- en equipmentset153
# FBX op echte GPU: live view (lichaam + FBX-zwaard) en volledige productie van body- en equipmentset144
xvfb-run -a cargo run --example fbx_live_fixture --locked -- /tmp/fbx-live /tmp/fbx-live-proof 32 48 4154
xvfb-run -a cargo run --example fbx_live_fixture --locked -- /tmp/fbx-live /tmp/fbx-live-proof 32 48 4155
# Statische lichamen op echte GPU: OBJ-man en glTF-vrouw zonder skin, live view, equipment-weigering, productie en export_set156
xvfb-run -a -s '-screen 0 1280x1024x24' cargo run --example static_body_fixture --locked -- /tmp/static-body /tmp/static-body-proof 32 48 4145
```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`.@@ -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 u72
| 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 te84
84
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.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
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.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
## 5. .ppd project and asset ownership91
## 5. .ppd project and asset ownership@@ -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 unskinned4
//! glTF body as female, screenshots the live viewport for both and the5
//! equipment screen (bone binding unavailable), runs the production batch for6
//! both bodies (one bind sample × 36 views × four maps), checks that an7
//! equipment render on the static body is refused, verifies the runs with8
//! `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
true64
}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
true84
}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 action101
// 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 = name181
.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 = checks285
.iter()286
.find(|c| c.run.render.sex == "male")287
.ok_or("male run ontbreekt")?;288
let female = checks289
.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 coverage293
// 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 = male308
.run309
.load_relative(rel, MapKind::Depth)310
.map_err(|e| e.to_string())?;311
let df = female312
.run313
.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, .. } = male330
.run331
.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, .. } = female346
.run347
.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, .. } = male356
.run357
.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() == 3390
&& g[0]["success"] == false391
&& g[0]["error"]392
.as_str()393
.is_some_and(|e| e.contains("statisch"))394
&& g[1]["success"] == true395
&& g[2]["success"] == true396
});397
let runs_ok = checks.len() == 2398
&& equipment_runs == 0399
&& checks.iter().all(|c| {400
c.run.complete.png_files == 144401
&& c.run.render.clips.len() == 1402
&& c.run.render.clips[0].token == "clip000-bind"403
});404
let maps_ok = covered > 0405
&& coverage_mismatch * 200 <= covered406
&& obj_red > 0407
&& obj_blue > 0408
&& obj_other * 20 <= obj_red + obj_blue409
&& gltf_colors.len() <= 2410
&& 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,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 python32
"""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). Boxes8
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 node11
(translation + uniform scale 2) and a child node (rotation 90 deg about Y);12
vertices/normals are stored pre-transformed so the baked world geometry13
equals the skinned body's bind pose. One node animation is included and14
must NOT be applied by the static loader.15
16
Run from the repository root:17
18
python3 scripts/generate_static_bodies.py19
"""20
21
from __future__ import annotations22
23
import math24
import struct25
import sys26
import zlib27
from pathlib import Path28
29
sys.path.insert(0, str(Path(__file__).resolve().parent))30
import generate_test_bodies as gen # noqa: E40231
32
OUT = Path("tests/fixtures/bodies/static")33
PARENT_T = (0.25, -0.1, 0.4)34
PARENT_S = 2.035
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.1558
yield corners, tris, v59
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 += vt77
v_count = 078
n_count = 079
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 284
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 += 188
faces.append("f " + " ".join(f"{v_count + i + 1}/{uv}/{n_count}" for i in t))89
v_count += len(corners)90
lines += faces91
(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()@@ -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.68
pub fn neck_height(body: &PreviewCharacter) -> Result<(usize, i32), String> {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 joint71
// 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 = body74
let candidates = body70
.joint_names75
.joint_names71
.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
@@ -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.session417
self.session414
.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.viewport1024
self.viewport1013
.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 ui1323
if ui1301
.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
.equipment1356
.equipment1331
.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 attached1360
// to an invented skeleton, so the live view shows only the body.1361
let presets: Vec<_> = preset1362
.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.viewport1371
self.viewport1335
.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 = app1989
.preview_male1990
.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");@@ -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;19
const MAX_ASSET: usize = 8 * 1024 * 1024;19
const MAX_ASSET: usize = 8 * 1024 * 1024;20
const MAX_ASSETS: usize = 32;20
const MAX_ASSETS: usize = 32;21
const MAX_VERTICES: usize = 80_000;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
#[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_VERTICES199
if vertices > max_vertices194
|| mesh.indices.len() > MAX_VERTICES * 6200
|| mesh.indices.len() > max_vertices * 6195
|| mesh.indices.len() < 3201
|| mesh.indices.len() < 3196
|| mesh.indices.len() % 3 != 0202
|| mesh.indices.len() % 3 != 0197
|| 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
}479
impl TextureCache {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
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.555
pub fn import_obj(preset: &mut Preset, source: &Path) -> Result<usize, String> {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
if source574
if source557
.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]> = source694
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 textures775
/// Embedded PNG textures are copied into the preset; external PNG textures758
/// 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.759
pub fn import_fbx(preset: &mut Preset, source: &Path) -> Result<usize, String> {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
if source786
if source761
.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 positions937
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
}@@ -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 meshes61
/// but none of them is skinned; `preview::load_body_file` then loads it as a62
/// static body instead of inventing a skeleton.63
pub 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)]62
pub struct FbxDrawableScene {67
pub 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), String240
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
246
fn build_nodes(scene: &ufbx::Scene) -> Result<Vec<SceneNode>, String> {252
fn build_nodes(scene: &ufbx::Scene) -> Result<Vec<SceneNode>, String> {@@ -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 and6
//! equipment previews, and streams production target maps.6
//! Completed export runs are opened through `export_set` with checksum/identity7
//! Completed export runs are opened through `export_set` with checksum/identity7
//! 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
@@ -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 and105
/// no clips; it is drawn in its stored (bind/static) pose and the production106
/// 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
#[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 normalized186
/// glTF/GLB use the glTF reader; FBX goes through the normalized156
/// `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 skin189
/// deformer) load as [`BodyRig::Static`]: geometry in its stored pose, no190
/// skeleton and no clips.157
pub fn load_body_file(path: &Path) -> Result<PreviewCharacter, String> {191
pub fn load_body_file(path: &Path) -> Result<PreviewCharacter, String> {158
match path192
match path159
.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 equipment221
/// (`equipment::import_obj`/`import_fbx`): OBJ is used as stored, FBX gets222
/// the ufbx axis/unit/geometry-transform normalization once, there. The only223
/// conversion here is the UV origin (bottom-left in OBJ/FBX, top-left in224
/// `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 = mesh244
.albedo245
.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 positions299
.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.normals320
.push(normals.get(i).copied().unwrap_or([0., 1., 0.]));321
self.uvs.push(uvs.get(i).copied().unwrap_or([0., 0.]));322
self.colors323
.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 resolves352
// 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: self357
.surfaces358
.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
pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {373
pub 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 = document392
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]> = reader232
.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]> = reader237
.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]> = reader241
.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]> = reader245
.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
reader254
.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
reader260
.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 = primitive276
.material()277
.pbr_metallic_roughness()278
.base_color_factor();279
let albedo = texture280
.map(|t| {281
let image = images282
.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.001325
},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.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]> = reader539
.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]> = reader544
.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]> = reader548
.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 = reader556
.read_colors(0)557
.map(|v| v.into_rgba_f32().collect::<Vec<_>>())558
.unwrap_or_else(|| vec![[1.; 4]; positions.len()]);559
let uvs = reader560
.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 = texture568
.map(|t| {569
let image = images570
.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.001618
},619
})620
}621
622
/// glTF without a skin: every mesh instance of the default scene, with its623
/// 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 document633
.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>> = document642
.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]> = prim666
.positions667
.iter()668
.map(|p| transform_point3(&world, *p))669
.collect();670
let normals: Vec<[f32; 3]> = match &prim.normals {671
Some(normals) => normals672
.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
minor742
} else {743
-minor744
}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
pub fn sample_skin_matrices(character: &PreviewCharacter, clip_name: &str, time: f32) -> Vec<Mat4> {764
pub 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();@@ -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 paperdoll3
//! The shaded mesh is a placement preview. It is not the unlit paperdoll4
//! 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 ui111
if ui101
.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
);@@ -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());@@ -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] {82
fn body_loader_dispatches_by_extension_and_rejects_other_formats() {82
fn 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");@@ -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.py23
Licentie van de gegenereerde meshes, rigs en clips: CC0-1.0. Zie `LICENSE` in deze map.23
Licentie 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).The contents of this binary file are not shown.
The contents of this binary file are not shown.
@@ -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 material2
newmtl body3
Kd 1.0 1.0 1.04
d 1.05
map_Kd static-body-albedo.png@@ -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.py2
mtllib static-body-male.mtl3
usemtl body4
vt 0.5 0.855
vt 0.5 0.156
v -0.140000 0.880000 -0.0900007
v 0.140000 0.880000 -0.0900008
v 0.140000 1.040000 -0.0900009
v -0.140000 1.040000 -0.09000010
v -0.140000 0.880000 0.09000011
v 0.140000 0.880000 0.09000012
v 0.140000 1.040000 0.09000013
v -0.140000 1.040000 0.09000014
vn 0.000000 0.000000 -1.00000015
vn 0.000000 0.000000 -1.00000016
vn 0.000000 0.000000 1.00000017
vn 0.000000 -0.000000 1.00000018
vn -1.000000 0.000000 0.00000019
vn -1.000000 0.000000 0.00000020
vn 1.000000 0.000000 0.00000021
vn 1.000000 0.000000 0.00000022
vn 0.000000 1.000000 0.00000023
vn 0.000000 1.000000 0.00000024
vn 0.000000 -1.000000 0.00000025
vn 0.000000 -1.000000 0.00000026
v -0.126000 1.020000 -0.08000027
v 0.126000 1.020000 -0.08000028
v 0.126000 1.180000 -0.08000029
v -0.126000 1.180000 -0.08000030
v -0.126000 1.020000 0.08000031
v 0.126000 1.020000 0.08000032
v 0.126000 1.180000 0.08000033
v -0.126000 1.180000 0.08000034
vn 0.000000 0.000000 -1.00000035
vn 0.000000 0.000000 -1.00000036
vn 0.000000 0.000000 1.00000037
vn 0.000000 -0.000000 1.00000038
vn -1.000000 0.000000 0.00000039
vn -1.000000 0.000000 0.00000040
vn 1.000000 0.000000 0.00000041
vn 1.000000 0.000000 0.00000042
vn 0.000000 1.000000 0.00000043
vn 0.000000 1.000000 0.00000044
vn 0.000000 -1.000000 0.00000045
vn 0.000000 -1.000000 0.00000046
v -0.180000 1.190000 -0.09000047
v 0.180000 1.190000 -0.09000048
v 0.180000 1.410000 -0.09000049
v -0.180000 1.410000 -0.09000050
v -0.180000 1.190000 0.09000051
v 0.180000 1.190000 0.09000052
v 0.180000 1.410000 0.09000053
v -0.180000 1.410000 0.09000054
vn 0.000000 0.000000 -1.00000055
vn 0.000000 0.000000 -1.00000056
vn 0.000000 0.000000 1.00000057
vn 0.000000 -0.000000 1.00000058
vn -1.000000 0.000000 0.00000059
vn -1.000000 0.000000 0.00000060
vn 1.000000 0.000000 0.00000061
vn 1.000000 0.000000 0.00000062
vn 0.000000 1.000000 0.00000063
vn 0.000000 1.000000 0.00000064
vn 0.000000 -1.000000 0.00000065
vn 0.000000 -1.000000 0.00000066
v -0.050000 1.420000 -0.05000067
v 0.050000 1.420000 -0.05000068
v 0.050000 1.500000 -0.05000069
v -0.050000 1.500000 -0.05000070
v -0.050000 1.420000 0.05000071
v 0.050000 1.420000 0.05000072
v 0.050000 1.500000 0.05000073
v -0.050000 1.500000 0.05000074
vn 0.000000 0.000000 -1.00000075
vn 0.000000 0.000000 -1.00000076
vn 0.000000 0.000000 1.00000077
vn 0.000000 -0.000000 1.00000078
vn -1.000000 0.000000 0.00000079
vn -1.000000 0.000000 0.00000080
vn 1.000000 0.000000 0.00000081
vn 1.000000 0.000000 0.00000082
vn 0.000000 1.000000 0.00000083
vn 0.000000 1.000000 0.00000084
vn 0.000000 -1.000000 0.00000085
vn 0.000000 -1.000000 0.00000086
v -0.090000 1.490000 -0.10000087
v 0.090000 1.490000 -0.10000088
v 0.090000 1.710000 -0.10000089
v -0.090000 1.710000 -0.10000090
v -0.090000 1.490000 0.10000091
v 0.090000 1.490000 0.10000092
v 0.090000 1.710000 0.10000093
v -0.090000 1.710000 0.10000094
vn 0.000000 0.000000 -1.00000095
vn 0.000000 0.000000 -1.00000096
vn 0.000000 0.000000 1.00000097
vn 0.000000 -0.000000 1.00000098
vn -1.000000 0.000000 0.00000099
vn -1.000000 0.000000 0.000000100
vn 1.000000 0.000000 0.000000101
vn 1.000000 0.000000 0.000000102
vn 0.000000 1.000000 0.000000103
vn 0.000000 1.000000 0.000000104
vn 0.000000 -1.000000 0.000000105
vn 0.000000 -1.000000 0.000000106
v -0.250000 1.340000 -0.040000107
v -0.150000 1.340000 -0.040000108
v -0.150000 1.420000 -0.040000109
v -0.250000 1.420000 -0.040000110
v -0.250000 1.340000 0.040000111
v -0.150000 1.340000 0.040000112
v -0.150000 1.420000 0.040000113
v -0.250000 1.420000 0.040000114
vn 0.000000 0.000000 -1.000000115
vn 0.000000 0.000000 -1.000000116
vn 0.000000 0.000000 1.000000117
vn 0.000000 -0.000000 1.000000118
vn -1.000000 0.000000 0.000000119
vn -1.000000 0.000000 0.000000120
vn 1.000000 0.000000 0.000000121
vn 1.000000 0.000000 0.000000122
vn 0.000000 1.000000 0.000000123
vn 0.000000 1.000000 0.000000124
vn 0.000000 -1.000000 0.000000125
vn 0.000000 -1.000000 0.000000126
v -0.320000 1.240000 -0.040000127
v -0.240000 1.240000 -0.040000128
v -0.240000 1.520000 -0.040000129
v -0.320000 1.520000 -0.040000130
v -0.320000 1.240000 0.040000131
v -0.240000 1.240000 0.040000132
v -0.240000 1.520000 0.040000133
v -0.320000 1.520000 0.040000134
vn 0.000000 0.000000 -1.000000135
vn 0.000000 0.000000 -1.000000136
vn 0.000000 0.000000 1.000000137
vn 0.000000 -0.000000 1.000000138
vn -1.000000 0.000000 0.000000139
vn -1.000000 0.000000 0.000000140
vn 1.000000 0.000000 0.000000141
vn 1.000000 0.000000 0.000000142
vn 0.000000 1.000000 0.000000143
vn 0.000000 1.000000 0.000000144
vn 0.000000 -1.000000 0.000000145
vn 0.000000 -1.000000 0.000000146
v -0.315000 0.970000 -0.035000147
v -0.245000 0.970000 -0.035000148
v -0.245000 1.230000 -0.035000149
v -0.315000 1.230000 -0.035000150
v -0.315000 0.970000 0.035000151
v -0.245000 0.970000 0.035000152
v -0.245000 1.230000 0.035000153
v -0.315000 1.230000 0.035000154
vn 0.000000 0.000000 -1.000000155
vn 0.000000 0.000000 -1.000000156
vn 0.000000 0.000000 1.000000157
vn 0.000000 -0.000000 1.000000158
vn -1.000000 0.000000 0.000000159
vn -1.000000 0.000000 0.000000160
vn 1.000000 0.000000 0.000000161
vn 1.000000 0.000000 0.000000162
vn 0.000000 1.000000 0.000000163
vn 0.000000 1.000000 0.000000164
vn 0.000000 -1.000000 0.000000165
vn 0.000000 -1.000000 0.000000166
v -0.320000 0.790000 -0.020000167
v -0.240000 0.790000 -0.020000168
v -0.240000 0.890000 -0.020000169
v -0.320000 0.890000 -0.020000170
v -0.320000 0.790000 0.020000171
v -0.240000 0.790000 0.020000172
v -0.240000 0.890000 0.020000173
v -0.320000 0.890000 0.020000174
vn 0.000000 0.000000 -1.000000175
vn 0.000000 0.000000 -1.000000176
vn 0.000000 0.000000 1.000000177
vn 0.000000 -0.000000 1.000000178
vn -1.000000 0.000000 0.000000179
vn -1.000000 0.000000 0.000000180
vn 1.000000 0.000000 0.000000181
vn 1.000000 0.000000 0.000000182
vn 0.000000 1.000000 0.000000183
vn 0.000000 1.000000 0.000000184
vn 0.000000 -1.000000 0.000000185
vn 0.000000 -1.000000 0.000000186
v 0.150000 1.340000 -0.040000187
v 0.250000 1.340000 -0.040000188
v 0.250000 1.420000 -0.040000189
v 0.150000 1.420000 -0.040000190
v 0.150000 1.340000 0.040000191
v 0.250000 1.340000 0.040000192
v 0.250000 1.420000 0.040000193
v 0.150000 1.420000 0.040000194
vn 0.000000 0.000000 -1.000000195
vn 0.000000 0.000000 -1.000000196
vn 0.000000 0.000000 1.000000197
vn 0.000000 -0.000000 1.000000198
vn -1.000000 0.000000 0.000000199
vn -1.000000 0.000000 0.000000200
vn 1.000000 0.000000 0.000000201
vn 1.000000 0.000000 0.000000202
vn 0.000000 1.000000 0.000000203
vn 0.000000 1.000000 0.000000204
vn 0.000000 -1.000000 0.000000205
vn 0.000000 -1.000000 0.000000206
v 0.240000 1.240000 -0.040000207
v 0.320000 1.240000 -0.040000208
v 0.320000 1.520000 -0.040000209
v 0.240000 1.520000 -0.040000210
v 0.240000 1.240000 0.040000211
v 0.320000 1.240000 0.040000212
v 0.320000 1.520000 0.040000213
v 0.240000 1.520000 0.040000214
vn 0.000000 0.000000 -1.000000215
vn 0.000000 0.000000 -1.000000216
vn 0.000000 0.000000 1.000000217
vn 0.000000 -0.000000 1.000000218
vn -1.000000 0.000000 0.000000219
vn -1.000000 0.000000 0.000000220
vn 1.000000 0.000000 0.000000221
vn 1.000000 0.000000 0.000000222
vn 0.000000 1.000000 0.000000223
vn 0.000000 1.000000 0.000000224
vn 0.000000 -1.000000 0.000000225
vn 0.000000 -1.000000 0.000000226
v 0.245000 0.970000 -0.035000227
v 0.315000 0.970000 -0.035000228
v 0.315000 1.230000 -0.035000229
v 0.245000 1.230000 -0.035000230
v 0.245000 0.970000 0.035000231
v 0.315000 0.970000 0.035000232
v 0.315000 1.230000 0.035000233
v 0.245000 1.230000 0.035000234
vn 0.000000 0.000000 -1.000000235
vn 0.000000 0.000000 -1.000000236
vn 0.000000 0.000000 1.000000237
vn 0.000000 -0.000000 1.000000238
vn -1.000000 0.000000 0.000000239
vn -1.000000 0.000000 0.000000240
vn 1.000000 0.000000 0.000000241
vn 1.000000 0.000000 0.000000242
vn 0.000000 1.000000 0.000000243
vn 0.000000 1.000000 0.000000244
vn 0.000000 -1.000000 0.000000245
vn 0.000000 -1.000000 0.000000246
v 0.240000 0.790000 -0.020000247
v 0.320000 0.790000 -0.020000248
v 0.320000 0.890000 -0.020000249
v 0.240000 0.890000 -0.020000250
v 0.240000 0.790000 0.020000251
v 0.320000 0.790000 0.020000252
v 0.320000 0.890000 0.020000253
v 0.240000 0.890000 0.020000254
vn 0.000000 0.000000 -1.000000255
vn 0.000000 0.000000 -1.000000256
vn 0.000000 0.000000 1.000000257
vn 0.000000 -0.000000 1.000000258
vn -1.000000 0.000000 0.000000259
vn -1.000000 0.000000 0.000000260
vn 1.000000 0.000000 0.000000261
vn 1.000000 0.000000 0.000000262
vn 0.000000 1.000000 0.000000263
vn 0.000000 1.000000 0.000000264
vn 0.000000 -1.000000 0.000000265
vn 0.000000 -1.000000 0.000000266
v -0.160000 0.690000 -0.060000267
v -0.040000 0.690000 -0.060000268
v -0.040000 1.110000 -0.060000269
v -0.160000 1.110000 -0.060000270
v -0.160000 0.690000 0.060000271
v -0.040000 0.690000 0.060000272
v -0.040000 1.110000 0.060000273
v -0.160000 1.110000 0.060000274
vn 0.000000 0.000000 -1.000000275
vn 0.000000 0.000000 -1.000000276
vn 0.000000 0.000000 1.000000277
vn 0.000000 -0.000000 1.000000278
vn -1.000000 0.000000 0.000000279
vn -1.000000 0.000000 0.000000280
vn 1.000000 0.000000 0.000000281
vn 1.000000 0.000000 0.000000282
vn 0.000000 1.000000 0.000000283
vn 0.000000 1.000000 0.000000284
vn 0.000000 -1.000000 0.000000285
vn 0.000000 -1.000000 0.000000286
v -0.145000 0.280000 -0.045000287
v -0.055000 0.280000 -0.045000288
v -0.055000 0.680000 -0.045000289
v -0.145000 0.680000 -0.045000290
v -0.145000 0.280000 0.045000291
v -0.055000 0.280000 0.045000292
v -0.055000 0.680000 0.045000293
v -0.145000 0.680000 0.045000294
vn 0.000000 0.000000 -1.000000295
vn 0.000000 0.000000 -1.000000296
vn 0.000000 0.000000 1.000000297
vn 0.000000 -0.000000 1.000000298
vn -1.000000 0.000000 0.000000299
vn -1.000000 0.000000 0.000000300
vn 1.000000 0.000000 0.000000301
vn 1.000000 0.000000 0.000000302
vn 0.000000 1.000000 0.000000303
vn 0.000000 1.000000 0.000000304
vn 0.000000 -1.000000 0.000000305
vn 0.000000 -1.000000 0.000000306
v -0.150000 0.050000 -0.170000307
v -0.050000 0.050000 -0.170000308
v -0.050000 0.110000 -0.170000309
v -0.150000 0.110000 -0.170000310
v -0.150000 0.050000 0.050000311
v -0.050000 0.050000 0.050000312
v -0.050000 0.110000 0.050000313
v -0.150000 0.110000 0.050000314
vn 0.000000 0.000000 -1.000000315
vn 0.000000 0.000000 -1.000000316
vn 0.000000 0.000000 1.000000317
vn 0.000000 -0.000000 1.000000318
vn -1.000000 0.000000 0.000000319
vn -1.000000 0.000000 0.000000320
vn 1.000000 0.000000 0.000000321
vn 1.000000 0.000000 0.000000322
vn 0.000000 1.000000 0.000000323
vn 0.000000 1.000000 0.000000324
vn 0.000000 -1.000000 0.000000325
vn 0.000000 -1.000000 0.000000326
v 0.040000 0.690000 -0.060000327
v 0.160000 0.690000 -0.060000328
v 0.160000 1.110000 -0.060000329
v 0.040000 1.110000 -0.060000330
v 0.040000 0.690000 0.060000331
v 0.160000 0.690000 0.060000332
v 0.160000 1.110000 0.060000333
v 0.040000 1.110000 0.060000334
vn 0.000000 0.000000 -1.000000335
vn 0.000000 0.000000 -1.000000336
vn 0.000000 0.000000 1.000000337
vn 0.000000 -0.000000 1.000000338
vn -1.000000 0.000000 0.000000339
vn -1.000000 0.000000 0.000000340
vn 1.000000 0.000000 0.000000341
vn 1.000000 0.000000 0.000000342
vn 0.000000 1.000000 0.000000343
vn 0.000000 1.000000 0.000000344
vn 0.000000 -1.000000 0.000000345
vn 0.000000 -1.000000 0.000000346
v 0.055000 0.280000 -0.045000347
v 0.145000 0.280000 -0.045000348
v 0.145000 0.680000 -0.045000349
v 0.055000 0.680000 -0.045000350
v 0.055000 0.280000 0.045000351
v 0.145000 0.280000 0.045000352
v 0.145000 0.680000 0.045000353
v 0.055000 0.680000 0.045000354
vn 0.000000 0.000000 -1.000000355
vn 0.000000 0.000000 -1.000000356
vn 0.000000 0.000000 1.000000357
vn 0.000000 -0.000000 1.000000358
vn -1.000000 0.000000 0.000000359
vn -1.000000 0.000000 0.000000360
vn 1.000000 0.000000 0.000000361
vn 1.000000 0.000000 0.000000362
vn 0.000000 1.000000 0.000000363
vn 0.000000 1.000000 0.000000364
vn 0.000000 -1.000000 0.000000365
vn 0.000000 -1.000000 0.000000366
v 0.050000 0.050000 -0.170000367
v 0.150000 0.050000 -0.170000368
v 0.150000 0.110000 -0.170000369
v 0.050000 0.110000 -0.170000370
v 0.050000 0.050000 0.050000371
v 0.150000 0.050000 0.050000372
v 0.150000 0.110000 0.050000373
v 0.050000 0.110000 0.050000374
vn 0.000000 0.000000 -1.000000375
vn 0.000000 0.000000 -1.000000376
vn 0.000000 0.000000 1.000000377
vn 0.000000 -0.000000 1.000000378
vn -1.000000 0.000000 0.000000379
vn -1.000000 0.000000 0.000000380
vn 1.000000 0.000000 0.000000381
vn 1.000000 0.000000 0.000000382
vn 0.000000 1.000000 0.000000383
vn 0.000000 1.000000 0.000000384
vn 0.000000 -1.000000 0.000000385
vn 0.000000 -1.000000 0.000000386
f 1/2/1 3/2/1 2/2/1387
f 1/2/2 4/2/2 3/2/2388
f 6/2/3 8/2/3 5/2/3389
f 6/2/4 7/2/4 8/2/4390
f 5/2/5 4/2/5 1/2/5391
f 5/2/6 8/2/6 4/2/6392
f 2/2/7 7/2/7 6/2/7393
f 2/2/8 3/2/8 7/2/8394
f 4/2/9 7/2/9 3/2/9395
f 4/2/10 8/2/10 7/2/10396
f 5/2/11 2/2/11 6/2/11397
f 5/2/12 1/2/12 2/2/12398
f 9/2/13 11/2/13 10/2/13399
f 9/2/14 12/2/14 11/2/14400
f 14/2/15 16/2/15 13/2/15401
f 14/2/16 15/2/16 16/2/16402
f 13/2/17 12/2/17 9/2/17403
f 13/2/18 16/2/18 12/2/18404
f 10/2/19 15/2/19 14/2/19405
f 10/2/20 11/2/20 15/2/20406
f 12/2/21 15/2/21 11/2/21407
f 12/2/22 16/2/22 15/2/22408
f 13/2/23 10/2/23 14/2/23409
f 13/2/24 9/2/24 10/2/24410
f 17/1/25 19/1/25 18/1/25411
f 17/1/26 20/1/26 19/1/26412
f 22/1/27 24/1/27 21/1/27413
f 22/1/28 23/1/28 24/1/28414
f 21/1/29 20/1/29 17/1/29415
f 21/1/30 24/1/30 20/1/30416
f 18/1/31 23/1/31 22/1/31417
f 18/1/32 19/1/32 23/1/32418
f 20/1/33 23/1/33 19/1/33419
f 20/1/34 24/1/34 23/1/34420
f 21/1/35 18/1/35 22/1/35421
f 21/1/36 17/1/36 18/1/36422
f 25/1/37 27/1/37 26/1/37423
f 25/1/38 28/1/38 27/1/38424
f 30/1/39 32/1/39 29/1/39425
f 30/1/40 31/1/40 32/1/40426
f 29/1/41 28/1/41 25/1/41427
f 29/1/42 32/1/42 28/1/42428
f 26/1/43 31/1/43 30/1/43429
f 26/1/44 27/1/44 31/1/44430
f 28/1/45 31/1/45 27/1/45431
f 28/1/46 32/1/46 31/1/46432
f 29/1/47 26/1/47 30/1/47433
f 29/1/48 25/1/48 26/1/48434
f 33/1/49 35/1/49 34/1/49435
f 33/1/50 36/1/50 35/1/50436
f 38/1/51 40/1/51 37/1/51437
f 38/1/52 39/1/52 40/1/52438
f 37/1/53 36/1/53 33/1/53439
f 37/1/54 40/1/54 36/1/54440
f 34/1/55 39/1/55 38/1/55441
f 34/1/56 35/1/56 39/1/56442
f 36/1/57 39/1/57 35/1/57443
f 36/1/58 40/1/58 39/1/58444
f 37/1/59 34/1/59 38/1/59445
f 37/1/60 33/1/60 34/1/60446
f 41/1/61 43/1/61 42/1/61447
f 41/1/62 44/1/62 43/1/62448
f 46/1/63 48/1/63 45/1/63449
f 46/1/64 47/1/64 48/1/64450
f 45/1/65 44/1/65 41/1/65451
f 45/1/66 48/1/66 44/1/66452
f 42/1/67 47/1/67 46/1/67453
f 42/1/68 43/1/68 47/1/68454
f 44/1/69 47/1/69 43/1/69455
f 44/1/70 48/1/70 47/1/70456
f 45/1/71 42/1/71 46/1/71457
f 45/1/72 41/1/72 42/1/72458
f 49/1/73 51/1/73 50/1/73459
f 49/1/74 52/1/74 51/1/74460
f 54/1/75 56/1/75 53/1/75461
f 54/1/76 55/1/76 56/1/76462
f 53/1/77 52/1/77 49/1/77463
f 53/1/78 56/1/78 52/1/78464
f 50/1/79 55/1/79 54/1/79465
f 50/1/80 51/1/80 55/1/80466
f 52/1/81 55/1/81 51/1/81467
f 52/1/82 56/1/82 55/1/82468
f 53/1/83 50/1/83 54/1/83469
f 53/1/84 49/1/84 50/1/84470
f 57/2/85 59/2/85 58/2/85471
f 57/2/86 60/2/86 59/2/86472
f 62/2/87 64/2/87 61/2/87473
f 62/2/88 63/2/88 64/2/88474
f 61/2/89 60/2/89 57/2/89475
f 61/2/90 64/2/90 60/2/90476
f 58/2/91 63/2/91 62/2/91477
f 58/2/92 59/2/92 63/2/92478
f 60/2/93 63/2/93 59/2/93479
f 60/2/94 64/2/94 63/2/94480
f 61/2/95 58/2/95 62/2/95481
f 61/2/96 57/2/96 58/2/96482
f 65/2/97 67/2/97 66/2/97483
f 65/2/98 68/2/98 67/2/98484
f 70/2/99 72/2/99 69/2/99485
f 70/2/100 71/2/100 72/2/100486
f 69/2/101 68/2/101 65/2/101487
f 69/2/102 72/2/102 68/2/102488
f 66/2/103 71/2/103 70/2/103489
f 66/2/104 67/2/104 71/2/104490
f 68/2/105 71/2/105 67/2/105491
f 68/2/106 72/2/106 71/2/106492
f 69/2/107 66/2/107 70/2/107493
f 69/2/108 65/2/108 66/2/108494
f 73/1/109 75/1/109 74/1/109495
f 73/1/110 76/1/110 75/1/110496
f 78/1/111 80/1/111 77/1/111497
f 78/1/112 79/1/112 80/1/112498
f 77/1/113 76/1/113 73/1/113499
f 77/1/114 80/1/114 76/1/114500
f 74/1/115 79/1/115 78/1/115501
f 74/1/116 75/1/116 79/1/116502
f 76/1/117 79/1/117 75/1/117503
f 76/1/118 80/1/118 79/1/118504
f 77/1/119 74/1/119 78/1/119505
f 77/1/120 73/1/120 74/1/120506
f 81/1/121 83/1/121 82/1/121507
f 81/1/122 84/1/122 83/1/122508
f 86/1/123 88/1/123 85/1/123509
f 86/1/124 87/1/124 88/1/124510
f 85/1/125 84/1/125 81/1/125511
f 85/1/126 88/1/126 84/1/126512
f 82/1/127 87/1/127 86/1/127513
f 82/1/128 83/1/128 87/1/128514
f 84/1/129 87/1/129 83/1/129515
f 84/1/130 88/1/130 87/1/130516
f 85/1/131 82/1/131 86/1/131517
f 85/1/132 81/1/132 82/1/132518
f 89/2/133 91/2/133 90/2/133519
f 89/2/134 92/2/134 91/2/134520
f 94/2/135 96/2/135 93/2/135521
f 94/2/136 95/2/136 96/2/136522
f 93/2/137 92/2/137 89/2/137523
f 93/2/138 96/2/138 92/2/138524
f 90/2/139 95/2/139 94/2/139525
f 90/2/140 91/2/140 95/2/140526
f 92/2/141 95/2/141 91/2/141527
f 92/2/142 96/2/142 95/2/142528
f 93/2/143 90/2/143 94/2/143529
f 93/2/144 89/2/144 90/2/144530
f 97/2/145 99/2/145 98/2/145531
f 97/2/146 100/2/146 99/2/146532
f 102/2/147 104/2/147 101/2/147533
f 102/2/148 103/2/148 104/2/148534
f 101/2/149 100/2/149 97/2/149535
f 101/2/150 104/2/150 100/2/150536
f 98/2/151 103/2/151 102/2/151537
f 98/2/152 99/2/152 103/2/152538
f 100/2/153 103/2/153 99/2/153539
f 100/2/154 104/2/154 103/2/154540
f 101/2/155 98/2/155 102/2/155541
f 101/2/156 97/2/156 98/2/156542
f 105/2/157 107/2/157 106/2/157543
f 105/2/158 108/2/158 107/2/158544
f 110/2/159 112/2/159 109/2/159545
f 110/2/160 111/2/160 112/2/160546
f 109/2/161 108/2/161 105/2/161547
f 109/2/162 112/2/162 108/2/162548
f 106/2/163 111/2/163 110/2/163549
f 106/2/164 107/2/164 111/2/164550
f 108/2/165 111/2/165 107/2/165551
f 108/2/166 112/2/166 111/2/166552
f 109/2/167 106/2/167 110/2/167553
f 109/2/168 105/2/168 106/2/168554
f 113/2/169 115/2/169 114/2/169555
f 113/2/170 116/2/170 115/2/170556
f 118/2/171 120/2/171 117/2/171557
f 118/2/172 119/2/172 120/2/172558
f 117/2/173 116/2/173 113/2/173559
f 117/2/174 120/2/174 116/2/174560
f 114/2/175 119/2/175 118/2/175561
f 114/2/176 115/2/176 119/2/176562
f 116/2/177 119/2/177 115/2/177563
f 116/2/178 120/2/178 119/2/178564
f 117/2/179 114/2/179 118/2/179565
f 117/2/180 113/2/180 114/2/180566
f 121/2/181 123/2/181 122/2/181567
f 121/2/182 124/2/182 123/2/182568
f 126/2/183 128/2/183 125/2/183569
f 126/2/184 127/2/184 128/2/184570
f 125/2/185 124/2/185 121/2/185571
f 125/2/186 128/2/186 124/2/186572
f 122/2/187 127/2/187 126/2/187573
f 122/2/188 123/2/188 127/2/188574
f 124/2/189 127/2/189 123/2/189575
f 124/2/190 128/2/190 127/2/190576
f 125/2/191 122/2/191 126/2/191577
f 125/2/192 121/2/192 122/2/192578
f 129/2/193 131/2/193 130/2/193579
f 129/2/194 132/2/194 131/2/194580
f 134/2/195 136/2/195 133/2/195581
f 134/2/196 135/2/196 136/2/196582
f 133/2/197 132/2/197 129/2/197583
f 133/2/198 136/2/198 132/2/198584
f 130/2/199 135/2/199 134/2/199585
f 130/2/200 131/2/200 135/2/200586
f 132/2/201 135/2/201 131/2/201587
f 132/2/202 136/2/202 135/2/202588
f 133/2/203 130/2/203 134/2/203589
f 133/2/204 129/2/204 130/2/204590
f 137/2/205 139/2/205 138/2/205591
f 137/2/206 140/2/206 139/2/206592
f 142/2/207 144/2/207 141/2/207593
f 142/2/208 143/2/208 144/2/208594
f 141/2/209 140/2/209 137/2/209595
f 141/2/210 144/2/210 140/2/210596
f 138/2/211 143/2/211 142/2/211597
f 138/2/212 139/2/212 143/2/212598
f 140/2/213 143/2/213 139/2/213599
f 140/2/214 144/2/214 143/2/214600
f 141/2/215 138/2/215 142/2/215601
f 141/2/216 137/2/216 138/2/216602
f 145/2/217 147/2/217 146/2/217603
f 145/2/218 148/2/218 147/2/218604
f 150/2/219 152/2/219 149/2/219605
f 150/2/220 151/2/220 152/2/220606
f 149/2/221 148/2/221 145/2/221607
f 149/2/222 152/2/222 148/2/222608
f 146/2/223 151/2/223 150/2/223609
f 146/2/224 147/2/224 151/2/224610
f 148/2/225 151/2/225 147/2/225611
f 148/2/226 152/2/226 151/2/226612
f 149/2/227 146/2/227 150/2/227613
f 149/2/228 145/2/228 146/2/228@@ -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 the3
//! documented center-Y pivot fallback, render one bind sample × 36 views and4
//! 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
..framing196
},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
paths215
.iter()216
.filter(|p| p.ends_with(&format!("_{map}.png")))217
.count(),218
36219
);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
}