Merge feature/model-adoption: model + sidecar adoption with provenance (#11)
@@ -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 --locked85
85
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.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 project89
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
## Animaties92
## Animaties89
93
90
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.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.@@ -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 files2
//! (glTF buffers/images, OBJ material libraries and textures) into the3
//! project, with a provenance record (source path + SHA-256 of every copied4
//! 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 = model21
.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]/`, keeping95
/// the relative layout, and write `<model><PROVENANCE_SUFFIX>`. Returns the96
/// project-relative model path. A model already inside the project is used97
/// 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 = source115
.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 to178
/// `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 = model181
.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!(project329
.0330
.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
}@@ -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::{17
use crate::preview_gl::Viewport;17
use crate::preview_gl::Viewport;18
use crate::project::open_project;18
use crate::project::open_project;19
use crate::render_job::{spawn, ImageBuf, RenderEvent, RenderJob, RenderUiState};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
pub struct EditorApp {22
pub 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();@@ -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/identity7
//! Completed export runs are opened through `export_set` with checksum/identity8
//! 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
10
pub mod adopt;10
pub mod atlas;11
pub mod atlas;11
pub mod camera;12
pub mod camera;12
pub mod compositor;13
pub mod compositor;