BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / atlas.rs

atlas.rs

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