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.
@@ -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 -- --status17
17
18
Het startvenster biedt **Project laden** en **Nieuw project**, beide met een systeembestandkiezer. Nieuw project maakt `character.ppd` en de mappen `assets/body/`, `assets/equipment/`, `presets/`, `cache/` en `renders/`. De CC0-testlichamen worden in `assets/body/` voorbereid. `--preview-fixture` maakt een tijdelijk voorbeeldproject en opent de mannelijke body.18
Het startvenster biedt **Project laden** en **Nieuw project**, beide met een systeembestandkiezer. Nieuw project maakt `character.ppd` en de mappen `assets/body/`, `assets/equipment/`, `presets/`, `cache/` en `renders/`. De CC0-testlichamen worden in `assets/body/` voorbereid. `--preview-fixture` maakt een tijdelijk voorbeeldproject en opent de mannelijke body.19
19
20
De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert een model. Een extern bestand wordt naar de projectmap gekopieerd; bij een bestaande naam krijgt de kopie een eigen naam. Modellen met bijbestanden kun je met hun relatieve mappenstructuur in de projectmap zetten en daar selecteren. **Importoverzicht** toont meshes, materialen, textures, joints en clips van glTF/GLB, FBX en OBJ+MTL.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
**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(), &RimP116
```116
```117
117
118
118
119
Highres is exact 8× beide assen; batch-targets zijn momenteel **1–256 pixels per as** (highres max 2048). Grotere projectinstellingen geven een duidelijke renderfout, geen stil verkleinde supersampling. Maximaal 500000 PNGs / 16 GiB geschatte ongecomprimeerde targetdata per run; output vereist hardlinks. Productie rendert de eerste skinned glTF/GLB-mesh met driehoekprimitieven, basiskleur/8-bit albedotextures en vertexkleuren. FBX 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
## Openen met121
## Openen met122
122
@@ -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 only67
67
68
This build reads glTF 2.0 `.gltf` and `.glb`: mesh positions, triangle indices, normals, UVs, skin weights, materials (base color and named textures), images (external files and buffer views), joint names with parents, and every animation clip (name, duration, channel count, target joints). It reads OBJ+MTL as a static equipment mesh, including referenced texture files, and always records that OBJ has no skeleton and no skeletal clips. External URIs that leave the project directory are rejected. A missing buffer, image or MTL file fails the import and names the missing path.68
This build reads glTF 2.0 `.gltf` and `.glb`: mesh positions, triangle indices, normals, UVs, skin weights, materials (base color and named textures), images (external files and buffer views), joint names with parents, and every animation clip (name, duration, channel count, target joints). It reads OBJ+MTL as a static equipment mesh, including referenced texture files, and always records that OBJ has no skeleton and no skeletal clips. External URIs that leave the project directory are rejected. A missing buffer, image or MTL file fails the import and names the missing path.69
69
70
FBX is read with the `ufbx` crate 0.11.5. That crate compiles the MIT-licensed ufbx C loader into the binary, so AppImage, `.deb`, Arch and the MinGW Windows build do not depend on a system `libassimp`. `russimp` was not used: it needs a native Assimp build whose FBX animation results and Windows packaging are still the open risk above, and ufbx is the loader that actually returns skins, clips and materials for the fixture. A file that is not FBX still fails the parse; the `.fbx` suffix alone is not accepted. 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
| 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
151
Every pass uses one uniform scale, centered letterbox, nearest-neighbor display; never stretch target X/Y independently. The progress bar counts actual completed passes out of `images*6`, and a separate label identifies the last completed image while a later pass works. Each pose saves exactly four target PNGs. **Annuleer render** stops between safe units (or after the in-flight GPU readback), keeps partial outputs and never produces a complete marker. Consumer-side rim/edge lighting lives in the 2D compositor/preview path, never in pure diffuse albedo.151
Every pass uses one uniform scale, centered letterbox, nearest-neighbor display; never stretch target X/Y independently. The progress bar counts actual completed passes out of `images*6`, and a separate label identifies the last completed image while a later pass works. Each pose saves exactly four target PNGs. **Annuleer render** stops between safe units (or after the in-flight GPU readback), keeps partial outputs and never produces a complete marker. Consumer-side rim/edge lighting lives in the 2D compositor/preview path, never in pure diffuse albedo.152
152
153
Current source scope: the first skinned glTF/GLB mesh (all its triangle primitives), first skin, vertex NORMAL attributes when present, otherwise true flat normals from GPU derivatives of the skinned view-space triangle position (never the live-only fallback vector), base-color factors/textures and COLOR_0; albedo images must be 8-bit and ≤2048×2048. Textures currently use nearest/repeat; sampler overrides/texture transforms, morph targets, tangent normal maps, multi-mesh scenes and arbitrary translucent layer ordering are not claimed. Opaque/alpha-cutout uses one winning surface; this is not order-independent blended transparency. GPU limits also reject non-finite geometry, more than 1M vertices/3M indices per draw, invalid texture payloads and invalid camera matrices. FBX 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
Equipment pass hides the body from **all color, depth, normal and specular writes**, while still evaluating its rig and animating attachments. The body is not an invisible occluder; otherwise equipment behind it would be missing when layers are recomposed. Equipment self-occludes normally. Every layer must use the same projection and absolute depth contract. Hair/alpha cutout versus true blended transparency is an open fidelity decision; initial opaque/cutout support must be stated rather than promising arbitrary multilayer transparency from a single depth value.155
Equipment pass hides the body from **all color, depth, normal and specular writes**, while still evaluating its rig and animating attachments. The body is not an invisible occluder; otherwise equipment behind it would be missing when layers are recomposed. Equipment self-occludes normally. Every layer must use the same projection and absolute depth contract. Hair/alpha cutout versus true blended transparency is an open fidelity decision; initial opaque/cutout support must be stated rather than promising arbitrary multilayer transparency from a single depth value.156
156
@@ -196,7 +196,7 @@ After every merge, `README.md` describes only the current behavior: what the pro196
196
197
The application icon is artwork supplied by Tim Falken. The original file is `assets/icons/paperdoll-renderer.png` (57×60 RGBA). Package bitmaps are scaled from that file with a premultiplied Lanczos filter, so the drawing is not redrawn. The eframe window uses the 128×128 raster as its window icon. Larger hicolor sizes stay in the packages. Linux AppImage, `.deb` and Arch packages install the hicolor sizes and name the icon `paperdoll-renderer` from both the desktop file and the AppStream metainfo. The MinGW Windows build embeds the same artwork in `paperdoll-renderer.exe` as `RT_GROUP_ICON`; the zip does not carry a separate `.ico`.197
The application icon is artwork supplied by Tim Falken. The original file is `assets/icons/paperdoll-renderer.png` (57×60 RGBA). Package bitmaps are scaled from that file with a premultiplied Lanczos filter, so the drawing is not redrawn. The eframe window uses the 128×128 raster as its window icon. Larger hicolor sizes stay in the packages. Linux AppImage, `.deb` and Arch packages install the hicolor sizes and name the icon `paperdoll-renderer` from both the desktop file and the AppStream metainfo. The MinGW Windows build embeds the same artwork in `paperdoll-renderer.exe` as `RT_GROUP_ICON`; the zip does not carry a separate `.ico`.198
198
199
Live 3D preview, as built: the body screen draws the skinned glTF/GLB mesh with an independent perspective orbit camera or the visible fixed orthographic render frame (§6). Drag orbits, scroll zooms, and buttons snap to pitches -30/0/+30 and the twelve yaw steps of 30 degrees. Yaw 0 places the camera on -Z because the test bodies face -Z. Walk and attack are sampled on the CPU and uploaded as joint matrices. The preview light is a placement shade. The placement preview is lit, unlike exported albedo. It overlays bone-attached OBJ equipment (§9); FBX 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
CI filename requested: `blue-ci-cd.yml`. **Unverified infrastructure detail:** do not invent BlueGit YAML syntax. Inspect documented BlueGit runner schema and narrow existing `timfa/posprint` and `legacy-cycles` pipeline/release examples before writing configuration. Respect runner/image selection, artifact retention and release API semantics. No GitLab mirror is requested for this new project.201
CI filename requested: `blue-ci-cd.yml`. **Unverified infrastructure detail:** do not invent BlueGit YAML syntax. Inspect documented BlueGit runner schema and narrow existing `timfa/posprint` and `legacy-cycles` pipeline/release examples before writing configuration. Respect runner/image selection, artifact retention and release API semantics. No GitLab mirror is requested for this new project.202
202
@@ -219,7 +219,7 @@ These requirements are durable even where the implementation is staged. They sup219
- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.219
- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.220
- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.220
- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.221
221
222
Current implementation checkpoint: native project/pickers, equipment authoring/presets, shared-depth animated placement preview, fixed per-sex framing and rest-pose neck-height orbit pivot are present. **Render alle hoeken** now produces every source clip × sampled frame × 36 views with the six real pass previews and four target maps described above, streaming, progress and cancellation, with schema-3 export identity, source fingerprints and per-PNG SHA-256 checksums plus the safe `export_set` reader. The former two-pass shaded color still is no longer the Render action. The older CPU still helper remains regression-tested but is not the production path. The library 2D depth compositor/relighting, consumer-side rim/edge lighting (R08) and the 2D-preview GUI/tab/playback (with Randlicht control) are implemented, as is checksum-verified resume of cancelled/failed/crashed runs (§7); FBX 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
## 12. Incremental feature plan and acceptance gates224
## 12. Incremental feature plan and acceptance gates225
225
@@ -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 retains5
//! conversion provenance. Live Glow viewport and production four-map batch6
//! 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 = path71
.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.description101
)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 = mesh123
.skin_deformers124
.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
other133
));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 = surfaces203
.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
None251
} else {252
node.parent253
.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 !translation276
.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_id299
));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 = cluster325
.bone_node326
.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_color398
} else {399
&material.fbx.diffuse_color400
};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.0407
} else {408
color_map.value_vec4.w as f32409
},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
material423
.fbx424
.normal_map425
.texture426
.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
relative495
};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::RootDir538
)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 = reader555
.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_indices612
.iter()613
.copied()614
.collect()615
};616
if face_indices.is_empty() {617
continue;618
}619
let seed = seeds620
.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_index711
));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.description761
)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 f32792
} else {793
(stack.time_end - stack.time_begin).max(0.0) as f32794
};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!(scene922
.character923
.joint_names924
.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!(scene930
.character931
.clips932
.iter()933
.any(|c| c.name.to_lowercase().contains("walk")));934
assert!(scene935
.character936
.clips937
.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-axis958
// rotations make intermediate rest XYZ diverge while animation matches.959
let f_hips = fbx960
.character961
.joint_names962
.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 = fbx977
.character978
.joint_names979
.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_names1004
.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 glb1056
.joint_names1057
.iter()1058
.zip(f_skin.iter())1059
.zip(g_skin.iter())1060
.map(|((n, f), g)| (n, f, g))1061
{1062
let fi = fbx1063
.character1064
.joint_names1065
.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 = fbx1074
.character1075
.joint_names1076
.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
}@@ -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 compositor3
//! 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
45
const CAPABILITIES: [Capability; 8] = [47
const 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::ObjMtlTextures66
| Capability::ObjMtlTextures64
| Capability::GltfGlb67
| Capability::GltfGlb65
| Capability::Fbx68
| Capability::Fbx69
| Capability::FbxDrawable66
| Capability::SkeletalAnimation70
| Capability::SkeletalAnimation67
| Capability::AttachmentEditor71
| Capability::AttachmentEditor68
| Capability::PaperdollRender => Availability::Present,72
| Capability::PaperdollRender69
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
113
pub fn dutch_summary() -> &'static str {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
/// 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::Present250
Availability::Present245
);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::Present256
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]@@ -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 not4
//! There is no separate libassimp to load at runtime. Inventory records stay5
//! draw a viewport and does not sample skeletal animation.5
//! here; the normalized drawable/animated FBX scene lives in `fbx_scene`.6
6
7
use std::fs;7
use std::fs;8
use std::path::Path;8
use 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!(model987
assert!(model988
.notices988
.notices989
.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]@@ -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/identity6
//! Completed export runs are opened through `export_set` with checksum/identity6
//! 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;12
pub mod editor;13
pub mod editor;13
pub mod equipment;14
pub mod equipment;14
pub mod export_set;15
pub mod export_set;16
pub mod fbx_scene;15
pub mod file_assoc;17
pub mod file_assoc;16
pub mod foundation;18
pub mod foundation;17
pub mod icon;19
pub mod icon;