BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / adopt.rs

adopt.rs

adopt.rs 13.0 KiB Download Raw
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.
5use crate::digest;
6use std::{
7 collections::BTreeSet,
8 fs,
9 path::{Path, PathBuf},
10};
11
12/// Largest model/sidecar file copied (bytes).
13const MAX_FILE: u64 = 512 * 1024 * 1024;
14/// Provenance file written next to an adopted model.
15pub 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.
19pub 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).
86pub 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).
98pub 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).
179pub 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
202fn 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
210fn 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
219fn 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.
238fn 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)]
254mod 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}