BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / atlas.rs

atlas.rs

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