//! Adopting an external body model together with its sidecar files //! (glTF buffers/images, OBJ material libraries and textures) into the //! project, with a provenance record (source path + SHA-256 of every copied //! file). Missing sidecars are reported by name before anything is copied. use crate::digest; use std::{ collections::BTreeSet, fs, path::{Path, PathBuf}, }; /// Largest model/sidecar file copied (bytes). const MAX_FILE: u64 = 512 * 1024 * 1024; /// Provenance file written next to an adopted model. pub const PROVENANCE_SUFFIX: &str = ".provenance.json"; /// Relative sidecar paths a model refers to (deduplicated, `/`-separated). /// Data URIs and embedded GLB buffers are not sidecars. pub fn sidecars(model: &Path) -> Result, String> { let ext = model .extension() .and_then(|e| e.to_str()) .map(str::to_ascii_lowercase) .unwrap_or_default(); let mut out = BTreeSet::new(); match ext.as_str() { "gltf" | "glb" => { let bytes = read(model)?; let json: serde_json::Value = if ext == "glb" { glb_json(&bytes)? } else { serde_json::from_slice(&bytes).map_err(|e| format!("glTF JSON: {e}"))? }; for key in ["buffers", "images"] { for item in json[key].as_array().into_iter().flatten() { if let Some(uri) = item["uri"].as_str() { if !uri.starts_with("data:") { out.insert(percent_decode(uri)); } } } } } "obj" => { let text = String::from_utf8_lossy(&read(model)?).to_string(); let dir = model.parent().unwrap_or(Path::new(".")); for line in text.lines() { if let Some(rest) = line.trim().strip_prefix("mtllib ") { let mtl = rest.trim().replace('\\', "/"); out.insert(mtl.clone()); if let Ok(bytes) = fs::read(dir.join(&mtl)) { let mtl_dir = Path::new(&mtl).parent().map(|p| p.to_path_buf()); for l in String::from_utf8_lossy(&bytes).lines() { let l = l.trim(); let lower = l.to_ascii_lowercase(); if lower.starts_with("map_") || lower.starts_with("bump ") || lower.starts_with("norm ") { // Last token is the file (options come first). if let Some(file) = l.split_whitespace().last() { let file = file.replace('\\', "/"); let rel = match &mtl_dir { Some(d) if !d.as_os_str().is_empty() => { format!("{}/{file}", d.to_string_lossy()) } _ => file, }; out.insert(rel); } } } } } } } _ => {} } for rel in &out { check_relative(rel)?; } Ok(out.into_iter().collect()) } /// Sidecars that do not exist next to `model` (relink diagnostics). pub fn missing_sidecars(model: &Path) -> Result, String> { let dir = model.parent().unwrap_or(Path::new(".")); Ok(sidecars(model)? .into_iter() .filter(|rel| !dir.join(rel).is_file()) .collect()) } /// Copy `source` and its sidecars into `root//[-n]/`, keeping /// the relative layout, and write ``. Returns the /// project-relative model path. A model already inside the project is used /// in place (after the sidecar check). pub fn adopt_model(root: &Path, source: &Path, folder: &str) -> Result { if !source.is_file() { return Err("kies een bestand".into()); } let missing = missing_sidecars(source)?; if !missing.is_empty() { return Err(format!( "bijbehorende bestanden ontbreken naast het model: {}", missing.join(", ") )); } if let (Ok(src), Ok(r)) = (source.canonicalize(), root.canonicalize()) { if src.starts_with(&r) { return crate::session::adopt_file(root, source, folder); } } let name = source .file_name() .and_then(|n| n.to_str()) .ok_or("de bestandsnaam is niet bruikbaar")?; let stem = Path::new(name) .file_stem() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_else(|| "model".into()); let mut sub = format!("{folder}/{stem}"); let mut n = 2; while root.join(&sub).exists() { sub = format!("{folder}/{stem}-{n}"); n += 1; } crate::project::portable_relative(&sub)?; let dest_dir = root.join(&sub); let src_dir = source.parent().unwrap_or(Path::new(".")); let mut files = vec![name.to_string()]; files.extend(sidecars(source)?); let staging = root.join(format!("{sub}.adopting")); let _ = fs::remove_dir_all(&staging); let result = (|| -> Result, String> { let mut record = Vec::new(); for rel in &files { let from = src_dir.join(rel); let meta = fs::metadata(&from).map_err(|e| format!("{rel}: {e}"))?; if !meta.is_file() || meta.len() > MAX_FILE { return Err(format!("{rel}: geen gewoon bestand of te groot")); } let to = staging.join(rel); if let Some(parent) = to.parent() { fs::create_dir_all(parent).map_err(|e| e.to_string())?; } fs::copy(&from, &to).map_err(|e| format!("{rel}: {e}"))?; record.push(serde_json::json!({ "path": rel, "sha256": digest::sha256_file(&to, MAX_FILE)?, })); } Ok(record) })(); let record = match result { Ok(r) => r, Err(e) => { let _ = fs::remove_dir_all(&staging); return Err(e); } }; let provenance = serde_json::json!({ "format_version": 1, "source": source.canonicalize().unwrap_or_else(|_| source.to_path_buf()).to_string_lossy(), "model": name, "files": record, }); fs::write( staging.join(format!("{name}{PROVENANCE_SUFFIX}")), serde_json::to_vec_pretty(&provenance).map_err(|e| e.to_string())?, ) .map_err(|e| e.to_string())?; fs::rename(&staging, &dest_dir).map_err(|e| e.to_string())?; Ok(format!("{sub}/{name}")) } /// Re-hash the adopted files listed in the provenance record next to /// `model`; returns the files whose content no longer matches (or are gone). pub fn changed_since_adoption(model: &Path) -> Result, String> { let name = model .file_name() .and_then(|n| n.to_str()) .ok_or("ongeldig pad")?; let dir = model.parent().unwrap_or(Path::new(".")); let record: serde_json::Value = serde_json::from_slice(&read(&dir.join(format!("{name}{PROVENANCE_SUFFIX}")))?) .map_err(|e| e.to_string())?; let mut changed = Vec::new(); for f in record["files"].as_array().into_iter().flatten() { let rel = f["path"].as_str().unwrap_or_default(); check_relative(rel)?; let ok = digest::sha256_file(&dir.join(rel), MAX_FILE) .map(|h| Some(h.as_str()) == f["sha256"].as_str()) .unwrap_or(false); if !ok { changed.push(rel.to_string()); } } Ok(changed) } fn read(path: &Path) -> Result, String> { let meta = fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?; if meta.len() > MAX_FILE { return Err("bestand te groot".into()); } fs::read(path).map_err(|e| e.to_string()) } fn glb_json(bytes: &[u8]) -> Result { if bytes.len() < 20 || &bytes[0..4] != b"glTF" || &bytes[16..20] != b"JSON" { return Err("geen geldige GLB".into()); } let len = u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]) as usize; let json = bytes.get(20..20 + len).ok_or("GLB JSON-chunk afgekapt")?; serde_json::from_slice(json).map_err(|e| format!("GLB JSON: {e}")) } fn percent_decode(uri: &str) -> String { let b = uri.as_bytes(); let mut out = Vec::with_capacity(b.len()); let mut i = 0; while i < b.len() { if b[i] == b'%' && i + 2 < b.len() { if let Ok(v) = u8::from_str_radix(&uri[i + 1..i + 3], 16) { out.push(v); i += 3; continue; } } out.push(b[i]); i += 1; } String::from_utf8_lossy(&out).replace('\\', "/") } /// Sidecars must stay inside the model folder. fn check_relative(rel: &str) -> Result<(), String> { let p = PathBuf::from(rel); if rel.is_empty() || p.is_absolute() || rel.contains(':') || p.components() .any(|c| !matches!(c, std::path::Component::Normal(_))) { return Err(format!( "verwijzing '{rel}' ligt buiten de modelmap; kopieer de bestanden naast het model" )); } Ok(()) } #[cfg(test)] mod tests { use super::*; struct Temp(PathBuf); impl Temp { fn new() -> Self { static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); let p = std::env::temp_dir().join(format!( "ppd-adopt-{}-{}", std::process::id(), N.fetch_add(1, std::sync::atomic::Ordering::Relaxed) )); let _ = fs::remove_dir_all(&p); fs::create_dir_all(&p).unwrap(); Self(p) } } impl Drop for Temp { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } fn fixtures() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") } #[test] fn obj_is_adopted_with_mtl_and_texture_and_provenance_and_still_loads() { let project = Temp::new(); let src = fixtures().join("bodies/static/static-body-male.obj"); assert_eq!( sidecars(&src).unwrap(), vec!["static-body-albedo.png", "static-body-male.mtl"] ); let rel = adopt_model(&project.0, &src, "assets/body").unwrap(); assert_eq!(rel, "assets/body/static-body-male/static-body-male.obj"); let model = project.0.join(&rel); for f in ["static-body-male.mtl", "static-body-albedo.png"] { assert!(model.with_file_name(f).is_file(), "{f}"); } crate::preview::load_body_file(&model).unwrap(); assert!(changed_since_adoption(&model).unwrap().is_empty()); // A second adoption never overwrites the first. let again = adopt_model(&project.0, &src, "assets/body").unwrap(); assert_eq!(again, "assets/body/static-body-male-2/static-body-male.obj"); // Editing an adopted file is detected. fs::write(model.with_file_name("static-body-albedo.png"), b"x").unwrap(); assert_eq!( changed_since_adoption(&model).unwrap(), vec!["static-body-albedo.png"] ); } #[test] fn missing_or_escaping_sidecars_are_reported_and_nothing_is_copied() { let project = Temp::new(); let src = Temp::new(); let gltf = src.0.join("m.gltf"); fs::write( &gltf, br#"{"asset":{"version":"2.0"},"buffers":[{"uri":"m%20data.bin","byteLength":4}],"images":[{"uri":"tex/skin.png"},{"uri":"data:image/png;base64,AA=="}]}"#, ) .unwrap(); assert_eq!(sidecars(&gltf).unwrap(), vec!["m data.bin", "tex/skin.png"]); let err = adopt_model(&project.0, &gltf, "assets/body").unwrap_err(); assert!( err.contains("m data.bin") && err.contains("tex/skin.png"), "{err}" ); assert!(!project.0.join("assets").exists()); fs::write(src.0.join("m data.bin"), [0u8; 4]).unwrap(); fs::create_dir_all(src.0.join("tex")).unwrap(); fs::write(src.0.join("tex/skin.png"), b"png").unwrap(); let rel = adopt_model(&project.0, &gltf, "assets/body").unwrap(); assert!(project .0 .join(&rel) .with_file_name("tex/skin.png") .is_file()); assert!(project.0.join(&rel).with_file_name("m data.bin").is_file()); let escape = src.0.join("e.gltf"); fs::write(&escape, br#"{"buffers":[{"uri":"../outside.bin"}]}"#).unwrap(); assert!(adopt_model(&project.0, &escape, "assets/body") .unwrap_err() .contains("buiten de modelmap")); // Self-contained GLB has no sidecars. let glb = fixtures().join("bodies/test-body-male.glb"); assert!(sidecars(&glb).unwrap().is_empty()); } }