BlueGit

golden-age paperdoll-renderer

Merge feature/model-adoption: model + sidecar adoption with provenance (#11)

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 7 days ago

Hash
386287922cb57c557ebba00dc84539ae83b7249c
Pipeline
Passed
4 files changed +360 −4
README.md +4 −0
@@ -85,6 +85,10 @@ cargo run --example equipment_fixture --locked
85 85
86 86 Bij sluiten of **Ander project / Nieuw project** met wijzigingen kies je **Opslaan**, **Niet opslaan** of **Annuleren**. Als opslaan mislukt, blijft het project open. Annuleren in de bestandkiezer behoudt je huidige werk.
87 87
88 ## Modellen in het project
89
90 **Kies man/vrouw** kopieert een model van buiten het project naar een eigen map `assets/body/<naam>/`, samen met alle bijbehorende bestanden: bij glTF de externe buffers en afbeeldingen (`uri`, behalve `data:`), bij OBJ de `mtllib`-bestanden en de texturen daaruit (`map_*`, `bump`, `norm`). De relatieve indeling blijft gelijk. Ontbreekt er een bijbehorend bestand, of wijst een verwijzing buiten de modelmap (`..`, absoluut pad), dan wordt niets gekopieerd en noemt de melding de bestanden. Naast het model komt `<model>.provenance.json` met het bronpad en de SHA-256 van elk gekopieerd bestand. Een tweede keer hetzelfde model kiezen maakt `<naam>-2/`; niets wordt overschreven. Een model dat al in het project staat wordt op zijn plaats gebruikt. Kan een projectmodel niet laden, dan noemt de foutmelding ook de ontbrekende bijbehorende bestanden. Assen, eenheden en windingvolgorde worden bij het laden één keer genormaliseerd (FBX via ufbx); de bronbestanden blijven ongewijzigd.
91
88 92 ## Animaties
89 93
90 94 Alle clips uit het bronbestand worden gebruikt. glTF-interpolatie blijft behouden: LINEAR (lineair, rotaties via slerp), STEP (houdt de vorige key vast) en CUBICSPLINE (Hermite met in/uit-tangenten, geschaald met het key-interval; rotaties genormaliseerd). Kanalen met niet-oplopende of niet-eindige tijden, niet-eindige waarden of een verkeerd aantal waarden laten het laden mislukken met een duidelijke melding in plaats van stil verkeerd af te spelen. Morph-target-gewichten worden niet toegepast; dat wordt per clip gemeld bij het renderen. Clips zonder naam heten `animatie <index>`; dubbele namen blijven apart (productie kiest clips op index). In de live preview wint een exacte clipnaam boven een gedeeltelijke match. Rootmotion blijft behouden zoals in de bron. FBX-clips worden door ufbx gebakken en lineair afgespeeld.
@@ -85,6 +85,10 @@ cargo run --example equipment_fixture --locked
85
85
86Bij sluiten of **Ander project / Nieuw project** met wijzigingen kies je **Opslaan**, **Niet opslaan** of **Annuleren**. Als opslaan mislukt, blijft het project open. Annuleren in de bestandkiezer behoudt je huidige werk.
86Bij sluiten of **Ander project / Nieuw project** met wijzigingen kies je **Opslaan**, **Niet opslaan** of **Annuleren**. Als opslaan mislukt, blijft het project open. Annuleren in de bestandkiezer behoudt je huidige werk.
87
87
88## Modellen in het project
89
90**Kies man/vrouw** kopieert een model van buiten het project naar een eigen map `assets/body/<naam>/`, samen met alle bijbehorende bestanden: bij glTF de externe buffers en afbeeldingen (`uri`, behalve `data:`), bij OBJ de `mtllib`-bestanden en de texturen daaruit (`map_*`, `bump`, `norm`). De relatieve indeling blijft gelijk. Ontbreekt er een bijbehorend bestand, of wijst een verwijzing buiten de modelmap (`..`, absoluut pad), dan wordt niets gekopieerd en noemt de melding de bestanden. Naast het model komt `<model>.provenance.json` met het bronpad en de SHA-256 van elk gekopieerd bestand. Een tweede keer hetzelfde model kiezen maakt `<naam>-2/`; niets wordt overschreven. Een model dat al in het project staat wordt op zijn plaats gebruikt. Kan een projectmodel niet laden, dan noemt de foutmelding ook de ontbrekende bijbehorende bestanden. Assen, eenheden en windingvolgorde worden bij het laden één keer genormaliseerd (FBX via ufbx); de bronbestanden blijven ongewijzigd.
91
88## Animaties
92## Animaties
89
93
90Alle clips uit het bronbestand worden gebruikt. glTF-interpolatie blijft behouden: LINEAR (lineair, rotaties via slerp), STEP (houdt de vorige key vast) en CUBICSPLINE (Hermite met in/uit-tangenten, geschaald met het key-interval; rotaties genormaliseerd). Kanalen met niet-oplopende of niet-eindige tijden, niet-eindige waarden of een verkeerd aantal waarden laten het laden mislukken met een duidelijke melding in plaats van stil verkeerd af te spelen. Morph-target-gewichten worden niet toegepast; dat wordt per clip gemeld bij het renderen. Clips zonder naam heten `animatie <index>`; dubbele namen blijven apart (productie kiest clips op index). In de live preview wint een exacte clipnaam boven een gedeeltelijke match. Rootmotion blijft behouden zoals in de bron. FBX-clips worden door ufbx gebakken en lineair afgespeeld.
94Alle clips uit het bronbestand worden gebruikt. glTF-interpolatie blijft behouden: LINEAR (lineair, rotaties via slerp), STEP (houdt de vorige key vast) en CUBICSPLINE (Hermite met in/uit-tangenten, geschaald met het key-interval; rotaties genormaliseerd). Kanalen met niet-oplopende of niet-eindige tijden, niet-eindige waarden of een verkeerd aantal waarden laten het laden mislukken met een duidelijke melding in plaats van stil verkeerd af te spelen. Morph-target-gewichten worden niet toegepast; dat wordt per clip gemeld bij het renderen. Clips zonder naam heten `animatie <index>`; dubbele namen blijven apart (productie kiest clips op index). In de live preview wint een exacte clipnaam boven een gedeeltelijke match. Rootmotion blijft behouden zoals in de bron. FBX-clips worden door ufbx gebakken en lineair afgespeeld.
src/adopt.rs added +343 −0
@@ -0,0 +1,343 @@
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 }
@@ -0,0 +1,343 @@
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}
src/editor.rs +12 −4
@@ -17,7 +17,7 @@ use crate::preview2d::{
17 17 use crate::preview_gl::Viewport;
18 18 use crate::project::open_project;
19 19 use crate::render_job::{spawn, ImageBuf, RenderEvent, RenderJob, RenderUiState};
20 use crate::session::{adopt_file, AfterChoice, Intent, Screen, Session, Sex};
20 use crate::session::{AfterChoice, Intent, Screen, Session, Sex};
21 21
22 22 pub struct EditorApp {
23 23 session: Session,
@@ -218,8 +218,16 @@ impl EditorApp {
218 218 };
219 219 for (index, sex) in [Sex::Male, Sex::Female].into_iter().enumerate() {
220 220 if let Some(relative) = self.session.body_path(sex) {
221 let imported =
222 import_project_model(&root, relative).map_err(|error| error.to_string());
221 let imported = import_project_model(&root, relative).map_err(|error| {
222 // Relink diagnostics: name the sidecars that are missing.
223 match crate::adopt::missing_sidecars(&root.join(relative)) {
224 Ok(missing) if !missing.is_empty() => format!(
225 "{error} (ontbrekende bijbehorende bestanden: {})",
226 missing.join(", ")
227 ),
228 _ => error.to_string(),
229 }
230 });
223 231 // Validate the source and its dependencies before the viewport reads it.
224 232 if imported.is_ok() && is_drawable_body(std::path::Path::new(relative)) {
225 233 match load_body_file(&root.join(relative)) {
@@ -319,7 +327,7 @@ impl EditorApp {
319 327 let Some(root) = self.session.project_root() else {
320 328 return;
321 329 };
322 match adopt_file(&root, path, "assets/body") {
330 match crate::adopt::adopt_model(&root, path, "assets/body") {
323 331 Ok(relative) => {
324 332 self.session.set_body_path(sex, relative.clone());
325 333 self.reload_bodies();
@@ -17,7 +17,7 @@ use crate::preview2d::{
17use crate::preview_gl::Viewport;
17use crate::preview_gl::Viewport;
18use crate::project::open_project;
18use crate::project::open_project;
19use crate::render_job::{spawn, ImageBuf, RenderEvent, RenderJob, RenderUiState};
19use crate::render_job::{spawn, ImageBuf, RenderEvent, RenderJob, RenderUiState};
20use crate::session::{adopt_file, AfterChoice, Intent, Screen, Session, Sex};
20use crate::session::{AfterChoice, Intent, Screen, Session, Sex};
21
21
22pub struct EditorApp {
22pub struct EditorApp {
23 session: Session,
23 session: Session,
@@ -218,8 +218,16 @@ impl EditorApp {
218 };
218 };
219 for (index, sex) in [Sex::Male, Sex::Female].into_iter().enumerate() {
219 for (index, sex) in [Sex::Male, Sex::Female].into_iter().enumerate() {
220 if let Some(relative) = self.session.body_path(sex) {
220 if let Some(relative) = self.session.body_path(sex) {
221 let imported =
221 let imported = import_project_model(&root, relative).map_err(|error| {
222 import_project_model(&root, relative).map_err(|error| error.to_string());
222 // Relink diagnostics: name the sidecars that are missing.
223 match crate::adopt::missing_sidecars(&root.join(relative)) {
224 Ok(missing) if !missing.is_empty() => format!(
225 "{error} (ontbrekende bijbehorende bestanden: {})",
226 missing.join(", ")
227 ),
228 _ => error.to_string(),
229 }
230 });
223 // Validate the source and its dependencies before the viewport reads it.
231 // Validate the source and its dependencies before the viewport reads it.
224 if imported.is_ok() && is_drawable_body(std::path::Path::new(relative)) {
232 if imported.is_ok() && is_drawable_body(std::path::Path::new(relative)) {
225 match load_body_file(&root.join(relative)) {
233 match load_body_file(&root.join(relative)) {
@@ -319,7 +327,7 @@ impl EditorApp {
319 let Some(root) = self.session.project_root() else {
327 let Some(root) = self.session.project_root() else {
320 return;
328 return;
321 };
329 };
322 match adopt_file(&root, path, "assets/body") {
330 match crate::adopt::adopt_model(&root, path, "assets/body") {
323 Ok(relative) => {
331 Ok(relative) => {
324 self.session.set_body_path(sex, relative.clone());
332 self.session.set_body_path(sex, relative.clone());
325 self.reload_bodies();
333 self.reload_bodies();
src/lib.rs +1 −0
@@ -7,6 +7,7 @@
7 7 //! Completed export runs are opened through `export_set` with checksum/identity
8 8 //! validation; `atlas` writes and validates the per-map atlases + `.ppda` consumer format; `compositor` depth-composites compatible layers and relights them; `preview2d` drives the rendered 2D GUI/tab/playback with view modes; `resume` recovers incomplete runs after contract and checksum verification.
9 9
10 pub mod adopt;
10 11 pub mod atlas;
11 12 pub mod camera;
12 13 pub mod compositor;
@@ -7,6 +7,7 @@
7//! Completed export runs are opened through `export_set` with checksum/identity
7//! Completed export runs are opened through `export_set` with checksum/identity
8//! validation; `atlas` writes and validates the per-map atlases + `.ppda` consumer format; `compositor` depth-composites compatible layers and relights them; `preview2d` drives the rendered 2D GUI/tab/playback with view modes; `resume` recovers incomplete runs after contract and checksum verification.
8//! validation; `atlas` writes and validates the per-map atlases + `.ppda` consumer format; `compositor` depth-composites compatible layers and relights them; `preview2d` drives the rendered 2D GUI/tab/playback with view modes; `resume` recovers incomplete runs after contract and checksum verification.
9
9
10pub mod adopt;
10pub mod atlas;
11pub mod atlas;
11pub mod camera;
12pub mod camera;
12pub mod compositor;
13pub mod compositor;