| 1 | //! Adopting an external body model together with its sidecar files |
| 2 | //! (glTF buffers/images, OBJ material libraries and textures) into the |
| 3 | //! project, with a provenance record (source path + SHA-256 of every copied |
| 4 | //! file). Missing sidecars are reported by name before anything is copied. |
| 5 | use crate::digest; |
| 6 | use std::{ |
| 7 | collections::BTreeSet, |
| 8 | fs, |
| 9 | path::{Path, PathBuf}, |
| 10 | }; |
| 11 | |
| 12 | /// Largest model/sidecar file copied (bytes). |
| 13 | const MAX_FILE: u64 = 512 * 1024 * 1024; |
| 14 | /// Provenance file written next to an adopted model. |
| 15 | pub const PROVENANCE_SUFFIX: &str = ".provenance.json"; |
| 16 | |
| 17 | /// Relative sidecar paths a model refers to (deduplicated, `/`-separated). |
| 18 | /// Data URIs and embedded GLB buffers are not sidecars. |
| 19 | pub fn sidecars(model: &Path) -> Result<Vec<String>, String> { |
| 20 | let ext = model |
| 21 | .extension() |
| 22 | .and_then(|e| e.to_str()) |
| 23 | .map(str::to_ascii_lowercase) |
| 24 | .unwrap_or_default(); |
| 25 | let mut out = BTreeSet::new(); |
| 26 | match ext.as_str() { |
| 27 | "gltf" | "glb" => { |
| 28 | let bytes = read(model)?; |
| 29 | let json: serde_json::Value = if ext == "glb" { |
| 30 | glb_json(&bytes)? |
| 31 | } else { |
| 32 | serde_json::from_slice(&bytes).map_err(|e| format!("glTF JSON: {e}"))? |
| 33 | }; |
| 34 | for key in ["buffers", "images"] { |
| 35 | for item in json[key].as_array().into_iter().flatten() { |
| 36 | if let Some(uri) = item["uri"].as_str() { |
| 37 | if !uri.starts_with("data:") { |
| 38 | out.insert(percent_decode(uri)); |
| 39 | } |
| 40 | } |
| 41 | } |
| 42 | } |
| 43 | } |
| 44 | "obj" => { |
| 45 | let text = String::from_utf8_lossy(&read(model)?).to_string(); |
| 46 | let dir = model.parent().unwrap_or(Path::new(".")); |
| 47 | for line in text.lines() { |
| 48 | if let Some(rest) = line.trim().strip_prefix("mtllib ") { |
| 49 | let mtl = rest.trim().replace('\\', "/"); |
| 50 | out.insert(mtl.clone()); |
| 51 | if let Ok(bytes) = fs::read(dir.join(&mtl)) { |
| 52 | let mtl_dir = Path::new(&mtl).parent().map(|p| p.to_path_buf()); |
| 53 | for l in String::from_utf8_lossy(&bytes).lines() { |
| 54 | let l = l.trim(); |
| 55 | let lower = l.to_ascii_lowercase(); |
| 56 | if lower.starts_with("map_") |
| 57 | || lower.starts_with("bump ") |
| 58 | || lower.starts_with("norm ") |
| 59 | { |
| 60 | // Last token is the file (options come first). |
| 61 | if let Some(file) = l.split_whitespace().last() { |
| 62 | let file = file.replace('\\', "/"); |
| 63 | let rel = match &mtl_dir { |
| 64 | Some(d) if !d.as_os_str().is_empty() => { |
| 65 | format!("{}/{file}", d.to_string_lossy()) |
| 66 | } |
| 67 | _ => file, |
| 68 | }; |
| 69 | out.insert(rel); |
| 70 | } |
| 71 | } |
| 72 | } |
| 73 | } |
| 74 | } |
| 75 | } |
| 76 | } |
| 77 | _ => {} |
| 78 | } |
| 79 | for rel in &out { |
| 80 | check_relative(rel)?; |
| 81 | } |
| 82 | Ok(out.into_iter().collect()) |
| 83 | } |
| 84 | |
| 85 | /// Sidecars that do not exist next to `model` (relink diagnostics). |
| 86 | pub fn missing_sidecars(model: &Path) -> Result<Vec<String>, String> { |
| 87 | let dir = model.parent().unwrap_or(Path::new(".")); |
| 88 | Ok(sidecars(model)? |
| 89 | .into_iter() |
| 90 | .filter(|rel| !dir.join(rel).is_file()) |
| 91 | .collect()) |
| 92 | } |
| 93 | |
| 94 | /// Copy `source` and its sidecars into `root/<folder>/<stem>[-n]/`, keeping |
| 95 | /// the relative layout, and write `<model><PROVENANCE_SUFFIX>`. Returns the |
| 96 | /// project-relative model path. A model already inside the project is used |
| 97 | /// in place (after the sidecar check). |
| 98 | pub fn adopt_model(root: &Path, source: &Path, folder: &str) -> Result<String, String> { |
| 99 | if !source.is_file() { |
| 100 | return Err("kies een bestand".into()); |
| 101 | } |
| 102 | let missing = missing_sidecars(source)?; |
| 103 | if !missing.is_empty() { |
| 104 | return Err(format!( |
| 105 | "bijbehorende bestanden ontbreken naast het model: {}", |
| 106 | missing.join(", ") |
| 107 | )); |
| 108 | } |
| 109 | if let (Ok(src), Ok(r)) = (source.canonicalize(), root.canonicalize()) { |
| 110 | if src.starts_with(&r) { |
| 111 | return crate::session::adopt_file(root, source, folder); |
| 112 | } |
| 113 | } |
| 114 | let name = source |
| 115 | .file_name() |
| 116 | .and_then(|n| n.to_str()) |
| 117 | .ok_or("de bestandsnaam is niet bruikbaar")?; |
| 118 | let stem = Path::new(name) |
| 119 | .file_stem() |
| 120 | .map(|s| s.to_string_lossy().to_string()) |
| 121 | .unwrap_or_else(|| "model".into()); |
| 122 | let mut sub = format!("{folder}/{stem}"); |
| 123 | let mut n = 2; |
| 124 | while root.join(&sub).exists() { |
| 125 | sub = format!("{folder}/{stem}-{n}"); |
| 126 | n += 1; |
| 127 | } |
| 128 | crate::project::portable_relative(&sub)?; |
| 129 | let dest_dir = root.join(&sub); |
| 130 | let src_dir = source.parent().unwrap_or(Path::new(".")); |
| 131 | let mut files = vec![name.to_string()]; |
| 132 | files.extend(sidecars(source)?); |
| 133 | let staging = root.join(format!("{sub}.adopting")); |
| 134 | let _ = fs::remove_dir_all(&staging); |
| 135 | let result = (|| -> Result<Vec<serde_json::Value>, String> { |
| 136 | let mut record = Vec::new(); |
| 137 | for rel in &files { |
| 138 | let from = src_dir.join(rel); |
| 139 | let meta = fs::metadata(&from).map_err(|e| format!("{rel}: {e}"))?; |
| 140 | if !meta.is_file() || meta.len() > MAX_FILE { |
| 141 | return Err(format!("{rel}: geen gewoon bestand of te groot")); |
| 142 | } |
| 143 | let to = staging.join(rel); |
| 144 | if let Some(parent) = to.parent() { |
| 145 | fs::create_dir_all(parent).map_err(|e| e.to_string())?; |
| 146 | } |
| 147 | fs::copy(&from, &to).map_err(|e| format!("{rel}: {e}"))?; |
| 148 | record.push(serde_json::json!({ |
| 149 | "path": rel, |
| 150 | "sha256": digest::sha256_file(&to, MAX_FILE)?, |
| 151 | })); |
| 152 | } |
| 153 | Ok(record) |
| 154 | })(); |
| 155 | let record = match result { |
| 156 | Ok(r) => r, |
| 157 | Err(e) => { |
| 158 | let _ = fs::remove_dir_all(&staging); |
| 159 | return Err(e); |
| 160 | } |
| 161 | }; |
| 162 | let provenance = serde_json::json!({ |
| 163 | "format_version": 1, |
| 164 | "source": source.canonicalize().unwrap_or_else(|_| source.to_path_buf()).to_string_lossy(), |
| 165 | "model": name, |
| 166 | "files": record, |
| 167 | }); |
| 168 | fs::write( |
| 169 | staging.join(format!("{name}{PROVENANCE_SUFFIX}")), |
| 170 | serde_json::to_vec_pretty(&provenance).map_err(|e| e.to_string())?, |
| 171 | ) |
| 172 | .map_err(|e| e.to_string())?; |
| 173 | fs::rename(&staging, &dest_dir).map_err(|e| e.to_string())?; |
| 174 | Ok(format!("{sub}/{name}")) |
| 175 | } |
| 176 | |
| 177 | /// Re-hash the adopted files listed in the provenance record next to |
| 178 | /// `model`; returns the files whose content no longer matches (or are gone). |
| 179 | pub fn changed_since_adoption(model: &Path) -> Result<Vec<String>, String> { |
| 180 | let name = model |
| 181 | .file_name() |
| 182 | .and_then(|n| n.to_str()) |
| 183 | .ok_or("ongeldig pad")?; |
| 184 | let dir = model.parent().unwrap_or(Path::new(".")); |
| 185 | let record: serde_json::Value = |
| 186 | serde_json::from_slice(&read(&dir.join(format!("{name}{PROVENANCE_SUFFIX}")))?) |
| 187 | .map_err(|e| e.to_string())?; |
| 188 | let mut changed = Vec::new(); |
| 189 | for f in record["files"].as_array().into_iter().flatten() { |
| 190 | let rel = f["path"].as_str().unwrap_or_default(); |
| 191 | check_relative(rel)?; |
| 192 | let ok = digest::sha256_file(&dir.join(rel), MAX_FILE) |
| 193 | .map(|h| Some(h.as_str()) == f["sha256"].as_str()) |
| 194 | .unwrap_or(false); |
| 195 | if !ok { |
| 196 | changed.push(rel.to_string()); |
| 197 | } |
| 198 | } |
| 199 | Ok(changed) |
| 200 | } |
| 201 | |
| 202 | fn read(path: &Path) -> Result<Vec<u8>, String> { |
| 203 | let meta = fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?; |
| 204 | if meta.len() > MAX_FILE { |
| 205 | return Err("bestand te groot".into()); |
| 206 | } |
| 207 | fs::read(path).map_err(|e| e.to_string()) |
| 208 | } |
| 209 | |
| 210 | fn glb_json(bytes: &[u8]) -> Result<serde_json::Value, String> { |
| 211 | if bytes.len() < 20 || &bytes[0..4] != b"glTF" || &bytes[16..20] != b"JSON" { |
| 212 | return Err("geen geldige GLB".into()); |
| 213 | } |
| 214 | let len = u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]) as usize; |
| 215 | let json = bytes.get(20..20 + len).ok_or("GLB JSON-chunk afgekapt")?; |
| 216 | serde_json::from_slice(json).map_err(|e| format!("GLB JSON: {e}")) |
| 217 | } |
| 218 | |
| 219 | fn percent_decode(uri: &str) -> String { |
| 220 | let b = uri.as_bytes(); |
| 221 | let mut out = Vec::with_capacity(b.len()); |
| 222 | let mut i = 0; |
| 223 | while i < b.len() { |
| 224 | if b[i] == b'%' && i + 2 < b.len() { |
| 225 | if let Ok(v) = u8::from_str_radix(&uri[i + 1..i + 3], 16) { |
| 226 | out.push(v); |
| 227 | i += 3; |
| 228 | continue; |
| 229 | } |
| 230 | } |
| 231 | out.push(b[i]); |
| 232 | i += 1; |
| 233 | } |
| 234 | String::from_utf8_lossy(&out).replace('\\', "/") |
| 235 | } |
| 236 | |
| 237 | /// Sidecars must stay inside the model folder. |
| 238 | fn check_relative(rel: &str) -> Result<(), String> { |
| 239 | let p = PathBuf::from(rel); |
| 240 | if rel.is_empty() |
| 241 | || p.is_absolute() |
| 242 | || rel.contains(':') |
| 243 | || p.components() |
| 244 | .any(|c| !matches!(c, std::path::Component::Normal(_))) |
| 245 | { |
| 246 | return Err(format!( |
| 247 | "verwijzing '{rel}' ligt buiten de modelmap; kopieer de bestanden naast het model" |
| 248 | )); |
| 249 | } |
| 250 | Ok(()) |
| 251 | } |
| 252 | |
| 253 | #[cfg(test)] |
| 254 | mod tests { |
| 255 | use super::*; |
| 256 | |
| 257 | struct Temp(PathBuf); |
| 258 | impl Temp { |
| 259 | fn new() -> Self { |
| 260 | static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); |
| 261 | let p = std::env::temp_dir().join(format!( |
| 262 | "ppd-adopt-{}-{}", |
| 263 | std::process::id(), |
| 264 | N.fetch_add(1, std::sync::atomic::Ordering::Relaxed) |
| 265 | )); |
| 266 | let _ = fs::remove_dir_all(&p); |
| 267 | fs::create_dir_all(&p).unwrap(); |
| 268 | Self(p) |
| 269 | } |
| 270 | } |
| 271 | impl Drop for Temp { |
| 272 | fn drop(&mut self) { |
| 273 | let _ = fs::remove_dir_all(&self.0); |
| 274 | } |
| 275 | } |
| 276 | fn fixtures() -> PathBuf { |
| 277 | Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") |
| 278 | } |
| 279 | |
| 280 | #[test] |
| 281 | fn obj_is_adopted_with_mtl_and_texture_and_provenance_and_still_loads() { |
| 282 | let project = Temp::new(); |
| 283 | let src = fixtures().join("bodies/static/static-body-male.obj"); |
| 284 | assert_eq!( |
| 285 | sidecars(&src).unwrap(), |
| 286 | vec!["static-body-albedo.png", "static-body-male.mtl"] |
| 287 | ); |
| 288 | let rel = adopt_model(&project.0, &src, "assets/body").unwrap(); |
| 289 | assert_eq!(rel, "assets/body/static-body-male/static-body-male.obj"); |
| 290 | let model = project.0.join(&rel); |
| 291 | for f in ["static-body-male.mtl", "static-body-albedo.png"] { |
| 292 | assert!(model.with_file_name(f).is_file(), "{f}"); |
| 293 | } |
| 294 | crate::preview::load_body_file(&model).unwrap(); |
| 295 | assert!(changed_since_adoption(&model).unwrap().is_empty()); |
| 296 | // A second adoption never overwrites the first. |
| 297 | let again = adopt_model(&project.0, &src, "assets/body").unwrap(); |
| 298 | assert_eq!(again, "assets/body/static-body-male-2/static-body-male.obj"); |
| 299 | // Editing an adopted file is detected. |
| 300 | fs::write(model.with_file_name("static-body-albedo.png"), b"x").unwrap(); |
| 301 | assert_eq!( |
| 302 | changed_since_adoption(&model).unwrap(), |
| 303 | vec!["static-body-albedo.png"] |
| 304 | ); |
| 305 | } |
| 306 | |
| 307 | #[test] |
| 308 | fn missing_or_escaping_sidecars_are_reported_and_nothing_is_copied() { |
| 309 | let project = Temp::new(); |
| 310 | let src = Temp::new(); |
| 311 | let gltf = src.0.join("m.gltf"); |
| 312 | fs::write( |
| 313 | &gltf, |
| 314 | br#"{"asset":{"version":"2.0"},"buffers":[{"uri":"m%20data.bin","byteLength":4}],"images":[{"uri":"tex/skin.png"},{"uri":"data:image/png;base64,AA=="}]}"#, |
| 315 | ) |
| 316 | .unwrap(); |
| 317 | assert_eq!(sidecars(&gltf).unwrap(), vec!["m data.bin", "tex/skin.png"]); |
| 318 | let err = adopt_model(&project.0, &gltf, "assets/body").unwrap_err(); |
| 319 | assert!( |
| 320 | err.contains("m data.bin") && err.contains("tex/skin.png"), |
| 321 | "{err}" |
| 322 | ); |
| 323 | assert!(!project.0.join("assets").exists()); |
| 324 | fs::write(src.0.join("m data.bin"), [0u8; 4]).unwrap(); |
| 325 | fs::create_dir_all(src.0.join("tex")).unwrap(); |
| 326 | fs::write(src.0.join("tex/skin.png"), b"png").unwrap(); |
| 327 | let rel = adopt_model(&project.0, &gltf, "assets/body").unwrap(); |
| 328 | assert!(project |
| 329 | .0 |
| 330 | .join(&rel) |
| 331 | .with_file_name("tex/skin.png") |
| 332 | .is_file()); |
| 333 | assert!(project.0.join(&rel).with_file_name("m data.bin").is_file()); |
| 334 | let escape = src.0.join("e.gltf"); |
| 335 | fs::write(&escape, br#"{"buffers":[{"uri":"../outside.bin"}]}"#).unwrap(); |
| 336 | assert!(adopt_model(&project.0, &escape, "assets/body") |
| 337 | .unwrap_err() |
| 338 | .contains("buiten de modelmap")); |
| 339 | // Self-contained GLB has no sidecars. |
| 340 | let glb = fixtures().join("bodies/test-body-male.glb"); |
| 341 | assert!(sidecars(&glb).unwrap().is_empty()); |
| 342 | } |
| 343 | } |