BlueGit

golden-age paperdoll-renderer

feat: import glTF clips and static OBJ meshes

Read skeleton, materials, textures and every animation clip from glTF/GLB,
and OBJ+MTL as equipment without clips. FBX is recognized and refused
because Assimp is not linked. The project window lists the inventory
and still does not render.

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

timfa Cursor Agent · pushed by timfa

Authored 2026-11-06 · 7 days ago

Hash
28660e170610ce1559b377f838e24f763b6633d5
Parent
9fb57549
8 files changed +1069 −40
Cargo.lock +97 −0
@@ -121,6 +121,12 @@ version = "1.5.1"
121 121 source = "registry+https://github.com/rust-lang/crates.io-index"
122 122 checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
123 123
124 [[package]]
125 name = "base64"
126 version = "0.13.1"
127 source = "registry+https://github.com/rust-lang/crates.io-index"
128 checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
129
124 130 [[package]]
125 131 name = "bit-set"
126 132 version = "0.6.0"
@@ -189,6 +195,12 @@ dependencies = [
189 195 "syn 3.0.9",
190 196 ]
191 197
198 [[package]]
199 name = "byteorder"
200 version = "1.5.0"
201 source = "registry+https://github.com/rust-lang/crates.io-index"
202 checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
203
192 204 [[package]]
193 205 name = "byteorder-lite"
194 206 version = "0.1.0"
@@ -802,6 +814,45 @@ dependencies = [
802 814 "web-sys",
803 815 ]
804 816
817 [[package]]
818 name = "gltf"
819 version = "1.4.1"
820 source = "registry+https://github.com/rust-lang/crates.io-index"
821 checksum = "e3ce1918195723ce6ac74e80542c5a96a40c2b26162c1957a5cd70799b8cacf7"
822 dependencies = [
823 "base64",
824 "byteorder",
825 "gltf-json",
826 "image",
827 "lazy_static",
828 "serde_json",
829 "urlencoding",
830 ]
831
832 [[package]]
833 name = "gltf-derive"
834 version = "1.4.1"
835 source = "registry+https://github.com/rust-lang/crates.io-index"
836 checksum = "14070e711538afba5d6c807edb74bcb84e5dbb9211a3bf5dea0dfab5b24f4c51"
837 dependencies = [
838 "inflections",
839 "proc-macro2",
840 "quote",
841 "syn 2.0.119",
842 ]
843
844 [[package]]
845 name = "gltf-json"
846 version = "1.4.1"
847 source = "registry+https://github.com/rust-lang/crates.io-index"
848 checksum = "e6176f9d60a7eab0a877e8e96548605dedbde9190a7ae1e80bbcc1c9af03ab14"
849 dependencies = [
850 "gltf-derive",
851 "serde",
852 "serde_derive",
853 "serde_json",
854 ]
855
805 856 [[package]]
806 857 name = "glutin"
807 858 version = "0.32.3"
@@ -1077,6 +1128,8 @@ dependencies = [
1077 1128 "moxcms",
1078 1129 "num-traits",
1079 1130 "png",
1131 "zune-core",
1132 "zune-jpeg",
1080 1133 ]
1081 1134
1082 1135 [[package]]
@@ -1089,6 +1142,12 @@ dependencies = [
1089 1142 "hashbrown 0.17.1",
1090 1143 ]
1091 1144
1145 [[package]]
1146 name = "inflections"
1147 version = "1.1.1"
1148 source = "registry+https://github.com/rust-lang/crates.io-index"
1149 checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a"
1150
1092 1151 [[package]]
1093 1152 name = "itoa"
1094 1153 version = "1.0.18"
@@ -1191,6 +1250,12 @@ version = "3.1.0"
1191 1250 source = "registry+https://github.com/rust-lang/crates.io-index"
1192 1251 checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
1193 1252
1253 [[package]]
1254 name = "lazy_static"
1255 version = "1.5.1"
1256 source = "registry+https://github.com/rust-lang/crates.io-index"
1257 checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
1258
1194 1259 [[package]]
1195 1260 name = "libc"
1196 1261 version = "0.2.191"
@@ -1743,9 +1808,11 @@ name = "paperdoll-renderer"
1743 1808 version = "0.1.0"
1744 1809 dependencies = [
1745 1810 "eframe",
1811 "gltf",
1746 1812 "libloading",
1747 1813 "serde",
1748 1814 "serde_json",
1815 "tobj",
1749 1816 ]
1750 1817
1751 1818 [[package]]
@@ -2330,6 +2397,15 @@ dependencies = [
2330 2397 "zerovec",
2331 2398 ]
2332 2399
2400 [[package]]
2401 name = "tobj"
2402 version = "4.0.5"
2403 source = "registry+https://github.com/rust-lang/crates.io-index"
2404 checksum = "6eb8e04167c1c0c76b5de63226fd733485ad63ef71e40de31e16272f47b099e2"
2405 dependencies = [
2406 "ahash",
2407 ]
2408
2333 2409 [[package]]
2334 2410 name = "tokio"
2335 2411 version = "1.54.1"
@@ -2437,6 +2513,12 @@ dependencies = [
2437 2513 "serde",
2438 2514 ]
2439 2515
2516 [[package]]
2517 name = "urlencoding"
2518 version = "2.1.3"
2519 source = "registry+https://github.com/rust-lang/crates.io-index"
2520 checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
2521
2440 2522 [[package]]
2441 2523 name = "utf8_iter"
2442 2524 version = "1.1.0"
@@ -3200,3 +3282,18 @@ name = "zmij"
3200 3282 version = "1.0.23"
3201 3283 source = "registry+https://github.com/rust-lang/crates.io-index"
3202 3284 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
3285
3286 [[package]]
3287 name = "zune-core"
3288 version = "0.5.3"
3289 source = "registry+https://github.com/rust-lang/crates.io-index"
3290 checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
3291
3292 [[package]]
3293 name = "zune-jpeg"
3294 version = "0.5.15"
3295 source = "registry+https://github.com/rust-lang/crates.io-index"
3296 checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
3297 dependencies = [
3298 "zune-core",
3299 ]
@@ -121,6 +121,12 @@ version = "1.5.1"
121source = "registry+https://github.com/rust-lang/crates.io-index"
121source = "registry+https://github.com/rust-lang/crates.io-index"
122checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
122checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
123
123
124[[package]]
125name = "base64"
126version = "0.13.1"
127source = "registry+https://github.com/rust-lang/crates.io-index"
128checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
129
124[[package]]
130[[package]]
125name = "bit-set"
131name = "bit-set"
126version = "0.6.0"
132version = "0.6.0"
@@ -189,6 +195,12 @@ dependencies = [
189 "syn 3.0.9",
195 "syn 3.0.9",
190]
196]
191
197
198[[package]]
199name = "byteorder"
200version = "1.5.0"
201source = "registry+https://github.com/rust-lang/crates.io-index"
202checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
203
192[[package]]
204[[package]]
193name = "byteorder-lite"
205name = "byteorder-lite"
194version = "0.1.0"
206version = "0.1.0"
@@ -802,6 +814,45 @@ dependencies = [
802 "web-sys",
814 "web-sys",
803]
815]
804
816
817[[package]]
818name = "gltf"
819version = "1.4.1"
820source = "registry+https://github.com/rust-lang/crates.io-index"
821checksum = "e3ce1918195723ce6ac74e80542c5a96a40c2b26162c1957a5cd70799b8cacf7"
822dependencies = [
823 "base64",
824 "byteorder",
825 "gltf-json",
826 "image",
827 "lazy_static",
828 "serde_json",
829 "urlencoding",
830]
831
832[[package]]
833name = "gltf-derive"
834version = "1.4.1"
835source = "registry+https://github.com/rust-lang/crates.io-index"
836checksum = "14070e711538afba5d6c807edb74bcb84e5dbb9211a3bf5dea0dfab5b24f4c51"
837dependencies = [
838 "inflections",
839 "proc-macro2",
840 "quote",
841 "syn 2.0.119",
842]
843
844[[package]]
845name = "gltf-json"
846version = "1.4.1"
847source = "registry+https://github.com/rust-lang/crates.io-index"
848checksum = "e6176f9d60a7eab0a877e8e96548605dedbde9190a7ae1e80bbcc1c9af03ab14"
849dependencies = [
850 "gltf-derive",
851 "serde",
852 "serde_derive",
853 "serde_json",
854]
855
805[[package]]
856[[package]]
806name = "glutin"
857name = "glutin"
807version = "0.32.3"
858version = "0.32.3"
@@ -1077,6 +1128,8 @@ dependencies = [
1077 "moxcms",
1128 "moxcms",
1078 "num-traits",
1129 "num-traits",
1079 "png",
1130 "png",
1131 "zune-core",
1132 "zune-jpeg",
1080]
1133]
1081
1134
1082[[package]]
1135[[package]]
@@ -1089,6 +1142,12 @@ dependencies = [
1089 "hashbrown 0.17.1",
1142 "hashbrown 0.17.1",
1090]
1143]
1091
1144
1145[[package]]
1146name = "inflections"
1147version = "1.1.1"
1148source = "registry+https://github.com/rust-lang/crates.io-index"
1149checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a"
1150
1092[[package]]
1151[[package]]
1093name = "itoa"
1152name = "itoa"
1094version = "1.0.18"
1153version = "1.0.18"
@@ -1191,6 +1250,12 @@ version = "3.1.0"
1191source = "registry+https://github.com/rust-lang/crates.io-index"
1250source = "registry+https://github.com/rust-lang/crates.io-index"
1192checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
1251checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
1193
1252
1253[[package]]
1254name = "lazy_static"
1255version = "1.5.1"
1256source = "registry+https://github.com/rust-lang/crates.io-index"
1257checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
1258
1194[[package]]
1259[[package]]
1195name = "libc"
1260name = "libc"
1196version = "0.2.191"
1261version = "0.2.191"
@@ -1743,9 +1808,11 @@ name = "paperdoll-renderer"
1743version = "0.1.0"
1808version = "0.1.0"
1744dependencies = [
1809dependencies = [
1745 "eframe",
1810 "eframe",
1811 "gltf",
1746 "libloading",
1812 "libloading",
1747 "serde",
1813 "serde",
1748 "serde_json",
1814 "serde_json",
1815 "tobj",
1749]
1816]
1750
1817
1751[[package]]
1818[[package]]
@@ -2330,6 +2397,15 @@ dependencies = [
2330 "zerovec",
2397 "zerovec",
2331]
2398]
2332
2399
2400[[package]]
2401name = "tobj"
2402version = "4.0.5"
2403source = "registry+https://github.com/rust-lang/crates.io-index"
2404checksum = "6eb8e04167c1c0c76b5de63226fd733485ad63ef71e40de31e16272f47b099e2"
2405dependencies = [
2406 "ahash",
2407]
2408
2333[[package]]
2409[[package]]
2334name = "tokio"
2410name = "tokio"
2335version = "1.54.1"
2411version = "1.54.1"
@@ -2437,6 +2513,12 @@ dependencies = [
2437 "serde",
2513 "serde",
2438]
2514]
2439
2515
2516[[package]]
2517name = "urlencoding"
2518version = "2.1.3"
2519source = "registry+https://github.com/rust-lang/crates.io-index"
2520checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
2521
2440[[package]]
2522[[package]]
2441name = "utf8_iter"
2523name = "utf8_iter"
2442version = "1.1.0"
2524version = "1.1.0"
@@ -3200,3 +3282,18 @@ name = "zmij"
3200version = "1.0.23"
3282version = "1.0.23"
3201source = "registry+https://github.com/rust-lang/crates.io-index"
3283source = "registry+https://github.com/rust-lang/crates.io-index"
3202checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
3284checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
3285
3286[[package]]
3287name = "zune-core"
3288version = "0.5.3"
3289source = "registry+https://github.com/rust-lang/crates.io-index"
3290checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
3291
3292[[package]]
3293name = "zune-jpeg"
3294version = "0.5.15"
3295source = "registry+https://github.com/rust-lang/crates.io-index"
3296checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
3297dependencies = [
3298 "zune-core",
3299]
Cargo.toml +2 −0
@@ -8,6 +8,8 @@ description = "Foundation shell for the Paperdoll Renderer. Import and rendering
8 8
9 9 [dependencies]
10 10 eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }
11 gltf = { version = "1.4.1", default-features = false, features = ["import", "names", "utils"] }
11 12 libloading = "0.8"
12 13 serde = { version = "1", features = ["derive"] }
13 14 serde_json = "1"
15 tobj = "4.0.3"
@@ -8,6 +8,8 @@ description = "Foundation shell for the Paperdoll Renderer. Import and rendering
8
8
9[dependencies]
9[dependencies]
10eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }
10eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }
11gltf = { version = "1.4.1", default-features = false, features = ["import", "names", "utils"] }
11libloading = "0.8"
12libloading = "0.8"
12serde = { version = "1", features = ["derive"] }
13serde = { version = "1", features = ["derive"] }
13serde_json = "1"
14serde_json = "1"
15tobj = "4.0.3"
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 maakt en bewaart een projectmap met één `character.ppd`: paden voor een mannelijk en vrouwelijk model, paden naar equipment-presets, en een uitvoermap, allemaal relatief aan die projectmap. Het importeert die modellen niet en het rendert geen paperdolls. 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 (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).
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 maakt en bewaart een projectmap met één `character.ppd`: paden voor een mannelijk en vrouwelijk model, paden naar equipment-presets, en een uitvoermap, allemaal relatief aan die projectmap. Het importeert die modellen niet en het rendert geen paperdolls. 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 (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).
4
4
5## Eisen
5## Eisen
6
6
design.md +11 −3
@@ -61,11 +61,19 @@ Do not create empty crates solely to imply completion. Establish boundaries as f
61 61
62 62 **Recommendation:** glTF 2.0/GLB is the preferred canonical interchange for animated characters. GLB is convenient for distributing a self-contained body and avoids accidental texture-path breakage. Preserve original sources and conversion/import provenance. OBJ and FBX are minimum product goals, not necessarily native Bevy capabilities. A Bevy glTF loader must not be advertised as an FBX or OBJ loader.
63 63
64 ### Import implementation status
65
66 The desktop shell stays on eframe/egui. Bevy is not linked. Pulling Bevy in only to parse files would replace the window stack already used for project editing, and it is not required to read glTF or OBJ. Import uses the `gltf` 1.4 crate and `tobj` 4. Both are pure Rust. A later viewport can consume the same CPU records (`MeshRecord`, `JointRecord`, `ClipRecord`) without treating this importer as a renderer.
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.
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.
71
64 72 | Format | Mesh | Materials/textures | Skeleton | Animation | Intended role / status |
65 73 |---|---|---|---|---|---|
66 | glTF 2.0 .gltf/.glb | Yes | PBR metallic/roughness and referenced/embedded textures | Skins/joints | Node/skeletal clips; morph support must be tested | Preferred character and equipment path; native Bevy glTF adapter planned |
67 | OBJ + MTL + texture files | Yes | MTL subset with explicit conversion and relative textures | No | No skeletal clips | Required static equipment/import preview; cannot by itself satisfy animated body requirement |
68 | FBX ASCII/binary | Yes | Varies; embedded/external textures and legacy material conversion | Commonly | Commonly multiple clips/takes | Required adapter/conversion path with compatibility fixtures; never claim universal FBX fidelity |
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. |
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. |
69 77 | Collada .dae | Usually | Adapter-dependent | Possible | Possible | Optional Assimp-backed candidate, not guaranteed until fixtures pass |
70 78 | PLY / STL | Static geometry | PLY colors possible; STL no conventional textures | No | No | Optional static equipment only; low priority |
71 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 |
@@ -61,11 +61,19 @@ Do not create empty crates solely to imply completion. Establish boundaries as f
61
61
62**Recommendation:** glTF 2.0/GLB is the preferred canonical interchange for animated characters. GLB is convenient for distributing a self-contained body and avoids accidental texture-path breakage. Preserve original sources and conversion/import provenance. OBJ and FBX are minimum product goals, not necessarily native Bevy capabilities. A Bevy glTF loader must not be advertised as an FBX or OBJ loader.
62**Recommendation:** glTF 2.0/GLB is the preferred canonical interchange for animated characters. GLB is convenient for distributing a self-contained body and avoids accidental texture-path breakage. Preserve original sources and conversion/import provenance. OBJ and FBX are minimum product goals, not necessarily native Bevy capabilities. A Bevy glTF loader must not be advertised as an FBX or OBJ loader.
63
63
64### Import implementation status
65
66The desktop shell stays on eframe/egui. Bevy is not linked. Pulling Bevy in only to parse files would replace the window stack already used for project editing, and it is not required to read glTF or OBJ. Import uses the `gltf` 1.4 crate and `tobj` 4. Both are pure Rust. A later viewport can consume the same CPU records (`MeshRecord`, `JointRecord`, `ClipRecord`) without treating this importer as a renderer.
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.
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.
71
64| Format | Mesh | Materials/textures | Skeleton | Animation | Intended role / status |
72| Format | Mesh | Materials/textures | Skeleton | Animation | Intended role / status |
65|---|---|---|---|---|---|
73|---|---|---|---|---|---|
66| glTF 2.0 .gltf/.glb | Yes | PBR metallic/roughness and referenced/embedded textures | Skins/joints | Node/skeletal clips; morph support must be tested | Preferred character and equipment path; native Bevy glTF adapter planned |
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. |
67| OBJ + MTL + texture files | Yes | MTL subset with explicit conversion and relative textures | No | No skeletal clips | Required static equipment/import preview; cannot by itself satisfy animated body requirement |
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. |
68| FBX ASCII/binary | Yes | Varies; embedded/external textures and legacy material conversion | Commonly | Commonly multiple clips/takes | Required adapter/conversion path with compatibility fixtures; never claim universal FBX fidelity |
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. |
69| 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 |
70| 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 |
71| .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 |
src/editor.rs +168 −12
@@ -4,6 +4,7 @@ use std::path::PathBuf;
4 4
5 5 use eframe::egui;
6 6
7 use crate::import::{import_project_model, ImportedModel, ModelFormat};
7 8 use crate::project::{
8 9 assemble, create_project, list_dir, open_project, save_project, OpenedProject, ProjectDraft,
9 10 ProjectError, FORMAT_VERSION, MANIFEST_NAME,
@@ -23,6 +24,8 @@ pub struct EditorApp {
23 24 height: String,
24 25 fps: String,
25 26 browser: String,
27 male_import: Option<Result<ImportedModel, String>>,
28 female_import: Option<Result<ImportedModel, String>>,
26 29 notice: String,
27 30 notice_ok: bool,
28 31 }
@@ -43,6 +46,8 @@ impl EditorApp {
43 46 height: "128".to_string(),
44 47 fps: "12".to_string(),
45 48 browser: String::new(),
49 male_import: None,
50 female_import: None,
46 51 notice: "Maak een projectmap of open een bestaand character.ppd-bestand.".to_string(),
47 52 notice_ok: true,
48 53 }
@@ -58,11 +63,23 @@ impl EditorApp {
58 63 self.height = opened.document.render.target_height.to_string();
59 64 self.fps = opened.document.render.fps.to_string();
60 65 self.browser.clear();
66 self.male_import = None;
67 self.female_import = None;
61 68 self.opened = Some(opened.clone());
62 if opened.disk_issues.is_empty() {
63 self.ok(notice);
64 } else {
65 self.fail_text(opened.disk_issues.join("\n"));
69 let male = self.male.clone();
70 let female = self.female.clone();
71 if !male.trim().is_empty() {
72 self.inspect(true);
73 }
74 if !female.trim().is_empty() {
75 self.inspect(false);
76 }
77 if male.trim().is_empty() && female.trim().is_empty() {
78 if opened.disk_issues.is_empty() {
79 self.ok(notice);
80 } else {
81 self.fail_text(opened.disk_issues.join("\n"));
82 }
66 83 }
67 84 }
68 85
@@ -154,9 +171,7 @@ impl EditorApp {
154 171 Ok(()) => {
155 172 let path = candidate.manifest_path.display().to_string();
156 173 self.opened = Some(candidate);
157 self.ok(&format!(
158 "Opgeslagen naar {path}. Import en render zijn niet uitgevoerd."
159 ));
174 self.ok(&format!("Opgeslagen naar {path}. Er is niet gerenderd."));
160 175 }
161 176 Err(error) => self.fail(error),
162 177 }
@@ -189,14 +204,65 @@ impl EditorApp {
189 204 self.add_preset(relative.to_string());
190 205 } else if self.male.trim().is_empty() {
191 206 self.male = relative.to_string();
192 self.ok("Pad ingevuld bij het mannelijke model. Het model wordt niet geladen.");
207 self.inspect(true);
193 208 } else if self.female.trim().is_empty() {
194 209 self.female = relative.to_string();
195 self.ok("Pad ingevuld bij het vrouwelijke model. Het model wordt niet geladen.");
210 self.inspect(false);
196 211 } else {
197 212 self.add_preset(relative.to_string());
198 213 }
199 214 }
215
216 fn inspect(&mut self, male: bool) {
217 let Some(root) = self.opened.as_ref().and_then(|opened| {
218 opened
219 .manifest_path
220 .parent()
221 .map(std::path::Path::to_path_buf)
222 }) else {
223 self.fail_text("open of maak eerst een project");
224 return;
225 };
226 let relative = if male {
227 self.male.trim()
228 } else {
229 self.female.trim()
230 };
231 if relative.is_empty() {
232 if male {
233 self.male_import = None;
234 } else {
235 self.female_import = None;
236 }
237 self.fail_text("dit slot heeft nog geen modelpad");
238 return;
239 }
240 match import_project_model(&root, relative) {
241 Ok(model) => {
242 let message = format!(
243 "{relative}: {} meshes, {} joints, {} clips. Er is niet gerenderd.",
244 model.meshes.len(),
245 model.joints.len(),
246 model.clips.len()
247 );
248 if male {
249 self.male_import = Some(Ok(model));
250 } else {
251 self.female_import = Some(Ok(model));
252 }
253 self.ok(&message);
254 }
255 Err(error) => {
256 let text = error.to_string();
257 if male {
258 self.male_import = Some(Err(text.clone()));
259 } else {
260 self.female_import = Some(Err(text.clone()));
261 }
262 self.fail_text(text);
263 }
264 }
265 }
200 266 }
201 267
202 268 impl eframe::App for EditorApp {
@@ -242,7 +308,9 @@ impl eframe::App for EditorApp {
242 308 impl EditorApp {
243 309 fn show_form(&mut self, ui: &mut egui::Ui) {
244 310 ui.heading("Paperdoll Renderer");
245 ui.label("Projectmap met één .ppd-bestand. Modellen kiezen slaat alleen het pad op.");
311 ui.label(
312 "Projectmap met één .ppd-bestand. Lees man of Lees vrouw toont mesh, skeleton en clips. Er is geen 3D-beeld.",
313 );
246 314 ui.add_space(8.0);
247 315 ui.horizontal(|ui| {
248 316 ui.label("Projectmap");
@@ -267,26 +335,42 @@ impl EditorApp {
267 335 });
268 336 ui.horizontal(|ui| {
269 337 ui.label("Man");
270 ui.add(
338 let male_edit = ui.add(
271 339 egui::TextEdit::singleline(&mut self.male)
272 340 .desired_width(360.0)
273 341 .hint_text("assets/body/male.glb"),
274 342 );
343 if male_edit.changed() {
344 self.male_import = None;
345 }
275 346 if ui.button("Wis man").clicked() {
276 347 self.male.clear();
348 self.male_import = None;
349 }
350 if ui.button("Lees man").clicked() {
351 self.inspect(true);
277 352 }
278 353 });
354 show_import(ui, "Import man", self.male_import.as_ref());
279 355 ui.horizontal(|ui| {
280 356 ui.label("Vrouw");
281 ui.add(
357 let female_edit = ui.add(
282 358 egui::TextEdit::singleline(&mut self.female)
283 359 .desired_width(360.0)
284 360 .hint_text("assets/body/female.glb"),
285 361 );
362 if female_edit.changed() {
363 self.female_import = None;
364 }
286 365 if ui.button("Wis vrouw").clicked() {
287 366 self.female.clear();
367 self.female_import = None;
368 }
369 if ui.button("Lees vrouw").clicked() {
370 self.inspect(false);
288 371 }
289 372 });
373 show_import(ui, "Import vrouw", self.female_import.as_ref());
290 374 ui.label("Equipment-presets, relatief aan de projectmap");
291 375 let mut remove = None;
292 376 for (index, preset) in self.presets.iter().enumerate() {
@@ -388,6 +472,78 @@ impl EditorApp {
388 472 }
389 473 }
390 474
475 fn show_import(ui: &mut egui::Ui, title: &str, report: Option<&Result<ImportedModel, String>>) {
476 let Some(report) = report else {
477 return;
478 };
479 ui.group(|ui| {
480 ui.strong(title);
481 match report {
482 Ok(model) => {
483 let format = match model.format {
484 ModelFormat::Gltf => "glTF/GLB",
485 ModelFormat::Obj => "OBJ",
486 ModelFormat::Fbx => "FBX",
487 };
488 ui.label(format);
489 for mesh in &model.meshes {
490 ui.label(format!(
491 "mesh {}: {} vertices, {} driehoeken{}",
492 mesh.name,
493 mesh.vertex_count,
494 mesh.triangle_count,
495 if mesh.has_skin { ", skin" } else { "" }
496 ));
497 }
498 for material in &model.materials {
499 ui.label(format!(
500 "materiaal {}{}",
501 material.name,
502 material
503 .base_color_texture
504 .as_ref()
505 .map(|name| format!(", texture {name}"))
506 .unwrap_or_default()
507 ));
508 }
509 for texture in &model.textures {
510 ui.label(format!(
511 "texture {} ({}x{})",
512 texture.source, texture.width, texture.height
513 ));
514 }
515 if model.joints.is_empty() {
516 ui.label("geen skeleton");
517 } else {
518 ui.label(format!("{} joints", model.joints.len()));
519 for joint in model.joints.iter().take(8) {
520 match &joint.parent {
521 Some(parent) => ui.label(format!("{} onder {parent}", joint.name)),
522 None => ui.label(format!("{} (wortel)", joint.name)),
523 };
524 }
525 }
526 if model.clips.is_empty() {
527 ui.label("geen animation clips");
528 } else {
529 for clip in &model.clips {
530 ui.label(format!(
531 "clip {} ({:.2} s, {} kanalen)",
532 clip.name, clip.duration_seconds, clip.channel_count
533 ));
534 }
535 }
536 for notice in &model.notices {
537 ui.label(notice);
538 }
539 }
540 Err(message) => {
541 ui.colored_label(egui::Color32::from_rgb(150, 30, 30), message);
542 }
543 }
544 });
545 }
546
391 547 fn parent_relative(relative: &str) -> String {
392 548 match relative.rfind('/') {
393 549 Some(index) => relative[..index].to_string(),
@@ -4,6 +4,7 @@ use std::path::PathBuf;
4
4
5use eframe::egui;
5use eframe::egui;
6
6
7use crate::import::{import_project_model, ImportedModel, ModelFormat};
7use crate::project::{
8use crate::project::{
8 assemble, create_project, list_dir, open_project, save_project, OpenedProject, ProjectDraft,
9 assemble, create_project, list_dir, open_project, save_project, OpenedProject, ProjectDraft,
9 ProjectError, FORMAT_VERSION, MANIFEST_NAME,
10 ProjectError, FORMAT_VERSION, MANIFEST_NAME,
@@ -23,6 +24,8 @@ pub struct EditorApp {
23 height: String,
24 height: String,
24 fps: String,
25 fps: String,
25 browser: String,
26 browser: String,
27 male_import: Option<Result<ImportedModel, String>>,
28 female_import: Option<Result<ImportedModel, String>>,
26 notice: String,
29 notice: String,
27 notice_ok: bool,
30 notice_ok: bool,
28}
31}
@@ -43,6 +46,8 @@ impl EditorApp {
43 height: "128".to_string(),
46 height: "128".to_string(),
44 fps: "12".to_string(),
47 fps: "12".to_string(),
45 browser: String::new(),
48 browser: String::new(),
49 male_import: None,
50 female_import: None,
46 notice: "Maak een projectmap of open een bestaand character.ppd-bestand.".to_string(),
51 notice: "Maak een projectmap of open een bestaand character.ppd-bestand.".to_string(),
47 notice_ok: true,
52 notice_ok: true,
48 }
53 }
@@ -58,11 +63,23 @@ impl EditorApp {
58 self.height = opened.document.render.target_height.to_string();
63 self.height = opened.document.render.target_height.to_string();
59 self.fps = opened.document.render.fps.to_string();
64 self.fps = opened.document.render.fps.to_string();
60 self.browser.clear();
65 self.browser.clear();
66 self.male_import = None;
67 self.female_import = None;
61 self.opened = Some(opened.clone());
68 self.opened = Some(opened.clone());
62 if opened.disk_issues.is_empty() {
69 let male = self.male.clone();
63 self.ok(notice);
70 let female = self.female.clone();
64 } else {
71 if !male.trim().is_empty() {
65 self.fail_text(opened.disk_issues.join("\n"));
72 self.inspect(true);
73 }
74 if !female.trim().is_empty() {
75 self.inspect(false);
76 }
77 if male.trim().is_empty() && female.trim().is_empty() {
78 if opened.disk_issues.is_empty() {
79 self.ok(notice);
80 } else {
81 self.fail_text(opened.disk_issues.join("\n"));
82 }
66 }
83 }
67 }
84 }
68
85
@@ -154,9 +171,7 @@ impl EditorApp {
154 Ok(()) => {
171 Ok(()) => {
155 let path = candidate.manifest_path.display().to_string();
172 let path = candidate.manifest_path.display().to_string();
156 self.opened = Some(candidate);
173 self.opened = Some(candidate);
157 self.ok(&format!(
174 self.ok(&format!("Opgeslagen naar {path}. Er is niet gerenderd."));
158 "Opgeslagen naar {path}. Import en render zijn niet uitgevoerd."
159 ));
160 }
175 }
161 Err(error) => self.fail(error),
176 Err(error) => self.fail(error),
162 }
177 }
@@ -189,14 +204,65 @@ impl EditorApp {
189 self.add_preset(relative.to_string());
204 self.add_preset(relative.to_string());
190 } else if self.male.trim().is_empty() {
205 } else if self.male.trim().is_empty() {
191 self.male = relative.to_string();
206 self.male = relative.to_string();
192 self.ok("Pad ingevuld bij het mannelijke model. Het model wordt niet geladen.");
207 self.inspect(true);
193 } else if self.female.trim().is_empty() {
208 } else if self.female.trim().is_empty() {
194 self.female = relative.to_string();
209 self.female = relative.to_string();
195 self.ok("Pad ingevuld bij het vrouwelijke model. Het model wordt niet geladen.");
210 self.inspect(false);
196 } else {
211 } else {
197 self.add_preset(relative.to_string());
212 self.add_preset(relative.to_string());
198 }
213 }
199 }
214 }
215
216 fn inspect(&mut self, male: bool) {
217 let Some(root) = self.opened.as_ref().and_then(|opened| {
218 opened
219 .manifest_path
220 .parent()
221 .map(std::path::Path::to_path_buf)
222 }) else {
223 self.fail_text("open of maak eerst een project");
224 return;
225 };
226 let relative = if male {
227 self.male.trim()
228 } else {
229 self.female.trim()
230 };
231 if relative.is_empty() {
232 if male {
233 self.male_import = None;
234 } else {
235 self.female_import = None;
236 }
237 self.fail_text("dit slot heeft nog geen modelpad");
238 return;
239 }
240 match import_project_model(&root, relative) {
241 Ok(model) => {
242 let message = format!(
243 "{relative}: {} meshes, {} joints, {} clips. Er is niet gerenderd.",
244 model.meshes.len(),
245 model.joints.len(),
246 model.clips.len()
247 );
248 if male {
249 self.male_import = Some(Ok(model));
250 } else {
251 self.female_import = Some(Ok(model));
252 }
253 self.ok(&message);
254 }
255 Err(error) => {
256 let text = error.to_string();
257 if male {
258 self.male_import = Some(Err(text.clone()));
259 } else {
260 self.female_import = Some(Err(text.clone()));
261 }
262 self.fail_text(text);
263 }
264 }
265 }
200}
266}
201
267
202impl eframe::App for EditorApp {
268impl eframe::App for EditorApp {
@@ -242,7 +308,9 @@ impl eframe::App for EditorApp {
242impl EditorApp {
308impl EditorApp {
243 fn show_form(&mut self, ui: &mut egui::Ui) {
309 fn show_form(&mut self, ui: &mut egui::Ui) {
244 ui.heading("Paperdoll Renderer");
310 ui.heading("Paperdoll Renderer");
245 ui.label("Projectmap met één .ppd-bestand. Modellen kiezen slaat alleen het pad op.");
311 ui.label(
312 "Projectmap met één .ppd-bestand. Lees man of Lees vrouw toont mesh, skeleton en clips. Er is geen 3D-beeld.",
313 );
246 ui.add_space(8.0);
314 ui.add_space(8.0);
247 ui.horizontal(|ui| {
315 ui.horizontal(|ui| {
248 ui.label("Projectmap");
316 ui.label("Projectmap");
@@ -267,26 +335,42 @@ impl EditorApp {
267 });
335 });
268 ui.horizontal(|ui| {
336 ui.horizontal(|ui| {
269 ui.label("Man");
337 ui.label("Man");
270 ui.add(
338 let male_edit = ui.add(
271 egui::TextEdit::singleline(&mut self.male)
339 egui::TextEdit::singleline(&mut self.male)
272 .desired_width(360.0)
340 .desired_width(360.0)
273 .hint_text("assets/body/male.glb"),
341 .hint_text("assets/body/male.glb"),
274 );
342 );
343 if male_edit.changed() {
344 self.male_import = None;
345 }
275 if ui.button("Wis man").clicked() {
346 if ui.button("Wis man").clicked() {
276 self.male.clear();
347 self.male.clear();
348 self.male_import = None;
349 }
350 if ui.button("Lees man").clicked() {
351 self.inspect(true);
277 }
352 }
278 });
353 });
354 show_import(ui, "Import man", self.male_import.as_ref());
279 ui.horizontal(|ui| {
355 ui.horizontal(|ui| {
280 ui.label("Vrouw");
356 ui.label("Vrouw");
281 ui.add(
357 let female_edit = ui.add(
282 egui::TextEdit::singleline(&mut self.female)
358 egui::TextEdit::singleline(&mut self.female)
283 .desired_width(360.0)
359 .desired_width(360.0)
284 .hint_text("assets/body/female.glb"),
360 .hint_text("assets/body/female.glb"),
285 );
361 );
362 if female_edit.changed() {
363 self.female_import = None;
364 }
286 if ui.button("Wis vrouw").clicked() {
365 if ui.button("Wis vrouw").clicked() {
287 self.female.clear();
366 self.female.clear();
367 self.female_import = None;
368 }
369 if ui.button("Lees vrouw").clicked() {
370 self.inspect(false);
288 }
371 }
289 });
372 });
373 show_import(ui, "Import vrouw", self.female_import.as_ref());
290 ui.label("Equipment-presets, relatief aan de projectmap");
374 ui.label("Equipment-presets, relatief aan de projectmap");
291 let mut remove = None;
375 let mut remove = None;
292 for (index, preset) in self.presets.iter().enumerate() {
376 for (index, preset) in self.presets.iter().enumerate() {
@@ -388,6 +472,78 @@ impl EditorApp {
388 }
472 }
389}
473}
390
474
475fn show_import(ui: &mut egui::Ui, title: &str, report: Option<&Result<ImportedModel, String>>) {
476 let Some(report) = report else {
477 return;
478 };
479 ui.group(|ui| {
480 ui.strong(title);
481 match report {
482 Ok(model) => {
483 let format = match model.format {
484 ModelFormat::Gltf => "glTF/GLB",
485 ModelFormat::Obj => "OBJ",
486 ModelFormat::Fbx => "FBX",
487 };
488 ui.label(format);
489 for mesh in &model.meshes {
490 ui.label(format!(
491 "mesh {}: {} vertices, {} driehoeken{}",
492 mesh.name,
493 mesh.vertex_count,
494 mesh.triangle_count,
495 if mesh.has_skin { ", skin" } else { "" }
496 ));
497 }
498 for material in &model.materials {
499 ui.label(format!(
500 "materiaal {}{}",
501 material.name,
502 material
503 .base_color_texture
504 .as_ref()
505 .map(|name| format!(", texture {name}"))
506 .unwrap_or_default()
507 ));
508 }
509 for texture in &model.textures {
510 ui.label(format!(
511 "texture {} ({}x{})",
512 texture.source, texture.width, texture.height
513 ));
514 }
515 if model.joints.is_empty() {
516 ui.label("geen skeleton");
517 } else {
518 ui.label(format!("{} joints", model.joints.len()));
519 for joint in model.joints.iter().take(8) {
520 match &joint.parent {
521 Some(parent) => ui.label(format!("{} onder {parent}", joint.name)),
522 None => ui.label(format!("{} (wortel)", joint.name)),
523 };
524 }
525 }
526 if model.clips.is_empty() {
527 ui.label("geen animation clips");
528 } else {
529 for clip in &model.clips {
530 ui.label(format!(
531 "clip {} ({:.2} s, {} kanalen)",
532 clip.name, clip.duration_seconds, clip.channel_count
533 ));
534 }
535 }
536 for notice in &model.notices {
537 ui.label(notice);
538 }
539 }
540 Err(message) => {
541 ui.colored_label(egui::Color32::from_rgb(150, 30, 30), message);
542 }
543 }
544 });
545}
546
391fn parent_relative(relative: &str) -> String {
547fn parent_relative(relative: &str) -> String {
392 match relative.rfind('/') {
548 match relative.rfind('/') {
393 Some(index) => relative[..index].to_string(),
549 Some(index) => relative[..index].to_string(),
src/foundation.rs +34 −22
@@ -55,10 +55,10 @@ pub fn capabilities() -> &'static [Capability] {
55 55
56 56 pub fn availability(capability: Capability) -> Availability {
57 57 match capability {
58 Capability::ProjectShell => Availability::Present,
59 Capability::ObjMtlTextures
60 | Capability::Fbx
61 | Capability::GltfGlb
58 Capability::ProjectShell | Capability::ObjMtlTextures | Capability::GltfGlb => {
59 Availability::Present
60 }
61 Capability::Fbx
62 62 | Capability::SkeletalAnimation
63 63 | Capability::AttachmentEditor
64 64 | Capability::PaperdollRender
@@ -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 bewaart een .ppd-projectmap. OBJ+MTL+textures, FBX, glTF/GLB, skeletanimatie, de attachment-editor, paperdoll-render en de 2D-preview zijn niet gebouwd. Genoemde modelbestanden worden niet geladen."
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."
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: project-files".to_string());
117 lines.push("phase: model-import".to_string());
118 118 for capability in CAPABILITIES {
119 119 lines.push(format!(
120 120 "{}: {}",
@@ -158,14 +158,14 @@ pub fn missing_window_libraries() -> Vec<&'static str> {
158 158
159 159 pub fn help_text() -> &'static str {
160 160 "\
161 Paperdoll Renderer — projectbestanden
161 Paperdoll Renderer — modelimport
162 162
163 163 Gebruik:
164 164 paperdoll-renderer open het projectvenster
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. Import en render zijn niet gebouwd."
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."
169 169 }
170 170
171 171 pub fn parse_cli<'a, I>(args: I) -> Result<CliRequest, CliError>
@@ -194,31 +194,43 @@ mod tests {
194 194 use super::*;
195 195
196 196 #[test]
197 fn only_the_project_shell_is_present() {
197 fn import_formats_are_present_and_render_is_not() {
198 198 assert_eq!(
199 199 availability(Capability::ProjectShell),
200 200 Availability::Present
201 201 );
202 for capability in capabilities() {
203 if *capability == Capability::ProjectShell {
204 continue;
205 }
202 assert_eq!(availability(Capability::GltfGlb), Availability::Present);
203 assert_eq!(
204 availability(Capability::ObjMtlTextures),
205 Availability::Present
206 );
207 for capability in [
208 Capability::Fbx,
209 Capability::SkeletalAnimation,
210 Capability::AttachmentEditor,
211 Capability::PaperdollRender,
212 Capability::Preview2d,
213 ] {
206 214 assert_eq!(
207 availability(*capability),
215 availability(capability),
208 216 Availability::NotBuilt,
209 "{capability:?} must stay not built in the foundation"
217 "{capability:?} must stay not built"
210 218 );
211 219 }
212 220 }
213 221
214 222 #[test]
215 fn status_report_names_every_format_as_not_built() {
223 fn status_report_matches_the_import_build() {
216 224 let report = report();
217 assert!(report.starts_with("name: Paperdoll Renderer\nphase: project-files\n"));
225 assert!(report.starts_with("name: Paperdoll Renderer\nphase: model-import\n"));
226 for key in ["project_shell", "obj_mtl_textures", "gltf_glb"] {
227 assert!(
228 report.contains(&format!("{key}: present")),
229 "missing present {key} in {report}"
230 );
231 }
218 232 for key in [
219 "obj_mtl_textures",
220 233 "fbx",
221 "gltf_glb",
222 234 "skeletal_animation",
223 235 "attachment_editor",
224 236 "paperdoll_render",
@@ -233,13 +245,13 @@ mod tests {
233 245 "{key} must not be reported as present"
234 246 );
235 247 }
236 assert!(report.contains("project_shell: present"));
237 248 let summary = dutch_summary();
238 249 assert!(summary.contains("niet gebouwd"));
239 assert!(summary.contains("niet geladen"));
250 assert!(summary.contains("OBJ+MTL"));
251 assert!(summary.contains("glTF/GLB"));
240 252 let lower = summary.to_lowercase();
241 assert!(!lower.contains("import werkt"));
242 253 assert!(!lower.contains("render werkt"));
254 assert!(!lower.contains("fbx-import werkt"));
243 255 }
244 256
245 257 #[test]
@@ -55,10 +55,10 @@ 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 => Availability::Present,
58 Capability::ProjectShell | Capability::ObjMtlTextures | Capability::GltfGlb => {
59 Capability::ObjMtlTextures
59 Availability::Present
60 | Capability::Fbx
60 }
61 | Capability::GltfGlb
61 Capability::Fbx
62 | Capability::SkeletalAnimation
62 | Capability::SkeletalAnimation
63 | Capability::AttachmentEditor
63 | Capability::AttachmentEditor
64 | Capability::PaperdollRender
64 | Capability::PaperdollRender
@@ -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 bewaart een .ppd-projectmap. OBJ+MTL+textures, FBX, glTF/GLB, skeletanimatie, de attachment-editor, paperdoll-render en de 2D-preview zijn niet gebouwd. Genoemde modelbestanden worden niet geladen."
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."
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: project-files".to_string());
117 lines.push("phase: model-import".to_string());
118 for capability in CAPABILITIES {
118 for capability in CAPABILITIES {
119 lines.push(format!(
119 lines.push(format!(
120 "{}: {}",
120 "{}: {}",
@@ -158,14 +158,14 @@ pub fn missing_window_libraries() -> Vec<&'static str> {
158
158
159pub fn help_text() -> &'static str {
159pub fn help_text() -> &'static str {
160 "\
160 "\
161Paperdoll Renderer — projectbestanden
161Paperdoll Renderer — modelimport
162
162
163Gebruik:
163Gebruik:
164 paperdoll-renderer open het projectvenster
164 paperdoll-renderer open het projectvenster
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. Import en render zijn niet gebouwd."
168Een modelpad op de opdrachtregel wordt niet geaccepteerd. glTF/GLB en OBJ+MTL worden in het venster gelezen. FBX en render zijn 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>
@@ -194,31 +194,43 @@ mod tests {
194 use super::*;
194 use super::*;
195
195
196 #[test]
196 #[test]
197 fn only_the_project_shell_is_present() {
197 fn import_formats_are_present_and_render_is_not() {
198 assert_eq!(
198 assert_eq!(
199 availability(Capability::ProjectShell),
199 availability(Capability::ProjectShell),
200 Availability::Present
200 Availability::Present
201 );
201 );
202 for capability in capabilities() {
202 assert_eq!(availability(Capability::GltfGlb), Availability::Present);
203 if *capability == Capability::ProjectShell {
203 assert_eq!(
204 continue;
204 availability(Capability::ObjMtlTextures),
205 }
205 Availability::Present
206 );
207 for capability in [
208 Capability::Fbx,
209 Capability::SkeletalAnimation,
210 Capability::AttachmentEditor,
211 Capability::PaperdollRender,
212 Capability::Preview2d,
213 ] {
206 assert_eq!(
214 assert_eq!(
207 availability(*capability),
215 availability(capability),
208 Availability::NotBuilt,
216 Availability::NotBuilt,
209 "{capability:?} must stay not built in the foundation"
217 "{capability:?} must stay not built"
210 );
218 );
211 }
219 }
212 }
220 }
213
221
214 #[test]
222 #[test]
215 fn status_report_names_every_format_as_not_built() {
223 fn status_report_matches_the_import_build() {
216 let report = report();
224 let report = report();
217 assert!(report.starts_with("name: Paperdoll Renderer\nphase: project-files\n"));
225 assert!(report.starts_with("name: Paperdoll Renderer\nphase: model-import\n"));
226 for key in ["project_shell", "obj_mtl_textures", "gltf_glb"] {
227 assert!(
228 report.contains(&format!("{key}: present")),
229 "missing present {key} in {report}"
230 );
231 }
218 for key in [
232 for key in [
219 "obj_mtl_textures",
220 "fbx",
233 "fbx",
221 "gltf_glb",
222 "skeletal_animation",
234 "skeletal_animation",
223 "attachment_editor",
235 "attachment_editor",
224 "paperdoll_render",
236 "paperdoll_render",
@@ -233,13 +245,13 @@ mod tests {
233 "{key} must not be reported as present"
245 "{key} must not be reported as present"
234 );
246 );
235 }
247 }
236 assert!(report.contains("project_shell: present"));
237 let summary = dutch_summary();
248 let summary = dutch_summary();
238 assert!(summary.contains("niet gebouwd"));
249 assert!(summary.contains("niet gebouwd"));
239 assert!(summary.contains("niet geladen"));
250 assert!(summary.contains("OBJ+MTL"));
251 assert!(summary.contains("glTF/GLB"));
240 let lower = summary.to_lowercase();
252 let lower = summary.to_lowercase();
241 assert!(!lower.contains("import werkt"));
242 assert!(!lower.contains("render werkt"));
253 assert!(!lower.contains("render werkt"));
254 assert!(!lower.contains("fbx-import werkt"));
243 }
255 }
244
256
245 #[test]
257 #[test]
src/import.rs added +753 −0
@@ -0,0 +1,753 @@
1 //! Read glTF/GLB characters and OBJ+MTL equipment into a CPU description.
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.
6
7 use std::fs;
8 use std::path::Path;
9
10 use serde_json::Value;
11
12 use crate::project::portable_relative;
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.";
15
16 const MAX_BYTES: u64 = 32 * 1024 * 1024;
17
18 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
19 pub enum ModelFormat {
20 Gltf,
21 Obj,
22 Fbx,
23 }
24
25 #[derive(Debug, Clone, PartialEq)]
26 pub struct ImportedModel {
27 pub format: ModelFormat,
28 pub meshes: Vec<MeshRecord>,
29 pub materials: Vec<MaterialRecord>,
30 pub textures: Vec<TextureRecord>,
31 pub joints: Vec<JointRecord>,
32 pub clips: Vec<ClipRecord>,
33 pub notices: Vec<String>,
34 }
35
36 #[derive(Debug, Clone, PartialEq)]
37 pub struct MeshRecord {
38 pub name: String,
39 pub vertex_count: usize,
40 pub triangle_count: usize,
41 pub has_normals: bool,
42 pub has_uvs: bool,
43 pub has_skin: bool,
44 pub material_name: Option<String>,
45 }
46
47 #[derive(Debug, Clone, PartialEq)]
48 pub struct MaterialRecord {
49 pub name: String,
50 pub base_color: [f32; 4],
51 pub base_color_texture: Option<String>,
52 pub normal_texture: Option<String>,
53 }
54
55 #[derive(Debug, Clone, PartialEq, Eq)]
56 pub struct TextureRecord {
57 pub name: String,
58 pub source: String,
59 pub width: u32,
60 pub height: u32,
61 }
62
63 #[derive(Debug, Clone, PartialEq, Eq)]
64 pub struct JointRecord {
65 pub name: String,
66 pub parent: Option<String>,
67 }
68
69 #[derive(Debug, Clone, PartialEq)]
70 pub struct ClipRecord {
71 pub name: String,
72 pub duration_seconds: f32,
73 pub channel_count: usize,
74 pub targets: Vec<String>,
75 }
76
77 #[derive(Debug)]
78 pub enum ImportError {
79 Missing(String),
80 Unsupported(String),
81 UnsafePath(String),
82 Parse { path: String, detail: String },
83 }
84
85 impl std::fmt::Display for ImportError {
86 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 match self {
88 Self::Missing(path) => write!(formatter, "het modelbestand ontbreekt: {path}"),
89 Self::Unsupported(message) => write!(formatter, "{message}"),
90 Self::UnsafePath(message) => write!(formatter, "{message}"),
91 Self::Parse { path, detail } => {
92 write!(formatter, "importeren van {path} is mislukt: {detail}")
93 }
94 }
95 }
96 }
97
98 impl std::error::Error for ImportError {}
99
100 pub fn import_project_model(
101 project_root: &Path,
102 relative: &str,
103 ) -> Result<ImportedModel, ImportError> {
104 let relative = portable_relative(relative).map_err(ImportError::UnsafePath)?;
105 let full = project_root.join(&relative);
106 if !full.is_file() {
107 return Err(ImportError::Missing(relative));
108 }
109 ensure_inside(project_root, &full, &relative)?;
110 let length = fs::metadata(&full)
111 .map_err(|err| ImportError::Parse {
112 path: relative.clone(),
113 detail: err.to_string(),
114 })?
115 .len();
116 if length > MAX_BYTES {
117 return Err(ImportError::Parse {
118 path: relative,
119 detail: format!("het bestand is groter dan {MAX_BYTES} bytes"),
120 });
121 }
122 let extension = full
123 .extension()
124 .and_then(|ext| ext.to_str())
125 .unwrap_or("")
126 .to_ascii_lowercase();
127 match extension.as_str() {
128 "glb" | "gltf" => import_gltf(project_root, &full, &relative),
129 "obj" => import_obj(project_root, &full, &relative),
130 "fbx" => Err(ImportError::Unsupported(FBX_UNAVAILABLE.to_string())),
131 other => Err(ImportError::Unsupported(format!(
132 "bestandstype '{other}' wordt niet gelezen. Gebruik .glb, .gltf of .obj."
133 ))),
134 }
135 }
136
137 fn import_gltf(
138 project_root: &Path,
139 full: &Path,
140 relative: &str,
141 ) -> Result<ImportedModel, ImportError> {
142 let json = gltf_json(full).map_err(|detail| ImportError::Parse {
143 path: relative.to_string(),
144 detail,
145 })?;
146 reject_unsafe_uris(&json)?;
147 let (document, buffers, images) = gltf::import(full).map_err(|err| ImportError::Parse {
148 path: relative.to_string(),
149 detail: err.to_string(),
150 })?;
151 let mut materials = Vec::new();
152 for material in document.materials() {
153 let pbr = material.pbr_metallic_roughness();
154 materials.push(MaterialRecord {
155 name: material.name().unwrap_or("materiaal").to_string(),
156 base_color: pbr.base_color_factor(),
157 base_color_texture: pbr
158 .base_color_texture()
159 .map(|info| texture_label(&info.texture())),
160 normal_texture: material
161 .normal_texture()
162 .map(|info| texture_label(&info.texture())),
163 });
164 }
165 let material_names: Vec<Option<String>> = document
166 .materials()
167 .map(|material| material.name().map(str::to_string))
168 .collect();
169 let mut meshes = Vec::new();
170 for mesh in document.meshes() {
171 let mut vertex_count = 0;
172 let mut triangle_count = 0;
173 let mut has_normals = false;
174 let mut has_uvs = false;
175 let mut has_skin = false;
176 let mut material_name = None;
177 for primitive in mesh.primitives() {
178 let reader = primitive.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
179 let positions: Vec<[f32; 3]> = reader
180 .read_positions()
181 .map(|iter| iter.collect())
182 .unwrap_or_default();
183 if positions
184 .iter()
185 .any(|position| position.iter().any(|value| !value.is_finite()))
186 {
187 return Err(ImportError::Parse {
188 path: relative.to_string(),
189 detail: format!(
190 "mesh '{}' heeft een niet-eindige positie",
191 mesh.name().unwrap_or("mesh")
192 ),
193 });
194 }
195 vertex_count += positions.len();
196 if let Some(indices) = reader.read_indices() {
197 let indices: Vec<u32> = indices.into_u32().collect();
198 if !indices.len().is_multiple_of(3) {
199 return Err(ImportError::Parse {
200 path: relative.to_string(),
201 detail: format!(
202 "mesh '{}' heeft geen driehoeken",
203 mesh.name().unwrap_or("mesh")
204 ),
205 });
206 }
207 triangle_count += indices.len() / 3;
208 } else if positions.len().is_multiple_of(3) {
209 triangle_count += positions.len() / 3;
210 }
211 has_normals |= reader.read_normals().is_some();
212 has_uvs |= reader.read_tex_coords(0).is_some();
213 if let Some(weights) = reader.read_weights(0) {
214 has_skin = true;
215 for weight in weights.into_f32() {
216 if weight
217 .iter()
218 .any(|value| !value.is_finite() || *value < 0.0)
219 {
220 return Err(ImportError::Parse {
221 path: relative.to_string(),
222 detail: "een skin-weight is niet eindig of is negatief".to_string(),
223 });
224 }
225 }
226 }
227 if let Some(index) = primitive.material().index() {
228 material_name = material_names.get(index).cloned().flatten();
229 }
230 }
231 meshes.push(MeshRecord {
232 name: mesh.name().unwrap_or("mesh").to_string(),
233 vertex_count,
234 triangle_count,
235 has_normals,
236 has_uvs,
237 has_skin,
238 material_name,
239 });
240 }
241 let mut textures = Vec::new();
242 for (image, data) in document.images().zip(images.iter()) {
243 let source = match image.source() {
244 gltf::image::Source::Uri { uri, .. } => {
245 let path = full.parent().unwrap_or(project_root).join(uri_path(uri));
246 ensure_inside(project_root, &path, uri)?;
247 uri.to_string()
248 }
249 gltf::image::Source::View { .. } => "ingebed".to_string(),
250 };
251 textures.push(TextureRecord {
252 name: image.name().unwrap_or("texture").to_string(),
253 source,
254 width: data.width,
255 height: data.height,
256 });
257 }
258 let parents = parent_index(&document);
259 let mut joints = Vec::new();
260 for skin in document.skins() {
261 let ibm = skin
262 .inverse_bind_matrices()
263 .map(|accessor| accessor.count())
264 .unwrap_or(0);
265 let joint_nodes: Vec<_> = skin.joints().collect();
266 if ibm != 0 && ibm != joint_nodes.len() {
267 return Err(ImportError::Parse {
268 path: relative.to_string(),
269 detail: "het aantal inverse-bindmatrices wijkt af van het aantal joints"
270 .to_string(),
271 });
272 }
273 for joint in joint_nodes {
274 let parent = parents
275 .get(&joint.index())
276 .and_then(|parent| node_name(&document, *parent));
277 joints.push(JointRecord {
278 name: node_name(&document, joint.index())
279 .unwrap_or_else(|| format!("node-{}", joint.index())),
280 parent,
281 });
282 }
283 }
284 let mut clips = Vec::new();
285 let mut notices = Vec::new();
286 for (index, animation) in document.animations().enumerate() {
287 let mut duration = 0.0_f32;
288 let mut channel_count = 0;
289 let mut targets = Vec::new();
290 for channel in animation.channels() {
291 channel_count += 1;
292 if let Some(name) = channel.target().node().name() {
293 if !targets.iter().any(|existing| existing == name) {
294 targets.push(name.to_string());
295 }
296 }
297 let reader = channel.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
298 if let Some(times) = reader.read_inputs() {
299 for time in times {
300 if !time.is_finite() {
301 return Err(ImportError::Parse {
302 path: relative.to_string(),
303 detail: "een animatiesample is niet eindig".to_string(),
304 });
305 }
306 duration = duration.max(time);
307 }
308 }
309 }
310 let name = animation.name().map(str::to_string).unwrap_or_else(|| {
311 notices.push(format!("clip {index} heeft geen naam"));
312 format!("naamloos-{index}")
313 });
314 clips.push(ClipRecord {
315 name,
316 duration_seconds: duration,
317 channel_count,
318 targets,
319 });
320 }
321 if joints.is_empty() {
322 notices.push(
323 "dit glTF-bestand heeft geen skeleton. Bot- en clipfuncties blijven uit tot er skins in het bestand staan."
324 .to_string(),
325 );
326 }
327 let _ = project_root;
328 Ok(ImportedModel {
329 format: ModelFormat::Gltf,
330 meshes,
331 materials,
332 textures,
333 joints,
334 clips,
335 notices,
336 })
337 }
338
339 fn import_obj(
340 project_root: &Path,
341 full: &Path,
342 relative: &str,
343 ) -> Result<ImportedModel, ImportError> {
344 let (models, materials) = tobj::load_obj(
345 full,
346 &tobj::LoadOptions {
347 single_index: true,
348 triangulate: true,
349 ignore_points: true,
350 ignore_lines: true,
351 },
352 )
353 .map_err(|err| ImportError::Parse {
354 path: relative.to_string(),
355 detail: err.to_string(),
356 })?;
357 let materials = materials.map_err(|err| ImportError::Parse {
358 path: relative.to_string(),
359 detail: format!("MTL ontbreekt of is onleesbaar: {err}"),
360 })?;
361 let mut meshes = Vec::new();
362 for model in &models {
363 let mesh = &model.mesh;
364 let vertex_count = mesh.positions.len() / 3;
365 if !mesh.positions.len().is_multiple_of(3)
366 || mesh.positions.iter().any(|value| !value.is_finite())
367 {
368 return Err(ImportError::Parse {
369 path: relative.to_string(),
370 detail: format!("mesh '{}' heeft een ongeldige positie", model.name),
371 });
372 }
373 if !mesh.indices.len().is_multiple_of(3) {
374 return Err(ImportError::Parse {
375 path: relative.to_string(),
376 detail: format!("mesh '{}' is niet in driehoeken", model.name),
377 });
378 }
379 let material_name = mesh
380 .material_id
381 .and_then(|index| materials.get(index))
382 .map(|material| material.name.clone());
383 meshes.push(MeshRecord {
384 name: model.name.clone(),
385 vertex_count,
386 triangle_count: mesh.indices.len() / 3,
387 has_normals: !mesh.normals.is_empty(),
388 has_uvs: !mesh.texcoords.is_empty(),
389 has_skin: false,
390 material_name,
391 });
392 }
393 let base = full.parent().unwrap_or(project_root);
394 let mut textures = Vec::new();
395 let mut material_records = Vec::new();
396 for material in &materials {
397 let base_color_texture =
398 checked_texture(project_root, base, material.diffuse_texture.as_deref())?;
399 let normal_texture =
400 checked_texture(project_root, base, material.normal_texture.as_deref())?;
401 if let Some(name) = base_color_texture.clone() {
402 let path = base.join(&name);
403 let (width, height) = png_size(&path).unwrap_or((0, 0));
404 textures.push(TextureRecord {
405 name: name.clone(),
406 source: name,
407 width,
408 height,
409 });
410 }
411 material_records.push(MaterialRecord {
412 name: material.name.clone(),
413 base_color: [
414 material.diffuse.unwrap_or([1.0, 1.0, 1.0])[0],
415 material.diffuse.unwrap_or([1.0, 1.0, 1.0])[1],
416 material.diffuse.unwrap_or([1.0, 1.0, 1.0])[2],
417 material.dissolve.unwrap_or(1.0),
418 ],
419 base_color_texture,
420 normal_texture,
421 });
422 }
423 Ok(ImportedModel {
424 format: ModelFormat::Obj,
425 meshes,
426 materials: material_records,
427 textures,
428 joints: Vec::new(),
429 clips: Vec::new(),
430 notices: vec![
431 "OBJ is een statische mesh voor equipment. Dit formaat heeft geen skeleton en levert geen skeletal animation clips."
432 .to_string(),
433 ],
434 })
435 }
436
437 fn checked_texture(
438 project_root: &Path,
439 base: &Path,
440 texture: Option<&str>,
441 ) -> Result<Option<String>, ImportError> {
442 let Some(texture) = texture else {
443 return Ok(None);
444 };
445 if texture.trim().is_empty() {
446 return Ok(None);
447 }
448 reject_relative_escape(texture)?;
449 let path = base.join(texture);
450 if !path.is_file() {
451 return Err(ImportError::Parse {
452 path: texture.to_string(),
453 detail: "de texture uit de MTL ontbreekt".to_string(),
454 });
455 }
456 ensure_inside(project_root, &path, texture)?;
457 Ok(Some(texture.replace('\\', "/")))
458 }
459
460 fn reject_relative_escape(value: &str) -> Result<(), ImportError> {
461 if value.starts_with('/')
462 || value.contains('\\')
463 || value.split(['/', '\\']).any(|part| part == "..")
464 {
465 return Err(ImportError::UnsafePath(format!(
466 "texturepad '{value}' wijst buiten de projectmap"
467 )));
468 }
469 Ok(())
470 }
471
472 fn reject_unsafe_uris(document: &Value) -> Result<(), ImportError> {
473 for key in ["buffers", "images"] {
474 let Some(items) = document.get(key).and_then(Value::as_array) else {
475 continue;
476 };
477 for item in items {
478 let Some(uri) = item.get("uri").and_then(Value::as_str) else {
479 continue;
480 };
481 if uri.starts_with("data:") {
482 continue;
483 }
484 let path = uri.split(['?', '#']).next().unwrap_or(uri);
485 if path.contains(':')
486 || path.starts_with('/')
487 || path.contains('\\')
488 || path
489 .split('/')
490 .any(|part| part.is_empty() || part == "." || part == "..")
491 {
492 return Err(ImportError::UnsafePath(format!(
493 "glTF-verwijzing '{uri}' wordt niet gevolgd"
494 )));
495 }
496 }
497 }
498 Ok(())
499 }
500
501 fn gltf_json(path: &Path) -> Result<Value, String> {
502 let bytes = fs::read(path).map_err(|err| err.to_string())?;
503 if path.extension().and_then(|ext| ext.to_str()) == Some("glb") || bytes.starts_with(b"glTF") {
504 if bytes.len() < 20 || &bytes[0..4] != b"glTF" {
505 return Err("dit is geen GLB".to_string());
506 }
507 let json_len = u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize;
508 let json_type = &bytes[16..20];
509 if json_type != b"JSON" || 20 + json_len > bytes.len() {
510 return Err("de GLB heeft geen JSON-chunk".to_string());
511 }
512 let text = std::str::from_utf8(&bytes[20..20 + json_len]).map_err(|err| err.to_string())?;
513 return serde_json::from_str(text.trim_end()).map_err(|err| err.to_string());
514 }
515 serde_json::from_slice(&bytes).map_err(|err| err.to_string())
516 }
517
518 fn uri_path(uri: &str) -> &str {
519 uri.split(['?', '#']).next().unwrap_or(uri)
520 }
521
522 fn texture_label(texture: &gltf::Texture<'_>) -> String {
523 texture
524 .name()
525 .map(str::to_string)
526 .or_else(|| texture.source().name().map(str::to_string))
527 .unwrap_or_else(|| format!("texture-{}", texture.index()))
528 }
529
530 fn parent_index(document: &gltf::Document) -> std::collections::HashMap<usize, usize> {
531 let mut parents = std::collections::HashMap::new();
532 for node in document.nodes() {
533 for child in node.children() {
534 parents.insert(child.index(), node.index());
535 }
536 }
537 parents
538 }
539
540 fn node_name(document: &gltf::Document, index: usize) -> Option<String> {
541 document
542 .nodes()
543 .find(|node| node.index() == index)
544 .and_then(|node| node.name().map(str::to_string))
545 }
546
547 fn ensure_inside(project_root: &Path, path: &Path, label: &str) -> Result<(), ImportError> {
548 let root = project_root
549 .canonicalize()
550 .map_err(|err| ImportError::Parse {
551 path: label.to_string(),
552 detail: err.to_string(),
553 })?;
554 let file = path.canonicalize().map_err(|err| ImportError::Parse {
555 path: label.to_string(),
556 detail: err.to_string(),
557 })?;
558 if file.starts_with(&root) {
559 Ok(())
560 } else {
561 Err(ImportError::UnsafePath(format!(
562 "het pad '{label}' wijst buiten de projectmap"
563 )))
564 }
565 }
566
567 fn png_size(path: &Path) -> Option<(u32, u32)> {
568 let bytes = fs::read(path).ok()?;
569 if bytes.len() < 24 || &bytes[0..8] != b"\x89PNG\r\n\x1a\n" {
570 return None;
571 }
572 let width = u32::from_be_bytes(bytes[16..20].try_into().ok()?);
573 let height = u32::from_be_bytes(bytes[20..24].try_into().ok()?);
574 Some((width, height))
575 }
576
577 #[cfg(test)]
578 mod tests {
579 use std::path::PathBuf;
580
581 use super::*;
582
583 fn fixture_root() -> PathBuf {
584 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies")
585 }
586
587 fn assert_body(relative: &str, sex: &str) {
588 let model = import_project_model(&fixture_root(), relative).unwrap();
589 assert_eq!(model.format, ModelFormat::Gltf);
590 assert_eq!(model.meshes.len(), 1);
591 assert_eq!(model.meshes[0].vertex_count, 19 * 8);
592 assert_eq!(model.meshes[0].triangle_count, 19 * 12);
593 assert!(model.meshes[0].has_skin);
594 assert_eq!(model.materials.len(), 1);
595 assert!(model.materials[0]
596 .base_color
597 .iter()
598 .all(|value| value.is_finite()));
599 assert_eq!(model.joints.len(), 19);
600 assert!(model
601 .joints
602 .iter()
603 .any(|joint| joint.name == "Hips" && joint.parent.is_none()));
604 assert!(model.joints.iter().any(|joint| {
605 joint.name == "LeftUpperArm" && joint.parent.as_deref() == Some("LeftShoulder")
606 }));
607 let walk = model.clips.iter().find(|clip| clip.name == "walk").unwrap();
608 let attack = model
609 .clips
610 .iter()
611 .find(|clip| clip.name == "attack")
612 .unwrap();
613 assert!((walk.duration_seconds - 1.0).abs() < 1e-3);
614 assert!((attack.duration_seconds - 0.8).abs() < 1e-3);
615 assert!(walk.targets.iter().any(|target| target == "LeftUpperLeg"));
616 assert!(attack
617 .targets
618 .iter()
619 .any(|target| target == "RightUpperArm"));
620 assert!(walk.channel_count >= 4);
621 assert!(model.materials[0].name.contains(sex) || model.meshes[0].name.contains(sex));
622 assert!(model
623 .notices
624 .iter()
625 .all(|notice| !notice.contains("geen skeleton")));
626 }
627
628 #[test]
629 fn test_bodies_expose_mesh_skin_and_both_clips() {
630 assert_body("test-body-male.glb", "male");
631 assert_body("test-body-female.glb", "female");
632 let male = import_project_model(&fixture_root(), "test-body-male.glb").unwrap();
633 let female = import_project_model(&fixture_root(), "test-body-female.glb").unwrap();
634 let male_shoulder = male
635 .joints
636 .iter()
637 .find(|joint| joint.name == "LeftShoulder")
638 .unwrap();
639 let female_shoulder = female
640 .joints
641 .iter()
642 .find(|joint| joint.name == "LeftShoulder")
643 .unwrap();
644 assert_eq!(male_shoulder.parent, female_shoulder.parent);
645 assert_ne!(male.materials[0].base_color, female.materials[0].base_color);
646 }
647
648 #[test]
649 fn missing_gltf_texture_and_escape_are_errors() {
650 let root = std::env::temp_dir().join(format!("paperdoll-gltf-{}", std::process::id()));
651 let _ = fs::remove_dir_all(&root);
652 fs::create_dir_all(&root).unwrap();
653 fs::write(
654 root.join("missing.gltf"),
655 r#"{
656 "asset": {"version": "2.0"},
657 "buffers": [{"uri": "missing.bin", "byteLength": 4}],
658 "images": [{"uri": "missing.png"}]
659 }"#,
660 )
661 .unwrap();
662 let error = import_project_model(&root, "missing.gltf").unwrap_err();
663 let text = error.to_string();
664 assert!(
665 text.contains("missing.bin")
666 || text.contains("missing.png")
667 || text.contains("mislukt"),
668 "{text}"
669 );
670 fs::write(
671 root.join("escape.gltf"),
672 r#"{
673 "asset": {"version": "2.0"},
674 "images": [{"uri": "../secret.png"}]
675 }"#,
676 )
677 .unwrap();
678 let error = import_project_model(&root, "escape.gltf").unwrap_err();
679 assert!(error.to_string().contains("niet gevolgd"), "{error}");
680 let error = import_project_model(&root, "absent.glb").unwrap_err();
681 assert!(error.to_string().contains("ontbreekt"));
682 let _ = fs::remove_dir_all(&root);
683 }
684
685 #[test]
686 fn obj_with_mtl_and_texture_is_static_and_missing_texture_fails() {
687 let root = std::env::temp_dir().join(format!("paperdoll-obj-{}", std::process::id()));
688 let _ = fs::remove_dir_all(&root);
689 fs::create_dir_all(root.join("textures")).unwrap();
690 fs::write(root.join("textures/blade.png"), tiny_png()).unwrap();
691 fs::write(
692 root.join("sword.mtl"),
693 "newmtl steel\nKd 0.7 0.7 0.8\nmap_Kd textures/blade.png\n",
694 )
695 .unwrap();
696 fs::write(
697 root.join("sword.obj"),
698 "mtllib sword.mtl\no blade\nv 0 0 0\nv 1 0 0\nv 0 1 0\nvt 0 0\nvt 1 0\nvt 0 1\nvn 0 0 1\nusemtl steel\nf 1/1/1 2/2/1 3/3/1\n",
699 )
700 .unwrap();
701 let model = import_project_model(&root, "sword.obj").unwrap();
702 assert_eq!(model.format, ModelFormat::Obj);
703 assert_eq!(model.meshes.len(), 1);
704 assert_eq!(model.meshes[0].triangle_count, 1);
705 assert!(model.meshes[0].has_uvs);
706 assert!(model.meshes[0].has_normals);
707 assert!(!model.meshes[0].has_skin);
708 assert!(model.joints.is_empty());
709 assert!(model.clips.is_empty());
710 assert_eq!(model.materials.len(), 1);
711 assert_eq!(model.materials[0].name, "steel");
712 assert_eq!(
713 model.materials[0].base_color_texture.as_deref(),
714 Some("textures/blade.png")
715 );
716 assert_eq!(model.textures[0].width, 1);
717 assert_eq!(model.textures[0].height, 1);
718 let notice = model.notices.join(" ");
719 assert!(notice.contains("geen skeleton"));
720 assert!(notice.contains("geen skeletal animation clips"));
721 fs::remove_file(root.join("textures/blade.png")).unwrap();
722 let error = import_project_model(&root, "sword.obj").unwrap_err();
723 assert!(error.to_string().contains("texture"), "{error}");
724 fs::remove_file(root.join("sword.mtl")).unwrap();
725 let error = import_project_model(&root, "sword.obj").unwrap_err();
726 assert!(error.to_string().contains("MTL"), "{error}");
727 let _ = fs::remove_dir_all(&root);
728 }
729
730 #[test]
731 fn fbx_is_refused_without_pretending_to_parse() {
732 let root = std::env::temp_dir().join(format!("paperdoll-fbx-{}", std::process::id()));
733 let _ = fs::remove_dir_all(&root);
734 fs::create_dir_all(&root).unwrap();
735 fs::write(root.join("hero.fbx"), b"not a real fbx").unwrap();
736 let error = import_project_model(&root, "hero.fbx").unwrap_err();
737 let text = error.to_string();
738 assert!(text.contains("Assimp"));
739 assert!(text.contains("niet geïmporteerd"));
740 assert!(!text.contains("walk"));
741 assert!(!text.contains("geïmporteerd: 0 clips"));
742 let _ = fs::remove_dir_all(&root);
743 }
744
745 fn tiny_png() -> Vec<u8> {
746 vec![
747 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1,
748 8, 2, 0, 0, 0, 144, 119, 83, 222, 0, 0, 0, 12, 73, 68, 65, 84, 120, 156, 99, 56, 177,
749 69, 3, 0, 3, 236, 1, 165, 147, 223, 177, 106, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96,
750 130,
751 ]
752 }
753 }
@@ -0,0 +1,753 @@
1//! Read glTF/GLB characters and OBJ+MTL equipment into a CPU description.
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.
6
7use std::fs;
8use std::path::Path;
9
10use serde_json::Value;
11
12use crate::project::portable_relative;
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.";
15
16const MAX_BYTES: u64 = 32 * 1024 * 1024;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ModelFormat {
20 Gltf,
21 Obj,
22 Fbx,
23}
24
25#[derive(Debug, Clone, PartialEq)]
26pub struct ImportedModel {
27 pub format: ModelFormat,
28 pub meshes: Vec<MeshRecord>,
29 pub materials: Vec<MaterialRecord>,
30 pub textures: Vec<TextureRecord>,
31 pub joints: Vec<JointRecord>,
32 pub clips: Vec<ClipRecord>,
33 pub notices: Vec<String>,
34}
35
36#[derive(Debug, Clone, PartialEq)]
37pub struct MeshRecord {
38 pub name: String,
39 pub vertex_count: usize,
40 pub triangle_count: usize,
41 pub has_normals: bool,
42 pub has_uvs: bool,
43 pub has_skin: bool,
44 pub material_name: Option<String>,
45}
46
47#[derive(Debug, Clone, PartialEq)]
48pub struct MaterialRecord {
49 pub name: String,
50 pub base_color: [f32; 4],
51 pub base_color_texture: Option<String>,
52 pub normal_texture: Option<String>,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct TextureRecord {
57 pub name: String,
58 pub source: String,
59 pub width: u32,
60 pub height: u32,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct JointRecord {
65 pub name: String,
66 pub parent: Option<String>,
67}
68
69#[derive(Debug, Clone, PartialEq)]
70pub struct ClipRecord {
71 pub name: String,
72 pub duration_seconds: f32,
73 pub channel_count: usize,
74 pub targets: Vec<String>,
75}
76
77#[derive(Debug)]
78pub enum ImportError {
79 Missing(String),
80 Unsupported(String),
81 UnsafePath(String),
82 Parse { path: String, detail: String },
83}
84
85impl std::fmt::Display for ImportError {
86 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 match self {
88 Self::Missing(path) => write!(formatter, "het modelbestand ontbreekt: {path}"),
89 Self::Unsupported(message) => write!(formatter, "{message}"),
90 Self::UnsafePath(message) => write!(formatter, "{message}"),
91 Self::Parse { path, detail } => {
92 write!(formatter, "importeren van {path} is mislukt: {detail}")
93 }
94 }
95 }
96}
97
98impl std::error::Error for ImportError {}
99
100pub fn import_project_model(
101 project_root: &Path,
102 relative: &str,
103) -> Result<ImportedModel, ImportError> {
104 let relative = portable_relative(relative).map_err(ImportError::UnsafePath)?;
105 let full = project_root.join(&relative);
106 if !full.is_file() {
107 return Err(ImportError::Missing(relative));
108 }
109 ensure_inside(project_root, &full, &relative)?;
110 let length = fs::metadata(&full)
111 .map_err(|err| ImportError::Parse {
112 path: relative.clone(),
113 detail: err.to_string(),
114 })?
115 .len();
116 if length > MAX_BYTES {
117 return Err(ImportError::Parse {
118 path: relative,
119 detail: format!("het bestand is groter dan {MAX_BYTES} bytes"),
120 });
121 }
122 let extension = full
123 .extension()
124 .and_then(|ext| ext.to_str())
125 .unwrap_or("")
126 .to_ascii_lowercase();
127 match extension.as_str() {
128 "glb" | "gltf" => import_gltf(project_root, &full, &relative),
129 "obj" => import_obj(project_root, &full, &relative),
130 "fbx" => Err(ImportError::Unsupported(FBX_UNAVAILABLE.to_string())),
131 other => Err(ImportError::Unsupported(format!(
132 "bestandstype '{other}' wordt niet gelezen. Gebruik .glb, .gltf of .obj."
133 ))),
134 }
135}
136
137fn import_gltf(
138 project_root: &Path,
139 full: &Path,
140 relative: &str,
141) -> Result<ImportedModel, ImportError> {
142 let json = gltf_json(full).map_err(|detail| ImportError::Parse {
143 path: relative.to_string(),
144 detail,
145 })?;
146 reject_unsafe_uris(&json)?;
147 let (document, buffers, images) = gltf::import(full).map_err(|err| ImportError::Parse {
148 path: relative.to_string(),
149 detail: err.to_string(),
150 })?;
151 let mut materials = Vec::new();
152 for material in document.materials() {
153 let pbr = material.pbr_metallic_roughness();
154 materials.push(MaterialRecord {
155 name: material.name().unwrap_or("materiaal").to_string(),
156 base_color: pbr.base_color_factor(),
157 base_color_texture: pbr
158 .base_color_texture()
159 .map(|info| texture_label(&info.texture())),
160 normal_texture: material
161 .normal_texture()
162 .map(|info| texture_label(&info.texture())),
163 });
164 }
165 let material_names: Vec<Option<String>> = document
166 .materials()
167 .map(|material| material.name().map(str::to_string))
168 .collect();
169 let mut meshes = Vec::new();
170 for mesh in document.meshes() {
171 let mut vertex_count = 0;
172 let mut triangle_count = 0;
173 let mut has_normals = false;
174 let mut has_uvs = false;
175 let mut has_skin = false;
176 let mut material_name = None;
177 for primitive in mesh.primitives() {
178 let reader = primitive.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
179 let positions: Vec<[f32; 3]> = reader
180 .read_positions()
181 .map(|iter| iter.collect())
182 .unwrap_or_default();
183 if positions
184 .iter()
185 .any(|position| position.iter().any(|value| !value.is_finite()))
186 {
187 return Err(ImportError::Parse {
188 path: relative.to_string(),
189 detail: format!(
190 "mesh '{}' heeft een niet-eindige positie",
191 mesh.name().unwrap_or("mesh")
192 ),
193 });
194 }
195 vertex_count += positions.len();
196 if let Some(indices) = reader.read_indices() {
197 let indices: Vec<u32> = indices.into_u32().collect();
198 if !indices.len().is_multiple_of(3) {
199 return Err(ImportError::Parse {
200 path: relative.to_string(),
201 detail: format!(
202 "mesh '{}' heeft geen driehoeken",
203 mesh.name().unwrap_or("mesh")
204 ),
205 });
206 }
207 triangle_count += indices.len() / 3;
208 } else if positions.len().is_multiple_of(3) {
209 triangle_count += positions.len() / 3;
210 }
211 has_normals |= reader.read_normals().is_some();
212 has_uvs |= reader.read_tex_coords(0).is_some();
213 if let Some(weights) = reader.read_weights(0) {
214 has_skin = true;
215 for weight in weights.into_f32() {
216 if weight
217 .iter()
218 .any(|value| !value.is_finite() || *value < 0.0)
219 {
220 return Err(ImportError::Parse {
221 path: relative.to_string(),
222 detail: "een skin-weight is niet eindig of is negatief".to_string(),
223 });
224 }
225 }
226 }
227 if let Some(index) = primitive.material().index() {
228 material_name = material_names.get(index).cloned().flatten();
229 }
230 }
231 meshes.push(MeshRecord {
232 name: mesh.name().unwrap_or("mesh").to_string(),
233 vertex_count,
234 triangle_count,
235 has_normals,
236 has_uvs,
237 has_skin,
238 material_name,
239 });
240 }
241 let mut textures = Vec::new();
242 for (image, data) in document.images().zip(images.iter()) {
243 let source = match image.source() {
244 gltf::image::Source::Uri { uri, .. } => {
245 let path = full.parent().unwrap_or(project_root).join(uri_path(uri));
246 ensure_inside(project_root, &path, uri)?;
247 uri.to_string()
248 }
249 gltf::image::Source::View { .. } => "ingebed".to_string(),
250 };
251 textures.push(TextureRecord {
252 name: image.name().unwrap_or("texture").to_string(),
253 source,
254 width: data.width,
255 height: data.height,
256 });
257 }
258 let parents = parent_index(&document);
259 let mut joints = Vec::new();
260 for skin in document.skins() {
261 let ibm = skin
262 .inverse_bind_matrices()
263 .map(|accessor| accessor.count())
264 .unwrap_or(0);
265 let joint_nodes: Vec<_> = skin.joints().collect();
266 if ibm != 0 && ibm != joint_nodes.len() {
267 return Err(ImportError::Parse {
268 path: relative.to_string(),
269 detail: "het aantal inverse-bindmatrices wijkt af van het aantal joints"
270 .to_string(),
271 });
272 }
273 for joint in joint_nodes {
274 let parent = parents
275 .get(&joint.index())
276 .and_then(|parent| node_name(&document, *parent));
277 joints.push(JointRecord {
278 name: node_name(&document, joint.index())
279 .unwrap_or_else(|| format!("node-{}", joint.index())),
280 parent,
281 });
282 }
283 }
284 let mut clips = Vec::new();
285 let mut notices = Vec::new();
286 for (index, animation) in document.animations().enumerate() {
287 let mut duration = 0.0_f32;
288 let mut channel_count = 0;
289 let mut targets = Vec::new();
290 for channel in animation.channels() {
291 channel_count += 1;
292 if let Some(name) = channel.target().node().name() {
293 if !targets.iter().any(|existing| existing == name) {
294 targets.push(name.to_string());
295 }
296 }
297 let reader = channel.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
298 if let Some(times) = reader.read_inputs() {
299 for time in times {
300 if !time.is_finite() {
301 return Err(ImportError::Parse {
302 path: relative.to_string(),
303 detail: "een animatiesample is niet eindig".to_string(),
304 });
305 }
306 duration = duration.max(time);
307 }
308 }
309 }
310 let name = animation.name().map(str::to_string).unwrap_or_else(|| {
311 notices.push(format!("clip {index} heeft geen naam"));
312 format!("naamloos-{index}")
313 });
314 clips.push(ClipRecord {
315 name,
316 duration_seconds: duration,
317 channel_count,
318 targets,
319 });
320 }
321 if joints.is_empty() {
322 notices.push(
323 "dit glTF-bestand heeft geen skeleton. Bot- en clipfuncties blijven uit tot er skins in het bestand staan."
324 .to_string(),
325 );
326 }
327 let _ = project_root;
328 Ok(ImportedModel {
329 format: ModelFormat::Gltf,
330 meshes,
331 materials,
332 textures,
333 joints,
334 clips,
335 notices,
336 })
337}
338
339fn import_obj(
340 project_root: &Path,
341 full: &Path,
342 relative: &str,
343) -> Result<ImportedModel, ImportError> {
344 let (models, materials) = tobj::load_obj(
345 full,
346 &tobj::LoadOptions {
347 single_index: true,
348 triangulate: true,
349 ignore_points: true,
350 ignore_lines: true,
351 },
352 )
353 .map_err(|err| ImportError::Parse {
354 path: relative.to_string(),
355 detail: err.to_string(),
356 })?;
357 let materials = materials.map_err(|err| ImportError::Parse {
358 path: relative.to_string(),
359 detail: format!("MTL ontbreekt of is onleesbaar: {err}"),
360 })?;
361 let mut meshes = Vec::new();
362 for model in &models {
363 let mesh = &model.mesh;
364 let vertex_count = mesh.positions.len() / 3;
365 if !mesh.positions.len().is_multiple_of(3)
366 || mesh.positions.iter().any(|value| !value.is_finite())
367 {
368 return Err(ImportError::Parse {
369 path: relative.to_string(),
370 detail: format!("mesh '{}' heeft een ongeldige positie", model.name),
371 });
372 }
373 if !mesh.indices.len().is_multiple_of(3) {
374 return Err(ImportError::Parse {
375 path: relative.to_string(),
376 detail: format!("mesh '{}' is niet in driehoeken", model.name),
377 });
378 }
379 let material_name = mesh
380 .material_id
381 .and_then(|index| materials.get(index))
382 .map(|material| material.name.clone());
383 meshes.push(MeshRecord {
384 name: model.name.clone(),
385 vertex_count,
386 triangle_count: mesh.indices.len() / 3,
387 has_normals: !mesh.normals.is_empty(),
388 has_uvs: !mesh.texcoords.is_empty(),
389 has_skin: false,
390 material_name,
391 });
392 }
393 let base = full.parent().unwrap_or(project_root);
394 let mut textures = Vec::new();
395 let mut material_records = Vec::new();
396 for material in &materials {
397 let base_color_texture =
398 checked_texture(project_root, base, material.diffuse_texture.as_deref())?;
399 let normal_texture =
400 checked_texture(project_root, base, material.normal_texture.as_deref())?;
401 if let Some(name) = base_color_texture.clone() {
402 let path = base.join(&name);
403 let (width, height) = png_size(&path).unwrap_or((0, 0));
404 textures.push(TextureRecord {
405 name: name.clone(),
406 source: name,
407 width,
408 height,
409 });
410 }
411 material_records.push(MaterialRecord {
412 name: material.name.clone(),
413 base_color: [
414 material.diffuse.unwrap_or([1.0, 1.0, 1.0])[0],
415 material.diffuse.unwrap_or([1.0, 1.0, 1.0])[1],
416 material.diffuse.unwrap_or([1.0, 1.0, 1.0])[2],
417 material.dissolve.unwrap_or(1.0),
418 ],
419 base_color_texture,
420 normal_texture,
421 });
422 }
423 Ok(ImportedModel {
424 format: ModelFormat::Obj,
425 meshes,
426 materials: material_records,
427 textures,
428 joints: Vec::new(),
429 clips: Vec::new(),
430 notices: vec![
431 "OBJ is een statische mesh voor equipment. Dit formaat heeft geen skeleton en levert geen skeletal animation clips."
432 .to_string(),
433 ],
434 })
435}
436
437fn checked_texture(
438 project_root: &Path,
439 base: &Path,
440 texture: Option<&str>,
441) -> Result<Option<String>, ImportError> {
442 let Some(texture) = texture else {
443 return Ok(None);
444 };
445 if texture.trim().is_empty() {
446 return Ok(None);
447 }
448 reject_relative_escape(texture)?;
449 let path = base.join(texture);
450 if !path.is_file() {
451 return Err(ImportError::Parse {
452 path: texture.to_string(),
453 detail: "de texture uit de MTL ontbreekt".to_string(),
454 });
455 }
456 ensure_inside(project_root, &path, texture)?;
457 Ok(Some(texture.replace('\\', "/")))
458}
459
460fn reject_relative_escape(value: &str) -> Result<(), ImportError> {
461 if value.starts_with('/')
462 || value.contains('\\')
463 || value.split(['/', '\\']).any(|part| part == "..")
464 {
465 return Err(ImportError::UnsafePath(format!(
466 "texturepad '{value}' wijst buiten de projectmap"
467 )));
468 }
469 Ok(())
470}
471
472fn reject_unsafe_uris(document: &Value) -> Result<(), ImportError> {
473 for key in ["buffers", "images"] {
474 let Some(items) = document.get(key).and_then(Value::as_array) else {
475 continue;
476 };
477 for item in items {
478 let Some(uri) = item.get("uri").and_then(Value::as_str) else {
479 continue;
480 };
481 if uri.starts_with("data:") {
482 continue;
483 }
484 let path = uri.split(['?', '#']).next().unwrap_or(uri);
485 if path.contains(':')
486 || path.starts_with('/')
487 || path.contains('\\')
488 || path
489 .split('/')
490 .any(|part| part.is_empty() || part == "." || part == "..")
491 {
492 return Err(ImportError::UnsafePath(format!(
493 "glTF-verwijzing '{uri}' wordt niet gevolgd"
494 )));
495 }
496 }
497 }
498 Ok(())
499}
500
501fn gltf_json(path: &Path) -> Result<Value, String> {
502 let bytes = fs::read(path).map_err(|err| err.to_string())?;
503 if path.extension().and_then(|ext| ext.to_str()) == Some("glb") || bytes.starts_with(b"glTF") {
504 if bytes.len() < 20 || &bytes[0..4] != b"glTF" {
505 return Err("dit is geen GLB".to_string());
506 }
507 let json_len = u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize;
508 let json_type = &bytes[16..20];
509 if json_type != b"JSON" || 20 + json_len > bytes.len() {
510 return Err("de GLB heeft geen JSON-chunk".to_string());
511 }
512 let text = std::str::from_utf8(&bytes[20..20 + json_len]).map_err(|err| err.to_string())?;
513 return serde_json::from_str(text.trim_end()).map_err(|err| err.to_string());
514 }
515 serde_json::from_slice(&bytes).map_err(|err| err.to_string())
516}
517
518fn uri_path(uri: &str) -> &str {
519 uri.split(['?', '#']).next().unwrap_or(uri)
520}
521
522fn texture_label(texture: &gltf::Texture<'_>) -> String {
523 texture
524 .name()
525 .map(str::to_string)
526 .or_else(|| texture.source().name().map(str::to_string))
527 .unwrap_or_else(|| format!("texture-{}", texture.index()))
528}
529
530fn parent_index(document: &gltf::Document) -> std::collections::HashMap<usize, usize> {
531 let mut parents = std::collections::HashMap::new();
532 for node in document.nodes() {
533 for child in node.children() {
534 parents.insert(child.index(), node.index());
535 }
536 }
537 parents
538}
539
540fn node_name(document: &gltf::Document, index: usize) -> Option<String> {
541 document
542 .nodes()
543 .find(|node| node.index() == index)
544 .and_then(|node| node.name().map(str::to_string))
545}
546
547fn ensure_inside(project_root: &Path, path: &Path, label: &str) -> Result<(), ImportError> {
548 let root = project_root
549 .canonicalize()
550 .map_err(|err| ImportError::Parse {
551 path: label.to_string(),
552 detail: err.to_string(),
553 })?;
554 let file = path.canonicalize().map_err(|err| ImportError::Parse {
555 path: label.to_string(),
556 detail: err.to_string(),
557 })?;
558 if file.starts_with(&root) {
559 Ok(())
560 } else {
561 Err(ImportError::UnsafePath(format!(
562 "het pad '{label}' wijst buiten de projectmap"
563 )))
564 }
565}
566
567fn png_size(path: &Path) -> Option<(u32, u32)> {
568 let bytes = fs::read(path).ok()?;
569 if bytes.len() < 24 || &bytes[0..8] != b"\x89PNG\r\n\x1a\n" {
570 return None;
571 }
572 let width = u32::from_be_bytes(bytes[16..20].try_into().ok()?);
573 let height = u32::from_be_bytes(bytes[20..24].try_into().ok()?);
574 Some((width, height))
575}
576
577#[cfg(test)]
578mod tests {
579 use std::path::PathBuf;
580
581 use super::*;
582
583 fn fixture_root() -> PathBuf {
584 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies")
585 }
586
587 fn assert_body(relative: &str, sex: &str) {
588 let model = import_project_model(&fixture_root(), relative).unwrap();
589 assert_eq!(model.format, ModelFormat::Gltf);
590 assert_eq!(model.meshes.len(), 1);
591 assert_eq!(model.meshes[0].vertex_count, 19 * 8);
592 assert_eq!(model.meshes[0].triangle_count, 19 * 12);
593 assert!(model.meshes[0].has_skin);
594 assert_eq!(model.materials.len(), 1);
595 assert!(model.materials[0]
596 .base_color
597 .iter()
598 .all(|value| value.is_finite()));
599 assert_eq!(model.joints.len(), 19);
600 assert!(model
601 .joints
602 .iter()
603 .any(|joint| joint.name == "Hips" && joint.parent.is_none()));
604 assert!(model.joints.iter().any(|joint| {
605 joint.name == "LeftUpperArm" && joint.parent.as_deref() == Some("LeftShoulder")
606 }));
607 let walk = model.clips.iter().find(|clip| clip.name == "walk").unwrap();
608 let attack = model
609 .clips
610 .iter()
611 .find(|clip| clip.name == "attack")
612 .unwrap();
613 assert!((walk.duration_seconds - 1.0).abs() < 1e-3);
614 assert!((attack.duration_seconds - 0.8).abs() < 1e-3);
615 assert!(walk.targets.iter().any(|target| target == "LeftUpperLeg"));
616 assert!(attack
617 .targets
618 .iter()
619 .any(|target| target == "RightUpperArm"));
620 assert!(walk.channel_count >= 4);
621 assert!(model.materials[0].name.contains(sex) || model.meshes[0].name.contains(sex));
622 assert!(model
623 .notices
624 .iter()
625 .all(|notice| !notice.contains("geen skeleton")));
626 }
627
628 #[test]
629 fn test_bodies_expose_mesh_skin_and_both_clips() {
630 assert_body("test-body-male.glb", "male");
631 assert_body("test-body-female.glb", "female");
632 let male = import_project_model(&fixture_root(), "test-body-male.glb").unwrap();
633 let female = import_project_model(&fixture_root(), "test-body-female.glb").unwrap();
634 let male_shoulder = male
635 .joints
636 .iter()
637 .find(|joint| joint.name == "LeftShoulder")
638 .unwrap();
639 let female_shoulder = female
640 .joints
641 .iter()
642 .find(|joint| joint.name == "LeftShoulder")
643 .unwrap();
644 assert_eq!(male_shoulder.parent, female_shoulder.parent);
645 assert_ne!(male.materials[0].base_color, female.materials[0].base_color);
646 }
647
648 #[test]
649 fn missing_gltf_texture_and_escape_are_errors() {
650 let root = std::env::temp_dir().join(format!("paperdoll-gltf-{}", std::process::id()));
651 let _ = fs::remove_dir_all(&root);
652 fs::create_dir_all(&root).unwrap();
653 fs::write(
654 root.join("missing.gltf"),
655 r#"{
656 "asset": {"version": "2.0"},
657 "buffers": [{"uri": "missing.bin", "byteLength": 4}],
658 "images": [{"uri": "missing.png"}]
659 }"#,
660 )
661 .unwrap();
662 let error = import_project_model(&root, "missing.gltf").unwrap_err();
663 let text = error.to_string();
664 assert!(
665 text.contains("missing.bin")
666 || text.contains("missing.png")
667 || text.contains("mislukt"),
668 "{text}"
669 );
670 fs::write(
671 root.join("escape.gltf"),
672 r#"{
673 "asset": {"version": "2.0"},
674 "images": [{"uri": "../secret.png"}]
675 }"#,
676 )
677 .unwrap();
678 let error = import_project_model(&root, "escape.gltf").unwrap_err();
679 assert!(error.to_string().contains("niet gevolgd"), "{error}");
680 let error = import_project_model(&root, "absent.glb").unwrap_err();
681 assert!(error.to_string().contains("ontbreekt"));
682 let _ = fs::remove_dir_all(&root);
683 }
684
685 #[test]
686 fn obj_with_mtl_and_texture_is_static_and_missing_texture_fails() {
687 let root = std::env::temp_dir().join(format!("paperdoll-obj-{}", std::process::id()));
688 let _ = fs::remove_dir_all(&root);
689 fs::create_dir_all(root.join("textures")).unwrap();
690 fs::write(root.join("textures/blade.png"), tiny_png()).unwrap();
691 fs::write(
692 root.join("sword.mtl"),
693 "newmtl steel\nKd 0.7 0.7 0.8\nmap_Kd textures/blade.png\n",
694 )
695 .unwrap();
696 fs::write(
697 root.join("sword.obj"),
698 "mtllib sword.mtl\no blade\nv 0 0 0\nv 1 0 0\nv 0 1 0\nvt 0 0\nvt 1 0\nvt 0 1\nvn 0 0 1\nusemtl steel\nf 1/1/1 2/2/1 3/3/1\n",
699 )
700 .unwrap();
701 let model = import_project_model(&root, "sword.obj").unwrap();
702 assert_eq!(model.format, ModelFormat::Obj);
703 assert_eq!(model.meshes.len(), 1);
704 assert_eq!(model.meshes[0].triangle_count, 1);
705 assert!(model.meshes[0].has_uvs);
706 assert!(model.meshes[0].has_normals);
707 assert!(!model.meshes[0].has_skin);
708 assert!(model.joints.is_empty());
709 assert!(model.clips.is_empty());
710 assert_eq!(model.materials.len(), 1);
711 assert_eq!(model.materials[0].name, "steel");
712 assert_eq!(
713 model.materials[0].base_color_texture.as_deref(),
714 Some("textures/blade.png")
715 );
716 assert_eq!(model.textures[0].width, 1);
717 assert_eq!(model.textures[0].height, 1);
718 let notice = model.notices.join(" ");
719 assert!(notice.contains("geen skeleton"));
720 assert!(notice.contains("geen skeletal animation clips"));
721 fs::remove_file(root.join("textures/blade.png")).unwrap();
722 let error = import_project_model(&root, "sword.obj").unwrap_err();
723 assert!(error.to_string().contains("texture"), "{error}");
724 fs::remove_file(root.join("sword.mtl")).unwrap();
725 let error = import_project_model(&root, "sword.obj").unwrap_err();
726 assert!(error.to_string().contains("MTL"), "{error}");
727 let _ = fs::remove_dir_all(&root);
728 }
729
730 #[test]
731 fn fbx_is_refused_without_pretending_to_parse() {
732 let root = std::env::temp_dir().join(format!("paperdoll-fbx-{}", std::process::id()));
733 let _ = fs::remove_dir_all(&root);
734 fs::create_dir_all(&root).unwrap();
735 fs::write(root.join("hero.fbx"), b"not a real fbx").unwrap();
736 let error = import_project_model(&root, "hero.fbx").unwrap_err();
737 let text = error.to_string();
738 assert!(text.contains("Assimp"));
739 assert!(text.contains("niet geïmporteerd"));
740 assert!(!text.contains("walk"));
741 assert!(!text.contains("geïmporteerd: 0 clips"));
742 let _ = fs::remove_dir_all(&root);
743 }
744
745 fn tiny_png() -> Vec<u8> {
746 vec![
747 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1,
748 8, 2, 0, 0, 0, 144, 119, 83, 222, 0, 0, 0, 12, 73, 68, 65, 84, 120, 156, 99, 56, 177,
749 69, 3, 0, 3, 236, 1, 165, 147, 223, 177, 106, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96,
750 130,
751 ]
752 }
753}
src/lib.rs +3 −2
@@ -1,8 +1,9 @@
1 1 //! Foundation library for the Paperdoll Renderer shell.
2 2 //!
3 //! This crate reports which product capabilities exist. It does not import
4 //! models and it does not render paperdolls.
3 //! The shell stores a `.ppd` project and reads glTF/GLB plus OBJ+MTL.
4 //! It does not render paperdolls and it does not play skeletal animation.
5 5
6 6 pub mod editor;
7 7 pub mod foundation;
8 pub mod import;
8 9 pub mod project;
@@ -1,8 +1,9 @@
1//! Foundation library for the Paperdoll Renderer shell.
1//! Foundation library for the Paperdoll Renderer shell.
2//!
2//!
3//! This crate reports which product capabilities exist. It does not import
3//! The shell stores a `.ppd` project and reads glTF/GLB plus OBJ+MTL.
4//! models and it does not render paperdolls.
4//! It does not render paperdolls and it does not play skeletal animation.
5
5
6pub mod editor;
6pub mod editor;
7pub mod foundation;
7pub mod foundation;
8pub mod import;
8pub mod project;
9pub mod project;