BlueGit

golden-age paperdoll-renderer

feat: import FBX skins and clips with ufbx

Compile the ufbx loader into the binary so Linux packages and the MinGW
Windows build do not need a system Assimp. Parse the Blender-converted
test body for a skinned mesh, joints, materials and both clips.

Co-authored-by: tim <tim@timfalken.com>

timfa Cursor Agent · pushed by timfa

Authored 2026-11-06 · 7 days ago

Hash
59dd36166abef8c084e8e13c40197c80bd8aeb52
Parent
11eefbcf
14 files changed +363 −31
.gitattributes +1 −0
@@ -1,2 +1,3 @@
1 1 *.glb binary
2 *.fbx binary
2 3 *.png binary
@@ -1,2 +1,3 @@
1*.glb binary
1*.glb binary
2*.fbx binary
2*.png binary
3*.png binary
Cargo.lock +10 −0
@@ -1813,6 +1813,7 @@ dependencies = [
1813 1813 "serde",
1814 1814 "serde_json",
1815 1815 "tobj",
1816 "ufbx",
1816 1817 ]
1817 1818
1818 1819 [[package]]
@@ -2477,6 +2478,15 @@ dependencies = [
2477 2478 "rustc-hash 2.1.3",
2478 2479 ]
2479 2480
2481 [[package]]
2482 name = "ufbx"
2483 version = "0.11.5"
2484 source = "registry+https://github.com/rust-lang/crates.io-index"
2485 checksum = "260522f1eb74139f0e78b29dd82a58e5437d728c5498024a39e5af7282694240"
2486 dependencies = [
2487 "cc",
2488 ]
2489
2480 2490 [[package]]
2481 2491 name = "unicode-ident"
2482 2492 version = "1.0.27"
@@ -1813,6 +1813,7 @@ dependencies = [
1813 "serde",
1813 "serde",
1814 "serde_json",
1814 "serde_json",
1815 "tobj",
1815 "tobj",
1816 "ufbx",
1816]
1817]
1817
1818
1818[[package]]
1819[[package]]
@@ -2477,6 +2478,15 @@ dependencies = [
2477 "rustc-hash 2.1.3",
2478 "rustc-hash 2.1.3",
2478]
2479]
2479
2480
2481[[package]]
2482name = "ufbx"
2483version = "0.11.5"
2484source = "registry+https://github.com/rust-lang/crates.io-index"
2485checksum = "260522f1eb74139f0e78b29dd82a58e5437d728c5498024a39e5af7282694240"
2486dependencies = [
2487 "cc",
2488]
2489
2480[[package]]
2490[[package]]
2481name = "unicode-ident"
2491name = "unicode-ident"
2482version = "1.0.27"
2492version = "1.0.27"
Cargo.toml +1 −0
@@ -13,3 +13,4 @@ libloading = "0.8"
13 13 serde = { version = "1", features = ["derive"] }
14 14 serde_json = "1"
15 15 tobj = "4.0.3"
16 ufbx = "0.11.5"
@@ -13,3 +13,4 @@ libloading = "0.8"
13serde = { version = "1", features = ["derive"] }
13serde = { version = "1", features = ["derive"] }
14serde_json = "1"
14serde_json = "1"
15tobj = "4.0.3"
15tobj = "4.0.3"
16ufbx = "0.11.5"
README.md +1 −1
@@ -1,6 +1,6 @@
1 1 # Paperdoll Renderer
2 2
3 Native Rust-schil voor Tim Falkens Paperdoll Renderer. Het venster bewaart een projectmap met één `character.ppd` en leest glTF/GLB (mesh, materialen, textures, skeleton en alle animatieclips) plus OBJ+MTL als statische equipment. FBX wordt herkend en geweigerd: Assimp is niet gekoppeld. Er is geen 3D-beeld en geen paperdoll-render. Het ontwerp, inclusief de letterlijke Nederlandse specificatie en de formaatmatrix voor OBJ+MTL+textures, FBX en glTF/GLB, staat in [`design.md`](design.md).
3 Native Rust-schil voor Tim Falkens Paperdoll Renderer. Het venster bewaart een projectmap met één `character.ppd` en leest glTF/GLB en FBX (mesh, materialen, textures, skeleton en animatieclips) plus OBJ+MTL als statische equipment. FBX gaat via ufbx, meegecompileerd in de binary, niet via een losse libassimp. Er is geen 3D-beeld en geen paperdoll-render. Het ontwerp, inclusief de letterlijke Nederlandse specificatie en de formaatmatrix voor OBJ+MTL+textures, FBX en glTF/GLB, staat in [`design.md`](design.md).
4 4
5 5 ## Eisen
6 6
@@ -1,6 +1,6 @@
1# Paperdoll Renderer
1# Paperdoll Renderer
2
2
3Native Rust-schil voor Tim Falkens Paperdoll Renderer. Het venster bewaart een projectmap met één `character.ppd` en leest glTF/GLB (mesh, materialen, textures, skeleton en alle animatieclips) plus OBJ+MTL als statische equipment. FBX wordt herkend en geweigerd: Assimp is niet gekoppeld. Er is geen 3D-beeld en geen paperdoll-render. Het ontwerp, inclusief de letterlijke Nederlandse specificatie en de formaatmatrix voor OBJ+MTL+textures, FBX en glTF/GLB, staat in [`design.md`](design.md).
3Native Rust-schil voor Tim Falkens Paperdoll Renderer. Het venster bewaart een projectmap met één `character.ppd` en leest glTF/GLB en FBX (mesh, materialen, textures, skeleton en animatieclips) plus OBJ+MTL als statische equipment. FBX gaat via ufbx, meegecompileerd in de binary, niet via een losse libassimp. Er is geen 3D-beeld en geen paperdoll-render. Het ontwerp, inclusief de letterlijke Nederlandse specificatie en de formaatmatrix voor OBJ+MTL+textures, FBX en glTF/GLB, staat in [`design.md`](design.md).
4
4
5## Eisen
5## Eisen
6
6
blue-ci-cd.yml +6 −4
@@ -45,7 +45,7 @@ workflow:
45 45 - export CARGO_HOME="$PWD/cargo-home"
46 46 - export PATH="$CARGO_HOME/bin:$PATH"
47 47 - apt-get update -qq
48 - apt-get install -y -qq pkg-config libxcb1-dev libx11-dev libxkbcommon-dev libxkbcommon-x11-dev libgl1-mesa-dev python3
48 - apt-get install -y -qq pkg-config gcc libxcb1-dev libx11-dev libxkbcommon-dev libxkbcommon-x11-dev libgl1-mesa-dev python3
49 49
50 50 .on_tag:
51 51 rules:
@@ -82,7 +82,7 @@ package:appimage:
82 82 - export CARGO_HOME="$PWD/cargo-home"
83 83 - export PATH="$CARGO_HOME/bin:$PATH"
84 84 - apt-get update -qq
85 - apt-get install -y -qq pkg-config libxcb1-dev libx11-dev libxkbcommon-dev libxkbcommon-x11-dev libgl1-mesa-dev file
85 - apt-get install -y -qq pkg-config gcc libxcb1-dev libx11-dev libxkbcommon-dev libxkbcommon-x11-dev libgl1-mesa-dev file
86 86 script:
87 87 - bash scripts/package/appimage.sh
88 88 artifacts:
@@ -101,7 +101,7 @@ package:ubuntu:
101 101 - export CARGO_HOME="$PWD/cargo-home"
102 102 - export PATH="$CARGO_HOME/bin:$PATH"
103 103 - apt-get update -qq
104 - apt-get install -y -qq pkg-config libxcb1-dev libx11-dev libxkbcommon-dev libxkbcommon-x11-dev libgl1-mesa-dev dpkg-dev
104 - apt-get install -y -qq pkg-config gcc libxcb1-dev libx11-dev libxkbcommon-dev libxkbcommon-x11-dev libgl1-mesa-dev dpkg-dev
105 105 script:
106 106 - bash scripts/package/deb.sh
107 107 artifacts:
@@ -117,7 +117,7 @@ package:arch:
117 117 interruptible: true
118 118 script:
119 119 - pacman -Syu --noconfirm
120 - pacman -S --noconfirm --needed rust pkgconf libxkbcommon libxcb libx11 libglvnd git
120 - pacman -S --noconfirm --needed base-devel rust pkgconf libxkbcommon libxcb libx11 libglvnd git
121 121 - bash scripts/package/arch.sh
122 122 artifacts:
123 123 paths: [dist/paperdoll-renderer-x86_64.pkg.tar.zst]
@@ -138,6 +138,8 @@ package:windows:
138 138 - apt-get update -qq
139 139 - apt-get install -y -qq gcc-mingw-w64-x86-64 binutils-mingw-w64-x86-64 zip
140 140 - rustup target add x86_64-pc-windows-gnu
141 - export CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc
142 - export AR_x86_64_pc_windows_gnu=x86_64-w64-mingw32-ar
141 143 script:
142 144 - bash scripts/package/windows.sh
143 145 artifacts:
@@ -45,7 +45,7 @@ workflow:
45 - export CARGO_HOME="$PWD/cargo-home"
45 - export CARGO_HOME="$PWD/cargo-home"
46 - export PATH="$CARGO_HOME/bin:$PATH"
46 - export PATH="$CARGO_HOME/bin:$PATH"
47 - apt-get update -qq
47 - apt-get update -qq
48 - apt-get install -y -qq pkg-config libxcb1-dev libx11-dev libxkbcommon-dev libxkbcommon-x11-dev libgl1-mesa-dev python3
48 - apt-get install -y -qq pkg-config gcc libxcb1-dev libx11-dev libxkbcommon-dev libxkbcommon-x11-dev libgl1-mesa-dev python3
49
49
50.on_tag:
50.on_tag:
51 rules:
51 rules:
@@ -82,7 +82,7 @@ package:appimage:
82 - export CARGO_HOME="$PWD/cargo-home"
82 - export CARGO_HOME="$PWD/cargo-home"
83 - export PATH="$CARGO_HOME/bin:$PATH"
83 - export PATH="$CARGO_HOME/bin:$PATH"
84 - apt-get update -qq
84 - apt-get update -qq
85 - apt-get install -y -qq pkg-config libxcb1-dev libx11-dev libxkbcommon-dev libxkbcommon-x11-dev libgl1-mesa-dev file
85 - apt-get install -y -qq pkg-config gcc libxcb1-dev libx11-dev libxkbcommon-dev libxkbcommon-x11-dev libgl1-mesa-dev file
86 script:
86 script:
87 - bash scripts/package/appimage.sh
87 - bash scripts/package/appimage.sh
88 artifacts:
88 artifacts:
@@ -101,7 +101,7 @@ package:ubuntu:
101 - export CARGO_HOME="$PWD/cargo-home"
101 - export CARGO_HOME="$PWD/cargo-home"
102 - export PATH="$CARGO_HOME/bin:$PATH"
102 - export PATH="$CARGO_HOME/bin:$PATH"
103 - apt-get update -qq
103 - apt-get update -qq
104 - apt-get install -y -qq pkg-config libxcb1-dev libx11-dev libxkbcommon-dev libxkbcommon-x11-dev libgl1-mesa-dev dpkg-dev
104 - apt-get install -y -qq pkg-config gcc libxcb1-dev libx11-dev libxkbcommon-dev libxkbcommon-x11-dev libgl1-mesa-dev dpkg-dev
105 script:
105 script:
106 - bash scripts/package/deb.sh
106 - bash scripts/package/deb.sh
107 artifacts:
107 artifacts:
@@ -117,7 +117,7 @@ package:arch:
117 interruptible: true
117 interruptible: true
118 script:
118 script:
119 - pacman -Syu --noconfirm
119 - pacman -Syu --noconfirm
120 - pacman -S --noconfirm --needed rust pkgconf libxkbcommon libxcb libx11 libglvnd git
120 - pacman -S --noconfirm --needed base-devel rust pkgconf libxkbcommon libxcb libx11 libglvnd git
121 - bash scripts/package/arch.sh
121 - bash scripts/package/arch.sh
122 artifacts:
122 artifacts:
123 paths: [dist/paperdoll-renderer-x86_64.pkg.tar.zst]
123 paths: [dist/paperdoll-renderer-x86_64.pkg.tar.zst]
@@ -138,6 +138,8 @@ package:windows:
138 - apt-get update -qq
138 - apt-get update -qq
139 - apt-get install -y -qq gcc-mingw-w64-x86-64 binutils-mingw-w64-x86-64 zip
139 - apt-get install -y -qq gcc-mingw-w64-x86-64 binutils-mingw-w64-x86-64 zip
140 - rustup target add x86_64-pc-windows-gnu
140 - rustup target add x86_64-pc-windows-gnu
141 - export CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc
142 - export AR_x86_64_pc_windows_gnu=x86_64-w64-mingw32-ar
141 script:
143 script:
142 - bash scripts/package/windows.sh
144 - bash scripts/package/windows.sh
143 artifacts:
145 artifacts:
design.md +2 −2
@@ -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 recognized and refused. `libassimp` is not present here, and `russimp` was not added. Assimp still needs a proven native build, packaging and animation fixtures on Linux and Windows before it can be called an FBX importer. The import entry point returns that explanation instead of an empty success. Playback, the 3D viewport and paperdoll rendering remain absent.
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. Playback, the 3D viewport and paperdoll rendering remain absent. 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. Not rendered. |
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 | Varies; embedded/external textures and legacy material conversion | Commonly | Commonly multiple clips/takes | Recognized and refused. Assimp is not linked. Not an importer yet. |
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. |
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 |
@@ -67,13 +67,13 @@ The desktop shell stays on eframe/egui. Bevy is not linked. Pulling Bevy in only
67
67
68This build reads glTF 2.0 `.gltf` and `.glb`: mesh positions, triangle indices, normals, UVs, skin weights, materials (base color and named textures), images (external files and buffer views), joint names with parents, and every animation clip (name, duration, channel count, target joints). It reads OBJ+MTL as a static equipment mesh, including referenced texture files, and always records that OBJ has no skeleton and no skeletal clips. External URIs that leave the project directory are rejected. A missing buffer, image or MTL file fails the import and names the missing path.
68This build reads glTF 2.0 `.gltf` and `.glb`: mesh positions, triangle indices, normals, UVs, skin weights, materials (base color and named textures), images (external files and buffer views), joint names with parents, and every animation clip (name, duration, channel count, target joints). It reads OBJ+MTL as a static equipment mesh, including referenced texture files, and always records that OBJ has no skeleton and no skeletal clips. External URIs that leave the project directory are rejected. A missing buffer, image or MTL file fails the import and names the missing path.
69
69
70FBX is recognized and refused. `libassimp` is not present here, and `russimp` was not added. Assimp still needs a proven native build, packaging and animation fixtures on Linux and Windows before it can be called an FBX importer. The import entry point returns that explanation instead of an empty success. Playback, the 3D viewport and paperdoll rendering remain absent.
70FBX is read with the `ufbx` crate 0.11.5. That crate compiles the MIT-licensed ufbx C loader into the binary, so AppImage, `.deb`, Arch and the MinGW Windows build do not depend on a system `libassimp`. `russimp` was not used: it needs a native Assimp build whose FBX animation results and Windows packaging are still the open risk above, and ufbx is the loader that actually returns skins, clips and materials for the fixture. A file that is not FBX still fails the parse; the `.fbx` suffix alone is not accepted. Playback, the 3D viewport and paperdoll rendering remain absent. 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. Not rendered. |
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. 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. |
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 | Varies; embedded/external textures and legacy material conversion | Commonly | Commonly multiple clips/takes | Recognized and refused. Assimp is not linked. Not an importer yet. |
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. |
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 |
scripts/convert_test_body_to_fbx.py added +53 −0
@@ -0,0 +1,53 @@
1 #!/usr/bin/env python3
2 """Convert the CC0 test-body GLB into an FBX with Blender.
3
4 The committed fixture tests/fixtures/bodies/test-body-male.fbx is the output
5 of this script. Re-run it when the generator changes:
6
7 blender --background --python scripts/convert_test_body_to_fbx.py
8
9 Blender is only a fixture tool. The application does not call Blender.
10 """
11
12 import sys
13 from pathlib import Path
14
15 import bpy
16
17 ROOT = Path(__file__).resolve().parents[1]
18 SOURCE = ROOT / "tests" / "fixtures" / "bodies" / "test-body-male.glb"
19 TARGET = ROOT / "tests" / "fixtures" / "bodies" / "test-body-male.fbx"
20
21
22 def main() -> None:
23 bpy.ops.wm.read_factory_settings(use_empty=True)
24 bpy.ops.import_scene.gltf(filepath=str(SOURCE))
25 for obj in list(bpy.data.objects):
26 if not obj.name.startswith("test-body"):
27 bpy.data.objects.remove(obj, do_unlink=True)
28 actions = [action.name for action in bpy.data.actions]
29 armatures = [obj.name for obj in bpy.data.objects if obj.type == "ARMATURE"]
30 meshes = [obj.name for obj in bpy.data.objects if obj.type == "MESH"]
31 print("actions", actions)
32 print("armatures", armatures)
33 print("meshes", meshes)
34 bpy.ops.export_scene.fbx(
35 filepath=str(TARGET),
36 use_selection=False,
37 object_types={"ARMATURE", "MESH"},
38 add_leaf_bones=False,
39 bake_anim=True,
40 bake_anim_use_all_actions=True,
41 bake_anim_use_nla_strips=True,
42 bake_anim_force_startend_keying=True,
43 path_mode="AUTO",
44 embed_textures=False,
45 axis_forward="-Z",
46 axis_up="Y",
47 apply_scale_options="FBX_SCALE_UNITS",
48 )
49 print(f"wrote {TARGET} ({TARGET.stat().st_size} bytes)")
50
51
52 if __name__ == "__main__":
53 main()
@@ -0,0 +1,53 @@
1#!/usr/bin/env python3
2"""Convert the CC0 test-body GLB into an FBX with Blender.
3
4The committed fixture tests/fixtures/bodies/test-body-male.fbx is the output
5of this script. Re-run it when the generator changes:
6
7 blender --background --python scripts/convert_test_body_to_fbx.py
8
9Blender is only a fixture tool. The application does not call Blender.
10"""
11
12import sys
13from pathlib import Path
14
15import bpy
16
17ROOT = Path(__file__).resolve().parents[1]
18SOURCE = ROOT / "tests" / "fixtures" / "bodies" / "test-body-male.glb"
19TARGET = ROOT / "tests" / "fixtures" / "bodies" / "test-body-male.fbx"
20
21
22def main() -> None:
23 bpy.ops.wm.read_factory_settings(use_empty=True)
24 bpy.ops.import_scene.gltf(filepath=str(SOURCE))
25 for obj in list(bpy.data.objects):
26 if not obj.name.startswith("test-body"):
27 bpy.data.objects.remove(obj, do_unlink=True)
28 actions = [action.name for action in bpy.data.actions]
29 armatures = [obj.name for obj in bpy.data.objects if obj.type == "ARMATURE"]
30 meshes = [obj.name for obj in bpy.data.objects if obj.type == "MESH"]
31 print("actions", actions)
32 print("armatures", armatures)
33 print("meshes", meshes)
34 bpy.ops.export_scene.fbx(
35 filepath=str(TARGET),
36 use_selection=False,
37 object_types={"ARMATURE", "MESH"},
38 add_leaf_bones=False,
39 bake_anim=True,
40 bake_anim_use_all_actions=True,
41 bake_anim_use_nla_strips=True,
42 bake_anim_force_startend_keying=True,
43 path_mode="AUTO",
44 embed_textures=False,
45 axis_forward="-Z",
46 axis_up="Y",
47 apply_scale_options="FBX_SCALE_UNITS",
48 )
49 print(f"wrote {TARGET} ({TARGET.stat().st_size} bytes)")
50
51
52if __name__ == "__main__":
53 main()
src/editor.rs +1 −1
@@ -309,7 +309,7 @@ impl EditorApp {
309 309 fn show_form(&mut self, ui: &mut egui::Ui) {
310 310 ui.heading("Paperdoll Renderer");
311 311 ui.label(
312 "Projectmap met één .ppd-bestand. Lees man of Lees vrouw toont mesh, skeleton en clips. Er is geen 3D-beeld.",
312 "Projectmap met één .ppd-bestand. Lees man of Lees vrouw toont glTF, FBX of OBJ: mesh, skeleton en clips. Er is geen 3D-beeld.",
313 313 );
314 314 ui.add_space(8.0);
315 315 ui.horizontal(|ui| {
@@ -309,7 +309,7 @@ impl EditorApp {
309 fn show_form(&mut self, ui: &mut egui::Ui) {
309 fn show_form(&mut self, ui: &mut egui::Ui) {
310 ui.heading("Paperdoll Renderer");
310 ui.heading("Paperdoll Renderer");
311 ui.label(
311 ui.label(
312 "Projectmap met één .ppd-bestand. Lees man of Lees vrouw toont mesh, skeleton en clips. Er is geen 3D-beeld.",
312 "Projectmap met één .ppd-bestand. Lees man of Lees vrouw toont glTF, FBX of OBJ: mesh, skeleton en clips. Er is geen 3D-beeld.",
313 );
313 );
314 ui.add_space(8.0);
314 ui.add_space(8.0);
315 ui.horizontal(|ui| {
315 ui.horizontal(|ui| {
src/foundation.rs +12 −13
@@ -55,11 +55,11 @@ pub fn capabilities() -> &'static [Capability] {
55 55
56 56 pub fn availability(capability: Capability) -> Availability {
57 57 match capability {
58 Capability::ProjectShell | Capability::ObjMtlTextures | Capability::GltfGlb => {
59 Availability::Present
60 }
61 Capability::Fbx
62 | Capability::SkeletalAnimation
58 Capability::ProjectShell
59 | Capability::ObjMtlTextures
60 | Capability::GltfGlb
61 | Capability::Fbx => Availability::Present,
62 Capability::SkeletalAnimation
63 63 | Capability::AttachmentEditor
64 64 | Capability::PaperdollRender
65 65 | Capability::Preview2d => Availability::NotBuilt,
@@ -107,14 +107,14 @@ pub fn dutch_state(availability: Availability) -> &'static str {
107 107 }
108 108
109 109 pub fn dutch_summary() -> &'static str {
110 "Deze build leest glTF/GLB uit tot meshes, materialen, textures, joints en animatieclips, en OBJ+MTL als statische mesh. FBX, het afspelen van skeletanimatie, de attachment-editor, paperdoll-render en de 2D-preview zijn niet gebouwd."
110 "Deze build leest glTF/GLB en FBX uit tot meshes, materialen, textures, joints en animatieclips, en OBJ+MTL als statische mesh. Het afspelen van skeletanimatie, de attachment-editor, paperdoll-render en de 2D-preview zijn niet gebouwd."
111 111 }
112 112
113 113 /// Stable status text shared by `--status` and the window.
114 114 pub fn report() -> String {
115 115 let mut lines = Vec::with_capacity(2 + CAPABILITIES.len());
116 116 lines.push("name: Paperdoll Renderer".to_string());
117 lines.push("phase: model-import".to_string());
117 lines.push("phase: fbx-import".to_string());
118 118 for capability in CAPABILITIES {
119 119 lines.push(format!(
120 120 "{}: {}",
@@ -165,7 +165,7 @@ Gebruik:
165 165 paperdoll-renderer --status print de status en sluit
166 166 paperdoll-renderer --help toon deze tekst
167 167
168 Een modelpad op de opdrachtregel wordt niet geaccepteerd. glTF/GLB en OBJ+MTL worden in het venster gelezen. FBX en render zijn niet gebouwd."
168 Een modelpad op de opdrachtregel wordt niet geaccepteerd. glTF/GLB, FBX en OBJ+MTL worden in het venster gelezen. Render is niet gebouwd."
169 169 }
170 170
171 171 pub fn parse_cli<'a, I>(args: I) -> Result<CliRequest, CliError>
@@ -204,8 +204,8 @@ mod tests {
204 204 availability(Capability::ObjMtlTextures),
205 205 Availability::Present
206 206 );
207 assert_eq!(availability(Capability::Fbx), Availability::Present);
207 208 for capability in [
208 Capability::Fbx,
209 209 Capability::SkeletalAnimation,
210 210 Capability::AttachmentEditor,
211 211 Capability::PaperdollRender,
@@ -222,15 +222,14 @@ mod tests {
222 222 #[test]
223 223 fn status_report_matches_the_import_build() {
224 224 let report = report();
225 assert!(report.starts_with("name: Paperdoll Renderer\nphase: model-import\n"));
226 for key in ["project_shell", "obj_mtl_textures", "gltf_glb"] {
225 assert!(report.starts_with("name: Paperdoll Renderer\nphase: fbx-import\n"));
226 for key in ["project_shell", "obj_mtl_textures", "gltf_glb", "fbx"] {
227 227 assert!(
228 228 report.contains(&format!("{key}: present")),
229 229 "missing present {key} in {report}"
230 230 );
231 231 }
232 232 for key in [
233 "fbx",
234 233 "skeletal_animation",
235 234 "attachment_editor",
236 235 "paperdoll_render",
@@ -249,9 +248,9 @@ mod tests {
249 248 assert!(summary.contains("niet gebouwd"));
250 249 assert!(summary.contains("OBJ+MTL"));
251 250 assert!(summary.contains("glTF/GLB"));
251 assert!(summary.contains("FBX"));
252 252 let lower = summary.to_lowercase();
253 253 assert!(!lower.contains("render werkt"));
254 assert!(!lower.contains("fbx-import werkt"));
255 254 }
256 255
257 256 #[test]
@@ -55,11 +55,11 @@ pub fn capabilities() -> &'static [Capability] {
55
55
56pub fn availability(capability: Capability) -> Availability {
56pub fn availability(capability: Capability) -> Availability {
57 match capability {
57 match capability {
58 Capability::ProjectShell | Capability::ObjMtlTextures | Capability::GltfGlb => {
58 Capability::ProjectShell
59 Availability::Present
59 | Capability::ObjMtlTextures
60 }
60 | Capability::GltfGlb
61 Capability::Fbx
61 | Capability::Fbx => Availability::Present,
62 | Capability::SkeletalAnimation
62 Capability::SkeletalAnimation
63 | Capability::AttachmentEditor
63 | Capability::AttachmentEditor
64 | Capability::PaperdollRender
64 | Capability::PaperdollRender
65 | Capability::Preview2d => Availability::NotBuilt,
65 | Capability::Preview2d => Availability::NotBuilt,
@@ -107,14 +107,14 @@ pub fn dutch_state(availability: Availability) -> &'static str {
107}
107}
108
108
109pub fn dutch_summary() -> &'static str {
109pub fn dutch_summary() -> &'static str {
110 "Deze build leest glTF/GLB uit tot meshes, materialen, textures, joints en animatieclips, en OBJ+MTL als statische mesh. FBX, het afspelen van skeletanimatie, de attachment-editor, paperdoll-render en de 2D-preview zijn niet gebouwd."
110 "Deze build leest glTF/GLB en FBX uit tot meshes, materialen, textures, joints en animatieclips, en OBJ+MTL als statische mesh. Het afspelen van skeletanimatie, de attachment-editor, paperdoll-render en de 2D-preview zijn niet gebouwd."
111}
111}
112
112
113/// Stable status text shared by `--status` and the window.
113/// Stable status text shared by `--status` and the window.
114pub fn report() -> String {
114pub fn report() -> String {
115 let mut lines = Vec::with_capacity(2 + CAPABILITIES.len());
115 let mut lines = Vec::with_capacity(2 + CAPABILITIES.len());
116 lines.push("name: Paperdoll Renderer".to_string());
116 lines.push("name: Paperdoll Renderer".to_string());
117 lines.push("phase: model-import".to_string());
117 lines.push("phase: fbx-import".to_string());
118 for capability in CAPABILITIES {
118 for capability in CAPABILITIES {
119 lines.push(format!(
119 lines.push(format!(
120 "{}: {}",
120 "{}: {}",
@@ -165,7 +165,7 @@ Gebruik:
165 paperdoll-renderer --status print de status en sluit
165 paperdoll-renderer --status print de status en sluit
166 paperdoll-renderer --help toon deze tekst
166 paperdoll-renderer --help toon deze tekst
167
167
168Een modelpad op de opdrachtregel wordt niet geaccepteerd. glTF/GLB en OBJ+MTL worden in het venster gelezen. FBX en render zijn niet gebouwd."
168Een modelpad op de opdrachtregel wordt niet geaccepteerd. glTF/GLB, FBX en OBJ+MTL worden in het venster gelezen. Render is niet gebouwd."
169}
169}
170
170
171pub fn parse_cli<'a, I>(args: I) -> Result<CliRequest, CliError>
171pub fn parse_cli<'a, I>(args: I) -> Result<CliRequest, CliError>
@@ -204,8 +204,8 @@ mod tests {
204 availability(Capability::ObjMtlTextures),
204 availability(Capability::ObjMtlTextures),
205 Availability::Present
205 Availability::Present
206 );
206 );
207 assert_eq!(availability(Capability::Fbx), Availability::Present);
207 for capability in [
208 for capability in [
208 Capability::Fbx,
209 Capability::SkeletalAnimation,
209 Capability::SkeletalAnimation,
210 Capability::AttachmentEditor,
210 Capability::AttachmentEditor,
211 Capability::PaperdollRender,
211 Capability::PaperdollRender,
@@ -222,15 +222,14 @@ mod tests {
222 #[test]
222 #[test]
223 fn status_report_matches_the_import_build() {
223 fn status_report_matches_the_import_build() {
224 let report = report();
224 let report = report();
225 assert!(report.starts_with("name: Paperdoll Renderer\nphase: model-import\n"));
225 assert!(report.starts_with("name: Paperdoll Renderer\nphase: fbx-import\n"));
226 for key in ["project_shell", "obj_mtl_textures", "gltf_glb"] {
226 for key in ["project_shell", "obj_mtl_textures", "gltf_glb", "fbx"] {
227 assert!(
227 assert!(
228 report.contains(&format!("{key}: present")),
228 report.contains(&format!("{key}: present")),
229 "missing present {key} in {report}"
229 "missing present {key} in {report}"
230 );
230 );
231 }
231 }
232 for key in [
232 for key in [
233 "fbx",
234 "skeletal_animation",
233 "skeletal_animation",
235 "attachment_editor",
234 "attachment_editor",
236 "paperdoll_render",
235 "paperdoll_render",
@@ -249,9 +248,9 @@ mod tests {
249 assert!(summary.contains("niet gebouwd"));
248 assert!(summary.contains("niet gebouwd"));
250 assert!(summary.contains("OBJ+MTL"));
249 assert!(summary.contains("OBJ+MTL"));
251 assert!(summary.contains("glTF/GLB"));
250 assert!(summary.contains("glTF/GLB"));
251 assert!(summary.contains("FBX"));
252 let lower = summary.to_lowercase();
252 let lower = summary.to_lowercase();
253 assert!(!lower.contains("render werkt"));
253 assert!(!lower.contains("render werkt"));
254 assert!(!lower.contains("fbx-import werkt"));
255 }
254 }
256
255
257 #[test]
256 #[test]
src/import.rs +271 −10
@@ -1,8 +1,8 @@
1 //! Read glTF/GLB characters and OBJ+MTL equipment into a CPU description.
1 //! Read glTF/GLB, OBJ+MTL and FBX into a CPU description.
2 2 //!
3 //! The description is for inspection and a later renderer. This module does
4 //! not draw a viewport and does not sample skeletal animation. FBX is refused:
5 //! Assimp is not linked in this build.
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.
6 6
7 7 use std::fs;
8 8 use std::path::Path;
@@ -11,7 +11,8 @@ use serde_json::Value;
11 11
12 12 use crate::project::portable_relative;
13 13
14 pub const FBX_UNAVAILABLE: &str = "FBX wordt in deze build niet geïmporteerd. Assimp is niet gekoppeld: de native bibliotheek ontbreekt en is niet op Linux en Windows bewezen. Gebruik glTF/GLB voor een lichaam met clips, of OBJ+MTL voor een statische equipment-mesh.";
14 /// FBX support is the vendored ufbx loader, not a system Assimp library.
15 pub const FBX_BACKEND: &str = "ufbx 0.11.5, meegecompileerd uit bron (geen losse libassimp)";
15 16
16 17 const MAX_BYTES: u64 = 32 * 1024 * 1024;
17 18
@@ -127,7 +128,7 @@ pub fn import_project_model(
127 128 match extension.as_str() {
128 129 "glb" | "gltf" => import_gltf(project_root, &full, &relative),
129 130 "obj" => import_obj(project_root, &full, &relative),
130 "fbx" => Err(ImportError::Unsupported(FBX_UNAVAILABLE.to_string())),
131 "fbx" => import_fbx(project_root, &full, &relative),
131 132 other => Err(ImportError::Unsupported(format!(
132 133 "bestandstype '{other}' wordt niet gelezen. Gebruik .glb, .gltf of .obj."
133 134 ))),
@@ -336,6 +337,218 @@ fn import_gltf(
336 337 })
337 338 }
338 339
340 fn import_fbx(
341 project_root: &Path,
342 full: &Path,
343 relative: &str,
344 ) -> Result<ImportedModel, ImportError> {
345 let path = full.to_str().ok_or_else(|| ImportError::Parse {
346 path: relative.to_string(),
347 detail: "het FBX-pad is geen UTF-8".to_string(),
348 })?;
349 let opts = ufbx::LoadOpts {
350 generate_missing_normals: true,
351 ..ufbx::LoadOpts::default()
352 };
353 let scene = ufbx::load_file(path, opts).map_err(|err| ImportError::Parse {
354 path: relative.to_string(),
355 detail: err.description.to_string(),
356 })?;
357 let mut meshes = Vec::new();
358 for mesh in &scene.meshes {
359 if mesh.num_vertices == 0 {
360 continue;
361 }
362 let material_name = mesh
363 .materials
364 .first()
365 .map(|material| material.element.name.trim().to_string())
366 .filter(|name| !name.is_empty());
367 meshes.push(MeshRecord {
368 name: fallback_name(mesh.element.name.trim(), "mesh"),
369 vertex_count: mesh.num_vertices,
370 triangle_count: mesh.num_triangles,
371 has_normals: mesh.vertex_normal.exists,
372 has_uvs: mesh.vertex_uv.exists,
373 has_skin: !mesh.skin_deformers.is_empty(),
374 material_name,
375 });
376 }
377 let mut joints = Vec::new();
378 for cluster in &scene.skin_clusters {
379 if let Some(bone) = cluster.bone_node.as_ref() {
380 record_fbx_chain(bone, &mut joints);
381 }
382 }
383 let mut materials = Vec::new();
384 let mut textures = Vec::new();
385 for material in &scene.materials {
386 let color_map = if material.pbr.base_color.has_value {
387 &material.pbr.base_color
388 } else {
389 &material.fbx.diffuse_color
390 };
391 let base_color_texture = fbx_texture(project_root, full, color_map.texture.as_ref())?;
392 let normal_texture =
393 fbx_texture(project_root, full, material.fbx.normal_map.texture.as_ref())?.or(
394 fbx_texture(project_root, full, material.pbr.normal_map.texture.as_ref())?,
395 );
396 if let Some(name) = &base_color_texture {
397 textures.push(texture_record(full, name));
398 }
399 materials.push(MaterialRecord {
400 name: fallback_name(material.element.name.trim(), "materiaal"),
401 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 base_color_texture,
412 normal_texture,
413 });
414 }
415 let mut clips = Vec::new();
416 for stack in &scene.anim_stacks {
417 let mut duration = (stack.time_end - stack.time_begin).max(0.0);
418 let mut channel_count = 0;
419 let mut targets = Vec::new();
420 for layer in &stack.layers {
421 for prop in &layer.anim_props {
422 let property = prop.prop_name.trim();
423 if property != "Lcl Translation"
424 && property != "Lcl Rotation"
425 && property != "Lcl Scaling"
426 {
427 continue;
428 }
429 channel_count += 1;
430 let target = prop.element.name.trim();
431 if !target.is_empty() && !targets.iter().any(|existing| existing == target) {
432 targets.push(target.to_string());
433 }
434 for curve in prop.anim_value.curves.iter().flatten() {
435 duration = duration.max(curve.max_time - curve.min_time);
436 }
437 }
438 }
439 if channel_count == 0 {
440 continue;
441 }
442 clips.push(ClipRecord {
443 name: fallback_name(stack.element.name.trim(), "clip"),
444 duration_seconds: duration as f32,
445 channel_count,
446 targets,
447 });
448 }
449 if meshes.is_empty() {
450 return Err(ImportError::Parse {
451 path: relative.to_string(),
452 detail: "het FBX-bestand bevat geen mesh".to_string(),
453 });
454 }
455 Ok(ImportedModel {
456 format: ModelFormat::Fbx,
457 meshes,
458 materials,
459 textures,
460 joints,
461 clips,
462 notices: vec![format!(
463 "FBX gelezen met {FBX_BACKEND}. Clips worden getoond, niet afgespeeld, en er is niet gerenderd."
464 )],
465 })
466 }
467
468 fn record_fbx_chain(start: &ufbx::Node, joints: &mut Vec<JointRecord>) {
469 let mut current = start;
470 loop {
471 if current.is_root {
472 break;
473 }
474 let name = current.element.name.trim();
475 if !name.is_empty() && joints.iter().all(|joint| joint.name != name) {
476 let parent = current.parent.as_ref().and_then(|parent| {
477 if parent.is_root {
478 return None;
479 }
480 let parent_name = parent.element.name.trim();
481 if parent_name.is_empty() {
482 None
483 } else {
484 Some(parent_name.to_string())
485 }
486 });
487 joints.push(JointRecord {
488 name: name.to_string(),
489 parent,
490 });
491 }
492 match current.parent.as_ref() {
493 Some(parent) if !parent.is_root => current = parent,
494 _ => break,
495 }
496 }
497 }
498
499 fn fbx_texture(
500 project_root: &Path,
501 fbx_file: &Path,
502 texture: Option<&ufbx::Ref<ufbx::Texture>>,
503 ) -> Result<Option<String>, ImportError> {
504 let Some(texture) = texture else {
505 return Ok(None);
506 };
507 if texture.content.size > 0 {
508 return Ok(Some(format!("ingebed:{}", texture.element.name.trim())));
509 }
510 let relative_name = texture.relative_filename.trim().replace('\\', "/");
511 let filename = if relative_name.is_empty() {
512 texture.filename.trim().replace('\\', "/")
513 } else {
514 relative_name
515 };
516 if filename.is_empty() {
517 return Ok(None);
518 }
519 reject_relative_escape(&filename)?;
520 let base = fbx_file.parent().unwrap_or(project_root);
521 let path = base.join(&filename);
522 if path.is_file() {
523 ensure_inside(project_root, &path, &filename)?;
524 } else if texture.has_file {
525 return Err(ImportError::Parse {
526 path: filename,
527 detail: "de FBX-texture ontbreekt".to_string(),
528 });
529 }
530 Ok(Some(filename))
531 }
532
533 fn texture_record(fbx_file: &Path, name: &str) -> TextureRecord {
534 let path = fbx_file.parent().unwrap_or(fbx_file).join(name);
535 let (width, height) = png_size(&path).unwrap_or((0, 0));
536 TextureRecord {
537 name: name.to_string(),
538 source: name.to_string(),
539 width,
540 height,
541 }
542 }
543
544 fn fallback_name(name: &str, fallback: &str) -> String {
545 if name.is_empty() {
546 fallback.to_string()
547 } else {
548 name.to_string()
549 }
550 }
551
339 552 fn import_obj(
340 553 project_root: &Path,
341 554 full: &Path,
@@ -728,17 +941,65 @@ mod tests {
728 941 }
729 942
730 943 #[test]
731 fn fbx_is_refused_without_pretending_to_parse() {
944 fn fbx_fixture_has_skin_clips_and_material() {
945 let model = import_project_model(&fixture_root(), "test-body-male.fbx").unwrap();
946 assert_eq!(model.format, ModelFormat::Fbx);
947 let mesh = model
948 .meshes
949 .iter()
950 .find(|mesh| mesh.vertex_count > 0 && mesh.triangle_count > 0 && mesh.has_skin)
951 .expect("skinned mesh");
952 assert!(
953 mesh.vertex_count >= 19 * 8,
954 "vertices {}",
955 mesh.vertex_count
956 );
957 assert!(model.joints.iter().any(|joint| joint.name == "Hips"));
958 assert!(model.joints.iter().any(|joint| {
959 joint.name == "LeftUpperArm" && joint.parent.as_deref() == Some("LeftShoulder")
960 }));
961 let walk = model
962 .clips
963 .iter()
964 .find(|clip| clip.name.to_lowercase().contains("walk"))
965 .expect("walk clip");
966 let attack = model
967 .clips
968 .iter()
969 .find(|clip| clip.name.to_lowercase().contains("attack"))
970 .expect("attack clip");
971 assert!(walk.duration_seconds > 0.5, "{}", walk.duration_seconds);
972 assert!(attack.duration_seconds > 0.2, "{}", attack.duration_seconds);
973 assert!(walk.channel_count > 0);
974 assert!(attack.channel_count > 0);
975 assert!(walk
976 .targets
977 .iter()
978 .any(|target| target.contains("LeftUpperLeg") || target.contains("Hips")));
979 assert!(!model.materials.is_empty());
980 assert!(model.materials.iter().any(|material| {
981 material
982 .base_color
983 .iter()
984 .all(|channel| channel.is_finite())
985 }));
986 assert!(model.notices.iter().any(|notice| notice.contains("ufbx")));
987 assert!(model
988 .notices
989 .iter()
990 .any(|notice| notice.contains("niet gerenderd")));
991 }
992
993 #[test]
994 fn bytes_named_fbx_are_not_accepted_without_parsing() {
732 995 let root = std::env::temp_dir().join(format!("paperdoll-fbx-{}", std::process::id()));
733 996 let _ = fs::remove_dir_all(&root);
734 997 fs::create_dir_all(&root).unwrap();
735 998 fs::write(root.join("hero.fbx"), b"not a real fbx").unwrap();
736 999 let error = import_project_model(&root, "hero.fbx").unwrap_err();
737 1000 let text = error.to_string();
738 assert!(text.contains("Assimp"));
739 assert!(text.contains("niet geïmporteerd"));
1001 assert!(text.contains("mislukt"), "{text}");
740 1002 assert!(!text.contains("walk"));
741 assert!(!text.contains("geïmporteerd: 0 clips"));
742 1003 let _ = fs::remove_dir_all(&root);
743 1004 }
744 1005
@@ -1,8 +1,8 @@
1//! Read glTF/GLB characters and OBJ+MTL equipment into a CPU description.
1//! Read glTF/GLB, OBJ+MTL and FBX into a CPU description.
2//!
2//!
3//! The description is for inspection and a later renderer. This module does
3//! FBX is parsed with ufbx, compiled into this binary from its MIT C source.
4//! not draw a viewport and does not sample skeletal animation. FBX is refused:
4//! There is no separate libassimp to load at runtime. This module does not
5//! Assimp is not linked in this build.
5//! draw a viewport and does not sample skeletal animation.
6
6
7use std::fs;
7use std::fs;
8use std::path::Path;
8use std::path::Path;
@@ -11,7 +11,8 @@ use serde_json::Value;
11
11
12use crate::project::portable_relative;
12use crate::project::portable_relative;
13
13
14pub const FBX_UNAVAILABLE: &str = "FBX wordt in deze build niet geïmporteerd. Assimp is niet gekoppeld: de native bibliotheek ontbreekt en is niet op Linux en Windows bewezen. Gebruik glTF/GLB voor een lichaam met clips, of OBJ+MTL voor een statische equipment-mesh.";
14/// FBX support is the vendored ufbx loader, not a system Assimp library.
15pub const FBX_BACKEND: &str = "ufbx 0.11.5, meegecompileerd uit bron (geen losse libassimp)";
15
16
16const MAX_BYTES: u64 = 32 * 1024 * 1024;
17const MAX_BYTES: u64 = 32 * 1024 * 1024;
17
18
@@ -127,7 +128,7 @@ pub fn import_project_model(
127 match extension.as_str() {
128 match extension.as_str() {
128 "glb" | "gltf" => import_gltf(project_root, &full, &relative),
129 "glb" | "gltf" => import_gltf(project_root, &full, &relative),
129 "obj" => import_obj(project_root, &full, &relative),
130 "obj" => import_obj(project_root, &full, &relative),
130 "fbx" => Err(ImportError::Unsupported(FBX_UNAVAILABLE.to_string())),
131 "fbx" => import_fbx(project_root, &full, &relative),
131 other => Err(ImportError::Unsupported(format!(
132 other => Err(ImportError::Unsupported(format!(
132 "bestandstype '{other}' wordt niet gelezen. Gebruik .glb, .gltf of .obj."
133 "bestandstype '{other}' wordt niet gelezen. Gebruik .glb, .gltf of .obj."
133 ))),
134 ))),
@@ -336,6 +337,218 @@ fn import_gltf(
336 })
337 })
337}
338}
338
339
340fn import_fbx(
341 project_root: &Path,
342 full: &Path,
343 relative: &str,
344) -> Result<ImportedModel, ImportError> {
345 let path = full.to_str().ok_or_else(|| ImportError::Parse {
346 path: relative.to_string(),
347 detail: "het FBX-pad is geen UTF-8".to_string(),
348 })?;
349 let opts = ufbx::LoadOpts {
350 generate_missing_normals: true,
351 ..ufbx::LoadOpts::default()
352 };
353 let scene = ufbx::load_file(path, opts).map_err(|err| ImportError::Parse {
354 path: relative.to_string(),
355 detail: err.description.to_string(),
356 })?;
357 let mut meshes = Vec::new();
358 for mesh in &scene.meshes {
359 if mesh.num_vertices == 0 {
360 continue;
361 }
362 let material_name = mesh
363 .materials
364 .first()
365 .map(|material| material.element.name.trim().to_string())
366 .filter(|name| !name.is_empty());
367 meshes.push(MeshRecord {
368 name: fallback_name(mesh.element.name.trim(), "mesh"),
369 vertex_count: mesh.num_vertices,
370 triangle_count: mesh.num_triangles,
371 has_normals: mesh.vertex_normal.exists,
372 has_uvs: mesh.vertex_uv.exists,
373 has_skin: !mesh.skin_deformers.is_empty(),
374 material_name,
375 });
376 }
377 let mut joints = Vec::new();
378 for cluster in &scene.skin_clusters {
379 if let Some(bone) = cluster.bone_node.as_ref() {
380 record_fbx_chain(bone, &mut joints);
381 }
382 }
383 let mut materials = Vec::new();
384 let mut textures = Vec::new();
385 for material in &scene.materials {
386 let color_map = if material.pbr.base_color.has_value {
387 &material.pbr.base_color
388 } else {
389 &material.fbx.diffuse_color
390 };
391 let base_color_texture = fbx_texture(project_root, full, color_map.texture.as_ref())?;
392 let normal_texture =
393 fbx_texture(project_root, full, material.fbx.normal_map.texture.as_ref())?.or(
394 fbx_texture(project_root, full, material.pbr.normal_map.texture.as_ref())?,
395 );
396 if let Some(name) = &base_color_texture {
397 textures.push(texture_record(full, name));
398 }
399 materials.push(MaterialRecord {
400 name: fallback_name(material.element.name.trim(), "materiaal"),
401 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 base_color_texture,
412 normal_texture,
413 });
414 }
415 let mut clips = Vec::new();
416 for stack in &scene.anim_stacks {
417 let mut duration = (stack.time_end - stack.time_begin).max(0.0);
418 let mut channel_count = 0;
419 let mut targets = Vec::new();
420 for layer in &stack.layers {
421 for prop in &layer.anim_props {
422 let property = prop.prop_name.trim();
423 if property != "Lcl Translation"
424 && property != "Lcl Rotation"
425 && property != "Lcl Scaling"
426 {
427 continue;
428 }
429 channel_count += 1;
430 let target = prop.element.name.trim();
431 if !target.is_empty() && !targets.iter().any(|existing| existing == target) {
432 targets.push(target.to_string());
433 }
434 for curve in prop.anim_value.curves.iter().flatten() {
435 duration = duration.max(curve.max_time - curve.min_time);
436 }
437 }
438 }
439 if channel_count == 0 {
440 continue;
441 }
442 clips.push(ClipRecord {
443 name: fallback_name(stack.element.name.trim(), "clip"),
444 duration_seconds: duration as f32,
445 channel_count,
446 targets,
447 });
448 }
449 if meshes.is_empty() {
450 return Err(ImportError::Parse {
451 path: relative.to_string(),
452 detail: "het FBX-bestand bevat geen mesh".to_string(),
453 });
454 }
455 Ok(ImportedModel {
456 format: ModelFormat::Fbx,
457 meshes,
458 materials,
459 textures,
460 joints,
461 clips,
462 notices: vec![format!(
463 "FBX gelezen met {FBX_BACKEND}. Clips worden getoond, niet afgespeeld, en er is niet gerenderd."
464 )],
465 })
466}
467
468fn record_fbx_chain(start: &ufbx::Node, joints: &mut Vec<JointRecord>) {
469 let mut current = start;
470 loop {
471 if current.is_root {
472 break;
473 }
474 let name = current.element.name.trim();
475 if !name.is_empty() && joints.iter().all(|joint| joint.name != name) {
476 let parent = current.parent.as_ref().and_then(|parent| {
477 if parent.is_root {
478 return None;
479 }
480 let parent_name = parent.element.name.trim();
481 if parent_name.is_empty() {
482 None
483 } else {
484 Some(parent_name.to_string())
485 }
486 });
487 joints.push(JointRecord {
488 name: name.to_string(),
489 parent,
490 });
491 }
492 match current.parent.as_ref() {
493 Some(parent) if !parent.is_root => current = parent,
494 _ => break,
495 }
496 }
497}
498
499fn fbx_texture(
500 project_root: &Path,
501 fbx_file: &Path,
502 texture: Option<&ufbx::Ref<ufbx::Texture>>,
503) -> Result<Option<String>, ImportError> {
504 let Some(texture) = texture else {
505 return Ok(None);
506 };
507 if texture.content.size > 0 {
508 return Ok(Some(format!("ingebed:{}", texture.element.name.trim())));
509 }
510 let relative_name = texture.relative_filename.trim().replace('\\', "/");
511 let filename = if relative_name.is_empty() {
512 texture.filename.trim().replace('\\', "/")
513 } else {
514 relative_name
515 };
516 if filename.is_empty() {
517 return Ok(None);
518 }
519 reject_relative_escape(&filename)?;
520 let base = fbx_file.parent().unwrap_or(project_root);
521 let path = base.join(&filename);
522 if path.is_file() {
523 ensure_inside(project_root, &path, &filename)?;
524 } else if texture.has_file {
525 return Err(ImportError::Parse {
526 path: filename,
527 detail: "de FBX-texture ontbreekt".to_string(),
528 });
529 }
530 Ok(Some(filename))
531}
532
533fn texture_record(fbx_file: &Path, name: &str) -> TextureRecord {
534 let path = fbx_file.parent().unwrap_or(fbx_file).join(name);
535 let (width, height) = png_size(&path).unwrap_or((0, 0));
536 TextureRecord {
537 name: name.to_string(),
538 source: name.to_string(),
539 width,
540 height,
541 }
542}
543
544fn fallback_name(name: &str, fallback: &str) -> String {
545 if name.is_empty() {
546 fallback.to_string()
547 } else {
548 name.to_string()
549 }
550}
551
339fn import_obj(
552fn import_obj(
340 project_root: &Path,
553 project_root: &Path,
341 full: &Path,
554 full: &Path,
@@ -728,17 +941,65 @@ mod tests {
728 }
941 }
729
942
730 #[test]
943 #[test]
731 fn fbx_is_refused_without_pretending_to_parse() {
944 fn fbx_fixture_has_skin_clips_and_material() {
945 let model = import_project_model(&fixture_root(), "test-body-male.fbx").unwrap();
946 assert_eq!(model.format, ModelFormat::Fbx);
947 let mesh = model
948 .meshes
949 .iter()
950 .find(|mesh| mesh.vertex_count > 0 && mesh.triangle_count > 0 && mesh.has_skin)
951 .expect("skinned mesh");
952 assert!(
953 mesh.vertex_count >= 19 * 8,
954 "vertices {}",
955 mesh.vertex_count
956 );
957 assert!(model.joints.iter().any(|joint| joint.name == "Hips"));
958 assert!(model.joints.iter().any(|joint| {
959 joint.name == "LeftUpperArm" && joint.parent.as_deref() == Some("LeftShoulder")
960 }));
961 let walk = model
962 .clips
963 .iter()
964 .find(|clip| clip.name.to_lowercase().contains("walk"))
965 .expect("walk clip");
966 let attack = model
967 .clips
968 .iter()
969 .find(|clip| clip.name.to_lowercase().contains("attack"))
970 .expect("attack clip");
971 assert!(walk.duration_seconds > 0.5, "{}", walk.duration_seconds);
972 assert!(attack.duration_seconds > 0.2, "{}", attack.duration_seconds);
973 assert!(walk.channel_count > 0);
974 assert!(attack.channel_count > 0);
975 assert!(walk
976 .targets
977 .iter()
978 .any(|target| target.contains("LeftUpperLeg") || target.contains("Hips")));
979 assert!(!model.materials.is_empty());
980 assert!(model.materials.iter().any(|material| {
981 material
982 .base_color
983 .iter()
984 .all(|channel| channel.is_finite())
985 }));
986 assert!(model.notices.iter().any(|notice| notice.contains("ufbx")));
987 assert!(model
988 .notices
989 .iter()
990 .any(|notice| notice.contains("niet gerenderd")));
991 }
992
993 #[test]
994 fn bytes_named_fbx_are_not_accepted_without_parsing() {
732 let root = std::env::temp_dir().join(format!("paperdoll-fbx-{}", std::process::id()));
995 let root = std::env::temp_dir().join(format!("paperdoll-fbx-{}", std::process::id()));
733 let _ = fs::remove_dir_all(&root);
996 let _ = fs::remove_dir_all(&root);
734 fs::create_dir_all(&root).unwrap();
997 fs::create_dir_all(&root).unwrap();
735 fs::write(root.join("hero.fbx"), b"not a real fbx").unwrap();
998 fs::write(root.join("hero.fbx"), b"not a real fbx").unwrap();
736 let error = import_project_model(&root, "hero.fbx").unwrap_err();
999 let error = import_project_model(&root, "hero.fbx").unwrap_err();
737 let text = error.to_string();
1000 let text = error.to_string();
738 assert!(text.contains("Assimp"));
1001 assert!(text.contains("mislukt"), "{text}");
739 assert!(text.contains("niet geïmporteerd"));
740 assert!(!text.contains("walk"));
1002 assert!(!text.contains("walk"));
741 assert!(!text.contains("geïmporteerd: 0 clips"));
742 let _ = fs::remove_dir_all(&root);
1003 let _ = fs::remove_dir_all(&root);
743 }
1004 }
744
1005
src/project.rs +1 −0
@@ -800,6 +800,7 @@ mod tests {
800 800 assert_eq!(parsed.male_model, None);
801 801 }
802 802
803 #[cfg(unix)]
803 804 #[test]
804 805 fn symlink_outside_the_project_is_rejected_and_listing_stays_inside() {
805 806 let root = temp_root("link");
@@ -800,6 +800,7 @@ mod tests {
800 assert_eq!(parsed.male_model, None);
800 assert_eq!(parsed.male_model, None);
801 }
801 }
802
802
803 #[cfg(unix)]
803 #[test]
804 #[test]
804 fn symlink_outside_the_project_is_rejected_and_listing_stays_inside() {
805 fn symlink_outside_the_project_is_rejected_and_listing_stays_inside() {
805 let root = temp_root("link");
806 let root = temp_root("link");
tests/fixtures/bodies/README.md +2 −0
@@ -21,3 +21,5 @@ python3 scripts/generate_test_bodies.py
21 21 ```
22 22
23 23 Licentie van de gegenereerde meshes, rigs en clips: CC0-1.0. Zie `LICENSE` in deze map.
24
25 `test-body-male.fbx` is dezelfde man, omgezet met Blender (`scripts/convert_test_body_to_fbx.py`). Blender hoort niet bij de applicatie; het bestand ligt in de repository zodat de FBX-import zonder Blender getest wordt. De clipnamen krijgen daar het suffix van de skin (`walk_test-body-male-skin`, `attack_test-body-male-skin`).
@@ -21,3 +21,5 @@ python3 scripts/generate_test_bodies.py
21```
21```
22
22
23Licentie van de gegenereerde meshes, rigs en clips: CC0-1.0. Zie `LICENSE` in deze map.
23Licentie van de gegenereerde meshes, rigs en clips: CC0-1.0. Zie `LICENSE` in deze map.
24
25`test-body-male.fbx` is dezelfde man, omgezet met Blender (`scripts/convert_test_body_to_fbx.py`). Blender hoort niet bij de applicatie; het bestand ligt in de repository zodat de FBX-import zonder Blender getest wordt. De clipnamen krijgen daar het suffix van de skin (`walk_test-body-male-skin`, `attack_test-body-male-skin`).
tests/fixtures/bodies/test-body-male.fbx added Binary file +0 −0

The contents of this binary file are not shown.

tests/release_publish.rs +2 −0
@@ -2,6 +2,8 @@
2 2 //! replace. BlueGit has no attach-to-existing-tag route; the script refuses
3 3 //! the create-release call instead of issuing it.
4 4
5 #![cfg(unix)]
6
5 7 use std::fs;
6 8 use std::os::unix::fs::PermissionsExt;
7 9 use std::path::PathBuf;
@@ -2,6 +2,8 @@
2//! replace. BlueGit has no attach-to-existing-tag route; the script refuses
2//! replace. BlueGit has no attach-to-existing-tag route; the script refuses
3//! the create-release call instead of issuing it.
3//! the create-release call instead of issuing it.
4
4
5#![cfg(unix)]
6
5use std::fs;
7use std::fs;
6use std::os::unix::fs::PermissionsExt;
8use std::os::unix::fs::PermissionsExt;
7use std::path::PathBuf;
9use std::path::PathBuf;