merge: FBX import via vendored ufbx
Non-fast-forward merge of feature/fbx-import into main. Co-authored-by: tim <tim@timfalken.com>
@@ -1,2 +1,3 @@
1
1
*.glb binary
2
*.fbx binary
2
3
*.png binary
@@ -1,2 +1,3 @@1
*.glb binary1
*.glb binary2
*.fbx binary2
*.png binary3
*.png binary@@ -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]]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
[[package]]2490
[[package]]2481
name = "unicode-ident"2491
name = "unicode-ident"2482
version = "1.0.27"2492
version = "1.0.27"@@ -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"13
serde = { version = "1", features = ["derive"] }13
serde = { version = "1", features = ["derive"] }14
serde_json = "1"14
serde_json = "1"15
tobj = "4.0.3"15
tobj = "4.0.3"16
ufbx = "0.11.5"@@ -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 Renderer1
# Paperdoll Renderer2
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
## Eisen5
## Eisen6
6
@@ -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 -qq47
- apt-get update -qq48
- apt-get install -y -qq pkg-config libxcb1-dev libx11-dev libxkbcommon-dev libxkbcommon-x11-dev libgl1-mesa-dev python348
- apt-get install -y -qq pkg-config gcc libxcb1-dev libx11-dev libxkbcommon-dev libxkbcommon-x11-dev libgl1-mesa-dev python349
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 -qq84
- apt-get update -qq85
- apt-get install -y -qq pkg-config libxcb1-dev libx11-dev libxkbcommon-dev libxkbcommon-x11-dev libgl1-mesa-dev file85
- apt-get install -y -qq pkg-config gcc libxcb1-dev libx11-dev libxkbcommon-dev libxkbcommon-x11-dev libgl1-mesa-dev file86
script:86
script:87
- bash scripts/package/appimage.sh87
- bash scripts/package/appimage.sh88
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 -qq103
- apt-get update -qq104
- apt-get install -y -qq pkg-config libxcb1-dev libx11-dev libxkbcommon-dev libxkbcommon-x11-dev libgl1-mesa-dev dpkg-dev104
- apt-get install -y -qq pkg-config gcc libxcb1-dev libx11-dev libxkbcommon-dev libxkbcommon-x11-dev libgl1-mesa-dev dpkg-dev105
script:105
script:106
- bash scripts/package/deb.sh106
- bash scripts/package/deb.sh107
artifacts:107
artifacts:@@ -117,7 +117,7 @@ package:arch:117
interruptible: true117
interruptible: true118
script:118
script:119
- pacman -Syu --noconfirm119
- pacman -Syu --noconfirm120
- pacman -S --noconfirm --needed rust pkgconf libxkbcommon libxcb libx11 libglvnd git120
- pacman -S --noconfirm --needed base-devel rust pkgconf libxkbcommon libxcb libx11 libglvnd git121
- bash scripts/package/arch.sh121
- bash scripts/package/arch.sh122
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 -qq138
- apt-get update -qq139
- apt-get install -y -qq gcc-mingw-w64-x86-64 binutils-mingw-w64-x86-64 zip139
- apt-get install -y -qq gcc-mingw-w64-x86-64 binutils-mingw-w64-x86-64 zip140
- rustup target add x86_64-pc-windows-gnu140
- rustup target add x86_64-pc-windows-gnu141
- export CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc142
- export AR_x86_64_pc_windows_gnu=x86_64-w64-mingw32-ar141
script:143
script:142
- bash scripts/package/windows.sh144
- bash scripts/package/windows.sh143
artifacts:145
artifacts:@@ -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 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 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
| 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 |@@ -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 python32
"""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 output5
of this script. Re-run it when the generator changes:6
7
blender --background --python scripts/convert_test_body_to_fbx.py8
9
Blender is only a fixture tool. The application does not call Blender.10
"""11
12
import sys13
from pathlib import Path14
15
import bpy16
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()@@ -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| {@@ -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
56
pub fn availability(capability: Capability) -> Availability {56
pub fn availability(capability: Capability) -> Availability {57
match capability {57
match capability {58
Capability::ProjectShell | Capability::ObjMtlTextures | Capability::GltfGlb => {58
Capability::ProjectShell59
Availability::Present59
| Capability::ObjMtlTextures60
}60
| Capability::GltfGlb61
Capability::Fbx61
| Capability::Fbx => Availability::Present,62
| Capability::SkeletalAnimation62
Capability::SkeletalAnimation63
| Capability::AttachmentEditor63
| Capability::AttachmentEditor64
| Capability::PaperdollRender64
| Capability::PaperdollRender65
| 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
109
pub fn dutch_summary() -> &'static str {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
/// Stable status text shared by `--status` and the window.113
/// Stable status text shared by `--status` and the window.114
pub fn report() -> String {114
pub 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 sluit165
paperdoll-renderer --status print de status en sluit166
paperdoll-renderer --help toon deze tekst166
paperdoll-renderer --help toon deze tekst167
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
pub fn parse_cli<'a, I>(args: I) -> Result<CliRequest, CliError>171
pub 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::Present205
Availability::Present206
);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]@@ -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 does3
//! 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 not5
//! Assimp is not linked in this build.5
//! draw a viewport and does not sample skeletal animation.6
6
7
use std::fs;7
use std::fs;8
use std::path::Path;8
use std::path::Path;@@ -11,7 +11,8 @@ use serde_json::Value;11
11
12
use crate::project::portable_relative;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
const MAX_BYTES: u64 = 32 * 1024 * 1024;17
const 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
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 = mesh363
.materials364
.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_color388
} else {389
&material.fbx.diffuse_color390
};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.0407
} else {408
color_map.value_vec4.w as f32409
},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
None483
} 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_name515
};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
fn import_obj(552
fn 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 = model948
.meshes949
.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_count956
);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 = model962
.clips963
.iter()964
.find(|clip| clip.name.to_lowercase().contains("walk"))965
.expect("walk clip");966
let attack = model967
.clips968
.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!(walk976
.targets977
.iter()978
.any(|target| target.contains("LeftUpperLeg") || target.contains("Hips")));979
assert!(!model.materials.is_empty());980
assert!(model.materials.iter().any(|material| {981
material982
.base_color983
.iter()984
.all(|channel| channel.is_finite())985
}));986
assert!(model.notices.iter().any(|notice| notice.contains("ufbx")));987
assert!(model988
.notices989
.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
@@ -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");@@ -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.py21
```21
```22
22
23
Licentie van de gegenereerde meshes, rigs en clips: CC0-1.0. Zie `LICENSE` in deze map.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`).The contents of this binary file are not shown.
@@ -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 refuses2
//! replace. BlueGit has no attach-to-existing-tag route; the script refuses3
//! the create-release call instead of issuing it.3
//! the create-release call instead of issuing it.4
4
5
#![cfg(unix)]6
5
use std::fs;7
use std::fs;6
use std::os::unix::fs::PermissionsExt;8
use std::os::unix::fs::PermissionsExt;7
use std::path::PathBuf;9
use std::path::PathBuf;