BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / atlas.rs

atlas.rs

atlas.rs 70.7 KiB Download Raw
1//! Per-map atlases and the `.ppda` (PaPerDollAtlas) consumer manifest
2//! (Tim t955u/t957u).
3//!
4//! After every loose target PNG of a run is published and checksum-verified,
5//! production writes one atlas per map type (`<sex>-<settype>-<map>-atlas.png`)
6//! and a strict JSON `.ppda` (`<sex>-<settype>-atlas.ppda`) next to them in the
7//! run directory. Cells are exactly the target resolution, without padding or
8//! scaling. One row per (clip, frame) in source clip order and frame order;
9//! columns are pitch (−60→+60) × yaw (0→330). All four atlases share identical
10//! cell coordinates. Atlases are streamed band by band (one cell row at a time),
11//! published atomically without clobbering, and the `.ppda` is written last.
12//!
13//! [`AtlasSet`] is the validating consumer reader used by the 2D preview.
14use crate::{
15 digest::{self, is_sha256_hex, Sha256},
16 export_set::{self, ExportError, MapImage, MapKind, MAX_PNG_BYTES, MAX_TARGET},
17 preview::PRODUCTION_PITCHES,
18 production::{self, Plan},
19 project::CameraFraming,
20};
21use serde::{Deserialize, Serialize};
22use std::{
23 collections::BTreeMap,
24 fs,
25 io::Write,
26 path::{Path, PathBuf},
27 sync::atomic::{AtomicBool, Ordering},
28};
29
30/// `.ppda` schema version written and accepted.
31/// 2: top-level depth_near/depth_far/depth_encoding (per-sex narrow range).
32/// 3: depth_bits + depth_encoding token (gray/rgb_bands/rgb_packed); v2 is
33/// still read (16-bit gray).
34/// 4: export contract embedded. 5 (t1030u): optional trimmed views with
35/// per-view offset/frame/empty; versions 2–4 are still read.
36pub const PPDA_FORMAT_VERSION: u32 = 5;
37/// Padding around the content bounding box of a trimmed view.
38pub const TRIM_PADDING: u32 = 2;
39/// Largest atlas side in pixels (common GPU texture limit). Larger exports are
40/// refused up front with a clear message; atlases are never split.
41pub const MAX_ATLAS_DIM: u32 = 16384;
42/// Bounded `.ppda` size on read.
43pub const MAX_PPDA_BYTES: u64 = 64 * 1024 * 1024;
44/// Bounded encoded atlas PNG size on read.
45pub const MAX_ATLAS_PNG_BYTES: u64 = 4 * 1024 * 1024 * 1024;
46/// Decoded-memory budget for one loaded atlas set (four maps) in the preview.
47pub const MAX_DECODED_SET_BYTES: u64 = 2 * 1024 * 1024 * 1024;
48/// Map order inside the `.ppda` and atlas production.
49pub const ATLAS_MAPS: [MapKind; 4] = [
50 MapKind::Diffuse,
51 MapKind::Normal,
52 MapKind::Depth,
53 MapKind::Specular,
54];
55const MAX_CLIPS: usize = 4096;
56const MAX_CLIP_NAME: usize = 256;
57
58pub fn atlas_png_name(sex: &str, set: &str, kind: MapKind) -> String {
59 format!("{sex}-{set}-{}-atlas.png", kind.token())
60}
61
62pub fn ppda_name(sex: &str, set: &str) -> String {
63 format!("{sex}-{set}-atlas.ppda")
64}
65
66/// Deterministic grid shared by all four atlases of one export.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub struct AtlasLayout {
69 pub cell_width: u32,
70 pub cell_height: u32,
71 pub columns: u32,
72 pub rows: u32,
73 pub width: u32,
74 pub height: u32,
75}
76
77impl AtlasLayout {
78 pub fn new(cell_width: u32, cell_height: u32, rows: u32) -> Result<Self, String> {
79 if cell_width == 0
80 || cell_height == 0
81 || cell_width > MAX_TARGET
82 || cell_height > MAX_TARGET
83 {
84 return Err("atlascel moet 1–256 pixels per as zijn".into());
85 }
86 if rows == 0 {
87 return Err("atlas zonder rijen".into());
88 }
89 let columns = (PRODUCTION_PITCHES.len() * production::production_yaws().len()) as u32;
90 let width = u64::from(columns) * u64::from(cell_width);
91 let height = u64::from(rows) * u64::from(cell_height);
92 if width > u64::from(MAX_ATLAS_DIM) || height > u64::from(MAX_ATLAS_DIM) {
93 return Err(format!(
94 "atlas zou {width}×{height} pixels worden ({columns} hoeken × {rows} frames); \
95 maximaal {MAX_ATLAS_DIM}×{MAX_ATLAS_DIM}. Verlaag fps, targetresolutie of \
96 animatieduur — de atlas wordt niet gesplitst"
97 ));
98 }
99 Ok(Self {
100 cell_width,
101 cell_height,
102 columns,
103 rows,
104 width: width as u32,
105 height: height as u32,
106 })
107 }
108
109 pub fn for_plan(plan: &Plan, cell_width: u32, cell_height: u32) -> Result<Self, String> {
110 let rows = plan
111 .clips
112 .iter()
113 .try_fold(0u32, |n, c| n.checked_add(c.frames))
114 .ok_or("te veel frames voor de atlas")?;
115 Self::new(cell_width, cell_height, rows)
116 }
117
118 /// Column of (pitch, yaw): pitch-major, then yaw.
119 pub fn column(pitch: i32, yaw: u32) -> Option<u32> {
120 let p = PRODUCTION_PITCHES.iter().position(|x| *x == pitch)?;
121 let y = production::production_yaws()
122 .iter()
123 .position(|x| *x == yaw)?;
124 Some((p * production::production_yaws().len() + y) as u32)
125 }
126
127 pub fn cell(&self, row: u32, column: u32) -> PpdaView {
128 let yaws = production::production_yaws();
129 let pitch = PRODUCTION_PITCHES[column as usize / yaws.len()];
130 let yaw = yaws[column as usize % yaws.len()];
131 PpdaView {
132 pitch,
133 yaw,
134 x: column * self.cell_width,
135 y: row * self.cell_height,
136 w: self.cell_width,
137 h: self.cell_height,
138 offset: None,
139 frame: None,
140 empty: None,
141 }
142 }
143}
144
145/// One view's place in a (possibly trimmed) atlas.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub struct ViewRect {
148 pub x: u32,
149 pub y: u32,
150 pub w: u32,
151 pub h: u32,
152 pub ox: u32,
153 pub oy: u32,
154 pub empty: bool,
155}
156
157/// Deterministic row packing: per atlas row the views left to right without
158/// gaps; row height = tallest view; size at least 1×1. Returns the (x, y) of
159/// every view and the atlas size.
160pub fn pack(sizes: &[Vec<(u32, u32)>]) -> (Vec<Vec<(u32, u32)>>, u32, u32) {
161 let mut y = 0u32;
162 let mut width = 0u32;
163 let mut out = Vec::with_capacity(sizes.len());
164 for row in sizes {
165 let mut x = 0u32;
166 let mut height = 0u32;
167 let mut places = Vec::with_capacity(row.len());
168 for &(w, h) in row {
169 places.push((x, y));
170 x = x.saturating_add(w);
171 height = height.max(h);
172 }
173 width = width.max(x);
174 y = y.saturating_add(height);
175 out.push(places);
176 }
177 (out, width.max(1), y.max(1))
178}
179
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181#[serde(deny_unknown_fields)]
182pub struct Ppda {
183 pub format_version: u32,
184 pub sex: String,
185 pub settype: String,
186 pub project: PpdaProject,
187 /// Export/camera contract so body and equipment atlases can be checked for
188 /// compatibility without the loose run manifests.
189 pub identity: PpdaIdentity,
190 pub fps: u32,
191 /// Camera distance (m) of 16-bit depth sample 0 / 65535; see depth_encoding.
192 pub depth_near: f32,
193 pub depth_far: f32,
194 pub depth_encoding: String,
195 /// 8 or 16 (format 3); absent in format 2 (= 16-bit gray).
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub depth_bits: Option<u64>,
198 pub cell_width: u32,
199 pub cell_height: u32,
200 pub pitches: Vec<i32>,
201 pub yaws: Vec<u32>,
202 pub maps: BTreeMap<String, PpdaMap>,
203 pub clips: Vec<PpdaClip>,
204 /// Format 4 (Tim t993u/t994u): the export contract (former `render.json`)
205 /// and the SHA-256 of every loose map under `raw files/`, so a finished
206 /// folder needs no other JSON. Absent in formats 2–3.
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub export: Option<PpdaExport>,
209 /// Format 5: whether views are trimmed to their content (t1030u).
210 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub trimmed: Option<bool>,
212}
213
214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
215#[serde(deny_unknown_fields)]
216pub struct PpdaExport {
217 /// The full render contract document (what `render.json` held).
218 pub render: serde_json::Value,
219 /// Planned passes of the run.
220 pub passes: u32,
221 /// Relative loose map path (`raw files/...`) → SHA-256.
222 pub png_sha256: BTreeMap<String, String>,
223}
224
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(deny_unknown_fields)]
227pub struct PpdaProject {
228 pub id: String,
229 pub name: String,
230}
231
232#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
233#[serde(deny_unknown_fields)]
234pub struct PpdaIdentity {
235 pub body_model_sha256: String,
236 pub equipment_preset_sha256: Option<String>,
237 pub framing: CameraFraming,
238 pub pivot_source: String,
239 pub near: f32,
240 pub far: f32,
241 pub camera_distance: f32,
242 pub normal_space: String,
243 pub depth: String,
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247#[serde(deny_unknown_fields)]
248pub struct PpdaMap {
249 pub file: String,
250 pub sha256: String,
251 pub width: u32,
252 pub height: u32,
253}
254
255#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
256#[serde(deny_unknown_fields)]
257pub struct PpdaClip {
258 pub source_index: u32,
259 pub name: String,
260 pub token: String,
261 pub frame_count: u32,
262 pub duration: f32,
263 pub frames: Vec<PpdaFrame>,
264}
265
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267#[serde(deny_unknown_fields)]
268pub struct PpdaFrame {
269 pub index: u32,
270 pub views: Vec<PpdaView>,
271}
272
273#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
274#[serde(deny_unknown_fields)]
275pub struct PpdaView {
276 pub pitch: i32,
277 pub yaw: u32,
278 pub x: u32,
279 pub y: u32,
280 pub w: u32,
281 pub h: u32,
282 /// Format 5: position of the rect within the full frame.
283 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub offset: Option<[u32; 2]>,
285 /// Format 5: full frame size (= cell size).
286 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub frame: Option<[u32; 2]>,
288 /// Format 5: no content; the view takes no atlas space.
289 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub empty: Option<bool>,
291}
292
293/// What production records in `complete.json` after writing the atlases.
294#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct AtlasOutput {
296 pub ppda: String,
297 pub ppda_sha256: String,
298 /// map token → (file name, sha256)
299 pub maps: BTreeMap<String, (String, String)>,
300}
301
302impl AtlasOutput {
303 pub fn to_json(&self) -> serde_json::Value {
304 let maps: serde_json::Map<String, serde_json::Value> = self
305 .maps
306 .iter()
307 .map(|(k, (file, sha))| (k.clone(), serde_json::json!({"file": file, "sha256": sha})))
308 .collect();
309 serde_json::json!({
310 "ppda": self.ppda,
311 "ppda_sha256": self.ppda_sha256,
312 "maps": maps,
313 })
314 }
315}
316
317struct HashingWriter<W: Write> {
318 inner: W,
319 hasher: Sha256,
320}
321
322impl<W: Write> Write for HashingWriter<W> {
323 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
324 let n = self.inner.write(buf)?;
325 self.hasher.update(&buf[..n]);
326 Ok(n)
327 }
328 fn flush(&mut self) -> std::io::Result<()> {
329 self.inner.flush()
330 }
331}
332
333/// Inputs for writing the atlases of one completed run.
334pub struct AtlasJob<'a> {
335 pub root: &'a Path,
336 pub render_doc: &'a serde_json::Value,
337 pub plan: &'a Plan,
338 pub sex: &'a str,
339 pub set: &'a str,
340 pub cell_width: u32,
341 pub cell_height: u32,
342 /// Relative loose PNG path → verified SHA-256 (every planned file).
343 pub png_sha256: &'a BTreeMap<String, String>,
344 /// Resume attempt number for quarantine names of stale atlas files.
345 pub attempt: u32,
346 pub cancel: &'a AtomicBool,
347 /// Crop every view to its content (t1030u).
348 pub trim: bool,
349}
350
351fn rows_of(plan: &Plan) -> Vec<(usize, u32)> {
352 plan.clips
353 .iter()
354 .enumerate()
355 .flat_map(|(c, clip)| (0..clip.frames).map(move |f| (c, f)))
356 .collect()
357}
358
359/// Load one verified loose cell as raw 4-byte-per-pixel rows (RGBA8 or
360/// big-endian gray16+alpha16, matching the atlas PNG sample layout).
361fn load_cell(
362 job: &AtlasJob<'_>,
363 clip: &str,
364 frame: u32,
365 pitch: i32,
366 yaw: u32,
367 kind: MapKind,
368) -> Result<Vec<u8>, String> {
369 let rel = production::output_path(job.sex, job.set, clip, frame, pitch, yaw, kind.token())?;
370 let rel = rel.to_str().ok_or("ongeldig PNG-pad")?.replace('\\', "/");
371 let expected = job
372 .png_sha256
373 .get(&rel)
374 .ok_or_else(|| format!("atlas: {rel} ontbreekt in de checksums"))?;
375 let path = job.root.join(&rel);
376 export_set::verify_regular_file(&path, job.root).map_err(|e| e.to_string())?;
377 let bytes = export_set::read_bounded_file(&path, MAX_PNG_BYTES).map_err(|e| e.to_string())?;
378 if digest::sha256_hex(&bytes) != *expected {
379 return Err(format!("atlas: checksum van {rel} klopt niet meer"));
380 }
381 if kind == MapKind::Depth {
382 // Copied in its own encoding, byte-exact (lossless for every format).
383 let fmt = crate::camera::DepthFormat::from_doc(job.render_doc)?;
384 return export_set::decode_depth_native(
385 &bytes,
386 job.cell_width,
387 job.cell_height,
388 export_set::MAX_TARGET,
389 fmt,
390 )
391 .map_err(|e| format!("atlas: {rel}: {e}"));
392 }
393 match export_set::decode_map(
394 &bytes,
395 kind,
396 job.cell_width,
397 job.cell_height,
398 crate::camera::DepthFormat::GRAY16,
399 )
400 .map_err(|e| format!("atlas: {rel}: {e}"))?
401 {
402 MapImage::Rgba8 { rgba, .. } => Ok(rgba),
403 MapImage::Depth16 { .. } => Err("atlas: onverwachte depth".into()),
404 }
405}
406
407/// Stream-encode one atlas PNG into `out`.
408fn encode_atlas(
409 job: &AtlasJob<'_>,
410 layout: &AtlasLayout,
411 rects: &[Vec<ViewRect>],
412 kind: MapKind,
413 out: &mut dyn Write,
414 progress: &mut dyn FnMut(String),
415) -> Result<(), String> {
416 let mut encoder = png::Encoder::new(out, layout.width, layout.height);
417 let mut bpp = 4;
418 if kind == MapKind::Depth {
419 let fmt = crate::camera::DepthFormat::from_doc(job.render_doc)?;
420 encoder.set_color(fmt.color_type());
421 encoder.set_depth(fmt.bit_depth());
422 bpp = fmt.bytes_per_pixel();
423 } else {
424 encoder.set_color(png::ColorType::Rgba);
425 encoder.set_depth(png::BitDepth::Eight);
426 if kind == MapKind::Diffuse {
427 encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual);
428 }
429 }
430 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
431 let row_bytes = layout.width as usize * bpp;
432 let cell_w = layout.cell_width as usize;
433 let mut stream = writer
434 .stream_writer_with_size(row_bytes.max(4096))
435 .map_err(|e| e.to_string())?;
436 let rows = rows_of(job.plan);
437 let total = rows.len();
438 let mut written = 0u32;
439 for (row, (clip, frame)) in rows.into_iter().enumerate() {
440 if job.cancel.load(Ordering::Relaxed) {
441 return Err("geannuleerd".into());
442 }
443 if row % 8 == 0 {
444 progress(format!(
445 "atlas {} · rij {}/{}",
446 kind.token(),
447 row + 1,
448 total
449 ));
450 }
451 let token = &job.plan.clips[clip].token;
452 let row_h = rects[row]
453 .iter()
454 .filter(|r| !r.empty)
455 .map(|r| r.h)
456 .max()
457 .unwrap_or(0) as usize;
458 if row_h == 0 {
459 continue;
460 }
461 let mut band = vec![0u8; row_bytes * row_h];
462 for column in 0..layout.columns {
463 let r = rects[row][column as usize];
464 if r.empty {
465 continue;
466 }
467 let view = layout.cell(0, column);
468 let cell = load_cell(job, token, frame, view.pitch, view.yaw, kind)?;
469 let w = r.w as usize * bpp;
470 for y in 0..r.h as usize {
471 let src = ((r.oy as usize + y) * cell_w + r.ox as usize) * bpp;
472 let dst = y * row_bytes + r.x as usize * bpp;
473 band[dst..dst + w].copy_from_slice(&cell[src..src + w]);
474 }
475 }
476 stream.write_all(&band).map_err(|e| e.to_string())?;
477 written += row_h as u32;
478 }
479 // An atlas of only empty views is one transparent pixel row high.
480 if written < layout.height {
481 let pad = vec![0u8; row_bytes * (layout.height - written) as usize];
482 stream.write_all(&pad).map_err(|e| e.to_string())?;
483 }
484 stream.finish().map_err(|e| e.to_string())?;
485 writer.finish().map_err(|e| e.to_string())?;
486 Ok(())
487}
488
489/// Publish `name` in the run root without clobbering. When a file already
490/// exists (crash after publication during an earlier attempt) it is kept when
491/// byte-identical to the regenerated output, otherwise moved aside.
492fn publish_streamed(
493 root: &Path,
494 name: &str,
495 attempt: u32,
496 mut encode: impl FnMut(&mut dyn Write) -> Result<(), String>,
497) -> Result<String, String> {
498 let path = root.join(name);
499 if fs::symlink_metadata(&path).is_ok() {
500 let mut sink = HashingWriter {
501 inner: std::io::sink(),
502 hasher: Sha256::new(),
503 };
504 encode(&mut sink)?;
505 let fresh = digest::hex(&sink.hasher.finalize());
506 let regular = fs::symlink_metadata(&path)
507 .map(|m| m.file_type().is_file())
508 .unwrap_or(false);
509 if regular
510 && digest::sha256_file(&path, MAX_ATLAS_PNG_BYTES)
511 .ok()
512 .as_deref()
513 == Some(fresh.as_str())
514 {
515 return Ok(fresh);
516 }
517 crate::resume::quarantine(root, name, attempt)?;
518 }
519 let mut digest_out = None;
520 production::atomic_file(root, Path::new(name), |file| {
521 let mut writer = HashingWriter {
522 inner: std::io::BufWriter::new(file),
523 hasher: Sha256::new(),
524 };
525 encode(&mut writer)?;
526 writer.flush().map_err(|e| e.to_string())?;
527 digest_out = Some(digest::hex(&writer.hasher.finalize()));
528 Ok(())
529 })?;
530 digest_out.ok_or_else(|| "atlas-digest ontbreekt".into())
531}
532
533/// Build the `.ppda` document for a plan/contract (pure; no disk access).
534pub fn build_ppda(
535 render_doc: &serde_json::Value,
536 plan: &Plan,
537 layout: &AtlasLayout,
538 rects: &[Vec<ViewRect>],
539 sex: &str,
540 set: &str,
541 map_digests: &BTreeMap<String, String>,
542) -> Result<Ppda, String> {
543 let get = |p: &str| render_doc.pointer(p).ok_or(format!("render.json mist {p}"));
544 let text = |p: &str| -> Result<String, String> {
545 get(p)?
546 .as_str()
547 .map(str::to_string)
548 .ok_or(format!("{p} is geen tekst"))
549 };
550 let framing: CameraFraming =
551 serde_json::from_value(get("/framing")?.clone()).map_err(|e| e.to_string())?;
552 let fps = get("/fps")?.as_u64().ok_or("fps")? as u32;
553 let (depth_near, depth_far) = crate::camera::depth_range(framing);
554 let mut maps = BTreeMap::new();
555 for kind in ATLAS_MAPS {
556 maps.insert(
557 kind.token().to_string(),
558 PpdaMap {
559 file: atlas_png_name(sex, set, kind),
560 sha256: map_digests
561 .get(kind.token())
562 .cloned()
563 .ok_or("atlas-digest ontbreekt")?,
564 width: layout.width,
565 height: layout.height,
566 },
567 );
568 }
569 let mut row = 0u32;
570 let mut clips = Vec::with_capacity(plan.clips.len());
571 for (i, clip) in plan.clips.iter().enumerate() {
572 let duration = render_doc
573 .pointer(&format!("/clips/{i}/duration"))
574 .and_then(|d| d.as_f64())
575 .unwrap_or(0.) as f32;
576 let mut frames = Vec::with_capacity(clip.frames as usize);
577 for index in 0..clip.frames {
578 frames.push(PpdaFrame {
579 index,
580 views: (0..layout.columns)
581 .map(|c| {
582 let r = rects[row as usize][c as usize];
583 let grid = layout.cell(row, c);
584 PpdaView {
585 pitch: grid.pitch,
586 yaw: grid.yaw,
587 x: r.x,
588 y: r.y,
589 w: r.w,
590 h: r.h,
591 offset: Some([r.ox, r.oy]),
592 frame: Some([layout.cell_width, layout.cell_height]),
593 empty: Some(r.empty),
594 }
595 })
596 .collect(),
597 });
598 row += 1;
599 }
600 clips.push(PpdaClip {
601 source_index: i as u32,
602 name: clip.source_name.clone(),
603 token: clip.token.clone(),
604 frame_count: clip.frames,
605 duration,
606 frames,
607 });
608 }
609 Ok(Ppda {
610 format_version: PPDA_FORMAT_VERSION,
611 sex: sex.into(),
612 settype: set.into(),
613 project: PpdaProject {
614 id: text("/project/id")?,
615 name: text("/project/name")?,
616 },
617 identity: PpdaIdentity {
618 body_model_sha256: text("/body_model/sha256")?,
619 equipment_preset_sha256: render_doc
620 .pointer("/equipment_preset/sha256")
621 .and_then(|v| v.as_str())
622 .map(str::to_string),
623 framing,
624 pivot_source: text("/pivot_source/kind")?,
625 near: depth_near,
626 far: depth_far,
627 camera_distance: crate::camera::CAMERA_DISTANCE,
628 normal_space: text("/normal_space")?,
629 depth: text("/depth")?,
630 },
631 fps,
632 depth_near,
633 depth_far,
634 depth_encoding: crate::camera::DepthFormat::from_doc(render_doc)?
635 .encoding
636 .token()
637 .into(),
638 depth_bits: Some(u64::from(
639 crate::camera::DepthFormat::from_doc(render_doc)?.bits,
640 )),
641 cell_width: layout.cell_width,
642 cell_height: layout.cell_height,
643 pitches: PRODUCTION_PITCHES.to_vec(),
644 yaws: production::production_yaws().to_vec(),
645 maps,
646 clips,
647 export: None,
648 trimmed: Some(false),
649 })
650}
651
652/// Rects of every view: the fixed grid, or (trim) the content bounding box
653/// plus padding over all four maps, packed with [`pack`].
654fn plan_rects(
655 job: &AtlasJob<'_>,
656 grid: &AtlasLayout,
657) -> Result<(Vec<Vec<ViewRect>>, u32, u32), String> {
658 let rows = rows_of(job.plan);
659 let (cw, ch) = (grid.cell_width, grid.cell_height);
660 type Bbox = Option<(u32, u32, u32, u32)>;
661 let mut boxes: Vec<Vec<Bbox>> = Vec::with_capacity(rows.len());
662 for &(clip, frame) in &rows {
663 if job.cancel.load(Ordering::Relaxed) {
664 return Err("geannuleerd".into());
665 }
666 let token = &job.plan.clips[clip].token;
667 let mut row = Vec::with_capacity(grid.columns as usize);
668 for column in 0..grid.columns {
669 if !job.trim {
670 row.push(Some((0, 0, cw - 1, ch - 1)));
671 continue;
672 }
673 let view = grid.cell(0, column);
674 let mut bbox: Option<(u32, u32, u32, u32)> = None;
675 for kind in ATLAS_MAPS {
676 let cell = load_cell(job, token, frame, view.pitch, view.yaw, kind)?;
677 let bpp = cell.len() / (cw as usize * ch as usize);
678 for (i, px) in cell.chunks_exact(bpp).enumerate() {
679 if px.iter().any(|b| *b != 0) {
680 let (x, y) = ((i % cw as usize) as u32, (i / cw as usize) as u32);
681 bbox = Some(match bbox {
682 None => (x, y, x, y),
683 Some((a, b, c, d)) => (a.min(x), b.min(y), c.max(x), d.max(y)),
684 });
685 }
686 }
687 }
688 row.push(bbox.map(|(a, b, c, d)| {
689 (
690 a.saturating_sub(TRIM_PADDING),
691 b.saturating_sub(TRIM_PADDING),
692 (c + TRIM_PADDING).min(cw - 1),
693 (d + TRIM_PADDING).min(ch - 1),
694 )
695 }));
696 }
697 boxes.push(row);
698 }
699 let sizes: Vec<Vec<(u32, u32)>> = boxes
700 .iter()
701 .map(|r| {
702 r.iter()
703 .map(|b| {
704 b.map(|(a, b, c, d)| (c - a + 1, d - b + 1))
705 .unwrap_or((0, 0))
706 })
707 .collect()
708 })
709 .collect();
710 let (places, width, height) = pack(&sizes);
711 let rects = boxes
712 .iter()
713 .zip(&places)
714 .map(|(r, p)| {
715 r.iter()
716 .zip(p)
717 .map(|(b, &(x, y))| match b {
718 None => ViewRect {
719 x: 0,
720 y: 0,
721 w: 0,
722 h: 0,
723 ox: 0,
724 oy: 0,
725 empty: true,
726 },
727 Some((a, bb, c, d)) => ViewRect {
728 x,
729 y,
730 w: c - a + 1,
731 h: d - bb + 1,
732 ox: *a,
733 oy: *bb,
734 empty: false,
735 },
736 })
737 .collect()
738 })
739 .collect();
740 Ok((rects, width, height))
741}
742
743/// Write the four atlases and the `.ppda` after all loose maps are verified.
744pub fn write_atlases(
745 job: &AtlasJob<'_>,
746 progress: &mut dyn FnMut(String),
747) -> Result<AtlasOutput, String> {
748 let grid = AtlasLayout::for_plan(job.plan, job.cell_width, job.cell_height)?;
749 if job.png_sha256.len() as u64 != u64::from(job.plan.files) {
750 return Err("atlas: niet alle losse maps zijn geverifieerd".into());
751 }
752 progress("atlas · uitsnede bepalen".into());
753 let (rects, width, height) = plan_rects(job, &grid)?;
754 let layout = AtlasLayout {
755 width,
756 height,
757 ..grid
758 };
759 let mut maps = BTreeMap::new();
760 let mut digests = BTreeMap::new();
761 for kind in ATLAS_MAPS {
762 let name = atlas_png_name(job.sex, job.set, kind);
763 let digest = publish_streamed(job.root, &name, job.attempt, |out| {
764 encode_atlas(job, &layout, &rects, kind, out, progress)
765 })?;
766 digests.insert(kind.token().to_string(), digest.clone());
767 maps.insert(kind.token().to_string(), (name, digest));
768 }
769 let mut ppda = build_ppda(
770 job.render_doc,
771 job.plan,
772 &layout,
773 &rects,
774 job.sex,
775 job.set,
776 &digests,
777 )?;
778 ppda.trimmed = Some(job.trim);
779 ppda.export = Some(PpdaExport {
780 render: job.render_doc.clone(),
781 passes: job.plan.passes,
782 png_sha256: job.png_sha256.clone(),
783 });
784 let mut bytes = serde_json::to_vec_pretty(&ppda).map_err(|e| e.to_string())?;
785 bytes.push(b'\n');
786 let name = ppda_name(job.sex, job.set);
787 progress(format!("atlas · {name}"));
788 let ppda_sha256 = publish_streamed(job.root, &name, job.attempt, |out| {
789 out.write_all(&bytes).map_err(|e| e.to_string())
790 })?;
791 Ok(AtlasOutput {
792 ppda: name,
793 ppda_sha256,
794 maps,
795 })
796}
797
798// ---------------------------------------------------------------- reader --
799
800fn ident(msg: impl Into<String>) -> ExportError {
801 ExportError::Identity(msg.into())
802}
803
804impl Ppda {
805 /// Depth PNG format of the depth atlas.
806 pub fn depth_format(&self) -> Result<crate::camera::DepthFormat, ExportError> {
807 if (self.format_version >= 3) != self.depth_bits.is_some() {
808 return Err(ident(".ppda: depth_bits past niet bij format_version"));
809 }
810 crate::camera::DepthFormat::from_manifest(self.depth_bits, &self.depth_encoding)
811 .map_err(|e| ident(format!(".ppda: {e}")))
812 }
813}
814
815fn validate_ppda(p: &Ppda, file_name: &str) -> Result<AtlasLayout, ExportError> {
816 if !(2..=PPDA_FORMAT_VERSION).contains(&p.format_version) {
817 return Err(ident(format!(
818 ".ppda format_version {} wordt niet ondersteund (verwacht {PPDA_FORMAT_VERSION}; versie 1 = oud globaal dieptebereik)",
819 p.format_version
820 )));
821 }
822 if (p.format_version >= 4) != p.export.is_some() {
823 return Err(ident(".ppda: export past niet bij format_version"));
824 }
825 if let Some(export) = &p.export {
826 if export.render.get("sex").and_then(|v| v.as_str()) != Some(p.sex.as_str())
827 || export.render.get("set").and_then(|v| v.as_str()) != Some(p.settype.as_str())
828 || export.png_sha256.values().any(|h| !is_sha256_hex(h))
829 {
830 return Err(ident(".ppda: exportgegevens passen niet bij de set"));
831 }
832 }
833 if !["male", "female"].contains(&p.sex.as_str()) {
834 return Err(ident(".ppda: ongeldig geslacht"));
835 }
836 if p.settype != "body" && !crate::equipment::valid_name(&p.settype) {
837 return Err(ident(".ppda: ongeldig settype"));
838 }
839 if file_name != ppda_name(&p.sex, &p.settype) {
840 return Err(ident(format!(
841 ".ppda-bestandsnaam '{file_name}' past niet bij {}/{}",
842 p.sex, p.settype
843 )));
844 }
845 if p.project.id.is_empty() || p.project.id.len() > 128 || p.project.name.len() > 256 {
846 return Err(ident(".ppda: project id/naam ongeldig"));
847 }
848 let id = &p.identity;
849 if !is_sha256_hex(&id.body_model_sha256) {
850 return Err(ExportError::Corrupt(
851 ".ppda: body_model_sha256 ongeldig".into(),
852 ));
853 }
854 match (&id.equipment_preset_sha256, p.settype == "body") {
855 (None, true) => {}
856 (Some(h), false) if is_sha256_hex(h) => {}
857 _ => {
858 return Err(ident(
859 ".ppda: equipment_preset_sha256 past niet bij settype",
860 ))
861 }
862 }
863 id.framing.validate().map_err(ExportError::Identity)?;
864 if !["manual", "bounds_center", "neck", "fallback"].contains(&id.pivot_source.as_str()) {
865 return Err(ident(".ppda: onbekende pivot_source"));
866 }
867 let (near, far) = crate::camera::depth_range(id.framing);
868 if id.near != near
869 || id.far != far
870 || p.depth_near != near
871 || p.depth_far != far
872 || id.camera_distance != crate::camera::CAMERA_DISTANCE
873 {
874 return Err(ident(".ppda: camera-/dieptecontract wijkt af"));
875 }
876 p.depth_format()?;
877 if p.fps == 0 || p.fps > 240 {
878 return Err(ident(".ppda: ongeldige fps"));
879 }
880 if p.pitches != PRODUCTION_PITCHES || p.yaws != production::production_yaws() {
881 return Err(ident(".ppda: pitches/yaws wijken af van −60…+60 × 0…330"));
882 }
883 if p.clips.is_empty() || p.clips.len() > MAX_CLIPS {
884 return Err(ExportError::Resource(
885 ".ppda: clip-inventaris ongeldig".into(),
886 ));
887 }
888 let rows = p
889 .clips
890 .iter()
891 .try_fold(0u32, |n, c| n.checked_add(c.frame_count))
892 .ok_or_else(|| ExportError::Resource(".ppda: te veel frames".into()))?;
893 let grid =
894 AtlasLayout::new(p.cell_width, p.cell_height, rows).map_err(ExportError::Resource)?;
895 let v5 = p.format_version >= 5;
896 if v5 != p.trimmed.is_some() {
897 return Err(ident(".ppda: trimmed past niet bij format_version"));
898 }
899 let trimmed = p.trimmed.unwrap_or(false);
900 // Recompute the packing from the stored sizes (v5); v2–4 use the grid.
901 let sizes: Vec<Vec<(u32, u32)>> = p
902 .clips
903 .iter()
904 .flat_map(|c| c.frames.iter())
905 .map(|f| f.views.iter().map(|v| (v.w, v.h)).collect())
906 .collect();
907 let (places, pw, ph) = pack(&sizes);
908 let layout = if v5 {
909 AtlasLayout {
910 width: pw,
911 height: ph,
912 ..grid
913 }
914 } else {
915 grid
916 };
917 let mut tokens = std::collections::BTreeSet::new();
918 let mut row = 0u32;
919 for (i, clip) in p.clips.iter().enumerate() {
920 if clip.source_index as usize != i {
921 return Err(ident(".ppda: clips niet in bronvolgorde"));
922 }
923 if clip.token.is_empty()
924 || clip.token.len() > 64
925 || !crate::equipment::valid_name(&clip.token)
926 || !tokens.insert(clip.token.clone())
927 {
928 return Err(ident(format!(
929 ".ppda: ongeldige clip-token '{}'",
930 clip.token
931 )));
932 }
933 if clip.name.len() > MAX_CLIP_NAME || !clip.duration.is_finite() || clip.duration < 0. {
934 return Err(ident(".ppda: clipnaam/duur ongeldig"));
935 }
936 if clip.frame_count == 0 || clip.frames.len() != clip.frame_count as usize {
937 return Err(ident(".ppda: frame_count past niet bij frames"));
938 }
939 for (k, frame) in clip.frames.iter().enumerate() {
940 if frame.index as usize != k || frame.views.len() != layout.columns as usize {
941 return Err(ident(".ppda: frame-index of aantal hoeken ongeldig"));
942 }
943 for (c, view) in frame.views.iter().enumerate() {
944 let grid_view = grid.cell(row, c as u32);
945 let inside = u64::from(view.x) + u64::from(view.w) <= u64::from(layout.width)
946 && u64::from(view.y) + u64::from(view.h) <= u64::from(layout.height);
947 let ok = if !v5 {
948 *view == grid_view
949 } else {
950 let (Some([ox, oy]), Some(fr), Some(empty)) =
951 (view.offset, view.frame, view.empty)
952 else {
953 return Err(ident(".ppda: offset/frame/empty ontbreken"));
954 };
955 let same_angle = view.pitch == grid_view.pitch && view.yaw == grid_view.yaw;
956 let shape = if empty {
957 trimmed && (view.x, view.y, view.w, view.h, ox, oy) == (0, 0, 0, 0, 0, 0)
958 } else {
959 view.w >= 1
960 && view.h >= 1
961 && u64::from(ox) + u64::from(view.w) <= u64::from(p.cell_width)
962 && u64::from(oy) + u64::from(view.h) <= u64::from(p.cell_height)
963 && (view.x, view.y) == places[row as usize][c]
964 && (trimmed
965 || (view.x, view.y, view.w, view.h, ox, oy)
966 == (grid_view.x, grid_view.y, grid_view.w, grid_view.h, 0, 0))
967 };
968 same_angle && fr == [p.cell_width, p.cell_height] && shape
969 };
970 if !ok || !inside {
971 return Err(ExportError::Corrupt(format!(
972 ".ppda: celcoördinaten van {} f{k} p{} y{} kloppen niet",
973 clip.token, view.pitch, view.yaw
974 )));
975 }
976 }
977 row += 1;
978 }
979 }
980 if p.maps.len() != ATLAS_MAPS.len() {
981 return Err(ident(
982 ".ppda: maps moet precies diffuse/normal/depth/specular bevatten",
983 ));
984 }
985 for kind in ATLAS_MAPS {
986 let m = p
987 .maps
988 .get(kind.token())
989 .ok_or_else(|| ident(format!(".ppda mist map {}", kind.token())))?;
990 if m.file != atlas_png_name(&p.sex, &p.settype, kind) {
991 return Err(ExportError::Traversal(format!(
992 ".ppda: atlasbestand '{}' is niet de verwachte naam in de exportmap",
993 m.file
994 )));
995 }
996 if !is_sha256_hex(&m.sha256) {
997 return Err(ExportError::Corrupt(".ppda: atlas sha256 ongeldig".into()));
998 }
999 if m.width != layout.width || m.height != layout.height {
1000 return Err(ExportError::Corrupt(format!(
1001 ".ppda: {} atlasafmetingen {}x{} wijken af van raster {}x{}",
1002 kind.token(),
1003 m.width,
1004 m.height,
1005 layout.width,
1006 layout.height
1007 )));
1008 }
1009 }
1010 Ok(layout)
1011}
1012
1013/// A validated `.ppda` whose atlases exist, match their SHA-256 and headers.
1014#[derive(Debug, Clone)]
1015pub struct AtlasSet {
1016 dir: PathBuf,
1017 pub ppda: Ppda,
1018 pub layout: AtlasLayout,
1019}
1020
1021impl AtlasSet {
1022 /// Export folder containing the `.ppda` and its atlases.
1023 pub fn dir(&self) -> &Path {
1024 &self.dir
1025 }
1026
1027 fn read_ppda(path: &Path) -> Result<(PathBuf, Ppda, AtlasLayout), ExportError> {
1028 let dir = path
1029 .parent()
1030 .ok_or_else(|| ExportError::Io(".ppda zonder map".into()))?;
1031 let dir = dir
1032 .canonicalize()
1033 .map_err(|e| ExportError::Io(e.to_string()))?;
1034 let file_name = path
1035 .file_name()
1036 .and_then(|n| n.to_str())
1037 .ok_or_else(|| ExportError::Io("ongeldige .ppda-naam".into()))?
1038 .to_string();
1039 let path = dir.join(&file_name);
1040 export_set::verify_regular_file(&path, &dir)?;
1041 let bytes = export_set::read_bounded_file(&path, MAX_PPDA_BYTES)?;
1042 let text = std::str::from_utf8(&bytes)
1043 .map_err(|_| ExportError::Corrupt(".ppda is geen UTF-8".into()))?;
1044 let ppda: Ppda =
1045 serde_json::from_str(text).map_err(|e| ExportError::Corrupt(format!(".ppda: {e}")))?;
1046 let layout = validate_ppda(&ppda, &file_name)?;
1047 Ok((dir, ppda, layout))
1048 }
1049
1050 fn read_map(&self, kind: MapKind) -> Result<Vec<u8>, ExportError> {
1051 let m = &self.ppda.maps[kind.token()];
1052 let path = self.dir.join(&m.file);
1053 export_set::verify_regular_file(&path, &self.dir)?;
1054 let bytes = export_set::read_bounded_file(&path, MAX_ATLAS_PNG_BYTES)?;
1055 if digest::sha256_hex(&bytes) != m.sha256 {
1056 return Err(ExportError::Corrupt(format!(
1057 "checksum mismatch voor '{}'",
1058 m.file
1059 )));
1060 }
1061 Ok(bytes)
1062 }
1063
1064 /// Validate the manifest, then each atlas: regular file inside the folder,
1065 /// SHA-256, PNG header dimensions and sample format.
1066 pub fn open(ppda_path: &Path) -> Result<Self, ExportError> {
1067 let (dir, ppda, layout) = Self::read_ppda(ppda_path)?;
1068 let set = Self { dir, ppda, layout };
1069 for kind in ATLAS_MAPS {
1070 let bytes = set.read_map(kind)?;
1071 let decoder = png::Decoder::new(std::io::Cursor::new(&bytes[..]));
1072 let reader = decoder
1073 .read_info()
1074 .map_err(|e| ExportError::Corrupt(format!("atlas PNG header: {e}")))?;
1075 let info = reader.info();
1076 let (color, depth) = if kind == MapKind::Depth {
1077 let fmt = set.ppda.depth_format()?;
1078 (fmt.color_type(), fmt.bit_depth())
1079 } else {
1080 (png::ColorType::Rgba, png::BitDepth::Eight)
1081 };
1082 if info.width != layout.width
1083 || info.height != layout.height
1084 || info.color_type != color
1085 || info.bit_depth != depth
1086 {
1087 return Err(ExportError::Corrupt(format!(
1088 "{} atlas: afmetingen of kleurtype wijken af van .ppda",
1089 kind.token()
1090 )));
1091 }
1092 }
1093 Ok(set)
1094 }
1095
1096 /// Open the single `.ppda` inside a run directory.
1097 pub fn open_run(run_dir: &Path) -> Result<Self, ExportError> {
1098 let mut found = Vec::new();
1099 for entry in fs::read_dir(run_dir).map_err(|e| ExportError::Io(e.to_string()))? {
1100 let entry = entry.map_err(|e| ExportError::Io(e.to_string()))?;
1101 let name = entry.file_name();
1102 if name.to_str().is_some_and(|n| n.ends_with("-atlas.ppda")) {
1103 found.push(entry.path());
1104 }
1105 }
1106 match found.as_slice() {
1107 [one] => Self::open(one),
1108 [] => Err(ExportError::Incomplete(
1109 "geen .ppda in deze exportmap".into(),
1110 )),
1111 _ => Err(ExportError::Identity(
1112 "meer dan één .ppda in deze exportmap".into(),
1113 )),
1114 }
1115 }
1116
1117 /// Decode all four atlases (re-verifying SHA-256 on the bytes decoded) with
1118 /// the full map pixel contract.
1119 pub fn load(&self) -> Result<LoadedAtlas, ExportError> {
1120 let decoded = u64::from(self.layout.width) * u64::from(self.layout.height) * 4 * 4;
1121 if decoded > MAX_DECODED_SET_BYTES {
1122 return Err(ExportError::Resource(format!(
1123 "atlasset te groot om te laden ({decoded} bytes gedecodeerd)"
1124 )));
1125 }
1126 let mut maps = BTreeMap::new();
1127 let mut depth_display = Vec::new();
1128 for kind in ATLAS_MAPS {
1129 let bytes = self.read_map(kind)?;
1130 if kind == MapKind::Depth {
1131 let fmt = self.ppda.depth_format()?;
1132 let native = export_set::decode_depth_native(
1133 &bytes,
1134 self.layout.width,
1135 self.layout.height,
1136 MAX_ATLAS_DIM,
1137 fmt,
1138 )?;
1139 depth_display = export_set::depth_display_rgba(&native, fmt);
1140 }
1141 let image = export_set::decode_map_sized(
1142 &bytes,
1143 kind,
1144 self.layout.width,
1145 self.layout.height,
1146 MAX_ATLAS_DIM,
1147 self.ppda.depth_format()?,
1148 )?;
1149 maps.insert(kind.token(), image);
1150 }
1151 Ok(LoadedAtlas {
1152 set: self.clone(),
1153 maps,
1154 depth_display,
1155 })
1156 }
1157}
1158
1159/// Decoded atlases for one `.ppda`; crops cells for the compositor.
1160#[derive(Debug, Clone)]
1161pub struct LoadedAtlas {
1162 pub set: AtlasSet,
1163 maps: BTreeMap<&'static str, MapImage>,
1164 /// Depth atlas as stored, RGBA8 display form (no decoding; Tim t992u).
1165 depth_display: Vec<u8>,
1166}
1167
1168impl LoadedAtlas {
1169 pub fn ppda(&self) -> &Ppda {
1170 &self.set.ppda
1171 }
1172
1173 /// Coordinates of one view from the `.ppda`.
1174 pub fn view(
1175 &self,
1176 clip_token: &str,
1177 frame: u32,
1178 pitch: i32,
1179 yaw: u32,
1180 ) -> Result<PpdaView, ExportError> {
1181 let clip = self
1182 .set
1183 .ppda
1184 .clips
1185 .iter()
1186 .find(|c| c.token == clip_token)
1187 .ok_or_else(|| ExportError::Incomplete(format!("clip '{clip_token}' niet in .ppda")))?;
1188 let frame = clip.frames.get(frame as usize).ok_or_else(|| {
1189 ExportError::Incomplete(format!("frame {frame} niet in clip '{clip_token}'"))
1190 })?;
1191 frame
1192 .views
1193 .iter()
1194 .find(|v| v.pitch == pitch && v.yaw == yaw)
1195 .copied()
1196 .ok_or_else(|| ExportError::Incomplete(format!("hoek p{pitch} y{yaw} niet in .ppda")))
1197 }
1198
1199 /// Crop the depth cell as stored, in RGBA8 display form (rgb_bands /
1200 /// rgb_packed keep their raw RGB; 16-bit at display precision).
1201 pub fn depth_display_cell(
1202 &self,
1203 clip_token: &str,
1204 frame: u32,
1205 pitch: i32,
1206 yaw: u32,
1207 ) -> Result<Vec<u8>, ExportError> {
1208 let v = self.view(clip_token, frame, pitch, yaw)?;
1209 let fmt = self.set.ppda.depth_format()?;
1210 let fill = export_set::depth_display_rgba(&vec![0u8; fmt.bytes_per_pixel()], fmt);
1211 Ok(paste_full(
1212 &self.depth_display,
1213 self.set.layout.width,
1214 &v,
1215 4,
1216 &fill,
1217 ))
1218 }
1219
1220 /// Crop one map cell exactly as stored.
1221 pub fn cell(
1222 &self,
1223 clip_token: &str,
1224 frame: u32,
1225 pitch: i32,
1226 yaw: u32,
1227 kind: MapKind,
1228 ) -> Result<MapImage, ExportError> {
1229 let v = self.view(clip_token, frame, pitch, yaw)?;
1230 let [fw, fh] = v.frame.unwrap_or([v.w, v.h]);
1231 let atlas_w = self.set.layout.width;
1232 match &self.maps[kind.token()] {
1233 MapImage::Rgba8 { rgba, .. } => Ok(MapImage::Rgba8 {
1234 width: fw,
1235 height: fh,
1236 rgba: paste_full(rgba, atlas_w, &v, 4, &[0; 4]),
1237 }),
1238 MapImage::Depth16 { samples, .. } => Ok(MapImage::Depth16 {
1239 width: fw,
1240 height: fh,
1241 samples: paste_full(samples, atlas_w, &v, 2, &[0; 2]),
1242 }),
1243 }
1244 }
1245}
1246
1247/// The full frame of a view: its atlas rect pasted at its offset into a frame
1248/// filled with `fill` (one pixel, `per_px` elements). Untrimmed views have
1249/// offset 0 and the full size, so this is the plain crop.
1250fn paste_full<T: Copy>(src: &[T], atlas_w: u32, v: &PpdaView, per_px: usize, fill: &[T]) -> Vec<T> {
1251 let [fw, fh] = v.frame.unwrap_or([v.w, v.h]);
1252 let [ox, oy] = v.offset.unwrap_or([0, 0]);
1253 let (fw, fh) = (fw as usize, fh as usize);
1254 let mut out: Vec<T> = fill
1255 .iter()
1256 .copied()
1257 .cycle()
1258 .take(fw * fh * per_px)
1259 .collect();
1260 if v.empty == Some(true) {
1261 return out;
1262 }
1263 let atlas_w = atlas_w as usize;
1264 let w = v.w as usize * per_px;
1265 for row in 0..v.h as usize {
1266 let s = ((v.y as usize + row) * atlas_w + v.x as usize) * per_px;
1267 let d = ((oy as usize + row) * fw + ox as usize) * per_px;
1268 out[d..d + w].copy_from_slice(&src[s..s + w]);
1269 }
1270 out
1271}
1272
1273/// Open a completed run through both readers and compare every atlas cell of
1274/// every map with its loose PNG. Returns a JSON summary (used by GPU proofs).
1275pub fn cross_check_run(run_dir: &Path) -> Result<serde_json::Value, String> {
1276 let run = export_set::ExportRun::open(run_dir).map_err(|e| e.to_string())?;
1277 let set = AtlasSet::open_run(run_dir).map_err(|e| e.to_string())?;
1278 let loaded = set.load().map_err(|e| e.to_string())?;
1279 let mut cells = 0u64;
1280 for clip in &set.ppda.clips {
1281 for frame in &clip.frames {
1282 for view in &frame.views {
1283 for kind in ATLAS_MAPS {
1284 let a = loaded
1285 .cell(&clip.token, frame.index, view.pitch, view.yaw, kind)
1286 .map_err(|e| e.to_string())?;
1287 let b = run
1288 .load_map(&clip.token, frame.index, view.pitch, view.yaw, kind)
1289 .map_err(|e| e.to_string())?;
1290 if a != b {
1291 return Err(format!(
1292 "atlascel {} f{} p{} y{} {} wijkt af van losse PNG",
1293 clip.token,
1294 frame.index,
1295 view.pitch,
1296 view.yaw,
1297 kind.token()
1298 ));
1299 }
1300 cells += 1;
1301 }
1302 }
1303 }
1304 }
1305 if cells != u64::from(run.complete.png_files) {
1306 return Err(format!(
1307 "{cells} atlascellen ≠ {} losse PNG's",
1308 run.complete.png_files
1309 ));
1310 }
1311 Ok(serde_json::json!({
1312 "ppda": ppda_name(&set.ppda.sex, &set.ppda.settype),
1313 "atlas_size": [set.layout.width, set.layout.height],
1314 "cell": [set.layout.cell_width, set.layout.cell_height],
1315 "rows": set.layout.rows,
1316 "columns": set.layout.columns,
1317 "fps": set.ppda.fps,
1318 "cells_equal_to_loose_pngs": cells,
1319 "maps": set.ppda.maps.iter().map(|(k, m)| (k.clone(), serde_json::json!({"file": m.file, "sha256": m.sha256}))).collect::<serde_json::Map<_, _>>(),
1320 }))
1321}
1322
1323#[cfg(test)]
1324mod tests {
1325 use super::*;
1326 use crate::export_fixture::{Pixel, SyntheticRun};
1327 use std::sync::atomic::AtomicU64;
1328
1329 struct Temp(PathBuf);
1330 impl Temp {
1331 fn new() -> Self {
1332 static N: AtomicU64 = AtomicU64::new(0);
1333 let path = std::env::temp_dir().join(format!(
1334 "paperdoll-atlas-{}-{}",
1335 std::process::id(),
1336 N.fetch_add(1, Ordering::Relaxed)
1337 ));
1338 let _ = fs::remove_dir_all(&path);
1339 Self(path)
1340 }
1341 }
1342 impl Drop for Temp {
1343 fn drop(&mut self) {
1344 let _ = fs::remove_dir_all(&self.0);
1345 }
1346 }
1347
1348 fn fixture() -> Temp {
1349 let tmp = Temp::new();
1350 let mut spec = SyntheticRun::body("atlas-test", "female", b"body");
1351 spec.clips = vec![("walk".into(), 3, 0.75), ("idle".into(), 1, 0.0)];
1352 spec.target = [3, 2];
1353 spec.write(&tmp.0, |c, f, p, y| Pixel {
1354 diffuse: [c as u8 * 100 + f as u8, (p + 60) as u8, (y / 30) as u8, 255],
1355 normal: [128, 128, 255, 255],
1356 specular: [9, 9, 9, 255],
1357 depth: 1000 + f as u16,
1358 })
1359 .unwrap();
1360 tmp
1361 }
1362
1363 fn rewrite_ppda(dir: &Path, edit: impl FnOnce(&mut serde_json::Value)) {
1364 let path = dir.join("female-body-atlas.ppda");
1365 let mut doc: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
1366 edit(&mut doc);
1367 fs::remove_file(&path).unwrap();
1368 fs::write(&path, serde_json::to_vec_pretty(&doc).unwrap()).unwrap();
1369 }
1370
1371 #[test]
1372 fn all_depth_formats_round_trip_through_loose_pngs_atlas_and_reader() {
1373 use crate::camera::{DepthEncoding, DepthFormat};
1374 for bits in [8u8, 16] {
1375 for encoding in DepthEncoding::ALL {
1376 let fmt = DepthFormat::new(bits, encoding).unwrap();
1377 let tmp = Temp::new();
1378 let mut spec = SyntheticRun::body("atlas-test", "female", b"body");
1379 spec.clips = vec![("walk".into(), 2, 0.5)];
1380 spec.target = [2, 2];
1381 spec.depth_format = fmt;
1382 spec.write(&tmp.0, |_, f, p, y| Pixel {
1383 diffuse: [1, 2, 3, if y == 30 { 0 } else { 255 }],
1384 normal: if y == 30 {
1385 [0, 0, 0, 0]
1386 } else {
1387 [128, 128, 255, 255]
1388 },
1389 specular: if y == 30 {
1390 [0, 0, 0, 0]
1391 } else {
1392 [0, 0, 0, 255]
1393 },
1394 depth: 9000 + f as u16 * 20000 + (p + 60) as u16 * 50 + y as u16,
1395 })
1396 .unwrap();
1397 let set = AtlasSet::open_run(&tmp.0).unwrap();
1398 assert_eq!(set.ppda.depth_encoding, encoding.token());
1399 assert_eq!(set.ppda.depth_bits, Some(u64::from(bits)));
1400 let loaded = set.load().unwrap();
1401 let MapImage::Depth16 { samples, width, .. } = &loaded.maps["depth"] else {
1402 panic!("depth")
1403 };
1404 // Precision: one level of the format, in 16-bit canonical units.
1405 let tol = (65535. / fmt.levels() as f64).ceil() as i64;
1406 for f in 0..2u32 {
1407 for col in 0..set.layout.columns {
1408 let cell = set.layout.cell(0, col);
1409 let view = (cell.pitch, cell.yaw);
1410 let x = col * 2;
1411 let y = f * 2;
1412 let i = (y * width + x) as usize * 2;
1413 if view.1 == 30 {
1414 assert_eq!(&samples[i..i + 2], &[0, 0]);
1415 continue;
1416 }
1417 let want =
1418 9000 + f as i64 * 20000 + (view.0 + 60) as i64 * 50 + view.1 as i64;
1419 let got = samples[i] as i64;
1420 assert!((got - want).abs() <= tol, "{fmt:?}: {got} vs {want}");
1421 assert_eq!(samples[i + 1], 65535);
1422 }
1423 }
1424 crate::export_set::ExportRun::open(&tmp.0).unwrap();
1425 }
1426 }
1427 }
1428
1429 /// Strip the format-5 fields (back to the v2–v4 view shape).
1430 fn strip_v5(d: &mut serde_json::Value) {
1431 d.as_object_mut().unwrap().remove("trimmed");
1432 for clip in d["clips"].as_array_mut().unwrap() {
1433 for frame in clip["frames"].as_array_mut().unwrap() {
1434 for view in frame["views"].as_array_mut().unwrap() {
1435 let v = view.as_object_mut().unwrap();
1436 v.remove("offset");
1437 v.remove("frame");
1438 v.remove("empty");
1439 }
1440 }
1441 }
1442 }
1443
1444 /// Body + equipment pair, trimmed or not, with views of varying content
1445 /// boxes and some completely empty views (t1030u).
1446 fn trim_pair(dir: &Path, trim: bool, fmt: crate::camera::DepthFormat) -> [LoadedAtlas; 2] {
1447 let mut out = Vec::new();
1448 for set in ["body", "sword"] {
1449 let mut spec = SyntheticRun::body("trim-test", "male", b"body");
1450 if set != "body" {
1451 spec.set = set.into();
1452 spec.equipment_sha256 = Some(crate::digest::sha256_hex(b"sword"));
1453 }
1454 spec.clips = vec![("walk".into(), 2, 0.5)];
1455 spec.target = [12, 16];
1456 spec.depth_format = fmt;
1457 spec.trim = trim;
1458 spec.mask = Some(if set == "body" {
1459 |_, f, p, y, x, yy| {
1460 let s = (y / 30 + f) % 5;
1461 x >= 2 + s && x < 8 + s % 4 && yy > s && yy < 12 + (p.unsigned_abs() / 30)
1462 }
1463 } else {
1464 // The sword is only drawn in some views; the rest are empty.
1465 |_, f, _, y, x, yy| (y / 30 + f) % 3 == 0 && x == 9 && (4..9).contains(&yy)
1466 });
1467 let root = dir.join(set);
1468 spec.write(&root, |_, f, p, y| Pixel {
1469 diffuse: [40 + f as u8, (p + 60) as u8, (y / 30) as u8 * 20, 255],
1470 normal: [120, 130, 250, 255],
1471 specular: [7, 7, 7, 200],
1472 depth: 9000 + y as u16 * 3 + f as u16,
1473 })
1474 .unwrap();
1475 out.push(
1476 AtlasSet::open(&root.join(ppda_name("male", set)))
1477 .unwrap()
1478 .load()
1479 .unwrap(),
1480 );
1481 }
1482 out.try_into().unwrap()
1483 }
1484
1485 #[test]
1486 fn trimmed_atlases_composite_pixel_exact_like_untrimmed() {
1487 for fmt in [
1488 crate::camera::DepthFormat::GRAY16,
1489 crate::camera::DepthFormat::new(8, crate::camera::DepthEncoding::RgbPacked).unwrap(),
1490 ] {
1491 let tmp = Temp::new();
1492 let full = trim_pair(&tmp.0.join("full"), false, fmt);
1493 let trim = trim_pair(&tmp.0.join("trim"), true, fmt);
1494 for (a, b) in full.iter().zip(&trim) {
1495 assert_eq!(a.ppda().trimmed, Some(false));
1496 assert_eq!(b.ppda().trimmed, Some(true));
1497 assert!(
1498 u64::from(b.set.layout.width) * u64::from(b.set.layout.height)
1499 < u64::from(a.set.layout.width) * u64::from(a.set.layout.height),
1500 "trimmed atlas is smaller"
1501 );
1502 }
1503 // The sword has empty views: no atlas space, all-zero full frames.
1504 let empties = trim[1]
1505 .ppda()
1506 .clips
1507 .iter()
1508 .flat_map(|c| &c.frames)
1509 .flat_map(|f| &f.views)
1510 .filter(|v| v.empty == Some(true))
1511 .inspect(|v| assert_eq!((v.w, v.h), (0, 0)))
1512 .count();
1513 assert!(empties > 0);
1514 let light = crate::compositor::Light::default();
1515 for clip in &full[0].ppda().clips {
1516 for frame in &clip.frames {
1517 for view in &frame.views {
1518 let (t, f, p, y) = (&clip.token, frame.index, view.pitch, view.yaw);
1519 for i in 0..2 {
1520 for kind in ATLAS_MAPS {
1521 assert_eq!(
1522 full[i].cell(t, f, p, y, kind).unwrap(),
1523 trim[i].cell(t, f, p, y, kind).unwrap()
1524 );
1525 }
1526 assert_eq!(
1527 full[i].depth_display_cell(t, f, p, y).unwrap(),
1528 trim[i].depth_display_cell(t, f, p, y).unwrap()
1529 );
1530 }
1531 let layers = |set: &[LoadedAtlas; 2]| {
1532 set.iter()
1533 .map(|a| {
1534 crate::compositor::LayerMaps::from_atlas(a, "l", t, f, p, y)
1535 .unwrap()
1536 })
1537 .collect::<Vec<_>>()
1538 };
1539 let a = crate::compositor::compose(&layers(&full)).unwrap();
1540 let b = crate::compositor::compose(&layers(&trim)).unwrap();
1541 assert_eq!(
1542 (&a.diffuse, &a.normal, &a.specular, &a.depth),
1543 (&b.diffuse, &b.normal, &b.specular, &b.depth)
1544 );
1545 assert_eq!(
1546 crate::compositor::relight(&a, &light).unwrap().rgba,
1547 crate::compositor::relight(&b, &light).unwrap().rgba
1548 );
1549 }
1550 }
1551 }
1552 }
1553 }
1554
1555 #[test]
1556 fn all_empty_trimmed_set_is_one_transparent_pixel_and_tampering_is_refused() {
1557 let tmp = Temp::new();
1558 let mut spec = SyntheticRun::body("trim-empty", "female", b"body");
1559 spec.target = [4, 4];
1560 spec.trim = true;
1561 spec.mask = Some(|_, _, _, _, _, _| false);
1562 spec.write(&tmp.0, |_, _, _, _| Pixel {
1563 diffuse: [1, 2, 3, 255],
1564 normal: [128, 128, 255, 255],
1565 specular: [0, 0, 0, 255],
1566 depth: 5,
1567 })
1568 .unwrap();
1569 let ppda = tmp.0.join("female-body-atlas.ppda");
1570 let set = AtlasSet::open(&ppda).unwrap();
1571 assert_eq!((set.layout.width, set.layout.height), (1, 1));
1572 let loaded = set.load().unwrap();
1573 let cell = loaded
1574 .cell("clip000-bind", 0, 0, 0, MapKind::Diffuse)
1575 .unwrap();
1576 assert_eq!(
1577 cell,
1578 MapImage::Rgba8 {
1579 width: 4,
1580 height: 4,
1581 rgba: vec![0; 64]
1582 }
1583 );
1584 // A non-empty rect where the packing does not put it is refused.
1585 let mut spec = SyntheticRun::body("trim-tamper", "female", b"body");
1586 spec.target = [4, 4];
1587 spec.trim = true;
1588 spec.mask = Some(|_, _, _, y, x, _| y == 0 && x == 1);
1589 let dir = tmp.0.join("t");
1590 spec.write(&dir, |_, _, _, _| Pixel {
1591 diffuse: [1, 2, 3, 255],
1592 normal: [128, 128, 255, 255],
1593 specular: [0, 0, 0, 255],
1594 depth: 5,
1595 })
1596 .unwrap();
1597 AtlasSet::open(&dir.join("female-body-atlas.ppda")).unwrap();
1598 rewrite_ppda(&dir, |d| {
1599 d["clips"][0]["frames"][0]["views"][0]["x"] = 1.into();
1600 });
1601 assert!(AtlasSet::open(&dir.join("female-body-atlas.ppda")).is_err());
1602 }
1603
1604 #[test]
1605 fn legacy_ppda_v2_gray16_is_still_read() {
1606 let tmp = fixture();
1607 rewrite_ppda(&tmp.0, |d| {
1608 strip_v5(d);
1609 d["format_version"] = 2.into();
1610 d.as_object_mut().unwrap().remove("depth_bits");
1611 d.as_object_mut().unwrap().remove("export");
1612 d["depth_encoding"] = crate::camera::DEPTH_ENCODING.into();
1613 });
1614 let set = AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).unwrap();
1615 assert_eq!(
1616 set.ppda.depth_format().unwrap(),
1617 crate::camera::DepthFormat::GRAY16
1618 );
1619 set.load().unwrap();
1620 // A v3 file must say which format it uses.
1621 rewrite_ppda(&tmp.0, |d| d["format_version"] = 3.into());
1622 assert!(AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).is_err());
1623 }
1624
1625 #[test]
1626 fn layout_is_pitch_major_columns_and_frame_rows() {
1627 let l = AtlasLayout::new(32, 48, 5).unwrap();
1628 assert_eq!((l.columns, l.width, l.height), (60, 60 * 32, 5 * 48));
1629 assert_eq!(AtlasLayout::column(-60, 0), Some(0));
1630 assert_eq!(AtlasLayout::column(-60, 330), Some(11));
1631 assert_eq!(AtlasLayout::column(-30, 0), Some(12));
1632 assert_eq!(AtlasLayout::column(60, 330), Some(59));
1633 assert_eq!(AtlasLayout::column(45, 0), None);
1634 let v = l.cell(2, 13);
1635 assert_eq!(
1636 (v.pitch, v.yaw, v.x, v.y, v.w, v.h),
1637 (-30, 30, 13 * 32, 96, 32, 48)
1638 );
1639 // 256-pixel cells allow 64 frame rows; 65 exceeds the GPU/PNG limit.
1640 assert!(AtlasLayout::new(256, 256, 64).is_ok());
1641 let err = AtlasLayout::new(256, 256, 65).unwrap_err();
1642 assert!(err.contains("niet gesplitst"), "{err}");
1643 assert!(AtlasLayout::new(0, 1, 1).is_err());
1644 assert!(AtlasLayout::new(257, 1, 1).is_err());
1645 }
1646
1647 #[test]
1648 fn written_ppda_has_exact_coordinates_names_fps_and_checksums() {
1649 let tmp = fixture();
1650 let set = AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).unwrap();
1651 let p = &set.ppda;
1652 assert_eq!(p.format_version, 5);
1653 assert_eq!(p.trimmed, Some(false));
1654 assert_eq!((p.sex.as_str(), p.settype.as_str()), ("female", "body"));
1655 // Format 4 embeds the export contract and every loose-map checksum.
1656 let export = p.export.as_ref().unwrap();
1657 assert_eq!(export.render["sex"], "female");
1658 assert!(!export.png_sha256.is_empty());
1659 assert!(export
1660 .png_sha256
1661 .keys()
1662 .all(|k| k.starts_with("raw files/female/")));
1663 // The finished folder holds only consumer output.
1664 let mut names: Vec<String> = fs::read_dir(&tmp.0)
1665 .unwrap()
1666 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
1667 .collect();
1668 names.sort();
1669 assert_eq!(
1670 names,
1671 [
1672 "female-body-atlas.ppda",
1673 "female-body-depth-atlas.png",
1674 "female-body-diffuse-atlas.png",
1675 "female-body-normal-atlas.png",
1676 "female-body-specular-atlas.png",
1677 "raw files",
1678 ]
1679 );
1680 let raw: Vec<String> = fs::read_dir(tmp.0.join("raw files"))
1681 .unwrap()
1682 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
1683 .collect();
1684 assert_eq!(raw, ["female"]);
1685 let (near, far) = crate::camera::depth_range(p.identity.framing);
1686 assert_eq!((p.depth_near, p.depth_far), (near, far));
1687 assert_eq!((p.identity.near, p.identity.far), (near, far));
1688 assert_eq!(p.depth_encoding, "gray");
1689 assert_eq!(p.depth_bits, Some(16));
1690 assert_eq!(p.fps, crate::project::DEFAULT_FPS);
1691 assert_eq!((p.cell_width, p.cell_height), (3, 2));
1692 assert_eq!(p.pitches, [-60, -30, 0, 30, 60]);
1693 assert_eq!(p.yaws.len(), 12);
1694 assert_eq!(p.maps["diffuse"].file, "female-body-diffuse-atlas.png");
1695 assert_eq!(p.maps["depth"].file, "female-body-depth-atlas.png");
1696 assert_eq!((p.maps["normal"].width, p.maps["normal"].height), (180, 8));
1697 for kind in ATLAS_MAPS {
1698 let m = &p.maps[kind.token()];
1699 let bytes = fs::read(tmp.0.join(&m.file)).unwrap();
1700 assert_eq!(digest::sha256_hex(&bytes), m.sha256);
1701 }
1702 assert_eq!(p.clips.len(), 2);
1703 assert_eq!(p.clips[0].frame_count, 3);
1704 assert_eq!(p.clips[1].token, "clip001-idle");
1705 let idle = &p.clips[1].frames[0].views[59];
1706 assert_eq!((idle.pitch, idle.yaw, idle.x, idle.y), (60, 330, 177, 6));
1707 // Cells hold exactly the loose map bytes.
1708 let loaded = set.load().unwrap();
1709 let run = crate::export_set::ExportRun::open(&tmp.0).unwrap();
1710 for (clip, frame) in [("clip000-walk", 2), ("clip001-idle", 0)] {
1711 for (pitch, yaw) in [(-60, 0), (30, 210), (60, 330)] {
1712 for kind in ATLAS_MAPS {
1713 assert_eq!(
1714 loaded.cell(clip, frame, pitch, yaw, kind).unwrap(),
1715 run.load_map(clip, frame, pitch, yaw, kind).unwrap()
1716 );
1717 }
1718 }
1719 }
1720 assert!(loaded
1721 .cell("clip000-walk", 3, 0, 0, MapKind::Diffuse)
1722 .is_err());
1723 assert!(loaded.cell("clip009-x", 0, 0, 0, MapKind::Diffuse).is_err());
1724 // The diffuse atlas carries the sRGB chunk like the loose diffuse maps.
1725 let bytes = fs::read(tmp.0.join("female-body-diffuse-atlas.png")).unwrap();
1726 let info = png::Decoder::new(std::io::Cursor::new(&bytes[..]))
1727 .read_info()
1728 .unwrap()
1729 .info()
1730 .srgb;
1731 assert!(info.is_some());
1732 }
1733
1734 #[test]
1735 fn reader_rejects_tampering_unknown_fields_versions_paths_and_coordinates() {
1736 let tmp = fixture();
1737 let ppda = tmp.0.join("female-body-atlas.ppda");
1738 let original = fs::read(&ppda).unwrap();
1739 let restore = || {
1740 fs::remove_file(&ppda).unwrap();
1741 fs::write(&ppda, &original).unwrap();
1742 };
1743 AtlasSet::open(&ppda).unwrap();
1744
1745 rewrite_ppda(&tmp.0, |d| d["format_version"] = 1.into());
1746 assert!(matches!(
1747 AtlasSet::open(&ppda),
1748 Err(ExportError::Identity(_))
1749 ));
1750 restore();
1751 rewrite_ppda(&tmp.0, |d| d["depth_far"] = 40.0.into());
1752 assert!(matches!(
1753 AtlasSet::open(&ppda),
1754 Err(ExportError::Identity(_))
1755 ));
1756 restore();
1757 rewrite_ppda(&tmp.0, |d| d["format_version"] = 6.into());
1758 assert!(matches!(
1759 AtlasSet::open(&ppda),
1760 Err(ExportError::Identity(_))
1761 ));
1762 restore();
1763 rewrite_ppda(&tmp.0, |d| {
1764 d.as_object_mut().unwrap().remove("export");
1765 });
1766 assert!(matches!(
1767 AtlasSet::open(&ppda),
1768 Err(ExportError::Identity(_))
1769 ));
1770 restore();
1771 // A v4 file never carries the format-5 view fields.
1772 rewrite_ppda(&tmp.0, |d| d["format_version"] = 4.into());
1773 assert!(matches!(
1774 AtlasSet::open(&ppda),
1775 Err(ExportError::Identity(_))
1776 ));
1777 restore();
1778 rewrite_ppda(&tmp.0, |d| {
1779 strip_v5(d);
1780 d["format_version"] = 4.into();
1781 });
1782 AtlasSet::open(&ppda).unwrap();
1783 restore();
1784 // Format 2 never carries depth_bits; format 3 always does.
1785 rewrite_ppda(&tmp.0, |d| {
1786 strip_v5(d);
1787 d["format_version"] = 2.into();
1788 });
1789 assert!(matches!(
1790 AtlasSet::open(&ppda),
1791 Err(ExportError::Identity(_))
1792 ));
1793 restore();
1794 rewrite_ppda(&tmp.0, |d| d["depth_encoding"] = "rgb_weird".into());
1795 assert!(matches!(
1796 AtlasSet::open(&ppda),
1797 Err(ExportError::Identity(_))
1798 ));
1799 restore();
1800 rewrite_ppda(&tmp.0, |d| d["extra"] = true.into());
1801 assert!(matches!(
1802 AtlasSet::open(&ppda),
1803 Err(ExportError::Corrupt(_))
1804 ));
1805 restore();
1806 rewrite_ppda(&tmp.0, |d| {
1807 d["clips"][0]["frames"][1]["views"][5]["x"] = 1.into()
1808 });
1809 assert!(matches!(
1810 AtlasSet::open(&ppda),
1811 Err(ExportError::Corrupt(_))
1812 ));
1813 restore();
1814 rewrite_ppda(&tmp.0, |d| {
1815 d["maps"]["diffuse"]["file"] = "../female-body-diffuse-atlas.png".into()
1816 });
1817 assert!(matches!(
1818 AtlasSet::open(&ppda),
1819 Err(ExportError::Traversal(_))
1820 ));
1821 restore();
1822 rewrite_ppda(&tmp.0, |d| d["pitches"] = serde_json::json!([-30, 0, 30]));
1823 assert!(AtlasSet::open(&ppda).is_err());
1824 restore();
1825 rewrite_ppda(&tmp.0, |d| d["maps"]["depth"]["height"] = 10.into());
1826 assert!(AtlasSet::open(&ppda).is_err());
1827 restore();
1828 rewrite_ppda(&tmp.0, |d| d["clips"][0]["frame_count"] = 2.into());
1829 assert!(AtlasSet::open(&ppda).is_err());
1830 restore();
1831 AtlasSet::open(&ppda).unwrap();
1832
1833 // Damaged atlas bytes fail the SHA-256 check.
1834 let normal = tmp.0.join("female-body-normal-atlas.png");
1835 let good = fs::read(&normal).unwrap();
1836 let mut bad = good.clone();
1837 let last = bad.len() - 20;
1838 bad[last] ^= 0xff;
1839 fs::remove_file(&normal).unwrap();
1840 fs::write(&normal, &bad).unwrap();
1841 assert!(matches!(
1842 AtlasSet::open(&ppda),
1843 Err(ExportError::Corrupt(_))
1844 ));
1845 fs::remove_file(&normal).unwrap();
1846 #[cfg(unix)]
1847 {
1848 // A symlinked atlas is refused even with matching bytes.
1849 let outside = Temp::new();
1850 fs::create_dir_all(&outside.0).unwrap();
1851 fs::write(outside.0.join("n.png"), &good).unwrap();
1852 std::os::unix::fs::symlink(outside.0.join("n.png"), &normal).unwrap();
1853 assert!(matches!(
1854 AtlasSet::open(&ppda),
1855 Err(ExportError::Traversal(_))
1856 ));
1857 fs::remove_file(&normal).unwrap();
1858 }
1859 fs::write(&normal, &good).unwrap();
1860 AtlasSet::open(&ppda).unwrap();
1861 // A renamed .ppda no longer matches its sex/settype.
1862 fs::copy(&ppda, tmp.0.join("male-body-atlas.ppda")).unwrap();
1863 assert!(AtlasSet::open(&tmp.0.join("male-body-atlas.ppda")).is_err());
1864 assert!(matches!(
1865 AtlasSet::open_run(&tmp.0),
1866 Err(ExportError::Identity(_))
1867 ));
1868 }
1869
1870 #[test]
1871 fn atlases_never_clobber_and_are_refused_when_too_large() {
1872 let tmp = fixture();
1873 let export = AtlasSet::open_run(&tmp.0).unwrap().ppda.export.unwrap();
1874 let render = export.render.clone();
1875 let sums = export.png_sha256.clone();
1876 let ppda_sha = crate::export_set::ExportRun::open(&tmp.0)
1877 .unwrap()
1878 .complete
1879 .atlas
1880 .ppda_sha256;
1881 let complete = serde_json::json!({"atlas": {"ppda_sha256": ppda_sha}});
1882 let mut spec = SyntheticRun::body("atlas-test", "female", b"body");
1883 spec.clips = vec![("walk".into(), 3, 0.75), ("idle".into(), 1, 0.0)];
1884 let plan = spec.plan();
1885 let cancel = AtomicBool::new(false);
1886 let job = AtlasJob {
1887 root: &tmp.0,
1888 render_doc: &render,
1889 plan: &plan,
1890 sex: "female",
1891 set: "body",
1892 cell_width: 3,
1893 cell_height: 2,
1894 png_sha256: &sums,
1895 attempt: 1,
1896 cancel: &cancel,
1897 trim: false,
1898 };
1899 // Re-running over identical files keeps them (no clobber, same result).
1900 let before = fs::read(tmp.0.join("female-body-atlas.ppda")).unwrap();
1901 let out = write_atlases(&job, &mut |_| {}).unwrap();
1902 assert_eq!(
1903 fs::read(tmp.0.join("female-body-atlas.ppda")).unwrap(),
1904 before
1905 );
1906 assert_eq!(out.ppda_sha256, complete["atlas"]["ppda_sha256"]);
1907 // A loose map changed after verification is refused.
1908 let rel = "raw files/female/body/clip000-walk/f000001/p+30_y090_specular.png";
1909 let mut tampered = sums.clone();
1910 tampered.insert(rel.into(), digest::sha256_hex(b"x"));
1911 let err = write_atlases(
1912 &AtlasJob {
1913 png_sha256: &tampered,
1914 ..job
1915 },
1916 &mut |_| {},
1917 )
1918 .unwrap_err();
1919 assert!(err.contains("checksum"), "{err}");
1920 // Too many frames for 256-pixel cells: clear refusal, never split.
1921 let mut big = spec.clone();
1922 big.clips = vec![("long".into(), 65, 16.25)];
1923 assert!(AtlasLayout::for_plan(&big.plan(), 256, 256)
1924 .unwrap_err()
1925 .contains("16384"));
1926 }
1927}