BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / atlas.rs

atlas.rs

atlas.rs 30.6 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}