BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / atlas.rs

atlas.rs

atlas.rs 51.0 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).
34pub const PPDA_FORMAT_VERSION: u32 = 3;
35/// Largest atlas side in pixels (common GPU texture limit). Larger exports are
36/// refused up front with a clear message; atlases are never split.
37pub const MAX_ATLAS_DIM: u32 = 16384;
38/// Bounded `.ppda` size on read.
39pub const MAX_PPDA_BYTES: u64 = 64 * 1024 * 1024;
40/// Bounded encoded atlas PNG size on read.
41pub const MAX_ATLAS_PNG_BYTES: u64 = 4 * 1024 * 1024 * 1024;
42/// Decoded-memory budget for one loaded atlas set (four maps) in the preview.
43pub const MAX_DECODED_SET_BYTES: u64 = 2 * 1024 * 1024 * 1024;
44/// Map order inside the `.ppda` and atlas production.
45pub const ATLAS_MAPS: [MapKind; 4] = [
46 MapKind::Diffuse,
47 MapKind::Normal,
48 MapKind::Depth,
49 MapKind::Specular,
50];
51const MAX_CLIPS: usize = 4096;
52const MAX_CLIP_NAME: usize = 256;
53
54pub fn atlas_png_name(sex: &str, set: &str, kind: MapKind) -> String {
55 format!("{sex}-{set}-{}-atlas.png", kind.token())
56}
57
58pub fn ppda_name(sex: &str, set: &str) -> String {
59 format!("{sex}-{set}-atlas.ppda")
60}
61
62/// Deterministic grid shared by all four atlases of one export.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub struct AtlasLayout {
65 pub cell_width: u32,
66 pub cell_height: u32,
67 pub columns: u32,
68 pub rows: u32,
69 pub width: u32,
70 pub height: u32,
71}
72
73impl AtlasLayout {
74 pub fn new(cell_width: u32, cell_height: u32, rows: u32) -> Result<Self, String> {
75 if cell_width == 0
76 || cell_height == 0
77 || cell_width > MAX_TARGET
78 || cell_height > MAX_TARGET
79 {
80 return Err("atlascel moet 1–256 pixels per as zijn".into());
81 }
82 if rows == 0 {
83 return Err("atlas zonder rijen".into());
84 }
85 let columns = (PRODUCTION_PITCHES.len() * production::production_yaws().len()) as u32;
86 let width = u64::from(columns) * u64::from(cell_width);
87 let height = u64::from(rows) * u64::from(cell_height);
88 if width > u64::from(MAX_ATLAS_DIM) || height > u64::from(MAX_ATLAS_DIM) {
89 return Err(format!(
90 "atlas zou {width}×{height} pixels worden ({columns} hoeken × {rows} frames); \
91 maximaal {MAX_ATLAS_DIM}×{MAX_ATLAS_DIM}. Verlaag fps, targetresolutie of \
92 animatieduur — de atlas wordt niet gesplitst"
93 ));
94 }
95 Ok(Self {
96 cell_width,
97 cell_height,
98 columns,
99 rows,
100 width: width as u32,
101 height: height as u32,
102 })
103 }
104
105 pub fn for_plan(plan: &Plan, cell_width: u32, cell_height: u32) -> Result<Self, String> {
106 let rows = plan
107 .clips
108 .iter()
109 .try_fold(0u32, |n, c| n.checked_add(c.frames))
110 .ok_or("te veel frames voor de atlas")?;
111 Self::new(cell_width, cell_height, rows)
112 }
113
114 /// Column of (pitch, yaw): pitch-major, then yaw.
115 pub fn column(pitch: i32, yaw: u32) -> Option<u32> {
116 let p = PRODUCTION_PITCHES.iter().position(|x| *x == pitch)?;
117 let y = production::production_yaws()
118 .iter()
119 .position(|x| *x == yaw)?;
120 Some((p * production::production_yaws().len() + y) as u32)
121 }
122
123 pub fn cell(&self, row: u32, column: u32) -> PpdaView {
124 let yaws = production::production_yaws();
125 let pitch = PRODUCTION_PITCHES[column as usize / yaws.len()];
126 let yaw = yaws[column as usize % yaws.len()];
127 PpdaView {
128 pitch,
129 yaw,
130 x: column * self.cell_width,
131 y: row * self.cell_height,
132 w: self.cell_width,
133 h: self.cell_height,
134 }
135 }
136}
137
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139#[serde(deny_unknown_fields)]
140pub struct Ppda {
141 pub format_version: u32,
142 pub sex: String,
143 pub settype: String,
144 pub project: PpdaProject,
145 /// Export/camera contract so body and equipment atlases can be checked for
146 /// compatibility without the loose run manifests.
147 pub identity: PpdaIdentity,
148 pub fps: u32,
149 /// Camera distance (m) of 16-bit depth sample 0 / 65535; see depth_encoding.
150 pub depth_near: f32,
151 pub depth_far: f32,
152 pub depth_encoding: String,
153 /// 8 or 16 (format 3); absent in format 2 (= 16-bit gray).
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub depth_bits: Option<u64>,
156 pub cell_width: u32,
157 pub cell_height: u32,
158 pub pitches: Vec<i32>,
159 pub yaws: Vec<u32>,
160 pub maps: BTreeMap<String, PpdaMap>,
161 pub clips: Vec<PpdaClip>,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(deny_unknown_fields)]
166pub struct PpdaProject {
167 pub id: String,
168 pub name: String,
169}
170
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
172#[serde(deny_unknown_fields)]
173pub struct PpdaIdentity {
174 pub body_model_sha256: String,
175 pub equipment_preset_sha256: Option<String>,
176 pub framing: CameraFraming,
177 pub pivot_source: String,
178 pub near: f32,
179 pub far: f32,
180 pub camera_distance: f32,
181 pub normal_space: String,
182 pub depth: String,
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct PpdaMap {
188 pub file: String,
189 pub sha256: String,
190 pub width: u32,
191 pub height: u32,
192}
193
194#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
195#[serde(deny_unknown_fields)]
196pub struct PpdaClip {
197 pub source_index: u32,
198 pub name: String,
199 pub token: String,
200 pub frame_count: u32,
201 pub duration: f32,
202 pub frames: Vec<PpdaFrame>,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
206#[serde(deny_unknown_fields)]
207pub struct PpdaFrame {
208 pub index: u32,
209 pub views: Vec<PpdaView>,
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(deny_unknown_fields)]
214pub struct PpdaView {
215 pub pitch: i32,
216 pub yaw: u32,
217 pub x: u32,
218 pub y: u32,
219 pub w: u32,
220 pub h: u32,
221}
222
223/// What production records in `complete.json` after writing the atlases.
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct AtlasOutput {
226 pub ppda: String,
227 pub ppda_sha256: String,
228 /// map token → (file name, sha256)
229 pub maps: BTreeMap<String, (String, String)>,
230}
231
232impl AtlasOutput {
233 pub fn to_json(&self) -> serde_json::Value {
234 let maps: serde_json::Map<String, serde_json::Value> = self
235 .maps
236 .iter()
237 .map(|(k, (file, sha))| (k.clone(), serde_json::json!({"file": file, "sha256": sha})))
238 .collect();
239 serde_json::json!({
240 "ppda": self.ppda,
241 "ppda_sha256": self.ppda_sha256,
242 "maps": maps,
243 })
244 }
245}
246
247struct HashingWriter<W: Write> {
248 inner: W,
249 hasher: Sha256,
250}
251
252impl<W: Write> Write for HashingWriter<W> {
253 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
254 let n = self.inner.write(buf)?;
255 self.hasher.update(&buf[..n]);
256 Ok(n)
257 }
258 fn flush(&mut self) -> std::io::Result<()> {
259 self.inner.flush()
260 }
261}
262
263/// Inputs for writing the atlases of one completed run.
264pub struct AtlasJob<'a> {
265 pub root: &'a Path,
266 pub render_doc: &'a serde_json::Value,
267 pub plan: &'a Plan,
268 pub sex: &'a str,
269 pub set: &'a str,
270 pub cell_width: u32,
271 pub cell_height: u32,
272 /// Relative loose PNG path → verified SHA-256 (every planned file).
273 pub png_sha256: &'a BTreeMap<String, String>,
274 /// Resume attempt number for quarantine names of stale atlas files.
275 pub attempt: u32,
276 pub cancel: &'a AtomicBool,
277}
278
279fn rows_of(plan: &Plan) -> Vec<(usize, u32)> {
280 plan.clips
281 .iter()
282 .enumerate()
283 .flat_map(|(c, clip)| (0..clip.frames).map(move |f| (c, f)))
284 .collect()
285}
286
287/// Load one verified loose cell as raw 4-byte-per-pixel rows (RGBA8 or
288/// big-endian gray16+alpha16, matching the atlas PNG sample layout).
289fn load_cell(
290 job: &AtlasJob<'_>,
291 clip: &str,
292 frame: u32,
293 pitch: i32,
294 yaw: u32,
295 kind: MapKind,
296) -> Result<Vec<u8>, String> {
297 let rel = production::output_path(job.sex, job.set, clip, frame, pitch, yaw, kind.token())?;
298 let rel = rel.to_str().ok_or("ongeldig PNG-pad")?.replace('\\', "/");
299 let expected = job
300 .png_sha256
301 .get(&rel)
302 .ok_or_else(|| format!("atlas: {rel} ontbreekt in de checksums"))?;
303 let path = job.root.join(&rel);
304 export_set::verify_regular_file(&path, job.root).map_err(|e| e.to_string())?;
305 let bytes = export_set::read_bounded_file(&path, MAX_PNG_BYTES).map_err(|e| e.to_string())?;
306 if digest::sha256_hex(&bytes) != *expected {
307 return Err(format!("atlas: checksum van {rel} klopt niet meer"));
308 }
309 if kind == MapKind::Depth {
310 // Copied in its own encoding, byte-exact (lossless for every format).
311 let fmt = crate::camera::DepthFormat::from_doc(job.render_doc)?;
312 return export_set::decode_depth_native(
313 &bytes,
314 job.cell_width,
315 job.cell_height,
316 export_set::MAX_TARGET,
317 fmt,
318 )
319 .map_err(|e| format!("atlas: {rel}: {e}"));
320 }
321 match export_set::decode_map(
322 &bytes,
323 kind,
324 job.cell_width,
325 job.cell_height,
326 crate::camera::DepthFormat::GRAY16,
327 )
328 .map_err(|e| format!("atlas: {rel}: {e}"))?
329 {
330 MapImage::Rgba8 { rgba, .. } => Ok(rgba),
331 MapImage::Depth16 { .. } => Err("atlas: onverwachte depth".into()),
332 }
333}
334
335/// Stream-encode one atlas PNG into `out`.
336fn encode_atlas(
337 job: &AtlasJob<'_>,
338 layout: &AtlasLayout,
339 kind: MapKind,
340 out: &mut dyn Write,
341 progress: &mut dyn FnMut(String),
342) -> Result<(), String> {
343 let mut encoder = png::Encoder::new(out, layout.width, layout.height);
344 let mut bpp = 4;
345 if kind == MapKind::Depth {
346 let fmt = crate::camera::DepthFormat::from_doc(job.render_doc)?;
347 encoder.set_color(fmt.color_type());
348 encoder.set_depth(fmt.bit_depth());
349 bpp = fmt.bytes_per_pixel();
350 } else {
351 encoder.set_color(png::ColorType::Rgba);
352 encoder.set_depth(png::BitDepth::Eight);
353 if kind == MapKind::Diffuse {
354 encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual);
355 }
356 }
357 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
358 let row_bytes = layout.width as usize * bpp;
359 let cell_row_bytes = layout.cell_width as usize * bpp;
360 let mut stream = writer
361 .stream_writer_with_size(row_bytes.max(4096))
362 .map_err(|e| e.to_string())?;
363 let rows = rows_of(job.plan);
364 let total = rows.len();
365 let mut band = vec![0u8; row_bytes * layout.cell_height as usize];
366 for (row, (clip, frame)) in rows.into_iter().enumerate() {
367 if job.cancel.load(Ordering::Relaxed) {
368 return Err("geannuleerd".into());
369 }
370 if row % 8 == 0 {
371 progress(format!(
372 "atlas {} · rij {}/{}",
373 kind.token(),
374 row + 1,
375 total
376 ));
377 }
378 let token = &job.plan.clips[clip].token;
379 for column in 0..layout.columns {
380 let view = layout.cell(0, column);
381 let cell = load_cell(job, token, frame, view.pitch, view.yaw, kind)?;
382 for y in 0..layout.cell_height as usize {
383 let dst = y * row_bytes + view.x as usize * bpp;
384 band[dst..dst + cell_row_bytes]
385 .copy_from_slice(&cell[y * cell_row_bytes..(y + 1) * cell_row_bytes]);
386 }
387 }
388 stream.write_all(&band).map_err(|e| e.to_string())?;
389 }
390 stream.finish().map_err(|e| e.to_string())?;
391 writer.finish().map_err(|e| e.to_string())?;
392 Ok(())
393}
394
395/// Publish `name` in the run root without clobbering. When a file already
396/// exists (crash after publication during an earlier attempt) it is kept when
397/// byte-identical to the regenerated output, otherwise moved aside.
398fn publish_streamed(
399 root: &Path,
400 name: &str,
401 attempt: u32,
402 mut encode: impl FnMut(&mut dyn Write) -> Result<(), String>,
403) -> Result<String, String> {
404 let path = root.join(name);
405 if fs::symlink_metadata(&path).is_ok() {
406 let mut sink = HashingWriter {
407 inner: std::io::sink(),
408 hasher: Sha256::new(),
409 };
410 encode(&mut sink)?;
411 let fresh = digest::hex(&sink.hasher.finalize());
412 let regular = fs::symlink_metadata(&path)
413 .map(|m| m.file_type().is_file())
414 .unwrap_or(false);
415 if regular
416 && digest::sha256_file(&path, MAX_ATLAS_PNG_BYTES)
417 .ok()
418 .as_deref()
419 == Some(fresh.as_str())
420 {
421 return Ok(fresh);
422 }
423 crate::resume::quarantine(root, name, attempt)?;
424 }
425 let mut digest_out = None;
426 production::atomic_file(root, Path::new(name), |file| {
427 let mut writer = HashingWriter {
428 inner: std::io::BufWriter::new(file),
429 hasher: Sha256::new(),
430 };
431 encode(&mut writer)?;
432 writer.flush().map_err(|e| e.to_string())?;
433 digest_out = Some(digest::hex(&writer.hasher.finalize()));
434 Ok(())
435 })?;
436 digest_out.ok_or_else(|| "atlas-digest ontbreekt".into())
437}
438
439/// Build the `.ppda` document for a plan/contract (pure; no disk access).
440pub fn build_ppda(
441 render_doc: &serde_json::Value,
442 plan: &Plan,
443 layout: &AtlasLayout,
444 sex: &str,
445 set: &str,
446 map_digests: &BTreeMap<String, String>,
447) -> Result<Ppda, String> {
448 let get = |p: &str| render_doc.pointer(p).ok_or(format!("render.json mist {p}"));
449 let text = |p: &str| -> Result<String, String> {
450 get(p)?
451 .as_str()
452 .map(str::to_string)
453 .ok_or(format!("{p} is geen tekst"))
454 };
455 let framing: CameraFraming =
456 serde_json::from_value(get("/framing")?.clone()).map_err(|e| e.to_string())?;
457 let fps = get("/fps")?.as_u64().ok_or("fps")? as u32;
458 let (depth_near, depth_far) = crate::camera::depth_range(framing);
459 let mut maps = BTreeMap::new();
460 for kind in ATLAS_MAPS {
461 maps.insert(
462 kind.token().to_string(),
463 PpdaMap {
464 file: atlas_png_name(sex, set, kind),
465 sha256: map_digests
466 .get(kind.token())
467 .cloned()
468 .ok_or("atlas-digest ontbreekt")?,
469 width: layout.width,
470 height: layout.height,
471 },
472 );
473 }
474 let mut row = 0u32;
475 let mut clips = Vec::with_capacity(plan.clips.len());
476 for (i, clip) in plan.clips.iter().enumerate() {
477 let duration = render_doc
478 .pointer(&format!("/clips/{i}/duration"))
479 .and_then(|d| d.as_f64())
480 .unwrap_or(0.) as f32;
481 let mut frames = Vec::with_capacity(clip.frames as usize);
482 for index in 0..clip.frames {
483 frames.push(PpdaFrame {
484 index,
485 views: (0..layout.columns).map(|c| layout.cell(row, c)).collect(),
486 });
487 row += 1;
488 }
489 clips.push(PpdaClip {
490 source_index: i as u32,
491 name: clip.source_name.clone(),
492 token: clip.token.clone(),
493 frame_count: clip.frames,
494 duration,
495 frames,
496 });
497 }
498 Ok(Ppda {
499 format_version: PPDA_FORMAT_VERSION,
500 sex: sex.into(),
501 settype: set.into(),
502 project: PpdaProject {
503 id: text("/project/id")?,
504 name: text("/project/name")?,
505 },
506 identity: PpdaIdentity {
507 body_model_sha256: text("/body_model/sha256")?,
508 equipment_preset_sha256: render_doc
509 .pointer("/equipment_preset/sha256")
510 .and_then(|v| v.as_str())
511 .map(str::to_string),
512 framing,
513 pivot_source: text("/pivot_source/kind")?,
514 near: depth_near,
515 far: depth_far,
516 camera_distance: crate::camera::CAMERA_DISTANCE,
517 normal_space: text("/normal_space")?,
518 depth: text("/depth")?,
519 },
520 fps,
521 depth_near,
522 depth_far,
523 depth_encoding: crate::camera::DepthFormat::from_doc(render_doc)?
524 .encoding
525 .token()
526 .into(),
527 depth_bits: Some(u64::from(
528 crate::camera::DepthFormat::from_doc(render_doc)?.bits,
529 )),
530 cell_width: layout.cell_width,
531 cell_height: layout.cell_height,
532 pitches: PRODUCTION_PITCHES.to_vec(),
533 yaws: production::production_yaws().to_vec(),
534 maps,
535 clips,
536 })
537}
538
539/// Write the four atlases and the `.ppda` after all loose maps are verified.
540pub fn write_atlases(
541 job: &AtlasJob<'_>,
542 progress: &mut dyn FnMut(String),
543) -> Result<AtlasOutput, String> {
544 let layout = AtlasLayout::for_plan(job.plan, job.cell_width, job.cell_height)?;
545 if job.png_sha256.len() as u64 != u64::from(job.plan.files) {
546 return Err("atlas: niet alle losse maps zijn geverifieerd".into());
547 }
548 let mut maps = BTreeMap::new();
549 let mut digests = BTreeMap::new();
550 for kind in ATLAS_MAPS {
551 let name = atlas_png_name(job.sex, job.set, kind);
552 let digest = publish_streamed(job.root, &name, job.attempt, |out| {
553 encode_atlas(job, &layout, kind, out, progress)
554 })?;
555 digests.insert(kind.token().to_string(), digest.clone());
556 maps.insert(kind.token().to_string(), (name, digest));
557 }
558 let ppda = build_ppda(
559 job.render_doc,
560 job.plan,
561 &layout,
562 job.sex,
563 job.set,
564 &digests,
565 )?;
566 let mut bytes = serde_json::to_vec_pretty(&ppda).map_err(|e| e.to_string())?;
567 bytes.push(b'\n');
568 let name = ppda_name(job.sex, job.set);
569 progress(format!("atlas · {name}"));
570 let ppda_sha256 = publish_streamed(job.root, &name, job.attempt, |out| {
571 out.write_all(&bytes).map_err(|e| e.to_string())
572 })?;
573 Ok(AtlasOutput {
574 ppda: name,
575 ppda_sha256,
576 maps,
577 })
578}
579
580// ---------------------------------------------------------------- reader --
581
582fn ident(msg: impl Into<String>) -> ExportError {
583 ExportError::Identity(msg.into())
584}
585
586impl Ppda {
587 /// Depth PNG format of the depth atlas.
588 pub fn depth_format(&self) -> Result<crate::camera::DepthFormat, ExportError> {
589 if (self.format_version >= 3) != self.depth_bits.is_some() {
590 return Err(ident(".ppda: depth_bits past niet bij format_version"));
591 }
592 crate::camera::DepthFormat::from_manifest(self.depth_bits, &self.depth_encoding)
593 .map_err(|e| ident(format!(".ppda: {e}")))
594 }
595}
596
597fn validate_ppda(p: &Ppda, file_name: &str) -> Result<AtlasLayout, ExportError> {
598 if !(2..=PPDA_FORMAT_VERSION).contains(&p.format_version) {
599 return Err(ident(format!(
600 ".ppda format_version {} wordt niet ondersteund (verwacht {PPDA_FORMAT_VERSION}; versie 1 = oud globaal dieptebereik)",
601 p.format_version
602 )));
603 }
604 if !["male", "female"].contains(&p.sex.as_str()) {
605 return Err(ident(".ppda: ongeldig geslacht"));
606 }
607 if p.settype != "body" && !crate::equipment::valid_name(&p.settype) {
608 return Err(ident(".ppda: ongeldig settype"));
609 }
610 if file_name != ppda_name(&p.sex, &p.settype) {
611 return Err(ident(format!(
612 ".ppda-bestandsnaam '{file_name}' past niet bij {}/{}",
613 p.sex, p.settype
614 )));
615 }
616 if p.project.id.is_empty() || p.project.id.len() > 128 || p.project.name.len() > 256 {
617 return Err(ident(".ppda: project id/naam ongeldig"));
618 }
619 let id = &p.identity;
620 if !is_sha256_hex(&id.body_model_sha256) {
621 return Err(ExportError::Corrupt(
622 ".ppda: body_model_sha256 ongeldig".into(),
623 ));
624 }
625 match (&id.equipment_preset_sha256, p.settype == "body") {
626 (None, true) => {}
627 (Some(h), false) if is_sha256_hex(h) => {}
628 _ => {
629 return Err(ident(
630 ".ppda: equipment_preset_sha256 past niet bij settype",
631 ))
632 }
633 }
634 id.framing.validate().map_err(ExportError::Identity)?;
635 if !["manual", "neck", "fallback"].contains(&id.pivot_source.as_str()) {
636 return Err(ident(".ppda: onbekende pivot_source"));
637 }
638 let (near, far) = crate::camera::depth_range(id.framing);
639 if id.near != near
640 || id.far != far
641 || p.depth_near != near
642 || p.depth_far != far
643 || id.camera_distance != crate::camera::CAMERA_DISTANCE
644 {
645 return Err(ident(".ppda: camera-/dieptecontract wijkt af"));
646 }
647 p.depth_format()?;
648 if p.fps == 0 || p.fps > 240 {
649 return Err(ident(".ppda: ongeldige fps"));
650 }
651 if p.pitches != PRODUCTION_PITCHES || p.yaws != production::production_yaws() {
652 return Err(ident(".ppda: pitches/yaws wijken af van −60…+60 × 0…330"));
653 }
654 if p.clips.is_empty() || p.clips.len() > MAX_CLIPS {
655 return Err(ExportError::Resource(
656 ".ppda: clip-inventaris ongeldig".into(),
657 ));
658 }
659 let rows = p
660 .clips
661 .iter()
662 .try_fold(0u32, |n, c| n.checked_add(c.frame_count))
663 .ok_or_else(|| ExportError::Resource(".ppda: te veel frames".into()))?;
664 let layout =
665 AtlasLayout::new(p.cell_width, p.cell_height, rows).map_err(ExportError::Resource)?;
666 let mut tokens = std::collections::BTreeSet::new();
667 let mut row = 0u32;
668 for (i, clip) in p.clips.iter().enumerate() {
669 if clip.source_index as usize != i {
670 return Err(ident(".ppda: clips niet in bronvolgorde"));
671 }
672 if clip.token.is_empty()
673 || clip.token.len() > 64
674 || !crate::equipment::valid_name(&clip.token)
675 || !tokens.insert(clip.token.clone())
676 {
677 return Err(ident(format!(
678 ".ppda: ongeldige clip-token '{}'",
679 clip.token
680 )));
681 }
682 if clip.name.len() > MAX_CLIP_NAME || !clip.duration.is_finite() || clip.duration < 0. {
683 return Err(ident(".ppda: clipnaam/duur ongeldig"));
684 }
685 if clip.frame_count == 0 || clip.frames.len() != clip.frame_count as usize {
686 return Err(ident(".ppda: frame_count past niet bij frames"));
687 }
688 for (k, frame) in clip.frames.iter().enumerate() {
689 if frame.index as usize != k || frame.views.len() != layout.columns as usize {
690 return Err(ident(".ppda: frame-index of aantal hoeken ongeldig"));
691 }
692 for (c, view) in frame.views.iter().enumerate() {
693 let expected = layout.cell(row, c as u32);
694 let inside = u64::from(view.x) + u64::from(view.w) <= u64::from(layout.width)
695 && u64::from(view.y) + u64::from(view.h) <= u64::from(layout.height);
696 if *view != expected || !inside {
697 return Err(ExportError::Corrupt(format!(
698 ".ppda: celcoördinaten van {} f{k} p{} y{} kloppen niet",
699 clip.token, view.pitch, view.yaw
700 )));
701 }
702 }
703 row += 1;
704 }
705 }
706 if p.maps.len() != ATLAS_MAPS.len() {
707 return Err(ident(
708 ".ppda: maps moet precies diffuse/normal/depth/specular bevatten",
709 ));
710 }
711 for kind in ATLAS_MAPS {
712 let m = p
713 .maps
714 .get(kind.token())
715 .ok_or_else(|| ident(format!(".ppda mist map {}", kind.token())))?;
716 if m.file != atlas_png_name(&p.sex, &p.settype, kind) {
717 return Err(ExportError::Traversal(format!(
718 ".ppda: atlasbestand '{}' is niet de verwachte naam in de exportmap",
719 m.file
720 )));
721 }
722 if !is_sha256_hex(&m.sha256) {
723 return Err(ExportError::Corrupt(".ppda: atlas sha256 ongeldig".into()));
724 }
725 if m.width != layout.width || m.height != layout.height {
726 return Err(ExportError::Corrupt(format!(
727 ".ppda: {} atlasafmetingen {}x{} wijken af van raster {}x{}",
728 kind.token(),
729 m.width,
730 m.height,
731 layout.width,
732 layout.height
733 )));
734 }
735 }
736 Ok(layout)
737}
738
739/// A validated `.ppda` whose atlases exist, match their SHA-256 and headers.
740#[derive(Debug, Clone)]
741pub struct AtlasSet {
742 dir: PathBuf,
743 pub ppda: Ppda,
744 pub layout: AtlasLayout,
745}
746
747impl AtlasSet {
748 /// Export folder containing the `.ppda` and its atlases.
749 pub fn dir(&self) -> &Path {
750 &self.dir
751 }
752
753 fn read_ppda(path: &Path) -> Result<(PathBuf, Ppda, AtlasLayout), ExportError> {
754 let dir = path
755 .parent()
756 .ok_or_else(|| ExportError::Io(".ppda zonder map".into()))?;
757 let dir = dir
758 .canonicalize()
759 .map_err(|e| ExportError::Io(e.to_string()))?;
760 let file_name = path
761 .file_name()
762 .and_then(|n| n.to_str())
763 .ok_or_else(|| ExportError::Io("ongeldige .ppda-naam".into()))?
764 .to_string();
765 let path = dir.join(&file_name);
766 export_set::verify_regular_file(&path, &dir)?;
767 let bytes = export_set::read_bounded_file(&path, MAX_PPDA_BYTES)?;
768 let text = std::str::from_utf8(&bytes)
769 .map_err(|_| ExportError::Corrupt(".ppda is geen UTF-8".into()))?;
770 let ppda: Ppda =
771 serde_json::from_str(text).map_err(|e| ExportError::Corrupt(format!(".ppda: {e}")))?;
772 let layout = validate_ppda(&ppda, &file_name)?;
773 Ok((dir, ppda, layout))
774 }
775
776 fn read_map(&self, kind: MapKind) -> Result<Vec<u8>, ExportError> {
777 let m = &self.ppda.maps[kind.token()];
778 let path = self.dir.join(&m.file);
779 export_set::verify_regular_file(&path, &self.dir)?;
780 let bytes = export_set::read_bounded_file(&path, MAX_ATLAS_PNG_BYTES)?;
781 if digest::sha256_hex(&bytes) != m.sha256 {
782 return Err(ExportError::Corrupt(format!(
783 "checksum mismatch voor '{}'",
784 m.file
785 )));
786 }
787 Ok(bytes)
788 }
789
790 /// Validate the manifest, then each atlas: regular file inside the folder,
791 /// SHA-256, PNG header dimensions and sample format.
792 pub fn open(ppda_path: &Path) -> Result<Self, ExportError> {
793 let (dir, ppda, layout) = Self::read_ppda(ppda_path)?;
794 let set = Self { dir, ppda, layout };
795 for kind in ATLAS_MAPS {
796 let bytes = set.read_map(kind)?;
797 let decoder = png::Decoder::new(std::io::Cursor::new(&bytes[..]));
798 let reader = decoder
799 .read_info()
800 .map_err(|e| ExportError::Corrupt(format!("atlas PNG header: {e}")))?;
801 let info = reader.info();
802 let (color, depth) = if kind == MapKind::Depth {
803 let fmt = set.ppda.depth_format()?;
804 (fmt.color_type(), fmt.bit_depth())
805 } else {
806 (png::ColorType::Rgba, png::BitDepth::Eight)
807 };
808 if info.width != layout.width
809 || info.height != layout.height
810 || info.color_type != color
811 || info.bit_depth != depth
812 {
813 return Err(ExportError::Corrupt(format!(
814 "{} atlas: afmetingen of kleurtype wijken af van .ppda",
815 kind.token()
816 )));
817 }
818 }
819 Ok(set)
820 }
821
822 /// Open the single `.ppda` inside a run directory.
823 pub fn open_run(run_dir: &Path) -> Result<Self, ExportError> {
824 let mut found = Vec::new();
825 for entry in fs::read_dir(run_dir).map_err(|e| ExportError::Io(e.to_string()))? {
826 let entry = entry.map_err(|e| ExportError::Io(e.to_string()))?;
827 let name = entry.file_name();
828 if name.to_str().is_some_and(|n| n.ends_with("-atlas.ppda")) {
829 found.push(entry.path());
830 }
831 }
832 match found.as_slice() {
833 [one] => Self::open(one),
834 [] => Err(ExportError::Incomplete(
835 "geen .ppda in deze exportmap".into(),
836 )),
837 _ => Err(ExportError::Identity(
838 "meer dan één .ppda in deze exportmap".into(),
839 )),
840 }
841 }
842
843 /// Decode all four atlases (re-verifying SHA-256 on the bytes decoded) with
844 /// the full map pixel contract.
845 pub fn load(&self) -> Result<LoadedAtlas, ExportError> {
846 let decoded = u64::from(self.layout.width) * u64::from(self.layout.height) * 4 * 4;
847 if decoded > MAX_DECODED_SET_BYTES {
848 return Err(ExportError::Resource(format!(
849 "atlasset te groot om te laden ({decoded} bytes gedecodeerd)"
850 )));
851 }
852 let mut maps = BTreeMap::new();
853 let mut depth_display = Vec::new();
854 for kind in ATLAS_MAPS {
855 let bytes = self.read_map(kind)?;
856 if kind == MapKind::Depth {
857 let fmt = self.ppda.depth_format()?;
858 let native = export_set::decode_depth_native(
859 &bytes,
860 self.layout.width,
861 self.layout.height,
862 MAX_ATLAS_DIM,
863 fmt,
864 )?;
865 depth_display = export_set::depth_display_rgba(&native, fmt);
866 }
867 let image = export_set::decode_map_sized(
868 &bytes,
869 kind,
870 self.layout.width,
871 self.layout.height,
872 MAX_ATLAS_DIM,
873 self.ppda.depth_format()?,
874 )?;
875 maps.insert(kind.token(), image);
876 }
877 Ok(LoadedAtlas {
878 set: self.clone(),
879 maps,
880 depth_display,
881 })
882 }
883}
884
885/// Decoded atlases for one `.ppda`; crops cells for the compositor.
886#[derive(Debug, Clone)]
887pub struct LoadedAtlas {
888 pub set: AtlasSet,
889 maps: BTreeMap<&'static str, MapImage>,
890 /// Depth atlas as stored, RGBA8 display form (no decoding; Tim t992u).
891 depth_display: Vec<u8>,
892}
893
894impl LoadedAtlas {
895 pub fn ppda(&self) -> &Ppda {
896 &self.set.ppda
897 }
898
899 /// Coordinates of one view from the `.ppda`.
900 pub fn view(
901 &self,
902 clip_token: &str,
903 frame: u32,
904 pitch: i32,
905 yaw: u32,
906 ) -> Result<PpdaView, ExportError> {
907 let clip = self
908 .set
909 .ppda
910 .clips
911 .iter()
912 .find(|c| c.token == clip_token)
913 .ok_or_else(|| ExportError::Incomplete(format!("clip '{clip_token}' niet in .ppda")))?;
914 let frame = clip.frames.get(frame as usize).ok_or_else(|| {
915 ExportError::Incomplete(format!("frame {frame} niet in clip '{clip_token}'"))
916 })?;
917 frame
918 .views
919 .iter()
920 .find(|v| v.pitch == pitch && v.yaw == yaw)
921 .copied()
922 .ok_or_else(|| ExportError::Incomplete(format!("hoek p{pitch} y{yaw} niet in .ppda")))
923 }
924
925 /// Crop the depth cell as stored, in RGBA8 display form (rgb_bands /
926 /// rgb_packed keep their raw RGB; 16-bit at display precision).
927 pub fn depth_display_cell(
928 &self,
929 clip_token: &str,
930 frame: u32,
931 pitch: i32,
932 yaw: u32,
933 ) -> Result<Vec<u8>, ExportError> {
934 let v = self.view(clip_token, frame, pitch, yaw)?;
935 let atlas_w = self.set.layout.width as usize;
936 let (x, y, w, h) = (v.x as usize, v.y as usize, v.w as usize, v.h as usize);
937 let mut out = Vec::with_capacity(w * h * 4);
938 for row in y..y + h {
939 let start = (row * atlas_w + x) * 4;
940 out.extend_from_slice(&self.depth_display[start..start + w * 4]);
941 }
942 Ok(out)
943 }
944
945 /// Crop one map cell exactly as stored.
946 pub fn cell(
947 &self,
948 clip_token: &str,
949 frame: u32,
950 pitch: i32,
951 yaw: u32,
952 kind: MapKind,
953 ) -> Result<MapImage, ExportError> {
954 let v = self.view(clip_token, frame, pitch, yaw)?;
955 let atlas_w = self.set.layout.width as usize;
956 let (x, y, w, h) = (v.x as usize, v.y as usize, v.w as usize, v.h as usize);
957 match &self.maps[kind.token()] {
958 MapImage::Rgba8 { rgba, .. } => {
959 let mut out = Vec::with_capacity(w * h * 4);
960 for row in y..y + h {
961 let start = (row * atlas_w + x) * 4;
962 out.extend_from_slice(&rgba[start..start + w * 4]);
963 }
964 Ok(MapImage::Rgba8 {
965 width: v.w,
966 height: v.h,
967 rgba: out,
968 })
969 }
970 MapImage::Depth16 { samples, .. } => {
971 let mut out = Vec::with_capacity(w * h * 2);
972 for row in y..y + h {
973 let start = (row * atlas_w + x) * 2;
974 out.extend_from_slice(&samples[start..start + w * 2]);
975 }
976 Ok(MapImage::Depth16 {
977 width: v.w,
978 height: v.h,
979 samples: out,
980 })
981 }
982 }
983 }
984}
985
986/// Open a completed run through both readers and compare every atlas cell of
987/// every map with its loose PNG. Returns a JSON summary (used by GPU proofs).
988pub fn cross_check_run(run_dir: &Path) -> Result<serde_json::Value, String> {
989 let run = export_set::ExportRun::open(run_dir).map_err(|e| e.to_string())?;
990 let set = AtlasSet::open_run(run_dir).map_err(|e| e.to_string())?;
991 let loaded = set.load().map_err(|e| e.to_string())?;
992 let mut cells = 0u64;
993 for clip in &set.ppda.clips {
994 for frame in &clip.frames {
995 for view in &frame.views {
996 for kind in ATLAS_MAPS {
997 let a = loaded
998 .cell(&clip.token, frame.index, view.pitch, view.yaw, kind)
999 .map_err(|e| e.to_string())?;
1000 let b = run
1001 .load_map(&clip.token, frame.index, view.pitch, view.yaw, kind)
1002 .map_err(|e| e.to_string())?;
1003 if a != b {
1004 return Err(format!(
1005 "atlascel {} f{} p{} y{} {} wijkt af van losse PNG",
1006 clip.token,
1007 frame.index,
1008 view.pitch,
1009 view.yaw,
1010 kind.token()
1011 ));
1012 }
1013 cells += 1;
1014 }
1015 }
1016 }
1017 }
1018 if cells != u64::from(run.complete.png_files) {
1019 return Err(format!(
1020 "{cells} atlascellen ≠ {} losse PNG's",
1021 run.complete.png_files
1022 ));
1023 }
1024 Ok(serde_json::json!({
1025 "ppda": ppda_name(&set.ppda.sex, &set.ppda.settype),
1026 "atlas_size": [set.layout.width, set.layout.height],
1027 "cell": [set.layout.cell_width, set.layout.cell_height],
1028 "rows": set.layout.rows,
1029 "columns": set.layout.columns,
1030 "fps": set.ppda.fps,
1031 "cells_equal_to_loose_pngs": cells,
1032 "maps": set.ppda.maps.iter().map(|(k, m)| (k.clone(), serde_json::json!({"file": m.file, "sha256": m.sha256}))).collect::<serde_json::Map<_, _>>(),
1033 }))
1034}
1035
1036#[cfg(test)]
1037mod tests {
1038 use super::*;
1039 use crate::export_fixture::{Pixel, SyntheticRun};
1040 use std::sync::atomic::AtomicU64;
1041
1042 struct Temp(PathBuf);
1043 impl Temp {
1044 fn new() -> Self {
1045 static N: AtomicU64 = AtomicU64::new(0);
1046 let path = std::env::temp_dir().join(format!(
1047 "paperdoll-atlas-{}-{}",
1048 std::process::id(),
1049 N.fetch_add(1, Ordering::Relaxed)
1050 ));
1051 let _ = fs::remove_dir_all(&path);
1052 Self(path)
1053 }
1054 }
1055 impl Drop for Temp {
1056 fn drop(&mut self) {
1057 let _ = fs::remove_dir_all(&self.0);
1058 }
1059 }
1060
1061 fn fixture() -> Temp {
1062 let tmp = Temp::new();
1063 let mut spec = SyntheticRun::body("atlas-test", "female", b"body");
1064 spec.clips = vec![("walk".into(), 3, 0.75), ("idle".into(), 1, 0.0)];
1065 spec.target = [3, 2];
1066 spec.write(&tmp.0, |c, f, p, y| Pixel {
1067 diffuse: [c as u8 * 100 + f as u8, (p + 60) as u8, (y / 30) as u8, 255],
1068 normal: [128, 128, 255, 255],
1069 specular: [9, 9, 9, 255],
1070 depth: 1000 + f as u16,
1071 })
1072 .unwrap();
1073 tmp
1074 }
1075
1076 fn rewrite_ppda(dir: &Path, edit: impl FnOnce(&mut serde_json::Value)) {
1077 let path = dir.join("female-body-atlas.ppda");
1078 let mut doc: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
1079 edit(&mut doc);
1080 fs::remove_file(&path).unwrap();
1081 fs::write(&path, serde_json::to_vec_pretty(&doc).unwrap()).unwrap();
1082 }
1083
1084 #[test]
1085 fn all_depth_formats_round_trip_through_loose_pngs_atlas_and_reader() {
1086 use crate::camera::{DepthEncoding, DepthFormat};
1087 for bits in [8u8, 16] {
1088 for encoding in DepthEncoding::ALL {
1089 let fmt = DepthFormat::new(bits, encoding).unwrap();
1090 let tmp = Temp::new();
1091 let mut spec = SyntheticRun::body("atlas-test", "female", b"body");
1092 spec.clips = vec![("walk".into(), 2, 0.5)];
1093 spec.target = [2, 2];
1094 spec.depth_format = fmt;
1095 spec.write(&tmp.0, |_, f, p, y| Pixel {
1096 diffuse: [1, 2, 3, if y == 30 { 0 } else { 255 }],
1097 normal: if y == 30 {
1098 [0, 0, 0, 0]
1099 } else {
1100 [128, 128, 255, 255]
1101 },
1102 specular: if y == 30 {
1103 [0, 0, 0, 0]
1104 } else {
1105 [0, 0, 0, 255]
1106 },
1107 depth: 9000 + f as u16 * 20000 + (p + 60) as u16 * 50 + y as u16,
1108 })
1109 .unwrap();
1110 let set = AtlasSet::open_run(&tmp.0).unwrap();
1111 assert_eq!(set.ppda.depth_encoding, encoding.token());
1112 assert_eq!(set.ppda.depth_bits, Some(u64::from(bits)));
1113 let loaded = set.load().unwrap();
1114 let MapImage::Depth16 { samples, width, .. } = &loaded.maps["depth"] else {
1115 panic!("depth")
1116 };
1117 // Precision: one level of the format, in 16-bit canonical units.
1118 let tol = (65535. / fmt.levels() as f64).ceil() as i64;
1119 for f in 0..2u32 {
1120 for col in 0..set.layout.columns {
1121 let cell = set.layout.cell(0, col);
1122 let view = (cell.pitch, cell.yaw);
1123 let x = col * 2;
1124 let y = f * 2;
1125 let i = (y * width + x) as usize * 2;
1126 if view.1 == 30 {
1127 assert_eq!(&samples[i..i + 2], &[0, 0]);
1128 continue;
1129 }
1130 let want =
1131 9000 + f as i64 * 20000 + (view.0 + 60) as i64 * 50 + view.1 as i64;
1132 let got = samples[i] as i64;
1133 assert!((got - want).abs() <= tol, "{fmt:?}: {got} vs {want}");
1134 assert_eq!(samples[i + 1], 65535);
1135 }
1136 }
1137 crate::export_set::ExportRun::open(&tmp.0).unwrap();
1138 }
1139 }
1140 }
1141
1142 #[test]
1143 fn legacy_ppda_v2_gray16_is_still_read() {
1144 let tmp = fixture();
1145 rewrite_ppda(&tmp.0, |d| {
1146 d["format_version"] = 2.into();
1147 d.as_object_mut().unwrap().remove("depth_bits");
1148 d["depth_encoding"] = crate::camera::DEPTH_ENCODING.into();
1149 });
1150 let set = AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).unwrap();
1151 assert_eq!(
1152 set.ppda.depth_format().unwrap(),
1153 crate::camera::DepthFormat::GRAY16
1154 );
1155 set.load().unwrap();
1156 // A v3 file must say which format it uses.
1157 rewrite_ppda(&tmp.0, |d| d["format_version"] = 3.into());
1158 assert!(AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).is_err());
1159 }
1160
1161 #[test]
1162 fn layout_is_pitch_major_columns_and_frame_rows() {
1163 let l = AtlasLayout::new(32, 48, 5).unwrap();
1164 assert_eq!((l.columns, l.width, l.height), (60, 60 * 32, 5 * 48));
1165 assert_eq!(AtlasLayout::column(-60, 0), Some(0));
1166 assert_eq!(AtlasLayout::column(-60, 330), Some(11));
1167 assert_eq!(AtlasLayout::column(-30, 0), Some(12));
1168 assert_eq!(AtlasLayout::column(60, 330), Some(59));
1169 assert_eq!(AtlasLayout::column(45, 0), None);
1170 let v = l.cell(2, 13);
1171 assert_eq!(
1172 (v.pitch, v.yaw, v.x, v.y, v.w, v.h),
1173 (-30, 30, 13 * 32, 96, 32, 48)
1174 );
1175 // 256-pixel cells allow 64 frame rows; 65 exceeds the GPU/PNG limit.
1176 assert!(AtlasLayout::new(256, 256, 64).is_ok());
1177 let err = AtlasLayout::new(256, 256, 65).unwrap_err();
1178 assert!(err.contains("niet gesplitst"), "{err}");
1179 assert!(AtlasLayout::new(0, 1, 1).is_err());
1180 assert!(AtlasLayout::new(257, 1, 1).is_err());
1181 }
1182
1183 #[test]
1184 fn written_ppda_has_exact_coordinates_names_fps_and_checksums() {
1185 let tmp = fixture();
1186 let set = AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).unwrap();
1187 let p = &set.ppda;
1188 assert_eq!(p.format_version, 3);
1189 assert_eq!((p.sex.as_str(), p.settype.as_str()), ("female", "body"));
1190 let (near, far) = crate::camera::depth_range(p.identity.framing);
1191 assert_eq!((p.depth_near, p.depth_far), (near, far));
1192 assert_eq!((p.identity.near, p.identity.far), (near, far));
1193 assert_eq!(p.depth_encoding, "gray");
1194 assert_eq!(p.depth_bits, Some(16));
1195 assert_eq!(p.fps, 4);
1196 assert_eq!((p.cell_width, p.cell_height), (3, 2));
1197 assert_eq!(p.pitches, [-60, -30, 0, 30, 60]);
1198 assert_eq!(p.yaws.len(), 12);
1199 assert_eq!(p.maps["diffuse"].file, "female-body-diffuse-atlas.png");
1200 assert_eq!(p.maps["depth"].file, "female-body-depth-atlas.png");
1201 assert_eq!((p.maps["normal"].width, p.maps["normal"].height), (180, 8));
1202 for kind in ATLAS_MAPS {
1203 let m = &p.maps[kind.token()];
1204 let bytes = fs::read(tmp.0.join(&m.file)).unwrap();
1205 assert_eq!(digest::sha256_hex(&bytes), m.sha256);
1206 }
1207 assert_eq!(p.clips.len(), 2);
1208 assert_eq!(p.clips[0].frame_count, 3);
1209 assert_eq!(p.clips[1].token, "clip001-idle");
1210 let idle = &p.clips[1].frames[0].views[59];
1211 assert_eq!((idle.pitch, idle.yaw, idle.x, idle.y), (60, 330, 177, 6));
1212 // Cells hold exactly the loose map bytes.
1213 let loaded = set.load().unwrap();
1214 let run = crate::export_set::ExportRun::open(&tmp.0).unwrap();
1215 for (clip, frame) in [("clip000-walk", 2), ("clip001-idle", 0)] {
1216 for (pitch, yaw) in [(-60, 0), (30, 210), (60, 330)] {
1217 for kind in ATLAS_MAPS {
1218 assert_eq!(
1219 loaded.cell(clip, frame, pitch, yaw, kind).unwrap(),
1220 run.load_map(clip, frame, pitch, yaw, kind).unwrap()
1221 );
1222 }
1223 }
1224 }
1225 assert!(loaded
1226 .cell("clip000-walk", 3, 0, 0, MapKind::Diffuse)
1227 .is_err());
1228 assert!(loaded.cell("clip009-x", 0, 0, 0, MapKind::Diffuse).is_err());
1229 // The diffuse atlas carries the sRGB chunk like the loose diffuse maps.
1230 let bytes = fs::read(tmp.0.join("female-body-diffuse-atlas.png")).unwrap();
1231 let info = png::Decoder::new(std::io::Cursor::new(&bytes[..]))
1232 .read_info()
1233 .unwrap()
1234 .info()
1235 .srgb;
1236 assert!(info.is_some());
1237 }
1238
1239 #[test]
1240 fn reader_rejects_tampering_unknown_fields_versions_paths_and_coordinates() {
1241 let tmp = fixture();
1242 let ppda = tmp.0.join("female-body-atlas.ppda");
1243 let original = fs::read(&ppda).unwrap();
1244 let restore = || {
1245 fs::remove_file(&ppda).unwrap();
1246 fs::write(&ppda, &original).unwrap();
1247 };
1248 AtlasSet::open(&ppda).unwrap();
1249
1250 rewrite_ppda(&tmp.0, |d| d["format_version"] = 1.into());
1251 assert!(matches!(
1252 AtlasSet::open(&ppda),
1253 Err(ExportError::Identity(_))
1254 ));
1255 restore();
1256 rewrite_ppda(&tmp.0, |d| d["depth_far"] = 40.0.into());
1257 assert!(matches!(
1258 AtlasSet::open(&ppda),
1259 Err(ExportError::Identity(_))
1260 ));
1261 restore();
1262 rewrite_ppda(&tmp.0, |d| d["format_version"] = 4.into());
1263 assert!(matches!(
1264 AtlasSet::open(&ppda),
1265 Err(ExportError::Identity(_))
1266 ));
1267 restore();
1268 // Format 2 never carries depth_bits; format 3 always does.
1269 rewrite_ppda(&tmp.0, |d| d["format_version"] = 2.into());
1270 assert!(matches!(
1271 AtlasSet::open(&ppda),
1272 Err(ExportError::Identity(_))
1273 ));
1274 restore();
1275 rewrite_ppda(&tmp.0, |d| d["depth_encoding"] = "rgb_weird".into());
1276 assert!(matches!(
1277 AtlasSet::open(&ppda),
1278 Err(ExportError::Identity(_))
1279 ));
1280 restore();
1281 rewrite_ppda(&tmp.0, |d| d["extra"] = true.into());
1282 assert!(matches!(
1283 AtlasSet::open(&ppda),
1284 Err(ExportError::Corrupt(_))
1285 ));
1286 restore();
1287 rewrite_ppda(&tmp.0, |d| {
1288 d["clips"][0]["frames"][1]["views"][5]["x"] = 1.into()
1289 });
1290 assert!(matches!(
1291 AtlasSet::open(&ppda),
1292 Err(ExportError::Corrupt(_))
1293 ));
1294 restore();
1295 rewrite_ppda(&tmp.0, |d| {
1296 d["maps"]["diffuse"]["file"] = "../female-body-diffuse-atlas.png".into()
1297 });
1298 assert!(matches!(
1299 AtlasSet::open(&ppda),
1300 Err(ExportError::Traversal(_))
1301 ));
1302 restore();
1303 rewrite_ppda(&tmp.0, |d| d["pitches"] = serde_json::json!([-30, 0, 30]));
1304 assert!(AtlasSet::open(&ppda).is_err());
1305 restore();
1306 rewrite_ppda(&tmp.0, |d| d["maps"]["depth"]["height"] = 10.into());
1307 assert!(AtlasSet::open(&ppda).is_err());
1308 restore();
1309 rewrite_ppda(&tmp.0, |d| d["clips"][0]["frame_count"] = 2.into());
1310 assert!(AtlasSet::open(&ppda).is_err());
1311 restore();
1312 AtlasSet::open(&ppda).unwrap();
1313
1314 // Damaged atlas bytes fail the SHA-256 check.
1315 let normal = tmp.0.join("female-body-normal-atlas.png");
1316 let good = fs::read(&normal).unwrap();
1317 let mut bad = good.clone();
1318 let last = bad.len() - 20;
1319 bad[last] ^= 0xff;
1320 fs::remove_file(&normal).unwrap();
1321 fs::write(&normal, &bad).unwrap();
1322 assert!(matches!(
1323 AtlasSet::open(&ppda),
1324 Err(ExportError::Corrupt(_))
1325 ));
1326 fs::remove_file(&normal).unwrap();
1327 #[cfg(unix)]
1328 {
1329 // A symlinked atlas is refused even with matching bytes.
1330 let outside = Temp::new();
1331 fs::create_dir_all(&outside.0).unwrap();
1332 fs::write(outside.0.join("n.png"), &good).unwrap();
1333 std::os::unix::fs::symlink(outside.0.join("n.png"), &normal).unwrap();
1334 assert!(matches!(
1335 AtlasSet::open(&ppda),
1336 Err(ExportError::Traversal(_))
1337 ));
1338 fs::remove_file(&normal).unwrap();
1339 }
1340 fs::write(&normal, &good).unwrap();
1341 AtlasSet::open(&ppda).unwrap();
1342 // A renamed .ppda no longer matches its sex/settype.
1343 fs::copy(&ppda, tmp.0.join("male-body-atlas.ppda")).unwrap();
1344 assert!(AtlasSet::open(&tmp.0.join("male-body-atlas.ppda")).is_err());
1345 assert!(matches!(
1346 AtlasSet::open_run(&tmp.0),
1347 Err(ExportError::Identity(_))
1348 ));
1349 }
1350
1351 #[test]
1352 fn atlases_never_clobber_and_are_refused_when_too_large() {
1353 let tmp = fixture();
1354 let render: serde_json::Value =
1355 serde_json::from_slice(&fs::read(tmp.0.join("render.json")).unwrap()).unwrap();
1356 let complete: serde_json::Value =
1357 serde_json::from_slice(&fs::read(tmp.0.join("complete.json")).unwrap()).unwrap();
1358 let sums: BTreeMap<String, String> =
1359 serde_json::from_value(complete["png_sha256"].clone()).unwrap();
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 let plan = spec.plan();
1363 let cancel = AtomicBool::new(false);
1364 let job = AtlasJob {
1365 root: &tmp.0,
1366 render_doc: &render,
1367 plan: &plan,
1368 sex: "female",
1369 set: "body",
1370 cell_width: 3,
1371 cell_height: 2,
1372 png_sha256: &sums,
1373 attempt: 1,
1374 cancel: &cancel,
1375 };
1376 // Re-running over identical files keeps them (no clobber, same result).
1377 let before = fs::read(tmp.0.join("female-body-atlas.ppda")).unwrap();
1378 let out = write_atlases(&job, &mut |_| {}).unwrap();
1379 assert_eq!(
1380 fs::read(tmp.0.join("female-body-atlas.ppda")).unwrap(),
1381 before
1382 );
1383 assert_eq!(out.ppda_sha256, complete["atlas"]["ppda_sha256"]);
1384 // A loose map changed after verification is refused.
1385 let rel = "female/body/clip000-walk/f000001/p+30_y090_specular.png";
1386 let mut tampered = sums.clone();
1387 tampered.insert(rel.into(), digest::sha256_hex(b"x"));
1388 let err = write_atlases(
1389 &AtlasJob {
1390 png_sha256: &tampered,
1391 ..job
1392 },
1393 &mut |_| {},
1394 )
1395 .unwrap_err();
1396 assert!(err.contains("checksum"), "{err}");
1397 // Too many frames for 256-pixel cells: clear refusal, never split.
1398 let mut big = spec.clone();
1399 big.clips = vec![("long".into(), 65, 16.25)];
1400 assert!(AtlasLayout::for_plan(&big.plan(), 256, 256)
1401 .unwrap_err()
1402 .contains("16384"));
1403 }
1404}