BlueGit

golden-age paperdoll-renderer

Add normalized drawable/animated FBX scene via ufbx

Build an in-process FBX scene (fbx_scene) with unit/axis normalization,
materials/textures, skin inverse-binds, and baked sampleable clips.
Fixture tests compare bind-equivalent and animated joints against the
matching GLB. Live viewport and production remain glTF/GLB-only.

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 7 days ago

Hash
2a20c651376542a73283b353329642c906a360bd
Parent
b78ad139
6 files changed +1126 −19
README.md +2 −2
@@ -17,7 +17,7 @@ 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.
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.
21 21
22 22 **Open man / Open vrouw** opent de body. Een geskind glTF/GLB-model is in de live preview zichtbaar:
23 23
@@ -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 is alleen importoverzicht; 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. 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.
120 120
121 121 ## Openen met
122 122
@@ -17,7 +17,7 @@ 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.
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.
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-model is in de live preview zichtbaar:
23
23
@@ -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 is alleen importoverzicht; 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. 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.
120
120
121## Openen met
121## Openen met
122
122
design.md +5 −5
@@ -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. The live viewport samples glTF/GLB. FBX stays in the import inventory, and the production orthographic export now renders glTF/GLB, not FBX. 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 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).
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 and bone parents | Takes as animation stacks, with duration and transform channels | Read in this build by vendored ufbx. Not rendered. Not a claim that every FBX dialect matches. |
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. |
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,7 +150,7 @@ 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 remains inventory-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 has a normalized drawable/animated CPU scene (`fbx_scene`); live/production GPU paths remain glTF/GLB-only.
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
@@ -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 is not drawn. 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 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.
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 drawing remains unimplemented.
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.
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. The live viewport samples glTF/GLB. FBX stays in the import inventory, and the production orthographic export now renders glTF/GLB, not FBX. 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 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).
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 and bone parents | Takes as animation stacks, with duration and transform channels | Read in this build by vendored ufbx. Not rendered. Not a claim that every FBX dialect matches. |
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. |
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,7 +150,7 @@ 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 remains inventory-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 has a normalized drawable/animated CPU scene (`fbx_scene`); live/production GPU paths remain glTF/GLB-only.
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
@@ -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 is not drawn. 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 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.
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 drawing remains unimplemented.
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.
223
223
224## 12. Incremental feature plan and acceptance gates
224## 12. Incremental feature plan and acceptance gates
225
225
src/fbx_scene.rs added +1096 −0
@@ -0,0 +1,1096 @@
1 //! In-process normalized drawable/animated FBX scene.
2 //!
3 //! Loads FBX through vendored ufbx once with unit/axis/winding normalization,
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.
7
8 use std::io::Cursor;
9 use std::path::Path;
10 use std::sync::Arc;
11
12 use crate::equipment::Texture;
13 use crate::import::FBX_BACKEND;
14 use crate::preview::{
15 AnimChannel, BodySurface, ChannelProperty, Mat4, PreviewCharacter, PreviewClip, SceneNode,
16 MAX_JOINTS,
17 };
18
19 const MAX_BYTES: u64 = 32 * 1024 * 1024;
20 const MAX_VERTICES: usize = 1_000_000;
21 const MAX_INDICES: usize = 3_000_000;
22 const TARGET_UNIT_METERS: f64 = 1.0;
23
24 /// Metadata describing the one-shot conversion applied while loading.
25 #[derive(Debug, Clone, PartialEq)]
26 pub struct ConversionProvenance {
27 pub source_path: String,
28 pub backend: String,
29 pub target_axes: String,
30 pub target_unit_meters: f64,
31 pub source_unit_meters: f64,
32 pub space_conversion: String,
33 pub geometry_transform_handling: String,
34 pub mirror_axis: String,
35 pub winding_reversed: bool,
36 pub geometry_scale: f64,
37 pub notices: Vec<String>,
38 }
39
40 /// Material as reported by ufbx after normalization.
41 #[derive(Debug, Clone, PartialEq)]
42 pub struct FbxMaterial {
43 pub name: String,
44 pub base_color: [f32; 4],
45 pub base_color_texture: Option<String>,
46 pub normal_texture: Option<String>,
47 }
48
49 /// Texture bytes that ufbx reported (file path relative label or embedded).
50 #[derive(Debug, Clone, PartialEq)]
51 pub struct FbxTextureRef {
52 pub name: String,
53 pub source: String,
54 pub width: u32,
55 pub height: u32,
56 pub embedded: bool,
57 }
58
59 /// Normalized drawable FBX scene ready for the shared sampling API.
60 #[derive(Debug, Clone)]
61 pub struct FbxDrawableScene {
62 pub character: PreviewCharacter,
63 pub provenance: ConversionProvenance,
64 pub materials: Vec<FbxMaterial>,
65 pub textures: Vec<FbxTextureRef>,
66 }
67
68 /// Load an FBX file into a normalized drawable/animated scene.
69 pub fn load_fbx_drawable(path: &Path) -> Result<FbxDrawableScene, String> {
70 let path_str = path
71 .to_str()
72 .ok_or_else(|| "het FBX-pad is geen UTF-8".to_string())?;
73 let meta = std::fs::metadata(path).map_err(|err| format!("FBX openen mislukt: {err}"))?;
74 if !meta.is_file() {
75 return Err(format!("FBX-bestand ontbreekt: {}", path.display()));
76 }
77 if meta.len() > MAX_BYTES {
78 return Err(format!(
79 "FBX-bestand is groter dan {MAX_BYTES} bytes ({})",
80 meta.len()
81 ));
82 }
83
84 let opts = ufbx::LoadOpts {
85 target_axes: ufbx::axes_right_handed_y_up(),
86 target_unit_meters: TARGET_UNIT_METERS,
87 space_conversion: ufbx::SpaceConversion::ModifyGeometry,
88 geometry_transform_handling: ufbx::GeometryTransformHandling::ModifyGeometry,
89 generate_missing_normals: true,
90 clean_skin_weights: true,
91 normalize_normals: true,
92 load_external_files: true,
93 ignore_missing_external_files: false,
94 ..Default::default()
95 };
96 let scene = ufbx::load_file(path_str, opts).map_err(|err| {
97 format!(
98 "FBX laden mislukt ({}): {}",
99 path.display(),
100 err.description
101 )
102 })?;
103
104 let mut notices = Vec::new();
105 notices.push(format!(
106 "FBX genormaliseerd met {FBX_BACKEND}; assen right-handed Y-up, eenheden meters."
107 ));
108 notices.push(
109 "Drawable/animated scene is beschikbaar voor sampling; live viewport en productie gebruiken nog glTF/GLB."
110 .into(),
111 );
112
113 if scene.metadata.geometry_ignored {
114 return Err("FBX heeft geometrie genegeerd; bestand is niet tekenbaar".into());
115 }
116 if scene.metadata.animation_ignored {
117 notices.push("animatiegegevens waren gemarkeerd als genegeerd door de loader".into());
118 }
119
120 let (mesh_node_index, mesh) = find_skinned_mesh(&scene)?;
121 let _ = mesh_node_index;
122 let skin = mesh
123 .skin_deformers
124 .first()
125 .ok_or_else(|| "skinned mesh heeft geen skin deformer".to_string())?;
126
127 match skin.skinning_method {
128 ufbx::SkinningMethod::Linear | ufbx::SkinningMethod::Rigid => {}
129 other => {
130 return Err(format!(
131 "niet-ondersteunde FBX-skinningmethode {:?}; alleen linear/rigid",
132 other
133 ));
134 }
135 }
136 if skin.clusters.is_empty() {
137 return Err("FBX-skin heeft geen clusters".into());
138 }
139 if skin.clusters.len() > MAX_JOINTS {
140 return Err(format!(
141 "FBX-skeleton heeft {} joints; limiet is {MAX_JOINTS}",
142 skin.clusters.len()
143 ));
144 }
145 if !mesh.blend_deformers.is_empty() {
146 notices.push(format!(
147 "{} blend deformers genegeerd (morph targets niet ondersteund)",
148 mesh.blend_deformers.len()
149 ));
150 }
151
152 let nodes = build_nodes(&scene)?;
153 let (joint_nodes, joint_names, inverse_binds) = build_skin(skin)?;
154
155 let (materials, textures, surfaces_base) = collect_materials(path, &scene, mesh, &mut notices)?;
156
157 let (
158 positions,
159 normals,
160 source_normals_complete,
161 joints,
162 weights,
163 indices,
164 colors,
165 uvs,
166 surfaces,
167 ) = extract_mesh(mesh, skin, &surfaces_base)?;
168
169 if positions.len() > MAX_VERTICES || indices.len() > MAX_INDICES {
170 return Err(format!(
171 "FBX-mesh te groot ({} vertices, {} indices)",
172 positions.len(),
173 indices.len()
174 ));
175 }
176 if positions.iter().any(|p| p.iter().any(|v| !v.is_finite()))
177 || normals.iter().any(|n| n.iter().any(|v| !v.is_finite()))
178 {
179 return Err("FBX-mesh bevat niet-eindige posities of normalen".into());
180 }
181
182 let clips = build_clips(&scene, &nodes)?;
183 if clips.is_empty() {
184 notices.push("geen animatiestacks met transformkanalen gevonden".into());
185 }
186
187 let winding_reversed = mesh.reversed_winding;
188 let provenance = ConversionProvenance {
189 source_path: path.display().to_string(),
190 backend: FBX_BACKEND.to_string(),
191 target_axes: "right-handed Y-up".into(),
192 target_unit_meters: TARGET_UNIT_METERS,
193 source_unit_meters: scene.settings.original_unit_meters as f64,
194 space_conversion: format!("{:?}", scene.metadata.space_conversion),
195 geometry_transform_handling: format!("{:?}", scene.metadata.geometry_transform_handling),
196 mirror_axis: format!("{:?}", scene.metadata.mirror_axis),
197 winding_reversed,
198 geometry_scale: scene.metadata.geometry_scale as f64,
199 notices: notices.clone(),
200 };
201
202 let base_color = surfaces
203 .first()
204 .map(|s| s.color)
205 .unwrap_or([0.7, 0.7, 0.75, 1.0]);
206
207 Ok(FbxDrawableScene {
208 character: PreviewCharacter {
209 nodes,
210 joint_nodes,
211 joint_names,
212 inverse_binds,
213 positions,
214 normals,
215 source_normals_complete,
216 joints,
217 weights,
218 indices,
219 base_color,
220 colors,
221 uvs,
222 surfaces,
223 clips,
224 },
225 provenance,
226 materials,
227 textures,
228 })
229 }
230
231 fn find_skinned_mesh(scene: &ufbx::Scene) -> Result<(usize, &ufbx::Mesh), String> {
232 for node in &scene.nodes {
233 if let Some(mesh) = node.mesh.as_ref() {
234 if mesh.num_vertices == 0 || mesh.num_triangles == 0 {
235 continue;
236 }
237 if mesh.skin_deformers.is_empty() {
238 continue;
239 }
240 return Ok((node.element.typed_id as usize, mesh));
241 }
242 }
243 Err("FBX bevat geen skinned mesh met driehoeken".into())
244 }
245
246 fn build_nodes(scene: &ufbx::Scene) -> Result<Vec<SceneNode>, String> {
247 let mut nodes = Vec::with_capacity(scene.nodes.len());
248 for node in &scene.nodes {
249 let parent = if node.is_root {
250 None
251 } else {
252 node.parent
253 .as_ref()
254 .map(|parent| parent.element.typed_id as usize)
255 };
256 if node.has_geometry_transform && node.is_geometry_transform_helper {
257 return Err(format!(
258 "node '{}' is een geometry-transform helper die niet is weggenomen; dit dialect wordt niet stil geaccepteerd",
259 node.element.name.trim()
260 ));
261 }
262 let t = node.local_transform;
263 let translation = [
264 t.translation.x as f32,
265 t.translation.y as f32,
266 t.translation.z as f32,
267 ];
268 let rotation = [
269 t.rotation.x as f32,
270 t.rotation.y as f32,
271 t.rotation.z as f32,
272 t.rotation.w as f32,
273 ];
274 let scale = [t.scale.x as f32, t.scale.y as f32, t.scale.z as f32];
275 if !translation
276 .iter()
277 .chain(rotation.iter())
278 .chain(scale.iter())
279 .all(|v| v.is_finite())
280 {
281 return Err(format!(
282 "node '{}' heeft niet-eindige lokale transform",
283 node.element.name.trim()
284 ));
285 }
286 nodes.push(SceneNode {
287 name: node.element.name.trim().to_string(),
288 parent,
289 translation,
290 rotation,
291 scale,
292 });
293 }
294 for (index, node) in scene.nodes.iter().enumerate() {
295 if node.element.typed_id as usize != index {
296 return Err(format!(
297 "ufbx typed_id {} komt niet overeen met node-index {index}",
298 node.element.typed_id
299 ));
300 }
301 }
302 Ok(nodes)
303 }
304
305 type SkinBuild = (Vec<usize>, Vec<String>, Vec<Mat4>);
306 type MaterialBuild = (Vec<FbxMaterial>, Vec<FbxTextureRef>, Vec<SurfaceSeed>);
307 type MeshBuild = (
308 Vec<[f32; 3]>,
309 Vec<[f32; 3]>,
310 bool,
311 Vec<[u16; 4]>,
312 Vec<[f32; 4]>,
313 Vec<u32>,
314 Vec<[f32; 4]>,
315 Vec<[f32; 2]>,
316 Vec<BodySurface>,
317 );
318
319 fn build_skin(skin: &ufbx::SkinDeformer) -> Result<SkinBuild, String> {
320 let mut joint_nodes = Vec::with_capacity(skin.clusters.len());
321 let mut joint_names = Vec::with_capacity(skin.clusters.len());
322 let mut inverse_binds = Vec::with_capacity(skin.clusters.len());
323 for cluster in &skin.clusters {
324 let bone = cluster
325 .bone_node
326 .as_ref()
327 .ok_or_else(|| "skin cluster zonder bone_node".to_string())?;
328 let name = bone.element.name.trim();
329 if name.is_empty() {
330 return Err("skin cluster bone heeft geen naam".into());
331 }
332 joint_nodes.push(bone.element.typed_id as usize);
333 joint_names.push(name.to_string());
334 let ib = matrix_to_mat4(&cluster.geometry_to_bone);
335 if !ib.iter().all(|v| v.is_finite()) {
336 return Err(format!("inverse-bind van '{name}' is niet eindig"));
337 }
338 inverse_binds.push(ib);
339 }
340 Ok((joint_nodes, joint_names, inverse_binds))
341 }
342
343 fn matrix_to_mat4(m: &ufbx::Matrix) -> Mat4 {
344 [
345 m.m00 as f32,
346 m.m10 as f32,
347 m.m20 as f32,
348 0.0,
349 m.m01 as f32,
350 m.m11 as f32,
351 m.m21 as f32,
352 0.0,
353 m.m02 as f32,
354 m.m12 as f32,
355 m.m22 as f32,
356 0.0,
357 m.m03 as f32,
358 m.m13 as f32,
359 m.m23 as f32,
360 1.0,
361 ]
362 }
363
364 struct SurfaceSeed {
365 color: [f32; 4],
366 albedo: Option<Arc<Texture>>,
367 cutoff: f32,
368 }
369
370 fn collect_materials(
371 path: &Path,
372 scene: &ufbx::Scene,
373 mesh: &ufbx::Mesh,
374 notices: &mut Vec<String>,
375 ) -> Result<MaterialBuild, String> {
376 let mut materials = Vec::new();
377 let mut textures = Vec::new();
378 let mut seeds = Vec::new();
379
380 let material_list: Vec<&ufbx::Material> = if mesh.materials.is_empty() {
381 (&scene.materials).into_iter().collect()
382 } else {
383 (&mesh.materials).into_iter().collect()
384 };
385
386 if material_list.is_empty() {
387 seeds.push(SurfaceSeed {
388 color: [0.7, 0.7, 0.75, 1.0],
389 albedo: None,
390 cutoff: 0.001,
391 });
392 return Ok((materials, textures, seeds));
393 }
394
395 for material in material_list {
396 let color_map = if material.pbr.base_color.has_value {
397 &material.pbr.base_color
398 } else {
399 &material.fbx.diffuse_color
400 };
401 let base_color = [
402 color_map.value_vec4.x as f32,
403 color_map.value_vec4.y as f32,
404 color_map.value_vec4.z as f32,
405 if color_map.value_vec4.w == 0.0 {
406 1.0
407 } else {
408 color_map.value_vec4.w as f32
409 },
410 ];
411 if !base_color.iter().all(|c| c.is_finite()) {
412 return Err(format!(
413 "materiaal '{}' heeft niet-eindige basiskleur",
414 material.element.name.trim()
415 ));
416 }
417
418 let (base_tex_name, base_albedo) =
419 resolve_texture(path, color_map.texture.as_ref(), &mut textures, notices)?;
420 let (normal_tex_name, _) = resolve_texture(
421 path,
422 material
423 .fbx
424 .normal_map
425 .texture
426 .as_ref()
427 .or(material.pbr.normal_map.texture.as_ref()),
428 &mut textures,
429 notices,
430 )?;
431
432 materials.push(FbxMaterial {
433 name: {
434 let name = material.element.name.trim();
435 if name.is_empty() {
436 "materiaal".into()
437 } else {
438 name.to_string()
439 }
440 },
441 base_color,
442 base_color_texture: base_tex_name.clone(),
443 normal_texture: normal_tex_name,
444 });
445 seeds.push(SurfaceSeed {
446 color: base_color,
447 albedo: base_albedo,
448 cutoff: 0.001,
449 });
450 }
451 Ok((materials, textures, seeds))
452 }
453
454 fn resolve_texture(
455 fbx_path: &Path,
456 texture: Option<&ufbx::Ref<ufbx::Texture>>,
457 textures: &mut Vec<FbxTextureRef>,
458 notices: &mut Vec<String>,
459 ) -> Result<(Option<String>, Option<Arc<Texture>>), String> {
460 let Some(texture) = texture else {
461 return Ok((None, None));
462 };
463 if texture.content.size > 0 {
464 let name = {
465 let n = texture.element.name.trim();
466 if n.is_empty() {
467 format!("ingebed:{}", textures.len())
468 } else {
469 format!("ingebed:{n}")
470 }
471 };
472 let decoded = decode_png_bytes(&texture.content)
473 .map_err(|err| format!("ingebedde texture '{name}' kon niet gelezen worden: {err}"))?;
474 if decoded.width > 2048 || decoded.height > 2048 {
475 return Err(format!("texture '{name}' is groter dan 2048×2048"));
476 }
477 let width = decoded.width;
478 let height = decoded.height;
479 let arc = Arc::new(decoded);
480 textures.push(FbxTextureRef {
481 name: name.clone(),
482 source: name.clone(),
483 width,
484 height,
485 embedded: true,
486 });
487 return Ok((Some(name), Some(arc)));
488 }
489
490 let relative = texture.relative_filename.trim().replace('\\', "/");
491 let filename = if relative.is_empty() {
492 texture.filename.trim().replace('\\', "/")
493 } else {
494 relative
495 };
496 if filename.is_empty() {
497 return Ok((None, None));
498 }
499 reject_escape(&filename)?;
500 let base = fbx_path.parent().unwrap_or(fbx_path);
501 let full = base.join(&filename);
502 if full.is_file() {
503 let bytes = std::fs::read(&full)
504 .map_err(|err| format!("texture '{filename}' lezen mislukt: {err}"))?;
505 let decoded = decode_png_bytes(&bytes)
506 .map_err(|err| format!("texture '{filename}' is geen geldige 8-bit PNG: {err}"))?;
507 if decoded.width > 2048 || decoded.height > 2048 {
508 return Err(format!("texture '{filename}' is groter dan 2048×2048"));
509 }
510 let width = decoded.width;
511 let height = decoded.height;
512 let arc = Arc::new(decoded);
513 textures.push(FbxTextureRef {
514 name: filename.clone(),
515 source: filename.clone(),
516 width,
517 height,
518 embedded: false,
519 });
520 Ok((Some(filename), Some(arc)))
521 } else if texture.has_file {
522 Err(format!("FBX-texture ontbreekt: {filename}"))
523 } else {
524 notices.push(format!(
525 "texture-referentie '{filename}' zonder bestand genegeerd"
526 ));
527 Ok((Some(filename), None))
528 }
529 }
530
531 fn reject_escape(relative: &str) -> Result<(), String> {
532 if relative.starts_with('/')
533 || relative.starts_with('\\')
534 || Path::new(relative).components().any(|c| {
535 matches!(
536 c,
537 std::path::Component::ParentDir | std::path::Component::RootDir
538 )
539 })
540 {
541 return Err(format!("onveilig texturepad geweigerd: {relative}"));
542 }
543 Ok(())
544 }
545
546 fn decode_png_bytes(bytes: &[u8]) -> Result<Texture, String> {
547 let mut decoder = png::Decoder::new(Cursor::new(bytes));
548 decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
549 let mut reader = decoder.read_info().map_err(|e| e.to_string())?;
550 let info = reader.info();
551 if info.width == 0 || info.height == 0 || info.width > 2048 || info.height > 2048 {
552 return Err("textureafmeting ongeldig of >2048".into());
553 }
554 let size = reader
555 .output_buffer_size()
556 .ok_or_else(|| "texturebuffer te groot".to_string())?;
557 let mut buffer = vec![0u8; size];
558 let frame = reader.next_frame(&mut buffer).map_err(|e| e.to_string())?;
559 let mut rgba = Vec::with_capacity((frame.width * frame.height * 4) as usize);
560 match frame.color_type {
561 png::ColorType::Grayscale => {
562 for &p in &buffer[..frame.buffer_size()] {
563 rgba.extend_from_slice(&[p, p, p, 255]);
564 }
565 }
566 png::ColorType::GrayscaleAlpha => {
567 for chunk in buffer[..frame.buffer_size()].chunks_exact(2) {
568 rgba.extend_from_slice(&[chunk[0], chunk[0], chunk[0], chunk[1]]);
569 }
570 }
571 png::ColorType::Rgb => {
572 for chunk in buffer[..frame.buffer_size()].chunks_exact(3) {
573 rgba.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]);
574 }
575 }
576 png::ColorType::Rgba => rgba.extend_from_slice(&buffer[..frame.buffer_size()]),
577 other => return Err(format!("niet-ondersteund PNG-kleurtype {other:?}")),
578 }
579 use std::hash::{Hash, Hasher};
580 let mut hasher = std::collections::hash_map::DefaultHasher::new();
581 rgba.hash(&mut hasher);
582 Ok(Texture {
583 content_hash: hasher.finish(),
584 width: frame.width,
585 height: frame.height,
586 rgba,
587 })
588 }
589
590 fn extract_mesh(
591 mesh: &ufbx::Mesh,
592 skin: &ufbx::SkinDeformer,
593 seeds: &[SurfaceSeed],
594 ) -> Result<MeshBuild, String> {
595 let source_normals_complete = mesh.vertex_normal.exists && !mesh.generated_normals;
596 let mut positions = Vec::new();
597 let mut normals = Vec::new();
598 let mut joints = Vec::new();
599 let mut weights = Vec::new();
600 let mut indices = Vec::new();
601 let mut colors = Vec::new();
602 let mut uvs = Vec::new();
603 let mut surfaces = Vec::new();
604
605 let part_count = mesh.material_parts.len().max(1);
606 for part_index in 0..part_count {
607 let face_indices: Vec<u32> = if mesh.material_parts.is_empty() {
608 (0..mesh.faces.len() as u32).collect()
609 } else {
610 mesh.material_parts[part_index]
611 .face_indices
612 .iter()
613 .copied()
614 .collect()
615 };
616 if face_indices.is_empty() {
617 continue;
618 }
619 let seed = seeds
620 .get(part_index.min(seeds.len().saturating_sub(1)))
621 .ok_or_else(|| "materiaalpart zonder seed".to_string())?;
622 let first = indices.len();
623 let mut tri_buf = Vec::new();
624 for face_index in face_indices {
625 let face = mesh.faces[face_index as usize];
626 if face.num_indices < 3 {
627 continue;
628 }
629 let tri_count = ufbx::triangulate_face_vec(&mut tri_buf, mesh, face);
630 for t in 0..tri_count as usize {
631 let corner_indices = [tri_buf[t * 3], tri_buf[t * 3 + 1], tri_buf[t * 3 + 2]];
632 for &corner in &corner_indices {
633 let corner = corner as usize;
634 let pos = &mesh.vertex_position[corner];
635 let position = [pos.x as f32, pos.y as f32, pos.z as f32];
636 let normal = if mesh.vertex_normal.exists {
637 let n = &mesh.vertex_normal[corner];
638 [n.x as f32, n.y as f32, n.z as f32]
639 } else {
640 [0.0, 1.0, 0.0]
641 };
642 let uv = if mesh.vertex_uv.exists {
643 let u = &mesh.vertex_uv[corner];
644 [u.x as f32, u.y as f32]
645 } else {
646 [0.0, 0.0]
647 };
648 let color = if mesh.vertex_color.exists {
649 let c = &mesh.vertex_color[corner];
650 [c.x as f32, c.y as f32, c.z as f32, c.w as f32]
651 } else {
652 [1.0, 1.0, 1.0, 1.0]
653 };
654 let vertex_ix = mesh.vertex_indices[corner] as usize;
655 let (j, w) = skin_influences(skin, vertex_ix)?;
656 let out_index = positions.len() as u32;
657 positions.push(position);
658 normals.push(normal);
659 uvs.push(uv);
660 colors.push(color);
661 joints.push(j);
662 weights.push(w);
663 indices.push(out_index);
664 }
665 }
666 }
667 let count = indices.len() - first;
668 if count > 0 {
669 surfaces.push(BodySurface {
670 first,
671 count,
672 color: seed.color,
673 albedo: seed.albedo.clone(),
674 cutoff: seed.cutoff,
675 flat_normals: !mesh.vertex_normal.exists,
676 });
677 }
678 }
679
680 if positions.is_empty() || indices.len() < 3 {
681 return Err("FBX-mesh heeft geen tekenbare driehoeken na triangulatie".into());
682 }
683 Ok((
684 positions,
685 normals,
686 source_normals_complete,
687 joints,
688 weights,
689 indices,
690 colors,
691 uvs,
692 surfaces,
693 ))
694 }
695
696 fn skin_influences(
697 skin: &ufbx::SkinDeformer,
698 vertex: usize,
699 ) -> Result<([u16; 4], [f32; 4]), String> {
700 if vertex >= skin.vertices.len() {
701 return Err(format!("skin vertex-index {vertex} buiten bereik"));
702 }
703 let sv = &skin.vertices[vertex];
704 let mut pairs: Vec<(u16, f32)> = Vec::new();
705 for i in 0..sv.num_weights as usize {
706 let w = &skin.weights[sv.weight_begin as usize + i];
707 if w.cluster_index as usize >= skin.clusters.len() {
708 return Err(format!(
709 "skin weight verwijst naar onbekende cluster {}",
710 w.cluster_index
711 ));
712 }
713 let weight = w.weight as f32;
714 if !weight.is_finite() || weight < 0.0 {
715 return Err("skin weight is niet eindig of negatief".into());
716 }
717 if weight > 0.0 {
718 pairs.push((w.cluster_index as u16, weight));
719 }
720 }
721 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
722 pairs.truncate(4);
723 if pairs.is_empty() {
724 pairs.push((0, 1.0));
725 }
726 let sum: f32 = pairs.iter().map(|(_, w)| *w).sum();
727 if sum <= 1e-8 {
728 return Err("skin weights sommeren tot nul".into());
729 }
730 let mut joints = [0u16; 4];
731 let mut weights = [0.0f32; 4];
732 for (i, (j, w)) in pairs.into_iter().enumerate() {
733 joints[i] = j;
734 weights[i] = w / sum;
735 }
736 Ok((joints, weights))
737 }
738
739 fn build_clips(scene: &ufbx::Scene, nodes: &[SceneNode]) -> Result<Vec<PreviewClip>, String> {
740 let mut clips = Vec::new();
741 for stack in &scene.anim_stacks {
742 let bake = ufbx::bake_anim(
743 scene,
744 &stack.anim,
745 ufbx::BakeOpts {
746 resample_rate: 30.0,
747 minimum_sample_rate: 1.0,
748 maximum_sample_rate: 120.0,
749 trim_start_time: true,
750 key_reduction_enabled: true,
751 key_reduction_rotation: true,
752 key_reduction_threshold: 1e-4,
753 ..Default::default()
754 },
755 )
756 .map_err(|err| {
757 format!(
758 "animatiestack '{}' bakken mislukt: {}",
759 stack.element.name.trim(),
760 err.description
761 )
762 })?;
763
764 let mut channels = Vec::new();
765 for baked in &bake.nodes {
766 let node = baked.typed_id as usize;
767 if node >= nodes.len() {
768 return Err(format!(
769 "gebakken node typed_id {node} buiten bereik ({} nodes)",
770 nodes.len()
771 ));
772 }
773 push_vec3_channel(
774 &mut channels,
775 node,
776 ChannelProperty::Translation,
777 &baked.translation_keys,
778 )?;
779 push_quat_channel(&mut channels, node, &baked.rotation_keys)?;
780 push_vec3_channel(
781 &mut channels,
782 node,
783 ChannelProperty::Scale,
784 &baked.scale_keys,
785 )?;
786 }
787 if channels.is_empty() {
788 continue;
789 }
790 let duration = if bake.playback_duration > 0.0 {
791 bake.playback_duration as f32
792 } else {
793 (stack.time_end - stack.time_begin).max(0.0) as f32
794 };
795 let name = {
796 let raw = stack.element.name.trim();
797 if raw.is_empty() {
798 "clip".to_string()
799 } else {
800 raw.to_string()
801 }
802 };
803 clips.push(PreviewClip {
804 name,
805 duration,
806 channels,
807 });
808 }
809 Ok(clips)
810 }
811
812 fn push_vec3_channel(
813 channels: &mut Vec<AnimChannel>,
814 node: usize,
815 property: ChannelProperty,
816 keys: &[ufbx::BakedVec3],
817 ) -> Result<(), String> {
818 if keys.is_empty() {
819 return Ok(());
820 }
821 let mut times = Vec::with_capacity(keys.len());
822 let mut values = Vec::with_capacity(keys.len() * 3);
823 for key in keys {
824 let t = key.time as f32;
825 let v = [key.value.x as f32, key.value.y as f32, key.value.z as f32];
826 if !t.is_finite() || !v.iter().all(|n| n.is_finite()) {
827 return Err("gebakken vec3-kanaal heeft niet-eindige waarden".into());
828 }
829 times.push(t);
830 values.extend_from_slice(&v);
831 }
832 channels.push(AnimChannel {
833 node,
834 property,
835 times,
836 values,
837 });
838 Ok(())
839 }
840
841 fn push_quat_channel(
842 channels: &mut Vec<AnimChannel>,
843 node: usize,
844 keys: &[ufbx::BakedQuat],
845 ) -> Result<(), String> {
846 if keys.is_empty() {
847 return Ok(());
848 }
849 let mut times = Vec::with_capacity(keys.len());
850 let mut values = Vec::with_capacity(keys.len() * 4);
851 for key in keys {
852 let t = key.time as f32;
853 let v = [
854 key.value.x as f32,
855 key.value.y as f32,
856 key.value.z as f32,
857 key.value.w as f32,
858 ];
859 if !t.is_finite() || !v.iter().all(|n| n.is_finite()) {
860 return Err("gebakken rotatiekanaal heeft niet-eindige waarden".into());
861 }
862 times.push(t);
863 values.extend_from_slice(&v);
864 }
865 channels.push(AnimChannel {
866 node,
867 property: ChannelProperty::Rotation,
868 times,
869 values,
870 });
871 Ok(())
872 }
873
874 #[cfg(test)]
875 mod tests {
876 use super::*;
877 use crate::preview::{
878 animated_joint_world, joint_world_translation, load_gltf_file, rest_joint_world,
879 sample_skin_matrices,
880 };
881
882 fn fixture_fbx() -> std::path::PathBuf {
883 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.fbx")
884 }
885
886 fn fixture_glb() -> std::path::PathBuf {
887 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb")
888 }
889
890 fn translation_of(m: Mat4) -> [f32; 3] {
891 [m[12], m[13], m[14]]
892 }
893
894 fn distance(a: [f32; 3], b: [f32; 3]) -> f32 {
895 let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
896 (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
897 }
898
899 fn bone_length(character: &PreviewCharacter, joint: usize, node: usize) -> f32 {
900 let Some(parent_node) = character.nodes[node].parent else {
901 return 0.0;
902 };
903 let Some(parent_joint) = character.joint_nodes.iter().position(|&n| n == parent_node)
904 else {
905 return 0.0;
906 };
907 let child = rest_joint_world(character, joint).unwrap();
908 let parent = rest_joint_world(character, parent_joint).unwrap();
909 distance(translation_of(child), translation_of(parent))
910 }
911
912 #[test]
913 fn fbx_drawable_loads_normalized_skinned_scene() {
914 let scene = load_fbx_drawable(&fixture_fbx()).expect("load fbx");
915 assert_eq!(scene.provenance.target_axes, "right-handed Y-up");
916 assert!((scene.provenance.target_unit_meters - 1.0).abs() < 1e-9);
917 assert!(scene.provenance.space_conversion.contains("ModifyGeometry"));
918 assert!(scene.provenance.notices.iter().any(|n| n.contains("ufbx")));
919 assert_eq!(scene.character.joint_names.len(), 19);
920 assert!(scene.character.joint_names.iter().any(|n| n == "Hips"));
921 assert!(scene
922 .character
923 .joint_names
924 .iter()
925 .any(|n| n == "LeftLowerLeg"));
926 assert!(scene.character.positions.len() >= 19 * 8);
927 assert!(scene.character.indices.len() >= 3);
928 assert!(scene.character.clips.len() >= 2);
929 assert!(scene
930 .character
931 .clips
932 .iter()
933 .any(|c| c.name.to_lowercase().contains("walk")));
934 assert!(scene
935 .character
936 .clips
937 .iter()
938 .any(|c| c.name.to_lowercase().contains("attack")));
939 assert!(!scene.materials.is_empty());
940 let matrices = sample_skin_matrices(&scene.character, "walk", 0.2);
941 assert_eq!(matrices.len(), 19);
942 assert!(matrices.iter().flatten().all(|v| v.is_finite()));
943 }
944
945 #[test]
946 fn fbx_bind_pose_matches_glb_fixture() {
947 let fbx = load_fbx_drawable(&fixture_fbx()).unwrap();
948 let glb = load_gltf_file(&fixture_glb()).unwrap();
949 assert_eq!(fbx.character.joint_names.len(), glb.joint_names.len());
950 for name in &glb.joint_names {
951 assert!(
952 fbx.character.joint_names.iter().any(|n| n == name),
953 "FBX mist joint {name}"
954 );
955 }
956
957 // Hips rest translation matches (shared rig origin); Blender bone-axis
958 // rotations make intermediate rest XYZ diverge while animation matches.
959 let f_hips = fbx
960 .character
961 .joint_names
962 .iter()
963 .position(|n| n == "Hips")
964 .unwrap();
965 let g_hips = glb.joint_names.iter().position(|n| n == "Hips").unwrap();
966 let f_rest = rest_joint_world(&fbx.character, f_hips).unwrap();
967 let g_rest = rest_joint_world(&glb, g_hips).unwrap();
968 assert!(
969 distance(translation_of(f_rest), translation_of(g_rest)) < 0.02,
970 "Hips bind {:?} vs {:?}",
971 translation_of(f_rest),
972 translation_of(g_rest)
973 );
974
975 for name in &glb.joint_names {
976 let fi = fbx
977 .character
978 .joint_names
979 .iter()
980 .position(|n| n == name)
981 .unwrap();
982 let gi = glb.joint_names.iter().position(|n| n == name).unwrap();
983 let f_len = bone_length(&fbx.character, fi, fbx.character.joint_nodes[fi]);
984 let g_len = bone_length(&glb, gi, glb.joint_nodes[gi]);
985 if f_len > 1e-4 && g_len > 1e-4 {
986 assert!(
987 (f_len - g_len).abs() < 0.02,
988 "bone length {name}: fbx={f_len} glb={g_len}"
989 );
990 }
991 }
992
993 // Bind-equivalent pose is reached inside the walk clip (fixture mid sample).
994 let f_leg = joint_world_translation(&fbx.character, "walk", 0.25, "LeftLowerLeg").unwrap();
995 let g_leg = joint_world_translation(&glb, "walk", 0.25, "LeftLowerLeg").unwrap();
996 assert!(
997 distance(f_leg, g_leg) < 0.02,
998 "bind-equivalent walk sample {f_leg:?} vs {g_leg:?}"
999 );
1000 let g_bind = translation_of(
1001 rest_joint_world(
1002 &glb,
1003 glb.joint_names
1004 .iter()
1005 .position(|n| n == "LeftLowerLeg")
1006 .unwrap(),
1007 )
1008 .unwrap(),
1009 );
1010 assert!(
1011 distance(f_leg, g_bind) < 0.02,
1012 "walk 0.25 should match GLB bind for LeftLowerLeg: {f_leg:?} vs {g_bind:?}"
1013 );
1014 }
1015
1016 #[test]
1017 fn fbx_animation_samples_match_glb_fixture() {
1018 let fbx = load_fbx_drawable(&fixture_fbx()).unwrap();
1019 let glb = load_gltf_file(&fixture_glb()).unwrap();
1020
1021 let leg_a = joint_world_translation(&fbx.character, "walk", 0.0, "LeftLowerLeg").unwrap();
1022 let leg_b = joint_world_translation(&fbx.character, "walk", 0.25, "LeftLowerLeg").unwrap();
1023 assert!(
1024 distance(leg_a, leg_b) > 0.05,
1025 "FBX walk must move LeftLowerLeg, delta {}",
1026 distance(leg_a, leg_b)
1027 );
1028
1029 let g_a = joint_world_translation(&glb, "walk", 0.0, "LeftLowerLeg").unwrap();
1030 let g_b = joint_world_translation(&glb, "walk", 0.25, "LeftLowerLeg").unwrap();
1031 assert!(
1032 distance(leg_a, g_a) < 0.05,
1033 "walk t=0 LeftLowerLeg fbx={leg_a:?} glb={g_a:?}"
1034 );
1035 assert!(
1036 distance(leg_b, g_b) < 0.08,
1037 "walk t=0.25 LeftLowerLeg fbx={leg_b:?} glb={g_b:?}"
1038 );
1039
1040 let hand_a = joint_world_translation(&fbx.character, "attack", 0.0, "RightHand").unwrap();
1041 let hand_b = joint_world_translation(&fbx.character, "attack", 0.35, "RightHand").unwrap();
1042 assert!(distance(hand_a, hand_b) > 0.05);
1043 let gh_a = joint_world_translation(&glb, "attack", 0.0, "RightHand").unwrap();
1044 let gh_b = joint_world_translation(&glb, "attack", 0.35, "RightHand").unwrap();
1045 assert!(distance(hand_a, gh_a) < 0.05);
1046 assert!(
1047 distance(hand_b, gh_b) < 0.1,
1048 "attack t=0.35 RightHand fbx={hand_b:?} glb={gh_b:?}"
1049 );
1050
1051 // Skin matrices stay finite and joint count matches.
1052 let f_skin = sample_skin_matrices(&fbx.character, "walk", 0.5);
1053 let g_skin = sample_skin_matrices(&glb, "walk", 0.5);
1054 assert_eq!(f_skin.len(), g_skin.len());
1055 for (name, f_m, g_m) in glb
1056 .joint_names
1057 .iter()
1058 .zip(f_skin.iter())
1059 .zip(g_skin.iter())
1060 .map(|((n, f), g)| (n, f, g))
1061 {
1062 let fi = fbx
1063 .character
1064 .joint_names
1065 .iter()
1066 .position(|n| n == name)
1067 .unwrap();
1068 let _ = fi;
1069 let d = distance(translation_of(*f_m), translation_of(*g_m));
1070 assert!(d < 0.15, "skin matrix translation {name} delta {d}");
1071 }
1072
1073 let fi = fbx
1074 .character
1075 .joint_names
1076 .iter()
1077 .position(|n| n == "Hips")
1078 .unwrap();
1079 assert!(animated_joint_world(&fbx.character, "walk", 0.1, fi).is_some());
1080 }
1081
1082 #[test]
1083 fn garbage_fbx_fails_explicitly() {
1084 let root = std::env::temp_dir().join(format!("paperdoll-fbx-scene-{}", std::process::id()));
1085 let _ = std::fs::remove_dir_all(&root);
1086 std::fs::create_dir_all(&root).unwrap();
1087 let path = root.join("bogus.fbx");
1088 std::fs::write(&path, b"not really fbx").unwrap();
1089 let err = load_fbx_drawable(&path).unwrap_err();
1090 assert!(
1091 err.contains("mislukt") || err.to_lowercase().contains("fbx"),
1092 "{err}"
1093 );
1094 let _ = std::fs::remove_dir_all(&root);
1095 }
1096 }
@@ -0,0 +1,1096 @@
1//! In-process normalized drawable/animated FBX scene.
2//!
3//! Loads FBX through vendored ufbx once with unit/axis/winding normalization,
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.
7
8use std::io::Cursor;
9use std::path::Path;
10use std::sync::Arc;
11
12use crate::equipment::Texture;
13use crate::import::FBX_BACKEND;
14use crate::preview::{
15 AnimChannel, BodySurface, ChannelProperty, Mat4, PreviewCharacter, PreviewClip, SceneNode,
16 MAX_JOINTS,
17};
18
19const MAX_BYTES: u64 = 32 * 1024 * 1024;
20const MAX_VERTICES: usize = 1_000_000;
21const MAX_INDICES: usize = 3_000_000;
22const TARGET_UNIT_METERS: f64 = 1.0;
23
24/// Metadata describing the one-shot conversion applied while loading.
25#[derive(Debug, Clone, PartialEq)]
26pub struct ConversionProvenance {
27 pub source_path: String,
28 pub backend: String,
29 pub target_axes: String,
30 pub target_unit_meters: f64,
31 pub source_unit_meters: f64,
32 pub space_conversion: String,
33 pub geometry_transform_handling: String,
34 pub mirror_axis: String,
35 pub winding_reversed: bool,
36 pub geometry_scale: f64,
37 pub notices: Vec<String>,
38}
39
40/// Material as reported by ufbx after normalization.
41#[derive(Debug, Clone, PartialEq)]
42pub struct FbxMaterial {
43 pub name: String,
44 pub base_color: [f32; 4],
45 pub base_color_texture: Option<String>,
46 pub normal_texture: Option<String>,
47}
48
49/// Texture bytes that ufbx reported (file path relative label or embedded).
50#[derive(Debug, Clone, PartialEq)]
51pub struct FbxTextureRef {
52 pub name: String,
53 pub source: String,
54 pub width: u32,
55 pub height: u32,
56 pub embedded: bool,
57}
58
59/// Normalized drawable FBX scene ready for the shared sampling API.
60#[derive(Debug, Clone)]
61pub struct FbxDrawableScene {
62 pub character: PreviewCharacter,
63 pub provenance: ConversionProvenance,
64 pub materials: Vec<FbxMaterial>,
65 pub textures: Vec<FbxTextureRef>,
66}
67
68/// Load an FBX file into a normalized drawable/animated scene.
69pub fn load_fbx_drawable(path: &Path) -> Result<FbxDrawableScene, String> {
70 let path_str = path
71 .to_str()
72 .ok_or_else(|| "het FBX-pad is geen UTF-8".to_string())?;
73 let meta = std::fs::metadata(path).map_err(|err| format!("FBX openen mislukt: {err}"))?;
74 if !meta.is_file() {
75 return Err(format!("FBX-bestand ontbreekt: {}", path.display()));
76 }
77 if meta.len() > MAX_BYTES {
78 return Err(format!(
79 "FBX-bestand is groter dan {MAX_BYTES} bytes ({})",
80 meta.len()
81 ));
82 }
83
84 let opts = ufbx::LoadOpts {
85 target_axes: ufbx::axes_right_handed_y_up(),
86 target_unit_meters: TARGET_UNIT_METERS,
87 space_conversion: ufbx::SpaceConversion::ModifyGeometry,
88 geometry_transform_handling: ufbx::GeometryTransformHandling::ModifyGeometry,
89 generate_missing_normals: true,
90 clean_skin_weights: true,
91 normalize_normals: true,
92 load_external_files: true,
93 ignore_missing_external_files: false,
94 ..Default::default()
95 };
96 let scene = ufbx::load_file(path_str, opts).map_err(|err| {
97 format!(
98 "FBX laden mislukt ({}): {}",
99 path.display(),
100 err.description
101 )
102 })?;
103
104 let mut notices = Vec::new();
105 notices.push(format!(
106 "FBX genormaliseerd met {FBX_BACKEND}; assen right-handed Y-up, eenheden meters."
107 ));
108 notices.push(
109 "Drawable/animated scene is beschikbaar voor sampling; live viewport en productie gebruiken nog glTF/GLB."
110 .into(),
111 );
112
113 if scene.metadata.geometry_ignored {
114 return Err("FBX heeft geometrie genegeerd; bestand is niet tekenbaar".into());
115 }
116 if scene.metadata.animation_ignored {
117 notices.push("animatiegegevens waren gemarkeerd als genegeerd door de loader".into());
118 }
119
120 let (mesh_node_index, mesh) = find_skinned_mesh(&scene)?;
121 let _ = mesh_node_index;
122 let skin = mesh
123 .skin_deformers
124 .first()
125 .ok_or_else(|| "skinned mesh heeft geen skin deformer".to_string())?;
126
127 match skin.skinning_method {
128 ufbx::SkinningMethod::Linear | ufbx::SkinningMethod::Rigid => {}
129 other => {
130 return Err(format!(
131 "niet-ondersteunde FBX-skinningmethode {:?}; alleen linear/rigid",
132 other
133 ));
134 }
135 }
136 if skin.clusters.is_empty() {
137 return Err("FBX-skin heeft geen clusters".into());
138 }
139 if skin.clusters.len() > MAX_JOINTS {
140 return Err(format!(
141 "FBX-skeleton heeft {} joints; limiet is {MAX_JOINTS}",
142 skin.clusters.len()
143 ));
144 }
145 if !mesh.blend_deformers.is_empty() {
146 notices.push(format!(
147 "{} blend deformers genegeerd (morph targets niet ondersteund)",
148 mesh.blend_deformers.len()
149 ));
150 }
151
152 let nodes = build_nodes(&scene)?;
153 let (joint_nodes, joint_names, inverse_binds) = build_skin(skin)?;
154
155 let (materials, textures, surfaces_base) = collect_materials(path, &scene, mesh, &mut notices)?;
156
157 let (
158 positions,
159 normals,
160 source_normals_complete,
161 joints,
162 weights,
163 indices,
164 colors,
165 uvs,
166 surfaces,
167 ) = extract_mesh(mesh, skin, &surfaces_base)?;
168
169 if positions.len() > MAX_VERTICES || indices.len() > MAX_INDICES {
170 return Err(format!(
171 "FBX-mesh te groot ({} vertices, {} indices)",
172 positions.len(),
173 indices.len()
174 ));
175 }
176 if positions.iter().any(|p| p.iter().any(|v| !v.is_finite()))
177 || normals.iter().any(|n| n.iter().any(|v| !v.is_finite()))
178 {
179 return Err("FBX-mesh bevat niet-eindige posities of normalen".into());
180 }
181
182 let clips = build_clips(&scene, &nodes)?;
183 if clips.is_empty() {
184 notices.push("geen animatiestacks met transformkanalen gevonden".into());
185 }
186
187 let winding_reversed = mesh.reversed_winding;
188 let provenance = ConversionProvenance {
189 source_path: path.display().to_string(),
190 backend: FBX_BACKEND.to_string(),
191 target_axes: "right-handed Y-up".into(),
192 target_unit_meters: TARGET_UNIT_METERS,
193 source_unit_meters: scene.settings.original_unit_meters as f64,
194 space_conversion: format!("{:?}", scene.metadata.space_conversion),
195 geometry_transform_handling: format!("{:?}", scene.metadata.geometry_transform_handling),
196 mirror_axis: format!("{:?}", scene.metadata.mirror_axis),
197 winding_reversed,
198 geometry_scale: scene.metadata.geometry_scale as f64,
199 notices: notices.clone(),
200 };
201
202 let base_color = surfaces
203 .first()
204 .map(|s| s.color)
205 .unwrap_or([0.7, 0.7, 0.75, 1.0]);
206
207 Ok(FbxDrawableScene {
208 character: PreviewCharacter {
209 nodes,
210 joint_nodes,
211 joint_names,
212 inverse_binds,
213 positions,
214 normals,
215 source_normals_complete,
216 joints,
217 weights,
218 indices,
219 base_color,
220 colors,
221 uvs,
222 surfaces,
223 clips,
224 },
225 provenance,
226 materials,
227 textures,
228 })
229}
230
231fn find_skinned_mesh(scene: &ufbx::Scene) -> Result<(usize, &ufbx::Mesh), String> {
232 for node in &scene.nodes {
233 if let Some(mesh) = node.mesh.as_ref() {
234 if mesh.num_vertices == 0 || mesh.num_triangles == 0 {
235 continue;
236 }
237 if mesh.skin_deformers.is_empty() {
238 continue;
239 }
240 return Ok((node.element.typed_id as usize, mesh));
241 }
242 }
243 Err("FBX bevat geen skinned mesh met driehoeken".into())
244}
245
246fn build_nodes(scene: &ufbx::Scene) -> Result<Vec<SceneNode>, String> {
247 let mut nodes = Vec::with_capacity(scene.nodes.len());
248 for node in &scene.nodes {
249 let parent = if node.is_root {
250 None
251 } else {
252 node.parent
253 .as_ref()
254 .map(|parent| parent.element.typed_id as usize)
255 };
256 if node.has_geometry_transform && node.is_geometry_transform_helper {
257 return Err(format!(
258 "node '{}' is een geometry-transform helper die niet is weggenomen; dit dialect wordt niet stil geaccepteerd",
259 node.element.name.trim()
260 ));
261 }
262 let t = node.local_transform;
263 let translation = [
264 t.translation.x as f32,
265 t.translation.y as f32,
266 t.translation.z as f32,
267 ];
268 let rotation = [
269 t.rotation.x as f32,
270 t.rotation.y as f32,
271 t.rotation.z as f32,
272 t.rotation.w as f32,
273 ];
274 let scale = [t.scale.x as f32, t.scale.y as f32, t.scale.z as f32];
275 if !translation
276 .iter()
277 .chain(rotation.iter())
278 .chain(scale.iter())
279 .all(|v| v.is_finite())
280 {
281 return Err(format!(
282 "node '{}' heeft niet-eindige lokale transform",
283 node.element.name.trim()
284 ));
285 }
286 nodes.push(SceneNode {
287 name: node.element.name.trim().to_string(),
288 parent,
289 translation,
290 rotation,
291 scale,
292 });
293 }
294 for (index, node) in scene.nodes.iter().enumerate() {
295 if node.element.typed_id as usize != index {
296 return Err(format!(
297 "ufbx typed_id {} komt niet overeen met node-index {index}",
298 node.element.typed_id
299 ));
300 }
301 }
302 Ok(nodes)
303}
304
305type SkinBuild = (Vec<usize>, Vec<String>, Vec<Mat4>);
306type MaterialBuild = (Vec<FbxMaterial>, Vec<FbxTextureRef>, Vec<SurfaceSeed>);
307type MeshBuild = (
308 Vec<[f32; 3]>,
309 Vec<[f32; 3]>,
310 bool,
311 Vec<[u16; 4]>,
312 Vec<[f32; 4]>,
313 Vec<u32>,
314 Vec<[f32; 4]>,
315 Vec<[f32; 2]>,
316 Vec<BodySurface>,
317);
318
319fn build_skin(skin: &ufbx::SkinDeformer) -> Result<SkinBuild, String> {
320 let mut joint_nodes = Vec::with_capacity(skin.clusters.len());
321 let mut joint_names = Vec::with_capacity(skin.clusters.len());
322 let mut inverse_binds = Vec::with_capacity(skin.clusters.len());
323 for cluster in &skin.clusters {
324 let bone = cluster
325 .bone_node
326 .as_ref()
327 .ok_or_else(|| "skin cluster zonder bone_node".to_string())?;
328 let name = bone.element.name.trim();
329 if name.is_empty() {
330 return Err("skin cluster bone heeft geen naam".into());
331 }
332 joint_nodes.push(bone.element.typed_id as usize);
333 joint_names.push(name.to_string());
334 let ib = matrix_to_mat4(&cluster.geometry_to_bone);
335 if !ib.iter().all(|v| v.is_finite()) {
336 return Err(format!("inverse-bind van '{name}' is niet eindig"));
337 }
338 inverse_binds.push(ib);
339 }
340 Ok((joint_nodes, joint_names, inverse_binds))
341}
342
343fn matrix_to_mat4(m: &ufbx::Matrix) -> Mat4 {
344 [
345 m.m00 as f32,
346 m.m10 as f32,
347 m.m20 as f32,
348 0.0,
349 m.m01 as f32,
350 m.m11 as f32,
351 m.m21 as f32,
352 0.0,
353 m.m02 as f32,
354 m.m12 as f32,
355 m.m22 as f32,
356 0.0,
357 m.m03 as f32,
358 m.m13 as f32,
359 m.m23 as f32,
360 1.0,
361 ]
362}
363
364struct SurfaceSeed {
365 color: [f32; 4],
366 albedo: Option<Arc<Texture>>,
367 cutoff: f32,
368}
369
370fn collect_materials(
371 path: &Path,
372 scene: &ufbx::Scene,
373 mesh: &ufbx::Mesh,
374 notices: &mut Vec<String>,
375) -> Result<MaterialBuild, String> {
376 let mut materials = Vec::new();
377 let mut textures = Vec::new();
378 let mut seeds = Vec::new();
379
380 let material_list: Vec<&ufbx::Material> = if mesh.materials.is_empty() {
381 (&scene.materials).into_iter().collect()
382 } else {
383 (&mesh.materials).into_iter().collect()
384 };
385
386 if material_list.is_empty() {
387 seeds.push(SurfaceSeed {
388 color: [0.7, 0.7, 0.75, 1.0],
389 albedo: None,
390 cutoff: 0.001,
391 });
392 return Ok((materials, textures, seeds));
393 }
394
395 for material in material_list {
396 let color_map = if material.pbr.base_color.has_value {
397 &material.pbr.base_color
398 } else {
399 &material.fbx.diffuse_color
400 };
401 let base_color = [
402 color_map.value_vec4.x as f32,
403 color_map.value_vec4.y as f32,
404 color_map.value_vec4.z as f32,
405 if color_map.value_vec4.w == 0.0 {
406 1.0
407 } else {
408 color_map.value_vec4.w as f32
409 },
410 ];
411 if !base_color.iter().all(|c| c.is_finite()) {
412 return Err(format!(
413 "materiaal '{}' heeft niet-eindige basiskleur",
414 material.element.name.trim()
415 ));
416 }
417
418 let (base_tex_name, base_albedo) =
419 resolve_texture(path, color_map.texture.as_ref(), &mut textures, notices)?;
420 let (normal_tex_name, _) = resolve_texture(
421 path,
422 material
423 .fbx
424 .normal_map
425 .texture
426 .as_ref()
427 .or(material.pbr.normal_map.texture.as_ref()),
428 &mut textures,
429 notices,
430 )?;
431
432 materials.push(FbxMaterial {
433 name: {
434 let name = material.element.name.trim();
435 if name.is_empty() {
436 "materiaal".into()
437 } else {
438 name.to_string()
439 }
440 },
441 base_color,
442 base_color_texture: base_tex_name.clone(),
443 normal_texture: normal_tex_name,
444 });
445 seeds.push(SurfaceSeed {
446 color: base_color,
447 albedo: base_albedo,
448 cutoff: 0.001,
449 });
450 }
451 Ok((materials, textures, seeds))
452}
453
454fn resolve_texture(
455 fbx_path: &Path,
456 texture: Option<&ufbx::Ref<ufbx::Texture>>,
457 textures: &mut Vec<FbxTextureRef>,
458 notices: &mut Vec<String>,
459) -> Result<(Option<String>, Option<Arc<Texture>>), String> {
460 let Some(texture) = texture else {
461 return Ok((None, None));
462 };
463 if texture.content.size > 0 {
464 let name = {
465 let n = texture.element.name.trim();
466 if n.is_empty() {
467 format!("ingebed:{}", textures.len())
468 } else {
469 format!("ingebed:{n}")
470 }
471 };
472 let decoded = decode_png_bytes(&texture.content)
473 .map_err(|err| format!("ingebedde texture '{name}' kon niet gelezen worden: {err}"))?;
474 if decoded.width > 2048 || decoded.height > 2048 {
475 return Err(format!("texture '{name}' is groter dan 2048×2048"));
476 }
477 let width = decoded.width;
478 let height = decoded.height;
479 let arc = Arc::new(decoded);
480 textures.push(FbxTextureRef {
481 name: name.clone(),
482 source: name.clone(),
483 width,
484 height,
485 embedded: true,
486 });
487 return Ok((Some(name), Some(arc)));
488 }
489
490 let relative = texture.relative_filename.trim().replace('\\', "/");
491 let filename = if relative.is_empty() {
492 texture.filename.trim().replace('\\', "/")
493 } else {
494 relative
495 };
496 if filename.is_empty() {
497 return Ok((None, None));
498 }
499 reject_escape(&filename)?;
500 let base = fbx_path.parent().unwrap_or(fbx_path);
501 let full = base.join(&filename);
502 if full.is_file() {
503 let bytes = std::fs::read(&full)
504 .map_err(|err| format!("texture '{filename}' lezen mislukt: {err}"))?;
505 let decoded = decode_png_bytes(&bytes)
506 .map_err(|err| format!("texture '{filename}' is geen geldige 8-bit PNG: {err}"))?;
507 if decoded.width > 2048 || decoded.height > 2048 {
508 return Err(format!("texture '{filename}' is groter dan 2048×2048"));
509 }
510 let width = decoded.width;
511 let height = decoded.height;
512 let arc = Arc::new(decoded);
513 textures.push(FbxTextureRef {
514 name: filename.clone(),
515 source: filename.clone(),
516 width,
517 height,
518 embedded: false,
519 });
520 Ok((Some(filename), Some(arc)))
521 } else if texture.has_file {
522 Err(format!("FBX-texture ontbreekt: {filename}"))
523 } else {
524 notices.push(format!(
525 "texture-referentie '{filename}' zonder bestand genegeerd"
526 ));
527 Ok((Some(filename), None))
528 }
529}
530
531fn reject_escape(relative: &str) -> Result<(), String> {
532 if relative.starts_with('/')
533 || relative.starts_with('\\')
534 || Path::new(relative).components().any(|c| {
535 matches!(
536 c,
537 std::path::Component::ParentDir | std::path::Component::RootDir
538 )
539 })
540 {
541 return Err(format!("onveilig texturepad geweigerd: {relative}"));
542 }
543 Ok(())
544}
545
546fn decode_png_bytes(bytes: &[u8]) -> Result<Texture, String> {
547 let mut decoder = png::Decoder::new(Cursor::new(bytes));
548 decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
549 let mut reader = decoder.read_info().map_err(|e| e.to_string())?;
550 let info = reader.info();
551 if info.width == 0 || info.height == 0 || info.width > 2048 || info.height > 2048 {
552 return Err("textureafmeting ongeldig of >2048".into());
553 }
554 let size = reader
555 .output_buffer_size()
556 .ok_or_else(|| "texturebuffer te groot".to_string())?;
557 let mut buffer = vec![0u8; size];
558 let frame = reader.next_frame(&mut buffer).map_err(|e| e.to_string())?;
559 let mut rgba = Vec::with_capacity((frame.width * frame.height * 4) as usize);
560 match frame.color_type {
561 png::ColorType::Grayscale => {
562 for &p in &buffer[..frame.buffer_size()] {
563 rgba.extend_from_slice(&[p, p, p, 255]);
564 }
565 }
566 png::ColorType::GrayscaleAlpha => {
567 for chunk in buffer[..frame.buffer_size()].chunks_exact(2) {
568 rgba.extend_from_slice(&[chunk[0], chunk[0], chunk[0], chunk[1]]);
569 }
570 }
571 png::ColorType::Rgb => {
572 for chunk in buffer[..frame.buffer_size()].chunks_exact(3) {
573 rgba.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]);
574 }
575 }
576 png::ColorType::Rgba => rgba.extend_from_slice(&buffer[..frame.buffer_size()]),
577 other => return Err(format!("niet-ondersteund PNG-kleurtype {other:?}")),
578 }
579 use std::hash::{Hash, Hasher};
580 let mut hasher = std::collections::hash_map::DefaultHasher::new();
581 rgba.hash(&mut hasher);
582 Ok(Texture {
583 content_hash: hasher.finish(),
584 width: frame.width,
585 height: frame.height,
586 rgba,
587 })
588}
589
590fn extract_mesh(
591 mesh: &ufbx::Mesh,
592 skin: &ufbx::SkinDeformer,
593 seeds: &[SurfaceSeed],
594) -> Result<MeshBuild, String> {
595 let source_normals_complete = mesh.vertex_normal.exists && !mesh.generated_normals;
596 let mut positions = Vec::new();
597 let mut normals = Vec::new();
598 let mut joints = Vec::new();
599 let mut weights = Vec::new();
600 let mut indices = Vec::new();
601 let mut colors = Vec::new();
602 let mut uvs = Vec::new();
603 let mut surfaces = Vec::new();
604
605 let part_count = mesh.material_parts.len().max(1);
606 for part_index in 0..part_count {
607 let face_indices: Vec<u32> = if mesh.material_parts.is_empty() {
608 (0..mesh.faces.len() as u32).collect()
609 } else {
610 mesh.material_parts[part_index]
611 .face_indices
612 .iter()
613 .copied()
614 .collect()
615 };
616 if face_indices.is_empty() {
617 continue;
618 }
619 let seed = seeds
620 .get(part_index.min(seeds.len().saturating_sub(1)))
621 .ok_or_else(|| "materiaalpart zonder seed".to_string())?;
622 let first = indices.len();
623 let mut tri_buf = Vec::new();
624 for face_index in face_indices {
625 let face = mesh.faces[face_index as usize];
626 if face.num_indices < 3 {
627 continue;
628 }
629 let tri_count = ufbx::triangulate_face_vec(&mut tri_buf, mesh, face);
630 for t in 0..tri_count as usize {
631 let corner_indices = [tri_buf[t * 3], tri_buf[t * 3 + 1], tri_buf[t * 3 + 2]];
632 for &corner in &corner_indices {
633 let corner = corner as usize;
634 let pos = &mesh.vertex_position[corner];
635 let position = [pos.x as f32, pos.y as f32, pos.z as f32];
636 let normal = if mesh.vertex_normal.exists {
637 let n = &mesh.vertex_normal[corner];
638 [n.x as f32, n.y as f32, n.z as f32]
639 } else {
640 [0.0, 1.0, 0.0]
641 };
642 let uv = if mesh.vertex_uv.exists {
643 let u = &mesh.vertex_uv[corner];
644 [u.x as f32, u.y as f32]
645 } else {
646 [0.0, 0.0]
647 };
648 let color = if mesh.vertex_color.exists {
649 let c = &mesh.vertex_color[corner];
650 [c.x as f32, c.y as f32, c.z as f32, c.w as f32]
651 } else {
652 [1.0, 1.0, 1.0, 1.0]
653 };
654 let vertex_ix = mesh.vertex_indices[corner] as usize;
655 let (j, w) = skin_influences(skin, vertex_ix)?;
656 let out_index = positions.len() as u32;
657 positions.push(position);
658 normals.push(normal);
659 uvs.push(uv);
660 colors.push(color);
661 joints.push(j);
662 weights.push(w);
663 indices.push(out_index);
664 }
665 }
666 }
667 let count = indices.len() - first;
668 if count > 0 {
669 surfaces.push(BodySurface {
670 first,
671 count,
672 color: seed.color,
673 albedo: seed.albedo.clone(),
674 cutoff: seed.cutoff,
675 flat_normals: !mesh.vertex_normal.exists,
676 });
677 }
678 }
679
680 if positions.is_empty() || indices.len() < 3 {
681 return Err("FBX-mesh heeft geen tekenbare driehoeken na triangulatie".into());
682 }
683 Ok((
684 positions,
685 normals,
686 source_normals_complete,
687 joints,
688 weights,
689 indices,
690 colors,
691 uvs,
692 surfaces,
693 ))
694}
695
696fn skin_influences(
697 skin: &ufbx::SkinDeformer,
698 vertex: usize,
699) -> Result<([u16; 4], [f32; 4]), String> {
700 if vertex >= skin.vertices.len() {
701 return Err(format!("skin vertex-index {vertex} buiten bereik"));
702 }
703 let sv = &skin.vertices[vertex];
704 let mut pairs: Vec<(u16, f32)> = Vec::new();
705 for i in 0..sv.num_weights as usize {
706 let w = &skin.weights[sv.weight_begin as usize + i];
707 if w.cluster_index as usize >= skin.clusters.len() {
708 return Err(format!(
709 "skin weight verwijst naar onbekende cluster {}",
710 w.cluster_index
711 ));
712 }
713 let weight = w.weight as f32;
714 if !weight.is_finite() || weight < 0.0 {
715 return Err("skin weight is niet eindig of negatief".into());
716 }
717 if weight > 0.0 {
718 pairs.push((w.cluster_index as u16, weight));
719 }
720 }
721 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
722 pairs.truncate(4);
723 if pairs.is_empty() {
724 pairs.push((0, 1.0));
725 }
726 let sum: f32 = pairs.iter().map(|(_, w)| *w).sum();
727 if sum <= 1e-8 {
728 return Err("skin weights sommeren tot nul".into());
729 }
730 let mut joints = [0u16; 4];
731 let mut weights = [0.0f32; 4];
732 for (i, (j, w)) in pairs.into_iter().enumerate() {
733 joints[i] = j;
734 weights[i] = w / sum;
735 }
736 Ok((joints, weights))
737}
738
739fn build_clips(scene: &ufbx::Scene, nodes: &[SceneNode]) -> Result<Vec<PreviewClip>, String> {
740 let mut clips = Vec::new();
741 for stack in &scene.anim_stacks {
742 let bake = ufbx::bake_anim(
743 scene,
744 &stack.anim,
745 ufbx::BakeOpts {
746 resample_rate: 30.0,
747 minimum_sample_rate: 1.0,
748 maximum_sample_rate: 120.0,
749 trim_start_time: true,
750 key_reduction_enabled: true,
751 key_reduction_rotation: true,
752 key_reduction_threshold: 1e-4,
753 ..Default::default()
754 },
755 )
756 .map_err(|err| {
757 format!(
758 "animatiestack '{}' bakken mislukt: {}",
759 stack.element.name.trim(),
760 err.description
761 )
762 })?;
763
764 let mut channels = Vec::new();
765 for baked in &bake.nodes {
766 let node = baked.typed_id as usize;
767 if node >= nodes.len() {
768 return Err(format!(
769 "gebakken node typed_id {node} buiten bereik ({} nodes)",
770 nodes.len()
771 ));
772 }
773 push_vec3_channel(
774 &mut channels,
775 node,
776 ChannelProperty::Translation,
777 &baked.translation_keys,
778 )?;
779 push_quat_channel(&mut channels, node, &baked.rotation_keys)?;
780 push_vec3_channel(
781 &mut channels,
782 node,
783 ChannelProperty::Scale,
784 &baked.scale_keys,
785 )?;
786 }
787 if channels.is_empty() {
788 continue;
789 }
790 let duration = if bake.playback_duration > 0.0 {
791 bake.playback_duration as f32
792 } else {
793 (stack.time_end - stack.time_begin).max(0.0) as f32
794 };
795 let name = {
796 let raw = stack.element.name.trim();
797 if raw.is_empty() {
798 "clip".to_string()
799 } else {
800 raw.to_string()
801 }
802 };
803 clips.push(PreviewClip {
804 name,
805 duration,
806 channels,
807 });
808 }
809 Ok(clips)
810}
811
812fn push_vec3_channel(
813 channels: &mut Vec<AnimChannel>,
814 node: usize,
815 property: ChannelProperty,
816 keys: &[ufbx::BakedVec3],
817) -> Result<(), String> {
818 if keys.is_empty() {
819 return Ok(());
820 }
821 let mut times = Vec::with_capacity(keys.len());
822 let mut values = Vec::with_capacity(keys.len() * 3);
823 for key in keys {
824 let t = key.time as f32;
825 let v = [key.value.x as f32, key.value.y as f32, key.value.z as f32];
826 if !t.is_finite() || !v.iter().all(|n| n.is_finite()) {
827 return Err("gebakken vec3-kanaal heeft niet-eindige waarden".into());
828 }
829 times.push(t);
830 values.extend_from_slice(&v);
831 }
832 channels.push(AnimChannel {
833 node,
834 property,
835 times,
836 values,
837 });
838 Ok(())
839}
840
841fn push_quat_channel(
842 channels: &mut Vec<AnimChannel>,
843 node: usize,
844 keys: &[ufbx::BakedQuat],
845) -> Result<(), String> {
846 if keys.is_empty() {
847 return Ok(());
848 }
849 let mut times = Vec::with_capacity(keys.len());
850 let mut values = Vec::with_capacity(keys.len() * 4);
851 for key in keys {
852 let t = key.time as f32;
853 let v = [
854 key.value.x as f32,
855 key.value.y as f32,
856 key.value.z as f32,
857 key.value.w as f32,
858 ];
859 if !t.is_finite() || !v.iter().all(|n| n.is_finite()) {
860 return Err("gebakken rotatiekanaal heeft niet-eindige waarden".into());
861 }
862 times.push(t);
863 values.extend_from_slice(&v);
864 }
865 channels.push(AnimChannel {
866 node,
867 property: ChannelProperty::Rotation,
868 times,
869 values,
870 });
871 Ok(())
872}
873
874#[cfg(test)]
875mod tests {
876 use super::*;
877 use crate::preview::{
878 animated_joint_world, joint_world_translation, load_gltf_file, rest_joint_world,
879 sample_skin_matrices,
880 };
881
882 fn fixture_fbx() -> std::path::PathBuf {
883 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.fbx")
884 }
885
886 fn fixture_glb() -> std::path::PathBuf {
887 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb")
888 }
889
890 fn translation_of(m: Mat4) -> [f32; 3] {
891 [m[12], m[13], m[14]]
892 }
893
894 fn distance(a: [f32; 3], b: [f32; 3]) -> f32 {
895 let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
896 (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
897 }
898
899 fn bone_length(character: &PreviewCharacter, joint: usize, node: usize) -> f32 {
900 let Some(parent_node) = character.nodes[node].parent else {
901 return 0.0;
902 };
903 let Some(parent_joint) = character.joint_nodes.iter().position(|&n| n == parent_node)
904 else {
905 return 0.0;
906 };
907 let child = rest_joint_world(character, joint).unwrap();
908 let parent = rest_joint_world(character, parent_joint).unwrap();
909 distance(translation_of(child), translation_of(parent))
910 }
911
912 #[test]
913 fn fbx_drawable_loads_normalized_skinned_scene() {
914 let scene = load_fbx_drawable(&fixture_fbx()).expect("load fbx");
915 assert_eq!(scene.provenance.target_axes, "right-handed Y-up");
916 assert!((scene.provenance.target_unit_meters - 1.0).abs() < 1e-9);
917 assert!(scene.provenance.space_conversion.contains("ModifyGeometry"));
918 assert!(scene.provenance.notices.iter().any(|n| n.contains("ufbx")));
919 assert_eq!(scene.character.joint_names.len(), 19);
920 assert!(scene.character.joint_names.iter().any(|n| n == "Hips"));
921 assert!(scene
922 .character
923 .joint_names
924 .iter()
925 .any(|n| n == "LeftLowerLeg"));
926 assert!(scene.character.positions.len() >= 19 * 8);
927 assert!(scene.character.indices.len() >= 3);
928 assert!(scene.character.clips.len() >= 2);
929 assert!(scene
930 .character
931 .clips
932 .iter()
933 .any(|c| c.name.to_lowercase().contains("walk")));
934 assert!(scene
935 .character
936 .clips
937 .iter()
938 .any(|c| c.name.to_lowercase().contains("attack")));
939 assert!(!scene.materials.is_empty());
940 let matrices = sample_skin_matrices(&scene.character, "walk", 0.2);
941 assert_eq!(matrices.len(), 19);
942 assert!(matrices.iter().flatten().all(|v| v.is_finite()));
943 }
944
945 #[test]
946 fn fbx_bind_pose_matches_glb_fixture() {
947 let fbx = load_fbx_drawable(&fixture_fbx()).unwrap();
948 let glb = load_gltf_file(&fixture_glb()).unwrap();
949 assert_eq!(fbx.character.joint_names.len(), glb.joint_names.len());
950 for name in &glb.joint_names {
951 assert!(
952 fbx.character.joint_names.iter().any(|n| n == name),
953 "FBX mist joint {name}"
954 );
955 }
956
957 // Hips rest translation matches (shared rig origin); Blender bone-axis
958 // rotations make intermediate rest XYZ diverge while animation matches.
959 let f_hips = fbx
960 .character
961 .joint_names
962 .iter()
963 .position(|n| n == "Hips")
964 .unwrap();
965 let g_hips = glb.joint_names.iter().position(|n| n == "Hips").unwrap();
966 let f_rest = rest_joint_world(&fbx.character, f_hips).unwrap();
967 let g_rest = rest_joint_world(&glb, g_hips).unwrap();
968 assert!(
969 distance(translation_of(f_rest), translation_of(g_rest)) < 0.02,
970 "Hips bind {:?} vs {:?}",
971 translation_of(f_rest),
972 translation_of(g_rest)
973 );
974
975 for name in &glb.joint_names {
976 let fi = fbx
977 .character
978 .joint_names
979 .iter()
980 .position(|n| n == name)
981 .unwrap();
982 let gi = glb.joint_names.iter().position(|n| n == name).unwrap();
983 let f_len = bone_length(&fbx.character, fi, fbx.character.joint_nodes[fi]);
984 let g_len = bone_length(&glb, gi, glb.joint_nodes[gi]);
985 if f_len > 1e-4 && g_len > 1e-4 {
986 assert!(
987 (f_len - g_len).abs() < 0.02,
988 "bone length {name}: fbx={f_len} glb={g_len}"
989 );
990 }
991 }
992
993 // Bind-equivalent pose is reached inside the walk clip (fixture mid sample).
994 let f_leg = joint_world_translation(&fbx.character, "walk", 0.25, "LeftLowerLeg").unwrap();
995 let g_leg = joint_world_translation(&glb, "walk", 0.25, "LeftLowerLeg").unwrap();
996 assert!(
997 distance(f_leg, g_leg) < 0.02,
998 "bind-equivalent walk sample {f_leg:?} vs {g_leg:?}"
999 );
1000 let g_bind = translation_of(
1001 rest_joint_world(
1002 &glb,
1003 glb.joint_names
1004 .iter()
1005 .position(|n| n == "LeftLowerLeg")
1006 .unwrap(),
1007 )
1008 .unwrap(),
1009 );
1010 assert!(
1011 distance(f_leg, g_bind) < 0.02,
1012 "walk 0.25 should match GLB bind for LeftLowerLeg: {f_leg:?} vs {g_bind:?}"
1013 );
1014 }
1015
1016 #[test]
1017 fn fbx_animation_samples_match_glb_fixture() {
1018 let fbx = load_fbx_drawable(&fixture_fbx()).unwrap();
1019 let glb = load_gltf_file(&fixture_glb()).unwrap();
1020
1021 let leg_a = joint_world_translation(&fbx.character, "walk", 0.0, "LeftLowerLeg").unwrap();
1022 let leg_b = joint_world_translation(&fbx.character, "walk", 0.25, "LeftLowerLeg").unwrap();
1023 assert!(
1024 distance(leg_a, leg_b) > 0.05,
1025 "FBX walk must move LeftLowerLeg, delta {}",
1026 distance(leg_a, leg_b)
1027 );
1028
1029 let g_a = joint_world_translation(&glb, "walk", 0.0, "LeftLowerLeg").unwrap();
1030 let g_b = joint_world_translation(&glb, "walk", 0.25, "LeftLowerLeg").unwrap();
1031 assert!(
1032 distance(leg_a, g_a) < 0.05,
1033 "walk t=0 LeftLowerLeg fbx={leg_a:?} glb={g_a:?}"
1034 );
1035 assert!(
1036 distance(leg_b, g_b) < 0.08,
1037 "walk t=0.25 LeftLowerLeg fbx={leg_b:?} glb={g_b:?}"
1038 );
1039
1040 let hand_a = joint_world_translation(&fbx.character, "attack", 0.0, "RightHand").unwrap();
1041 let hand_b = joint_world_translation(&fbx.character, "attack", 0.35, "RightHand").unwrap();
1042 assert!(distance(hand_a, hand_b) > 0.05);
1043 let gh_a = joint_world_translation(&glb, "attack", 0.0, "RightHand").unwrap();
1044 let gh_b = joint_world_translation(&glb, "attack", 0.35, "RightHand").unwrap();
1045 assert!(distance(hand_a, gh_a) < 0.05);
1046 assert!(
1047 distance(hand_b, gh_b) < 0.1,
1048 "attack t=0.35 RightHand fbx={hand_b:?} glb={gh_b:?}"
1049 );
1050
1051 // Skin matrices stay finite and joint count matches.
1052 let f_skin = sample_skin_matrices(&fbx.character, "walk", 0.5);
1053 let g_skin = sample_skin_matrices(&glb, "walk", 0.5);
1054 assert_eq!(f_skin.len(), g_skin.len());
1055 for (name, f_m, g_m) in glb
1056 .joint_names
1057 .iter()
1058 .zip(f_skin.iter())
1059 .zip(g_skin.iter())
1060 .map(|((n, f), g)| (n, f, g))
1061 {
1062 let fi = fbx
1063 .character
1064 .joint_names
1065 .iter()
1066 .position(|n| n == name)
1067 .unwrap();
1068 let _ = fi;
1069 let d = distance(translation_of(*f_m), translation_of(*g_m));
1070 assert!(d < 0.15, "skin matrix translation {name} delta {d}");
1071 }
1072
1073 let fi = fbx
1074 .character
1075 .joint_names
1076 .iter()
1077 .position(|n| n == "Hips")
1078 .unwrap();
1079 assert!(animated_joint_world(&fbx.character, "walk", 0.1, fi).is_some());
1080 }
1081
1082 #[test]
1083 fn garbage_fbx_fails_explicitly() {
1084 let root = std::env::temp_dir().join(format!("paperdoll-fbx-scene-{}", std::process::id()));
1085 let _ = std::fs::remove_dir_all(&root);
1086 std::fs::create_dir_all(&root).unwrap();
1087 let path = root.join("bogus.fbx");
1088 std::fs::write(&path, b"not really fbx").unwrap();
1089 let err = load_fbx_drawable(&path).unwrap_err();
1090 assert!(
1091 err.contains("mislukt") || err.to_lowercase().contains("fbx"),
1092 "{err}"
1093 );
1094 let _ = std::fs::remove_dir_all(&root);
1095 }
1096}
src/foundation.rs +15 −6
@@ -1,7 +1,8 @@
1 1 //! Honest capability report for the current editor.
2 2 //!
3 //! Attachment authoring, target-map production and the library depth compositor
4 //! are present. The rendered 2D GUI/tab/playback remains not built.
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.
5 6
6 7 /// Product capability named by the design.
7 8 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -9,6 +10,7 @@ pub enum Capability {
9 10 ProjectShell,
10 11 ObjMtlTextures,
11 12 Fbx,
13 FbxDrawable,
12 14 GltfGlb,
13 15 SkeletalAnimation,
14 16 AttachmentEditor,
@@ -42,10 +44,11 @@ pub enum CliError {
42 44 Usage(String),
43 45 }
44 46
45 const CAPABILITIES: [Capability; 8] = [
47 const CAPABILITIES: [Capability; 9] = [
46 48 Capability::ProjectShell,
47 49 Capability::ObjMtlTextures,
48 50 Capability::Fbx,
51 Capability::FbxDrawable,
49 52 Capability::GltfGlb,
50 53 Capability::SkeletalAnimation,
51 54 Capability::AttachmentEditor,
@@ -63,10 +66,11 @@ pub fn availability(capability: Capability) -> Availability {
63 66 | Capability::ObjMtlTextures
64 67 | Capability::GltfGlb
65 68 | Capability::Fbx
69 | Capability::FbxDrawable
66 70 | Capability::SkeletalAnimation
67 71 | Capability::AttachmentEditor
68 | Capability::PaperdollRender => Availability::Present,
69 Capability::Preview2d => Availability::Present,
72 | Capability::PaperdollRender
73 | Capability::Preview2d => Availability::Present,
70 74 }
71 75 }
72 76
@@ -75,6 +79,7 @@ pub fn machine_key(capability: Capability) -> &'static str {
75 79 Capability::ProjectShell => "project_shell",
76 80 Capability::ObjMtlTextures => "obj_mtl_textures",
77 81 Capability::Fbx => "fbx",
82 Capability::FbxDrawable => "fbx_drawable",
78 83 Capability::GltfGlb => "gltf_glb",
79 84 Capability::SkeletalAnimation => "skeletal_animation",
80 85 Capability::AttachmentEditor => "attachment_editor",
@@ -95,6 +100,7 @@ pub fn dutch_label(capability: Capability) -> &'static str {
95 100 Capability::ProjectShell => "projectschil",
96 101 Capability::ObjMtlTextures => "OBJ + MTL + textures",
97 102 Capability::Fbx => "FBX",
103 Capability::FbxDrawable => "FBX drawable scene",
98 104 Capability::GltfGlb => "glTF/GLB",
99 105 Capability::SkeletalAnimation => "skeletanimatie",
100 106 Capability::AttachmentEditor => "3D-attachmenteditor",
@@ -111,7 +117,7 @@ pub fn dutch_state(availability: Availability) -> &'static str {
111 117 }
112 118
113 119 pub fn dutch_summary() -> &'static str {
114 "Deze build leest glTF/GLB, FBX en OBJ+MTL, 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."
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."
115 121 }
116 122
117 123 /// Stable status text shared by `--status` and the window.
@@ -244,6 +250,7 @@ mod tests {
244 250 Availability::Present
245 251 );
246 252 assert_eq!(availability(Capability::Fbx), Availability::Present);
253 assert_eq!(availability(Capability::FbxDrawable), Availability::Present);
247 254 assert_eq!(
248 255 availability(Capability::SkeletalAnimation),
249 256 Availability::Present
@@ -268,6 +275,7 @@ mod tests {
268 275 "obj_mtl_textures",
269 276 "gltf_glb",
270 277 "fbx",
278 "fbx_drawable",
271 279 "skeletal_animation",
272 280 "attachment_editor",
273 281 "paperdoll_render",
@@ -287,6 +295,7 @@ mod tests {
287 295 assert!(summary.contains("glTF/GLB"));
288 296 assert!(summary.contains("walk"));
289 297 assert!(summary.contains("2D-preview"));
298 assert!(summary.contains("FBX-scene") || summary.contains("tekenbare"));
290 299 }
291 300
292 301 #[test]
@@ -1,7 +1,8 @@
1//! Honest capability report for the current editor.
1//! Honest capability report for the current editor.
2//!
2//!
3//! Attachment authoring, target-map production and the library depth compositor
3//! Attachment authoring, target-map production, the library depth compositor,
4//! are present. The rendered 2D GUI/tab/playback remains not built.
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.
5
6
6/// Product capability named by the design.
7/// Product capability named by the design.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -9,6 +10,7 @@ pub enum Capability {
9 ProjectShell,
10 ProjectShell,
10 ObjMtlTextures,
11 ObjMtlTextures,
11 Fbx,
12 Fbx,
13 FbxDrawable,
12 GltfGlb,
14 GltfGlb,
13 SkeletalAnimation,
15 SkeletalAnimation,
14 AttachmentEditor,
16 AttachmentEditor,
@@ -42,10 +44,11 @@ pub enum CliError {
42 Usage(String),
44 Usage(String),
43}
45}
44
46
45const CAPABILITIES: [Capability; 8] = [
47const CAPABILITIES: [Capability; 9] = [
46 Capability::ProjectShell,
48 Capability::ProjectShell,
47 Capability::ObjMtlTextures,
49 Capability::ObjMtlTextures,
48 Capability::Fbx,
50 Capability::Fbx,
51 Capability::FbxDrawable,
49 Capability::GltfGlb,
52 Capability::GltfGlb,
50 Capability::SkeletalAnimation,
53 Capability::SkeletalAnimation,
51 Capability::AttachmentEditor,
54 Capability::AttachmentEditor,
@@ -63,10 +66,11 @@ pub fn availability(capability: Capability) -> Availability {
63 | Capability::ObjMtlTextures
66 | Capability::ObjMtlTextures
64 | Capability::GltfGlb
67 | Capability::GltfGlb
65 | Capability::Fbx
68 | Capability::Fbx
69 | Capability::FbxDrawable
66 | Capability::SkeletalAnimation
70 | Capability::SkeletalAnimation
67 | Capability::AttachmentEditor
71 | Capability::AttachmentEditor
68 | Capability::PaperdollRender => Availability::Present,
72 | Capability::PaperdollRender
69 Capability::Preview2d => Availability::Present,
73 | Capability::Preview2d => Availability::Present,
70 }
74 }
71}
75}
72
76
@@ -75,6 +79,7 @@ pub fn machine_key(capability: Capability) -> &'static str {
75 Capability::ProjectShell => "project_shell",
79 Capability::ProjectShell => "project_shell",
76 Capability::ObjMtlTextures => "obj_mtl_textures",
80 Capability::ObjMtlTextures => "obj_mtl_textures",
77 Capability::Fbx => "fbx",
81 Capability::Fbx => "fbx",
82 Capability::FbxDrawable => "fbx_drawable",
78 Capability::GltfGlb => "gltf_glb",
83 Capability::GltfGlb => "gltf_glb",
79 Capability::SkeletalAnimation => "skeletal_animation",
84 Capability::SkeletalAnimation => "skeletal_animation",
80 Capability::AttachmentEditor => "attachment_editor",
85 Capability::AttachmentEditor => "attachment_editor",
@@ -95,6 +100,7 @@ pub fn dutch_label(capability: Capability) -> &'static str {
95 Capability::ProjectShell => "projectschil",
100 Capability::ProjectShell => "projectschil",
96 Capability::ObjMtlTextures => "OBJ + MTL + textures",
101 Capability::ObjMtlTextures => "OBJ + MTL + textures",
97 Capability::Fbx => "FBX",
102 Capability::Fbx => "FBX",
103 Capability::FbxDrawable => "FBX drawable scene",
98 Capability::GltfGlb => "glTF/GLB",
104 Capability::GltfGlb => "glTF/GLB",
99 Capability::SkeletalAnimation => "skeletanimatie",
105 Capability::SkeletalAnimation => "skeletanimatie",
100 Capability::AttachmentEditor => "3D-attachmenteditor",
106 Capability::AttachmentEditor => "3D-attachmenteditor",
@@ -111,7 +117,7 @@ pub fn dutch_state(availability: Availability) -> &'static str {
111}
117}
112
118
113pub fn dutch_summary() -> &'static str {
119pub fn dutch_summary() -> &'static str {
114 "Deze build leest glTF/GLB, FBX en OBJ+MTL, 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."
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."
115}
121}
116
122
117/// Stable status text shared by `--status` and the window.
123/// Stable status text shared by `--status` and the window.
@@ -244,6 +250,7 @@ mod tests {
244 Availability::Present
250 Availability::Present
245 );
251 );
246 assert_eq!(availability(Capability::Fbx), Availability::Present);
252 assert_eq!(availability(Capability::Fbx), Availability::Present);
253 assert_eq!(availability(Capability::FbxDrawable), Availability::Present);
247 assert_eq!(
254 assert_eq!(
248 availability(Capability::SkeletalAnimation),
255 availability(Capability::SkeletalAnimation),
249 Availability::Present
256 Availability::Present
@@ -268,6 +275,7 @@ mod tests {
268 "obj_mtl_textures",
275 "obj_mtl_textures",
269 "gltf_glb",
276 "gltf_glb",
270 "fbx",
277 "fbx",
278 "fbx_drawable",
271 "skeletal_animation",
279 "skeletal_animation",
272 "attachment_editor",
280 "attachment_editor",
273 "paperdoll_render",
281 "paperdoll_render",
@@ -287,6 +295,7 @@ mod tests {
287 assert!(summary.contains("glTF/GLB"));
295 assert!(summary.contains("glTF/GLB"));
288 assert!(summary.contains("walk"));
296 assert!(summary.contains("walk"));
289 assert!(summary.contains("2D-preview"));
297 assert!(summary.contains("2D-preview"));
298 assert!(summary.contains("FBX-scene") || summary.contains("tekenbare"));
290 }
299 }
291
300
292 #[test]
301 #[test]
src/import.rs +5 −5
@@ -1,8 +1,8 @@
1 //! Read glTF/GLB, OBJ+MTL and FBX into a CPU description.
1 //! Read glTF/GLB, OBJ+MTL and FBX into a CPU inventory description.
2 2 //!
3 3 //! FBX is parsed with ufbx, compiled into this binary from its MIT C source.
4 //! There is no separate libassimp to load at runtime. This module does not
5 //! draw a viewport and does not sample skeletal animation.
4 //! There is no separate libassimp to load at runtime. Inventory records stay
5 //! here; the normalized drawable/animated FBX scene lives in `fbx_scene`.
6 6
7 7 use std::fs;
8 8 use std::path::Path;
@@ -460,7 +460,7 @@ fn import_fbx(
460 460 joints,
461 461 clips,
462 462 notices: vec![format!(
463 "FBX gelezen met {FBX_BACKEND}. Clips worden getoond, niet afgespeeld, en er is niet gerenderd."
463 "FBX gelezen met {FBX_BACKEND}. Importoverzicht toont meshes/joints/clips; genormaliseerde drawable scene via fbx_scene. Live viewport en productie gebruiken nog glTF/GLB."
464 464 )],
465 465 })
466 466 }
@@ -987,7 +987,7 @@ mod tests {
987 987 assert!(model
988 988 .notices
989 989 .iter()
990 .any(|notice| notice.contains("niet gerenderd")));
990 .any(|notice| notice.contains("fbx_scene") || notice.contains("drawable")));
991 991 }
992 992
993 993 #[test]
@@ -1,8 +1,8 @@
1//! Read glTF/GLB, OBJ+MTL and FBX into a CPU description.
1//! Read glTF/GLB, OBJ+MTL and FBX into a CPU inventory description.
2//!
2//!
3//! FBX is parsed with ufbx, compiled into this binary from its MIT C source.
3//! FBX is parsed with ufbx, compiled into this binary from its MIT C source.
4//! There is no separate libassimp to load at runtime. This module does not
4//! There is no separate libassimp to load at runtime. Inventory records stay
5//! draw a viewport and does not sample skeletal animation.
5//! here; the normalized drawable/animated FBX scene lives in `fbx_scene`.
6
6
7use std::fs;
7use std::fs;
8use std::path::Path;
8use std::path::Path;
@@ -460,7 +460,7 @@ fn import_fbx(
460 joints,
460 joints,
461 clips,
461 clips,
462 notices: vec![format!(
462 notices: vec![format!(
463 "FBX gelezen met {FBX_BACKEND}. Clips worden getoond, niet afgespeeld, en er is niet gerenderd."
463 "FBX gelezen met {FBX_BACKEND}. Importoverzicht toont meshes/joints/clips; genormaliseerde drawable scene via fbx_scene. Live viewport en productie gebruiken nog glTF/GLB."
464 )],
464 )],
465 })
465 })
466}
466}
@@ -987,7 +987,7 @@ mod tests {
987 assert!(model
987 assert!(model
988 .notices
988 .notices
989 .iter()
989 .iter()
990 .any(|notice| notice.contains("niet gerenderd")));
990 .any(|notice| notice.contains("fbx_scene") || notice.contains("drawable")));
991 }
991 }
992
992
993 #[test]
993 #[test]
src/lib.rs +3 −1
@@ -1,7 +1,8 @@
1 1 //! Paperdoll Renderer library.
2 2 //!
3 3 //! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,
4 //! draws skinned body/equipment previews, and streams production target maps.
4 //! builds a normalized drawable FBX scene (`fbx_scene`) for CPU sampling,
5 //! draws skinned glTF body/equipment previews, and streams production target maps.
5 6 //! Completed export runs are opened through `export_set` with checksum/identity
6 7 //! validation; `compositor` depth-composites compatible layers, relights them and applies optional rim; `preview2d` drives the rendered 2D GUI/tab/playback; `resume` recovers incomplete runs after contract and checksum verification.
7 8
@@ -12,6 +13,7 @@ pub mod digest;
12 13 pub mod editor;
13 14 pub mod equipment;
14 15 pub mod export_set;
16 pub mod fbx_scene;
15 17 pub mod file_assoc;
16 18 pub mod foundation;
17 19 pub mod icon;
@@ -1,7 +1,8 @@
1//! Paperdoll Renderer library.
1//! Paperdoll Renderer library.
2//!
2//!
3//! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,
3//! The shell stores a `.ppd` project, reads glTF/GLB, FBX and OBJ+MTL,
4//! draws skinned body/equipment previews, and streams production target maps.
4//! builds a normalized drawable FBX scene (`fbx_scene`) for CPU sampling,
5//! draws skinned glTF body/equipment previews, and streams production target maps.
5//! Completed export runs are opened through `export_set` with checksum/identity
6//! Completed export runs are opened through `export_set` with checksum/identity
6//! validation; `compositor` depth-composites compatible layers, relights them and applies optional rim; `preview2d` drives the rendered 2D GUI/tab/playback; `resume` recovers incomplete runs after contract and checksum verification.
7//! validation; `compositor` depth-composites compatible layers, relights them and applies optional rim; `preview2d` drives the rendered 2D GUI/tab/playback; `resume` recovers incomplete runs after contract and checksum verification.
7
8
@@ -12,6 +13,7 @@ pub mod digest;
12pub mod editor;
13pub mod editor;
13pub mod equipment;
14pub mod equipment;
14pub mod export_set;
15pub mod export_set;
16pub mod fbx_scene;
15pub mod file_assoc;
17pub mod file_assoc;
16pub mod foundation;
18pub mod foundation;
17pub mod icon;
19pub mod icon;