//! Per-map atlases and the `.ppda` (PaPerDollAtlas) consumer manifest //! (Tim t955u/t957u). //! //! After every loose target PNG of a run is published and checksum-verified, //! production writes one atlas per map type (`---atlas.png`) //! and a strict JSON `.ppda` (`--atlas.ppda`) next to them in the //! run directory. Cells are exactly the target resolution, without padding or //! scaling. One row per (clip, frame) in source clip order and frame order; //! columns are pitch (−60→+60) × yaw (0→330). All four atlases share identical //! cell coordinates. Atlases are streamed band by band (one cell row at a time), //! published atomically without clobbering, and the `.ppda` is written last. //! //! [`AtlasSet`] is the validating consumer reader used by the 2D preview. use crate::{ digest::{self, is_sha256_hex, Sha256}, export_set::{self, ExportError, MapImage, MapKind, MAX_PNG_BYTES, MAX_TARGET}, preview::PRODUCTION_PITCHES, production::{self, Plan, FAR, NEAR}, project::CameraFraming, }; use serde::{Deserialize, Serialize}; use std::{ collections::BTreeMap, fs, io::Write, path::{Path, PathBuf}, sync::atomic::{AtomicBool, Ordering}, }; /// `.ppda` schema version written and accepted. pub const PPDA_FORMAT_VERSION: u32 = 1; /// Largest atlas side in pixels (common GPU texture limit). Larger exports are /// refused up front with a clear message; atlases are never split. pub const MAX_ATLAS_DIM: u32 = 16384; /// Bounded `.ppda` size on read. pub const MAX_PPDA_BYTES: u64 = 64 * 1024 * 1024; /// Bounded encoded atlas PNG size on read. pub const MAX_ATLAS_PNG_BYTES: u64 = 4 * 1024 * 1024 * 1024; /// Decoded-memory budget for one loaded atlas set (four maps) in the preview. pub const MAX_DECODED_SET_BYTES: u64 = 2 * 1024 * 1024 * 1024; /// Map order inside the `.ppda` and atlas production. pub const ATLAS_MAPS: [MapKind; 4] = [ MapKind::Diffuse, MapKind::Normal, MapKind::Depth, MapKind::Specular, ]; const MAX_CLIPS: usize = 4096; const MAX_CLIP_NAME: usize = 256; pub fn atlas_png_name(sex: &str, set: &str, kind: MapKind) -> String { format!("{sex}-{set}-{}-atlas.png", kind.token()) } pub fn ppda_name(sex: &str, set: &str) -> String { format!("{sex}-{set}-atlas.ppda") } /// Deterministic grid shared by all four atlases of one export. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct AtlasLayout { pub cell_width: u32, pub cell_height: u32, pub columns: u32, pub rows: u32, pub width: u32, pub height: u32, } impl AtlasLayout { pub fn new(cell_width: u32, cell_height: u32, rows: u32) -> Result { if cell_width == 0 || cell_height == 0 || cell_width > MAX_TARGET || cell_height > MAX_TARGET { return Err("atlascel moet 1–256 pixels per as zijn".into()); } if rows == 0 { return Err("atlas zonder rijen".into()); } let columns = (PRODUCTION_PITCHES.len() * production::production_yaws().len()) as u32; let width = u64::from(columns) * u64::from(cell_width); let height = u64::from(rows) * u64::from(cell_height); if width > u64::from(MAX_ATLAS_DIM) || height > u64::from(MAX_ATLAS_DIM) { return Err(format!( "atlas zou {width}×{height} pixels worden ({columns} hoeken × {rows} frames); \ maximaal {MAX_ATLAS_DIM}×{MAX_ATLAS_DIM}. Verlaag fps, targetresolutie of \ animatieduur — de atlas wordt niet gesplitst" )); } Ok(Self { cell_width, cell_height, columns, rows, width: width as u32, height: height as u32, }) } pub fn for_plan(plan: &Plan, cell_width: u32, cell_height: u32) -> Result { let rows = plan .clips .iter() .try_fold(0u32, |n, c| n.checked_add(c.frames)) .ok_or("te veel frames voor de atlas")?; Self::new(cell_width, cell_height, rows) } /// Column of (pitch, yaw): pitch-major, then yaw. pub fn column(pitch: i32, yaw: u32) -> Option { let p = PRODUCTION_PITCHES.iter().position(|x| *x == pitch)?; let y = production::production_yaws() .iter() .position(|x| *x == yaw)?; Some((p * production::production_yaws().len() + y) as u32) } pub fn cell(&self, row: u32, column: u32) -> PpdaView { let yaws = production::production_yaws(); let pitch = PRODUCTION_PITCHES[column as usize / yaws.len()]; let yaw = yaws[column as usize % yaws.len()]; PpdaView { pitch, yaw, x: column * self.cell_width, y: row * self.cell_height, w: self.cell_width, h: self.cell_height, } } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Ppda { pub format_version: u32, pub sex: String, pub settype: String, pub project: PpdaProject, /// Export/camera contract so body and equipment atlases can be checked for /// compatibility without the loose run manifests. pub identity: PpdaIdentity, pub fps: u32, pub cell_width: u32, pub cell_height: u32, pub pitches: Vec, pub yaws: Vec, pub maps: BTreeMap, pub clips: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct PpdaProject { pub id: String, pub name: String, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct PpdaIdentity { pub body_model_sha256: String, pub equipment_preset_sha256: Option, pub framing: CameraFraming, pub pivot_source: String, pub near: f32, pub far: f32, pub camera_distance: f32, pub normal_space: String, pub depth: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct PpdaMap { pub file: String, pub sha256: String, pub width: u32, pub height: u32, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct PpdaClip { pub source_index: u32, pub name: String, pub token: String, pub frame_count: u32, pub duration: f32, pub frames: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct PpdaFrame { pub index: u32, pub views: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct PpdaView { pub pitch: i32, pub yaw: u32, pub x: u32, pub y: u32, pub w: u32, pub h: u32, } /// What production records in `complete.json` after writing the atlases. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AtlasOutput { pub ppda: String, pub ppda_sha256: String, /// map token → (file name, sha256) pub maps: BTreeMap, } impl AtlasOutput { pub fn to_json(&self) -> serde_json::Value { let maps: serde_json::Map = self .maps .iter() .map(|(k, (file, sha))| (k.clone(), serde_json::json!({"file": file, "sha256": sha}))) .collect(); serde_json::json!({ "ppda": self.ppda, "ppda_sha256": self.ppda_sha256, "maps": maps, }) } } struct HashingWriter { inner: W, hasher: Sha256, } impl Write for HashingWriter { fn write(&mut self, buf: &[u8]) -> std::io::Result { let n = self.inner.write(buf)?; self.hasher.update(&buf[..n]); Ok(n) } fn flush(&mut self) -> std::io::Result<()> { self.inner.flush() } } /// Inputs for writing the atlases of one completed run. pub struct AtlasJob<'a> { pub root: &'a Path, pub render_doc: &'a serde_json::Value, pub plan: &'a Plan, pub sex: &'a str, pub set: &'a str, pub cell_width: u32, pub cell_height: u32, /// Relative loose PNG path → verified SHA-256 (every planned file). pub png_sha256: &'a BTreeMap, /// Resume attempt number for quarantine names of stale atlas files. pub attempt: u32, pub cancel: &'a AtomicBool, } fn rows_of(plan: &Plan) -> Vec<(usize, u32)> { plan.clips .iter() .enumerate() .flat_map(|(c, clip)| (0..clip.frames).map(move |f| (c, f))) .collect() } /// Load one verified loose cell as raw 4-byte-per-pixel rows (RGBA8 or /// big-endian gray16+alpha16, matching the atlas PNG sample layout). fn load_cell( job: &AtlasJob<'_>, clip: &str, frame: u32, pitch: i32, yaw: u32, kind: MapKind, ) -> Result, String> { let rel = production::output_path(job.sex, job.set, clip, frame, pitch, yaw, kind.token())?; let rel = rel.to_str().ok_or("ongeldig PNG-pad")?.replace('\\', "/"); let expected = job .png_sha256 .get(&rel) .ok_or_else(|| format!("atlas: {rel} ontbreekt in de checksums"))?; let path = job.root.join(&rel); export_set::verify_regular_file(&path, job.root).map_err(|e| e.to_string())?; let bytes = export_set::read_bounded_file(&path, MAX_PNG_BYTES).map_err(|e| e.to_string())?; if digest::sha256_hex(&bytes) != *expected { return Err(format!("atlas: checksum van {rel} klopt niet meer")); } match export_set::decode_map(&bytes, kind, job.cell_width, job.cell_height) .map_err(|e| format!("atlas: {rel}: {e}"))? { MapImage::Rgba8 { rgba, .. } => Ok(rgba), MapImage::Depth16 { samples, .. } => { Ok(samples.iter().flat_map(|s| s.to_be_bytes()).collect()) } } } /// Stream-encode one atlas PNG into `out`. fn encode_atlas( job: &AtlasJob<'_>, layout: &AtlasLayout, kind: MapKind, out: &mut dyn Write, progress: &mut dyn FnMut(String), ) -> Result<(), String> { let mut encoder = png::Encoder::new(out, layout.width, layout.height); if kind == MapKind::Depth { encoder.set_color(png::ColorType::GrayscaleAlpha); encoder.set_depth(png::BitDepth::Sixteen); } else { encoder.set_color(png::ColorType::Rgba); encoder.set_depth(png::BitDepth::Eight); if kind == MapKind::Diffuse { encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual); } } let mut writer = encoder.write_header().map_err(|e| e.to_string())?; let row_bytes = layout.width as usize * 4; let cell_row_bytes = layout.cell_width as usize * 4; let mut stream = writer .stream_writer_with_size(row_bytes.max(4096)) .map_err(|e| e.to_string())?; let rows = rows_of(job.plan); let total = rows.len(); let mut band = vec![0u8; row_bytes * layout.cell_height as usize]; for (row, (clip, frame)) in rows.into_iter().enumerate() { if job.cancel.load(Ordering::Relaxed) { return Err("geannuleerd".into()); } if row % 8 == 0 { progress(format!( "atlas {} · rij {}/{}", kind.token(), row + 1, total )); } let token = &job.plan.clips[clip].token; for column in 0..layout.columns { let view = layout.cell(0, column); let cell = load_cell(job, token, frame, view.pitch, view.yaw, kind)?; for y in 0..layout.cell_height as usize { let dst = y * row_bytes + view.x as usize * 4; band[dst..dst + cell_row_bytes] .copy_from_slice(&cell[y * cell_row_bytes..(y + 1) * cell_row_bytes]); } } stream.write_all(&band).map_err(|e| e.to_string())?; } stream.finish().map_err(|e| e.to_string())?; writer.finish().map_err(|e| e.to_string())?; Ok(()) } /// Publish `name` in the run root without clobbering. When a file already /// exists (crash after publication during an earlier attempt) it is kept when /// byte-identical to the regenerated output, otherwise moved aside. fn publish_streamed( root: &Path, name: &str, attempt: u32, mut encode: impl FnMut(&mut dyn Write) -> Result<(), String>, ) -> Result { let path = root.join(name); if fs::symlink_metadata(&path).is_ok() { let mut sink = HashingWriter { inner: std::io::sink(), hasher: Sha256::new(), }; encode(&mut sink)?; let fresh = digest::hex(&sink.hasher.finalize()); let regular = fs::symlink_metadata(&path) .map(|m| m.file_type().is_file()) .unwrap_or(false); if regular && digest::sha256_file(&path, MAX_ATLAS_PNG_BYTES) .ok() .as_deref() == Some(fresh.as_str()) { return Ok(fresh); } crate::resume::quarantine(root, name, attempt)?; } let mut digest_out = None; production::atomic_file(root, Path::new(name), |file| { let mut writer = HashingWriter { inner: std::io::BufWriter::new(file), hasher: Sha256::new(), }; encode(&mut writer)?; writer.flush().map_err(|e| e.to_string())?; digest_out = Some(digest::hex(&writer.hasher.finalize())); Ok(()) })?; digest_out.ok_or_else(|| "atlas-digest ontbreekt".into()) } /// Build the `.ppda` document for a plan/contract (pure; no disk access). pub fn build_ppda( render_doc: &serde_json::Value, plan: &Plan, layout: &AtlasLayout, sex: &str, set: &str, map_digests: &BTreeMap, ) -> Result { let get = |p: &str| render_doc.pointer(p).ok_or(format!("render.json mist {p}")); let text = |p: &str| -> Result { get(p)? .as_str() .map(str::to_string) .ok_or(format!("{p} is geen tekst")) }; let framing: CameraFraming = serde_json::from_value(get("/framing")?.clone()).map_err(|e| e.to_string())?; let fps = get("/fps")?.as_u64().ok_or("fps")? as u32; let mut maps = BTreeMap::new(); for kind in ATLAS_MAPS { maps.insert( kind.token().to_string(), PpdaMap { file: atlas_png_name(sex, set, kind), sha256: map_digests .get(kind.token()) .cloned() .ok_or("atlas-digest ontbreekt")?, width: layout.width, height: layout.height, }, ); } let mut row = 0u32; let mut clips = Vec::with_capacity(plan.clips.len()); for (i, clip) in plan.clips.iter().enumerate() { let duration = render_doc .pointer(&format!("/clips/{i}/duration")) .and_then(|d| d.as_f64()) .unwrap_or(0.) as f32; let mut frames = Vec::with_capacity(clip.frames as usize); for index in 0..clip.frames { frames.push(PpdaFrame { index, views: (0..layout.columns).map(|c| layout.cell(row, c)).collect(), }); row += 1; } clips.push(PpdaClip { source_index: i as u32, name: clip.source_name.clone(), token: clip.token.clone(), frame_count: clip.frames, duration, frames, }); } Ok(Ppda { format_version: PPDA_FORMAT_VERSION, sex: sex.into(), settype: set.into(), project: PpdaProject { id: text("/project/id")?, name: text("/project/name")?, }, identity: PpdaIdentity { body_model_sha256: text("/body_model/sha256")?, equipment_preset_sha256: render_doc .pointer("/equipment_preset/sha256") .and_then(|v| v.as_str()) .map(str::to_string), framing, pivot_source: text("/pivot_source/kind")?, near: NEAR, far: FAR, camera_distance: 10., normal_space: text("/normal_space")?, depth: text("/depth")?, }, fps, cell_width: layout.cell_width, cell_height: layout.cell_height, pitches: PRODUCTION_PITCHES.to_vec(), yaws: production::production_yaws().to_vec(), maps, clips, }) } /// Write the four atlases and the `.ppda` after all loose maps are verified. pub fn write_atlases( job: &AtlasJob<'_>, progress: &mut dyn FnMut(String), ) -> Result { let layout = AtlasLayout::for_plan(job.plan, job.cell_width, job.cell_height)?; if job.png_sha256.len() as u64 != u64::from(job.plan.files) { return Err("atlas: niet alle losse maps zijn geverifieerd".into()); } let mut maps = BTreeMap::new(); let mut digests = BTreeMap::new(); for kind in ATLAS_MAPS { let name = atlas_png_name(job.sex, job.set, kind); let digest = publish_streamed(job.root, &name, job.attempt, |out| { encode_atlas(job, &layout, kind, out, progress) })?; digests.insert(kind.token().to_string(), digest.clone()); maps.insert(kind.token().to_string(), (name, digest)); } let ppda = build_ppda( job.render_doc, job.plan, &layout, job.sex, job.set, &digests, )?; let mut bytes = serde_json::to_vec_pretty(&ppda).map_err(|e| e.to_string())?; bytes.push(b'\n'); let name = ppda_name(job.sex, job.set); progress(format!("atlas · {name}")); let ppda_sha256 = publish_streamed(job.root, &name, job.attempt, |out| { out.write_all(&bytes).map_err(|e| e.to_string()) })?; Ok(AtlasOutput { ppda: name, ppda_sha256, maps, }) } // ---------------------------------------------------------------- reader -- fn ident(msg: impl Into) -> ExportError { ExportError::Identity(msg.into()) } fn validate_ppda(p: &Ppda, file_name: &str) -> Result { if p.format_version != PPDA_FORMAT_VERSION { return Err(ident(format!( ".ppda format_version {} wordt niet ondersteund (verwacht {PPDA_FORMAT_VERSION})", p.format_version ))); } if !["male", "female"].contains(&p.sex.as_str()) { return Err(ident(".ppda: ongeldig geslacht")); } if p.settype != "body" && !crate::equipment::valid_name(&p.settype) { return Err(ident(".ppda: ongeldig settype")); } if file_name != ppda_name(&p.sex, &p.settype) { return Err(ident(format!( ".ppda-bestandsnaam '{file_name}' past niet bij {}/{}", p.sex, p.settype ))); } if p.project.id.is_empty() || p.project.id.len() > 128 || p.project.name.len() > 256 { return Err(ident(".ppda: project id/naam ongeldig")); } let id = &p.identity; if !is_sha256_hex(&id.body_model_sha256) { return Err(ExportError::Corrupt( ".ppda: body_model_sha256 ongeldig".into(), )); } match (&id.equipment_preset_sha256, p.settype == "body") { (None, true) => {} (Some(h), false) if is_sha256_hex(h) => {} _ => { return Err(ident( ".ppda: equipment_preset_sha256 past niet bij settype", )) } } id.framing.validate().map_err(ExportError::Identity)?; if !["manual", "neck", "fallback"].contains(&id.pivot_source.as_str()) { return Err(ident(".ppda: onbekende pivot_source")); } if id.near != NEAR || id.far != FAR || id.camera_distance != 10. { return Err(ident(".ppda: camera-contract wijkt af")); } if p.fps == 0 || p.fps > 240 { return Err(ident(".ppda: ongeldige fps")); } if p.pitches != PRODUCTION_PITCHES || p.yaws != production::production_yaws() { return Err(ident(".ppda: pitches/yaws wijken af van −60…+60 × 0…330")); } if p.clips.is_empty() || p.clips.len() > MAX_CLIPS { return Err(ExportError::Resource( ".ppda: clip-inventaris ongeldig".into(), )); } let rows = p .clips .iter() .try_fold(0u32, |n, c| n.checked_add(c.frame_count)) .ok_or_else(|| ExportError::Resource(".ppda: te veel frames".into()))?; let layout = AtlasLayout::new(p.cell_width, p.cell_height, rows).map_err(ExportError::Resource)?; let mut tokens = std::collections::BTreeSet::new(); let mut row = 0u32; for (i, clip) in p.clips.iter().enumerate() { if clip.source_index as usize != i { return Err(ident(".ppda: clips niet in bronvolgorde")); } if clip.token.is_empty() || clip.token.len() > 64 || !crate::equipment::valid_name(&clip.token) || !tokens.insert(clip.token.clone()) { return Err(ident(format!( ".ppda: ongeldige clip-token '{}'", clip.token ))); } if clip.name.len() > MAX_CLIP_NAME || !clip.duration.is_finite() || clip.duration < 0. { return Err(ident(".ppda: clipnaam/duur ongeldig")); } if clip.frame_count == 0 || clip.frames.len() != clip.frame_count as usize { return Err(ident(".ppda: frame_count past niet bij frames")); } for (k, frame) in clip.frames.iter().enumerate() { if frame.index as usize != k || frame.views.len() != layout.columns as usize { return Err(ident(".ppda: frame-index of aantal hoeken ongeldig")); } for (c, view) in frame.views.iter().enumerate() { let expected = layout.cell(row, c as u32); let inside = u64::from(view.x) + u64::from(view.w) <= u64::from(layout.width) && u64::from(view.y) + u64::from(view.h) <= u64::from(layout.height); if *view != expected || !inside { return Err(ExportError::Corrupt(format!( ".ppda: celcoördinaten van {} f{k} p{} y{} kloppen niet", clip.token, view.pitch, view.yaw ))); } } row += 1; } } if p.maps.len() != ATLAS_MAPS.len() { return Err(ident( ".ppda: maps moet precies diffuse/normal/depth/specular bevatten", )); } for kind in ATLAS_MAPS { let m = p .maps .get(kind.token()) .ok_or_else(|| ident(format!(".ppda mist map {}", kind.token())))?; if m.file != atlas_png_name(&p.sex, &p.settype, kind) { return Err(ExportError::Traversal(format!( ".ppda: atlasbestand '{}' is niet de verwachte naam in de exportmap", m.file ))); } if !is_sha256_hex(&m.sha256) { return Err(ExportError::Corrupt(".ppda: atlas sha256 ongeldig".into())); } if m.width != layout.width || m.height != layout.height { return Err(ExportError::Corrupt(format!( ".ppda: {} atlasafmetingen {}x{} wijken af van raster {}x{}", kind.token(), m.width, m.height, layout.width, layout.height ))); } } Ok(layout) } /// A validated `.ppda` whose atlases exist, match their SHA-256 and headers. #[derive(Debug, Clone)] pub struct AtlasSet { dir: PathBuf, pub ppda: Ppda, pub layout: AtlasLayout, } impl AtlasSet { /// Export folder containing the `.ppda` and its atlases. pub fn dir(&self) -> &Path { &self.dir } fn read_ppda(path: &Path) -> Result<(PathBuf, Ppda, AtlasLayout), ExportError> { let dir = path .parent() .ok_or_else(|| ExportError::Io(".ppda zonder map".into()))?; let dir = dir .canonicalize() .map_err(|e| ExportError::Io(e.to_string()))?; let file_name = path .file_name() .and_then(|n| n.to_str()) .ok_or_else(|| ExportError::Io("ongeldige .ppda-naam".into()))? .to_string(); let path = dir.join(&file_name); export_set::verify_regular_file(&path, &dir)?; let bytes = export_set::read_bounded_file(&path, MAX_PPDA_BYTES)?; let text = std::str::from_utf8(&bytes) .map_err(|_| ExportError::Corrupt(".ppda is geen UTF-8".into()))?; let ppda: Ppda = serde_json::from_str(text).map_err(|e| ExportError::Corrupt(format!(".ppda: {e}")))?; let layout = validate_ppda(&ppda, &file_name)?; Ok((dir, ppda, layout)) } fn read_map(&self, kind: MapKind) -> Result, ExportError> { let m = &self.ppda.maps[kind.token()]; let path = self.dir.join(&m.file); export_set::verify_regular_file(&path, &self.dir)?; let bytes = export_set::read_bounded_file(&path, MAX_ATLAS_PNG_BYTES)?; if digest::sha256_hex(&bytes) != m.sha256 { return Err(ExportError::Corrupt(format!( "checksum mismatch voor '{}'", m.file ))); } Ok(bytes) } /// Validate the manifest, then each atlas: regular file inside the folder, /// SHA-256, PNG header dimensions and sample format. pub fn open(ppda_path: &Path) -> Result { let (dir, ppda, layout) = Self::read_ppda(ppda_path)?; let set = Self { dir, ppda, layout }; for kind in ATLAS_MAPS { let bytes = set.read_map(kind)?; let decoder = png::Decoder::new(std::io::Cursor::new(&bytes[..])); let reader = decoder .read_info() .map_err(|e| ExportError::Corrupt(format!("atlas PNG header: {e}")))?; let info = reader.info(); let (color, depth) = if kind == MapKind::Depth { (png::ColorType::GrayscaleAlpha, png::BitDepth::Sixteen) } else { (png::ColorType::Rgba, png::BitDepth::Eight) }; if info.width != layout.width || info.height != layout.height || info.color_type != color || info.bit_depth != depth { return Err(ExportError::Corrupt(format!( "{} atlas: afmetingen of kleurtype wijken af van .ppda", kind.token() ))); } } Ok(set) } /// Open the single `.ppda` inside a run directory. pub fn open_run(run_dir: &Path) -> Result { let mut found = Vec::new(); for entry in fs::read_dir(run_dir).map_err(|e| ExportError::Io(e.to_string()))? { let entry = entry.map_err(|e| ExportError::Io(e.to_string()))?; let name = entry.file_name(); if name.to_str().is_some_and(|n| n.ends_with("-atlas.ppda")) { found.push(entry.path()); } } match found.as_slice() { [one] => Self::open(one), [] => Err(ExportError::Incomplete( "geen .ppda in deze exportmap".into(), )), _ => Err(ExportError::Identity( "meer dan één .ppda in deze exportmap".into(), )), } } /// Decode all four atlases (re-verifying SHA-256 on the bytes decoded) with /// the full map pixel contract. pub fn load(&self) -> Result { let decoded = u64::from(self.layout.width) * u64::from(self.layout.height) * 4 * 4; if decoded > MAX_DECODED_SET_BYTES { return Err(ExportError::Resource(format!( "atlasset te groot om te laden ({decoded} bytes gedecodeerd)" ))); } let mut maps = BTreeMap::new(); for kind in ATLAS_MAPS { let bytes = self.read_map(kind)?; let image = export_set::decode_map_sized( &bytes, kind, self.layout.width, self.layout.height, MAX_ATLAS_DIM, )?; maps.insert(kind.token(), image); } Ok(LoadedAtlas { set: self.clone(), maps, }) } } /// Decoded atlases for one `.ppda`; crops cells for the compositor. #[derive(Debug, Clone)] pub struct LoadedAtlas { pub set: AtlasSet, maps: BTreeMap<&'static str, MapImage>, } impl LoadedAtlas { pub fn ppda(&self) -> &Ppda { &self.set.ppda } /// Coordinates of one view from the `.ppda`. pub fn view( &self, clip_token: &str, frame: u32, pitch: i32, yaw: u32, ) -> Result { let clip = self .set .ppda .clips .iter() .find(|c| c.token == clip_token) .ok_or_else(|| ExportError::Incomplete(format!("clip '{clip_token}' niet in .ppda")))?; let frame = clip.frames.get(frame as usize).ok_or_else(|| { ExportError::Incomplete(format!("frame {frame} niet in clip '{clip_token}'")) })?; frame .views .iter() .find(|v| v.pitch == pitch && v.yaw == yaw) .copied() .ok_or_else(|| ExportError::Incomplete(format!("hoek p{pitch} y{yaw} niet in .ppda"))) } /// Crop one map cell exactly as stored. pub fn cell( &self, clip_token: &str, frame: u32, pitch: i32, yaw: u32, kind: MapKind, ) -> Result { let v = self.view(clip_token, frame, pitch, yaw)?; let atlas_w = self.set.layout.width as usize; let (x, y, w, h) = (v.x as usize, v.y as usize, v.w as usize, v.h as usize); match &self.maps[kind.token()] { MapImage::Rgba8 { rgba, .. } => { let mut out = Vec::with_capacity(w * h * 4); for row in y..y + h { let start = (row * atlas_w + x) * 4; out.extend_from_slice(&rgba[start..start + w * 4]); } Ok(MapImage::Rgba8 { width: v.w, height: v.h, rgba: out, }) } MapImage::Depth16 { samples, .. } => { let mut out = Vec::with_capacity(w * h * 2); for row in y..y + h { let start = (row * atlas_w + x) * 2; out.extend_from_slice(&samples[start..start + w * 2]); } Ok(MapImage::Depth16 { width: v.w, height: v.h, samples: out, }) } } } }