BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / atlas.rs

atlas.rs

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