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