//! Deterministic 2D depth compositor and linear relighting. //! //! Consumes already-exported maps through [`crate::export_set`] (schema 3). //! Saved diffuse remains unlit albedo elsewhere; this module only applies //! consumer-side lighting to composed layers. //! //! # Depth selection //! //! For each pixel, every layer with a **valid** sample (depth alpha ≠ 0 and //! diffuse alpha ≠ 0) competes. The nearest surface wins: smaller depth gray //! is closer (OpenGL-style 0 = near, 1 = far, stored as 16-bit gray). //! //! ## Epsilon and tie-break //! //! Depths that differ by at most [`DEPTH_EPSILON_U16`] codes are treated as a //! tie. On a tie, the **lower layer index** wins. Callers must pass layers in a //! stable order (body first, then equipment sorted by preset name) so ties are //! reproducible across machines. //! //! # Same-layer attribute binding //! //! The winning layer supplies diffuse, normal and specular/shiny from that //! **same** layer index. Maps are never mixed across layers for one pixel. //! //! # Alpha-edge policy //! //! Single-depth compositing cannot reconstruct arbitrary order-dependent //! translucency. Supported approximation: //! //! - Eligible samples require non-zero depth coverage **and** non-zero diffuse //! alpha (cutout / coverage). Purely transparent texels never win. //! - Exactly one winning layer is chosen by depth+tie-break; losing layers //! contribute nothing, even if they have soft alpha. //! - The winner’s straight diffuse alpha is copied to the composed output. //! Soft edges therefore keep the winner’s own coverage; they are not blended //! with occluded layers behind them. //! //! # Lighting //! //! Linear ambient + Lambertian diffuse + Blinn-Phong specular. The light //! direction is already in **view space**, matching the exported normal maps //! (`normal_space: view (+Y up)`). View direction is `(0, 0, 1)` (toward the //! camera). Output RGB is encoded back to sRGB; alpha is unchanged coverage. use crate::{ export_set::{ExportError, ExportRun, MapImage, MapKind}, render_job::ImageBuf, }; use std::fmt; /// Depth codes within this inclusive distance count as a tie. pub const DEPTH_EPSILON_U16: u16 = 1; /// Default Blinn-Phong exponent for the simple preview light. pub const DEFAULT_SHININESS: f32 = 32.0; #[derive(Debug, Clone, PartialEq, Eq)] pub enum ComposeError { Empty, Dimension(String), Map(String), Incompatible(String), Export(String), } impl fmt::Display for ComposeError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Empty => write!(f, "geen lagen om te composeren"), Self::Dimension(m) | Self::Map(m) | Self::Incompatible(m) | Self::Export(m) => { write!(f, "{m}") } } } } impl std::error::Error for ComposeError {} impl From for ComposeError { fn from(value: ExportError) -> Self { Self::Export(value.to_string()) } } /// One composable layer already decoded to the export pixel contract. #[derive(Debug, Clone)] pub struct LayerMaps { pub label: String, pub width: u32, pub height: u32, pub diffuse: Vec, pub normal: Vec, pub specular: Vec, /// Interleaved gray, alpha (`width*height*2`), same layout as `MapImage::Depth16`. pub depth: Vec, } impl LayerMaps { pub fn from_maps( label: impl Into, diffuse: &MapImage, normal: &MapImage, specular: &MapImage, depth: &MapImage, ) -> Result { let (dw, dh, drgba) = expect_rgba(diffuse, "diffuse")?; let (nw, nh, nrgba) = expect_rgba(normal, "normal")?; let (sw, sh, srgba) = expect_rgba(specular, "specular")?; let (zw, zh, samples) = expect_depth(depth)?; if !(dw == nw && nw == sw && sw == zw && dh == nh && nh == sh && sh == zh) { return Err(ComposeError::Dimension( "laagmaps hebben niet dezelfde afmetingen".into(), )); } let pixels = (dw as usize) .checked_mul(dh as usize) .ok_or_else(|| ComposeError::Dimension("afmetingen overlopen".into()))?; if drgba.len() != pixels * 4 || nrgba.len() != pixels * 4 || srgba.len() != pixels * 4 || samples.len() != pixels * 2 { return Err(ComposeError::Map( "buffergrootte past niet bij breedte×hoogte".into(), )); } Ok(Self { label: label.into(), width: dw, height: dh, diffuse: drgba, normal: nrgba, specular: srgba, depth: samples, }) } /// Load the four target maps for one pose from a completed export run. pub fn from_export_run( run: &ExportRun, label: impl Into, clip_token: &str, frame: u32, pitch: i32, yaw: u32, ) -> Result { let diffuse = run.load_map(clip_token, frame, pitch, yaw, MapKind::Diffuse)?; let normal = run.load_map(clip_token, frame, pitch, yaw, MapKind::Normal)?; let specular = run.load_map(clip_token, frame, pitch, yaw, MapKind::Specular)?; let depth = run.load_map(clip_token, frame, pitch, yaw, MapKind::Depth)?; Self::from_maps(label, &diffuse, &normal, &specular, &depth) } } fn expect_rgba(image: &MapImage, kind: &str) -> Result<(u32, u32, Vec), ComposeError> { match image { MapImage::Rgba8 { width, height, rgba, } => Ok((*width, *height, rgba.clone())), MapImage::Depth16 { .. } => Err(ComposeError::Map(format!( "{kind} verwacht RGBA8, kreeg depth16" ))), } } fn expect_depth(image: &MapImage) -> Result<(u32, u32, Vec), ComposeError> { match image { MapImage::Depth16 { width, height, samples, } => Ok((*width, *height, samples.clone())), MapImage::Rgba8 { .. } => Err(ComposeError::Map( "depth verwacht gray-alpha16, kreeg RGBA8".into(), )), } } /// Shared export contract that body + equipment layers must agree on. #[derive(Debug, Clone, PartialEq)] pub struct CompatKey { pub project_id: String, pub sex: String, pub body_model_sha256: String, pub target: [u32; 2], pub fps: u32, pub framing: crate::project::CameraFraming, pub near: f32, pub far: f32, pub clip_tokens: Vec<(String, u32)>, } impl CompatKey { pub fn from_run(run: &ExportRun) -> Self { Self { project_id: run.render.project.id.clone(), sex: run.render.sex.clone(), body_model_sha256: run.render.body_model.sha256.clone(), target: run.render.target, fps: run.render.fps, framing: run.render.framing, near: run.render.near, far: run.render.far, clip_tokens: run .render .clips .iter() .map(|c| (c.token.clone(), c.frames)) .collect(), } } } /// Reject runs that cannot be depth-composited together. pub fn assert_compatible(runs: &[&ExportRun]) -> Result<(), ComposeError> { if runs.is_empty() { return Err(ComposeError::Empty); } let first = CompatKey::from_run(runs[0]); for run in runs.iter().skip(1) { let key = CompatKey::from_run(run); if key.project_id != first.project_id { return Err(ComposeError::Incompatible( "project.id verschilt tussen exportsets".into(), )); } if key.sex != first.sex { return Err(ComposeError::Incompatible( "geslacht verschilt tussen exportsets".into(), )); } if key.body_model_sha256 != first.body_model_sha256 { return Err(ComposeError::Incompatible( "body_model fingerprint verschilt".into(), )); } if key.target != first.target { return Err(ComposeError::Incompatible( "target-resolutie verschilt".into(), )); } if key.fps != first.fps { return Err(ComposeError::Incompatible("fps verschilt".into())); } if key.framing != first.framing { return Err(ComposeError::Incompatible("framing verschilt".into())); } if (key.near - first.near).abs() > f32::EPSILON || (key.far - first.far).abs() > f32::EPSILON { return Err(ComposeError::Incompatible("near/far verschilt".into())); } if key.clip_tokens != first.clip_tokens { return Err(ComposeError::Incompatible( "clip-inventaris verschilt".into(), )); } } Ok(()) } /// Composed target maps plus the winning layer index per pixel (`u8::MAX` = empty). #[derive(Debug, Clone)] pub struct ComposedMaps { pub width: u32, pub height: u32, pub diffuse: Vec, pub normal: Vec, pub specular: Vec, pub depth: Vec, pub winner: Vec, } /// Select nearest valid surface per pixel; bind all maps from that layer. pub fn compose(layers: &[LayerMaps]) -> Result { if layers.is_empty() { return Err(ComposeError::Empty); } let width = layers[0].width; let height = layers[0].height; if width == 0 || height == 0 { return Err(ComposeError::Dimension("lege afmetingen".into())); } let pixels = (width as usize) .checked_mul(height as usize) .ok_or_else(|| ComposeError::Dimension("afmetingen overlopen".into()))?; for (index, layer) in layers.iter().enumerate() { if layer.width != width || layer.height != height { return Err(ComposeError::Dimension(format!( "laag {index} ('{}') heeft andere afmetingen", layer.label ))); } if layer.diffuse.len() != pixels * 4 || layer.normal.len() != pixels * 4 || layer.specular.len() != pixels * 4 || layer.depth.len() != pixels * 2 { return Err(ComposeError::Map(format!( "laag {index} ('{}') heeft inconsistente buffers", layer.label ))); } if layers.len() > u8::MAX as usize { return Err(ComposeError::Map( "maximaal 255 lagen voor winner-index".into(), )); } } let mut diffuse = vec![0u8; pixels * 4]; let mut normal = vec![0u8; pixels * 4]; let mut specular = vec![0u8; pixels * 4]; let mut depth = vec![0u16; pixels * 2]; let mut winner = vec![u8::MAX; pixels]; for i in 0..pixels { let mut best_depth: Option = None; let mut best_layer: Option = None; for (layer_idx, layer) in layers.iter().enumerate() { let depth_alpha = layer.depth[i * 2 + 1]; let diffuse_alpha = layer.diffuse[i * 4 + 3]; if depth_alpha == 0 || diffuse_alpha == 0 { continue; } let gray = layer.depth[i * 2]; match (best_depth, best_layer) { (None, _) => { best_depth = Some(gray); best_layer = Some(layer_idx); } (Some(bd), Some(_)) => { if gray.saturating_add(DEPTH_EPSILON_U16) < bd { // Strictly nearer beyond epsilon. best_depth = Some(gray); best_layer = Some(layer_idx); } else if gray <= bd.saturating_add(DEPTH_EPSILON_U16) { // Tie (within epsilon): keep lower layer index already chosen. } // else: farther — ignore } _ => unreachable!(), } } if let (Some(_), Some(layer_idx)) = (best_depth, best_layer) { let layer = &layers[layer_idx]; let o = i * 4; diffuse[o..o + 4].copy_from_slice(&layer.diffuse[o..o + 4]); normal[o..o + 4].copy_from_slice(&layer.normal[o..o + 4]); specular[o..o + 4].copy_from_slice(&layer.specular[o..o + 4]); depth[i * 2] = layer.depth[i * 2]; depth[i * 2 + 1] = layer.depth[i * 2 + 1]; winner[i] = layer_idx as u8; } } Ok(ComposedMaps { width, height, diffuse, normal, specular, depth, winner, }) } /// View-space directional light for consumer-side relighting. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Light { /// Direction **toward** the light, view space (will be normalized). pub direction_view: [f32; 3], pub ambient: f32, pub diffuse: f32, pub specular: f32, pub shininess: f32, } impl Default for Light { fn default() -> Self { Self { // Slightly above and toward +Z so a front-facing normal responds. direction_view: [0.35, 0.55, 0.75], ambient: 0.15, diffuse: 0.75, specular: 0.4, shininess: DEFAULT_SHININESS, } } } /// Relight composed albedo/normal/specular into an sRGB RGBA image. pub fn relight(composed: &ComposedMaps, light: &Light) -> Result { let pixels = (composed.width as usize) .checked_mul(composed.height as usize) .ok_or_else(|| ComposeError::Dimension("afmetingen overlopen".into()))?; if composed.diffuse.len() != pixels * 4 || composed.normal.len() != pixels * 4 || composed.specular.len() != pixels * 4 { return Err(ComposeError::Map( "composed buffers passen niet bij afmetingen".into(), )); } let l = normalize(light.direction_view); let view = [0.0_f32, 0.0, 1.0]; let half = normalize([l[0] + view[0], l[1] + view[1], l[2] + view[2]]); let mut rgba = vec![0u8; pixels * 4]; for i in 0..pixels { let o = i * 4; let a = composed.diffuse[o + 3]; if a == 0 || composed.winner[i] == u8::MAX { continue; } let albedo = [ linear_srgb(composed.diffuse[o]), linear_srgb(composed.diffuse[o + 1]), linear_srgb(composed.diffuse[o + 2]), ]; let n = decode_normal([ composed.normal[o], composed.normal[o + 1], composed.normal[o + 2], ]); let shiny = composed.specular[o] as f32 / 255.0; let ndotl = dot(n, l).max(0.0); let ndoth = dot(n, half).max(0.0); let spec = shiny * ndoth.powf(light.shininess.max(1.0)); let mut lit = [0.0_f32; 3]; for c in 0..3 { lit[c] = light.ambient * albedo[c] + light.diffuse * ndotl * albedo[c] + light.specular * spec; lit[c] = lit[c].clamp(0.0, 1.0); } rgba[o] = encode_srgb(lit[0]); rgba[o + 1] = encode_srgb(lit[1]); rgba[o + 2] = encode_srgb(lit[2]); rgba[o + 3] = a; } Ok(ImageBuf { width: composed.width, height: composed.height, rgba, }) } /// Compose then relight in one step. pub fn compose_and_relight( layers: &[LayerMaps], light: &Light, ) -> Result<(ComposedMaps, ImageBuf), ComposeError> { let composed = compose(layers)?; let lit = relight(&composed, light)?; Ok((composed, lit)) } fn decode_normal(rgb: [u8; 3]) -> [f32; 3] { normalize([ rgb[0] as f32 / 255.0 * 2.0 - 1.0, rgb[1] as f32 / 255.0 * 2.0 - 1.0, rgb[2] as f32 / 255.0 * 2.0 - 1.0, ]) } fn normalize(v: [f32; 3]) -> [f32; 3] { let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt(); if len > 1e-10 { [v[0] / len, v[1] / len, v[2] / len] } else { [0.0, 0.0, 1.0] } } fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] } fn linear_srgb(x: u8) -> f32 { static LUT: std::sync::OnceLock<[f32; 256]> = std::sync::OnceLock::new(); LUT.get_or_init(|| { std::array::from_fn(|i| { let v = i as f32 / 255.; if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } }) })[x as usize] } fn encode_srgb(x: f32) -> u8 { let v = if x <= 0.0031308 { x * 12.92 } else { 1.055 * x.powf(1. / 2.4) - 0.055 }; (v * 255.).round().clamp(0., 255.) as u8 } #[cfg(test)] mod tests { use super::*; use crate::digest; use crate::export_set::{complete_identity_json, ExportRun}; use crate::production::{write_depth, write_rgba, FAR, NEAR, PASSES}; use crate::project::CameraFraming; use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; struct Temp(PathBuf); impl Temp { fn new() -> Self { static N: AtomicU64 = AtomicU64::new(0); let id = N.fetch_add(1, Ordering::Relaxed); let path = std::env::temp_dir().join(format!( "paperdoll-compositor-{}-{}", std::process::id(), id )); let _ = fs::remove_dir_all(&path); fs::create_dir_all(&path).unwrap(); Self(path) } } impl Drop for Temp { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } fn framing() -> CameraFraming { CameraFraming { height_milli: 2600, center_y_milli: 1000, pivot_y_milli: Some(1460), } } #[allow(clippy::too_many_arguments)] fn solid_layer( label: &str, w: u32, h: u32, diffuse: [u8; 4], normal: [u8; 4], specular: [u8; 4], depth_gray: u16, depth_alpha: u16, ) -> LayerMaps { let pixels = (w * h) as usize; LayerMaps { label: label.into(), width: w, height: h, diffuse: diffuse.repeat(pixels), normal: normal.repeat(pixels), specular: specular.repeat(pixels), depth: [depth_gray, depth_alpha] .into_iter() .cycle() .take(pixels * 2) .collect(), } } fn pixel_layer( label: &str, w: u32, h: u32, fill: impl Fn(usize) -> ([u8; 4], [u8; 4], [u8; 4], u16, u16), ) -> LayerMaps { let pixels = (w * h) as usize; let mut diffuse = vec![0; pixels * 4]; let mut normal = vec![0; pixels * 4]; let mut specular = vec![0; pixels * 4]; let mut depth = vec![0u16; pixels * 2]; for i in 0..pixels { let (d, n, s, g, a) = fill(i); diffuse[i * 4..i * 4 + 4].copy_from_slice(&d); normal[i * 4..i * 4 + 4].copy_from_slice(&n); specular[i * 4..i * 4 + 4].copy_from_slice(&s); depth[i * 2] = g; depth[i * 2 + 1] = a; } LayerMaps { label: label.into(), width: w, height: h, diffuse, normal, specular, depth, } } #[test] fn front_occludes_back_and_binds_same_layer_maps() { // 2×1: left pixel has front red equipment over blue body; right is body only. let body = pixel_layer("body", 2, 1, |i| { let depth = if i == 0 { 30000 } else { 20000 }; ( [0, 0, 200, 255], [128, 128, 255, 255], [0, 0, 0, 255], depth, 65535, ) }); let gear = pixel_layer("sword", 2, 1, |i| { if i == 0 { ( [200, 0, 0, 255], [255, 128, 128, 255], // +X-ish normal from gear [180, 180, 180, 255], 10000, // nearer 65535, ) } else { ([0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], 0, 0) } }); let composed = compose(&[body, gear]).unwrap(); assert_eq!(composed.winner[0], 1, "front equipment wins left"); assert_eq!(&composed.diffuse[0..4], &[200, 0, 0, 255]); assert_eq!(&composed.normal[0..4], &[255, 128, 128, 255]); assert_eq!(&composed.specular[0..4], &[180, 180, 180, 255]); assert_eq!(composed.depth[0], 10000); assert_eq!(composed.winner[1], 0, "body remains on right"); assert_eq!(&composed.diffuse[4..8], &[0, 0, 200, 255]); assert_eq!(&composed.specular[4..8], &[0, 0, 0, 255]); } #[test] fn epsilon_ties_prefer_lower_layer_index() { let body = solid_layer( "body", 1, 1, [10, 20, 30, 255], [128, 128, 255, 255], [0, 0, 0, 255], 20000, 65535, ); // Within DEPTH_EPSILON_U16 of body depth. let gear = solid_layer( "hat", 1, 1, [90, 80, 70, 255], [128, 200, 128, 255], [50, 50, 50, 255], 20000 + DEPTH_EPSILON_U16, 65535, ); let composed = compose(&[body.clone(), gear.clone()]).unwrap(); assert_eq!(composed.winner[0], 0); assert_eq!(&composed.diffuse[..4], &[10, 20, 30, 255]); // Reverse order: now gear is index 0 and wins the tie. let reversed = compose(&[gear, body]).unwrap(); assert_eq!(reversed.winner[0], 0); assert_eq!(&reversed.diffuse[..4], &[90, 80, 70, 255]); } #[test] fn strictly_nearer_beyond_epsilon_beats_earlier_layer() { let body = solid_layer( "body", 1, 1, [1, 2, 3, 255], [128, 128, 255, 255], [0, 0, 0, 255], 20000, 65535, ); let gear = solid_layer( "cloak", 1, 1, [9, 8, 7, 255], [128, 128, 255, 255], [0, 0, 0, 255], 20000 - DEPTH_EPSILON_U16 - 1, 65535, ); let composed = compose(&[body, gear]).unwrap(); assert_eq!(composed.winner[0], 1); assert_eq!(&composed.diffuse[..4], &[9, 8, 7, 255]); } #[test] fn transparency_and_alpha_edges_follow_documented_policy() { // Center soft-edge body; equipment only on left with full coverage; // right pixel has transparent equipment over body — body keeps soft alpha. let body = pixel_layer("body", 3, 1, |i| { let alpha = match i { 0 => 255, 1 => 128, // soft edge _ => 255, }; ( [40, 40, 40, alpha], [128, 128, 255, alpha], [0, 0, 0, alpha], 25000, u16::from(alpha) * 257, ) }); let gear = pixel_layer("pad", 3, 1, |i| match i { 0 => ( [255, 0, 0, 255], [128, 128, 255, 255], [0, 0, 0, 255], 10000, 65535, ), // Transparent equipment must not steal the soft body edge. 1 => ([255, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], 5000, 0), // Semi-transparent losing layer behind would also be ignored if farther; // here nearer but alpha 0 — skipped. _ => ([0, 255, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], 5000, 0), }); let composed = compose(&[body, gear]).unwrap(); assert_eq!(composed.winner[0], 1); assert_eq!(composed.diffuse[3], 255); assert_eq!(composed.winner[1], 0); assert_eq!( composed.diffuse[7], 128, "winner soft alpha preserved; no cross-layer blend" ); assert_eq!(composed.winner[2], 0); assert_eq!(&composed.diffuse[8..12], &[40, 40, 40, 255]); } #[test] fn lighting_responds_to_normal_and_light_direction() { let facing = solid_layer( "front", 1, 1, [200, 200, 200, 255], [128, 128, 255, 255], // ~+Z [0, 0, 0, 255], 10000, 65535, ); let away = solid_layer( "back", 1, 1, [200, 200, 200, 255], [128, 128, 0, 255], // ~-Z [0, 0, 0, 255], 10000, 65535, ); let light = Light { direction_view: [0.0, 0.0, 1.0], ambient: 0.05, diffuse: 0.9, specular: 0.0, shininess: 16.0, }; let lit_front = relight(&compose(&[facing]).unwrap(), &light).unwrap(); let lit_back = relight(&compose(&[away]).unwrap(), &light).unwrap(); let front_luma = lit_front.rgba[0] as u32 + lit_front.rgba[1] as u32 + lit_front.rgba[2] as u32; let back_luma = lit_back.rgba[0] as u32 + lit_back.rgba[1] as u32 + lit_back.rgba[2] as u32; assert!( front_luma > back_luma + 40, "front-facing should be brighter: {front_luma} vs {back_luma}" ); let shiny = solid_layer( "metal", 1, 1, [180, 180, 180, 255], [128, 128, 255, 255], [255, 255, 255, 255], 10000, 65535, ); let matte = solid_layer( "matte", 1, 1, [180, 180, 180, 255], [128, 128, 255, 255], [0, 0, 0, 255], 10000, 65535, ); let spec_light = Light { direction_view: [0.0, 0.0, 1.0], ambient: 0.0, diffuse: 0.2, specular: 1.0, shininess: 64.0, }; let lit_shiny = relight(&compose(&[shiny]).unwrap(), &spec_light).unwrap(); let lit_matte = relight(&compose(&[matte]).unwrap(), &spec_light).unwrap(); let shiny_luma = lit_shiny.rgba[0] as u32 + lit_shiny.rgba[1] as u32 + lit_shiny.rgba[2] as u32; let matte_luma = lit_matte.rgba[0] as u32 + lit_matte.rgba[1] as u32 + lit_matte.rgba[2] as u32; assert!( shiny_luma > matte_luma + 20, "specular map must brighten highlight: {shiny_luma} vs {matte_luma}" ); assert_eq!(lit_shiny.rgba[3], 255); } #[test] fn empty_and_mismatched_layers_error() { assert!(matches!(compose(&[]), Err(ComposeError::Empty))); let a = solid_layer( "a", 1, 1, [1, 1, 1, 255], [128, 128, 255, 255], [0, 0, 0, 255], 1, 65535, ); let b = solid_layer( "b", 2, 1, [1, 1, 1, 255], [128, 128, 255, 255], [0, 0, 0, 255], 1, 65535, ); assert!(matches!(compose(&[a, b]), Err(ComposeError::Dimension(_)))); } fn write_atomic_json(root: &Path, name: &str, value: &serde_json::Value) { let data = serde_json::to_vec_pretty(value).unwrap(); let path = root.join(name); let temp = path.with_extension(format!("part-{}", std::process::id())); fs::write(&temp, &data).unwrap(); fs::hard_link(&temp, &path).unwrap(); let _ = fs::remove_file(temp); } fn publish_pose( root: &Path, sex: &str, set: &str, diffuse: [u8; 4], normal: [u8; 4], specular: [u8; 4], depth_gray: u16, ) -> BTreeMap { let mut map = BTreeMap::new(); let base = format!("{sex}/{set}/clip000-bind/f000000/p+00_y000"); let rels = [ (format!("{base}_diffuse.png"), "diffuse"), (format!("{base}_depth.png"), "depth"), (format!("{base}_normal.png"), "normal"), (format!("{base}_specular.png"), "specular"), ]; for (rel, kind) in rels { let path = Path::new(&rel); match kind { "depth" => { write_depth(root, path, 1, 1, &[depth_gray, 65535]).unwrap(); } "normal" => { write_rgba( root, path, &ImageBuf { width: 1, height: 1, rgba: normal.to_vec(), }, ) .unwrap(); } "specular" => { write_rgba( root, path, &ImageBuf { width: 1, height: 1, rgba: specular.to_vec(), }, ) .unwrap(); } _ => { write_rgba( root, path, &ImageBuf { width: 1, height: 1, rgba: diffuse.to_vec(), }, ) .unwrap(); } } let bytes = fs::read(root.join(&rel)).unwrap(); map.insert(rel, digest::sha256_hex(&bytes)); } map } fn write_completed_run( root: &Path, set: &str, equipment_sha: Option<&str>, checksums: BTreeMap, body_bytes: &[u8], ) { let body_hash = digest::sha256_hex(body_bytes); let mut render = serde_json::json!({ "format_version": 3, "status": "incomplete", "project": {"id": "demo", "name": "Demo"}, "sex": "male", "set": set, "body_model": {"path": "assets/body/x.glb", "sha256": body_hash}, "equipment_preset": null, "target": [1, 1], "fps": 12, "highres_scale": 8, "framing": framing(), "pivot_source": {"kind": "manual"}, "near": NEAR, "far": FAR, "camera_distance": 10.0, "yaw_zero": "camera on -Z", "normal_space": "view (+Y up), PNG rows top-down", "pitches": [-30, 0, 30], "yaw_step": 30, "sampling": "test", "alpha": "straight", "diffuse": "unlit", "depth": "16-bit", "specular": "scalar", "pass_order": PASSES, "saved_maps": ["diffuse", "depth", "normal", "specular"], "clips": [{"name": "", "token": "clip000-bind", "frames": 1, "duration": 0.0}], "images": 1, "passes": 6, "png_files": 4 }); if let Some(sha) = equipment_sha { render["equipment_preset"] = serde_json::json!({"name": set, "sha256": sha}); } write_atomic_json(root, "render.json", &render); let identity = complete_identity_json(&render).unwrap(); write_atomic_json( root, "complete.json", &serde_json::json!({ "format_version": 3, "status": "complete", "identity": identity, "passes": 6, "png_files": 4, "png_sha256": checksums }), ); } #[test] fn export_set_layers_compose_through_safe_reader() { let body_tmp = Temp::new(); let gear_tmp = Temp::new(); let body_bytes = b"shared-body"; let body_maps = publish_pose( &body_tmp.0, "male", "body", [20, 40, 60, 255], [128, 128, 255, 255], [0, 0, 0, 255], 30000, ); write_completed_run(&body_tmp.0, "body", None, body_maps, body_bytes); let gear_hash = digest::sha256_hex(b"sword-ppe"); let gear_maps = publish_pose( &gear_tmp.0, "male", "sword", [220, 10, 10, 255], [255, 128, 128, 255], [200, 200, 200, 255], 12000, ); write_completed_run( &gear_tmp.0, "sword", Some(&gear_hash), gear_maps, body_bytes, ); let body_run = ExportRun::open(&body_tmp.0).unwrap(); let gear_run = ExportRun::open(&gear_tmp.0).unwrap(); assert_compatible(&[&body_run, &gear_run]).unwrap(); let body_layer = LayerMaps::from_export_run(&body_run, "body", "clip000-bind", 0, 0, 0).unwrap(); let gear_layer = LayerMaps::from_export_run(&gear_run, "sword", "clip000-bind", 0, 0, 0).unwrap(); let (composed, lit) = compose_and_relight(&[body_layer, gear_layer], &Light::default()).unwrap(); assert_eq!(composed.winner[0], 1); assert_eq!(&composed.diffuse[..4], &[220, 10, 10, 255]); assert_eq!(&composed.specular[..4], &[200, 200, 200, 255]); assert_eq!(lit.rgba[3], 255); assert!(lit.rgba[0] > 0); } #[test] fn incompatible_export_sets_are_rejected() { let a = Temp::new(); let b = Temp::new(); let maps_a = publish_pose( &a.0, "male", "body", [1, 2, 3, 255], [128, 128, 255, 255], [0, 0, 0, 255], 1000, ); write_completed_run(&a.0, "body", None, maps_a, b"body-a"); let maps_b = publish_pose( &b.0, "male", "body", [1, 2, 3, 255], [128, 128, 255, 255], [0, 0, 0, 255], 1000, ); write_completed_run(&b.0, "body", None, maps_b, b"body-b"); let run_a = ExportRun::open(&a.0).unwrap(); let run_b = ExportRun::open(&b.0).unwrap(); let err = assert_compatible(&[&run_a, &run_b]).unwrap_err(); assert!(matches!(err, ComposeError::Incompatible(_))); } }