BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / atlas.rs

atlas.rs

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