BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / atlas.rs

atlas.rs

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