//! Read glTF/GLB characters and OBJ+MTL equipment into a CPU description. //! //! The description is for inspection and a later renderer. This module does //! not draw a viewport and does not sample skeletal animation. FBX is refused: //! Assimp is not linked in this build. use std::fs; use std::path::Path; use serde_json::Value; use crate::project::portable_relative; 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."; const MAX_BYTES: u64 = 32 * 1024 * 1024; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ModelFormat { Gltf, Obj, Fbx, } #[derive(Debug, Clone, PartialEq)] pub struct ImportedModel { pub format: ModelFormat, pub meshes: Vec, pub materials: Vec, pub textures: Vec, pub joints: Vec, pub clips: Vec, pub notices: Vec, } #[derive(Debug, Clone, PartialEq)] pub struct MeshRecord { pub name: String, pub vertex_count: usize, pub triangle_count: usize, pub has_normals: bool, pub has_uvs: bool, pub has_skin: bool, pub material_name: Option, } #[derive(Debug, Clone, PartialEq)] pub struct MaterialRecord { pub name: String, pub base_color: [f32; 4], pub base_color_texture: Option, pub normal_texture: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct TextureRecord { pub name: String, pub source: String, pub width: u32, pub height: u32, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct JointRecord { pub name: String, pub parent: Option, } #[derive(Debug, Clone, PartialEq)] pub struct ClipRecord { pub name: String, pub duration_seconds: f32, pub channel_count: usize, pub targets: Vec, } #[derive(Debug)] pub enum ImportError { Missing(String), Unsupported(String), UnsafePath(String), Parse { path: String, detail: String }, } impl std::fmt::Display for ImportError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Missing(path) => write!(formatter, "het modelbestand ontbreekt: {path}"), Self::Unsupported(message) => write!(formatter, "{message}"), Self::UnsafePath(message) => write!(formatter, "{message}"), Self::Parse { path, detail } => { write!(formatter, "importeren van {path} is mislukt: {detail}") } } } } impl std::error::Error for ImportError {} pub fn import_project_model( project_root: &Path, relative: &str, ) -> Result { let relative = portable_relative(relative).map_err(ImportError::UnsafePath)?; let full = project_root.join(&relative); if !full.is_file() { return Err(ImportError::Missing(relative)); } ensure_inside(project_root, &full, &relative)?; let length = fs::metadata(&full) .map_err(|err| ImportError::Parse { path: relative.clone(), detail: err.to_string(), })? .len(); if length > MAX_BYTES { return Err(ImportError::Parse { path: relative, detail: format!("het bestand is groter dan {MAX_BYTES} bytes"), }); } let extension = full .extension() .and_then(|ext| ext.to_str()) .unwrap_or("") .to_ascii_lowercase(); match extension.as_str() { "glb" | "gltf" => import_gltf(project_root, &full, &relative), "obj" => import_obj(project_root, &full, &relative), "fbx" => Err(ImportError::Unsupported(FBX_UNAVAILABLE.to_string())), other => Err(ImportError::Unsupported(format!( "bestandstype '{other}' wordt niet gelezen. Gebruik .glb, .gltf of .obj." ))), } } fn import_gltf( project_root: &Path, full: &Path, relative: &str, ) -> Result { let json = gltf_json(full).map_err(|detail| ImportError::Parse { path: relative.to_string(), detail, })?; reject_unsafe_uris(&json)?; let (document, buffers, images) = gltf::import(full).map_err(|err| ImportError::Parse { path: relative.to_string(), detail: err.to_string(), })?; let mut materials = Vec::new(); for material in document.materials() { let pbr = material.pbr_metallic_roughness(); materials.push(MaterialRecord { name: material.name().unwrap_or("materiaal").to_string(), base_color: pbr.base_color_factor(), base_color_texture: pbr .base_color_texture() .map(|info| texture_label(&info.texture())), normal_texture: material .normal_texture() .map(|info| texture_label(&info.texture())), }); } let material_names: Vec> = document .materials() .map(|material| material.name().map(str::to_string)) .collect(); let mut meshes = Vec::new(); for mesh in document.meshes() { let mut vertex_count = 0; let mut triangle_count = 0; let mut has_normals = false; let mut has_uvs = false; let mut has_skin = false; let mut material_name = None; for primitive in mesh.primitives() { let reader = primitive.reader(|buffer| Some(buffers[buffer.index()].as_ref())); let positions: Vec<[f32; 3]> = reader .read_positions() .map(|iter| iter.collect()) .unwrap_or_default(); if positions .iter() .any(|position| position.iter().any(|value| !value.is_finite())) { return Err(ImportError::Parse { path: relative.to_string(), detail: format!( "mesh '{}' heeft een niet-eindige positie", mesh.name().unwrap_or("mesh") ), }); } vertex_count += positions.len(); if let Some(indices) = reader.read_indices() { let indices: Vec = indices.into_u32().collect(); if !indices.len().is_multiple_of(3) { return Err(ImportError::Parse { path: relative.to_string(), detail: format!( "mesh '{}' heeft geen driehoeken", mesh.name().unwrap_or("mesh") ), }); } triangle_count += indices.len() / 3; } else if positions.len().is_multiple_of(3) { triangle_count += positions.len() / 3; } has_normals |= reader.read_normals().is_some(); has_uvs |= reader.read_tex_coords(0).is_some(); if let Some(weights) = reader.read_weights(0) { has_skin = true; for weight in weights.into_f32() { if weight .iter() .any(|value| !value.is_finite() || *value < 0.0) { return Err(ImportError::Parse { path: relative.to_string(), detail: "een skin-weight is niet eindig of is negatief".to_string(), }); } } } if let Some(index) = primitive.material().index() { material_name = material_names.get(index).cloned().flatten(); } } meshes.push(MeshRecord { name: mesh.name().unwrap_or("mesh").to_string(), vertex_count, triangle_count, has_normals, has_uvs, has_skin, material_name, }); } let mut textures = Vec::new(); for (image, data) in document.images().zip(images.iter()) { let source = match image.source() { gltf::image::Source::Uri { uri, .. } => { let path = full.parent().unwrap_or(project_root).join(uri_path(uri)); ensure_inside(project_root, &path, uri)?; uri.to_string() } gltf::image::Source::View { .. } => "ingebed".to_string(), }; textures.push(TextureRecord { name: image.name().unwrap_or("texture").to_string(), source, width: data.width, height: data.height, }); } let parents = parent_index(&document); let mut joints = Vec::new(); for skin in document.skins() { let ibm = skin .inverse_bind_matrices() .map(|accessor| accessor.count()) .unwrap_or(0); let joint_nodes: Vec<_> = skin.joints().collect(); if ibm != 0 && ibm != joint_nodes.len() { return Err(ImportError::Parse { path: relative.to_string(), detail: "het aantal inverse-bindmatrices wijkt af van het aantal joints" .to_string(), }); } for joint in joint_nodes { let parent = parents .get(&joint.index()) .and_then(|parent| node_name(&document, *parent)); joints.push(JointRecord { name: node_name(&document, joint.index()) .unwrap_or_else(|| format!("node-{}", joint.index())), parent, }); } } let mut clips = Vec::new(); let mut notices = Vec::new(); for (index, animation) in document.animations().enumerate() { let mut duration = 0.0_f32; let mut channel_count = 0; let mut targets = Vec::new(); for channel in animation.channels() { channel_count += 1; if let Some(name) = channel.target().node().name() { if !targets.iter().any(|existing| existing == name) { targets.push(name.to_string()); } } let reader = channel.reader(|buffer| Some(buffers[buffer.index()].as_ref())); if let Some(times) = reader.read_inputs() { for time in times { if !time.is_finite() { return Err(ImportError::Parse { path: relative.to_string(), detail: "een animatiesample is niet eindig".to_string(), }); } duration = duration.max(time); } } } let name = animation.name().map(str::to_string).unwrap_or_else(|| { notices.push(format!("clip {index} heeft geen naam")); format!("naamloos-{index}") }); clips.push(ClipRecord { name, duration_seconds: duration, channel_count, targets, }); } if joints.is_empty() { notices.push( "dit glTF-bestand heeft geen skeleton. Bot- en clipfuncties blijven uit tot er skins in het bestand staan." .to_string(), ); } let _ = project_root; Ok(ImportedModel { format: ModelFormat::Gltf, meshes, materials, textures, joints, clips, notices, }) } fn import_obj( project_root: &Path, full: &Path, relative: &str, ) -> Result { let (models, materials) = tobj::load_obj( full, &tobj::LoadOptions { single_index: true, triangulate: true, ignore_points: true, ignore_lines: true, }, ) .map_err(|err| ImportError::Parse { path: relative.to_string(), detail: err.to_string(), })?; let materials = materials.map_err(|err| ImportError::Parse { path: relative.to_string(), detail: format!("MTL ontbreekt of is onleesbaar: {err}"), })?; let mut meshes = Vec::new(); for model in &models { let mesh = &model.mesh; let vertex_count = mesh.positions.len() / 3; if !mesh.positions.len().is_multiple_of(3) || mesh.positions.iter().any(|value| !value.is_finite()) { return Err(ImportError::Parse { path: relative.to_string(), detail: format!("mesh '{}' heeft een ongeldige positie", model.name), }); } if !mesh.indices.len().is_multiple_of(3) { return Err(ImportError::Parse { path: relative.to_string(), detail: format!("mesh '{}' is niet in driehoeken", model.name), }); } let material_name = mesh .material_id .and_then(|index| materials.get(index)) .map(|material| material.name.clone()); meshes.push(MeshRecord { name: model.name.clone(), vertex_count, triangle_count: mesh.indices.len() / 3, has_normals: !mesh.normals.is_empty(), has_uvs: !mesh.texcoords.is_empty(), has_skin: false, material_name, }); } let base = full.parent().unwrap_or(project_root); let mut textures = Vec::new(); let mut material_records = Vec::new(); for material in &materials { let base_color_texture = checked_texture(project_root, base, material.diffuse_texture.as_deref())?; let normal_texture = checked_texture(project_root, base, material.normal_texture.as_deref())?; if let Some(name) = base_color_texture.clone() { let path = base.join(&name); let (width, height) = png_size(&path).unwrap_or((0, 0)); textures.push(TextureRecord { name: name.clone(), source: name, width, height, }); } material_records.push(MaterialRecord { name: material.name.clone(), base_color: [ material.diffuse.unwrap_or([1.0, 1.0, 1.0])[0], material.diffuse.unwrap_or([1.0, 1.0, 1.0])[1], material.diffuse.unwrap_or([1.0, 1.0, 1.0])[2], material.dissolve.unwrap_or(1.0), ], base_color_texture, normal_texture, }); } Ok(ImportedModel { format: ModelFormat::Obj, meshes, materials: material_records, textures, joints: Vec::new(), clips: Vec::new(), notices: vec![ "OBJ is een statische mesh voor equipment. Dit formaat heeft geen skeleton en levert geen skeletal animation clips." .to_string(), ], }) } fn checked_texture( project_root: &Path, base: &Path, texture: Option<&str>, ) -> Result, ImportError> { let Some(texture) = texture else { return Ok(None); }; if texture.trim().is_empty() { return Ok(None); } reject_relative_escape(texture)?; let path = base.join(texture); if !path.is_file() { return Err(ImportError::Parse { path: texture.to_string(), detail: "de texture uit de MTL ontbreekt".to_string(), }); } ensure_inside(project_root, &path, texture)?; Ok(Some(texture.replace('\\', "/"))) } fn reject_relative_escape(value: &str) -> Result<(), ImportError> { if value.starts_with('/') || value.contains('\\') || value.split(['/', '\\']).any(|part| part == "..") { return Err(ImportError::UnsafePath(format!( "texturepad '{value}' wijst buiten de projectmap" ))); } Ok(()) } fn reject_unsafe_uris(document: &Value) -> Result<(), ImportError> { for key in ["buffers", "images"] { let Some(items) = document.get(key).and_then(Value::as_array) else { continue; }; for item in items { let Some(uri) = item.get("uri").and_then(Value::as_str) else { continue; }; if uri.starts_with("data:") { continue; } let path = uri.split(['?', '#']).next().unwrap_or(uri); if path.contains(':') || path.starts_with('/') || path.contains('\\') || path .split('/') .any(|part| part.is_empty() || part == "." || part == "..") { return Err(ImportError::UnsafePath(format!( "glTF-verwijzing '{uri}' wordt niet gevolgd" ))); } } } Ok(()) } fn gltf_json(path: &Path) -> Result { let bytes = fs::read(path).map_err(|err| err.to_string())?; if path.extension().and_then(|ext| ext.to_str()) == Some("glb") || bytes.starts_with(b"glTF") { if bytes.len() < 20 || &bytes[0..4] != b"glTF" { return Err("dit is geen GLB".to_string()); } let json_len = u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize; let json_type = &bytes[16..20]; if json_type != b"JSON" || 20 + json_len > bytes.len() { return Err("de GLB heeft geen JSON-chunk".to_string()); } let text = std::str::from_utf8(&bytes[20..20 + json_len]).map_err(|err| err.to_string())?; return serde_json::from_str(text.trim_end()).map_err(|err| err.to_string()); } serde_json::from_slice(&bytes).map_err(|err| err.to_string()) } fn uri_path(uri: &str) -> &str { uri.split(['?', '#']).next().unwrap_or(uri) } fn texture_label(texture: &gltf::Texture<'_>) -> String { texture .name() .map(str::to_string) .or_else(|| texture.source().name().map(str::to_string)) .unwrap_or_else(|| format!("texture-{}", texture.index())) } fn parent_index(document: &gltf::Document) -> std::collections::HashMap { let mut parents = std::collections::HashMap::new(); for node in document.nodes() { for child in node.children() { parents.insert(child.index(), node.index()); } } parents } fn node_name(document: &gltf::Document, index: usize) -> Option { document .nodes() .find(|node| node.index() == index) .and_then(|node| node.name().map(str::to_string)) } fn ensure_inside(project_root: &Path, path: &Path, label: &str) -> Result<(), ImportError> { let root = project_root .canonicalize() .map_err(|err| ImportError::Parse { path: label.to_string(), detail: err.to_string(), })?; let file = path.canonicalize().map_err(|err| ImportError::Parse { path: label.to_string(), detail: err.to_string(), })?; if file.starts_with(&root) { Ok(()) } else { Err(ImportError::UnsafePath(format!( "het pad '{label}' wijst buiten de projectmap" ))) } } fn png_size(path: &Path) -> Option<(u32, u32)> { let bytes = fs::read(path).ok()?; if bytes.len() < 24 || &bytes[0..8] != b"\x89PNG\r\n\x1a\n" { return None; } let width = u32::from_be_bytes(bytes[16..20].try_into().ok()?); let height = u32::from_be_bytes(bytes[20..24].try_into().ok()?); Some((width, height)) } #[cfg(test)] mod tests { use std::path::PathBuf; use super::*; fn fixture_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies") } fn assert_body(relative: &str, sex: &str) { let model = import_project_model(&fixture_root(), relative).unwrap(); assert_eq!(model.format, ModelFormat::Gltf); assert_eq!(model.meshes.len(), 1); assert_eq!(model.meshes[0].vertex_count, 19 * 8); assert_eq!(model.meshes[0].triangle_count, 19 * 12); assert!(model.meshes[0].has_skin); assert_eq!(model.materials.len(), 1); assert!(model.materials[0] .base_color .iter() .all(|value| value.is_finite())); assert_eq!(model.joints.len(), 19); assert!(model .joints .iter() .any(|joint| joint.name == "Hips" && joint.parent.is_none())); assert!(model.joints.iter().any(|joint| { joint.name == "LeftUpperArm" && joint.parent.as_deref() == Some("LeftShoulder") })); let walk = model.clips.iter().find(|clip| clip.name == "walk").unwrap(); let attack = model .clips .iter() .find(|clip| clip.name == "attack") .unwrap(); assert!((walk.duration_seconds - 1.0).abs() < 1e-3); assert!((attack.duration_seconds - 0.8).abs() < 1e-3); assert!(walk.targets.iter().any(|target| target == "LeftUpperLeg")); assert!(attack .targets .iter() .any(|target| target == "RightUpperArm")); assert!(walk.channel_count >= 4); assert!(model.materials[0].name.contains(sex) || model.meshes[0].name.contains(sex)); assert!(model .notices .iter() .all(|notice| !notice.contains("geen skeleton"))); } #[test] fn test_bodies_expose_mesh_skin_and_both_clips() { assert_body("test-body-male.glb", "male"); assert_body("test-body-female.glb", "female"); let male = import_project_model(&fixture_root(), "test-body-male.glb").unwrap(); let female = import_project_model(&fixture_root(), "test-body-female.glb").unwrap(); let male_shoulder = male .joints .iter() .find(|joint| joint.name == "LeftShoulder") .unwrap(); let female_shoulder = female .joints .iter() .find(|joint| joint.name == "LeftShoulder") .unwrap(); assert_eq!(male_shoulder.parent, female_shoulder.parent); assert_ne!(male.materials[0].base_color, female.materials[0].base_color); } #[test] fn missing_gltf_texture_and_escape_are_errors() { let root = std::env::temp_dir().join(format!("paperdoll-gltf-{}", std::process::id())); let _ = fs::remove_dir_all(&root); fs::create_dir_all(&root).unwrap(); fs::write( root.join("missing.gltf"), r#"{ "asset": {"version": "2.0"}, "buffers": [{"uri": "missing.bin", "byteLength": 4}], "images": [{"uri": "missing.png"}] }"#, ) .unwrap(); let error = import_project_model(&root, "missing.gltf").unwrap_err(); let text = error.to_string(); assert!( text.contains("missing.bin") || text.contains("missing.png") || text.contains("mislukt"), "{text}" ); fs::write( root.join("escape.gltf"), r#"{ "asset": {"version": "2.0"}, "images": [{"uri": "../secret.png"}] }"#, ) .unwrap(); let error = import_project_model(&root, "escape.gltf").unwrap_err(); assert!(error.to_string().contains("niet gevolgd"), "{error}"); let error = import_project_model(&root, "absent.glb").unwrap_err(); assert!(error.to_string().contains("ontbreekt")); let _ = fs::remove_dir_all(&root); } #[test] fn obj_with_mtl_and_texture_is_static_and_missing_texture_fails() { let root = std::env::temp_dir().join(format!("paperdoll-obj-{}", std::process::id())); let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join("textures")).unwrap(); fs::write(root.join("textures/blade.png"), tiny_png()).unwrap(); fs::write( root.join("sword.mtl"), "newmtl steel\nKd 0.7 0.7 0.8\nmap_Kd textures/blade.png\n", ) .unwrap(); fs::write( root.join("sword.obj"), "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", ) .unwrap(); let model = import_project_model(&root, "sword.obj").unwrap(); assert_eq!(model.format, ModelFormat::Obj); assert_eq!(model.meshes.len(), 1); assert_eq!(model.meshes[0].triangle_count, 1); assert!(model.meshes[0].has_uvs); assert!(model.meshes[0].has_normals); assert!(!model.meshes[0].has_skin); assert!(model.joints.is_empty()); assert!(model.clips.is_empty()); assert_eq!(model.materials.len(), 1); assert_eq!(model.materials[0].name, "steel"); assert_eq!( model.materials[0].base_color_texture.as_deref(), Some("textures/blade.png") ); assert_eq!(model.textures[0].width, 1); assert_eq!(model.textures[0].height, 1); let notice = model.notices.join(" "); assert!(notice.contains("geen skeleton")); assert!(notice.contains("geen skeletal animation clips")); fs::remove_file(root.join("textures/blade.png")).unwrap(); let error = import_project_model(&root, "sword.obj").unwrap_err(); assert!(error.to_string().contains("texture"), "{error}"); fs::remove_file(root.join("sword.mtl")).unwrap(); let error = import_project_model(&root, "sword.obj").unwrap_err(); assert!(error.to_string().contains("MTL"), "{error}"); let _ = fs::remove_dir_all(&root); } #[test] fn fbx_is_refused_without_pretending_to_parse() { let root = std::env::temp_dir().join(format!("paperdoll-fbx-{}", std::process::id())); let _ = fs::remove_dir_all(&root); fs::create_dir_all(&root).unwrap(); fs::write(root.join("hero.fbx"), b"not a real fbx").unwrap(); let error = import_project_model(&root, "hero.fbx").unwrap_err(); let text = error.to_string(); assert!(text.contains("Assimp")); assert!(text.contains("niet geïmporteerd")); assert!(!text.contains("walk")); assert!(!text.contains("geïmporteerd: 0 clips")); let _ = fs::remove_dir_all(&root); } fn tiny_png() -> Vec { vec![ 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 2, 0, 0, 0, 144, 119, 83, 222, 0, 0, 0, 12, 73, 68, 65, 84, 120, 156, 99, 56, 177, 69, 3, 0, 3, 236, 1, 165, 147, 223, 177, 106, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, ] } }