BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / import.rs

import.rs

import.rs 26.3 KiB Download Raw
1//! Read glTF/GLB characters and OBJ+MTL equipment into a CPU description.
2//!
3//! The description is for inspection and a later renderer. This module does
4//! not draw a viewport and does not sample skeletal animation. FBX is refused:
5//! Assimp is not linked in this build.
6
7use std::fs;
8use std::path::Path;
9
10use serde_json::Value;
11
12use crate::project::portable_relative;
13
14pub const FBX_UNAVAILABLE: &str = "FBX wordt in deze build niet geïmporteerd. Assimp is niet gekoppeld: de native bibliotheek ontbreekt en is niet op Linux en Windows bewezen. Gebruik glTF/GLB voor een lichaam met clips, of OBJ+MTL voor een statische equipment-mesh.";
15
16const MAX_BYTES: u64 = 32 * 1024 * 1024;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ModelFormat {
20 Gltf,
21 Obj,
22 Fbx,
23}
24
25#[derive(Debug, Clone, PartialEq)]
26pub struct ImportedModel {
27 pub format: ModelFormat,
28 pub meshes: Vec<MeshRecord>,
29 pub materials: Vec<MaterialRecord>,
30 pub textures: Vec<TextureRecord>,
31 pub joints: Vec<JointRecord>,
32 pub clips: Vec<ClipRecord>,
33 pub notices: Vec<String>,
34}
35
36#[derive(Debug, Clone, PartialEq)]
37pub struct MeshRecord {
38 pub name: String,
39 pub vertex_count: usize,
40 pub triangle_count: usize,
41 pub has_normals: bool,
42 pub has_uvs: bool,
43 pub has_skin: bool,
44 pub material_name: Option<String>,
45}
46
47#[derive(Debug, Clone, PartialEq)]
48pub struct MaterialRecord {
49 pub name: String,
50 pub base_color: [f32; 4],
51 pub base_color_texture: Option<String>,
52 pub normal_texture: Option<String>,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct TextureRecord {
57 pub name: String,
58 pub source: String,
59 pub width: u32,
60 pub height: u32,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct JointRecord {
65 pub name: String,
66 pub parent: Option<String>,
67}
68
69#[derive(Debug, Clone, PartialEq)]
70pub struct ClipRecord {
71 pub name: String,
72 pub duration_seconds: f32,
73 pub channel_count: usize,
74 pub targets: Vec<String>,
75}
76
77#[derive(Debug)]
78pub enum ImportError {
79 Missing(String),
80 Unsupported(String),
81 UnsafePath(String),
82 Parse { path: String, detail: String },
83}
84
85impl std::fmt::Display for ImportError {
86 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 match self {
88 Self::Missing(path) => write!(formatter, "het modelbestand ontbreekt: {path}"),
89 Self::Unsupported(message) => write!(formatter, "{message}"),
90 Self::UnsafePath(message) => write!(formatter, "{message}"),
91 Self::Parse { path, detail } => {
92 write!(formatter, "importeren van {path} is mislukt: {detail}")
93 }
94 }
95 }
96}
97
98impl std::error::Error for ImportError {}
99
100pub fn import_project_model(
101 project_root: &Path,
102 relative: &str,
103) -> Result<ImportedModel, ImportError> {
104 let relative = portable_relative(relative).map_err(ImportError::UnsafePath)?;
105 let full = project_root.join(&relative);
106 if !full.is_file() {
107 return Err(ImportError::Missing(relative));
108 }
109 ensure_inside(project_root, &full, &relative)?;
110 let length = fs::metadata(&full)
111 .map_err(|err| ImportError::Parse {
112 path: relative.clone(),
113 detail: err.to_string(),
114 })?
115 .len();
116 if length > MAX_BYTES {
117 return Err(ImportError::Parse {
118 path: relative,
119 detail: format!("het bestand is groter dan {MAX_BYTES} bytes"),
120 });
121 }
122 let extension = full
123 .extension()
124 .and_then(|ext| ext.to_str())
125 .unwrap_or("")
126 .to_ascii_lowercase();
127 match extension.as_str() {
128 "glb" | "gltf" => import_gltf(project_root, &full, &relative),
129 "obj" => import_obj(project_root, &full, &relative),
130 "fbx" => Err(ImportError::Unsupported(FBX_UNAVAILABLE.to_string())),
131 other => Err(ImportError::Unsupported(format!(
132 "bestandstype '{other}' wordt niet gelezen. Gebruik .glb, .gltf of .obj."
133 ))),
134 }
135}
136
137fn import_gltf(
138 project_root: &Path,
139 full: &Path,
140 relative: &str,
141) -> Result<ImportedModel, ImportError> {
142 let json = gltf_json(full).map_err(|detail| ImportError::Parse {
143 path: relative.to_string(),
144 detail,
145 })?;
146 reject_unsafe_uris(&json)?;
147 let (document, buffers, images) = gltf::import(full).map_err(|err| ImportError::Parse {
148 path: relative.to_string(),
149 detail: err.to_string(),
150 })?;
151 let mut materials = Vec::new();
152 for material in document.materials() {
153 let pbr = material.pbr_metallic_roughness();
154 materials.push(MaterialRecord {
155 name: material.name().unwrap_or("materiaal").to_string(),
156 base_color: pbr.base_color_factor(),
157 base_color_texture: pbr
158 .base_color_texture()
159 .map(|info| texture_label(&info.texture())),
160 normal_texture: material
161 .normal_texture()
162 .map(|info| texture_label(&info.texture())),
163 });
164 }
165 let material_names: Vec<Option<String>> = document
166 .materials()
167 .map(|material| material.name().map(str::to_string))
168 .collect();
169 let mut meshes = Vec::new();
170 for mesh in document.meshes() {
171 let mut vertex_count = 0;
172 let mut triangle_count = 0;
173 let mut has_normals = false;
174 let mut has_uvs = false;
175 let mut has_skin = false;
176 let mut material_name = None;
177 for primitive in mesh.primitives() {
178 let reader = primitive.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
179 let positions: Vec<[f32; 3]> = reader
180 .read_positions()
181 .map(|iter| iter.collect())
182 .unwrap_or_default();
183 if positions
184 .iter()
185 .any(|position| position.iter().any(|value| !value.is_finite()))
186 {
187 return Err(ImportError::Parse {
188 path: relative.to_string(),
189 detail: format!(
190 "mesh '{}' heeft een niet-eindige positie",
191 mesh.name().unwrap_or("mesh")
192 ),
193 });
194 }
195 vertex_count += positions.len();
196 if let Some(indices) = reader.read_indices() {
197 let indices: Vec<u32> = indices.into_u32().collect();
198 if !indices.len().is_multiple_of(3) {
199 return Err(ImportError::Parse {
200 path: relative.to_string(),
201 detail: format!(
202 "mesh '{}' heeft geen driehoeken",
203 mesh.name().unwrap_or("mesh")
204 ),
205 });
206 }
207 triangle_count += indices.len() / 3;
208 } else if positions.len().is_multiple_of(3) {
209 triangle_count += positions.len() / 3;
210 }
211 has_normals |= reader.read_normals().is_some();
212 has_uvs |= reader.read_tex_coords(0).is_some();
213 if let Some(weights) = reader.read_weights(0) {
214 has_skin = true;
215 for weight in weights.into_f32() {
216 if weight
217 .iter()
218 .any(|value| !value.is_finite() || *value < 0.0)
219 {
220 return Err(ImportError::Parse {
221 path: relative.to_string(),
222 detail: "een skin-weight is niet eindig of is negatief".to_string(),
223 });
224 }
225 }
226 }
227 if let Some(index) = primitive.material().index() {
228 material_name = material_names.get(index).cloned().flatten();
229 }
230 }
231 meshes.push(MeshRecord {
232 name: mesh.name().unwrap_or("mesh").to_string(),
233 vertex_count,
234 triangle_count,
235 has_normals,
236 has_uvs,
237 has_skin,
238 material_name,
239 });
240 }
241 let mut textures = Vec::new();
242 for (image, data) in document.images().zip(images.iter()) {
243 let source = match image.source() {
244 gltf::image::Source::Uri { uri, .. } => {
245 let path = full.parent().unwrap_or(project_root).join(uri_path(uri));
246 ensure_inside(project_root, &path, uri)?;
247 uri.to_string()
248 }
249 gltf::image::Source::View { .. } => "ingebed".to_string(),
250 };
251 textures.push(TextureRecord {
252 name: image.name().unwrap_or("texture").to_string(),
253 source,
254 width: data.width,
255 height: data.height,
256 });
257 }
258 let parents = parent_index(&document);
259 let mut joints = Vec::new();
260 for skin in document.skins() {
261 let ibm = skin
262 .inverse_bind_matrices()
263 .map(|accessor| accessor.count())
264 .unwrap_or(0);
265 let joint_nodes: Vec<_> = skin.joints().collect();
266 if ibm != 0 && ibm != joint_nodes.len() {
267 return Err(ImportError::Parse {
268 path: relative.to_string(),
269 detail: "het aantal inverse-bindmatrices wijkt af van het aantal joints"
270 .to_string(),
271 });
272 }
273 for joint in joint_nodes {
274 let parent = parents
275 .get(&joint.index())
276 .and_then(|parent| node_name(&document, *parent));
277 joints.push(JointRecord {
278 name: node_name(&document, joint.index())
279 .unwrap_or_else(|| format!("node-{}", joint.index())),
280 parent,
281 });
282 }
283 }
284 let mut clips = Vec::new();
285 let mut notices = Vec::new();
286 for (index, animation) in document.animations().enumerate() {
287 let mut duration = 0.0_f32;
288 let mut channel_count = 0;
289 let mut targets = Vec::new();
290 for channel in animation.channels() {
291 channel_count += 1;
292 if let Some(name) = channel.target().node().name() {
293 if !targets.iter().any(|existing| existing == name) {
294 targets.push(name.to_string());
295 }
296 }
297 let reader = channel.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
298 if let Some(times) = reader.read_inputs() {
299 for time in times {
300 if !time.is_finite() {
301 return Err(ImportError::Parse {
302 path: relative.to_string(),
303 detail: "een animatiesample is niet eindig".to_string(),
304 });
305 }
306 duration = duration.max(time);
307 }
308 }
309 }
310 let name = animation.name().map(str::to_string).unwrap_or_else(|| {
311 notices.push(format!("clip {index} heeft geen naam"));
312 format!("naamloos-{index}")
313 });
314 clips.push(ClipRecord {
315 name,
316 duration_seconds: duration,
317 channel_count,
318 targets,
319 });
320 }
321 if joints.is_empty() {
322 notices.push(
323 "dit glTF-bestand heeft geen skeleton. Bot- en clipfuncties blijven uit tot er skins in het bestand staan."
324 .to_string(),
325 );
326 }
327 let _ = project_root;
328 Ok(ImportedModel {
329 format: ModelFormat::Gltf,
330 meshes,
331 materials,
332 textures,
333 joints,
334 clips,
335 notices,
336 })
337}
338
339fn import_obj(
340 project_root: &Path,
341 full: &Path,
342 relative: &str,
343) -> Result<ImportedModel, ImportError> {
344 let (models, materials) = tobj::load_obj(
345 full,
346 &tobj::LoadOptions {
347 single_index: true,
348 triangulate: true,
349 ignore_points: true,
350 ignore_lines: true,
351 },
352 )
353 .map_err(|err| ImportError::Parse {
354 path: relative.to_string(),
355 detail: err.to_string(),
356 })?;
357 let materials = materials.map_err(|err| ImportError::Parse {
358 path: relative.to_string(),
359 detail: format!("MTL ontbreekt of is onleesbaar: {err}"),
360 })?;
361 let mut meshes = Vec::new();
362 for model in &models {
363 let mesh = &model.mesh;
364 let vertex_count = mesh.positions.len() / 3;
365 if !mesh.positions.len().is_multiple_of(3)
366 || mesh.positions.iter().any(|value| !value.is_finite())
367 {
368 return Err(ImportError::Parse {
369 path: relative.to_string(),
370 detail: format!("mesh '{}' heeft een ongeldige positie", model.name),
371 });
372 }
373 if !mesh.indices.len().is_multiple_of(3) {
374 return Err(ImportError::Parse {
375 path: relative.to_string(),
376 detail: format!("mesh '{}' is niet in driehoeken", model.name),
377 });
378 }
379 let material_name = mesh
380 .material_id
381 .and_then(|index| materials.get(index))
382 .map(|material| material.name.clone());
383 meshes.push(MeshRecord {
384 name: model.name.clone(),
385 vertex_count,
386 triangle_count: mesh.indices.len() / 3,
387 has_normals: !mesh.normals.is_empty(),
388 has_uvs: !mesh.texcoords.is_empty(),
389 has_skin: false,
390 material_name,
391 });
392 }
393 let base = full.parent().unwrap_or(project_root);
394 let mut textures = Vec::new();
395 let mut material_records = Vec::new();
396 for material in &materials {
397 let base_color_texture =
398 checked_texture(project_root, base, material.diffuse_texture.as_deref())?;
399 let normal_texture =
400 checked_texture(project_root, base, material.normal_texture.as_deref())?;
401 if let Some(name) = base_color_texture.clone() {
402 let path = base.join(&name);
403 let (width, height) = png_size(&path).unwrap_or((0, 0));
404 textures.push(TextureRecord {
405 name: name.clone(),
406 source: name,
407 width,
408 height,
409 });
410 }
411 material_records.push(MaterialRecord {
412 name: material.name.clone(),
413 base_color: [
414 material.diffuse.unwrap_or([1.0, 1.0, 1.0])[0],
415 material.diffuse.unwrap_or([1.0, 1.0, 1.0])[1],
416 material.diffuse.unwrap_or([1.0, 1.0, 1.0])[2],
417 material.dissolve.unwrap_or(1.0),
418 ],
419 base_color_texture,
420 normal_texture,
421 });
422 }
423 Ok(ImportedModel {
424 format: ModelFormat::Obj,
425 meshes,
426 materials: material_records,
427 textures,
428 joints: Vec::new(),
429 clips: Vec::new(),
430 notices: vec![
431 "OBJ is een statische mesh voor equipment. Dit formaat heeft geen skeleton en levert geen skeletal animation clips."
432 .to_string(),
433 ],
434 })
435}
436
437fn checked_texture(
438 project_root: &Path,
439 base: &Path,
440 texture: Option<&str>,
441) -> Result<Option<String>, ImportError> {
442 let Some(texture) = texture else {
443 return Ok(None);
444 };
445 if texture.trim().is_empty() {
446 return Ok(None);
447 }
448 reject_relative_escape(texture)?;
449 let path = base.join(texture);
450 if !path.is_file() {
451 return Err(ImportError::Parse {
452 path: texture.to_string(),
453 detail: "de texture uit de MTL ontbreekt".to_string(),
454 });
455 }
456 ensure_inside(project_root, &path, texture)?;
457 Ok(Some(texture.replace('\\', "/")))
458}
459
460fn reject_relative_escape(value: &str) -> Result<(), ImportError> {
461 if value.starts_with('/')
462 || value.contains('\\')
463 || value.split(['/', '\\']).any(|part| part == "..")
464 {
465 return Err(ImportError::UnsafePath(format!(
466 "texturepad '{value}' wijst buiten de projectmap"
467 )));
468 }
469 Ok(())
470}
471
472fn reject_unsafe_uris(document: &Value) -> Result<(), ImportError> {
473 for key in ["buffers", "images"] {
474 let Some(items) = document.get(key).and_then(Value::as_array) else {
475 continue;
476 };
477 for item in items {
478 let Some(uri) = item.get("uri").and_then(Value::as_str) else {
479 continue;
480 };
481 if uri.starts_with("data:") {
482 continue;
483 }
484 let path = uri.split(['?', '#']).next().unwrap_or(uri);
485 if path.contains(':')
486 || path.starts_with('/')
487 || path.contains('\\')
488 || path
489 .split('/')
490 .any(|part| part.is_empty() || part == "." || part == "..")
491 {
492 return Err(ImportError::UnsafePath(format!(
493 "glTF-verwijzing '{uri}' wordt niet gevolgd"
494 )));
495 }
496 }
497 }
498 Ok(())
499}
500
501fn gltf_json(path: &Path) -> Result<Value, String> {
502 let bytes = fs::read(path).map_err(|err| err.to_string())?;
503 if path.extension().and_then(|ext| ext.to_str()) == Some("glb") || bytes.starts_with(b"glTF") {
504 if bytes.len() < 20 || &bytes[0..4] != b"glTF" {
505 return Err("dit is geen GLB".to_string());
506 }
507 let json_len = u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize;
508 let json_type = &bytes[16..20];
509 if json_type != b"JSON" || 20 + json_len > bytes.len() {
510 return Err("de GLB heeft geen JSON-chunk".to_string());
511 }
512 let text = std::str::from_utf8(&bytes[20..20 + json_len]).map_err(|err| err.to_string())?;
513 return serde_json::from_str(text.trim_end()).map_err(|err| err.to_string());
514 }
515 serde_json::from_slice(&bytes).map_err(|err| err.to_string())
516}
517
518fn uri_path(uri: &str) -> &str {
519 uri.split(['?', '#']).next().unwrap_or(uri)
520}
521
522fn texture_label(texture: &gltf::Texture<'_>) -> String {
523 texture
524 .name()
525 .map(str::to_string)
526 .or_else(|| texture.source().name().map(str::to_string))
527 .unwrap_or_else(|| format!("texture-{}", texture.index()))
528}
529
530fn parent_index(document: &gltf::Document) -> std::collections::HashMap<usize, usize> {
531 let mut parents = std::collections::HashMap::new();
532 for node in document.nodes() {
533 for child in node.children() {
534 parents.insert(child.index(), node.index());
535 }
536 }
537 parents
538}
539
540fn node_name(document: &gltf::Document, index: usize) -> Option<String> {
541 document
542 .nodes()
543 .find(|node| node.index() == index)
544 .and_then(|node| node.name().map(str::to_string))
545}
546
547fn ensure_inside(project_root: &Path, path: &Path, label: &str) -> Result<(), ImportError> {
548 let root = project_root
549 .canonicalize()
550 .map_err(|err| ImportError::Parse {
551 path: label.to_string(),
552 detail: err.to_string(),
553 })?;
554 let file = path.canonicalize().map_err(|err| ImportError::Parse {
555 path: label.to_string(),
556 detail: err.to_string(),
557 })?;
558 if file.starts_with(&root) {
559 Ok(())
560 } else {
561 Err(ImportError::UnsafePath(format!(
562 "het pad '{label}' wijst buiten de projectmap"
563 )))
564 }
565}
566
567fn png_size(path: &Path) -> Option<(u32, u32)> {
568 let bytes = fs::read(path).ok()?;
569 if bytes.len() < 24 || &bytes[0..8] != b"\x89PNG\r\n\x1a\n" {
570 return None;
571 }
572 let width = u32::from_be_bytes(bytes[16..20].try_into().ok()?);
573 let height = u32::from_be_bytes(bytes[20..24].try_into().ok()?);
574 Some((width, height))
575}
576
577#[cfg(test)]
578mod tests {
579 use std::path::PathBuf;
580
581 use super::*;
582
583 fn fixture_root() -> PathBuf {
584 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies")
585 }
586
587 fn assert_body(relative: &str, sex: &str) {
588 let model = import_project_model(&fixture_root(), relative).unwrap();
589 assert_eq!(model.format, ModelFormat::Gltf);
590 assert_eq!(model.meshes.len(), 1);
591 assert_eq!(model.meshes[0].vertex_count, 19 * 8);
592 assert_eq!(model.meshes[0].triangle_count, 19 * 12);
593 assert!(model.meshes[0].has_skin);
594 assert_eq!(model.materials.len(), 1);
595 assert!(model.materials[0]
596 .base_color
597 .iter()
598 .all(|value| value.is_finite()));
599 assert_eq!(model.joints.len(), 19);
600 assert!(model
601 .joints
602 .iter()
603 .any(|joint| joint.name == "Hips" && joint.parent.is_none()));
604 assert!(model.joints.iter().any(|joint| {
605 joint.name == "LeftUpperArm" && joint.parent.as_deref() == Some("LeftShoulder")
606 }));
607 let walk = model.clips.iter().find(|clip| clip.name == "walk").unwrap();
608 let attack = model
609 .clips
610 .iter()
611 .find(|clip| clip.name == "attack")
612 .unwrap();
613 assert!((walk.duration_seconds - 1.0).abs() < 1e-3);
614 assert!((attack.duration_seconds - 0.8).abs() < 1e-3);
615 assert!(walk.targets.iter().any(|target| target == "LeftUpperLeg"));
616 assert!(attack
617 .targets
618 .iter()
619 .any(|target| target == "RightUpperArm"));
620 assert!(walk.channel_count >= 4);
621 assert!(model.materials[0].name.contains(sex) || model.meshes[0].name.contains(sex));
622 assert!(model
623 .notices
624 .iter()
625 .all(|notice| !notice.contains("geen skeleton")));
626 }
627
628 #[test]
629 fn test_bodies_expose_mesh_skin_and_both_clips() {
630 assert_body("test-body-male.glb", "male");
631 assert_body("test-body-female.glb", "female");
632 let male = import_project_model(&fixture_root(), "test-body-male.glb").unwrap();
633 let female = import_project_model(&fixture_root(), "test-body-female.glb").unwrap();
634 let male_shoulder = male
635 .joints
636 .iter()
637 .find(|joint| joint.name == "LeftShoulder")
638 .unwrap();
639 let female_shoulder = female
640 .joints
641 .iter()
642 .find(|joint| joint.name == "LeftShoulder")
643 .unwrap();
644 assert_eq!(male_shoulder.parent, female_shoulder.parent);
645 assert_ne!(male.materials[0].base_color, female.materials[0].base_color);
646 }
647
648 #[test]
649 fn missing_gltf_texture_and_escape_are_errors() {
650 let root = std::env::temp_dir().join(format!("paperdoll-gltf-{}", std::process::id()));
651 let _ = fs::remove_dir_all(&root);
652 fs::create_dir_all(&root).unwrap();
653 fs::write(
654 root.join("missing.gltf"),
655 r#"{
656 "asset": {"version": "2.0"},
657 "buffers": [{"uri": "missing.bin", "byteLength": 4}],
658 "images": [{"uri": "missing.png"}]
659 }"#,
660 )
661 .unwrap();
662 let error = import_project_model(&root, "missing.gltf").unwrap_err();
663 let text = error.to_string();
664 assert!(
665 text.contains("missing.bin")
666 || text.contains("missing.png")
667 || text.contains("mislukt"),
668 "{text}"
669 );
670 fs::write(
671 root.join("escape.gltf"),
672 r#"{
673 "asset": {"version": "2.0"},
674 "images": [{"uri": "../secret.png"}]
675 }"#,
676 )
677 .unwrap();
678 let error = import_project_model(&root, "escape.gltf").unwrap_err();
679 assert!(error.to_string().contains("niet gevolgd"), "{error}");
680 let error = import_project_model(&root, "absent.glb").unwrap_err();
681 assert!(error.to_string().contains("ontbreekt"));
682 let _ = fs::remove_dir_all(&root);
683 }
684
685 #[test]
686 fn obj_with_mtl_and_texture_is_static_and_missing_texture_fails() {
687 let root = std::env::temp_dir().join(format!("paperdoll-obj-{}", std::process::id()));
688 let _ = fs::remove_dir_all(&root);
689 fs::create_dir_all(root.join("textures")).unwrap();
690 fs::write(root.join("textures/blade.png"), tiny_png()).unwrap();
691 fs::write(
692 root.join("sword.mtl"),
693 "newmtl steel\nKd 0.7 0.7 0.8\nmap_Kd textures/blade.png\n",
694 )
695 .unwrap();
696 fs::write(
697 root.join("sword.obj"),
698 "mtllib sword.mtl\no blade\nv 0 0 0\nv 1 0 0\nv 0 1 0\nvt 0 0\nvt 1 0\nvt 0 1\nvn 0 0 1\nusemtl steel\nf 1/1/1 2/2/1 3/3/1\n",
699 )
700 .unwrap();
701 let model = import_project_model(&root, "sword.obj").unwrap();
702 assert_eq!(model.format, ModelFormat::Obj);
703 assert_eq!(model.meshes.len(), 1);
704 assert_eq!(model.meshes[0].triangle_count, 1);
705 assert!(model.meshes[0].has_uvs);
706 assert!(model.meshes[0].has_normals);
707 assert!(!model.meshes[0].has_skin);
708 assert!(model.joints.is_empty());
709 assert!(model.clips.is_empty());
710 assert_eq!(model.materials.len(), 1);
711 assert_eq!(model.materials[0].name, "steel");
712 assert_eq!(
713 model.materials[0].base_color_texture.as_deref(),
714 Some("textures/blade.png")
715 );
716 assert_eq!(model.textures[0].width, 1);
717 assert_eq!(model.textures[0].height, 1);
718 let notice = model.notices.join(" ");
719 assert!(notice.contains("geen skeleton"));
720 assert!(notice.contains("geen skeletal animation clips"));
721 fs::remove_file(root.join("textures/blade.png")).unwrap();
722 let error = import_project_model(&root, "sword.obj").unwrap_err();
723 assert!(error.to_string().contains("texture"), "{error}");
724 fs::remove_file(root.join("sword.mtl")).unwrap();
725 let error = import_project_model(&root, "sword.obj").unwrap_err();
726 assert!(error.to_string().contains("MTL"), "{error}");
727 let _ = fs::remove_dir_all(&root);
728 }
729
730 #[test]
731 fn fbx_is_refused_without_pretending_to_parse() {
732 let root = std::env::temp_dir().join(format!("paperdoll-fbx-{}", std::process::id()));
733 let _ = fs::remove_dir_all(&root);
734 fs::create_dir_all(&root).unwrap();
735 fs::write(root.join("hero.fbx"), b"not a real fbx").unwrap();
736 let error = import_project_model(&root, "hero.fbx").unwrap_err();
737 let text = error.to_string();
738 assert!(text.contains("Assimp"));
739 assert!(text.contains("niet geïmporteerd"));
740 assert!(!text.contains("walk"));
741 assert!(!text.contains("geïmporteerd: 0 clips"));
742 let _ = fs::remove_dir_all(&root);
743 }
744
745 fn tiny_png() -> Vec<u8> {
746 vec![
747 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1,
748 8, 2, 0, 0, 0, 144, 119, 83, 222, 0, 0, 0, 12, 73, 68, 65, 84, 120, 156, 99, 56, 177,
749 69, 3, 0, 3, 236, 1, 165, 147, 223, 177, 106, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96,
750 130,
751 ]
752 }
753}