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>
@@ -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"121
source = "registry+https://github.com/rust-lang/crates.io-index"121
source = "registry+https://github.com/rust-lang/crates.io-index"122
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"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
[[package]]130
[[package]]125
name = "bit-set"131
name = "bit-set"126
version = "0.6.0"132
version = "0.6.0"@@ -189,6 +195,12 @@ dependencies = [189
"syn 3.0.9",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
[[package]]204
[[package]]193
name = "byteorder-lite"205
name = "byteorder-lite"194
version = "0.1.0"206
version = "0.1.0"@@ -802,6 +814,45 @@ dependencies = [802
"web-sys",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
[[package]]856
[[package]]806
name = "glutin"857
name = "glutin"807
version = "0.32.3"858
version = "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]]1146
name = "inflections"1147
version = "1.1.1"1148
source = "registry+https://github.com/rust-lang/crates.io-index"1149
checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a"1150
1092
[[package]]1151
[[package]]1093
name = "itoa"1152
name = "itoa"1094
version = "1.0.18"1153
version = "1.0.18"@@ -1191,6 +1250,12 @@ version = "3.1.0"1191
source = "registry+https://github.com/rust-lang/crates.io-index"1250
source = "registry+https://github.com/rust-lang/crates.io-index"1192
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"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
[[package]]1259
[[package]]1195
name = "libc"1260
name = "libc"1196
version = "0.2.191"1261
version = "0.2.191"@@ -1743,9 +1808,11 @@ name = "paperdoll-renderer"1743
version = "0.1.0"1808
version = "0.1.0"1744
dependencies = [1809
dependencies = [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]]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
[[package]]2409
[[package]]2334
name = "tokio"2410
name = "tokio"2335
version = "1.54.1"2411
version = "1.54.1"@@ -2437,6 +2513,12 @@ dependencies = [2437
"serde",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
[[package]]2522
[[package]]2441
name = "utf8_iter"2523
name = "utf8_iter"2442
version = "1.1.0"2524
version = "1.1.0"@@ -3200,3 +3282,18 @@ name = "zmij"3200
version = "1.0.23"3282
version = "1.0.23"3201
source = "registry+https://github.com/rust-lang/crates.io-index"3283
source = "registry+https://github.com/rust-lang/crates.io-index"3202
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"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
]@@ -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 rendering8
8
9
[dependencies]9
[dependencies]10
eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }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
libloading = "0.8"12
libloading = "0.8"12
serde = { version = "1", features = ["derive"] }13
serde = { version = "1", features = ["derive"] }13
serde_json = "1"14
serde_json = "1"15
tobj = "4.0.3"@@ -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 Renderer1
# Paperdoll Renderer2
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
## Eisen5
## Eisen6
6
@@ -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 f61
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 status65
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
| 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 |@@ -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
5
use eframe::egui;5
use eframe::egui;6
6
7
use crate::import::{import_project_model, ImportedModel, ModelFormat};7
use crate::project::{8
use 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
opened219
.manifest_path220
.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
impl eframe::App for EditorApp {268
impl eframe::App for EditorApp {@@ -242,7 +308,9 @@ impl eframe::App for EditorApp {242
impl EditorApp {308
impl 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
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
material503
.base_color_texture504
.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.height513
));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_count533
));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
fn parent_relative(relative: &str) -> String {547
fn 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(),@@ -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
56
pub fn availability(capability: Capability) -> Availability {56
pub 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::ObjMtlTextures59
Availability::Present60
| Capability::Fbx60
}61
| Capability::GltfGlb61
Capability::Fbx62
| Capability::SkeletalAnimation62
| Capability::SkeletalAnimation63
| Capability::AttachmentEditor63
| Capability::AttachmentEditor64
| Capability::PaperdollRender64
| Capability::PaperdollRender@@ -107,14 +107,14 @@ pub fn dutch_state(availability: Availability) -> &'static str {107
}107
}108
108
109
pub fn dutch_summary() -> &'static str {109
pub fn dutch_summary() -> &'static str {110
"Deze build 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.114
pub fn report() -> String {114
pub fn report() -> String {115
let mut lines = Vec::with_capacity(2 + CAPABILITIES.len());115
let mut lines = Vec::with_capacity(2 + CAPABILITIES.len());116
lines.push("name: Paperdoll Renderer".to_string());116
lines.push("name: Paperdoll Renderer".to_string());117
lines.push("phase: 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
159
pub fn help_text() -> &'static str {159
pub fn help_text() -> &'static str {160
"\160
"\161
Paperdoll Renderer — projectbestanden161
Paperdoll Renderer — modelimport162
162
163
Gebruik:163
Gebruik:164
paperdoll-renderer open het projectvenster164
paperdoll-renderer open het projectvenster165
paperdoll-renderer --status print de status en sluit165
paperdoll-renderer --status print de status en sluit166
paperdoll-renderer --help toon deze tekst166
paperdoll-renderer --help toon deze tekst167
167
168
Een modelpad op de opdrachtregel wordt niet geaccepteerd. 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
pub fn parse_cli<'a, I>(args: I) -> Result<CliRequest, CliError>171
pub 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::Present200
Availability::Present201
);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::Present206
);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]@@ -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 does4
//! 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 = full123
.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: pbr158
.base_color_texture()159
.map(|info| texture_label(&info.texture())),160
normal_texture: material161
.normal_texture()162
.map(|info| texture_label(&info.texture())),163
});164
}165
let material_names: Vec<Option<String>> = document166
.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]> = reader180
.read_positions()181
.map(|iter| iter.collect())182
.unwrap_or_default();183
if positions184
.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 weight217
.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 = skin262
.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 = parents275
.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 = mesh380
.material_id381
.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
|| path489
.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
texture524
.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
parents538
}539
540
fn node_name(document: &gltf::Document, index: usize) -> Option<String> {541
document542
.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_root549
.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_color597
.iter()598
.all(|value| value.is_finite()));599
assert_eq!(model.joints.len(), 19);600
assert!(model601
.joints602
.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 = model609
.clips610
.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!(attack617
.targets618
.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!(model623
.notices624
.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 = male635
.joints636
.iter()637
.find(|joint| joint.name == "LeftShoulder")638
.unwrap();639
let female_shoulder = female640
.joints641
.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
}@@ -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 import3
//! 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
6
pub mod editor;6
pub mod editor;7
pub mod foundation;7
pub mod foundation;8
pub mod import;8
pub mod project;9
pub mod project;