BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / atlas.rs

atlas.rs

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