Merge feature/fbx-live-production: FBX bodies live+production, rigid FBX equipment
@@ -17,9 +17,9 @@ cargo run -- --status
17
17
18
18
Het startvenster biedt **Project laden** en **Nieuw project**, beide met een systeembestandkiezer. Nieuw project maakt `character.ppd` en de mappen `assets/body/`, `assets/equipment/`, `presets/`, `cache/` en `renders/`. De CC0-testlichamen worden in `assets/body/` voorbereid. `--preview-fixture` maakt een tijdelijk voorbeeldproject en opent de mannelijke body.
19
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. Voor FBX bestaat daarnaast een genormaliseerde drawable/animated scene-API voor CPU-sampling; de live 3D-preview en productierender lezen nog glTF/GLB.
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-model is in de live preview zichtbaar:
22
**Open man / Open vrouw** opent de body. Een geskind glTF/GLB- of FBX-model 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**.
@@ -52,7 +52,7 @@ Vanuit een body opent **Equipment** de lijst. Kies een definitie of **Nieuw**.
52
52
53
53
1. Geef een unieke naam van maximaal 64 kleine letters/cijfers/`-`/`_`, zonder spaties; `body` is gereserveerd.
54
54
2. Kies **Man / Vrouw** voor het lichaam dat je bekijkt; **Geldig voor** bepaalt op welke geslachten het preset van toepassing is.
55
3. **Onderdeel toevoegen (OBJ)** opent de systeembestandkiezer. Rigide OBJ-onderdelen worden inclusief MTL en relatieve PNG-bijbestanden ingesloten. De preview gebruikt diffuse kleur/texture en alpha-cutout (grens 0,5). Bronbestanden mogen de modelmap niet verlaten.
55
3. **Onderdeel toevoegen (OBJ/FBX)** opent de systeembestandkiezer. Rigide OBJ-onderdelen worden inclusief MTL en relatieve PNG-bijbestanden ingesloten. Rigide (niet-geskinde) FBX-onderdelen krijgen dezelfde as- en eenheidsnormalisatie als FBX-lichamen; in de FBX ingebedde PNG-textures en relatieve PNG-textures naast het model worden in het preset ingesloten. Een gekoppelde diffuse texture vervangt de FBX-diffusekleur. Geskinde FBX-meshes, morph targets en niet-PNG-textures worden met een melding geweigerd. De preview gebruikt diffuse kleur/texture en alpha-cutout (grens 0,5). Bronbestanden mogen de modelmap niet verlaten.
56
56
4. Vouw **Skeleton / botten** uit en kies het doelbot. Stel relatieve positie XYZ en rotatie XYZ in graden in (Rz·Ry·Rx). Het onderdeel beweegt met dat bot tijdens walk/attack of een andere geïmporteerde bodyclip. Iedere sekse heeft een eigen botbinding; de numerieke offsets zijn gedeeld.
57
57
5. **Kies shinymap (PNG)** moduleert de instelbare glanssterkte. **Wis shinymap** keert terug naar een uniforme glanssterkte. Vul desgewenst **Herkomst / licentie** in. Meerdere onderdelen kunnen verschillende botten gebruiken.
58
58
6. **Opslaan** bewaart het project plus zelfbevattende binaire `.ppe`-snapshots in `presets/` (geometrie, bronpayloads, materialen/textures, transformaties, bindingen, seksevlaggen en herkomst). Er worden geen bronpaden buiten het project nodig om die opnieuw te laden.
@@ -116,7 +116,7 @@ let (maps, lit_rgba) = compose_relight_and_rim(&layers, &Light::default(), &RimP
116
116
```
117
117
118
118
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. FBX heeft een genormaliseerde tekenbare/animeerbare CPU-scene (`fbx_scene`) met unit/as-normalisatie, skin/clips en fixturevergelijking tegen de GLB; live viewport en productie gebruiken die scene nog niet. Morph targets, tangent-normaltextures, volledige PBR en willekeurige transparante lagen zijn niet ondersteund. Live placement blijft een belichte, vereenvoudigde weergave.
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.
120
120
121
121
## Openen met
122
122
@@ -140,6 +140,8 @@ cargo run --release --example resume_fixture --locked -- setup /tmp/resume tests
140
140
xvfb-run -a cargo run --release --example resume_fixture --locked -- render /tmp/resume male --abort-after=200
141
141
xvfb-run -a cargo run --release --example resume_fixture --locked -- resume /tmp/resume male
142
142
cargo run --release --example resume_fixture --locked -- verify /tmp/resume
143
# FBX op echte GPU: live view (lichaam + FBX-zwaard) en volledige productie van body- en equipmentset
144
xvfb-run -a cargo run --example fbx_live_fixture --locked -- /tmp/fbx-live /tmp/fbx-live-proof 32 48 4
143
145
```
144
146
145
De testlichamen komen uit `scripts/generate_test_bodies.py`. Het FBX-testbestand wordt met Blender via `scripts/convert_test_body_to_fbx.py` gemaakt.
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).
@@ -17,9 +17,9 @@ cargo run -- --status17
17
18
Het startvenster biedt **Project laden** en **Nieuw project**, beide met een systeembestandkiezer. Nieuw project maakt `character.ppd` en de mappen `assets/body/`, `assets/equipment/`, `presets/`, `cache/` en `renders/`. De CC0-testlichamen worden in `assets/body/` voorbereid. `--preview-fixture` maakt een tijdelijk voorbeeldproject en opent de mannelijke body.18
Het startvenster biedt **Project laden** en **Nieuw project**, beide met een systeembestandkiezer. Nieuw project maakt `character.ppd` en de mappen `assets/body/`, `assets/equipment/`, `presets/`, `cache/` en `renders/`. De CC0-testlichamen worden in `assets/body/` voorbereid. `--preview-fixture` maakt een tijdelijk voorbeeldproject en opent de mannelijke body.19
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. Voor FBX bestaat daarnaast een genormaliseerde drawable/animated scene-API voor CPU-sampling; de live 3D-preview en productierender lezen nog glTF/GLB.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-model is in de live preview zichtbaar:22
**Open man / Open vrouw** opent de body. Een geskind glTF/GLB- of FBX-model 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**.@@ -52,7 +52,7 @@ Vanuit een body opent **Equipment** de lijst. Kies een definitie of **Nieuw**.52
52
53
1. Geef een unieke naam van maximaal 64 kleine letters/cijfers/`-`/`_`, zonder spaties; `body` is gereserveerd.53
1. Geef een unieke naam van maximaal 64 kleine letters/cijfers/`-`/`_`, zonder spaties; `body` is gereserveerd.54
2. Kies **Man / Vrouw** voor het lichaam dat je bekijkt; **Geldig voor** bepaalt op welke geslachten het preset van toepassing is.54
2. Kies **Man / Vrouw** voor het lichaam dat je bekijkt; **Geldig voor** bepaalt op welke geslachten het preset van toepassing is.55
3. **Onderdeel toevoegen (OBJ)** opent de systeembestandkiezer. Rigide OBJ-onderdelen worden inclusief MTL en relatieve PNG-bijbestanden ingesloten. De preview gebruikt diffuse kleur/texture en alpha-cutout (grens 0,5). Bronbestanden mogen de modelmap niet verlaten.55
3. **Onderdeel toevoegen (OBJ/FBX)** opent de systeembestandkiezer. Rigide OBJ-onderdelen worden inclusief MTL en relatieve PNG-bijbestanden ingesloten. Rigide (niet-geskinde) FBX-onderdelen krijgen dezelfde as- en eenheidsnormalisatie als FBX-lichamen; in de FBX ingebedde PNG-textures en relatieve PNG-textures naast het model worden in het preset ingesloten. Een gekoppelde diffuse texture vervangt de FBX-diffusekleur. Geskinde FBX-meshes, morph targets en niet-PNG-textures worden met een melding geweigerd. De preview gebruikt diffuse kleur/texture en alpha-cutout (grens 0,5). Bronbestanden mogen de modelmap niet verlaten.56
4. Vouw **Skeleton / botten** uit en kies het doelbot. Stel relatieve positie XYZ en rotatie XYZ in graden in (Rz·Ry·Rx). Het onderdeel beweegt met dat bot tijdens walk/attack of een andere geïmporteerde bodyclip. Iedere sekse heeft een eigen botbinding; de numerieke offsets zijn gedeeld.56
4. Vouw **Skeleton / botten** uit en kies het doelbot. Stel relatieve positie XYZ en rotatie XYZ in graden in (Rz·Ry·Rx). Het onderdeel beweegt met dat bot tijdens walk/attack of een andere geïmporteerde bodyclip. Iedere sekse heeft een eigen botbinding; de numerieke offsets zijn gedeeld.57
5. **Kies shinymap (PNG)** moduleert de instelbare glanssterkte. **Wis shinymap** keert terug naar een uniforme glanssterkte. Vul desgewenst **Herkomst / licentie** in. Meerdere onderdelen kunnen verschillende botten gebruiken.57
5. **Kies shinymap (PNG)** moduleert de instelbare glanssterkte. **Wis shinymap** keert terug naar een uniforme glanssterkte. Vul desgewenst **Herkomst / licentie** in. Meerdere onderdelen kunnen verschillende botten gebruiken.58
6. **Opslaan** bewaart het project plus zelfbevattende binaire `.ppe`-snapshots in `presets/` (geometrie, bronpayloads, materialen/textures, transformaties, bindingen, seksevlaggen en herkomst). Er worden geen bronpaden buiten het project nodig om die opnieuw te laden.58
6. **Opslaan** bewaart het project plus zelfbevattende binaire `.ppe`-snapshots in `presets/` (geometrie, bronpayloads, materialen/textures, transformaties, bindingen, seksevlaggen en herkomst). Er worden geen bronpaden buiten het project nodig om die opnieuw te laden.@@ -116,7 +116,7 @@ let (maps, lit_rgba) = compose_relight_and_rim(&layers, &Light::default(), &RimP116
```116
```117
117
118
118
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. FBX heeft een genormaliseerde tekenbare/animeerbare CPU-scene (`fbx_scene`) met unit/as-normalisatie, skin/clips en fixturevergelijking tegen de GLB; live viewport en productie gebruiken die scene nog niet. Morph targets, tangent-normaltextures, volledige PBR en willekeurige transparante lagen zijn niet ondersteund. Live placement blijft een belichte, vereenvoudigde weergave.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.120
120
121
## Openen met121
## Openen met122
122
@@ -140,6 +140,8 @@ cargo run --release --example resume_fixture --locked -- setup /tmp/resume tests140
xvfb-run -a cargo run --release --example resume_fixture --locked -- render /tmp/resume male --abort-after=200140
xvfb-run -a cargo run --release --example resume_fixture --locked -- render /tmp/resume male --abort-after=200141
xvfb-run -a cargo run --release --example resume_fixture --locked -- resume /tmp/resume male141
xvfb-run -a cargo run --release --example resume_fixture --locked -- resume /tmp/resume male142
cargo run --release --example resume_fixture --locked -- verify /tmp/resume142
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 equipmentset144
xvfb-run -a cargo run --example fbx_live_fixture --locked -- /tmp/fbx-live /tmp/fbx-live-proof 32 48 4143
```145
```144
146
145
De testlichamen komen uit `scripts/generate_test_bodies.py`. Het FBX-testbestand wordt met Blender via `scripts/convert_test_body_to_fbx.py` gemaakt.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).@@ -67,13 +67,13 @@ The desktop shell stays on eframe/egui. Bevy is not linked. Pulling Bevy in only
67
67
68
68
This build reads glTF 2.0 `.gltf` and `.glb`: mesh positions, triangle indices, normals, UVs, skin weights, materials (base color and named textures), images (external files and buffer views), joint names with parents, and every animation clip (name, duration, channel count, target joints). It reads OBJ+MTL as a static equipment mesh, including referenced texture files, and always records that OBJ has no skeleton and no skeletal clips. External URIs that leave the project directory are rejected. A missing buffer, image or MTL file fails the import and names the missing path.
69
69
70
FBX is read with the `ufbx` crate 0.11.5. That crate compiles the MIT-licensed ufbx C loader into the binary, so AppImage, `.deb`, Arch and the MinGW Windows build do not depend on a system `libassimp`. `russimp` was not used: it needs a native Assimp build whose FBX animation results and Windows packaging are still the open risk above, and ufbx is the loader that actually returns skins, clips and materials for the fixture. A file that is not FBX still fails the parse; the `.fbx` suffix alone is not accepted. **`fbx_scene` builds a normalized in-process drawable/animated scene** (right-handed Y-up meters, ModifyGeometry space/geometry-transform handling, materials/textures ufbx reports, skin clusters with inverse-binds, every anim stack baked to sampleable TRS channels) with retained conversion provenance; fixture tests compare bind-equivalent and animated joint samples against the matching GLB. Unsupported skinning methods and leftover geometry-transform helpers fail explicitly. The live Glow viewport and production orthographic export still sample/render glTF/GLB only — wiring FBX into those paths is a later point. The fixture `tests/fixtures/bodies/test-body-male.fbx` is produced from the CC0 GLB by `scripts/convert_test_body_to_fbx.py` (Blender is a fixture tool only; the application does not call it).
70
FBX is read with the `ufbx` crate 0.11.5. That crate compiles the MIT-licensed ufbx C loader into the binary, so AppImage, `.deb`, Arch and the MinGW Windows build do not depend on a system `libassimp`. `russimp` was not used: it needs a native Assimp build whose FBX animation results and Windows packaging are still the open risk above, and ufbx is the loader that actually returns skins, clips and materials for the fixture. A file that is not FBX still fails the parse; the `.fbx` suffix alone is not accepted. **`fbx_scene` builds a normalized in-process drawable/animated scene** (right-handed Y-up meters, ModifyGeometry space/geometry-transform handling, materials/textures ufbx reports, skin clusters with inverse-binds, every anim stack baked to sampleable TRS channels) with retained conversion provenance; fixture tests compare bind-equivalent and animated joint samples against the matching GLB. Unsupported skinning methods and leftover geometry-transform helpers fail explicitly. The live Glow viewport and the production orthographic four-map batch load bodies through `preview::load_body_file`, which dispatches `.glb/.gltf` to the glTF reader and `.fbx` to `fbx_scene`; the FBX conversion is applied once there and nothing downstream re-applies axis/unit correction. FBX UVs are converted once to the top-left `PreviewCharacter` convention used by the body production path. Rigid FBX equipment (`equipment::import_fbx`) parses only the in-memory file bytes with the same normalization (ModifyGeometry, node geometry-to-world baked into vertices, normal matrix for normals, reversed winding fixed), stores one mesh per material part, embeds the original FBX plus embedded or relative-inside-folder PNG textures in the `.ppe`, treats a connected diffuse texture as replacing the FBX diffuse colour, and rejects skinned meshes, blend shapes and non-PNG textures. Tests compare the FBX body production silhouette (walk) and the animated attack arm chain against the GLB, the neck pivot within 30 mm, FBX equipment axis/unit/UV/texture fidelity, and exact attachment to the animated FBX joint; a GPU fixture (`examples/fbx_live_fixture.rs`) screenshots the live view and renders and verifies both export sets. Note: the Blender-exported fixture's `attack` stack contains a leftover walk leg pose for bones the GLB clip does not key; the loader reproduces the file as stored. The fixture `tests/fixtures/bodies/test-body-male.fbx` is produced from the CC0 GLB by `scripts/convert_test_body_to_fbx.py` (Blender is a fixture tool only; the application does not call it).
71
71
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
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. |
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` (CPU). Live/production still glTF/GLB. 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
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 |
79
79
| .blend | Not a portable interchange contract | Requires Blender/version matching | Possible through export | Possible through export | No direct minimum support; explicit external export to GLB preferred |
@@ -150,13 +150,13 @@ Per pose/view, the actual completed-preview order is:
150
150
151
151
Every pass uses one uniform scale, centered letterbox, nearest-neighbor display; never stretch target X/Y independently. The progress bar counts actual completed passes out of `images*6`, and a separate label identifies the last completed image while a later pass works. Each pose saves exactly four target PNGs. **Annuleer render** stops between safe units (or after the in-flight GPU readback), keeps partial outputs and never produces a complete marker. Consumer-side rim/edge lighting lives in the 2D compositor/preview path, never in pure diffuse albedo.
152
152
153
Current source scope: the first skinned glTF/GLB mesh (all its triangle primitives), first skin, vertex NORMAL attributes when present, otherwise true flat normals from GPU derivatives of the skinned view-space triangle position (never the live-only fallback vector), base-color factors/textures and COLOR_0; albedo images must be 8-bit and ≤2048×2048. Textures currently use nearest/repeat; sampler overrides/texture transforms, morph targets, tangent normal maps, multi-mesh scenes and arbitrary translucent layer ordering are not claimed. Opaque/alpha-cutout uses one winning surface; this is not order-independent blended transparency. GPU limits also reject non-finite geometry, more than 1M vertices/3M indices per draw, invalid texture payloads and invalid camera matrices. FBX has a normalized drawable/animated CPU scene (`fbx_scene`); live/production GPU paths remain glTF/GLB-only.
153
Current source scope: the first skinned glTF/GLB mesh (all its triangle primitives), first skin, vertex NORMAL attributes when present, otherwise true flat normals from GPU derivatives of the skinned view-space triangle position (never the live-only fallback vector), base-color factors/textures and COLOR_0; albedo images must be 8-bit and ≤2048×2048. Textures currently use nearest/repeat; sampler overrides/texture transforms, morph targets, tangent normal maps, multi-mesh scenes and arbitrary translucent layer ordering are not claimed. Opaque/alpha-cutout uses one winning surface; this is not order-independent blended transparency. GPU limits also reject non-finite geometry, more than 1M vertices/3M indices per draw, invalid texture payloads and invalid camera matrices. FBX bodies use the normalized drawable/animated scene (`fbx_scene`) in both the live and production GPU paths, with the same draw/material rules (first skinned mesh, per material part surfaces, PNG albedo).
154
154
155
155
Equipment pass hides the body from **all color, depth, normal and specular writes**, while still evaluating its rig and animating attachments. The body is not an invisible occluder; otherwise equipment behind it would be missing when layers are recomposed. Equipment self-occludes normally. Every layer must use the same projection and absolute depth contract. Hair/alpha cutout versus true blended transparency is an open fidelity decision; initial opaque/cutout support must be stated rather than promising arbitrary multilayer transparency from a single depth value.
156
156
157
157
## 9. Equipment editor and binary presets
158
158
159
The equipment editor is implemented for **rigid OBJ + MTL + PNG** parts over a skinned glTF/GLB body. Select a preview sex, applicability flags, a stable hierarchical bone path, XYZ translation, XYZ Euler degrees, shinymap and specular strength, and optional provenance/license text. The native picker is behind the `Dialogs` interface and runs on a request thread; polling never waits for a modal picker. Material preview uses MTL diffuse color, optional PNG diffuse texture and alpha-cutout at 0.5. Other embedded source-material maps are preserved as declared dependencies, not advertised as applied shading. Equipment FBX/glTF import and deformation/weight painting are not implemented by the equipment feature. Library depth compositing of exported maps is separate (`compositor`).
159
The equipment editor is implemented for **rigid OBJ + MTL + PNG and rigid FBX (embedded or relative PNG)** parts over a skinned glTF/GLB or FBX body. Select a preview sex, applicability flags, a stable hierarchical bone path, XYZ translation, XYZ Euler degrees, shinymap and specular strength, and optional provenance/license text. The native picker is behind the `Dialogs` interface and runs on a request thread; polling never waits for a modal picker. Material preview uses MTL diffuse color, optional PNG diffuse texture and alpha-cutout at 0.5. Other embedded source-material maps are preserved as declared dependencies, not advertised as applied shading. Equipment glTF import and deformation/weight painting are not implemented by the equipment feature. Library depth compositing of exported maps is separate (`compositor`).
160
160
161
161
Persisted rotation is **Euler degrees with `Rz * Ry * Rx`**, not the earlier proposed quaternion. Reconstruct from stored values each frame; never accumulate rotations. The exact rigid attachment matrix is `animated_joint_world * attachment_local`. The joint-world matrix already includes source root and non-joint ancestors, so neither body/world transforms nor inverse-bind matrices are applied twice. Tests include translated/rotated parents and a nonidentity inverse bind. Each sex has its own exact rig signature (hierarchical paths disambiguated by node indices plus rest-world matrices), with separate target-bone paths per part. Offsets/rotations are shared across sexes; use separate presets if distinct offsets are needed. An explicit bone selection updates that sex's rig binding; changing the body without rebinding yields a visible diagnostic rather than guessed retargeting. Empty/incomplete presets may be saved for authoring; unresolved attachments do not render on guessed joints.
162
162
@@ -196,7 +196,7 @@ After every merge, `README.md` describes only the current behavior: what the pro
196
196
197
197
The application icon is artwork supplied by Tim Falken. The original file is `assets/icons/paperdoll-renderer.png` (57×60 RGBA). Package bitmaps are scaled from that file with a premultiplied Lanczos filter, so the drawing is not redrawn. The eframe window uses the 128×128 raster as its window icon. Larger hicolor sizes stay in the packages. Linux AppImage, `.deb` and Arch packages install the hicolor sizes and name the icon `paperdoll-renderer` from both the desktop file and the AppStream metainfo. The MinGW Windows build embeds the same artwork in `paperdoll-renderer.exe` as `RT_GROUP_ICON`; the zip does not carry a separate `.ico`.
198
198
199
Live 3D preview, as built: the body screen draws the skinned glTF/GLB mesh with an independent perspective orbit camera or the visible fixed orthographic render frame (§6). Drag orbits, scroll zooms, and buttons snap to pitches -30/0/+30 and the twelve yaw steps of 30 degrees. Yaw 0 places the camera on -Z because the test bodies face -Z. Walk and attack are sampled on the CPU and uploaded as joint matrices. The preview light is a placement shade. The placement preview is lit, unlike exported albedo. It overlays bone-attached OBJ equipment (§9); FBX geometry is not yet drawn in the live viewport (CPU drawable scene exists in `fbx_scene`). The shell explicitly requests a native 24-bit depth buffer in eframe's **Glow/OpenGL** backend (not wgpu). Every body callback owns depth-test LESS, depth writes and clear-to-1 each frame; subsequent equipment layers must share that attachment without clearing it. A real GPU overlap/readback probe checks draw-order independence and depth clearing between frames. The CPU still rasterizer also depth-tests finite in-frustum fragments; neither preview is allowed to use painter's submission order as occlusion.
199
Live 3D preview, as built: the body screen draws the skinned glTF/GLB or FBX mesh with an independent perspective orbit camera or the visible fixed orthographic render frame (§6). Drag orbits, scroll zooms, and buttons snap to pitches -30/0/+30 and the twelve yaw steps of 30 degrees. Yaw 0 places the camera on -Z because the test bodies face -Z. Walk and attack are sampled on the CPU and uploaded as joint matrices. The preview light is a placement shade. The placement preview is lit, unlike exported albedo. It overlays bone-attached OBJ or FBX equipment (§9); FBX bodies are drawn from the normalized `fbx_scene`. The shell explicitly requests a native 24-bit depth buffer in eframe's **Glow/OpenGL** backend (not wgpu). Every body callback owns depth-test LESS, depth writes and clear-to-1 each frame; subsequent equipment layers must share that attachment without clearing it. A real GPU overlap/readback probe checks draw-order independence and depth clearing between frames. The CPU still rasterizer also depth-tests finite in-frustum fragments; neither preview is allowed to use painter's submission order as occlusion.
200
200
201
201
CI filename requested: `blue-ci-cd.yml`. **Unverified infrastructure detail:** do not invent BlueGit YAML syntax. Inspect documented BlueGit runner schema and narrow existing `timfa/posprint` and `legacy-cycles` pipeline/release examples before writing configuration. Respect runner/image selection, artifact retention and release API semantics. No GitLab mirror is requested for this new project.
202
202
@@ -219,7 +219,7 @@ These requirements are durable even where the implementation is staged. They sup
219
219
- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.
220
220
- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.
221
221
222
Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation, with schema-3 export identity, source fingerprints and per-PNG SHA-256 checksums plus the safe `export_set` reader. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The library 2D depth compositor/relighting, consumer-side rim/edge lighting (R08) and the 2D-preview GUI/tab/playback (with Randlicht control) are implemented, as is checksum-verified resume of cancelled/failed/crashed runs (§7). FBX normalized drawable/animated scene (`fbx_scene`) is implemented for CPU sampling with GLB fixture comparison; live viewport and production four-map batch still do not consume FBX geometry.
222
Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation, with schema-3 export identity, source fingerprints and per-PNG SHA-256 checksums plus the safe `export_set` reader. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The library 2D depth compositor/relighting, consumer-side rim/edge lighting (R08) and the 2D-preview GUI/tab/playback (with Randlicht control) are implemented, as is checksum-verified resume of cancelled/failed/crashed runs (§7). FBX bodies (normalized `fbx_scene`) are drawn in the live viewport and rendered by the production four-map batch with the same camera, neck pivot and fixed per-body zoom; rigid FBX equipment is imported with embedded/relative PNG textures.
223
223
224
224
## 12. Incremental feature plan and acceptance gates
225
225
@@ -67,13 +67,13 @@ The desktop shell stays on eframe/egui. Bevy is not linked. Pulling Bevy in only67
67
68
This build reads glTF 2.0 `.gltf` and `.glb`: mesh positions, triangle indices, normals, UVs, skin weights, materials (base color and named textures), images (external files and buffer views), joint names with parents, and every animation clip (name, duration, channel count, target joints). It reads OBJ+MTL as a static equipment mesh, including referenced texture files, and always records that OBJ has no skeleton and no skeletal clips. External URIs that leave the project directory are rejected. A missing buffer, image or MTL file fails the import and names the missing path.68
This build reads glTF 2.0 `.gltf` and `.glb`: mesh positions, triangle indices, normals, UVs, skin weights, materials (base color and named textures), images (external files and buffer views), joint names with parents, and every animation clip (name, duration, channel count, target joints). It reads OBJ+MTL as a static equipment mesh, including referenced texture files, and always records that OBJ has no skeleton and no skeletal clips. External URIs that leave the project directory are rejected. A missing buffer, image or MTL file fails the import and names the missing path.69
69
70
FBX is read with the `ufbx` crate 0.11.5. That crate compiles the MIT-licensed ufbx C loader into the binary, so AppImage, `.deb`, Arch and the MinGW Windows build do not depend on a system `libassimp`. `russimp` was not used: it needs a native Assimp build whose FBX animation results and Windows packaging are still the open risk above, and ufbx is the loader that actually returns skins, clips and materials for the fixture. A file that is not FBX still fails the parse; the `.fbx` suffix alone is not accepted. **`fbx_scene` builds a normalized in-process drawable/animated scene** (right-handed Y-up meters, ModifyGeometry space/geometry-transform handling, materials/textures ufbx reports, skin clusters with inverse-binds, every anim stack baked to sampleable TRS channels) with retained conversion provenance; fixture tests compare bind-equivalent and animated joint samples against the matching GLB. Unsupported skinning methods and leftover geometry-transform helpers fail explicitly. The live Glow viewport and production orthographic export still sample/render glTF/GLB only — wiring FBX into those paths is a later point. The fixture `tests/fixtures/bodies/test-body-male.fbx` is produced from the CC0 GLB by `scripts/convert_test_body_to_fbx.py` (Blender is a fixture tool only; the application does not call it).70
FBX is read with the `ufbx` crate 0.11.5. That crate compiles the MIT-licensed ufbx C loader into the binary, so AppImage, `.deb`, Arch and the MinGW Windows build do not depend on a system `libassimp`. `russimp` was not used: it needs a native Assimp build whose FBX animation results and Windows packaging are still the open risk above, and ufbx is the loader that actually returns skins, clips and materials for the fixture. A file that is not FBX still fails the parse; the `.fbx` suffix alone is not accepted. **`fbx_scene` builds a normalized in-process drawable/animated scene** (right-handed Y-up meters, ModifyGeometry space/geometry-transform handling, materials/textures ufbx reports, skin clusters with inverse-binds, every anim stack baked to sampleable TRS channels) with retained conversion provenance; fixture tests compare bind-equivalent and animated joint samples against the matching GLB. Unsupported skinning methods and leftover geometry-transform helpers fail explicitly. The live Glow viewport and the production orthographic four-map batch load bodies through `preview::load_body_file`, which dispatches `.glb/.gltf` to the glTF reader and `.fbx` to `fbx_scene`; the FBX conversion is applied once there and nothing downstream re-applies axis/unit correction. FBX UVs are converted once to the top-left `PreviewCharacter` convention used by the body production path. Rigid FBX equipment (`equipment::import_fbx`) parses only the in-memory file bytes with the same normalization (ModifyGeometry, node geometry-to-world baked into vertices, normal matrix for normals, reversed winding fixed), stores one mesh per material part, embeds the original FBX plus embedded or relative-inside-folder PNG textures in the `.ppe`, treats a connected diffuse texture as replacing the FBX diffuse colour, and rejects skinned meshes, blend shapes and non-PNG textures. Tests compare the FBX body production silhouette (walk) and the animated attack arm chain against the GLB, the neck pivot within 30 mm, FBX equipment axis/unit/UV/texture fidelity, and exact attachment to the animated FBX joint; a GPU fixture (`examples/fbx_live_fixture.rs`) screenshots the live view and renders and verifies both export sets. Note: the Blender-exported fixture's `attack` stack contains a leftover walk leg pose for bones the GLB clip does not key; the loader reproduces the file as stored. The fixture `tests/fixtures/bodies/test-body-male.fbx` is produced from the CC0 GLB by `scripts/convert_test_body_to_fbx.py` (Blender is a fixture tool only; the application does not call it).71
71
72
| Format | Mesh | Materials/textures | Skeleton | Animation | Intended role / status |72
| Format | Mesh | Materials/textures | Skeleton | Animation | Intended role / status |73
|---|---|---|---|---|---|73
|---|---|---|---|---|---|74
| glTF 2.0 .gltf/.glb | Yes | PBR metallic/roughness and referenced/embedded textures | Skins/joints | Node/skeletal clips; morph support must be tested | Read by the `gltf` crate in this build: mesh, materials, textures, joints and clips. The live preview draws the skinned body mesh. |74
| glTF 2.0 .gltf/.glb | Yes | PBR metallic/roughness and referenced/embedded textures | Skins/joints | Node/skeletal clips; morph support must be tested | Read by the `gltf` crate in this build: mesh, materials, textures, joints and clips. The live preview draws the skinned body mesh. |75
| OBJ + MTL + texture files | Yes | MTL subset with explicit conversion and relative textures | No | No skeletal clips | Read by `tobj` in this build as a static mesh. The import states that OBJ has no skeletal clips. Not rendered. |75
| OBJ + MTL + texture files | Yes | MTL subset with explicit conversion and relative textures | No | No skeletal clips | Read by `tobj` in this build as a static mesh. The import states that OBJ has no skeletal clips. Not rendered. |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` (CPU). Live/production still glTF/GLB. 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 |79
| .blend | Not a portable interchange contract | Requires Blender/version matching | Possible through export | Possible through export | No direct minimum support; explicit external export to GLB preferred |79
| .blend | Not a portable interchange contract | Requires Blender/version matching | Possible through export | Possible through export | No direct minimum support; explicit external export to GLB preferred |@@ -150,13 +150,13 @@ Per pose/view, the actual completed-preview order is:150
150
151
Every pass uses one uniform scale, centered letterbox, nearest-neighbor display; never stretch target X/Y independently. The progress bar counts actual completed passes out of `images*6`, and a separate label identifies the last completed image while a later pass works. Each pose saves exactly four target PNGs. **Annuleer render** stops between safe units (or after the in-flight GPU readback), keeps partial outputs and never produces a complete marker. Consumer-side rim/edge lighting lives in the 2D compositor/preview path, never in pure diffuse albedo.151
Every pass uses one uniform scale, centered letterbox, nearest-neighbor display; never stretch target X/Y independently. The progress bar counts actual completed passes out of `images*6`, and a separate label identifies the last completed image while a later pass works. Each pose saves exactly four target PNGs. **Annuleer render** stops between safe units (or after the in-flight GPU readback), keeps partial outputs and never produces a complete marker. Consumer-side rim/edge lighting lives in the 2D compositor/preview path, never in pure diffuse albedo.152
152
153
Current source scope: the first skinned glTF/GLB mesh (all its triangle primitives), first skin, vertex NORMAL attributes when present, otherwise true flat normals from GPU derivatives of the skinned view-space triangle position (never the live-only fallback vector), base-color factors/textures and COLOR_0; albedo images must be 8-bit and ≤2048×2048. Textures currently use nearest/repeat; sampler overrides/texture transforms, morph targets, tangent normal maps, multi-mesh scenes and arbitrary translucent layer ordering are not claimed. Opaque/alpha-cutout uses one winning surface; this is not order-independent blended transparency. GPU limits also reject non-finite geometry, more than 1M vertices/3M indices per draw, invalid texture payloads and invalid camera matrices. FBX has a normalized drawable/animated CPU scene (`fbx_scene`); live/production GPU paths remain glTF/GLB-only.153
Current source scope: the first skinned glTF/GLB mesh (all its triangle primitives), first skin, vertex NORMAL attributes when present, otherwise true flat normals from GPU derivatives of the skinned view-space triangle position (never the live-only fallback vector), base-color factors/textures and COLOR_0; albedo images must be 8-bit and ≤2048×2048. Textures currently use nearest/repeat; sampler overrides/texture transforms, morph targets, tangent normal maps, multi-mesh scenes and arbitrary translucent layer ordering are not claimed. Opaque/alpha-cutout uses one winning surface; this is not order-independent blended transparency. GPU limits also reject non-finite geometry, more than 1M vertices/3M indices per draw, invalid texture payloads and invalid camera matrices. FBX bodies use the normalized drawable/animated scene (`fbx_scene`) in both the live and production GPU paths, with the same draw/material rules (first skinned mesh, per material part surfaces, PNG albedo).154
154
155
Equipment pass hides the body from **all color, depth, normal and specular writes**, while still evaluating its rig and animating attachments. The body is not an invisible occluder; otherwise equipment behind it would be missing when layers are recomposed. Equipment self-occludes normally. Every layer must use the same projection and absolute depth contract. Hair/alpha cutout versus true blended transparency is an open fidelity decision; initial opaque/cutout support must be stated rather than promising arbitrary multilayer transparency from a single depth value.155
Equipment pass hides the body from **all color, depth, normal and specular writes**, while still evaluating its rig and animating attachments. The body is not an invisible occluder; otherwise equipment behind it would be missing when layers are recomposed. Equipment self-occludes normally. Every layer must use the same projection and absolute depth contract. Hair/alpha cutout versus true blended transparency is an open fidelity decision; initial opaque/cutout support must be stated rather than promising arbitrary multilayer transparency from a single depth value.156
156
157
## 9. Equipment editor and binary presets157
## 9. Equipment editor and binary presets158
158
159
The equipment editor is implemented for **rigid OBJ + MTL + PNG** parts over a skinned glTF/GLB body. Select a preview sex, applicability flags, a stable hierarchical bone path, XYZ translation, XYZ Euler degrees, shinymap and specular strength, and optional provenance/license text. The native picker is behind the `Dialogs` interface and runs on a request thread; polling never waits for a modal picker. Material preview uses MTL diffuse color, optional PNG diffuse texture and alpha-cutout at 0.5. Other embedded source-material maps are preserved as declared dependencies, not advertised as applied shading. Equipment FBX/glTF import and deformation/weight painting are not implemented by the equipment feature. Library depth compositing of exported maps is separate (`compositor`).159
The equipment editor is implemented for **rigid OBJ + MTL + PNG and rigid FBX (embedded or relative PNG)** parts over a skinned glTF/GLB or FBX body. Select a preview sex, applicability flags, a stable hierarchical bone path, XYZ translation, XYZ Euler degrees, shinymap and specular strength, and optional provenance/license text. The native picker is behind the `Dialogs` interface and runs on a request thread; polling never waits for a modal picker. Material preview uses MTL diffuse color, optional PNG diffuse texture and alpha-cutout at 0.5. Other embedded source-material maps are preserved as declared dependencies, not advertised as applied shading. Equipment glTF import and deformation/weight painting are not implemented by the equipment feature. Library depth compositing of exported maps is separate (`compositor`).160
160
161
Persisted rotation is **Euler degrees with `Rz * Ry * Rx`**, not the earlier proposed quaternion. Reconstruct from stored values each frame; never accumulate rotations. The exact rigid attachment matrix is `animated_joint_world * attachment_local`. The joint-world matrix already includes source root and non-joint ancestors, so neither body/world transforms nor inverse-bind matrices are applied twice. Tests include translated/rotated parents and a nonidentity inverse bind. Each sex has its own exact rig signature (hierarchical paths disambiguated by node indices plus rest-world matrices), with separate target-bone paths per part. Offsets/rotations are shared across sexes; use separate presets if distinct offsets are needed. An explicit bone selection updates that sex's rig binding; changing the body without rebinding yields a visible diagnostic rather than guessed retargeting. Empty/incomplete presets may be saved for authoring; unresolved attachments do not render on guessed joints.161
Persisted rotation is **Euler degrees with `Rz * Ry * Rx`**, not the earlier proposed quaternion. Reconstruct from stored values each frame; never accumulate rotations. The exact rigid attachment matrix is `animated_joint_world * attachment_local`. The joint-world matrix already includes source root and non-joint ancestors, so neither body/world transforms nor inverse-bind matrices are applied twice. Tests include translated/rotated parents and a nonidentity inverse bind. Each sex has its own exact rig signature (hierarchical paths disambiguated by node indices plus rest-world matrices), with separate target-bone paths per part. Offsets/rotations are shared across sexes; use separate presets if distinct offsets are needed. An explicit bone selection updates that sex's rig binding; changing the body without rebinding yields a visible diagnostic rather than guessed retargeting. Empty/incomplete presets may be saved for authoring; unresolved attachments do not render on guessed joints.162
162
@@ -196,7 +196,7 @@ After every merge, `README.md` describes only the current behavior: what the pro196
196
197
The application icon is artwork supplied by Tim Falken. The original file is `assets/icons/paperdoll-renderer.png` (57×60 RGBA). Package bitmaps are scaled from that file with a premultiplied Lanczos filter, so the drawing is not redrawn. The eframe window uses the 128×128 raster as its window icon. Larger hicolor sizes stay in the packages. Linux AppImage, `.deb` and Arch packages install the hicolor sizes and name the icon `paperdoll-renderer` from both the desktop file and the AppStream metainfo. The MinGW Windows build embeds the same artwork in `paperdoll-renderer.exe` as `RT_GROUP_ICON`; the zip does not carry a separate `.ico`.197
The application icon is artwork supplied by Tim Falken. The original file is `assets/icons/paperdoll-renderer.png` (57×60 RGBA). Package bitmaps are scaled from that file with a premultiplied Lanczos filter, so the drawing is not redrawn. The eframe window uses the 128×128 raster as its window icon. Larger hicolor sizes stay in the packages. Linux AppImage, `.deb` and Arch packages install the hicolor sizes and name the icon `paperdoll-renderer` from both the desktop file and the AppStream metainfo. The MinGW Windows build embeds the same artwork in `paperdoll-renderer.exe` as `RT_GROUP_ICON`; the zip does not carry a separate `.ico`.198
198
199
Live 3D preview, as built: the body screen draws the skinned glTF/GLB mesh with an independent perspective orbit camera or the visible fixed orthographic render frame (§6). Drag orbits, scroll zooms, and buttons snap to pitches -30/0/+30 and the twelve yaw steps of 30 degrees. Yaw 0 places the camera on -Z because the test bodies face -Z. Walk and attack are sampled on the CPU and uploaded as joint matrices. The preview light is a placement shade. The placement preview is lit, unlike exported albedo. It overlays bone-attached OBJ equipment (§9); FBX geometry is not yet drawn in the live viewport (CPU drawable scene exists in `fbx_scene`). The shell explicitly requests a native 24-bit depth buffer in eframe's **Glow/OpenGL** backend (not wgpu). Every body callback owns depth-test LESS, depth writes and clear-to-1 each frame; subsequent equipment layers must share that attachment without clearing it. A real GPU overlap/readback probe checks draw-order independence and depth clearing between frames. The CPU still rasterizer also depth-tests finite in-frustum fragments; neither preview is allowed to use painter's submission order as occlusion.199
Live 3D preview, as built: the body screen draws the skinned glTF/GLB or FBX mesh with an independent perspective orbit camera or the visible fixed orthographic render frame (§6). Drag orbits, scroll zooms, and buttons snap to pitches -30/0/+30 and the twelve yaw steps of 30 degrees. Yaw 0 places the camera on -Z because the test bodies face -Z. Walk and attack are sampled on the CPU and uploaded as joint matrices. The preview light is a placement shade. The placement preview is lit, unlike exported albedo. It overlays bone-attached OBJ or FBX equipment (§9); FBX bodies are drawn from the normalized `fbx_scene`. The shell explicitly requests a native 24-bit depth buffer in eframe's **Glow/OpenGL** backend (not wgpu). Every body callback owns depth-test LESS, depth writes and clear-to-1 each frame; subsequent equipment layers must share that attachment without clearing it. A real GPU overlap/readback probe checks draw-order independence and depth clearing between frames. The CPU still rasterizer also depth-tests finite in-frustum fragments; neither preview is allowed to use painter's submission order as occlusion.200
200
201
CI filename requested: `blue-ci-cd.yml`. **Unverified infrastructure detail:** do not invent BlueGit YAML syntax. Inspect documented BlueGit runner schema and narrow existing `timfa/posprint` and `legacy-cycles` pipeline/release examples before writing configuration. Respect runner/image selection, artifact retention and release API semantics. No GitLab mirror is requested for this new project.201
CI filename requested: `blue-ci-cd.yml`. **Unverified infrastructure detail:** do not invent BlueGit YAML syntax. Inspect documented BlueGit runner schema and narrow existing `timfa/posprint` and `legacy-cycles` pipeline/release examples before writing configuration. Respect runner/image selection, artifact retention and release API semantics. No GitLab mirror is requested for this new project.202
202
@@ -219,7 +219,7 @@ These requirements are durable even where the implementation is staged. They sup219
- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.219
- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.220
- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.220
- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.221
221
222
Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation, with schema-3 export identity, source fingerprints and per-PNG SHA-256 checksums plus the safe `export_set` reader. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The library 2D depth compositor/relighting, consumer-side rim/edge lighting (R08) and the 2D-preview GUI/tab/playback (with Randlicht control) are implemented, as is checksum-verified resume of cancelled/failed/crashed runs (§7). FBX normalized drawable/animated scene (`fbx_scene`) is implemented for CPU sampling with GLB fixture comparison; live viewport and production four-map batch still do not consume FBX geometry.222
Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation, with schema-3 export identity, source fingerprints and per-PNG SHA-256 checksums plus the safe `export_set` reader. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The library 2D depth compositor/relighting, consumer-side rim/edge lighting (R08) and the 2D-preview GUI/tab/playback (with Randlicht control) are implemented, as is checksum-verified resume of cancelled/failed/crashed runs (§7). FBX bodies (normalized `fbx_scene`) are drawn in the live viewport and rendered by the production four-map batch with the same camera, neck pivot and fixed per-body zoom; rigid FBX equipment is imported with embedded/relative PNG textures.223
223
224
## 12. Incremental feature plan and acceptance gates224
## 12. Incremental feature plan and acceptance gates225
225
@@ -29,7 +29,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
29
29
equipment::import_obj(&mut preset, &fixtures.join("sword.obj"))?;
30
30
equipment::import_shiny(&mut preset, 0, &fixtures.join("shiny.png"))?;
31
31
for (sex, name) in [(0, "test-body-male.glb"), (1, "test-body-female.glb")] {
32
let body = preview::load_gltf_file(&root.join("assets/body").join(name))?;
32
let body = preview::load_body_file(&root.join("assets/body").join(name))?;
33
33
let joint = body
34
34
.joint_names
35
35
.iter()
@@ -29,7 +29,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {29
equipment::import_obj(&mut preset, &fixtures.join("sword.obj"))?;29
equipment::import_obj(&mut preset, &fixtures.join("sword.obj"))?;30
equipment::import_shiny(&mut preset, 0, &fixtures.join("shiny.png"))?;30
equipment::import_shiny(&mut preset, 0, &fixtures.join("shiny.png"))?;31
for (sex, name) in [(0, "test-body-male.glb"), (1, "test-body-female.glb")] {31
for (sex, name) in [(0, "test-body-male.glb"), (1, "test-body-female.glb")] {32
let body = preview::load_gltf_file(&root.join("assets/body").join(name))?;32
let body = preview::load_body_file(&root.join("assets/body").join(name))?;33
let joint = body33
let joint = body34
.joint_names34
.joint_names35
.iter()35
.iter()@@ -0,0 +1,292 @@
1
//! Real native GPU proof for FBX bodies and rigid FBX equipment (no input automation).
2
//! Usage: xvfb-run -a cargo run --example fbx_live_fixture -- ROOT OUTDIR [WIDTH HEIGHT FPS]
3
//! Builds a project with the FBX male body and an FBX sword bound to its RightHand,
4
//! screenshots the live viewport (body, then equipment), runs the production batch
5
//! for the body set and the equipment set, and verifies both runs with `export_set`.
6
use eframe::App;
7
use paperdoll_renderer::{
8
editor::EditorApp,
9
equipment, export_set, preview,
10
session::{adopt_file, Session, Sex},
11
};
12
use std::path::{Path, PathBuf};
13
14
const SETTLE_FRAMES: u64 = 40;
15
16
struct Fixture {
17
editor: EditorApp,
18
out: PathBuf,
19
phase: u8,
20
waited: u64,
21
shot_requested: bool,
22
results: Vec<serde_json::Value>,
23
last: u32,
24
}
25
26
fn save_png(path: &Path, w: usize, h: usize, rgba: &[u8]) {
27
let file = std::fs::File::create(path).unwrap();
28
let mut enc = png::Encoder::new(std::io::BufWriter::new(file), w as u32, h as u32);
29
enc.set_color(png::ColorType::Rgba);
30
enc.set_depth(png::BitDepth::Eight);
31
enc.write_header().unwrap().write_image_data(rgba).unwrap();
32
}
33
34
impl Fixture {
35
/// Returns true once the screenshot was written.
36
fn screenshot(&mut self, ctx: &eframe::egui::Context, name: &str) -> bool {
37
self.waited += 1;
38
if self.waited < SETTLE_FRAMES {
39
return false;
40
}
41
if !self.shot_requested {
42
self.shot_requested = true;
43
ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Screenshot);
44
return false;
45
}
46
let shot = ctx.input(|i| {
47
i.raw.events.iter().find_map(|e| match e {
48
eframe::egui::Event::Screenshot { image, .. } => Some(image.clone()),
49
_ => None,
50
})
51
});
52
let Some(image) = shot else { return false };
53
let [w, h] = image.size;
54
let rgba: Vec<u8> = image.pixels.iter().flat_map(|p| p.to_array()).collect();
55
let path = self.out.join(name);
56
save_png(&path, w, h, &rgba);
57
println!("SCREENSHOT {}", path.display());
58
self.waited = 0;
59
self.shot_requested = false;
60
true
61
}
62
fn render_done(&mut self, label: &str) -> bool {
63
let (completed, total, pass) = self.editor.render_progress();
64
if completed != self.last {
65
if completed % 50 == 0 || completed == total {
66
println!("PASS {label} {completed}/{total}: {pass}");
67
}
68
self.last = completed;
69
}
70
if self.editor.render_is_running() {
71
return false;
72
}
73
let error = self.editor.render_error().map(str::to_string);
74
let success = error.is_none() && total > 0 && completed == total;
75
self.results.push(serde_json::json!({
76
"set": label, "success": success, "completed": completed,
77
"total": total, "last_pass": pass, "error": error,
78
}));
79
self.last = 0;
80
true
81
}
82
}
83
84
impl App for Fixture {
85
fn update(&mut self, ctx: &eframe::egui::Context, frame: &mut eframe::Frame) {
86
self.editor.update(ctx, frame);
87
ctx.request_repaint();
88
match self.phase {
89
0 => {
90
if self.screenshot(ctx, "live-fbx-body.png") {
91
self.editor.open_equipment_configuration(0, Sex::Male);
92
self.phase = 1;
93
}
94
}
95
1 => {
96
if self.screenshot(ctx, "live-fbx-equipment.png") {
97
self.editor.open_body_configuration(Sex::Male);
98
self.editor.render_all_angles();
99
self.phase = 2;
100
}
101
}
102
2 => {
103
if self.render_done("body") {
104
self.editor.open_equipment_configuration(0, Sex::Male);
105
self.editor.render_all_angles();
106
self.phase = 3;
107
}
108
}
109
3 => {
110
if self.render_done("fbxsword") {
111
self.phase = 4;
112
}
113
}
114
4 => {
115
let written = self.screenshot(ctx, "production-fbx-equipment-ui.png");
116
if written {
117
self.phase = 5;
118
ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Close);
119
}
120
}
121
_ => {}
122
}
123
}
124
fn on_exit(&mut self, gl: Option<&glow::Context>) {
125
let value = serde_json::Value::Array(self.results.clone());
126
std::fs::write(
127
self.out.join("gui-result.json"),
128
serde_json::to_vec_pretty(&value).unwrap(),
129
)
130
.unwrap();
131
self.editor.on_exit(gl);
132
}
133
}
134
135
fn verify(root: &Path, width: u32, height: u32) -> Result<serde_json::Value, String> {
136
let mut runs = Vec::new();
137
let mut dirs = std::fs::read_dir(root.join("renders"))
138
.map_err(|e| e.to_string())?
139
.map(|e| e.map(|e| e.path()).map_err(|e| e.to_string()))
140
.collect::<Result<Vec<_>, _>>()?;
141
dirs.sort();
142
for dir in dirs.into_iter().filter(|d| d.is_dir()) {
143
let run =
144
export_set::ExportRun::open(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
145
let mut counts = std::collections::BTreeMap::<String, u32>::new();
146
let mut opaque = 0u64;
147
let mut textured = 0u64;
148
let mut stack = vec![dir.clone()];
149
while let Some(d) = stack.pop() {
150
for entry in std::fs::read_dir(&d).map_err(|e| e.to_string())? {
151
let path = entry.map_err(|e| e.to_string())?.path();
152
if path.is_dir() {
153
stack.push(path);
154
continue;
155
}
156
let name = path.file_name().unwrap().to_string_lossy().to_string();
157
if !name.ends_with(".png") {
158
continue;
159
}
160
let map = name
161
.rsplit_once('_')
162
.map(|(_, m)| m.trim_end_matches(".png").to_string())
163
.unwrap_or_default();
164
if !["diffuse", "normal", "depth", "specular"].contains(&map.as_str()) {
165
return Err(format!("onverwachte map {name}"));
166
}
167
*counts.entry(map.clone()).or_default() += 1;
168
let decoder = png::Decoder::new(std::io::BufReader::new(
169
std::fs::File::open(&path).map_err(|e| e.to_string())?,
170
));
171
let mut reader = decoder.read_info().map_err(|e| e.to_string())?;
172
let (w, h) = (reader.info().width, reader.info().height);
173
if (w, h) != (width, height) {
174
return Err(format!("{name}: {w}x{h} ≠ doel {width}x{height}"));
175
}
176
if map == "diffuse" {
177
let mut buf = vec![0; reader.output_buffer_size().unwrap()];
178
let info = reader.next_frame(&mut buf).map_err(|e| e.to_string())?;
179
if info.color_type == png::ColorType::Rgba {
180
for px in buf[..info.buffer_size()].chunks_exact(4) {
181
if px[3] > 0 {
182
opaque += 1;
183
// Brass guard albedo (0.75,0.55,0.2) or checker blade: colourful.
184
if px[0] as i32 - px[2] as i32 > 60 {
185
textured += 1;
186
}
187
}
188
}
189
}
190
}
191
}
192
}
193
runs.push(serde_json::json!({
194
"dir": dir.display().to_string(),
195
"png_files": run.complete.png_files,
196
"maps": counts,
197
"diffuse_opaque_pixels": opaque,
198
"diffuse_warm_pixels": textured,
199
"body_model": run.render.body_model.path.clone(),
200
"set": run.render.set.clone(),
201
"clips": run.render.clips.len(),
202
"images": run.render.images,
203
}));
204
}
205
Ok(serde_json::Value::Array(runs))
206
}
207
208
fn main() -> Result<(), Box<dyn std::error::Error>> {
209
let a = std::env::args().skip(1).collect::<Vec<_>>();
210
if a.len() < 2 {
211
return Err("ROOT OUTDIR [WIDTH HEIGHT FPS]".into());
212
}
213
let root = PathBuf::from(&a[0]);
214
let out = PathBuf::from(&a[1]);
215
std::fs::create_dir_all(&out)?;
216
let width: u32 = a.get(2).map(|v| v.parse()).transpose()?.unwrap_or(32);
217
let height: u32 = a.get(3).map(|v| v.parse()).transpose()?.unwrap_or(48);
218
let fps: u32 = a.get(4).map(|v| v.parse()).transpose()?.unwrap_or(4);
219
let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
220
let mut s = Session::new();
221
s.install_new_project(&root)?;
222
for (sex, file) in [
223
(Sex::Male, "tests/fixtures/bodies/test-body-male.fbx"),
224
(Sex::Female, "tests/fixtures/bodies/test-body-female.glb"),
225
] {
226
let path = adopt_file(&root, &repo.join(file), "assets/body")?;
227
s.set_body_path(sex, path);
228
}
229
s.set_render(width, height, fps);
230
let body = preview::load_body_file(&root.join(s.body_path(Sex::Male).unwrap()))?;
231
let mut preset = equipment::Preset::new("fbxsword".into());
232
preset.provenance = "CC0-1.0 generated fixture (Blender FBX, embedded PNG)".into();
233
equipment::import_model(
234
&mut preset,
235
&repo.join("tests/fixtures/equipment/sword-fbx.fbx"),
236
)?;
237
let joint = body
238
.joint_names
239
.iter()
240
.position(|n| n == "RightHand")
241
.ok_or("RightHand ontbreekt")?;
242
preset.bind(0, &body, 0, joint)?;
243
preset.applies = [true, false];
244
preset.attachments[0].rotation_deg = [0., 0., -15.];
245
let i = s.add_equipment();
246
s.equipment[i].slug = "fbxsword".into();
247
s.equipment[i].preset = Some(preset);
248
s.save()?;
249
println!(
250
"PLAN male FBX: {:?}",
251
paperdoll_renderer::production::plan(&body, fps)?
252
);
253
let (root2, out2) = (root.clone(), out.clone());
254
eframe::run_native(
255
"Paperdoll FBX Live Fixture",
256
eframe::NativeOptions {
257
viewport: eframe::egui::ViewportBuilder::default().with_inner_size([1150., 820.]),
258
..paperdoll_renderer::preview_gl::native_options()
259
},
260
Box::new(move |_| {
261
let mut editor = EditorApp::default();
262
editor.open_path(root2.join("character.ppd"));
263
editor.open_body_configuration(Sex::Male);
264
Ok(Box::new(Fixture {
265
editor,
266
out: out2,
267
phase: 0,
268
waited: 0,
269
shot_requested: false,
270
results: Vec::new(),
271
last: 0,
272
}))
273
}),
274
)?;
275
let gui: serde_json::Value =
276
serde_json::from_slice(&std::fs::read(out.join("gui-result.json"))?)?;
277
let runs = verify(&root, width, height)?;
278
let report = serde_json::json!({"gui": gui, "runs": runs});
279
std::fs::write(
280
out.join("fbx-live-proof.json"),
281
serde_json::to_vec_pretty(&report)?,
282
)?;
283
println!("REPORT {report}");
284
let ok = gui
285
.as_array()
286
.is_some_and(|g| g.len() == 2 && g.iter().all(|r| r["success"] == true))
287
&& runs.as_array().is_some_and(|r| r.len() == 2);
288
if !ok {
289
return Err("FBX-proof mislukt; zie fbx-live-proof.json".into());
290
}
291
Ok(())
292
}
@@ -0,0 +1,292 @@1
//! Real native GPU proof for FBX bodies and rigid FBX equipment (no input automation).2
//! Usage: xvfb-run -a cargo run --example fbx_live_fixture -- ROOT OUTDIR [WIDTH HEIGHT FPS]3
//! Builds a project with the FBX male body and an FBX sword bound to its RightHand,4
//! screenshots the live viewport (body, then equipment), runs the production batch5
//! for the body set and the equipment set, and verifies both runs with `export_set`.6
use eframe::App;7
use paperdoll_renderer::{8
editor::EditorApp,9
equipment, export_set, preview,10
session::{adopt_file, Session, Sex},11
};12
use std::path::{Path, PathBuf};13
14
const SETTLE_FRAMES: u64 = 40;15
16
struct Fixture {17
editor: EditorApp,18
out: PathBuf,19
phase: u8,20
waited: u64,21
shot_requested: bool,22
results: Vec<serde_json::Value>,23
last: u32,24
}25
26
fn save_png(path: &Path, w: usize, h: usize, rgba: &[u8]) {27
let file = std::fs::File::create(path).unwrap();28
let mut enc = png::Encoder::new(std::io::BufWriter::new(file), w as u32, h as u32);29
enc.set_color(png::ColorType::Rgba);30
enc.set_depth(png::BitDepth::Eight);31
enc.write_header().unwrap().write_image_data(rgba).unwrap();32
}33
34
impl Fixture {35
/// Returns true once the screenshot was written.36
fn screenshot(&mut self, ctx: &eframe::egui::Context, name: &str) -> bool {37
self.waited += 1;38
if self.waited < SETTLE_FRAMES {39
return false;40
}41
if !self.shot_requested {42
self.shot_requested = true;43
ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Screenshot);44
return false;45
}46
let shot = ctx.input(|i| {47
i.raw.events.iter().find_map(|e| match e {48
eframe::egui::Event::Screenshot { image, .. } => Some(image.clone()),49
_ => None,50
})51
});52
let Some(image) = shot else { return false };53
let [w, h] = image.size;54
let rgba: Vec<u8> = image.pixels.iter().flat_map(|p| p.to_array()).collect();55
let path = self.out.join(name);56
save_png(&path, w, h, &rgba);57
println!("SCREENSHOT {}", path.display());58
self.waited = 0;59
self.shot_requested = false;60
true61
}62
fn render_done(&mut self, label: &str) -> bool {63
let (completed, total, pass) = self.editor.render_progress();64
if completed != self.last {65
if completed % 50 == 0 || completed == total {66
println!("PASS {label} {completed}/{total}: {pass}");67
}68
self.last = completed;69
}70
if self.editor.render_is_running() {71
return false;72
}73
let error = self.editor.render_error().map(str::to_string);74
let success = error.is_none() && total > 0 && completed == total;75
self.results.push(serde_json::json!({76
"set": label, "success": success, "completed": completed,77
"total": total, "last_pass": pass, "error": error,78
}));79
self.last = 0;80
true81
}82
}83
84
impl App for Fixture {85
fn update(&mut self, ctx: &eframe::egui::Context, frame: &mut eframe::Frame) {86
self.editor.update(ctx, frame);87
ctx.request_repaint();88
match self.phase {89
0 => {90
if self.screenshot(ctx, "live-fbx-body.png") {91
self.editor.open_equipment_configuration(0, Sex::Male);92
self.phase = 1;93
}94
}95
1 => {96
if self.screenshot(ctx, "live-fbx-equipment.png") {97
self.editor.open_body_configuration(Sex::Male);98
self.editor.render_all_angles();99
self.phase = 2;100
}101
}102
2 => {103
if self.render_done("body") {104
self.editor.open_equipment_configuration(0, Sex::Male);105
self.editor.render_all_angles();106
self.phase = 3;107
}108
}109
3 => {110
if self.render_done("fbxsword") {111
self.phase = 4;112
}113
}114
4 => {115
let written = self.screenshot(ctx, "production-fbx-equipment-ui.png");116
if written {117
self.phase = 5;118
ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Close);119
}120
}121
_ => {}122
}123
}124
fn on_exit(&mut self, gl: Option<&glow::Context>) {125
let value = serde_json::Value::Array(self.results.clone());126
std::fs::write(127
self.out.join("gui-result.json"),128
serde_json::to_vec_pretty(&value).unwrap(),129
)130
.unwrap();131
self.editor.on_exit(gl);132
}133
}134
135
fn verify(root: &Path, width: u32, height: u32) -> Result<serde_json::Value, String> {136
let mut runs = Vec::new();137
let mut dirs = std::fs::read_dir(root.join("renders"))138
.map_err(|e| e.to_string())?139
.map(|e| e.map(|e| e.path()).map_err(|e| e.to_string()))140
.collect::<Result<Vec<_>, _>>()?;141
dirs.sort();142
for dir in dirs.into_iter().filter(|d| d.is_dir()) {143
let run =144
export_set::ExportRun::open(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;145
let mut counts = std::collections::BTreeMap::<String, u32>::new();146
let mut opaque = 0u64;147
let mut textured = 0u64;148
let mut stack = vec![dir.clone()];149
while let Some(d) = stack.pop() {150
for entry in std::fs::read_dir(&d).map_err(|e| e.to_string())? {151
let path = entry.map_err(|e| e.to_string())?.path();152
if path.is_dir() {153
stack.push(path);154
continue;155
}156
let name = path.file_name().unwrap().to_string_lossy().to_string();157
if !name.ends_with(".png") {158
continue;159
}160
let map = name161
.rsplit_once('_')162
.map(|(_, m)| m.trim_end_matches(".png").to_string())163
.unwrap_or_default();164
if !["diffuse", "normal", "depth", "specular"].contains(&map.as_str()) {165
return Err(format!("onverwachte map {name}"));166
}167
*counts.entry(map.clone()).or_default() += 1;168
let decoder = png::Decoder::new(std::io::BufReader::new(169
std::fs::File::open(&path).map_err(|e| e.to_string())?,170
));171
let mut reader = decoder.read_info().map_err(|e| e.to_string())?;172
let (w, h) = (reader.info().width, reader.info().height);173
if (w, h) != (width, height) {174
return Err(format!("{name}: {w}x{h} ≠ doel {width}x{height}"));175
}176
if map == "diffuse" {177
let mut buf = vec![0; reader.output_buffer_size().unwrap()];178
let info = reader.next_frame(&mut buf).map_err(|e| e.to_string())?;179
if info.color_type == png::ColorType::Rgba {180
for px in buf[..info.buffer_size()].chunks_exact(4) {181
if px[3] > 0 {182
opaque += 1;183
// Brass guard albedo (0.75,0.55,0.2) or checker blade: colourful.184
if px[0] as i32 - px[2] as i32 > 60 {185
textured += 1;186
}187
}188
}189
}190
}191
}192
}193
runs.push(serde_json::json!({194
"dir": dir.display().to_string(),195
"png_files": run.complete.png_files,196
"maps": counts,197
"diffuse_opaque_pixels": opaque,198
"diffuse_warm_pixels": textured,199
"body_model": run.render.body_model.path.clone(),200
"set": run.render.set.clone(),201
"clips": run.render.clips.len(),202
"images": run.render.images,203
}));204
}205
Ok(serde_json::Value::Array(runs))206
}207
208
fn main() -> Result<(), Box<dyn std::error::Error>> {209
let a = std::env::args().skip(1).collect::<Vec<_>>();210
if a.len() < 2 {211
return Err("ROOT OUTDIR [WIDTH HEIGHT FPS]".into());212
}213
let root = PathBuf::from(&a[0]);214
let out = PathBuf::from(&a[1]);215
std::fs::create_dir_all(&out)?;216
let width: u32 = a.get(2).map(|v| v.parse()).transpose()?.unwrap_or(32);217
let height: u32 = a.get(3).map(|v| v.parse()).transpose()?.unwrap_or(48);218
let fps: u32 = a.get(4).map(|v| v.parse()).transpose()?.unwrap_or(4);219
let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR"));220
let mut s = Session::new();221
s.install_new_project(&root)?;222
for (sex, file) in [223
(Sex::Male, "tests/fixtures/bodies/test-body-male.fbx"),224
(Sex::Female, "tests/fixtures/bodies/test-body-female.glb"),225
] {226
let path = adopt_file(&root, &repo.join(file), "assets/body")?;227
s.set_body_path(sex, path);228
}229
s.set_render(width, height, fps);230
let body = preview::load_body_file(&root.join(s.body_path(Sex::Male).unwrap()))?;231
let mut preset = equipment::Preset::new("fbxsword".into());232
preset.provenance = "CC0-1.0 generated fixture (Blender FBX, embedded PNG)".into();233
equipment::import_model(234
&mut preset,235
&repo.join("tests/fixtures/equipment/sword-fbx.fbx"),236
)?;237
let joint = body238
.joint_names239
.iter()240
.position(|n| n == "RightHand")241
.ok_or("RightHand ontbreekt")?;242
preset.bind(0, &body, 0, joint)?;243
preset.applies = [true, false];244
preset.attachments[0].rotation_deg = [0., 0., -15.];245
let i = s.add_equipment();246
s.equipment[i].slug = "fbxsword".into();247
s.equipment[i].preset = Some(preset);248
s.save()?;249
println!(250
"PLAN male FBX: {:?}",251
paperdoll_renderer::production::plan(&body, fps)?252
);253
let (root2, out2) = (root.clone(), out.clone());254
eframe::run_native(255
"Paperdoll FBX Live Fixture",256
eframe::NativeOptions {257
viewport: eframe::egui::ViewportBuilder::default().with_inner_size([1150., 820.]),258
..paperdoll_renderer::preview_gl::native_options()259
},260
Box::new(move |_| {261
let mut editor = EditorApp::default();262
editor.open_path(root2.join("character.ppd"));263
editor.open_body_configuration(Sex::Male);264
Ok(Box::new(Fixture {265
editor,266
out: out2,267
phase: 0,268
waited: 0,269
shot_requested: false,270
results: Vec::new(),271
last: 0,272
}))273
}),274
)?;275
let gui: serde_json::Value =276
serde_json::from_slice(&std::fs::read(out.join("gui-result.json"))?)?;277
let runs = verify(&root, width, height)?;278
let report = serde_json::json!({"gui": gui, "runs": runs});279
std::fs::write(280
out.join("fbx-live-proof.json"),281
serde_json::to_vec_pretty(&report)?,282
)?;283
println!("REPORT {report}");284
let ok = gui285
.as_array()286
.is_some_and(|g| g.len() == 2 && g.iter().all(|r| r["success"] == true))287
&& runs.as_array().is_some_and(|r| r.len() == 2);288
if !ok {289
return Err("FBX-proof mislukt; zie fbx-live-proof.json".into());290
}291
Ok(())292
}@@ -1,5 +1,5 @@
1
1
//! Actual native end-to-end batch smoke; no GUI input automation or preview delays.
2
//! Usage: production_fixture ROOT MALE.glb FEMALE.glb male|female WIDTH HEIGHT FPS [equipment] [--height-milli=N]
2
//! Usage: production_fixture ROOT MALE.(glb|fbx) FEMALE.(glb|fbx) male|female WIDTH HEIGHT FPS [equipment] [--height-milli=N]
3
3
use eframe::App;
4
4
use paperdoll_renderer::{
5
5
editor::EditorApp,
@@ -74,7 +74,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
74
74
equipment::import_obj(&mut p, &assets.join("sword.obj"))?;
75
75
equipment::import_shiny(&mut p, 0, &assets.join("shiny.png"))?;
76
76
for (i, sex) in [(0, Sex::Male), (1, Sex::Female)] {
77
let b = preview::load_gltf_file(&root.join(s.body_path(sex).unwrap()))?;
77
let b = preview::load_body_file(&root.join(s.body_path(sex).unwrap()))?;
78
78
let joint = b
79
79
.joint_names
80
80
.iter()
@@ -88,7 +88,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
88
88
}
89
89
s.save()?;
90
90
for sex in [Sex::Male, Sex::Female] {
91
let b = preview::load_gltf_file(&root.join(s.body_path(sex).unwrap()))?;
91
let b = preview::load_body_file(&root.join(s.body_path(sex).unwrap()))?;
92
92
println!(
93
93
"PLAN {sex:?}: {:?}",
94
94
paperdoll_renderer::production::plan(&b, a[6].parse()?)?
@@ -1,5 +1,5 @@1
//! Actual native end-to-end batch smoke; no GUI input automation or preview delays.1
//! Actual native end-to-end batch smoke; no GUI input automation or preview delays.2
//! Usage: production_fixture ROOT MALE.glb FEMALE.glb male|female WIDTH HEIGHT FPS [equipment] [--height-milli=N]2
//! Usage: production_fixture ROOT MALE.(glb|fbx) FEMALE.(glb|fbx) male|female WIDTH HEIGHT FPS [equipment] [--height-milli=N]3
use eframe::App;3
use eframe::App;4
use paperdoll_renderer::{4
use paperdoll_renderer::{5
editor::EditorApp,5
editor::EditorApp,@@ -74,7 +74,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {74
equipment::import_obj(&mut p, &assets.join("sword.obj"))?;74
equipment::import_obj(&mut p, &assets.join("sword.obj"))?;75
equipment::import_shiny(&mut p, 0, &assets.join("shiny.png"))?;75
equipment::import_shiny(&mut p, 0, &assets.join("shiny.png"))?;76
for (i, sex) in [(0, Sex::Male), (1, Sex::Female)] {76
for (i, sex) in [(0, Sex::Male), (1, Sex::Female)] {77
let b = preview::load_gltf_file(&root.join(s.body_path(sex).unwrap()))?;77
let b = preview::load_body_file(&root.join(s.body_path(sex).unwrap()))?;78
let joint = b78
let joint = b79
.joint_names79
.joint_names80
.iter()80
.iter()@@ -88,7 +88,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {88
}88
}89
s.save()?;89
s.save()?;90
for sex in [Sex::Male, Sex::Female] {90
for sex in [Sex::Male, Sex::Female] {91
let b = preview::load_gltf_file(&root.join(s.body_path(sex).unwrap()))?;91
let b = preview::load_body_file(&root.join(s.body_path(sex).unwrap()))?;92
println!(92
println!(93
"PLAN {sex:?}: {:?}",93
"PLAN {sex:?}: {:?}",94
paperdoll_renderer::production::plan(&b, a[6].parse()?)?94
paperdoll_renderer::production::plan(&b, a[6].parse()?)?@@ -0,0 +1,98 @@
1
#!/usr/bin/env python3
2
"""Generate the CC0 rigid FBX equipment fixture with Blender.
3
4
Writes tests/fixtures/equipment/sword-fbx.fbx: a blade with the CC0 checker
5
texture embedded in the FBX and a plain-colour guard, and
6
sword-fbx-external.fbx: the same model referencing checker.png next to it
7
instead of embedding it. The files are exported
8
Z-up in centimetres so the importer has to perform the axis/unit
9
normalisation itself. Re-run when needed:
10
11
blender --background --factory-startup --python scripts/make_fbx_equipment_fixture.py
12
13
Blender is only a fixture tool; the application never calls Blender.
14
"""
15
16
from pathlib import Path
17
18
import bpy
19
20
ROOT = Path(__file__).resolve().parents[1]
21
FIXTURES = ROOT / "tests" / "fixtures" / "equipment"
22
TARGET = FIXTURES / "sword-fbx.fbx"
23
EXTERNAL = FIXTURES / "sword-fbx-external.fbx"
24
25
26
def box(name, x0, x1, y0, y1, z0, z1, material):
27
verts = [
28
(x0, y0, z0), (x1, y0, z0), (x1, y1, z0), (x0, y1, z0),
29
(x0, y0, z1), (x1, y0, z1), (x1, y1, z1), (x0, y1, z1),
30
]
31
faces = [
32
(0, 3, 2, 1), (4, 5, 6, 7), (0, 1, 5, 4),
33
(1, 2, 6, 5), (2, 3, 7, 6), (3, 0, 4, 7),
34
]
35
mesh = bpy.data.meshes.new(name)
36
mesh.from_pydata(verts, [], faces)
37
uv = mesh.uv_layers.new(name="UVMap")
38
corners = [(0, 0), (1, 0), (1, 1), (0, 1)]
39
for poly in mesh.polygons:
40
for k, loop in enumerate(poly.loop_indices):
41
uv.data[loop].uv = corners[k]
42
mesh.materials.append(material)
43
mesh.update()
44
obj = bpy.data.objects.new(name, mesh)
45
bpy.context.scene.collection.objects.link(obj)
46
return obj
47
48
49
def main() -> None:
50
bpy.ops.wm.read_factory_settings(use_empty=True)
51
scene = bpy.context.scene
52
scene.unit_settings.system = "METRIC"
53
scene.unit_settings.scale_length = 1.0
54
55
image = bpy.data.images.load(str(FIXTURES / "checker.png"))
56
image.pack()
57
blade_mat = bpy.data.materials.new("blade_checker")
58
blade_mat.use_nodes = True
59
bsdf = blade_mat.node_tree.nodes["Principled BSDF"]
60
tex = blade_mat.node_tree.nodes.new("ShaderNodeTexImage")
61
tex.image = image
62
blade_mat.node_tree.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
63
64
guard_mat = bpy.data.materials.new("guard_brass")
65
guard_mat.use_nodes = True
66
guard_mat.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = (
67
0.75, 0.55, 0.2, 1.0,
68
)
69
70
# Blender space: Z up. Blade 0.58 m long, guard at the bottom.
71
box("blade", -0.03, 0.03, -0.01, 0.01, 0.03, 0.58, blade_mat)
72
box("guard", -0.09, 0.09, -0.015, 0.015, 0.0, 0.03, guard_mat)
73
74
common = dict(
75
use_selection=False,
76
object_types={"MESH"},
77
axis_forward="Y",
78
axis_up="Z",
79
global_scale=1.0,
80
apply_unit_scale=True,
81
apply_scale_options="FBX_SCALE_NONE",
82
bake_anim=False,
83
add_leaf_bones=False,
84
)
85
bpy.ops.export_scene.fbx(
86
filepath=str(TARGET), path_mode="COPY", embed_textures=True, **common
87
)
88
image.unpack(method="REMOVE")
89
image.filepath = str(FIXTURES / "checker.png")
90
bpy.ops.export_scene.fbx(
91
filepath=str(EXTERNAL), path_mode="RELATIVE", embed_textures=False, **common
92
)
93
print("wrote", EXTERNAL)
94
print("wrote", TARGET)
95
96
97
if __name__ == "__main__":
98
main()
@@ -0,0 +1,98 @@1
#!/usr/bin/env python32
"""Generate the CC0 rigid FBX equipment fixture with Blender.3
4
Writes tests/fixtures/equipment/sword-fbx.fbx: a blade with the CC0 checker5
texture embedded in the FBX and a plain-colour guard, and6
sword-fbx-external.fbx: the same model referencing checker.png next to it7
instead of embedding it. The files are exported8
Z-up in centimetres so the importer has to perform the axis/unit9
normalisation itself. Re-run when needed:10
11
blender --background --factory-startup --python scripts/make_fbx_equipment_fixture.py12
13
Blender is only a fixture tool; the application never calls Blender.14
"""15
16
from pathlib import Path17
18
import bpy19
20
ROOT = Path(__file__).resolve().parents[1]21
FIXTURES = ROOT / "tests" / "fixtures" / "equipment"22
TARGET = FIXTURES / "sword-fbx.fbx"23
EXTERNAL = FIXTURES / "sword-fbx-external.fbx"24
25
26
def box(name, x0, x1, y0, y1, z0, z1, material):27
verts = [28
(x0, y0, z0), (x1, y0, z0), (x1, y1, z0), (x0, y1, z0),29
(x0, y0, z1), (x1, y0, z1), (x1, y1, z1), (x0, y1, z1),30
]31
faces = [32
(0, 3, 2, 1), (4, 5, 6, 7), (0, 1, 5, 4),33
(1, 2, 6, 5), (2, 3, 7, 6), (3, 0, 4, 7),34
]35
mesh = bpy.data.meshes.new(name)36
mesh.from_pydata(verts, [], faces)37
uv = mesh.uv_layers.new(name="UVMap")38
corners = [(0, 0), (1, 0), (1, 1), (0, 1)]39
for poly in mesh.polygons:40
for k, loop in enumerate(poly.loop_indices):41
uv.data[loop].uv = corners[k]42
mesh.materials.append(material)43
mesh.update()44
obj = bpy.data.objects.new(name, mesh)45
bpy.context.scene.collection.objects.link(obj)46
return obj47
48
49
def main() -> None:50
bpy.ops.wm.read_factory_settings(use_empty=True)51
scene = bpy.context.scene52
scene.unit_settings.system = "METRIC"53
scene.unit_settings.scale_length = 1.054
55
image = bpy.data.images.load(str(FIXTURES / "checker.png"))56
image.pack()57
blade_mat = bpy.data.materials.new("blade_checker")58
blade_mat.use_nodes = True59
bsdf = blade_mat.node_tree.nodes["Principled BSDF"]60
tex = blade_mat.node_tree.nodes.new("ShaderNodeTexImage")61
tex.image = image62
blade_mat.node_tree.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])63
64
guard_mat = bpy.data.materials.new("guard_brass")65
guard_mat.use_nodes = True66
guard_mat.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = (67
0.75, 0.55, 0.2, 1.0,68
)69
70
# Blender space: Z up. Blade 0.58 m long, guard at the bottom.71
box("blade", -0.03, 0.03, -0.01, 0.01, 0.03, 0.58, blade_mat)72
box("guard", -0.09, 0.09, -0.015, 0.015, 0.0, 0.03, guard_mat)73
74
common = dict(75
use_selection=False,76
object_types={"MESH"},77
axis_forward="Y",78
axis_up="Z",79
global_scale=1.0,80
apply_unit_scale=True,81
apply_scale_options="FBX_SCALE_NONE",82
bake_anim=False,83
add_leaf_bones=False,84
)85
bpy.ops.export_scene.fbx(86
filepath=str(TARGET), path_mode="COPY", embed_textures=True, **common87
)88
image.unpack(method="REMOVE")89
image.filepath = str(FIXTURES / "checker.png")90
bpy.ops.export_scene.fbx(91
filepath=str(EXTERNAL), path_mode="RELATIVE", embed_textures=False, **common92
)93
print("wrote", EXTERNAL)94
print("wrote", TARGET)95
96
97
if __name__ == "__main__":98
main()@@ -130,7 +130,9 @@ fn native_pick(purpose: &DialogPurpose) -> Option<PathBuf> {
130
130
.pick_file(),
131
131
DialogPurpose::EquipmentModel(_) => dialog
132
132
.set_title("Rigide equipment toevoegen")
133
.add_filter("Rigide model", &["obj", "fbx"])
133
134
.add_filter("Wavefront OBJ", &["obj"])
135
.add_filter("FBX", &["fbx"])
134
136
.pick_file(),
135
137
DialogPurpose::EquipmentShiny(_, _) => dialog
136
138
.set_title("Shinymap")
@@ -130,7 +130,9 @@ fn native_pick(purpose: &DialogPurpose) -> Option<PathBuf> {130
.pick_file(),130
.pick_file(),131
DialogPurpose::EquipmentModel(_) => dialog131
DialogPurpose::EquipmentModel(_) => dialog132
.set_title("Rigide equipment toevoegen")132
.set_title("Rigide equipment toevoegen")133
.add_filter("Rigide model", &["obj", "fbx"])133
.add_filter("Wavefront OBJ", &["obj"])134
.add_filter("Wavefront OBJ", &["obj"])135
.add_filter("FBX", &["fbx"])134
.pick_file(),136
.pick_file(),135
DialogPurpose::EquipmentShiny(_, _) => dialog137
DialogPurpose::EquipmentShiny(_, _) => dialog136
.set_title("Shinymap")138
.set_title("Shinymap")@@ -7,7 +7,9 @@ use eframe::egui;
7
7
use crate::compositor::MAX_RIM_INTENSITY;
8
8
use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};
9
9
use crate::import::{import_project_model, ImportedModel};
10
use crate::preview::{load_gltf_file, PreviewCharacter, PreviewControls, PreviewSex};
10
use crate::preview::{
11
is_drawable_body, load_body_file, PreviewCharacter, PreviewControls, PreviewSex,
12
};
11
13
use crate::preview2d::{
12
14
compatible_equipment_sets, compose_preview_frame, discover_completed_runs, open_selection,
13
15
sync_state_from_catalog, CatalogEntry, ComposeRequest, HoldSign, LightAxis, Preview2dState,
@@ -159,15 +161,8 @@ impl EditorApp {
159
161
let imported =
160
162
import_project_model(&root, relative).map_err(|error| error.to_string());
161
163
// Validate the source and its dependencies before the viewport reads it.
162
if imported.is_ok()
163
&& matches!(
164
std::path::Path::new(relative)
165
.extension()
166
.and_then(|e| e.to_str()),
167
Some("gltf" | "glb")
168
)
169
{
170
match load_gltf_file(&root.join(relative)) {
164
if imported.is_ok() && is_drawable_body(std::path::Path::new(relative)) {
165
match load_body_file(&root.join(relative)) {
171
166
Ok(character) => match sex {
172
167
Sex::Male => self.preview_male = Some(character),
173
168
Sex::Female => self.preview_female = Some(character),
@@ -300,7 +295,7 @@ impl EditorApp {
300
295
.clone()
301
296
.unwrap_or_else(|| crate::equipment::Preset::new(item.slug.clone()));
302
297
preset.name = item.slug.clone();
303
match crate::equipment::import_obj(&mut preset, path) {
298
match crate::equipment::import_model(&mut preset, path) {
304
299
Ok(part) => {
305
300
if let Some(body) = body {
306
301
if !body.joint_nodes.is_empty() {
@@ -415,7 +410,8 @@ impl EditorApp {
415
410
return None;
416
411
}
417
412
let Some(character) = self.character().cloned() else {
418
self.session.fail("dit lichaam heeft geen tekenbaar glTF");
413
self.session
414
.fail("dit lichaam heeft geen tekenbaar glTF/GLB- of FBX-model");
419
415
return None;
420
416
};
421
417
let root = self.session.project_root()?;
@@ -741,7 +737,7 @@ impl EditorApp {
741
737
if let Some(resolved) = &diagnostic {
742
738
ui.label(resolved.description());
743
739
} else {
744
ui.label("Laad een glTF/GLB-lichaam om de nekpivot te bepalen.");
740
ui.label("Laad een glTF/GLB- of FBX-lichaam om de nekpivot te bepalen.");
745
741
}
746
742
}
747
743
}
@@ -1016,7 +1012,7 @@ impl EditorApp {
1016
1012
self.viewport
1017
1013
.show_framed(ui, &mut self.controls, character, &[], framing);
1018
1014
} else {
1019
ui.label("Open een glTF/GLB-lichaam voor de live 3D-weergave.");
1015
ui.label("Open een glTF/GLB- of FBX-lichaam voor de live 3D-weergave.");
1020
1016
}
1021
1017
}
1022
1018
});
@@ -1157,7 +1153,7 @@ impl EditorApp {
1157
1153
if let Some(error) = &item.error {
1158
1154
ui.colored_label(egui::Color32::LIGHT_RED, error);
1159
1155
}
1160
if ui.button("Onderdeel toevoegen (OBJ)").clicked() {
1156
if ui.button("Onderdeel toevoegen (OBJ/FBX)").clicked() {
1161
1157
self.dialogs.start(DialogPurpose::EquipmentModel(index));
1162
1158
}
1163
1159
if let Some(preset) = &mut item.preset {
@@ -1273,7 +1269,7 @@ impl EditorApp {
1273
1269
} else {
1274
1270
ui.colored_label(
1275
1271
egui::Color32::LIGHT_RED,
1276
"Open een geskind glTF/GLB-lichaam voor deze rigbinding.",
1272
"Open een geskind glTF/GLB- of FBX-lichaam voor deze rigbinding.",
1277
1273
);
1278
1274
}
1279
1275
if ui.button("Verwijder onderdeel").clicked() {
@@ -1889,6 +1885,44 @@ mod tests {
1889
1885
let _ = std::fs::remove_dir_all(root);
1890
1886
}
1891
1887
1888
#[test]
1889
fn fbx_body_and_fbx_equipment_reach_live_view_and_render_job() {
1890
let root = root();
1891
let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
1892
let mut app = EditorApp::default();
1893
app.session.install_new_project(&root).unwrap();
1894
let relative = crate::session::adopt_file(
1895
&root,
1896
&fixtures.join("bodies/test-body-male.fbx"),
1897
"assets/body",
1898
)
1899
.unwrap();
1900
app.session.set_body_path(Sex::Male, relative);
1901
app.session.save().unwrap();
1902
app.open_path(root.join("character.ppd"));
1903
let male = app.preview_male.clone().expect("FBX-lichaam in live view");
1904
assert_eq!(male.joint_names.len(), 19);
1905
assert!(male.clips.iter().any(|c| c.name.contains("walk")));
1906
assert!(matches!(&app.inventories[0], Some(Ok(_))));
1907
// Rigid FBX equipment through the same picker path, auto-bound to the FBX rig.
1908
let index = app.session.add_equipment();
1909
app.session.equipment[index].slug = "fbxsword".into();
1910
app.session.active_body = Some(Sex::Male);
1911
app.take_equipment(index, &fixtures.join("equipment/sword-fbx.fbx"));
1912
let preset = app.session.equipment[index].preset.clone().expect("preset");
1913
assert_eq!(preset.attachments[0].label, "sword-fbx.fbx");
1914
assert!(preset.attachments[0].bones[0].is_some());
1915
assert_eq!(
1916
preset.rigs[0].as_ref(),
1917
Some(&crate::equipment::RigSignature::of(&male))
1918
);
1919
app.open_body_configuration(Sex::Male);
1920
let job = app.build_job().expect("render job for FBX body");
1921
assert_eq!(job.body_model.path, "assets/body/test-body-male.fbx");
1922
assert_eq!(job.character.positions, male.positions);
1923
let _ = std::fs::remove_dir_all(root);
1924
}
1925
1892
1926
#[test]
1893
1927
fn open_with_project_path_loads_bodies_and_models() {
1894
1928
let root = root().join("project with spaces");
@@ -7,7 +7,9 @@ use eframe::egui;7
use crate::compositor::MAX_RIM_INTENSITY;7
use crate::compositor::MAX_RIM_INTENSITY;8
use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};8
use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};9
use crate::import::{import_project_model, ImportedModel};9
use crate::import::{import_project_model, ImportedModel};10
use crate::preview::{load_gltf_file, PreviewCharacter, PreviewControls, PreviewSex};10
use crate::preview::{11
is_drawable_body, load_body_file, PreviewCharacter, PreviewControls, PreviewSex,12
};11
use crate::preview2d::{13
use crate::preview2d::{12
compatible_equipment_sets, compose_preview_frame, discover_completed_runs, open_selection,14
compatible_equipment_sets, compose_preview_frame, discover_completed_runs, open_selection,13
sync_state_from_catalog, CatalogEntry, ComposeRequest, HoldSign, LightAxis, Preview2dState,15
sync_state_from_catalog, CatalogEntry, ComposeRequest, HoldSign, LightAxis, Preview2dState,@@ -159,15 +161,8 @@ impl EditorApp {159
let imported =161
let imported =160
import_project_model(&root, relative).map_err(|error| error.to_string());162
import_project_model(&root, relative).map_err(|error| error.to_string());161
// Validate the source and its dependencies before the viewport reads it.163
// Validate the source and its dependencies before the viewport reads it.162
if imported.is_ok()164
if imported.is_ok() && is_drawable_body(std::path::Path::new(relative)) {163
&& matches!(165
match load_body_file(&root.join(relative)) {164
std::path::Path::new(relative)165
.extension()166
.and_then(|e| e.to_str()),167
Some("gltf" | "glb")168
)169
{170
match load_gltf_file(&root.join(relative)) {171
Ok(character) => match sex {166
Ok(character) => match sex {172
Sex::Male => self.preview_male = Some(character),167
Sex::Male => self.preview_male = Some(character),173
Sex::Female => self.preview_female = Some(character),168
Sex::Female => self.preview_female = Some(character),@@ -300,7 +295,7 @@ impl EditorApp {300
.clone()295
.clone()301
.unwrap_or_else(|| crate::equipment::Preset::new(item.slug.clone()));296
.unwrap_or_else(|| crate::equipment::Preset::new(item.slug.clone()));302
preset.name = item.slug.clone();297
preset.name = item.slug.clone();303
match crate::equipment::import_obj(&mut preset, path) {298
match crate::equipment::import_model(&mut preset, path) {304
Ok(part) => {299
Ok(part) => {305
if let Some(body) = body {300
if let Some(body) = body {306
if !body.joint_nodes.is_empty() {301
if !body.joint_nodes.is_empty() {@@ -415,7 +410,8 @@ impl EditorApp {415
return None;410
return None;416
}411
}417
let Some(character) = self.character().cloned() else {412
let Some(character) = self.character().cloned() else {418
self.session.fail("dit lichaam heeft geen tekenbaar glTF");413
self.session414
.fail("dit lichaam heeft geen tekenbaar glTF/GLB- of FBX-model");419
return None;415
return None;420
};416
};421
let root = self.session.project_root()?;417
let root = self.session.project_root()?;@@ -741,7 +737,7 @@ impl EditorApp {741
if let Some(resolved) = &diagnostic {737
if let Some(resolved) = &diagnostic {742
ui.label(resolved.description());738
ui.label(resolved.description());743
} else {739
} else {744
ui.label("Laad een glTF/GLB-lichaam om de nekpivot te bepalen.");740
ui.label("Laad een glTF/GLB- of FBX-lichaam om de nekpivot te bepalen.");745
}741
}746
}742
}747
}743
}@@ -1016,7 +1012,7 @@ impl EditorApp {1016
self.viewport1012
self.viewport1017
.show_framed(ui, &mut self.controls, character, &[], framing);1013
.show_framed(ui, &mut self.controls, character, &[], framing);1018
} else {1014
} else {1019
ui.label("Open een glTF/GLB-lichaam voor de live 3D-weergave.");1015
ui.label("Open een glTF/GLB- of FBX-lichaam voor de live 3D-weergave.");1020
}1016
}1021
}1017
}1022
});1018
});@@ -1157,7 +1153,7 @@ impl EditorApp {1157
if let Some(error) = &item.error {1153
if let Some(error) = &item.error {1158
ui.colored_label(egui::Color32::LIGHT_RED, error);1154
ui.colored_label(egui::Color32::LIGHT_RED, error);1159
}1155
}1160
if ui.button("Onderdeel toevoegen (OBJ)").clicked() {1156
if ui.button("Onderdeel toevoegen (OBJ/FBX)").clicked() {1161
self.dialogs.start(DialogPurpose::EquipmentModel(index));1157
self.dialogs.start(DialogPurpose::EquipmentModel(index));1162
}1158
}1163
if let Some(preset) = &mut item.preset {1159
if let Some(preset) = &mut item.preset {@@ -1273,7 +1269,7 @@ impl EditorApp {1273
} else {1269
} else {1274
ui.colored_label(1270
ui.colored_label(1275
egui::Color32::LIGHT_RED,1271
egui::Color32::LIGHT_RED,1276
"Open een geskind glTF/GLB-lichaam voor deze rigbinding.",1272
"Open een geskind glTF/GLB- of FBX-lichaam voor deze rigbinding.",1277
);1273
);1278
}1274
}1279
if ui.button("Verwijder onderdeel").clicked() {1275
if ui.button("Verwijder onderdeel").clicked() {@@ -1889,6 +1885,44 @@ mod tests {1889
let _ = std::fs::remove_dir_all(root);1885
let _ = std::fs::remove_dir_all(root);1890
}1886
}1891
1887
1888
#[test]1889
fn fbx_body_and_fbx_equipment_reach_live_view_and_render_job() {1890
let root = root();1891
let fixtures = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");1892
let mut app = EditorApp::default();1893
app.session.install_new_project(&root).unwrap();1894
let relative = crate::session::adopt_file(1895
&root,1896
&fixtures.join("bodies/test-body-male.fbx"),1897
"assets/body",1898
)1899
.unwrap();1900
app.session.set_body_path(Sex::Male, relative);1901
app.session.save().unwrap();1902
app.open_path(root.join("character.ppd"));1903
let male = app.preview_male.clone().expect("FBX-lichaam in live view");1904
assert_eq!(male.joint_names.len(), 19);1905
assert!(male.clips.iter().any(|c| c.name.contains("walk")));1906
assert!(matches!(&app.inventories[0], Some(Ok(_))));1907
// Rigid FBX equipment through the same picker path, auto-bound to the FBX rig.1908
let index = app.session.add_equipment();1909
app.session.equipment[index].slug = "fbxsword".into();1910
app.session.active_body = Some(Sex::Male);1911
app.take_equipment(index, &fixtures.join("equipment/sword-fbx.fbx"));1912
let preset = app.session.equipment[index].preset.clone().expect("preset");1913
assert_eq!(preset.attachments[0].label, "sword-fbx.fbx");1914
assert!(preset.attachments[0].bones[0].is_some());1915
assert_eq!(1916
preset.rigs[0].as_ref(),1917
Some(&crate::equipment::RigSignature::of(&male))1918
);1919
app.open_body_configuration(Sex::Male);1920
let job = app.build_job().expect("render job for FBX body");1921
assert_eq!(job.body_model.path, "assets/body/test-body-male.fbx");1922
assert_eq!(job.character.positions, male.positions);1923
let _ = std::fs::remove_dir_all(root);1924
}1925
1892
#[test]1926
#[test]1893
fn open_with_project_path_loads_bodies_and_models() {1927
fn open_with_project_path_loads_bodies_and_models() {1894
let root = root().join("project with spaces");1928
let root = root().join("project with spaces");@@ -737,6 +737,287 @@ pub fn import_obj(preset: &mut Preset, source: &Path) -> Result<usize, String> {
737
737
Ok(preset.attachments.len() - 1)
738
738
}
739
739
740
/// Import a rigid equipment model, choosing the reader by extension (OBJ+MTL or FBX).
741
pub fn import_model(preset: &mut Preset, source: &Path) -> Result<usize, String> {
742
match source
743
.extension()
744
.and_then(|x| x.to_str())
745
.map(|x| x.to_ascii_lowercase())
746
.as_deref()
747
{
748
Some("obj") => import_obj(preset, source),
749
Some("fbx") => import_fbx(preset, source),
750
_ => Err("kies een rigide OBJ- (met MTL/PNG) of FBX-model".into()),
751
}
752
}
753
754
/// Import a rigid (unskinned) FBX into the preset. ufbx parses only the
755
/// in-memory bytes (no external file access); axis/unit normalisation to
756
/// right-handed Y-up metres happens once, here, exactly like `fbx_scene`.
757
/// Embedded PNG textures are copied into the preset; external PNG textures
758
/// 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> {
760
if source
761
.extension()
762
.and_then(|x| x.to_str())
763
.map(|x| !x.eq_ignore_ascii_case("fbx"))
764
.unwrap_or(true)
765
{
766
return Err("kies een rigide FBX-model".into());
767
}
768
let root = source
769
.parent()
770
.ok_or("modelmap ontbreekt")?
771
.canonicalize()
772
.map_err(|e| e.to_string())?;
773
let name = source
774
.file_name()
775
.and_then(|x| x.to_str())
776
.ok_or("ongeldige modelnaam")?;
777
let mut candidate = preset.clone();
778
if candidate.attachments.len() >= 32 {
779
return Err("te veel onderdelen".into());
780
}
781
compact_assets(&mut candidate)?;
782
let prefix = unique_prefix(&candidate, "part");
783
let fbx_bytes = read_asset(&root, name)?;
784
let opts = ufbx::LoadOpts {
785
target_axes: ufbx::axes_right_handed_y_up(),
786
target_unit_meters: 1.0,
787
space_conversion: ufbx::SpaceConversion::ModifyGeometry,
788
geometry_transform_handling: ufbx::GeometryTransformHandling::ModifyGeometry,
789
generate_missing_normals: true,
790
normalize_normals: true,
791
load_external_files: false,
792
..Default::default()
793
};
794
let scene = ufbx::load_memory(&fbx_bytes, opts)
795
.map_err(|e| format!("FBX laden mislukt: {}", e.description))?;
796
if scene.metadata.geometry_ignored {
797
return Err("FBX heeft geometrie genegeerd; bestand is niet tekenbaar".into());
798
}
799
let mut total_bytes: usize = candidate.assets.iter().map(|a| a.bytes.len()).sum();
800
let mut push_asset =
801
|candidate: &mut Preset, path: String, bytes: Vec<u8>| -> Result<usize, String> {
802
total_bytes += bytes.len();
803
if total_bytes > MAX_FILE || candidate.assets.len() >= MAX_ASSETS {
804
return Err("assets samen te groot of te veel bijbestanden".into());
805
}
806
candidate.assets.push(EmbeddedAsset { path, bytes });
807
Ok(candidate.assets.len() - 1)
808
};
809
let source_index = push_asset(&mut candidate, format!("{prefix}/{name}"), fbx_bytes)?;
810
let mut source_assets = vec![source_index];
811
// Texture identity (element id) -> embedded asset index, so shared textures are stored once.
812
let mut texture_assets: std::collections::BTreeMap<u32, usize> = Default::default();
813
let mut vertices: usize = candidate
814
.attachments
815
.iter()
816
.flat_map(|a| &a.meshes)
817
.map(|m| m.positions.len())
818
.sum();
819
let mut meshes = Vec::new();
820
let mut tri = Vec::new();
821
for node in &scene.nodes {
822
let Some(mesh) = node.mesh.as_ref() else {
823
continue;
824
};
825
if mesh.num_triangles == 0 {
826
continue;
827
}
828
if !mesh.skin_deformers.is_empty() {
829
return Err(format!(
830
"FBX-mesh '{}' is geskinned; alleen rigide equipment wordt ondersteund",
831
node.element.name.trim()
832
));
833
}
834
if !mesh.blend_deformers.is_empty() {
835
return Err(format!(
836
"FBX-mesh '{}' heeft morph targets; die worden niet ondersteund",
837
node.element.name.trim()
838
));
839
}
840
let to_world = node.geometry_to_world;
841
let for_normals = ufbx::matrix_for_normals(&to_world);
842
let parts = mesh.material_parts.len().max(1);
843
for part in 0..parts {
844
let faces: Vec<u32> = if mesh.material_parts.is_empty() {
845
(0..mesh.faces.len() as u32).collect()
846
} else {
847
mesh.material_parts[part]
848
.face_indices
849
.iter()
850
.copied()
851
.collect()
852
};
853
if faces.is_empty() {
854
continue;
855
}
856
let material = if mesh.material_parts.is_empty() {
857
None
858
} else {
859
mesh.materials.get(part)
860
};
861
let mut positions = Vec::new();
862
let mut normals = Vec::new();
863
let mut uvs = Vec::new();
864
let mut indices = Vec::new();
865
for face in faces {
866
let face = mesh.faces[face as usize];
867
if face.num_indices < 3 {
868
continue;
869
}
870
let count = ufbx::triangulate_face_vec(&mut tri, mesh, face) as usize;
871
for t in 0..count {
872
let mut corners = [tri[t * 3], tri[t * 3 + 1], tri[t * 3 + 2]];
873
if mesh.reversed_winding {
874
corners.swap(1, 2);
875
}
876
for corner in corners {
877
let corner = corner as usize;
878
let p = ufbx::transform_position(&to_world, mesh.vertex_position[corner]);
879
let n = if mesh.vertex_normal.exists {
880
let n =
881
ufbx::transform_direction(&for_normals, mesh.vertex_normal[corner]);
882
let len = (n.x * n.x + n.y * n.y + n.z * n.z).sqrt();
883
if len > 1e-12 {
884
[(n.x / len) as f32, (n.y / len) as f32, (n.z / len) as f32]
885
} else {
886
[0., 1., 0.]
887
}
888
} else {
889
[0., 1., 0.]
890
};
891
let uv = if mesh.vertex_uv.exists {
892
let uv = mesh.vertex_uv[corner];
893
[uv.x as f32, uv.y as f32]
894
} else {
895
[0., 0.]
896
};
897
indices.push(positions.len() as u32);
898
positions.push([p.x as f32, p.y as f32, p.z as f32]);
899
normals.push(n);
900
uvs.push(uv);
901
}
902
}
903
}
904
if positions.is_empty() {
905
continue;
906
}
907
vertices += positions.len();
908
if vertices > MAX_VERTICES {
909
return Err("equipmentmesh is te groot".into());
910
}
911
if positions
912
.iter()
913
.chain(&normals)
914
.any(|v| v.iter().any(|x| !x.is_finite()))
915
{
916
return Err("FBX-mesh bevat niet-eindige posities of normalen".into());
917
}
918
if !mesh.vertex_normal.exists {
919
normals = compute_normals(&positions, &indices)?;
920
}
921
let (mut color, texture) = match material {
922
None => ([0.7, 0.7, 0.7, 1.0], None),
923
Some(m) => {
924
let map = if m.pbr.base_color.has_value {
925
&m.pbr.base_color
926
} else {
927
&m.fbx.diffuse_color
928
};
929
let v = map.value_vec4;
930
// Same alpha rule as the FBX body loader (`fbx_scene`).
931
let alpha = if v.w == 0.0 { 1.0 } else { v.w as f32 };
932
(
933
[v.x as f32, v.y as f32, v.z as f32, alpha],
934
map.texture
935
.as_ref()
936
.or(m.fbx.diffuse_color.texture.as_ref()),
937
)
938
}
939
};
940
if !color.iter().all(|c| c.is_finite()) {
941
return Err("FBX-materiaal heeft een niet-eindige kleur".into());
942
}
943
let albedo = match texture {
944
None => None,
945
Some(texture) => {
946
let id = texture.element.element_id;
947
if let Some(index) = texture_assets.get(&id) {
948
Some(*index)
949
} else {
950
let (path, bytes) = if texture.content.size > 0 {
951
let label = Path::new(texture.relative_filename.trim())
952
.file_name()
953
.and_then(|n| n.to_str())
954
.filter(|n| !n.is_empty())
955
.map(|n| n.replace(['\\', ':'], "_"))
956
.unwrap_or_else(|| format!("texture-{id}.png"));
957
(
958
format!("{prefix}/embedded/{id}-{label}"),
959
texture.content.to_vec(),
960
)
961
} else {
962
let relative = texture.relative_filename.trim().replace('\\', "/");
963
if relative.is_empty() {
964
return Err(
965
"FBX-texture heeft geen relatief pad en is niet ingebed".into(),
966
);
967
}
968
portable_relative(&relative)
969
.map_err(|e| format!("FBX-texturepad geweigerd: {e}"))?;
970
let bytes = read_asset(&root, &relative)
971
.map_err(|e| format!("FBX-texture '{relative}': {e}"))?;
972
(format!("{prefix}/{relative}"), bytes)
973
};
974
if bytes.len() > MAX_ASSET {
975
return Err("FBX-texture is te groot".into());
976
}
977
decode_png(&bytes).map_err(|e| {
978
format!("FBX-texture is geen bruikbare PNG (alleen PNG wordt ingesloten): {e}")
979
})?;
980
let index = push_asset(&mut candidate, path, bytes)?;
981
texture_assets.insert(id, index);
982
source_assets.push(index);
983
Some(index)
984
}
985
}
986
};
987
if albedo.is_some() {
988
// FBX semantics: a connected diffuse texture replaces the colour property.
989
color = [1., 1., 1., color[3]];
990
}
991
meshes.push(Mesh {
992
positions,
993
normals,
994
uvs,
995
indices,
996
color,
997
albedo,
998
});
999
}
1000
}
1001
if meshes.is_empty() {
1002
return Err("het FBX-bestand heeft geen tekenbare meshes".into());
1003
}
1004
source_assets.sort_unstable();
1005
source_assets.dedup();
1006
candidate.attachments.push(Attachment {
1007
label: name.to_string(),
1008
source_assets,
1009
bones: [None, None],
1010
translation: [0.; 3],
1011
rotation_deg: [0.; 3],
1012
meshes,
1013
shiny: None,
1014
specular: 0.25,
1015
});
1016
candidate.validate()?;
1017
*preset = candidate;
1018
Ok(preset.attachments.len() - 1)
1019
}
1020
740
1021
pub fn import_shiny(preset: &mut Preset, attachment: usize, source: &Path) -> Result<(), String> {
741
1022
let root = source.parent().ok_or("texturemap ontbreekt")?;
742
1023
let name = source
@@ -737,6 +737,287 @@ pub fn import_obj(preset: &mut Preset, source: &Path) -> Result<usize, String> {737
Ok(preset.attachments.len() - 1)737
Ok(preset.attachments.len() - 1)738
}738
}739
739
740
/// Import a rigid equipment model, choosing the reader by extension (OBJ+MTL or FBX).741
pub fn import_model(preset: &mut Preset, source: &Path) -> Result<usize, String> {742
match source743
.extension()744
.and_then(|x| x.to_str())745
.map(|x| x.to_ascii_lowercase())746
.as_deref()747
{748
Some("obj") => import_obj(preset, source),749
Some("fbx") => import_fbx(preset, source),750
_ => Err("kies een rigide OBJ- (met MTL/PNG) of FBX-model".into()),751
}752
}753
754
/// Import a rigid (unskinned) FBX into the preset. ufbx parses only the755
/// in-memory bytes (no external file access); axis/unit normalisation to756
/// right-handed Y-up metres happens once, here, exactly like `fbx_scene`.757
/// Embedded PNG textures are copied into the preset; external PNG textures758
/// 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> {760
if source761
.extension()762
.and_then(|x| x.to_str())763
.map(|x| !x.eq_ignore_ascii_case("fbx"))764
.unwrap_or(true)765
{766
return Err("kies een rigide FBX-model".into());767
}768
let root = source769
.parent()770
.ok_or("modelmap ontbreekt")?771
.canonicalize()772
.map_err(|e| e.to_string())?;773
let name = source774
.file_name()775
.and_then(|x| x.to_str())776
.ok_or("ongeldige modelnaam")?;777
let mut candidate = preset.clone();778
if candidate.attachments.len() >= 32 {779
return Err("te veel onderdelen".into());780
}781
compact_assets(&mut candidate)?;782
let prefix = unique_prefix(&candidate, "part");783
let fbx_bytes = read_asset(&root, name)?;784
let opts = ufbx::LoadOpts {785
target_axes: ufbx::axes_right_handed_y_up(),786
target_unit_meters: 1.0,787
space_conversion: ufbx::SpaceConversion::ModifyGeometry,788
geometry_transform_handling: ufbx::GeometryTransformHandling::ModifyGeometry,789
generate_missing_normals: true,790
normalize_normals: true,791
load_external_files: false,792
..Default::default()793
};794
let scene = ufbx::load_memory(&fbx_bytes, opts)795
.map_err(|e| format!("FBX laden mislukt: {}", e.description))?;796
if scene.metadata.geometry_ignored {797
return Err("FBX heeft geometrie genegeerd; bestand is niet tekenbaar".into());798
}799
let mut total_bytes: usize = candidate.assets.iter().map(|a| a.bytes.len()).sum();800
let mut push_asset =801
|candidate: &mut Preset, path: String, bytes: Vec<u8>| -> Result<usize, String> {802
total_bytes += bytes.len();803
if total_bytes > MAX_FILE || candidate.assets.len() >= MAX_ASSETS {804
return Err("assets samen te groot of te veel bijbestanden".into());805
}806
candidate.assets.push(EmbeddedAsset { path, bytes });807
Ok(candidate.assets.len() - 1)808
};809
let source_index = push_asset(&mut candidate, format!("{prefix}/{name}"), fbx_bytes)?;810
let mut source_assets = vec![source_index];811
// Texture identity (element id) -> embedded asset index, so shared textures are stored once.812
let mut texture_assets: std::collections::BTreeMap<u32, usize> = Default::default();813
let mut vertices: usize = candidate814
.attachments815
.iter()816
.flat_map(|a| &a.meshes)817
.map(|m| m.positions.len())818
.sum();819
let mut meshes = Vec::new();820
let mut tri = Vec::new();821
for node in &scene.nodes {822
let Some(mesh) = node.mesh.as_ref() else {823
continue;824
};825
if mesh.num_triangles == 0 {826
continue;827
}828
if !mesh.skin_deformers.is_empty() {829
return Err(format!(830
"FBX-mesh '{}' is geskinned; alleen rigide equipment wordt ondersteund",831
node.element.name.trim()832
));833
}834
if !mesh.blend_deformers.is_empty() {835
return Err(format!(836
"FBX-mesh '{}' heeft morph targets; die worden niet ondersteund",837
node.element.name.trim()838
));839
}840
let to_world = node.geometry_to_world;841
let for_normals = ufbx::matrix_for_normals(&to_world);842
let parts = mesh.material_parts.len().max(1);843
for part in 0..parts {844
let faces: Vec<u32> = if mesh.material_parts.is_empty() {845
(0..mesh.faces.len() as u32).collect()846
} else {847
mesh.material_parts[part]848
.face_indices849
.iter()850
.copied()851
.collect()852
};853
if faces.is_empty() {854
continue;855
}856
let material = if mesh.material_parts.is_empty() {857
None858
} else {859
mesh.materials.get(part)860
};861
let mut positions = Vec::new();862
let mut normals = Vec::new();863
let mut uvs = Vec::new();864
let mut indices = Vec::new();865
for face in faces {866
let face = mesh.faces[face as usize];867
if face.num_indices < 3 {868
continue;869
}870
let count = ufbx::triangulate_face_vec(&mut tri, mesh, face) as usize;871
for t in 0..count {872
let mut corners = [tri[t * 3], tri[t * 3 + 1], tri[t * 3 + 2]];873
if mesh.reversed_winding {874
corners.swap(1, 2);875
}876
for corner in corners {877
let corner = corner as usize;878
let p = ufbx::transform_position(&to_world, mesh.vertex_position[corner]);879
let n = if mesh.vertex_normal.exists {880
let n =881
ufbx::transform_direction(&for_normals, mesh.vertex_normal[corner]);882
let len = (n.x * n.x + n.y * n.y + n.z * n.z).sqrt();883
if len > 1e-12 {884
[(n.x / len) as f32, (n.y / len) as f32, (n.z / len) as f32]885
} else {886
[0., 1., 0.]887
}888
} else {889
[0., 1., 0.]890
};891
let uv = if mesh.vertex_uv.exists {892
let uv = mesh.vertex_uv[corner];893
[uv.x as f32, uv.y as f32]894
} else {895
[0., 0.]896
};897
indices.push(positions.len() as u32);898
positions.push([p.x as f32, p.y as f32, p.z as f32]);899
normals.push(n);900
uvs.push(uv);901
}902
}903
}904
if positions.is_empty() {905
continue;906
}907
vertices += positions.len();908
if vertices > MAX_VERTICES {909
return Err("equipmentmesh is te groot".into());910
}911
if positions912
.iter()913
.chain(&normals)914
.any(|v| v.iter().any(|x| !x.is_finite()))915
{916
return Err("FBX-mesh bevat niet-eindige posities of normalen".into());917
}918
if !mesh.vertex_normal.exists {919
normals = compute_normals(&positions, &indices)?;920
}921
let (mut color, texture) = match material {922
None => ([0.7, 0.7, 0.7, 1.0], None),923
Some(m) => {924
let map = if m.pbr.base_color.has_value {925
&m.pbr.base_color926
} else {927
&m.fbx.diffuse_color928
};929
let v = map.value_vec4;930
// Same alpha rule as the FBX body loader (`fbx_scene`).931
let alpha = if v.w == 0.0 { 1.0 } else { v.w as f32 };932
(933
[v.x as f32, v.y as f32, v.z as f32, alpha],934
map.texture935
.as_ref()936
.or(m.fbx.diffuse_color.texture.as_ref()),937
)938
}939
};940
if !color.iter().all(|c| c.is_finite()) {941
return Err("FBX-materiaal heeft een niet-eindige kleur".into());942
}943
let albedo = match texture {944
None => None,945
Some(texture) => {946
let id = texture.element.element_id;947
if let Some(index) = texture_assets.get(&id) {948
Some(*index)949
} else {950
let (path, bytes) = if texture.content.size > 0 {951
let label = Path::new(texture.relative_filename.trim())952
.file_name()953
.and_then(|n| n.to_str())954
.filter(|n| !n.is_empty())955
.map(|n| n.replace(['\\', ':'], "_"))956
.unwrap_or_else(|| format!("texture-{id}.png"));957
(958
format!("{prefix}/embedded/{id}-{label}"),959
texture.content.to_vec(),960
)961
} else {962
let relative = texture.relative_filename.trim().replace('\\', "/");963
if relative.is_empty() {964
return Err(965
"FBX-texture heeft geen relatief pad en is niet ingebed".into(),966
);967
}968
portable_relative(&relative)969
.map_err(|e| format!("FBX-texturepad geweigerd: {e}"))?;970
let bytes = read_asset(&root, &relative)971
.map_err(|e| format!("FBX-texture '{relative}': {e}"))?;972
(format!("{prefix}/{relative}"), bytes)973
};974
if bytes.len() > MAX_ASSET {975
return Err("FBX-texture is te groot".into());976
}977
decode_png(&bytes).map_err(|e| {978
format!("FBX-texture is geen bruikbare PNG (alleen PNG wordt ingesloten): {e}")979
})?;980
let index = push_asset(&mut candidate, path, bytes)?;981
texture_assets.insert(id, index);982
source_assets.push(index);983
Some(index)984
}985
}986
};987
if albedo.is_some() {988
// FBX semantics: a connected diffuse texture replaces the colour property.989
color = [1., 1., 1., color[3]];990
}991
meshes.push(Mesh {992
positions,993
normals,994
uvs,995
indices,996
color,997
albedo,998
});999
}1000
}1001
if meshes.is_empty() {1002
return Err("het FBX-bestand heeft geen tekenbare meshes".into());1003
}1004
source_assets.sort_unstable();1005
source_assets.dedup();1006
candidate.attachments.push(Attachment {1007
label: name.to_string(),1008
source_assets,1009
bones: [None, None],1010
translation: [0.; 3],1011
rotation_deg: [0.; 3],1012
meshes,1013
shiny: None,1014
specular: 0.25,1015
});1016
candidate.validate()?;1017
*preset = candidate;1018
Ok(preset.attachments.len() - 1)1019
}1020
740
pub fn import_shiny(preset: &mut Preset, attachment: usize, source: &Path) -> Result<(), String> {1021
pub fn import_shiny(preset: &mut Preset, attachment: usize, source: &Path) -> Result<(), String> {741
let root = source.parent().ok_or("texturemap ontbreekt")?;1022
let root = source.parent().ok_or("texturemap ontbreekt")?;742
let name = source1023
let name = source@@ -2,8 +2,9 @@
2
2
//!
3
3
//! Loads FBX through vendored ufbx once with unit/axis/winding normalization,
4
4
//! builds a `PreviewCharacter` that samples like the glTF path, and retains
5
//! conversion provenance. Live Glow viewport and production four-map batch
6
//! still use glTF/GLB; this module is the CPU/API surface for that later wire-up.
5
//! conversion provenance. The live Glow viewport and the production four-map
6
//! batch draw FBX bodies through `preview::load_body_file`, which uses this
7
//! loader; no further axis/unit conversion is applied downstream.
7
8
8
9
use std::io::Cursor;
9
10
use std::path::Path;
@@ -106,8 +107,7 @@ pub fn load_fbx_drawable(path: &Path) -> Result<FbxDrawableScene, String> {
106
107
"FBX genormaliseerd met {FBX_BACKEND}; assen right-handed Y-up, eenheden meters."
107
108
));
108
109
notices.push(
109
"Drawable/animated scene is beschikbaar voor sampling; live viewport en productie gebruiken nog glTF/GLB."
110
.into(),
110
"Dezelfde genormaliseerde scene voedt de live viewport en de productierender.".into(),
111
111
);
112
112
113
113
if scene.metadata.geometry_ignored {
@@ -639,9 +639,11 @@ fn extract_mesh(
639
639
} else {
640
640
[0.0, 1.0, 0.0]
641
641
};
642
// `PreviewCharacter` UVs use the glTF convention (origin top-left,
643
// as sampled by the production body path); FBX stores V upwards.
642
644
let uv = if mesh.vertex_uv.exists {
643
645
let u = &mesh.vertex_uv[corner];
644
[u.x as f32, u.y as f32]
646
preview_uv(u.x, u.y)
645
647
} else {
646
648
[0.0, 0.0]
647
649
};
@@ -693,6 +695,12 @@ fn extract_mesh(
693
695
))
694
696
}
695
697
698
/// FBX UV (origin bottom-left) to the glTF-style `PreviewCharacter` UV
699
/// convention (origin top-left) used by the production body path.
700
pub(crate) fn preview_uv(u: f64, v: f64) -> [f32; 2] {
701
[u as f32, (1.0 - v) as f32]
702
}
703
696
704
fn skin_influences(
697
705
skin: &ufbx::SkinDeformer,
698
706
vertex: usize,
@@ -1079,6 +1087,13 @@ mod tests {
1079
1087
assert!(animated_joint_world(&fbx.character, "walk", 0.1, fi).is_some());
1080
1088
}
1081
1089
1090
#[test]
1091
fn fbx_uv_converts_to_preview_top_left_convention() {
1092
assert_eq!(preview_uv(0.25, 0.0), [0.25, 1.0]);
1093
assert_eq!(preview_uv(0.5, 1.0), [0.5, 0.0]);
1094
assert_eq!(preview_uv(1.0, 0.75), [1.0, 0.25]);
1095
}
1096
1082
1097
#[test]
1083
1098
fn garbage_fbx_fails_explicitly() {
1084
1099
let root = std::env::temp_dir().join(format!("paperdoll-fbx-scene-{}", std::process::id()));
@@ -2,8 +2,9 @@2
//!2
//!3
//! Loads FBX through vendored ufbx once with unit/axis/winding normalization,3
//! Loads FBX through vendored ufbx once with unit/axis/winding normalization,4
//! builds a `PreviewCharacter` that samples like the glTF path, and retains4
//! builds a `PreviewCharacter` that samples like the glTF path, and retains5
//! conversion provenance. Live Glow viewport and production four-map batch5
//! conversion provenance. The live Glow viewport and the production four-map6
//! still use glTF/GLB; this module is the CPU/API surface for that later wire-up.6
//! batch draw FBX bodies through `preview::load_body_file`, which uses this7
//! loader; no further axis/unit conversion is applied downstream.7
8
8
use std::io::Cursor;9
use std::io::Cursor;9
use std::path::Path;10
use std::path::Path;@@ -106,8 +107,7 @@ pub fn load_fbx_drawable(path: &Path) -> Result<FbxDrawableScene, String> {106
"FBX genormaliseerd met {FBX_BACKEND}; assen right-handed Y-up, eenheden meters."107
"FBX genormaliseerd met {FBX_BACKEND}; assen right-handed Y-up, eenheden meters."107
));108
));108
notices.push(109
notices.push(109
"Drawable/animated scene is beschikbaar voor sampling; live viewport en productie gebruiken nog glTF/GLB."110
"Dezelfde genormaliseerde scene voedt de live viewport en de productierender.".into(),110
.into(),111
);111
);112
112
113
if scene.metadata.geometry_ignored {113
if scene.metadata.geometry_ignored {@@ -639,9 +639,11 @@ fn extract_mesh(639
} else {639
} else {640
[0.0, 1.0, 0.0]640
[0.0, 1.0, 0.0]641
};641
};642
// `PreviewCharacter` UVs use the glTF convention (origin top-left,643
// as sampled by the production body path); FBX stores V upwards.642
let uv = if mesh.vertex_uv.exists {644
let uv = if mesh.vertex_uv.exists {643
let u = &mesh.vertex_uv[corner];645
let u = &mesh.vertex_uv[corner];644
[u.x as f32, u.y as f32]646
preview_uv(u.x, u.y)645
} else {647
} else {646
[0.0, 0.0]648
[0.0, 0.0]647
};649
};@@ -693,6 +695,12 @@ fn extract_mesh(693
))695
))694
}696
}695
697
698
/// FBX UV (origin bottom-left) to the glTF-style `PreviewCharacter` UV699
/// convention (origin top-left) used by the production body path.700
pub(crate) fn preview_uv(u: f64, v: f64) -> [f32; 2] {701
[u as f32, (1.0 - v) as f32]702
}703
696
fn skin_influences(704
fn skin_influences(697
skin: &ufbx::SkinDeformer,705
skin: &ufbx::SkinDeformer,698
vertex: usize,706
vertex: usize,@@ -1079,6 +1087,13 @@ mod tests {1079
assert!(animated_joint_world(&fbx.character, "walk", 0.1, fi).is_some());1087
assert!(animated_joint_world(&fbx.character, "walk", 0.1, fi).is_some());1080
}1088
}1081
1089
1090
#[test]1091
fn fbx_uv_converts_to_preview_top_left_convention() {1092
assert_eq!(preview_uv(0.25, 0.0), [0.25, 1.0]);1093
assert_eq!(preview_uv(0.5, 1.0), [0.5, 0.0]);1094
assert_eq!(preview_uv(1.0, 0.75), [1.0, 0.25]);1095
}1096
1082
#[test]1097
#[test]1083
fn garbage_fbx_fails_explicitly() {1098
fn garbage_fbx_fails_explicitly() {1084
let root = std::env::temp_dir().join(format!("paperdoll-fbx-scene-{}", std::process::id()));1099
let root = std::env::temp_dir().join(format!("paperdoll-fbx-scene-{}", std::process::id()));@@ -1,8 +1,9 @@
1
1
//! Honest capability report for the current editor.
2
2
//!
3
3
//! Attachment authoring, target-map production, the library depth compositor,
4
//! rendered 2D preview and the normalized FBX drawable scene API are present.
5
//! Live Glow viewport and production four-map batch still draw glTF/GLB only.
4
//! rendered 2D preview and the normalized FBX drawable scene are present.
5
//! The live Glow viewport and production four-map batch draw glTF/GLB and FBX
6
//! bodies; rigid equipment is imported from OBJ+MTL or FBX.
6
7
7
8
/// Product capability named by the design.
8
9
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -117,7 +118,7 @@ pub fn dutch_state(availability: Availability) -> &'static str {
117
118
}
118
119
119
120
pub fn dutch_summary() -> &'static str {
120
"Deze build leest glTF/GLB, FBX en OBJ+MTL, bouwt een genormaliseerde tekenbare/animeerbare FBX-scene voor CPU-sampling, opent een .ppd-project en toont een live geskinde 3D-preview van glTF/GLB met walk en attack, rigide bone-gebonden OBJ-equipment en binaire .ppe-presets. Render maakt alle clips × frames × 36 hoeken met vaste orthografische camera en diffuse/depth/normal/specular op doelresolutie. De 2D-preview composeert voltooide exportsets met depth-ordering, looping-playback, hoeknavigatie en continue lichtbediening. Live viewport en productie gebruiken nog geen FBX-geometrie."
121
"Deze build leest glTF/GLB, FBX en OBJ+MTL, opent een .ppd-project en toont een live geskinde 3D-preview van glTF/GLB- en FBX-lichamen (FBX via een eenmalig genormaliseerde tekenbare/animeerbare scene) met al hun clips (zoals walk en attack), rigide bone-gebonden OBJ- of FBX-equipment en binaire .ppe-presets. Render maakt voor glTF/GLB- en FBX-lichamen alle clips × frames × 36 hoeken met vaste orthografische camera en diffuse/depth/normal/specular op doelresolutie. De 2D-preview composeert voltooide exportsets met depth-ordering, looping-playback, hoeknavigatie en continue lichtbediening."
121
122
}
122
123
123
124
/// Stable status text shared by `--status` and the window.
@@ -295,7 +296,9 @@ mod tests {
295
296
assert!(summary.contains("glTF/GLB"));
296
297
assert!(summary.contains("walk"));
297
298
assert!(summary.contains("2D-preview"));
298
assert!(summary.contains("FBX-scene") || summary.contains("tekenbare"));
299
assert!(summary.contains("FBX-lichamen"));
300
assert!(summary.contains("FBX-equipment"));
301
assert!(!summary.contains("nog geen FBX"));
299
302
}
300
303
301
304
#[test]
@@ -1,8 +1,9 @@1
//! Honest capability report for the current editor.1
//! Honest capability report for the current editor.2
//!2
//!3
//! Attachment authoring, target-map production, the library depth compositor,3
//! Attachment authoring, target-map production, the library depth compositor,4
//! rendered 2D preview and the normalized FBX drawable scene API are present.4
//! rendered 2D preview and the normalized FBX drawable scene are present.5
//! Live Glow viewport and production four-map batch still draw glTF/GLB only.5
//! The live Glow viewport and production four-map batch draw glTF/GLB and FBX6
//! bodies; rigid equipment is imported from OBJ+MTL or FBX.6
7
7
/// Product capability named by the design.8
/// Product capability named by the design.8
#[derive(Debug, Clone, Copy, PartialEq, Eq)]9
#[derive(Debug, Clone, Copy, PartialEq, Eq)]@@ -117,7 +118,7 @@ pub fn dutch_state(availability: Availability) -> &'static str {117
}118
}118
119
119
pub fn dutch_summary() -> &'static str {120
pub fn dutch_summary() -> &'static str {120
"Deze build leest glTF/GLB, FBX en OBJ+MTL, bouwt een genormaliseerde tekenbare/animeerbare FBX-scene voor CPU-sampling, opent een .ppd-project en toont een live geskinde 3D-preview van glTF/GLB met walk en attack, rigide bone-gebonden OBJ-equipment en binaire .ppe-presets. Render maakt alle clips × frames × 36 hoeken met vaste orthografische camera en diffuse/depth/normal/specular op doelresolutie. De 2D-preview composeert voltooide exportsets met depth-ordering, looping-playback, hoeknavigatie en continue lichtbediening. Live viewport en productie gebruiken nog geen FBX-geometrie."121
"Deze build leest glTF/GLB, FBX en OBJ+MTL, opent een .ppd-project en toont een live geskinde 3D-preview van glTF/GLB- en FBX-lichamen (FBX via een eenmalig genormaliseerde tekenbare/animeerbare scene) met al hun clips (zoals walk en attack), rigide bone-gebonden OBJ- of FBX-equipment en binaire .ppe-presets. Render maakt voor glTF/GLB- en FBX-lichamen alle clips × frames × 36 hoeken met vaste orthografische camera en diffuse/depth/normal/specular op doelresolutie. De 2D-preview composeert voltooide exportsets met depth-ordering, looping-playback, hoeknavigatie en continue lichtbediening."121
}122
}122
123
123
/// Stable status text shared by `--status` and the window.124
/// Stable status text shared by `--status` and the window.@@ -295,7 +296,9 @@ mod tests {295
assert!(summary.contains("glTF/GLB"));296
assert!(summary.contains("glTF/GLB"));296
assert!(summary.contains("walk"));297
assert!(summary.contains("walk"));297
assert!(summary.contains("2D-preview"));298
assert!(summary.contains("2D-preview"));298
assert!(summary.contains("FBX-scene") || summary.contains("tekenbare"));299
assert!(summary.contains("FBX-lichamen"));300
assert!(summary.contains("FBX-equipment"));301
assert!(!summary.contains("nog geen FBX"));299
}302
}300
303
301
#[test]304
#[test]@@ -130,7 +130,7 @@ pub fn import_project_model(
130
130
"obj" => import_obj(project_root, &full, &relative),
131
131
"fbx" => import_fbx(project_root, &full, &relative),
132
132
other => Err(ImportError::Unsupported(format!(
133
"bestandstype '{other}' wordt niet gelezen. Gebruik .glb, .gltf of .obj."
133
"bestandstype '{other}' wordt niet gelezen. Gebruik .glb, .gltf, .fbx of .obj."
134
134
))),
135
135
}
136
136
}
@@ -130,7 +130,7 @@ pub fn import_project_model(130
"obj" => import_obj(project_root, &full, &relative),130
"obj" => import_obj(project_root, &full, &relative),131
"fbx" => import_fbx(project_root, &full, &relative),131
"fbx" => import_fbx(project_root, &full, &relative),132
other => Err(ImportError::Unsupported(format!(132
other => Err(ImportError::Unsupported(format!(133
"bestandstype '{other}' wordt niet gelezen. Gebruik .glb, .gltf of .obj."133
"bestandstype '{other}' wordt niet gelezen. Gebruik .glb, .gltf, .fbx of .obj."134
))),134
))),135
}135
}136
}136
}@@ -1,6 +1,6 @@
1
1
//! Desktop shell for Paperdoll Renderer project files.
2
2
//!
3
//! The window edits a `.ppd` project and shows a live skinned glTF preview.
3
//! The window edits a `.ppd` project and shows a live skinned glTF/GLB or FBX preview.
4
4
5
5
use std::process::ExitCode;
6
6
@@ -1,6 +1,6 @@1
//! Desktop shell for Paperdoll Renderer project files.1
//! Desktop shell for Paperdoll Renderer project files.2
//!2
//!3
//! The window edits a `.ppd` project and shows a live skinned glTF preview.3
//! The window edits a `.ppd` project and shows a live skinned glTF/GLB or FBX preview.4
4
5
use std::process::ExitCode;5
use std::process::ExitCode;6
6
@@ -140,6 +140,36 @@ pub struct AnimChannel {
140
140
pub values: Vec<f32>,
141
141
}
142
142
143
/// Whether a body model path is drawable by the live viewport and production batch.
144
pub fn is_drawable_body(path: &Path) -> bool {
145
matches!(
146
path.extension()
147
.and_then(|e| e.to_str())
148
.map(|e| e.to_ascii_lowercase())
149
.as_deref(),
150
Some("gltf" | "glb" | "fbx")
151
)
152
}
153
154
/// Load a skinned body for the live viewport and the production batch.
155
/// glTF/GLB use the glTF reader; FBX goes through the normalized
156
/// `fbx_scene` loader (axis/unit conversion is applied there exactly once).
157
pub fn load_body_file(path: &Path) -> Result<PreviewCharacter, String> {
158
match path
159
.extension()
160
.and_then(|e| e.to_str())
161
.map(|e| e.to_ascii_lowercase())
162
.as_deref()
163
{
164
Some("gltf" | "glb") => load_gltf_file(path),
165
Some("fbx") => crate::fbx_scene::load_fbx_drawable(path).map(|scene| scene.character),
166
_ => Err(format!(
167
"{} is geen tekenbaar lichaam; gebruik .glb, .gltf of .fbx",
168
path.display()
169
)),
170
}
171
}
172
143
173
pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
144
174
let (document, buffers, images) = gltf::import(path).map_err(|err| err.to_string())?;
145
175
let mut nodes = Vec::new();
@@ -140,6 +140,36 @@ pub struct AnimChannel {140
pub values: Vec<f32>,140
pub values: Vec<f32>,141
}141
}142
142
143
/// Whether a body model path is drawable by the live viewport and production batch.144
pub fn is_drawable_body(path: &Path) -> bool {145
matches!(146
path.extension()147
.and_then(|e| e.to_str())148
.map(|e| e.to_ascii_lowercase())149
.as_deref(),150
Some("gltf" | "glb" | "fbx")151
)152
}153
154
/// Load a skinned body for the live viewport and the production batch.155
/// glTF/GLB use the glTF reader; FBX goes through the normalized156
/// `fbx_scene` loader (axis/unit conversion is applied there exactly once).157
pub fn load_body_file(path: &Path) -> Result<PreviewCharacter, String> {158
match path159
.extension()160
.and_then(|e| e.to_str())161
.map(|e| e.to_ascii_lowercase())162
.as_deref()163
{164
Some("gltf" | "glb") => load_gltf_file(path),165
Some("fbx") => crate::fbx_scene::load_fbx_drawable(path).map(|scene| scene.character),166
_ => Err(format!(167
"{} is geen tekenbaar lichaam; gebruik .glb, .gltf of .fbx",168
path.display()169
)),170
}171
}172
143
pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {173
pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {144
let (document, buffers, images) = gltf::import(path).map_err(|err| err.to_string())?;174
let (document, buffers, images) = gltf::import(path).map_err(|err| err.to_string())?;145
let mut nodes = Vec::new();175
let mut nodes = Vec::new();@@ -1,4 +1,4 @@
1
//! OpenGL viewport for a skinned glTF body and rigid textured equipment.
1
//! OpenGL viewport for a skinned glTF/GLB or 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.
@@ -153,7 +153,7 @@ impl Viewport {
153
153
ui.painter().text(
154
154
rect.center(),
155
155
egui::Align2::CENTER_CENTER,
156
"Geen glTF geladen. Open een project met een .glb of start met --preview-fixture.",
156
"Geen lichaam geladen. Open een project met een .glb/.gltf/.fbx-lichaam of start met --preview-fixture.",
157
157
egui::FontId::proportional(14.0),
158
158
egui::Color32::from_rgb(210, 210, 210),
159
159
);
@@ -1,4 +1,4 @@1
//! OpenGL viewport for a skinned glTF body and rigid textured equipment.1
//! OpenGL viewport for a skinned glTF/GLB or 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.@@ -153,7 +153,7 @@ impl Viewport {153
ui.painter().text(153
ui.painter().text(154
rect.center(),154
rect.center(),155
egui::Align2::CENTER_CENTER,155
egui::Align2::CENTER_CENTER,156
"Geen glTF geladen. Open een project met een .glb of start met --preview-fixture.",156
"Geen lichaam geladen. Open een project met een .glb/.gltf/.fbx-lichaam of start met --preview-fixture.",157
egui::FontId::proportional(14.0),157
egui::FontId::proportional(14.0),158
egui::Color32::from_rgb(210, 210, 210),158
egui::Color32::from_rgb(210, 210, 210),159
);159
);@@ -575,7 +575,7 @@ fn read_equipment_with_budget(
575
575
};
576
576
}
577
577
}
578
failure("legacy preset; kies een OBJ om te configureren".into())
578
failure("legacy preset; kies een OBJ- of FBX-model om te configureren".into())
579
579
})
580
580
.collect()
581
581
}
@@ -575,7 +575,7 @@ fn read_equipment_with_budget(575
};575
};576
}576
}577
}577
}578
failure("legacy preset; kies een OBJ om te configureren".into())578
failure("legacy preset; kies een OBJ- of FBX-model om te configureren".into())579
})579
})580
.collect()580
.collect()581
}581
}@@ -0,0 +1,378 @@
1
//! FBX bodies in the live/production path and rigid FBX equipment import.
2
use paperdoll_renderer::equipment::{self, Preset};
3
use paperdoll_renderer::preview::{self, Mat4, PreviewCharacter, PreviewControls};
4
use paperdoll_renderer::production::{self, RenderJob, SourceFingerprint};
5
use paperdoll_renderer::project::CameraFraming;
6
use std::path::PathBuf;
7
8
fn repo(path: &str) -> PathBuf {
9
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(path)
10
}
11
fn fbx_body() -> PreviewCharacter {
12
preview::load_body_file(&repo("tests/fixtures/bodies/test-body-male.fbx")).unwrap()
13
}
14
fn glb_body() -> PreviewCharacter {
15
preview::load_body_file(&repo("tests/fixtures/bodies/test-body-male.glb")).unwrap()
16
}
17
fn scratch(tag: &str) -> PathBuf {
18
let dir = std::env::temp_dir().join(format!(
19
"paperdoll-fbx-{tag}-{}",
20
std::time::SystemTime::now()
21
.duration_since(std::time::UNIX_EPOCH)
22
.unwrap()
23
.as_nanos()
24
));
25
std::fs::create_dir_all(&dir).unwrap();
26
dir
27
}
28
fn job(character: PreviewCharacter, preset: Option<Preset>) -> RenderJob {
29
RenderJob {
30
character,
31
sex: "male".into(),
32
preset,
33
target_width: 32,
34
target_height: 48,
35
fps: 4,
36
framing: CameraFraming {
37
height_milli: 2400,
38
center_y_milli: 1000,
39
pivot_y_milli: None,
40
},
41
output: std::env::temp_dir(),
42
project_id: "fbx-test".into(),
43
project_name: "FBX".into(),
44
body_model: SourceFingerprint {
45
path: "assets/body/test-body-male.fbx".into(),
46
sha256: "0".repeat(64),
47
},
48
preset_sha256: None,
49
}
50
}
51
fn bounds(scene: &production::Scene) -> ([f32; 3], [f32; 3]) {
52
let mut lo = [f32::MAX; 3];
53
let mut hi = [f32::MIN; 3];
54
for draw in &scene.draws {
55
for &i in &draw.indices {
56
let p = draw.positions[i as usize];
57
for k in 0..3 {
58
lo[k] = lo[k].min(p[k]);
59
hi[k] = hi[k].max(p[k]);
60
}
61
}
62
}
63
(lo, hi)
64
}
65
fn clip_named(body: &PreviewCharacter, prefix: &str) -> String {
66
body.clips
67
.iter()
68
.find(|c| c.name.contains(prefix))
69
.unwrap_or_else(|| panic!("clip {prefix} ontbreekt"))
70
.name
71
.clone()
72
}
73
fn apply(m: &Mat4, p: [f32; 3]) -> [f32; 3] {
74
[
75
m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
76
m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],
77
m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14],
78
]
79
}
80
81
#[test]
82
fn body_loader_dispatches_by_extension_and_rejects_other_formats() {
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")));
85
assert!(!preview::is_drawable_body(std::path::Path::new("c.obj")));
86
let fbx = fbx_body();
87
let direct = paperdoll_renderer::fbx_scene::load_fbx_drawable(&repo(
88
"tests/fixtures/bodies/test-body-male.fbx",
89
))
90
.unwrap();
91
assert_eq!(fbx.positions, direct.character.positions);
92
assert_eq!(fbx.clips.len(), 2);
93
let err = preview::load_body_file(&repo("tests/fixtures/equipment/sword.obj")).unwrap_err();
94
assert!(err.contains("geen tekenbaar lichaam"), "{err}");
95
// A file named .glb that actually holds FBX bytes is not silently accepted.
96
let dir = scratch("renamed");
97
let fake = dir.join("body.glb");
98
std::fs::copy(repo("tests/fixtures/bodies/test-body-male.fbx"), &fake).unwrap();
99
assert!(preview::load_body_file(&fake).is_err());
100
let _ = std::fs::remove_dir_all(dir);
101
}
102
103
#[test]
104
fn fbx_body_production_scene_matches_glb_silhouette_without_double_normalization() {
105
let fbx = fbx_body();
106
let glb = glb_body();
107
let fbx_plan = production::plan(&fbx, 12).unwrap();
108
let glb_plan = production::plan(&glb, 12).unwrap();
109
assert_eq!(fbx_plan.images, glb_plan.images);
110
assert_eq!(fbx_plan.files, glb_plan.files);
111
let fbx_job = job(fbx, None);
112
let glb_job = job(glb, None);
113
let mut cache = equipment::TextureCache::default();
114
// Only `walk` is compared as a whole silhouette: Blender's FBX exporter
115
// baked the legs of the fixture's `attack` stack in a leftover walk pose
116
// (all 60 curves present in the file), so the attack clip is compared per
117
// animated arm joint below instead.
118
for (prefix, time) in [("walk", 0.0), ("walk", 0.25), ("walk", 0.6)] {
119
let f = production::scene(
120
&fbx_job,
121
&clip_named(&fbx_job.character, prefix),
122
time,
123
30.,
124
90.,
125
&mut cache,
126
)
127
.unwrap();
128
let g = production::scene(&glb_job, prefix, time, 30., 90., &mut cache).unwrap();
129
f.validate().unwrap();
130
let (flo, fhi) = bounds(&f);
131
let (glo, ghi) = bounds(&g);
132
for k in 0..3 {
133
assert!(
134
(flo[k] - glo[k]).abs() < 0.03 && (fhi[k] - ghi[k]).abs() < 0.03,
135
"{prefix}@{time} axis {k}: fbx {flo:?}..{fhi:?} vs glb {glo:?}..{ghi:?}"
136
);
137
}
138
// Body height in metres (no centimetre or axis double-conversion).
139
assert!((fhi[1] - flo[1]) > 1.4 && (fhi[1] - flo[1]) < 2.2);
140
// Same fixed per-body framing: identical orthographic projection and
141
// a neck pivot resolved from the FBX rig close to the GLB one.
142
assert_eq!(f.projection, g.projection);
143
assert_eq!((f.width, f.height), (g.width, g.height));
144
}
145
let attack = clip_named(&fbx_job.character, "attack");
146
for name in [
147
"RightUpperArm",
148
"RightLowerArm",
149
"RightHand",
150
"Chest",
151
"Spine",
152
] {
153
for time in [0.0, 0.2, 0.4, 0.7] {
154
let f =
155
preview::joint_world_translation(&fbx_job.character, &attack, time, name).unwrap();
156
let g =
157
preview::joint_world_translation(&glb_job.character, "attack", time, name).unwrap();
158
let d = ((f[0] - g[0]).powi(2) + (f[1] - g[1]).powi(2) + (f[2] - g[2]).powi(2)).sqrt();
159
assert!(d < 0.02, "attack {name}@{time}: {f:?} vs {g:?}");
160
}
161
}
162
let fp = paperdoll_renderer::camera::resolve(&fbx_job.character, fbx_job.framing);
163
let gp = paperdoll_renderer::camera::resolve(&glb_job.character, glb_job.framing);
164
assert!(matches!(
165
fp.source,
166
paperdoll_renderer::camera::PivotSource::Neck { .. }
167
));
168
let (fy, gy) = (
169
fp.framing.pivot_y_milli.unwrap(),
170
gp.framing.pivot_y_milli.unwrap(),
171
);
172
assert!((fy - gy).abs() <= 30, "neck pivot fbx {fy} vs glb {gy}");
173
}
174
175
#[test]
176
fn rigid_fbx_import_normalizes_axes_units_and_embeds_png() {
177
let mut preset = Preset::new("fbxsword".into());
178
let part = equipment::import_fbx(&mut preset, &repo("tests/fixtures/equipment/sword-fbx.fbx"))
179
.unwrap();
180
let item = &preset.attachments[part];
181
assert_eq!(item.label, "sword-fbx.fbx");
182
assert_eq!(item.meshes.len(), 2);
183
let mut lo = [f32::MAX; 3];
184
let mut hi = [f32::MIN; 3];
185
for p in item.meshes.iter().flat_map(|m| &m.positions) {
186
for k in 0..3 {
187
lo[k] = lo[k].min(p[k]);
188
hi[k] = hi[k].max(p[k]);
189
}
190
}
191
// Exported Z-up from Blender: blade length must end up along +Y in metres.
192
assert!(
193
lo[1].abs() < 1e-3 && (hi[1] - 0.58).abs() < 1e-3,
194
"{lo:?} {hi:?}"
195
);
196
assert!((hi[0] - 0.09).abs() < 1e-3 && (lo[0] + 0.09).abs() < 1e-3);
197
assert!((hi[2] - 0.015).abs() < 1e-3 && (lo[2] + 0.015).abs() < 1e-3);
198
for mesh in &item.meshes {
199
assert_eq!(mesh.positions.len(), mesh.normals.len());
200
assert_eq!(mesh.positions.len(), mesh.uvs.len());
201
for n in &mesh.normals {
202
let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
203
assert!((len - 1.).abs() < 1e-3);
204
}
205
}
206
let blade = item.meshes.iter().find(|m| m.albedo.is_some()).unwrap();
207
let guard = item.meshes.iter().find(|m| m.albedo.is_none()).unwrap();
208
// A connected texture replaces the FBX diffuse colour; the guard keeps its colour.
209
assert_eq!(blade.color, [1., 1., 1., 1.]);
210
for (got, want) in guard.color.iter().zip([0.75, 0.55, 0.2, 1.]) {
211
assert!((got - want).abs() < 0.01, "{:?}", guard.color);
212
}
213
// UVs keep the OBJ/FBX convention (V up): on the broad blade faces the
214
// tip has V=1 and the base V=0 (the fixture maps each face to the unit square).
215
let broad = |want_y: f32| {
216
blade
217
.positions
218
.iter()
219
.zip(&blade.normals)
220
.zip(&blade.uvs)
221
.filter(|((p, n), _)| n[2].abs() > 0.9 && (p[1] - want_y).abs() < 1e-4)
222
.map(|(_, uv)| uv[1])
223
.collect::<Vec<_>>()
224
};
225
let (tip, base) = (broad(0.58), broad(0.03));
226
assert!(
227
!tip.is_empty() && tip.iter().all(|v| (*v - 1.).abs() < 1e-4),
228
"{tip:?}"
229
);
230
assert!(
231
!base.is_empty() && base.iter().all(|v| v.abs() < 1e-4),
232
"{base:?}"
233
);
234
let albedo = blade.albedo.unwrap();
235
assert!(preset.assets[albedo].path.contains("/embedded/"));
236
let embedded = preset.texture(albedo).unwrap();
237
let mut reference = Preset::new("ref".into());
238
reference.assets.push(equipment::EmbeddedAsset {
239
path: "checker.png".into(),
240
bytes: std::fs::read(repo("tests/fixtures/equipment/checker.png")).unwrap(),
241
});
242
assert_eq!(embedded.rgba, reference.texture(0).unwrap().rgba);
243
// Original FBX payload is kept, and the binary preset round-trips.
244
assert!(item.source_assets.iter().any(|i| preset.assets[*i].bytes
245
== std::fs::read(repo("tests/fixtures/equipment/sword-fbx.fbx")).unwrap()));
246
let decoded = Preset::decode(&preset.encode().unwrap()).unwrap();
247
assert_eq!(decoded, preset);
248
}
249
250
#[test]
251
fn rigid_fbx_external_png_is_embedded_and_missing_texture_fails() {
252
let mut preset = Preset::new("fbxsword".into());
253
let part = equipment::import_model(
254
&mut preset,
255
&repo("tests/fixtures/equipment/sword-fbx-external.fbx"),
256
)
257
.unwrap();
258
let blade = preset.attachments[part]
259
.meshes
260
.iter()
261
.find(|m| m.albedo.is_some())
262
.unwrap();
263
let asset = &preset.assets[blade.albedo.unwrap()];
264
assert!(asset.path.ends_with("/checker.png"), "{}", asset.path);
265
assert_eq!(
266
asset.bytes,
267
std::fs::read(repo("tests/fixtures/equipment/checker.png")).unwrap()
268
);
269
let dir = scratch("missing-tex");
270
std::fs::copy(
271
repo("tests/fixtures/equipment/sword-fbx-external.fbx"),
272
dir.join("lonely.fbx"),
273
)
274
.unwrap();
275
let mut other = Preset::new("lonely".into());
276
let err = equipment::import_fbx(&mut other, &dir.join("lonely.fbx")).unwrap_err();
277
assert!(err.contains("FBX-texture"), "{err}");
278
assert!(other.attachments.is_empty() && other.assets.is_empty());
279
let _ = std::fs::remove_dir_all(dir);
280
}
281
282
#[test]
283
fn skinned_garbage_or_unsupported_equipment_is_rejected() {
284
let mut preset = Preset::new("bad".into());
285
let err = equipment::import_fbx(
286
&mut preset,
287
&repo("tests/fixtures/bodies/test-body-male.fbx"),
288
)
289
.unwrap_err();
290
assert!(err.contains("geskinned"), "{err}");
291
let dir = scratch("garbage");
292
std::fs::write(dir.join("junk.fbx"), b"not an fbx at all").unwrap();
293
assert!(equipment::import_fbx(&mut preset, &dir.join("junk.fbx")).is_err());
294
assert!(equipment::import_model(
295
&mut preset,
296
&repo("tests/fixtures/bodies/test-body-male.glb")
297
)
298
.is_err());
299
assert!(preset.attachments.is_empty() && preset.assets.is_empty());
300
// OBJ still goes through the same dispatcher.
301
equipment::import_model(&mut preset, &repo("tests/fixtures/equipment/sword.obj")).unwrap();
302
assert_eq!(preset.attachments.len(), 1);
303
let _ = std::fs::remove_dir_all(dir);
304
}
305
306
#[test]
307
fn fbx_equipment_on_fbx_body_follows_the_animated_hand_in_production() {
308
let body = fbx_body();
309
let joint = body
310
.joint_names
311
.iter()
312
.position(|n| n == "RightHand")
313
.unwrap();
314
let mut preset = Preset::new("fbxsword".into());
315
equipment::import_fbx(&mut preset, &repo("tests/fixtures/equipment/sword-fbx.fbx")).unwrap();
316
preset.bind(0, &body, 0, joint).unwrap();
317
preset.attachments[0].rotation_deg = [0., 0., -15.];
318
let attack = clip_named(&body, "attack");
319
let job = job(body, Some(preset.clone()));
320
let mut cache = equipment::TextureCache::default();
321
let early = production::scene(&job, &attack, 0.0, 0., 0., &mut cache).unwrap();
322
let late = production::scene(&job, &attack, 0.4, 0., 0., &mut cache).unwrap();
323
assert_eq!(early.draws.len(), 2);
324
assert!(early.draws.iter().any(|d| d.albedo.is_some()));
325
assert_eq!(early.view, late.view);
326
assert_ne!(early.draws[0].positions, late.draws[0].positions);
327
// Exact attachment: animated joint world × local transform × source vertex.
328
let world = preview::animated_joint_world(&job.character, &attack, 0.4, joint).unwrap();
329
let model = preview::mul(world, equipment::local_matrix([0.; 3], [0., 0., -15.]));
330
let layers = equipment::layers(
331
&preset,
332
0,
333
&job.character,
334
&PreviewControls {
335
clip_name: attack.clone(),
336
time: 0.4,
337
..Default::default()
338
},
339
)
340
.unwrap();
341
assert_eq!(layers[0].model, model);
342
let expected = apply(&model, preset.attachments[0].meshes[0].positions[0]);
343
let got = late.draws[0].positions[0];
344
for k in 0..3 {
345
assert!(
346
(expected[k] - got[k]).abs() < 1e-5,
347
"{expected:?} vs {got:?}"
348
);
349
}
350
// The hand-bound sword sits at the FBX hand, not at the origin.
351
let hand = [world[12], world[13], world[14]];
352
assert!(hand[1] > 0.5);
353
let d = ((got[0] - hand[0]).powi(2) + (got[1] - hand[1]).powi(2) + (got[2] - hand[2]).powi(2))
354
.sqrt();
355
assert!(d < 0.7, "sword vertex {got:?} far from hand {hand:?}");
356
// A preset bound to the GLB rig is refused on the FBX rig instead of guessing.
357
let mut glb_bound = Preset::new("glbsword".into());
358
equipment::import_fbx(
359
&mut glb_bound,
360
&repo("tests/fixtures/equipment/sword-fbx.fbx"),
361
)
362
.unwrap();
363
let glb = glb_body();
364
glb_bound
365
.bind(
366
0,
367
&glb,
368
0,
369
glb.joint_names
370
.iter()
371
.position(|n| n == "RightHand")
372
.unwrap(),
373
)
374
.unwrap();
375
let err =
376
equipment::layers(&glb_bound, 0, &job.character, &PreviewControls::default()).unwrap_err();
377
assert!(err.contains("rig wijkt af"), "{err}");
378
}
@@ -0,0 +1,378 @@1
//! FBX bodies in the live/production path and rigid FBX equipment import.2
use paperdoll_renderer::equipment::{self, Preset};3
use paperdoll_renderer::preview::{self, Mat4, PreviewCharacter, PreviewControls};4
use paperdoll_renderer::production::{self, RenderJob, SourceFingerprint};5
use paperdoll_renderer::project::CameraFraming;6
use std::path::PathBuf;7
8
fn repo(path: &str) -> PathBuf {9
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(path)10
}11
fn fbx_body() -> PreviewCharacter {12
preview::load_body_file(&repo("tests/fixtures/bodies/test-body-male.fbx")).unwrap()13
}14
fn glb_body() -> PreviewCharacter {15
preview::load_body_file(&repo("tests/fixtures/bodies/test-body-male.glb")).unwrap()16
}17
fn scratch(tag: &str) -> PathBuf {18
let dir = std::env::temp_dir().join(format!(19
"paperdoll-fbx-{tag}-{}",20
std::time::SystemTime::now()21
.duration_since(std::time::UNIX_EPOCH)22
.unwrap()23
.as_nanos()24
));25
std::fs::create_dir_all(&dir).unwrap();26
dir27
}28
fn job(character: PreviewCharacter, preset: Option<Preset>) -> RenderJob {29
RenderJob {30
character,31
sex: "male".into(),32
preset,33
target_width: 32,34
target_height: 48,35
fps: 4,36
framing: CameraFraming {37
height_milli: 2400,38
center_y_milli: 1000,39
pivot_y_milli: None,40
},41
output: std::env::temp_dir(),42
project_id: "fbx-test".into(),43
project_name: "FBX".into(),44
body_model: SourceFingerprint {45
path: "assets/body/test-body-male.fbx".into(),46
sha256: "0".repeat(64),47
},48
preset_sha256: None,49
}50
}51
fn bounds(scene: &production::Scene) -> ([f32; 3], [f32; 3]) {52
let mut lo = [f32::MAX; 3];53
let mut hi = [f32::MIN; 3];54
for draw in &scene.draws {55
for &i in &draw.indices {56
let p = draw.positions[i as usize];57
for k in 0..3 {58
lo[k] = lo[k].min(p[k]);59
hi[k] = hi[k].max(p[k]);60
}61
}62
}63
(lo, hi)64
}65
fn clip_named(body: &PreviewCharacter, prefix: &str) -> String {66
body.clips67
.iter()68
.find(|c| c.name.contains(prefix))69
.unwrap_or_else(|| panic!("clip {prefix} ontbreekt"))70
.name71
.clone()72
}73
fn apply(m: &Mat4, p: [f32; 3]) -> [f32; 3] {74
[75
m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],76
m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],77
m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14],78
]79
}80
81
#[test]82
fn body_loader_dispatches_by_extension_and_rejects_other_formats() {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")));85
assert!(!preview::is_drawable_body(std::path::Path::new("c.obj")));86
let fbx = fbx_body();87
let direct = paperdoll_renderer::fbx_scene::load_fbx_drawable(&repo(88
"tests/fixtures/bodies/test-body-male.fbx",89
))90
.unwrap();91
assert_eq!(fbx.positions, direct.character.positions);92
assert_eq!(fbx.clips.len(), 2);93
let err = preview::load_body_file(&repo("tests/fixtures/equipment/sword.obj")).unwrap_err();94
assert!(err.contains("geen tekenbaar lichaam"), "{err}");95
// A file named .glb that actually holds FBX bytes is not silently accepted.96
let dir = scratch("renamed");97
let fake = dir.join("body.glb");98
std::fs::copy(repo("tests/fixtures/bodies/test-body-male.fbx"), &fake).unwrap();99
assert!(preview::load_body_file(&fake).is_err());100
let _ = std::fs::remove_dir_all(dir);101
}102
103
#[test]104
fn fbx_body_production_scene_matches_glb_silhouette_without_double_normalization() {105
let fbx = fbx_body();106
let glb = glb_body();107
let fbx_plan = production::plan(&fbx, 12).unwrap();108
let glb_plan = production::plan(&glb, 12).unwrap();109
assert_eq!(fbx_plan.images, glb_plan.images);110
assert_eq!(fbx_plan.files, glb_plan.files);111
let fbx_job = job(fbx, None);112
let glb_job = job(glb, None);113
let mut cache = equipment::TextureCache::default();114
// Only `walk` is compared as a whole silhouette: Blender's FBX exporter115
// baked the legs of the fixture's `attack` stack in a leftover walk pose116
// (all 60 curves present in the file), so the attack clip is compared per117
// animated arm joint below instead.118
for (prefix, time) in [("walk", 0.0), ("walk", 0.25), ("walk", 0.6)] {119
let f = production::scene(120
&fbx_job,121
&clip_named(&fbx_job.character, prefix),122
time,123
30.,124
90.,125
&mut cache,126
)127
.unwrap();128
let g = production::scene(&glb_job, prefix, time, 30., 90., &mut cache).unwrap();129
f.validate().unwrap();130
let (flo, fhi) = bounds(&f);131
let (glo, ghi) = bounds(&g);132
for k in 0..3 {133
assert!(134
(flo[k] - glo[k]).abs() < 0.03 && (fhi[k] - ghi[k]).abs() < 0.03,135
"{prefix}@{time} axis {k}: fbx {flo:?}..{fhi:?} vs glb {glo:?}..{ghi:?}"136
);137
}138
// Body height in metres (no centimetre or axis double-conversion).139
assert!((fhi[1] - flo[1]) > 1.4 && (fhi[1] - flo[1]) < 2.2);140
// Same fixed per-body framing: identical orthographic projection and141
// a neck pivot resolved from the FBX rig close to the GLB one.142
assert_eq!(f.projection, g.projection);143
assert_eq!((f.width, f.height), (g.width, g.height));144
}145
let attack = clip_named(&fbx_job.character, "attack");146
for name in [147
"RightUpperArm",148
"RightLowerArm",149
"RightHand",150
"Chest",151
"Spine",152
] {153
for time in [0.0, 0.2, 0.4, 0.7] {154
let f =155
preview::joint_world_translation(&fbx_job.character, &attack, time, name).unwrap();156
let g =157
preview::joint_world_translation(&glb_job.character, "attack", time, name).unwrap();158
let d = ((f[0] - g[0]).powi(2) + (f[1] - g[1]).powi(2) + (f[2] - g[2]).powi(2)).sqrt();159
assert!(d < 0.02, "attack {name}@{time}: {f:?} vs {g:?}");160
}161
}162
let fp = paperdoll_renderer::camera::resolve(&fbx_job.character, fbx_job.framing);163
let gp = paperdoll_renderer::camera::resolve(&glb_job.character, glb_job.framing);164
assert!(matches!(165
fp.source,166
paperdoll_renderer::camera::PivotSource::Neck { .. }167
));168
let (fy, gy) = (169
fp.framing.pivot_y_milli.unwrap(),170
gp.framing.pivot_y_milli.unwrap(),171
);172
assert!((fy - gy).abs() <= 30, "neck pivot fbx {fy} vs glb {gy}");173
}174
175
#[test]176
fn rigid_fbx_import_normalizes_axes_units_and_embeds_png() {177
let mut preset = Preset::new("fbxsword".into());178
let part = equipment::import_fbx(&mut preset, &repo("tests/fixtures/equipment/sword-fbx.fbx"))179
.unwrap();180
let item = &preset.attachments[part];181
assert_eq!(item.label, "sword-fbx.fbx");182
assert_eq!(item.meshes.len(), 2);183
let mut lo = [f32::MAX; 3];184
let mut hi = [f32::MIN; 3];185
for p in item.meshes.iter().flat_map(|m| &m.positions) {186
for k in 0..3 {187
lo[k] = lo[k].min(p[k]);188
hi[k] = hi[k].max(p[k]);189
}190
}191
// Exported Z-up from Blender: blade length must end up along +Y in metres.192
assert!(193
lo[1].abs() < 1e-3 && (hi[1] - 0.58).abs() < 1e-3,194
"{lo:?} {hi:?}"195
);196
assert!((hi[0] - 0.09).abs() < 1e-3 && (lo[0] + 0.09).abs() < 1e-3);197
assert!((hi[2] - 0.015).abs() < 1e-3 && (lo[2] + 0.015).abs() < 1e-3);198
for mesh in &item.meshes {199
assert_eq!(mesh.positions.len(), mesh.normals.len());200
assert_eq!(mesh.positions.len(), mesh.uvs.len());201
for n in &mesh.normals {202
let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();203
assert!((len - 1.).abs() < 1e-3);204
}205
}206
let blade = item.meshes.iter().find(|m| m.albedo.is_some()).unwrap();207
let guard = item.meshes.iter().find(|m| m.albedo.is_none()).unwrap();208
// A connected texture replaces the FBX diffuse colour; the guard keeps its colour.209
assert_eq!(blade.color, [1., 1., 1., 1.]);210
for (got, want) in guard.color.iter().zip([0.75, 0.55, 0.2, 1.]) {211
assert!((got - want).abs() < 0.01, "{:?}", guard.color);212
}213
// UVs keep the OBJ/FBX convention (V up): on the broad blade faces the214
// tip has V=1 and the base V=0 (the fixture maps each face to the unit square).215
let broad = |want_y: f32| {216
blade217
.positions218
.iter()219
.zip(&blade.normals)220
.zip(&blade.uvs)221
.filter(|((p, n), _)| n[2].abs() > 0.9 && (p[1] - want_y).abs() < 1e-4)222
.map(|(_, uv)| uv[1])223
.collect::<Vec<_>>()224
};225
let (tip, base) = (broad(0.58), broad(0.03));226
assert!(227
!tip.is_empty() && tip.iter().all(|v| (*v - 1.).abs() < 1e-4),228
"{tip:?}"229
);230
assert!(231
!base.is_empty() && base.iter().all(|v| v.abs() < 1e-4),232
"{base:?}"233
);234
let albedo = blade.albedo.unwrap();235
assert!(preset.assets[albedo].path.contains("/embedded/"));236
let embedded = preset.texture(albedo).unwrap();237
let mut reference = Preset::new("ref".into());238
reference.assets.push(equipment::EmbeddedAsset {239
path: "checker.png".into(),240
bytes: std::fs::read(repo("tests/fixtures/equipment/checker.png")).unwrap(),241
});242
assert_eq!(embedded.rgba, reference.texture(0).unwrap().rgba);243
// Original FBX payload is kept, and the binary preset round-trips.244
assert!(item.source_assets.iter().any(|i| preset.assets[*i].bytes245
== std::fs::read(repo("tests/fixtures/equipment/sword-fbx.fbx")).unwrap()));246
let decoded = Preset::decode(&preset.encode().unwrap()).unwrap();247
assert_eq!(decoded, preset);248
}249
250
#[test]251
fn rigid_fbx_external_png_is_embedded_and_missing_texture_fails() {252
let mut preset = Preset::new("fbxsword".into());253
let part = equipment::import_model(254
&mut preset,255
&repo("tests/fixtures/equipment/sword-fbx-external.fbx"),256
)257
.unwrap();258
let blade = preset.attachments[part]259
.meshes260
.iter()261
.find(|m| m.albedo.is_some())262
.unwrap();263
let asset = &preset.assets[blade.albedo.unwrap()];264
assert!(asset.path.ends_with("/checker.png"), "{}", asset.path);265
assert_eq!(266
asset.bytes,267
std::fs::read(repo("tests/fixtures/equipment/checker.png")).unwrap()268
);269
let dir = scratch("missing-tex");270
std::fs::copy(271
repo("tests/fixtures/equipment/sword-fbx-external.fbx"),272
dir.join("lonely.fbx"),273
)274
.unwrap();275
let mut other = Preset::new("lonely".into());276
let err = equipment::import_fbx(&mut other, &dir.join("lonely.fbx")).unwrap_err();277
assert!(err.contains("FBX-texture"), "{err}");278
assert!(other.attachments.is_empty() && other.assets.is_empty());279
let _ = std::fs::remove_dir_all(dir);280
}281
282
#[test]283
fn skinned_garbage_or_unsupported_equipment_is_rejected() {284
let mut preset = Preset::new("bad".into());285
let err = equipment::import_fbx(286
&mut preset,287
&repo("tests/fixtures/bodies/test-body-male.fbx"),288
)289
.unwrap_err();290
assert!(err.contains("geskinned"), "{err}");291
let dir = scratch("garbage");292
std::fs::write(dir.join("junk.fbx"), b"not an fbx at all").unwrap();293
assert!(equipment::import_fbx(&mut preset, &dir.join("junk.fbx")).is_err());294
assert!(equipment::import_model(295
&mut preset,296
&repo("tests/fixtures/bodies/test-body-male.glb")297
)298
.is_err());299
assert!(preset.attachments.is_empty() && preset.assets.is_empty());300
// OBJ still goes through the same dispatcher.301
equipment::import_model(&mut preset, &repo("tests/fixtures/equipment/sword.obj")).unwrap();302
assert_eq!(preset.attachments.len(), 1);303
let _ = std::fs::remove_dir_all(dir);304
}305
306
#[test]307
fn fbx_equipment_on_fbx_body_follows_the_animated_hand_in_production() {308
let body = fbx_body();309
let joint = body310
.joint_names311
.iter()312
.position(|n| n == "RightHand")313
.unwrap();314
let mut preset = Preset::new("fbxsword".into());315
equipment::import_fbx(&mut preset, &repo("tests/fixtures/equipment/sword-fbx.fbx")).unwrap();316
preset.bind(0, &body, 0, joint).unwrap();317
preset.attachments[0].rotation_deg = [0., 0., -15.];318
let attack = clip_named(&body, "attack");319
let job = job(body, Some(preset.clone()));320
let mut cache = equipment::TextureCache::default();321
let early = production::scene(&job, &attack, 0.0, 0., 0., &mut cache).unwrap();322
let late = production::scene(&job, &attack, 0.4, 0., 0., &mut cache).unwrap();323
assert_eq!(early.draws.len(), 2);324
assert!(early.draws.iter().any(|d| d.albedo.is_some()));325
assert_eq!(early.view, late.view);326
assert_ne!(early.draws[0].positions, late.draws[0].positions);327
// Exact attachment: animated joint world × local transform × source vertex.328
let world = preview::animated_joint_world(&job.character, &attack, 0.4, joint).unwrap();329
let model = preview::mul(world, equipment::local_matrix([0.; 3], [0., 0., -15.]));330
let layers = equipment::layers(331
&preset,332
0,333
&job.character,334
&PreviewControls {335
clip_name: attack.clone(),336
time: 0.4,337
..Default::default()338
},339
)340
.unwrap();341
assert_eq!(layers[0].model, model);342
let expected = apply(&model, preset.attachments[0].meshes[0].positions[0]);343
let got = late.draws[0].positions[0];344
for k in 0..3 {345
assert!(346
(expected[k] - got[k]).abs() < 1e-5,347
"{expected:?} vs {got:?}"348
);349
}350
// The hand-bound sword sits at the FBX hand, not at the origin.351
let hand = [world[12], world[13], world[14]];352
assert!(hand[1] > 0.5);353
let d = ((got[0] - hand[0]).powi(2) + (got[1] - hand[1]).powi(2) + (got[2] - hand[2]).powi(2))354
.sqrt();355
assert!(d < 0.7, "sword vertex {got:?} far from hand {hand:?}");356
// A preset bound to the GLB rig is refused on the FBX rig instead of guessing.357
let mut glb_bound = Preset::new("glbsword".into());358
equipment::import_fbx(359
&mut glb_bound,360
&repo("tests/fixtures/equipment/sword-fbx.fbx"),361
)362
.unwrap();363
let glb = glb_body();364
glb_bound365
.bind(366
0,367
&glb,368
0,369
glb.joint_names370
.iter()371
.position(|n| n == "RightHand")372
.unwrap(),373
)374
.unwrap();375
let err =376
equipment::layers(&glb_bound, 0, &job.character, &PreviewControls::default()).unwrap_err();377
assert!(err.contains("rig wijkt af"), "{err}");378
}The contents of this binary file are not shown.
The contents of this binary file are not shown.