BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / import.rs

import.rs

import.rs 35.0 KiB Download Raw
1//! Read glTF/GLB, OBJ+MTL and FBX into a CPU inventory description.
2//!
3//! FBX is parsed with ufbx, compiled into this binary from its MIT C source.
4//! There is no separate libassimp to load at runtime. Inventory records stay
5//! here; the normalized drawable/animated FBX scene lives in `fbx_scene`.
6
7use std::fs;
8use std::path::Path;
9
10use serde_json::Value;
11
12use crate::project::portable_relative;
13
14/// FBX support is the vendored ufbx loader, not a system Assimp library.
15pub const FBX_BACKEND: &str = "ufbx 0.11.5, meegecompileerd uit bron (geen losse libassimp)";
16
17const MAX_BYTES: u64 = 32 * 1024 * 1024;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ModelFormat {
21 Gltf,
22 Obj,
23 Fbx,
24}
25
26#[derive(Debug, Clone, PartialEq)]
27pub struct ImportedModel {
28 pub format: ModelFormat,
29 pub meshes: Vec<MeshRecord>,
30 pub materials: Vec<MaterialRecord>,
31 pub textures: Vec<TextureRecord>,
32 pub joints: Vec<JointRecord>,
33 pub clips: Vec<ClipRecord>,
34 pub notices: Vec<String>,
35}
36
37#[derive(Debug, Clone, PartialEq)]
38pub struct MeshRecord {
39 pub name: String,
40 pub vertex_count: usize,
41 pub triangle_count: usize,
42 pub has_normals: bool,
43 pub has_uvs: bool,
44 pub has_skin: bool,
45 pub material_name: Option<String>,
46}
47
48#[derive(Debug, Clone, PartialEq)]
49pub struct MaterialRecord {
50 pub name: String,
51 pub base_color: [f32; 4],
52 pub base_color_texture: Option<String>,
53 pub normal_texture: Option<String>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct TextureRecord {
58 pub name: String,
59 pub source: String,
60 pub width: u32,
61 pub height: u32,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct JointRecord {
66 pub name: String,
67 pub parent: Option<String>,
68}
69
70#[derive(Debug, Clone, PartialEq)]
71pub struct ClipRecord {
72 pub name: String,
73 pub duration_seconds: f32,
74 pub channel_count: usize,
75 pub targets: Vec<String>,
76}
77
78#[derive(Debug)]
79pub enum ImportError {
80 Missing(String),
81 Unsupported(String),
82 UnsafePath(String),
83 Parse { path: String, detail: String },
84}
85
86impl std::fmt::Display for ImportError {
87 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 match self {
89 Self::Missing(path) => write!(formatter, "het modelbestand ontbreekt: {path}"),
90 Self::Unsupported(message) => write!(formatter, "{message}"),
91 Self::UnsafePath(message) => write!(formatter, "{message}"),
92 Self::Parse { path, detail } => {
93 write!(formatter, "importeren van {path} is mislukt: {detail}")
94 }
95 }
96 }
97}
98
99impl std::error::Error for ImportError {}
100
101pub fn import_project_model(
102 project_root: &Path,
103 relative: &str,
104) -> Result<ImportedModel, ImportError> {
105 let relative = portable_relative(relative).map_err(ImportError::UnsafePath)?;
106 let full = project_root.join(&relative);
107 if !full.is_file() {
108 return Err(ImportError::Missing(relative));
109 }
110 ensure_inside(project_root, &full, &relative)?;
111 let length = fs::metadata(&full)
112 .map_err(|err| ImportError::Parse {
113 path: relative.clone(),
114 detail: err.to_string(),
115 })?
116 .len();
117 if length > MAX_BYTES {
118 return Err(ImportError::Parse {
119 path: relative,
120 detail: format!("het bestand is groter dan {MAX_BYTES} bytes"),
121 });
122 }
123 let extension = full
124 .extension()
125 .and_then(|ext| ext.to_str())
126 .unwrap_or("")
127 .to_ascii_lowercase();
128 match extension.as_str() {
129 "glb" | "gltf" => import_gltf(project_root, &full, &relative),
130 "obj" => import_obj(project_root, &full, &relative),
131 "fbx" => import_fbx(project_root, &full, &relative),
132 other => Err(ImportError::Unsupported(format!(
133 "bestandstype '{other}' wordt niet gelezen. Gebruik .glb, .gltf, .fbx of .obj."
134 ))),
135 }
136}
137
138fn import_gltf(
139 project_root: &Path,
140 full: &Path,
141 relative: &str,
142) -> Result<ImportedModel, ImportError> {
143 let json = gltf_json(full).map_err(|detail| ImportError::Parse {
144 path: relative.to_string(),
145 detail,
146 })?;
147 reject_unsafe_uris(&json)?;
148 let (document, buffers, images) =
149 crate::preview::gltf_import_guarded(full).map_err(|detail| ImportError::Parse {
150 path: relative.to_string(),
151 detail,
152 })?;
153 let mut materials = Vec::new();
154 for material in document.materials() {
155 let pbr = material.pbr_metallic_roughness();
156 materials.push(MaterialRecord {
157 name: material.name().unwrap_or("materiaal").to_string(),
158 base_color: pbr.base_color_factor(),
159 base_color_texture: pbr
160 .base_color_texture()
161 .map(|info| texture_label(&info.texture())),
162 normal_texture: material
163 .normal_texture()
164 .map(|info| texture_label(&info.texture())),
165 });
166 }
167 let material_names: Vec<Option<String>> = document
168 .materials()
169 .map(|material| material.name().map(str::to_string))
170 .collect();
171 let mut meshes = Vec::new();
172 for mesh in document.meshes() {
173 let mut vertex_count = 0;
174 let mut triangle_count = 0;
175 let mut has_normals = false;
176 let mut has_uvs = false;
177 let mut has_skin = false;
178 let mut material_name = None;
179 for primitive in mesh.primitives() {
180 let reader = primitive.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
181 let positions: Vec<[f32; 3]> = reader
182 .read_positions()
183 .map(|iter| iter.collect())
184 .unwrap_or_default();
185 if positions
186 .iter()
187 .any(|position| position.iter().any(|value| !value.is_finite()))
188 {
189 return Err(ImportError::Parse {
190 path: relative.to_string(),
191 detail: format!(
192 "mesh '{}' heeft een niet-eindige positie",
193 mesh.name().unwrap_or("mesh")
194 ),
195 });
196 }
197 vertex_count += positions.len();
198 if let Some(indices) = reader.read_indices() {
199 let indices: Vec<u32> = indices.into_u32().collect();
200 if !indices.len().is_multiple_of(3) {
201 return Err(ImportError::Parse {
202 path: relative.to_string(),
203 detail: format!(
204 "mesh '{}' heeft geen driehoeken",
205 mesh.name().unwrap_or("mesh")
206 ),
207 });
208 }
209 triangle_count += indices.len() / 3;
210 } else if positions.len().is_multiple_of(3) {
211 triangle_count += positions.len() / 3;
212 }
213 has_normals |= reader.read_normals().is_some();
214 has_uvs |= reader.read_tex_coords(0).is_some();
215 if let Some(weights) = reader.read_weights(0) {
216 has_skin = true;
217 for weight in weights.into_f32() {
218 if weight
219 .iter()
220 .any(|value| !value.is_finite() || *value < 0.0)
221 {
222 return Err(ImportError::Parse {
223 path: relative.to_string(),
224 detail: "een skin-weight is niet eindig of is negatief".to_string(),
225 });
226 }
227 }
228 }
229 if let Some(index) = primitive.material().index() {
230 material_name = material_names.get(index).cloned().flatten();
231 }
232 }
233 meshes.push(MeshRecord {
234 name: mesh.name().unwrap_or("mesh").to_string(),
235 vertex_count,
236 triangle_count,
237 has_normals,
238 has_uvs,
239 has_skin,
240 material_name,
241 });
242 }
243 let mut textures = Vec::new();
244 for (image, data) in document.images().zip(images.iter()) {
245 let source = match image.source() {
246 gltf::image::Source::Uri { uri, .. } => {
247 let path = full.parent().unwrap_or(project_root).join(uri_path(uri));
248 ensure_inside(project_root, &path, uri)?;
249 uri.to_string()
250 }
251 gltf::image::Source::View { .. } => "ingebed".to_string(),
252 };
253 textures.push(TextureRecord {
254 name: image.name().unwrap_or("texture").to_string(),
255 source,
256 width: data.width,
257 height: data.height,
258 });
259 }
260 let parents = parent_index(&document);
261 let mut joints = Vec::new();
262 for skin in document.skins() {
263 let ibm = skin
264 .inverse_bind_matrices()
265 .map(|accessor| accessor.count())
266 .unwrap_or(0);
267 let joint_nodes: Vec<_> = skin.joints().collect();
268 if ibm != 0 && ibm != joint_nodes.len() {
269 return Err(ImportError::Parse {
270 path: relative.to_string(),
271 detail: "het aantal inverse-bindmatrices wijkt af van het aantal joints"
272 .to_string(),
273 });
274 }
275 for joint in joint_nodes {
276 let parent = parents
277 .get(&joint.index())
278 .and_then(|parent| node_name(&document, *parent));
279 joints.push(JointRecord {
280 name: node_name(&document, joint.index())
281 .unwrap_or_else(|| format!("node-{}", joint.index())),
282 parent,
283 });
284 }
285 }
286 let mut clips = Vec::new();
287 let mut notices = Vec::new();
288 for (index, animation) in document.animations().enumerate() {
289 let mut duration = 0.0_f32;
290 let mut channel_count = 0;
291 let mut targets = Vec::new();
292 for channel in animation.channels() {
293 channel_count += 1;
294 if let Some(name) = channel.target().node().name() {
295 if !targets.iter().any(|existing| existing == name) {
296 targets.push(name.to_string());
297 }
298 }
299 let reader = channel.reader(|buffer| Some(buffers[buffer.index()].as_ref()));
300 if let Some(times) = reader.read_inputs() {
301 for time in times {
302 if !time.is_finite() {
303 return Err(ImportError::Parse {
304 path: relative.to_string(),
305 detail: "een animatiesample is niet eindig".to_string(),
306 });
307 }
308 duration = duration.max(time);
309 }
310 }
311 }
312 let name = animation.name().map(str::to_string).unwrap_or_else(|| {
313 notices.push(format!("clip {index} heeft geen naam"));
314 format!("naamloos-{index}")
315 });
316 clips.push(ClipRecord {
317 name,
318 duration_seconds: duration,
319 channel_count,
320 targets,
321 });
322 }
323 if joints.is_empty() {
324 notices.push(
325 "dit glTF-bestand heeft geen skeleton. Bot- en clipfuncties blijven uit tot er skins in het bestand staan."
326 .to_string(),
327 );
328 }
329 let _ = project_root;
330 Ok(ImportedModel {
331 format: ModelFormat::Gltf,
332 meshes,
333 materials,
334 textures,
335 joints,
336 clips,
337 notices,
338 })
339}
340
341fn import_fbx(
342 project_root: &Path,
343 full: &Path,
344 relative: &str,
345) -> Result<ImportedModel, ImportError> {
346 let path = full.to_str().ok_or_else(|| ImportError::Parse {
347 path: relative.to_string(),
348 detail: "het FBX-pad is geen UTF-8".to_string(),
349 })?;
350 let opts = ufbx::LoadOpts {
351 generate_missing_normals: true,
352 ..ufbx::LoadOpts::default()
353 };
354 let scene = ufbx::load_file(path, opts).map_err(|err| ImportError::Parse {
355 path: relative.to_string(),
356 detail: err.description.to_string(),
357 })?;
358 let mut meshes = Vec::new();
359 for mesh in &scene.meshes {
360 if mesh.num_vertices == 0 {
361 continue;
362 }
363 let material_name = mesh
364 .materials
365 .first()
366 .map(|material| material.element.name.trim().to_string())
367 .filter(|name| !name.is_empty());
368 meshes.push(MeshRecord {
369 name: fallback_name(mesh.element.name.trim(), "mesh"),
370 vertex_count: mesh.num_vertices,
371 triangle_count: mesh.num_triangles,
372 has_normals: mesh.vertex_normal.exists,
373 has_uvs: mesh.vertex_uv.exists,
374 has_skin: !mesh.skin_deformers.is_empty(),
375 material_name,
376 });
377 }
378 let mut joints = Vec::new();
379 for cluster in &scene.skin_clusters {
380 if let Some(bone) = cluster.bone_node.as_ref() {
381 record_fbx_chain(bone, &mut joints);
382 }
383 }
384 let mut materials = Vec::new();
385 let mut textures = Vec::new();
386 for material in &scene.materials {
387 let color_map = if material.pbr.base_color.has_value {
388 &material.pbr.base_color
389 } else {
390 &material.fbx.diffuse_color
391 };
392 let base_color_texture = fbx_texture(project_root, full, color_map.texture.as_ref())?;
393 let normal_texture =
394 fbx_texture(project_root, full, material.fbx.normal_map.texture.as_ref())?.or(
395 fbx_texture(project_root, full, material.pbr.normal_map.texture.as_ref())?,
396 );
397 if let Some(name) = &base_color_texture {
398 textures.push(texture_record(full, name));
399 }
400 materials.push(MaterialRecord {
401 name: fallback_name(material.element.name.trim(), "materiaal"),
402 base_color: [
403 color_map.value_vec4.x as f32,
404 color_map.value_vec4.y as f32,
405 color_map.value_vec4.z as f32,
406 if color_map.value_vec4.w == 0.0 {
407 1.0
408 } else {
409 color_map.value_vec4.w as f32
410 },
411 ],
412 base_color_texture,
413 normal_texture,
414 });
415 }
416 let mut clips = Vec::new();
417 for stack in &scene.anim_stacks {
418 let mut duration = (stack.time_end - stack.time_begin).max(0.0);
419 let mut channel_count = 0;
420 let mut targets = Vec::new();
421 for layer in &stack.layers {
422 for prop in &layer.anim_props {
423 let property = prop.prop_name.trim();
424 if property != "Lcl Translation"
425 && property != "Lcl Rotation"
426 && property != "Lcl Scaling"
427 {
428 continue;
429 }
430 channel_count += 1;
431 let target = prop.element.name.trim();
432 if !target.is_empty() && !targets.iter().any(|existing| existing == target) {
433 targets.push(target.to_string());
434 }
435 for curve in prop.anim_value.curves.iter().flatten() {
436 duration = duration.max(curve.max_time - curve.min_time);
437 }
438 }
439 }
440 if channel_count == 0 {
441 continue;
442 }
443 clips.push(ClipRecord {
444 name: fallback_name(stack.element.name.trim(), "clip"),
445 duration_seconds: duration as f32,
446 channel_count,
447 targets,
448 });
449 }
450 if meshes.is_empty() {
451 return Err(ImportError::Parse {
452 path: relative.to_string(),
453 detail: "het FBX-bestand bevat geen mesh".to_string(),
454 });
455 }
456 Ok(ImportedModel {
457 format: ModelFormat::Fbx,
458 meshes,
459 materials,
460 textures,
461 joints,
462 clips,
463 notices: vec![format!(
464 "FBX gelezen met {FBX_BACKEND}. Importoverzicht toont meshes/joints/clips; genormaliseerde drawable scene via fbx_scene. Live viewport en productie gebruiken nog glTF/GLB."
465 )],
466 })
467}
468
469fn record_fbx_chain(start: &ufbx::Node, joints: &mut Vec<JointRecord>) {
470 let mut current = start;
471 loop {
472 if current.is_root {
473 break;
474 }
475 let name = current.element.name.trim();
476 if !name.is_empty() && joints.iter().all(|joint| joint.name != name) {
477 let parent = current.parent.as_ref().and_then(|parent| {
478 if parent.is_root {
479 return None;
480 }
481 let parent_name = parent.element.name.trim();
482 if parent_name.is_empty() {
483 None
484 } else {
485 Some(parent_name.to_string())
486 }
487 });
488 joints.push(JointRecord {
489 name: name.to_string(),
490 parent,
491 });
492 }
493 match current.parent.as_ref() {
494 Some(parent) if !parent.is_root => current = parent,
495 _ => break,
496 }
497 }
498}
499
500fn fbx_texture(
501 project_root: &Path,
502 fbx_file: &Path,
503 texture: Option<&ufbx::Ref<ufbx::Texture>>,
504) -> Result<Option<String>, ImportError> {
505 let Some(texture) = texture else {
506 return Ok(None);
507 };
508 if texture.content.size > 0 {
509 return Ok(Some(format!("ingebed:{}", texture.element.name.trim())));
510 }
511 let relative_name = texture.relative_filename.trim().replace('\\', "/");
512 let filename = if relative_name.is_empty() {
513 texture.filename.trim().replace('\\', "/")
514 } else {
515 relative_name
516 };
517 if filename.is_empty() {
518 return Ok(None);
519 }
520 reject_relative_escape(&filename)?;
521 let base = fbx_file.parent().unwrap_or(project_root);
522 let path = base.join(&filename);
523 if path.is_file() {
524 ensure_inside(project_root, &path, &filename)?;
525 } else if texture.has_file {
526 return Err(ImportError::Parse {
527 path: filename,
528 detail: "de FBX-texture ontbreekt".to_string(),
529 });
530 }
531 Ok(Some(filename))
532}
533
534fn texture_record(fbx_file: &Path, name: &str) -> TextureRecord {
535 let path = fbx_file.parent().unwrap_or(fbx_file).join(name);
536 let (width, height) = png_size(&path).unwrap_or((0, 0));
537 TextureRecord {
538 name: name.to_string(),
539 source: name.to_string(),
540 width,
541 height,
542 }
543}
544
545fn fallback_name(name: &str, fallback: &str) -> String {
546 if name.is_empty() {
547 fallback.to_string()
548 } else {
549 name.to_string()
550 }
551}
552
553fn import_obj(
554 project_root: &Path,
555 full: &Path,
556 relative: &str,
557) -> Result<ImportedModel, ImportError> {
558 let (models, materials) = tobj::load_obj(
559 full,
560 &tobj::LoadOptions {
561 single_index: true,
562 triangulate: true,
563 ignore_points: true,
564 ignore_lines: true,
565 },
566 )
567 .map_err(|err| ImportError::Parse {
568 path: relative.to_string(),
569 detail: err.to_string(),
570 })?;
571 let materials = materials.map_err(|err| ImportError::Parse {
572 path: relative.to_string(),
573 detail: format!("MTL ontbreekt of is onleesbaar: {err}"),
574 })?;
575 let mut meshes = Vec::new();
576 for model in &models {
577 let mesh = &model.mesh;
578 let vertex_count = mesh.positions.len() / 3;
579 if !mesh.positions.len().is_multiple_of(3)
580 || mesh.positions.iter().any(|value| !value.is_finite())
581 {
582 return Err(ImportError::Parse {
583 path: relative.to_string(),
584 detail: format!("mesh '{}' heeft een ongeldige positie", model.name),
585 });
586 }
587 if !mesh.indices.len().is_multiple_of(3) {
588 return Err(ImportError::Parse {
589 path: relative.to_string(),
590 detail: format!("mesh '{}' is niet in driehoeken", model.name),
591 });
592 }
593 let material_name = mesh
594 .material_id
595 .and_then(|index| materials.get(index))
596 .map(|material| material.name.clone());
597 meshes.push(MeshRecord {
598 name: model.name.clone(),
599 vertex_count,
600 triangle_count: mesh.indices.len() / 3,
601 has_normals: !mesh.normals.is_empty(),
602 has_uvs: !mesh.texcoords.is_empty(),
603 has_skin: false,
604 material_name,
605 });
606 }
607 let base = full.parent().unwrap_or(project_root);
608 let mut textures = Vec::new();
609 let mut material_records = Vec::new();
610 for material in &materials {
611 let base_color_texture =
612 checked_texture(project_root, base, material.diffuse_texture.as_deref())?;
613 let normal_texture =
614 checked_texture(project_root, base, material.normal_texture.as_deref())?;
615 if let Some(name) = base_color_texture.clone() {
616 let path = base.join(&name);
617 let (width, height) = png_size(&path).unwrap_or((0, 0));
618 textures.push(TextureRecord {
619 name: name.clone(),
620 source: name,
621 width,
622 height,
623 });
624 }
625 material_records.push(MaterialRecord {
626 name: material.name.clone(),
627 base_color: [
628 material.diffuse.unwrap_or([1.0, 1.0, 1.0])[0],
629 material.diffuse.unwrap_or([1.0, 1.0, 1.0])[1],
630 material.diffuse.unwrap_or([1.0, 1.0, 1.0])[2],
631 material.dissolve.unwrap_or(1.0),
632 ],
633 base_color_texture,
634 normal_texture,
635 });
636 }
637 Ok(ImportedModel {
638 format: ModelFormat::Obj,
639 meshes,
640 materials: material_records,
641 textures,
642 joints: Vec::new(),
643 clips: Vec::new(),
644 notices: vec![
645 "OBJ is een statische mesh voor equipment. Dit formaat heeft geen skeleton en levert geen skeletal animation clips."
646 .to_string(),
647 ],
648 })
649}
650
651fn checked_texture(
652 project_root: &Path,
653 base: &Path,
654 texture: Option<&str>,
655) -> Result<Option<String>, ImportError> {
656 let Some(texture) = texture else {
657 return Ok(None);
658 };
659 if texture.trim().is_empty() {
660 return Ok(None);
661 }
662 reject_relative_escape(texture)?;
663 let path = base.join(texture);
664 if !path.is_file() {
665 return Err(ImportError::Parse {
666 path: texture.to_string(),
667 detail: "de texture uit de MTL ontbreekt".to_string(),
668 });
669 }
670 ensure_inside(project_root, &path, texture)?;
671 Ok(Some(texture.replace('\\', "/")))
672}
673
674fn reject_relative_escape(value: &str) -> Result<(), ImportError> {
675 if value.starts_with('/')
676 || value.contains('\\')
677 || value.split(['/', '\\']).any(|part| part == "..")
678 {
679 return Err(ImportError::UnsafePath(format!(
680 "texturepad '{value}' wijst buiten de projectmap"
681 )));
682 }
683 Ok(())
684}
685
686fn reject_unsafe_uris(document: &Value) -> Result<(), ImportError> {
687 for key in ["buffers", "images"] {
688 let Some(items) = document.get(key).and_then(Value::as_array) else {
689 continue;
690 };
691 for item in items {
692 let Some(uri) = item.get("uri").and_then(Value::as_str) else {
693 continue;
694 };
695 if uri.starts_with("data:") {
696 continue;
697 }
698 let path = uri.split(['?', '#']).next().unwrap_or(uri);
699 if path.contains(':')
700 || path.starts_with('/')
701 || path.contains('\\')
702 || path
703 .split('/')
704 .any(|part| part.is_empty() || part == "." || part == "..")
705 {
706 return Err(ImportError::UnsafePath(format!(
707 "glTF-verwijzing '{uri}' wordt niet gevolgd"
708 )));
709 }
710 }
711 }
712 Ok(())
713}
714
715fn gltf_json(path: &Path) -> Result<Value, String> {
716 let bytes = fs::read(path).map_err(|err| err.to_string())?;
717 if path.extension().and_then(|ext| ext.to_str()) == Some("glb") || bytes.starts_with(b"glTF") {
718 if bytes.len() < 20 || &bytes[0..4] != b"glTF" {
719 return Err("dit is geen GLB".to_string());
720 }
721 let json_len = u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize;
722 let json_type = &bytes[16..20];
723 if json_type != b"JSON" || 20 + json_len > bytes.len() {
724 return Err("de GLB heeft geen JSON-chunk".to_string());
725 }
726 let text = std::str::from_utf8(&bytes[20..20 + json_len]).map_err(|err| err.to_string())?;
727 return serde_json::from_str(text.trim_end()).map_err(|err| err.to_string());
728 }
729 serde_json::from_slice(&bytes).map_err(|err| err.to_string())
730}
731
732fn uri_path(uri: &str) -> &str {
733 uri.split(['?', '#']).next().unwrap_or(uri)
734}
735
736fn texture_label(texture: &gltf::Texture<'_>) -> String {
737 texture
738 .name()
739 .map(str::to_string)
740 .or_else(|| texture.source().name().map(str::to_string))
741 .unwrap_or_else(|| format!("texture-{}", texture.index()))
742}
743
744fn parent_index(document: &gltf::Document) -> std::collections::HashMap<usize, usize> {
745 let mut parents = std::collections::HashMap::new();
746 for node in document.nodes() {
747 for child in node.children() {
748 parents.insert(child.index(), node.index());
749 }
750 }
751 parents
752}
753
754fn node_name(document: &gltf::Document, index: usize) -> Option<String> {
755 document
756 .nodes()
757 .find(|node| node.index() == index)
758 .and_then(|node| node.name().map(str::to_string))
759}
760
761fn ensure_inside(project_root: &Path, path: &Path, label: &str) -> Result<(), ImportError> {
762 let root = project_root
763 .canonicalize()
764 .map_err(|err| ImportError::Parse {
765 path: label.to_string(),
766 detail: err.to_string(),
767 })?;
768 let file = path.canonicalize().map_err(|err| ImportError::Parse {
769 path: label.to_string(),
770 detail: err.to_string(),
771 })?;
772 if file.starts_with(&root) {
773 Ok(())
774 } else {
775 Err(ImportError::UnsafePath(format!(
776 "het pad '{label}' wijst buiten de projectmap"
777 )))
778 }
779}
780
781fn png_size(path: &Path) -> Option<(u32, u32)> {
782 let bytes = fs::read(path).ok()?;
783 if bytes.len() < 24 || &bytes[0..8] != b"\x89PNG\r\n\x1a\n" {
784 return None;
785 }
786 let width = u32::from_be_bytes(bytes[16..20].try_into().ok()?);
787 let height = u32::from_be_bytes(bytes[20..24].try_into().ok()?);
788 Some((width, height))
789}
790
791#[cfg(test)]
792mod tests {
793 use std::path::PathBuf;
794
795 use super::*;
796
797 fn fixture_root() -> PathBuf {
798 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies")
799 }
800
801 fn assert_body(relative: &str, sex: &str) {
802 let model = import_project_model(&fixture_root(), relative).unwrap();
803 assert_eq!(model.format, ModelFormat::Gltf);
804 assert_eq!(model.meshes.len(), 1);
805 assert_eq!(model.meshes[0].vertex_count, 19 * 8);
806 assert_eq!(model.meshes[0].triangle_count, 19 * 12);
807 assert!(model.meshes[0].has_skin);
808 assert_eq!(model.materials.len(), 1);
809 assert!(model.materials[0]
810 .base_color
811 .iter()
812 .all(|value| value.is_finite()));
813 assert_eq!(model.joints.len(), 19);
814 assert!(model
815 .joints
816 .iter()
817 .any(|joint| joint.name == "Hips" && joint.parent.is_none()));
818 assert!(model.joints.iter().any(|joint| {
819 joint.name == "LeftUpperArm" && joint.parent.as_deref() == Some("LeftShoulder")
820 }));
821 let walk = model.clips.iter().find(|clip| clip.name == "walk").unwrap();
822 let attack = model
823 .clips
824 .iter()
825 .find(|clip| clip.name == "attack")
826 .unwrap();
827 assert!((walk.duration_seconds - 1.0).abs() < 1e-3);
828 assert!((attack.duration_seconds - 0.8).abs() < 1e-3);
829 assert!(walk.targets.iter().any(|target| target == "LeftUpperLeg"));
830 assert!(attack
831 .targets
832 .iter()
833 .any(|target| target == "RightUpperArm"));
834 assert!(walk.channel_count >= 4);
835 assert!(model.materials[0].name.contains(sex) || model.meshes[0].name.contains(sex));
836 assert!(model
837 .notices
838 .iter()
839 .all(|notice| !notice.contains("geen skeleton")));
840 }
841
842 #[test]
843 fn test_bodies_expose_mesh_skin_and_both_clips() {
844 assert_body("test-body-male.glb", "male");
845 assert_body("test-body-female.glb", "female");
846 let male = import_project_model(&fixture_root(), "test-body-male.glb").unwrap();
847 let female = import_project_model(&fixture_root(), "test-body-female.glb").unwrap();
848 let male_shoulder = male
849 .joints
850 .iter()
851 .find(|joint| joint.name == "LeftShoulder")
852 .unwrap();
853 let female_shoulder = female
854 .joints
855 .iter()
856 .find(|joint| joint.name == "LeftShoulder")
857 .unwrap();
858 assert_eq!(male_shoulder.parent, female_shoulder.parent);
859 assert_ne!(male.materials[0].base_color, female.materials[0].base_color);
860 }
861
862 #[test]
863 fn missing_gltf_texture_and_escape_are_errors() {
864 let root = std::env::temp_dir().join(format!("paperdoll-gltf-{}", std::process::id()));
865 let _ = fs::remove_dir_all(&root);
866 fs::create_dir_all(&root).unwrap();
867 fs::write(
868 root.join("missing.gltf"),
869 r#"{
870 "asset": {"version": "2.0"},
871 "buffers": [{"uri": "missing.bin", "byteLength": 4}],
872 "images": [{"uri": "missing.png"}]
873 }"#,
874 )
875 .unwrap();
876 let error = import_project_model(&root, "missing.gltf").unwrap_err();
877 let text = error.to_string();
878 assert!(
879 text.contains("missing.bin")
880 || text.contains("missing.png")
881 || text.contains("mislukt"),
882 "{text}"
883 );
884 fs::write(
885 root.join("escape.gltf"),
886 r#"{
887 "asset": {"version": "2.0"},
888 "images": [{"uri": "../secret.png"}]
889 }"#,
890 )
891 .unwrap();
892 let error = import_project_model(&root, "escape.gltf").unwrap_err();
893 assert!(error.to_string().contains("niet gevolgd"), "{error}");
894 let error = import_project_model(&root, "absent.glb").unwrap_err();
895 assert!(error.to_string().contains("ontbreekt"));
896 let _ = fs::remove_dir_all(&root);
897 }
898
899 #[test]
900 fn obj_with_mtl_and_texture_is_static_and_missing_texture_fails() {
901 let root = std::env::temp_dir().join(format!("paperdoll-obj-{}", std::process::id()));
902 let _ = fs::remove_dir_all(&root);
903 fs::create_dir_all(root.join("textures")).unwrap();
904 fs::write(root.join("textures/blade.png"), tiny_png()).unwrap();
905 fs::write(
906 root.join("sword.mtl"),
907 "newmtl steel\nKd 0.7 0.7 0.8\nmap_Kd textures/blade.png\n",
908 )
909 .unwrap();
910 fs::write(
911 root.join("sword.obj"),
912 "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",
913 )
914 .unwrap();
915 let model = import_project_model(&root, "sword.obj").unwrap();
916 assert_eq!(model.format, ModelFormat::Obj);
917 assert_eq!(model.meshes.len(), 1);
918 assert_eq!(model.meshes[0].triangle_count, 1);
919 assert!(model.meshes[0].has_uvs);
920 assert!(model.meshes[0].has_normals);
921 assert!(!model.meshes[0].has_skin);
922 assert!(model.joints.is_empty());
923 assert!(model.clips.is_empty());
924 assert_eq!(model.materials.len(), 1);
925 assert_eq!(model.materials[0].name, "steel");
926 assert_eq!(
927 model.materials[0].base_color_texture.as_deref(),
928 Some("textures/blade.png")
929 );
930 assert_eq!(model.textures[0].width, 1);
931 assert_eq!(model.textures[0].height, 1);
932 let notice = model.notices.join(" ");
933 assert!(notice.contains("geen skeleton"));
934 assert!(notice.contains("geen skeletal animation clips"));
935 fs::remove_file(root.join("textures/blade.png")).unwrap();
936 let error = import_project_model(&root, "sword.obj").unwrap_err();
937 assert!(error.to_string().contains("texture"), "{error}");
938 fs::remove_file(root.join("sword.mtl")).unwrap();
939 let error = import_project_model(&root, "sword.obj").unwrap_err();
940 assert!(error.to_string().contains("MTL"), "{error}");
941 let _ = fs::remove_dir_all(&root);
942 }
943
944 #[test]
945 fn fbx_fixture_has_skin_clips_and_material() {
946 let model = import_project_model(&fixture_root(), "test-body-male.fbx").unwrap();
947 assert_eq!(model.format, ModelFormat::Fbx);
948 let mesh = model
949 .meshes
950 .iter()
951 .find(|mesh| mesh.vertex_count > 0 && mesh.triangle_count > 0 && mesh.has_skin)
952 .expect("skinned mesh");
953 assert!(
954 mesh.vertex_count >= 19 * 8,
955 "vertices {}",
956 mesh.vertex_count
957 );
958 assert!(model.joints.iter().any(|joint| joint.name == "Hips"));
959 assert!(model.joints.iter().any(|joint| {
960 joint.name == "LeftUpperArm" && joint.parent.as_deref() == Some("LeftShoulder")
961 }));
962 let walk = model
963 .clips
964 .iter()
965 .find(|clip| clip.name.to_lowercase().contains("walk"))
966 .expect("walk clip");
967 let attack = model
968 .clips
969 .iter()
970 .find(|clip| clip.name.to_lowercase().contains("attack"))
971 .expect("attack clip");
972 assert!(walk.duration_seconds > 0.5, "{}", walk.duration_seconds);
973 assert!(attack.duration_seconds > 0.2, "{}", attack.duration_seconds);
974 assert!(walk.channel_count > 0);
975 assert!(attack.channel_count > 0);
976 assert!(walk
977 .targets
978 .iter()
979 .any(|target| target.contains("LeftUpperLeg") || target.contains("Hips")));
980 assert!(!model.materials.is_empty());
981 assert!(model.materials.iter().any(|material| {
982 material
983 .base_color
984 .iter()
985 .all(|channel| channel.is_finite())
986 }));
987 assert!(model.notices.iter().any(|notice| notice.contains("ufbx")));
988 assert!(model
989 .notices
990 .iter()
991 .any(|notice| notice.contains("fbx_scene") || notice.contains("drawable")));
992 }
993
994 #[test]
995 fn bytes_named_fbx_are_not_accepted_without_parsing() {
996 let root = std::env::temp_dir().join(format!("paperdoll-fbx-{}", std::process::id()));
997 let _ = fs::remove_dir_all(&root);
998 fs::create_dir_all(&root).unwrap();
999 fs::write(root.join("hero.fbx"), b"not a real fbx").unwrap();
1000 let error = import_project_model(&root, "hero.fbx").unwrap_err();
1001 let text = error.to_string();
1002 assert!(text.contains("mislukt"), "{text}");
1003 assert!(!text.contains("walk"));
1004 let _ = fs::remove_dir_all(&root);
1005 }
1006
1007 fn tiny_png() -> Vec<u8> {
1008 vec![
1009 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1,
1010 8, 2, 0, 0, 0, 144, 119, 83, 222, 0, 0, 0, 12, 73, 68, 65, 84, 120, 156, 99, 56, 177,
1011 69, 3, 0, 3, 236, 1, 165, 147, 223, 177, 106, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96,
1012 130,
1013 ]
1014 }
1015}