//! 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}, 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. /// 2: top-level depth_near/depth_far/depth_encoding (per-sex narrow range). /// 3: depth_bits + depth_encoding token (gray/rgb_bands/rgb_packed); v2 is /// still read (16-bit gray). pub const PPDA_FORMAT_VERSION: u32 = 3; /// 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, /// Camera distance (m) of 16-bit depth sample 0 / 65535; see depth_encoding. pub depth_near: f32, pub depth_far: f32, pub depth_encoding: String, /// 8 or 16 (format 3); absent in format 2 (= 16-bit gray). #[serde(default, skip_serializing_if = "Option::is_none")] pub depth_bits: Option, 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")); } if kind == MapKind::Depth { // Copied in its own encoding, byte-exact (lossless for every format). let fmt = crate::camera::DepthFormat::from_doc(job.render_doc)?; return export_set::decode_depth_native( &bytes, job.cell_width, job.cell_height, export_set::MAX_TARGET, fmt, ) .map_err(|e| format!("atlas: {rel}: {e}")); } match export_set::decode_map( &bytes, kind, job.cell_width, job.cell_height, crate::camera::DepthFormat::GRAY16, ) .map_err(|e| format!("atlas: {rel}: {e}"))? { MapImage::Rgba8 { rgba, .. } => Ok(rgba), MapImage::Depth16 { .. } => Err("atlas: onverwachte depth".into()), } } /// 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); let mut bpp = 4; if kind == MapKind::Depth { let fmt = crate::camera::DepthFormat::from_doc(job.render_doc)?; encoder.set_color(fmt.color_type()); encoder.set_depth(fmt.bit_depth()); bpp = fmt.bytes_per_pixel(); } 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 * bpp; let cell_row_bytes = layout.cell_width as usize * bpp; 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 * bpp; 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 (depth_near, depth_far) = crate::camera::depth_range(framing); 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: depth_near, far: depth_far, camera_distance: crate::camera::CAMERA_DISTANCE, normal_space: text("/normal_space")?, depth: text("/depth")?, }, fps, depth_near, depth_far, depth_encoding: crate::camera::DepthFormat::from_doc(render_doc)? .encoding .token() .into(), depth_bits: Some(u64::from( crate::camera::DepthFormat::from_doc(render_doc)?.bits, )), 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()) } impl Ppda { /// Depth PNG format of the depth atlas. pub fn depth_format(&self) -> Result { if (self.format_version >= 3) != self.depth_bits.is_some() { return Err(ident(".ppda: depth_bits past niet bij format_version")); } crate::camera::DepthFormat::from_manifest(self.depth_bits, &self.depth_encoding) .map_err(|e| ident(format!(".ppda: {e}"))) } } fn validate_ppda(p: &Ppda, file_name: &str) -> Result { if !(2..=PPDA_FORMAT_VERSION).contains(&p.format_version) { return Err(ident(format!( ".ppda format_version {} wordt niet ondersteund (verwacht {PPDA_FORMAT_VERSION}; versie 1 = oud globaal dieptebereik)", 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")); } let (near, far) = crate::camera::depth_range(id.framing); if id.near != near || id.far != far || p.depth_near != near || p.depth_far != far || id.camera_distance != crate::camera::CAMERA_DISTANCE { return Err(ident(".ppda: camera-/dieptecontract wijkt af")); } p.depth_format()?; 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 { let fmt = set.ppda.depth_format()?; (fmt.color_type(), fmt.bit_depth()) } 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(); let mut depth_display = Vec::new(); for kind in ATLAS_MAPS { let bytes = self.read_map(kind)?; if kind == MapKind::Depth { let fmt = self.ppda.depth_format()?; let native = export_set::decode_depth_native( &bytes, self.layout.width, self.layout.height, MAX_ATLAS_DIM, fmt, )?; depth_display = export_set::depth_display_rgba(&native, fmt); } let image = export_set::decode_map_sized( &bytes, kind, self.layout.width, self.layout.height, MAX_ATLAS_DIM, self.ppda.depth_format()?, )?; maps.insert(kind.token(), image); } Ok(LoadedAtlas { set: self.clone(), maps, depth_display, }) } } /// Decoded atlases for one `.ppda`; crops cells for the compositor. #[derive(Debug, Clone)] pub struct LoadedAtlas { pub set: AtlasSet, maps: BTreeMap<&'static str, MapImage>, /// Depth atlas as stored, RGBA8 display form (no decoding; Tim t992u). depth_display: Vec, } 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 the depth cell as stored, in RGBA8 display form (rgb_bands / /// rgb_packed keep their raw RGB; 16-bit at display precision). pub fn depth_display_cell( &self, clip_token: &str, frame: u32, pitch: i32, yaw: u32, ) -> Result, ExportError> { 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); 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(&self.depth_display[start..start + w * 4]); } Ok(out) } /// 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, }) } } } } /// Open a completed run through both readers and compare every atlas cell of /// every map with its loose PNG. Returns a JSON summary (used by GPU proofs). pub fn cross_check_run(run_dir: &Path) -> Result { let run = export_set::ExportRun::open(run_dir).map_err(|e| e.to_string())?; let set = AtlasSet::open_run(run_dir).map_err(|e| e.to_string())?; let loaded = set.load().map_err(|e| e.to_string())?; let mut cells = 0u64; for clip in &set.ppda.clips { for frame in &clip.frames { for view in &frame.views { for kind in ATLAS_MAPS { let a = loaded .cell(&clip.token, frame.index, view.pitch, view.yaw, kind) .map_err(|e| e.to_string())?; let b = run .load_map(&clip.token, frame.index, view.pitch, view.yaw, kind) .map_err(|e| e.to_string())?; if a != b { return Err(format!( "atlascel {} f{} p{} y{} {} wijkt af van losse PNG", clip.token, frame.index, view.pitch, view.yaw, kind.token() )); } cells += 1; } } } } if cells != u64::from(run.complete.png_files) { return Err(format!( "{cells} atlascellen ≠ {} losse PNG's", run.complete.png_files )); } Ok(serde_json::json!({ "ppda": ppda_name(&set.ppda.sex, &set.ppda.settype), "atlas_size": [set.layout.width, set.layout.height], "cell": [set.layout.cell_width, set.layout.cell_height], "rows": set.layout.rows, "columns": set.layout.columns, "fps": set.ppda.fps, "cells_equal_to_loose_pngs": cells, "maps": set.ppda.maps.iter().map(|(k, m)| (k.clone(), serde_json::json!({"file": m.file, "sha256": m.sha256}))).collect::>(), })) } #[cfg(test)] mod tests { use super::*; use crate::export_fixture::{Pixel, SyntheticRun}; use std::sync::atomic::AtomicU64; struct Temp(PathBuf); impl Temp { fn new() -> Self { static N: AtomicU64 = AtomicU64::new(0); let path = std::env::temp_dir().join(format!( "paperdoll-atlas-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed) )); let _ = fs::remove_dir_all(&path); Self(path) } } impl Drop for Temp { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } fn fixture() -> Temp { let tmp = Temp::new(); let mut spec = SyntheticRun::body("atlas-test", "female", b"body"); spec.clips = vec![("walk".into(), 3, 0.75), ("idle".into(), 1, 0.0)]; spec.target = [3, 2]; spec.write(&tmp.0, |c, f, p, y| Pixel { diffuse: [c as u8 * 100 + f as u8, (p + 60) as u8, (y / 30) as u8, 255], normal: [128, 128, 255, 255], specular: [9, 9, 9, 255], depth: 1000 + f as u16, }) .unwrap(); tmp } fn rewrite_ppda(dir: &Path, edit: impl FnOnce(&mut serde_json::Value)) { let path = dir.join("female-body-atlas.ppda"); let mut doc: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); edit(&mut doc); fs::remove_file(&path).unwrap(); fs::write(&path, serde_json::to_vec_pretty(&doc).unwrap()).unwrap(); } #[test] fn all_depth_formats_round_trip_through_loose_pngs_atlas_and_reader() { use crate::camera::{DepthEncoding, DepthFormat}; for bits in [8u8, 16] { for encoding in DepthEncoding::ALL { let fmt = DepthFormat::new(bits, encoding).unwrap(); let tmp = Temp::new(); let mut spec = SyntheticRun::body("atlas-test", "female", b"body"); spec.clips = vec![("walk".into(), 2, 0.5)]; spec.target = [2, 2]; spec.depth_format = fmt; spec.write(&tmp.0, |_, f, p, y| Pixel { diffuse: [1, 2, 3, if y == 30 { 0 } else { 255 }], normal: if y == 30 { [0, 0, 0, 0] } else { [128, 128, 255, 255] }, specular: if y == 30 { [0, 0, 0, 0] } else { [0, 0, 0, 255] }, depth: 9000 + f as u16 * 20000 + (p + 60) as u16 * 50 + y as u16, }) .unwrap(); let set = AtlasSet::open_run(&tmp.0).unwrap(); assert_eq!(set.ppda.depth_encoding, encoding.token()); assert_eq!(set.ppda.depth_bits, Some(u64::from(bits))); let loaded = set.load().unwrap(); let MapImage::Depth16 { samples, width, .. } = &loaded.maps["depth"] else { panic!("depth") }; // Precision: one level of the format, in 16-bit canonical units. let tol = (65535. / fmt.levels() as f64).ceil() as i64; for f in 0..2u32 { for col in 0..set.layout.columns { let cell = set.layout.cell(0, col); let view = (cell.pitch, cell.yaw); let x = col * 2; let y = f * 2; let i = (y * width + x) as usize * 2; if view.1 == 30 { assert_eq!(&samples[i..i + 2], &[0, 0]); continue; } let want = 9000 + f as i64 * 20000 + (view.0 + 60) as i64 * 50 + view.1 as i64; let got = samples[i] as i64; assert!((got - want).abs() <= tol, "{fmt:?}: {got} vs {want}"); assert_eq!(samples[i + 1], 65535); } } crate::export_set::ExportRun::open(&tmp.0).unwrap(); } } } #[test] fn legacy_ppda_v2_gray16_is_still_read() { let tmp = fixture(); rewrite_ppda(&tmp.0, |d| { d["format_version"] = 2.into(); d.as_object_mut().unwrap().remove("depth_bits"); d["depth_encoding"] = crate::camera::DEPTH_ENCODING.into(); }); let set = AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).unwrap(); assert_eq!( set.ppda.depth_format().unwrap(), crate::camera::DepthFormat::GRAY16 ); set.load().unwrap(); // A v3 file must say which format it uses. rewrite_ppda(&tmp.0, |d| d["format_version"] = 3.into()); assert!(AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).is_err()); } #[test] fn layout_is_pitch_major_columns_and_frame_rows() { let l = AtlasLayout::new(32, 48, 5).unwrap(); assert_eq!((l.columns, l.width, l.height), (60, 60 * 32, 5 * 48)); assert_eq!(AtlasLayout::column(-60, 0), Some(0)); assert_eq!(AtlasLayout::column(-60, 330), Some(11)); assert_eq!(AtlasLayout::column(-30, 0), Some(12)); assert_eq!(AtlasLayout::column(60, 330), Some(59)); assert_eq!(AtlasLayout::column(45, 0), None); let v = l.cell(2, 13); assert_eq!( (v.pitch, v.yaw, v.x, v.y, v.w, v.h), (-30, 30, 13 * 32, 96, 32, 48) ); // 256-pixel cells allow 64 frame rows; 65 exceeds the GPU/PNG limit. assert!(AtlasLayout::new(256, 256, 64).is_ok()); let err = AtlasLayout::new(256, 256, 65).unwrap_err(); assert!(err.contains("niet gesplitst"), "{err}"); assert!(AtlasLayout::new(0, 1, 1).is_err()); assert!(AtlasLayout::new(257, 1, 1).is_err()); } #[test] fn written_ppda_has_exact_coordinates_names_fps_and_checksums() { let tmp = fixture(); let set = AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).unwrap(); let p = &set.ppda; assert_eq!(p.format_version, 3); assert_eq!((p.sex.as_str(), p.settype.as_str()), ("female", "body")); let (near, far) = crate::camera::depth_range(p.identity.framing); assert_eq!((p.depth_near, p.depth_far), (near, far)); assert_eq!((p.identity.near, p.identity.far), (near, far)); assert_eq!(p.depth_encoding, "gray"); assert_eq!(p.depth_bits, Some(16)); assert_eq!(p.fps, 4); assert_eq!((p.cell_width, p.cell_height), (3, 2)); assert_eq!(p.pitches, [-60, -30, 0, 30, 60]); assert_eq!(p.yaws.len(), 12); assert_eq!(p.maps["diffuse"].file, "female-body-diffuse-atlas.png"); assert_eq!(p.maps["depth"].file, "female-body-depth-atlas.png"); assert_eq!((p.maps["normal"].width, p.maps["normal"].height), (180, 8)); for kind in ATLAS_MAPS { let m = &p.maps[kind.token()]; let bytes = fs::read(tmp.0.join(&m.file)).unwrap(); assert_eq!(digest::sha256_hex(&bytes), m.sha256); } assert_eq!(p.clips.len(), 2); assert_eq!(p.clips[0].frame_count, 3); assert_eq!(p.clips[1].token, "clip001-idle"); let idle = &p.clips[1].frames[0].views[59]; assert_eq!((idle.pitch, idle.yaw, idle.x, idle.y), (60, 330, 177, 6)); // Cells hold exactly the loose map bytes. let loaded = set.load().unwrap(); let run = crate::export_set::ExportRun::open(&tmp.0).unwrap(); for (clip, frame) in [("clip000-walk", 2), ("clip001-idle", 0)] { for (pitch, yaw) in [(-60, 0), (30, 210), (60, 330)] { for kind in ATLAS_MAPS { assert_eq!( loaded.cell(clip, frame, pitch, yaw, kind).unwrap(), run.load_map(clip, frame, pitch, yaw, kind).unwrap() ); } } } assert!(loaded .cell("clip000-walk", 3, 0, 0, MapKind::Diffuse) .is_err()); assert!(loaded.cell("clip009-x", 0, 0, 0, MapKind::Diffuse).is_err()); // The diffuse atlas carries the sRGB chunk like the loose diffuse maps. let bytes = fs::read(tmp.0.join("female-body-diffuse-atlas.png")).unwrap(); let info = png::Decoder::new(std::io::Cursor::new(&bytes[..])) .read_info() .unwrap() .info() .srgb; assert!(info.is_some()); } #[test] fn reader_rejects_tampering_unknown_fields_versions_paths_and_coordinates() { let tmp = fixture(); let ppda = tmp.0.join("female-body-atlas.ppda"); let original = fs::read(&ppda).unwrap(); let restore = || { fs::remove_file(&ppda).unwrap(); fs::write(&ppda, &original).unwrap(); }; AtlasSet::open(&ppda).unwrap(); rewrite_ppda(&tmp.0, |d| d["format_version"] = 1.into()); assert!(matches!( AtlasSet::open(&ppda), Err(ExportError::Identity(_)) )); restore(); rewrite_ppda(&tmp.0, |d| d["depth_far"] = 40.0.into()); assert!(matches!( AtlasSet::open(&ppda), Err(ExportError::Identity(_)) )); restore(); rewrite_ppda(&tmp.0, |d| d["format_version"] = 4.into()); assert!(matches!( AtlasSet::open(&ppda), Err(ExportError::Identity(_)) )); restore(); // Format 2 never carries depth_bits; format 3 always does. rewrite_ppda(&tmp.0, |d| d["format_version"] = 2.into()); assert!(matches!( AtlasSet::open(&ppda), Err(ExportError::Identity(_)) )); restore(); rewrite_ppda(&tmp.0, |d| d["depth_encoding"] = "rgb_weird".into()); assert!(matches!( AtlasSet::open(&ppda), Err(ExportError::Identity(_)) )); restore(); rewrite_ppda(&tmp.0, |d| d["extra"] = true.into()); assert!(matches!( AtlasSet::open(&ppda), Err(ExportError::Corrupt(_)) )); restore(); rewrite_ppda(&tmp.0, |d| { d["clips"][0]["frames"][1]["views"][5]["x"] = 1.into() }); assert!(matches!( AtlasSet::open(&ppda), Err(ExportError::Corrupt(_)) )); restore(); rewrite_ppda(&tmp.0, |d| { d["maps"]["diffuse"]["file"] = "../female-body-diffuse-atlas.png".into() }); assert!(matches!( AtlasSet::open(&ppda), Err(ExportError::Traversal(_)) )); restore(); rewrite_ppda(&tmp.0, |d| d["pitches"] = serde_json::json!([-30, 0, 30])); assert!(AtlasSet::open(&ppda).is_err()); restore(); rewrite_ppda(&tmp.0, |d| d["maps"]["depth"]["height"] = 10.into()); assert!(AtlasSet::open(&ppda).is_err()); restore(); rewrite_ppda(&tmp.0, |d| d["clips"][0]["frame_count"] = 2.into()); assert!(AtlasSet::open(&ppda).is_err()); restore(); AtlasSet::open(&ppda).unwrap(); // Damaged atlas bytes fail the SHA-256 check. let normal = tmp.0.join("female-body-normal-atlas.png"); let good = fs::read(&normal).unwrap(); let mut bad = good.clone(); let last = bad.len() - 20; bad[last] ^= 0xff; fs::remove_file(&normal).unwrap(); fs::write(&normal, &bad).unwrap(); assert!(matches!( AtlasSet::open(&ppda), Err(ExportError::Corrupt(_)) )); fs::remove_file(&normal).unwrap(); #[cfg(unix)] { // A symlinked atlas is refused even with matching bytes. let outside = Temp::new(); fs::create_dir_all(&outside.0).unwrap(); fs::write(outside.0.join("n.png"), &good).unwrap(); std::os::unix::fs::symlink(outside.0.join("n.png"), &normal).unwrap(); assert!(matches!( AtlasSet::open(&ppda), Err(ExportError::Traversal(_)) )); fs::remove_file(&normal).unwrap(); } fs::write(&normal, &good).unwrap(); AtlasSet::open(&ppda).unwrap(); // A renamed .ppda no longer matches its sex/settype. fs::copy(&ppda, tmp.0.join("male-body-atlas.ppda")).unwrap(); assert!(AtlasSet::open(&tmp.0.join("male-body-atlas.ppda")).is_err()); assert!(matches!( AtlasSet::open_run(&tmp.0), Err(ExportError::Identity(_)) )); } #[test] fn atlases_never_clobber_and_are_refused_when_too_large() { let tmp = fixture(); let render: serde_json::Value = serde_json::from_slice(&fs::read(tmp.0.join("render.json")).unwrap()).unwrap(); let complete: serde_json::Value = serde_json::from_slice(&fs::read(tmp.0.join("complete.json")).unwrap()).unwrap(); let sums: BTreeMap = serde_json::from_value(complete["png_sha256"].clone()).unwrap(); let mut spec = SyntheticRun::body("atlas-test", "female", b"body"); spec.clips = vec![("walk".into(), 3, 0.75), ("idle".into(), 1, 0.0)]; let plan = spec.plan(); let cancel = AtomicBool::new(false); let job = AtlasJob { root: &tmp.0, render_doc: &render, plan: &plan, sex: "female", set: "body", cell_width: 3, cell_height: 2, png_sha256: &sums, attempt: 1, cancel: &cancel, }; // Re-running over identical files keeps them (no clobber, same result). let before = fs::read(tmp.0.join("female-body-atlas.ppda")).unwrap(); let out = write_atlases(&job, &mut |_| {}).unwrap(); assert_eq!( fs::read(tmp.0.join("female-body-atlas.ppda")).unwrap(), before ); assert_eq!(out.ppda_sha256, complete["atlas"]["ppda_sha256"]); // A loose map changed after verification is refused. let rel = "female/body/clip000-walk/f000001/p+30_y090_specular.png"; let mut tampered = sums.clone(); tampered.insert(rel.into(), digest::sha256_hex(b"x")); let err = write_atlases( &AtlasJob { png_sha256: &tampered, ..job }, &mut |_| {}, ) .unwrap_err(); assert!(err.contains("checksum"), "{err}"); // Too many frames for 256-pixel cells: clear refusal, never split. let mut big = spec.clone(); big.clips = vec![("long".into(), 65, 16.25)]; assert!(AtlasLayout::for_plan(&big.plan(), 256, 256) .unwrap_err() .contains("16384")); } }