BlueGit

golden-age paperdoll-renderer

Merge feature/fbx-live-production: FBX bodies live+production, rigid FBX equipment

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 7 days ago

Hash
f7bf41c3bf8f2fc55797f0825fac6269dc31dc3c
Pipeline
Passed
19 files changed +1180 −45
README.md +7 −5
@@ -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 -- --status
17
17
18Het 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.
18Het 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
20De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert een model. Een extern bestand wordt naar de projectmap gekopieerd; bij een bestaande naam krijgt de kopie een eigen naam. Modellen met bijbestanden kun je met hun relatieve mappenstructuur in de projectmap zetten en daar selecteren. **Importoverzicht** toont meshes, materialen, textures, joints en clips van glTF/GLB, FBX en OBJ+MTL. Voor FBX bestaat daarnaast een genormaliseerde drawable/animated scene-API voor CPU-sampling; de live 3D-preview en productierender lezen nog glTF/GLB.
20De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert een model. Een extern bestand wordt naar de projectmap gekopieerd; bij een bestaande naam krijgt de kopie een eigen naam. Modellen met bijbestanden kun je met hun relatieve mappenstructuur in de projectmap zetten en daar selecteren. **Importoverzicht** toont meshes, materialen, textures, joints en clips van glTF/GLB, FBX en OBJ+MTL. FBX-lichamen worden bij het laden één keer genormaliseerd (rechtshandig Y-up, meters) en daarna net als glTF/GLB in de live 3D-preview en de productierender getekend, met al hun animatiestacks als clips.
21
21
22**Open man / Open vrouw** opent de body. Een geskind glTF/GLB-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
531. Geef een unieke naam van maximaal 64 kleine letters/cijfers/`-`/`_`, zonder spaties; `body` is gereserveerd.
531. Geef een unieke naam van maximaal 64 kleine letters/cijfers/`-`/`_`, zonder spaties; `body` is gereserveerd.
542. Kies **Man / Vrouw** voor het lichaam dat je bekijkt; **Geldig voor** bepaalt op welke geslachten het preset van toepassing is.
542. Kies **Man / Vrouw** voor het lichaam dat je bekijkt; **Geldig voor** bepaalt op welke geslachten het preset van toepassing is.
553. **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.
553. **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.
564. 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.
564. 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.
575. **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.
575. **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.
586. **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.
586. **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
119Highres is exact 8× beide assen; batch-targets zijn momenteel **1–256 pixels per as** (highres max 2048). Grotere projectinstellingen geven een duidelijke renderfout, geen stil verkleinde supersampling. Maximaal 500000 PNGs / 16 GiB geschatte ongecomprimeerde targetdata per run; output vereist hardlinks. Productie rendert de eerste skinned glTF/GLB-mesh met driehoekprimitieven, basiskleur/8-bit albedotextures en vertexkleuren. 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.
119Highres is exact 8× beide assen; batch-targets zijn momenteel **1–256 pixels per as** (highres max 2048). Grotere projectinstellingen geven een duidelijke renderfout, geen stil verkleinde supersampling. Maximaal 500000 PNGs / 16 GiB geschatte ongecomprimeerde targetdata per run; output vereist hardlinks. Productie rendert de eerste skinned glTF/GLB-mesh met driehoekprimitieven, basiskleur/8-bit albedotextures en vertexkleuren. Een FBX-lichaam loopt via de genormaliseerde scene (`fbx_scene`) door dezelfde batch: alle animatiestacks × frames × 36 hoeken, dezelfde vaste camera, nekpivot en vaste zoom per lichaam, en dezelfde vier maps. Equipment hangt op de geanimeerde botten van dat FBX-skelet. Morph targets, tangent-normaltextures, volledige PBR en willekeurige transparante lagen zijn niet ondersteund. Live placement blijft een belichte, vereenvoudigde weergave.
120
120
121## Openen met
121## Openen met
122
122
@@ -140,6 +140,8 @@ cargo run --release --example resume_fixture --locked -- setup /tmp/resume tests
140xvfb-run -a cargo run --release --example resume_fixture --locked -- render /tmp/resume male --abort-after=200
140xvfb-run -a cargo run --release --example resume_fixture --locked -- render /tmp/resume male --abort-after=200
141xvfb-run -a cargo run --release --example resume_fixture --locked -- resume /tmp/resume male
141xvfb-run -a cargo run --release --example resume_fixture --locked -- resume /tmp/resume male
142cargo run --release --example resume_fixture --locked -- verify /tmp/resume
142cargo 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
144xvfb-run -a cargo run --example fbx_live_fixture --locked -- /tmp/fbx-live /tmp/fbx-live-proof 32 48 4
143```
145```
144
146
145De testlichamen komen uit `scripts/generate_test_bodies.py`. Het FBX-testbestand wordt met Blender via `scripts/convert_test_body_to_fbx.py` gemaakt.
147De testlichamen komen uit `scripts/generate_test_bodies.py`. Het FBX-testbestand wordt met Blender via `scripts/convert_test_body_to_fbx.py` gemaakt. De rigide FBX-zwaarden (`sword-fbx.fbx` met ingebedde PNG, `sword-fbx-external.fbx` met PNG ernaast) komen uit `scripts/make_fbx_equipment_fixture.py` (Blender, Z-up geëxporteerd).
design.md +6 −6
@@ -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 only
67
67
68This 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.
68This 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
70FBX 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).
70FBX 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
151Every 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.
151Every 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
153Current 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.
153Current 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
155Equipment 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.
155Equipment 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 presets
157## 9. Equipment editor and binary presets
158
158
159The 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`).
159The 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
161Persisted 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.
161Persisted 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
197The 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`.
197The 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
199Live 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.
199Live 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
201CI 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.
201CI 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- 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
222Current 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.
222Current 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 gates
224## 12. Incremental feature plan and acceptance gates
225
225
examples/equipment_fixture.rs +1 −1
@@ -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 = body
33 let joint = body
34 .joint_names
34 .joint_names
35 .iter()
35 .iter()
examples/fbx_live_fixture.rs added +292 −0
@@ -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 batch
5//! for the body set and the equipment set, and verifies both runs with `export_set`.
6use eframe::App;
7use paperdoll_renderer::{
8 editor::EditorApp,
9 equipment, export_set, preview,
10 session::{adopt_file, Session, Sex},
11};
12use std::path::{Path, PathBuf};
13
14const SETTLE_FRAMES: u64 = 40;
15
16struct 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
26fn 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
34impl 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
84impl 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
135fn 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
208fn 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}
examples/production_fixture.rs +3 −3
@@ -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]
3use eframe::App;
3use eframe::App;
4use paperdoll_renderer::{
4use 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 = b
78 let joint = b
79 .joint_names
79 .joint_names
80 .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()?)?
scripts/make_fbx_equipment_fixture.py added +98 −0
@@ -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 python3
2"""Generate the CC0 rigid FBX equipment fixture with Blender.
3
4Writes tests/fixtures/equipment/sword-fbx.fbx: a blade with the CC0 checker
5texture embedded in the FBX and a plain-colour guard, and
6sword-fbx-external.fbx: the same model referencing checker.png next to it
7instead of embedding it. The files are exported
8Z-up in centimetres so the importer has to perform the axis/unit
9normalisation itself. Re-run when needed:
10
11 blender --background --factory-startup --python scripts/make_fbx_equipment_fixture.py
12
13Blender is only a fixture tool; the application never calls Blender.
14"""
15
16from pathlib import Path
17
18import bpy
19
20ROOT = Path(__file__).resolve().parents[1]
21FIXTURES = ROOT / "tests" / "fixtures" / "equipment"
22TARGET = FIXTURES / "sword-fbx.fbx"
23EXTERNAL = FIXTURES / "sword-fbx-external.fbx"
24
25
26def 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
49def 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
97if __name__ == "__main__":
98 main()
src/dialogs.rs +2 −0
@@ -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(_) => dialog
131 DialogPurpose::EquipmentModel(_) => dialog
132 .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(_, _) => dialog
137 DialogPurpose::EquipmentShiny(_, _) => dialog
136 .set_title("Shinymap")
138 .set_title("Shinymap")
src/editor.rs +50 −16
@@ -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;
7use crate::compositor::MAX_RIM_INTENSITY;
7use crate::compositor::MAX_RIM_INTENSITY;
8use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};
8use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};
9use crate::import::{import_project_model, ImportedModel};
9use crate::import::{import_project_model, ImportedModel};
10use crate::preview::{load_gltf_file, PreviewCharacter, PreviewControls, PreviewSex};
10use crate::preview::{
11 is_drawable_body, load_body_file, PreviewCharacter, PreviewControls, PreviewSex,
12};
11use crate::preview2d::{
13use 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.session
414 .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.viewport
1012 self.viewport
1017 .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");
src/equipment.rs +281 −0
@@ -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).
741pub 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.
759pub 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
740pub fn import_shiny(preset: &mut Preset, attachment: usize, source: &Path) -> Result<(), String> {
1021pub 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 = source
1023 let name = source
src/fbx_scene.rs +20 −5
@@ -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 retains
4//! builds a `PreviewCharacter` that samples like the glTF path, and retains
5//! conversion provenance. Live Glow viewport and production four-map batch
5//! conversion provenance. The live Glow viewport and the production four-map
6//! 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 this
7//! loader; no further axis/unit conversion is applied downstream.
7
8
8use std::io::Cursor;
9use std::io::Cursor;
9use std::path::Path;
10use 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` UV
699/// convention (origin top-left) used by the production body path.
700pub(crate) fn preview_uv(u: f64, v: f64) -> [f32; 2] {
701 [u as f32, (1.0 - v) as f32]
702}
703
696fn skin_influences(
704fn 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()));
src/foundation.rs +7 −4
@@ -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 FBX
6//! 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
119pub fn dutch_summary() -> &'static str {
120pub 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]
src/import.rs +1 −1
@@ -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}
src/main.rs +1 −1
@@ -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
5use std::process::ExitCode;
5use std::process::ExitCode;
6
6
src/preview.rs +30 −0
@@ -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.
144pub 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).
157pub 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
143pub fn load_gltf_file(path: &Path) -> Result<PreviewCharacter, String> {
173pub 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();
src/preview_gl.rs +2 −2
@@ -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 paperdoll
3//! The shaded mesh is a placement preview. It is not the unlit paperdoll
4//! diffuse/depth/normal export.
4//! diffuse/depth/normal export.
@@ -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 );
src/session.rs +1 −1
@@ -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}
tests/fbx_integration.rs added +378 −0
@@ -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.
2use paperdoll_renderer::equipment::{self, Preset};
3use paperdoll_renderer::preview::{self, Mat4, PreviewCharacter, PreviewControls};
4use paperdoll_renderer::production::{self, RenderJob, SourceFingerprint};
5use paperdoll_renderer::project::CameraFraming;
6use std::path::PathBuf;
7
8fn repo(path: &str) -> PathBuf {
9 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(path)
10}
11fn fbx_body() -> PreviewCharacter {
12 preview::load_body_file(&repo("tests/fixtures/bodies/test-body-male.fbx")).unwrap()
13}
14fn glb_body() -> PreviewCharacter {
15 preview::load_body_file(&repo("tests/fixtures/bodies/test-body-male.glb")).unwrap()
16}
17fn 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}
28fn 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}
51fn 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}
65fn 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}
73fn 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]
82fn 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]
104fn 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]
176fn 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]
251fn 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]
283fn 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]
307fn 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}
tests/fixtures/equipment/sword-fbx-external.fbx added Binary file +0 −0

The contents of this binary file are not shown.

tests/fixtures/equipment/sword-fbx.fbx added Binary file +0 −0

The contents of this binary file are not shown.