//! Bounded orthographic production rendering. GPU work runs on the native //! current context with a PBO readback ring (several views in flight); CPU //! resolve runs on the worker; PNG encode/write runs on a bounded IO queue. //! Live preview pixels go through a latest-wins [`PreviewSlot`] — the worker //! never waits for texture upload or UI acknowledgment (Tim t1063u/t1064u). use crate::{ equipment, io_queue::{EncodedMap, IoQueue, ViewPublish}, preview::{self, Mat4, PreviewCharacter, PreviewControls}, project::CameraFraming, render_job::{ImageBuf, RenderEvent}, }; use glow::HasContext; use std::{ collections::{BTreeMap, VecDeque}, path::{Path, PathBuf}, sync::{ atomic::{AtomicBool, Ordering}, mpsc, Arc, Mutex, }, thread, }; pub const SCALE: u32 = 8; pub const MAX_HIGHRES: u32 = 2048; pub const PASSES: [&str; 6] = [ "highres", "detail", "target-res / diffuse", "depth", "normal", "specular", ]; /// GPU readback ring depth (PBO + fence). Keep 2–3 views in flight. pub const GPU_PIPELINE_DEPTH: usize = 3; #[derive(Clone)] pub struct Draw { pub positions: Arc>, pub normals: Arc>, pub uvs: Arc>, pub vertex_colors: Arc>, pub indices: Vec, pub color: [f32; 4], pub albedo: Option>, pub shiny: Option>, pub specular: f32, pub cutoff: f32, pub flat_normals: bool, /// glTF BLEND: drawn after the opaque draws, blended (design.md t1016u). pub blend: bool, } #[derive(Clone)] pub struct Scene { pub draws: Vec, pub view: Mat4, pub projection: Mat4, pub width: u32, pub height: u32, } #[derive(Debug)] pub struct Buffers { pub width: u32, pub height: u32, pub diffuse: Vec, pub normal: Vec, pub specular: Vec, pub depth: Vec, } #[derive(Debug)] pub struct Target { pub diffuse: ImageBuf, pub normal: ImageBuf, pub specular: ImageBuf, pub depth: Vec, /// Unquantized normalized depth t (0..1) per pixel, for the export encoding. pub depth_t: Vec, } /// Content fingerprint of a source file used for a production job. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SourceFingerprint { pub path: String, pub sha256: String, } #[derive(Clone)] pub struct RenderJob { pub character: PreviewCharacter, pub sex: String, pub preset: Option, pub target_width: u32, pub target_height: u32, pub fps: u32, pub framing: CameraFraming, pub output: PathBuf, pub project_id: String, pub project_name: String, pub body_model: SourceFingerprint, /// SHA-256 of the encoded `.ppe` bytes when rendering equipment; None for body. pub preset_sha256: Option, /// Depth PNG bit depth + encoding (project setting). pub depth_format: crate::camera::DepthFormat, /// Crop atlas views to their content (project setting, t1030u). pub atlas_trim: bool, /// Semi-transparency allowed (t1031u); off = binary alpha at 0.5 in all maps. pub semi_transparency: bool, } #[derive(Debug, Clone, PartialEq)] pub struct ClipPlan { pub source_name: String, pub token: String, pub frames: u32, } #[derive(Debug, Clone, PartialEq)] pub struct Plan { pub clips: Vec, pub images: u32, pub passes: u32, pub files: u32, } pub fn plan(body: &PreviewCharacter, fps: u32) -> Result { if fps == 0 || fps > 240 { return Err("fps moet 1–240 zijn".into()); } let clips = if body.clips.is_empty() { vec![ClipPlan { source_name: String::new(), token: "clip000-bind".into(), frames: 1, }] } else { body.clips .iter() .enumerate() .map(|(i, c)| { let duration = c.duration; if !duration.is_finite() || duration < 0. || duration * fps as f32 > 100000. { return Err("ongeldige of te lange animatie".into()); } // Exporter float timestamps commonly put a nominal 1-second // endpoint one ULP above 1. Do not duplicate that terminal pose. let samples = f64::from(duration) * f64::from(fps); let nearest = samples.round(); let samples = if (samples - nearest).abs() <= 4. * f64::from(f32::EPSILON) * samples.max(1.) { nearest } else { samples }; Ok(ClipPlan { source_name: c.name.clone(), token: clip_token(i, &c.name), frames: samples.ceil().max(1.) as u32, }) }) .collect::, String>>()? }; let frames = clips .iter() .try_fold(0u32, |n, c| n.checked_add(c.frames)) .ok_or("te veel frames")?; let images = frames .checked_mul(preview::PRODUCTION_VIEWS) .ok_or("te veel beelden")?; let passes = images.checked_mul(6).ok_or("te veel passes")?; let files = images.checked_mul(4).ok_or("te veel bestanden")?; if files > 500000 { return Err("batch groter dan 500000 uitvoerbestanden".into()); } Ok(Plan { clips, images, passes, files, }) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Sample { pub clip: usize, pub frame: u32, pub pitch: i32, pub yaw: u32, } pub fn samples(plan: &Plan) -> impl Iterator + '_ { plan.clips.iter().enumerate().flat_map(|(clip, c)| { (0..c.frames).flat_map(move |frame| { preview::PRODUCTION_PITCHES .into_iter() .flat_map(move |pitch| { (0..12).map(move |y| Sample { clip, frame, pitch, yaw: y * 30, }) }) }) }) } /// Production yaw values in degrees, atlas column order within one pitch. pub fn production_yaws() -> [u32; 12] { std::array::from_fn(|i| i as u32 * 30) } pub fn clip_token(index: usize, name: &str) -> String { let slug: String = name .chars() .map(|c| { if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' } }) .take(48) .collect(); format!( "clip{index:03}-{}", if slug.is_empty() { "animation" } else { &slug } ) } pub fn output_path( sex: &str, set: &str, clip: &str, frame: u32, pitch: i32, yaw: u32, map: &str, ) -> Result { if !["male", "female"].contains(&sex) || !(set == "body" || equipment::valid_name(set)) || !equipment::valid_name(clip) || !preview::PRODUCTION_PITCHES.contains(&pitch) || yaw >= 360 || !yaw.is_multiple_of(30) || !["diffuse", "normal", "depth", "specular"].contains(&map) { return Err("ongeldig uitvoerpad/token".into()); } Ok(PathBuf::from(crate::resume::RAW_DIR) .join(sex) .join(set) .join(clip) .join(format!("f{frame:06}")) .join(format!("p{pitch:+03}_y{yaw:03}_{map}.png"))) } pub use crate::camera::matrices as camera; fn point(m: &Mat4, p: [f32; 3]) -> [f32; 3] { std::array::from_fn(|i| m[i] * p[0] + m[i + 4] * p[1] + m[i + 8] * p[2] + m[i + 12]) } fn normalize(v: [f32; 3]) -> [f32; 3] { let l = v.iter().map(|x| x * x).sum::().sqrt(); if l > 1e-10 { v.map(|x| x / l) } else { [0., 0., 1.] } } fn normal(m: &Mat4, n: [f32; 3]) -> [f32; 3] { let a = [m[0], m[1], m[2]]; let b = [m[4], m[5], m[6]]; let c = [m[8], m[9], m[10]]; let cross = |a: [f32; 3], b: [f32; 3]| { [ a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0], ] }; let x = cross(b, c); let y = cross(c, a); let z = cross(a, b); let det = a.iter().zip(x).map(|(a, b)| a * b).sum::(); if det.abs() < 1e-12 { return normalize(n); } normalize(std::array::from_fn(|i| { (x[i] * n[0] + y[i] * n[1] + z[i] * n[2]) / det })) } pub fn scene( job: &RenderJob, clip: &str, time: f32, pitch: f32, yaw: f32, cache: &mut equipment::TextureCache, ) -> Result { let body = &job.character; let mut draws = Vec::new(); if let Some(preset) = &job.preset { let controls = PreviewControls { clip_name: clip.into(), time, ..Default::default() }; for layer in equipment::layers_cached( preset, usize::from(job.sex == "female"), body, &controls, cache, )? { let m = layer.model; draws.push(Draw { positions: Arc::new(layer.mesh.positions.iter().map(|p| point(&m, *p)).collect()), normals: Arc::new(layer.mesh.normals.iter().map(|n| normal(&m, *n)).collect()), uvs: Arc::new( layer .mesh .uvs .into_iter() .map(|[u, v]| [u, 1. - v]) .collect(), ), vertex_colors: Arc::new(Vec::new()), indices: layer.mesh.indices, color: layer.mesh.color, albedo: layer.albedo, shiny: layer.shiny, specular: layer.specular, cutoff: 0.5, flat_normals: false, blend: false, }); } } else { let bones = preview::sample_skin_matrices(body, clip, time); let mut positions = Vec::with_capacity(body.positions.len()); let mut normals = Vec::with_capacity(body.positions.len()); for (i, p) in body.positions.iter().enumerate() { let mut m = preview::identity(); if !bones.is_empty() && body.weights.len() == body.positions.len() { m = [0.; 16]; let joints = body.joints.get(i).copied().unwrap_or([0; 4]); for (j, w) in joints.into_iter().zip(body.weights[i]) { let b = bones.get(j as usize).ok_or("ongeldige skinjoint")?; for k in 0..16 { m[k] += b[k] * w; } } } positions.push(point(&m, *p)); normals.push(normal( &m, body.normals.get(i).copied().unwrap_or([0., 1., 0.]), )); } let positions = Arc::new(positions); let normals = Arc::new(normals); let uvs = Arc::new(body.uvs.clone()); let colors = Arc::new(body.colors.clone()); if body.surfaces.is_empty() { draws.push(Draw { positions, normals, uvs, vertex_colors: colors, indices: body.indices.clone(), color: body.base_color, albedo: None, shiny: None, specular: 0., cutoff: 0.001, flat_normals: !body.source_normals_complete, blend: false, }); } else { for surface in &body.surfaces { draws.push(Draw { positions: positions.clone(), normals: normals.clone(), uvs: uvs.clone(), vertex_colors: colors.clone(), indices: body.indices[surface.first..surface.first + surface.count].to_vec(), color: surface.color, albedo: surface.albedo.clone(), shiny: surface.shiny.clone(), specular: surface.specular, cutoff: surface.cutoff, flat_normals: surface.flat_normals, blend: surface.blend, }); } } } let (view, projection) = camera( crate::camera::resolve(body, job.framing).framing, yaw, pitch, job.target_width as f32 / job.target_height as f32, ); Ok(Scene { draws, view, projection, width: job.target_width * SCALE, height: job.target_height * SCALE, }) } impl Scene { pub fn validate(&self) -> Result<(), String> { if self.width == 0 || self.height == 0 || self.width > MAX_HIGHRES || self.height > MAX_HIGHRES || self.draws.len() > 4096 || self .view .iter() .chain(&self.projection) .any(|v| !v.is_finite()) { return Err("ongeldige GPU-scène/afmetingen".into()); } for d in &self.draws { if d.positions.len() > 1_000_000 || d.positions.len() != d.normals.len() || d.indices.len() > 3_000_000 || !d.indices.len().is_multiple_of(3) || d.indices.iter().any(|i| *i as usize >= d.positions.len()) || d.positions .iter() .chain(d.normals.iter()) .flatten() .chain(d.uvs.iter().flatten()) .chain(d.vertex_colors.iter().flatten()) .chain(d.color.iter()) .any(|v| !v.is_finite()) || !d.specular.is_finite() || !d.cutoff.is_finite() { return Err("ongeldige of te grote GPU-geometrie".into()); } for t in [d.albedo.as_ref(), d.shiny.as_ref()].into_iter().flatten() { if t.width == 0 || t.height == 0 || t.width > 2048 || t.height > 2048 || t.rgba.len() != t.width as usize * t.height as usize * 4 { return Err("ongeldige GPU-texture".into()); } } } Ok(()) } } struct PendingReadback { reply: mpsc::SyncSender>, width: u32, height: u32, slot: usize, fence: Option, } struct ReadbackSlot { /// RGBA8 × 3 + depth f32 pack buffers (PIXEL_PACK). pbos: [Option; 4], bytes: [usize; 4], } impl Default for ReadbackSlot { fn default() -> Self { Self { pbos: [None, None, None, None], bytes: [0; 4], } } } pub struct GpuRenderer { program: Option, vao: Option, vbo: Option, ebo: Option, fbo: Option, colors: Vec, depth: Option, extent: [u32; 2], /// Async readback ring (Tim t1063u). readback: [ReadbackSlot; GPU_PIPELINE_DEPTH], pending: VecDeque, next_slot: usize, /// When false, fall back to synchronous read_pixels. pbo_ok: bool, } impl Default for GpuRenderer { fn default() -> Self { Self { program: None, vao: None, vbo: None, ebo: None, fbo: None, colors: Vec::new(), depth: None, extent: [0, 0], readback: std::array::from_fn(|_| ReadbackSlot::default()), pending: VecDeque::new(), next_slot: 0, pbo_ok: true, } } } impl GpuRenderer { /// # Safety /// A native OpenGL 3.3 context must be current on this thread. pub unsafe fn destroy(&mut self, gl: &glow::Context) { let _ = self.poll_readbacks(gl, true); self.destroy_readback(gl); self.destroy_target(gl); if let Some(x) = self.program.take() { gl.delete_program(x); } if let Some(x) = self.vao.take() { gl.delete_vertex_array(x); } if let Some(x) = self.vbo.take() { gl.delete_buffer(x); } if let Some(x) = self.ebo.take() { gl.delete_buffer(x); } } unsafe fn destroy_readback(&mut self, gl: &glow::Context) { while let Some(p) = self.pending.pop_front() { if let Some(f) = p.fence { gl.delete_sync(f); } let _ = p.reply.send(Err("GPU-renderer gestopt".into())); } for slot in &mut self.readback { for b in slot.pbos.iter_mut() { if let Some(buf) = b.take() { gl.delete_buffer(buf); } } slot.bytes = [0; 4]; } self.next_slot = 0; } unsafe fn destroy_target(&mut self, gl: &glow::Context) { for x in self.colors.drain(..) { gl.delete_texture(x); } if let Some(x) = self.depth.take() { gl.delete_renderbuffer(x); } if let Some(x) = self.fbo.take() { gl.delete_framebuffer(x); } self.extent = [0, 0]; } unsafe fn init(&mut self, gl: &glow::Context) -> Result<(), String> { if self.program.is_some() { return Ok(()); } let p = gl.create_program()?; let mut shaders = Vec::new(); for (kind, source) in [ (glow::VERTEX_SHADER, VERTEX), (glow::FRAGMENT_SHADER, FRAGMENT), ] { let shader = gl.create_shader(kind)?; gl.shader_source(shader, source); gl.compile_shader(shader); if !gl.get_shader_compile_status(shader) { let error = gl.get_shader_info_log(shader); gl.delete_shader(shader); for s in shaders { gl.delete_shader(s); } gl.delete_program(p); return Err(error); } gl.attach_shader(p, shader); shaders.push(shader); } gl.link_program(p); let result = if gl.get_program_link_status(p) { Ok(()) } else { Err(gl.get_program_info_log(p)) }; for s in shaders { gl.detach_shader(p, s); gl.delete_shader(s); } if let Err(e) = result { gl.delete_program(p); return Err(e); } self.program = Some(p); self.vao = Some(gl.create_vertex_array()?); self.vbo = Some(gl.create_buffer()?); self.ebo = Some(gl.create_buffer()?); Ok(()) } unsafe fn target(&mut self, gl: &glow::Context, w: u32, h: u32) -> Result<(), String> { if self.extent == [w, h] { return Ok(()); } // Size change invalidates in-flight PBOs for the old extent. let _ = self.poll_readbacks(gl, true); self.destroy_target(gl); self.fbo = Some(gl.create_framebuffer()?); gl.bind_framebuffer(glow::FRAMEBUFFER, self.fbo); for slot in 0..3 { let t = gl.create_texture()?; self.colors.push(t); gl.bind_texture(glow::TEXTURE_2D, Some(t)); gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32, ); gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32, ); gl.tex_image_2d( glow::TEXTURE_2D, 0, glow::RGBA8 as i32, w as i32, h as i32, 0, glow::RGBA, glow::UNSIGNED_BYTE, None, ); gl.framebuffer_texture_2d( glow::FRAMEBUFFER, glow::COLOR_ATTACHMENT0 + slot, glow::TEXTURE_2D, Some(t), 0, ); } let d = gl.create_renderbuffer()?; self.depth = Some(d); gl.bind_renderbuffer(glow::RENDERBUFFER, Some(d)); gl.renderbuffer_storage( glow::RENDERBUFFER, glow::DEPTH_COMPONENT24, w as i32, h as i32, ); gl.framebuffer_renderbuffer( glow::FRAMEBUFFER, glow::DEPTH_ATTACHMENT, glow::RENDERBUFFER, Some(d), ); gl.draw_buffers(&[ glow::COLOR_ATTACHMENT0, glow::COLOR_ATTACHMENT1, glow::COLOR_ATTACHMENT2, ]); if gl.check_framebuffer_status(glow::FRAMEBUFFER) != glow::FRAMEBUFFER_COMPLETE { return Err("productie-FBO onvolledig".into()); } self.extent = [w, h]; Ok(()) } unsafe fn ensure_pbo( &mut self, gl: &glow::Context, slot: usize, w: u32, h: u32, ) -> Result<(), String> { let pixels = w as usize * h as usize; let sizes = [pixels * 4, pixels * 4, pixels * 4, pixels * 4]; let ring = &mut self.readback[slot]; for i in 0..4 { if ring.bytes[i] == sizes[i] && ring.pbos[i].is_some() { continue; } if let Some(old) = ring.pbos[i].take() { gl.delete_buffer(old); } let buf = gl.create_buffer().map_err(|e| e.to_string())?; gl.bind_buffer(glow::PIXEL_PACK_BUFFER, Some(buf)); gl.buffer_data_size(glow::PIXEL_PACK_BUFFER, sizes[i] as i32, glow::STREAM_READ); gl.bind_buffer(glow::PIXEL_PACK_BUFFER, None); ring.pbos[i] = Some(buf); ring.bytes[i] = sizes[i]; } Ok(()) } /// Draw the scene into the production FBO. Leaves the FBO bound and GL draw state set. unsafe fn draw_scene(&mut self, gl: &glow::Context, scene: &Scene) -> Result<(), String> { if scene.width == 0 || scene.height == 0 || scene.width > MAX_HIGHRES || scene.height > MAX_HIGHRES { return Err("highres moet 1–2048 pixels zijn (8× target)".into()); } scene.validate()?; self.init(gl)?; self.target(gl, scene.width, scene.height)?; gl.bind_framebuffer(glow::FRAMEBUFFER, self.fbo); gl.draw_buffers(&[ glow::COLOR_ATTACHMENT0, glow::COLOR_ATTACHMENT1, glow::COLOR_ATTACHMENT2, ]); gl.viewport(0, 0, scene.width as i32, scene.height as i32); gl.disable(glow::SCISSOR_TEST); gl.disable(glow::CULL_FACE); gl.disable(glow::BLEND); gl.enable(glow::DEPTH_TEST); gl.depth_mask(true); gl.depth_func(glow::LESS); // Geometry outside the fixed per-sex interval is clamped, not clipped // away; production counts and reports clamped coverage. gl.enable(glow::DEPTH_CLAMP); gl.clear_depth_f32(1.); gl.clear_color(0., 0., 0., 0.); gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT); let p = self.program.ok_or("shader ontbreekt")?; gl.use_program(Some(p)); gl.uniform_matrix_4_f32_slice( gl.get_uniform_location(p, "u_view").as_ref(), false, &scene.view, ); gl.uniform_matrix_4_f32_slice( gl.get_uniform_location(p, "u_projection").as_ref(), false, &scene.projection, ); gl.bind_vertex_array(self.vao); gl.bind_buffer(glow::ARRAY_BUFFER, self.vbo); gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, self.ebo); for (slot, size, offset) in [(0, 3, 0), (1, 3, 3), (2, 2, 6), (3, 4, 8)] { gl.enable_vertex_attrib_array(slot); gl.vertex_attrib_pointer_f32(slot, size, glow::FLOAT, false, 12 * 4, offset * 4); } // glTF BLEND (design.md t1016u): opaque/mask first, then the // BLEND draws blended without depth writes, then their depth only. let order: Vec<(&Draw, u8)> = scene .draws .iter() .filter(|d| !d.blend) .map(|d| (d, 0)) .chain(scene.draws.iter().filter(|d| d.blend).map(|d| (d, 1))) .chain(scene.draws.iter().filter(|d| d.blend).map(|d| (d, 2))) .collect(); for (draw, pass) in order { match pass { 0 => { gl.disable(glow::BLEND); gl.depth_mask(true); gl.color_mask(true, true, true, true); } 1 => { gl.enable(glow::BLEND); gl.blend_func_separate( glow::SRC_ALPHA, glow::ONE_MINUS_SRC_ALPHA, glow::ONE, glow::ONE_MINUS_SRC_ALPHA, ); gl.depth_mask(false); gl.color_mask(true, true, true, true); } _ => { gl.disable(glow::BLEND); gl.depth_mask(true); gl.color_mask(false, false, false, false); } } if draw.positions.len() != draw.normals.len() || draw .indices .iter() .any(|i| *i as usize >= draw.positions.len()) || draw.indices.len() > i32::MAX as usize { return Err("ongeldige GPU-geometrie".into()); } let mut data = Vec::with_capacity(draw.positions.len() * 12); for (i, pos) in draw.positions.iter().enumerate() { data.extend_from_slice(pos); data.extend_from_slice(&draw.normals[i]); data.extend_from_slice(&draw.uvs.get(i).copied().unwrap_or([0.; 2])); data.extend_from_slice(&draw.vertex_colors.get(i).copied().unwrap_or([1.; 4])); } gl.buffer_data_u8_slice( glow::ARRAY_BUFFER, std::slice::from_raw_parts(data.as_ptr().cast(), data.len() * 4), glow::STREAM_DRAW, ); gl.buffer_data_u8_slice( glow::ELEMENT_ARRAY_BUFFER, std::slice::from_raw_parts(draw.indices.as_ptr().cast(), draw.indices.len() * 4), glow::STREAM_DRAW, ); gl.uniform_4_f32( gl.get_uniform_location(p, "u_color").as_ref(), draw.color[0], draw.color[1], draw.color[2], draw.color[3], ); gl.uniform_1_f32( gl.get_uniform_location(p, "u_specular").as_ref(), draw.specular, ); gl.uniform_1_f32(gl.get_uniform_location(p, "u_cutoff").as_ref(), draw.cutoff); gl.uniform_1_i32( gl.get_uniform_location(p, "u_flat").as_ref(), i32::from(draw.flat_normals), ); let mut temp = Vec::new(); for (unit, (name, tex)) in [("u_albedo", &draw.albedo), ("u_shiny", &draw.shiny)] .into_iter() .enumerate() { gl.active_texture(glow::TEXTURE0 + unit as u32); gl.uniform_1_i32(gl.get_uniform_location(p, name).as_ref(), unit as i32); gl.uniform_1_i32( gl.get_uniform_location(p, &format!("{name}_enabled")) .as_ref(), i32::from(tex.is_some()), ); if let Some(tex) = tex { let t = gl.create_texture()?; temp.push(t); gl.bind_texture(glow::TEXTURE_2D, Some(t)); gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32, ); gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32, ); gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32, ); gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32, ); gl.tex_image_2d( glow::TEXTURE_2D, 0, glow::RGBA8 as i32, tex.width as i32, tex.height as i32, 0, glow::RGBA, glow::UNSIGNED_BYTE, Some(&tex.rgba), ); } else { gl.bind_texture(glow::TEXTURE_2D, None); } } gl.draw_elements( glow::TRIANGLES, draw.indices.len() as i32, glow::UNSIGNED_INT, 0, ); for t in temp { gl.delete_texture(t); } } gl.disable(glow::BLEND); gl.depth_mask(true); gl.color_mask(true, true, true, true); Ok(()) } unsafe fn readback_into_slice(gl: &glow::Context, w: u32, h: u32) -> Result { let pixels = w as usize * h as usize; let mut maps = Vec::new(); gl.pixel_store_i32(glow::PACK_ALIGNMENT, 1); for slot in 0..3 { let mut data = vec![0; pixels * 4]; gl.read_buffer(glow::COLOR_ATTACHMENT0 + slot); gl.read_pixels( 0, 0, w as i32, h as i32, glow::RGBA, glow::UNSIGNED_BYTE, glow::PixelPackData::Slice(&mut data), ); flip_rows(&mut data, w as usize * 4, h as usize); maps.push(data); } let mut depth = vec![1f32; pixels]; let bytes = std::slice::from_raw_parts_mut(depth.as_mut_ptr().cast(), pixels * 4); gl.read_pixels( 0, 0, w as i32, h as i32, glow::DEPTH_COMPONENT, glow::FLOAT, glow::PixelPackData::Slice(bytes), ); flip_rows(&mut depth, w as usize, h as usize); let error = gl.get_error(); if error != glow::NO_ERROR { return Err(format!("productie-GL fout 0x{error:x}")); } let specular = maps.pop().unwrap(); let normal = maps.pop().unwrap(); let diffuse = maps.pop().unwrap(); Ok(Buffers { width: w, height: h, diffuse, normal, specular, depth, }) } unsafe fn kick_pbo_readback( &mut self, gl: &glow::Context, w: u32, h: u32, reply: mpsc::SyncSender>, ) -> Result<(), String> { let slot = self.next_slot % GPU_PIPELINE_DEPTH; self.next_slot = self.next_slot.wrapping_add(1); if let Err(e) = self.ensure_pbo(gl, slot, w, h) { self.pbo_ok = false; // Sync fallback for this view. let buffers = Self::readback_into_slice(gl, w, h); let _ = reply.send(buffers.map_err(|err| format!("{e}; {err}"))); return Ok(()); } gl.pixel_store_i32(glow::PACK_ALIGNMENT, 1); let ring = &self.readback[slot]; for i in 0..3 { gl.bind_buffer(glow::PIXEL_PACK_BUFFER, ring.pbos[i]); gl.read_buffer(glow::COLOR_ATTACHMENT0 + i as u32); gl.read_pixels( 0, 0, w as i32, h as i32, glow::RGBA, glow::UNSIGNED_BYTE, glow::PixelPackData::BufferOffset(0), ); } gl.bind_buffer(glow::PIXEL_PACK_BUFFER, ring.pbos[3]); gl.read_pixels( 0, 0, w as i32, h as i32, glow::DEPTH_COMPONENT, glow::FLOAT, glow::PixelPackData::BufferOffset(0), ); gl.bind_buffer(glow::PIXEL_PACK_BUFFER, None); let fence = match gl.fence_sync(glow::SYNC_GPU_COMMANDS_COMPLETE, 0) { Ok(f) => Some(f), Err(_) => { self.pbo_ok = false; let buffers = Self::readback_into_slice(gl, w, h); let _ = reply.send(buffers); return Ok(()); } }; self.pending.push_back(PendingReadback { reply, width: w, height: h, slot, fence, }); Ok(()) } unsafe fn complete_oldest(&mut self, gl: &glow::Context) -> Result<(), String> { let Some(mut pending) = self.pending.pop_front() else { return Ok(()); }; if let Some(fence) = pending.fence.take() { // Block until this readback is done; newer PBOs stay in flight. let status = gl.client_wait_sync(fence, glow::SYNC_FLUSH_COMMANDS_BIT, i32::MAX); gl.delete_sync(fence); if status == glow::WAIT_FAILED || status == glow::TIMEOUT_EXPIRED { let _ = pending .reply .send(Err(format!("PBO-readback wachtstatus 0x{status:x}"))); return Ok(()); } } let w = pending.width; let h = pending.height; let pixels = w as usize * h as usize; let slot = pending.slot; let mut maps = Vec::with_capacity(3); gl.pixel_store_i32(glow::PACK_ALIGNMENT, 1); for i in 0..3 { let buf = self.readback[slot].pbos[i]; gl.bind_buffer(glow::PIXEL_PACK_BUFFER, buf); let ptr = gl.map_buffer_range( glow::PIXEL_PACK_BUFFER, 0, (pixels * 4) as i32, glow::MAP_READ_BIT, ); let mut data = vec![0u8; pixels * 4]; if ptr.is_null() { gl.bind_buffer(glow::PIXEL_PACK_BUFFER, None); let _ = pending.reply.send(Err("PBO map mislukt".into())); return Ok(()); } std::ptr::copy_nonoverlapping(ptr, data.as_mut_ptr(), pixels * 4); let _ = gl.unmap_buffer(glow::PIXEL_PACK_BUFFER); flip_rows(&mut data, w as usize * 4, h as usize); maps.push(data); } let buf = self.readback[slot].pbos[3]; gl.bind_buffer(glow::PIXEL_PACK_BUFFER, buf); let ptr = gl.map_buffer_range( glow::PIXEL_PACK_BUFFER, 0, (pixels * 4) as i32, glow::MAP_READ_BIT, ); let mut depth = vec![1f32; pixels]; if ptr.is_null() { gl.bind_buffer(glow::PIXEL_PACK_BUFFER, None); let _ = pending.reply.send(Err("PBO depth-map mislukt".into())); return Ok(()); } std::ptr::copy_nonoverlapping(ptr as *const f32, depth.as_mut_ptr(), pixels); let _ = gl.unmap_buffer(glow::PIXEL_PACK_BUFFER); gl.bind_buffer(glow::PIXEL_PACK_BUFFER, None); flip_rows(&mut depth, w as usize, h as usize); let specular = maps.pop().unwrap(); let normal = maps.pop().unwrap(); let diffuse = maps.pop().unwrap(); let _ = pending.reply.send(Ok(Buffers { width: w, height: h, diffuse, normal, specular, depth, })); Ok(()) } /// Complete finished (or all, when `force`) PBO readbacks and deliver replies. /// # Safety /// `gl` must be current on this thread. pub unsafe fn poll_readbacks(&mut self, gl: &glow::Context, force: bool) -> Result<(), String> { if force { while !self.pending.is_empty() { self.complete_oldest(gl)?; } return Ok(()); } while let Some(front) = self.pending.front() { let ready = match front.fence { None => true, Some(fence) => { let status = gl.client_wait_sync(fence, 0, 0); status == glow::ALREADY_SIGNALED || status == glow::CONDITION_SATISFIED } }; if !ready { break; } self.complete_oldest(gl)?; } Ok(()) } /// Submit a scene into the PBO ring; the reply arrives when the readback finishes. /// # Safety /// `gl` is current on the UI thread. GL state is reset for egui painting. pub unsafe fn submit( &mut self, gl: &glow::Context, scene: &Scene, reply: mpsc::SyncSender>, ) { let previous = gl.get_parameter_i32(glow::FRAMEBUFFER_BINDING); let mut vp = [0; 4]; gl.get_parameter_i32_slice(glow::VIEWPORT, &mut vp); let restore = |gl: &glow::Context| { gl.bind_framebuffer( glow::FRAMEBUFFER, std::num::NonZeroU32::new(previous as u32).map(glow::NativeFramebuffer), ); gl.viewport(vp[0], vp[1], vp[2], vp[3]); gl.bind_vertex_array(None); gl.use_program(None); gl.active_texture(glow::TEXTURE0); gl.disable(glow::DEPTH_TEST); gl.disable(glow::DEPTH_CLAMP); gl.disable(glow::SCISSOR_TEST); gl.enable(glow::BLEND); gl.pixel_store_i32(glow::PACK_ALIGNMENT, 4); gl.bind_buffer(glow::PIXEL_PACK_BUFFER, None); }; if scene.width == 0 || scene.height == 0 || scene.width > MAX_HIGHRES || scene.height > MAX_HIGHRES { let _ = reply.send(Err("ongeldige highres-afmetingen".into())); restore(gl); return; } if let Err(e) = self.poll_readbacks(gl, false) { let _ = reply.send(Err(e)); restore(gl); return; } while self.pending.len() >= GPU_PIPELINE_DEPTH { if let Err(e) = self.complete_oldest(gl) { let _ = reply.send(Err(e)); restore(gl); return; } } if let Err(e) = self.draw_scene(gl, scene) { let _ = reply.send(Err(e)); restore(gl); return; } if self.pbo_ok { if let Err(e) = self.kick_pbo_readback(gl, scene.width, scene.height, reply) { // kick_pbo consumes reply on success paths; on ensure failure it may have sent already. let _ = e; } } else { let buffers = Self::readback_into_slice(gl, scene.width, scene.height); let _ = reply.send(buffers); } restore(gl); } /// Synchronous render+readback (probes / tests). Flushes the pipeline first. /// # Safety /// `gl` is current on the UI thread. GL state is reset for egui painting. pub unsafe fn render(&mut self, gl: &glow::Context, scene: &Scene) -> Result { let (tx, rx) = mpsc::sync_channel(1); self.submit(gl, scene, tx); let _ = self.poll_readbacks(gl, true); rx.recv() .map_err(|_| "GPU-antwoord ontbreekt".to_string())? } } /// Covered highres pixels whose depth hit the fixed interval's near or far /// plane (GL depth clamp), i.e. geometry outside `[depth_near, depth_far]`. pub fn clamped_depth_pixels(raw: &Buffers) -> usize { raw.depth .iter() .enumerate() .filter(|(i, d)| { raw.diffuse.get(i * 4 + 3).is_some_and(|a| *a > 0) && (**d <= 0. || **d >= 1.) }) .count() } fn flip_rows(data: &mut [T], stride: usize, height: usize) { for y in 0..height / 2 { let split = (height - 1 - y) * stride; let (a, b) = data.split_at_mut(split); a[y * stride..(y + 1) * stride].swap_with_slice(&mut b[..stride]); } } const VERTEX: &str = r#"#version 330 layout(location=0)in vec3 a_position;layout(location=1)in vec3 a_normal;layout(location=2)in vec2 a_uv;layout(location=3)in vec4 a_color; uniform mat4 u_view;uniform mat4 u_projection;out vec3 v_normal;out vec3 v_view_position;out vec2 v_uv;out vec4 v_color; void main(){v_color=a_color;v_normal=mat3(u_view)*a_normal;v_uv=a_uv;vec4 position=u_view*vec4(a_position,1.0);v_view_position=position.xyz;gl_Position=u_projection*position;} "#; const FRAGMENT: &str = r#"#version 330 in vec3 v_normal;in vec3 v_view_position;uniform bool u_flat;in vec2 v_uv;in vec4 v_color;uniform vec4 u_color;uniform float u_cutoff;uniform vec3 u_light_direction; // deliberately unused: production maps are unlit uniform float u_specular; uniform sampler2D u_albedo;uniform sampler2D u_shiny;uniform bool u_albedo_enabled;uniform bool u_shiny_enabled; layout(location=0)out vec4 diffuse;layout(location=1)out vec4 normal;layout(location=2)out vec4 specular; vec3 encode_srgb(vec3 x){return mix(x*12.92,1.055*pow(max(x,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),x));} void main(){vec3 n=normalize(v_normal);if(u_flat){n=normalize(cross(dFdx(v_view_position),dFdy(v_view_position)));if(!gl_FrontFacing)n=-n;}vec4 c=u_color*v_color;if(u_albedo_enabled)c*=texture(u_albedo,v_uv);if(c.a Result { let native = crate::export_set::decode_depth_native(png_bytes, w, h, crate::export_set::MAX_TARGET, fmt) .map_err(|e| e.to_string())?; Ok(image( w, h, crate::export_set::depth_display_rgba(&native, fmt), )) } fn image(w: u32, h: u32, rgba: Vec) -> ImageBuf { ImageBuf { width: w, height: h, rgba, } } fn linear(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 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 } impl Buffers { pub fn validate(&self) -> Result<(), String> { let pixels = (self.width as usize) .checked_mul(self.height as usize) .ok_or("te veel pixels")?; if self.width == 0 || self.height == 0 || self.width > MAX_HIGHRES || self.height > MAX_HIGHRES || self.diffuse.len() != pixels * 4 || self.normal.len() != pixels * 4 || self.specular.len() != pixels * 4 || self.depth.len() != pixels || self .depth .iter() .any(|d| !d.is_finite() || !(0.0..=1.0).contains(d)) { return Err("ongeldige GPU-mapafmetingen/data".into()); } Ok(()) } } pub fn detail_map(raw: &Buffers) -> Result, String> { raw.validate()?; let w = raw.width as usize; let h = raw.height as usize; let mut out = vec![0; w * h]; for y in 0..h { for x in 0..w { let i = y * w + x; let mut edge = 0f32; for (nx, ny) in [ (x.saturating_sub(1), y), ((x + 1).min(w - 1), y), (x, y.saturating_sub(1)), (x, (y + 1).min(h - 1)), ] { let j = ny * w + nx; let a = raw.diffuse[i * 4 + 3] as f32 / 255.; let b = raw.diffuse[j * 4 + 3] as f32 / 255.; edge = edge.max((a - b).abs()); if a > 0. && b > 0. { for c in 0..3 { edge = edge.max( (linear(raw.diffuse[i * 4 + c]) - linear(raw.diffuse[j * 4 + c])).abs(), ); edge = edge.max( (raw.normal[i * 4 + c] as f32 - raw.normal[j * 4 + c] as f32).abs() / 255., ); } edge = edge.max(((raw.depth[i] - raw.depth[j]).abs() * 200.).min(1.)); } } out[i] = (edge * 255.).round().clamp(0., 255.) as u8; } } Ok(out) } pub fn detail_image(raw: &Buffers, detail: &[u8]) -> ImageBuf { let mut rgba = Vec::with_capacity(detail.len() * 4); for (i, v) in detail.iter().enumerate() { rgba.extend_from_slice(&[*v, *v, *v, raw.diffuse[i * 4 + 3]]); } image(raw.width, raw.height, rgba) } pub fn resolve(raw: &Buffers, detail: &[u8], w: u32, h: u32) -> Result { raw.validate()?; if w == 0 || h == 0 || w > MAX_HIGHRES / SCALE || h > MAX_HIGHRES / SCALE { return Err("ongeldige target-afmetingen".into()); } if raw.width != w * SCALE || raw.height != h * SCALE || detail.len() != raw.width as usize * raw.height as usize { return Err("ongeldige 8× resolve-afmetingen".into()); } let count = w as usize * h as usize; let mut diffuse = vec![0; count * 4]; let mut normals = diffuse.clone(); let mut specular = diffuse.clone(); let mut depth = vec![0u16; count * 2]; let mut depth_t = vec![0f32; count]; for y in 0..h { for x in 0..w { let i = (y * w + x) as usize; let mut closest = 1f32; let mut coverage = 0f32; for dy in 0..SCALE { for dx in 0..SCALE { let j = ((y * SCALE + dy) * raw.width + x * SCALE + dx) as usize; let a = raw.diffuse[j * 4 + 3] as f32 / 255.; coverage += a; if a > 0. && raw.depth[j].is_finite() { closest = closest.min(raw.depth[j]); } } } let alpha = (coverage / (SCALE * SCALE) as f32 * 255.) .round() .clamp(0., 255.) as u8; if alpha == 0 { continue; } let mut color = [0.; 3]; let mut n = [0.; 3]; let mut shiny = 0.; let mut sum = 0.; // One coherent front-surface cluster; never average foreground with a rear // surface. Edges get extra weight, and any thin covered feature survives. for dy in 0..SCALE { for dx in 0..SCALE { let j = ((y * SCALE + dy) * raw.width + x * SCALE + dx) as usize; let a = raw.diffuse[j * 4 + 3] as f32 / 255.; if a == 0. || raw.depth[j] > closest + 0.0025 { continue; } let weight = a * (1. + 4. * detail[j] as f32 / 255.); sum += weight; for c in 0..3 { color[c] += linear(raw.diffuse[j * 4 + c]) * weight; n[c] += (raw.normal[j * 4 + c] as f32 / 255. * 2. - 1.) * weight; } shiny += raw.specular[j * 4] as f32 * weight; } } if sum <= 0. { return Err("ongeldige depth/coverage in GPU-resultaat".into()); } n = normalize(n); let d = (closest.clamp(0., 1.) * 65535.).round() as u16; for c in 0..3 { diffuse[i * 4 + c] = srgb(color[c] / sum); normals[i * 4 + c] = ((n[c] * 0.5 + 0.5) * 255.).round().clamp(0., 255.) as u8; specular[i * 4 + c] = (shiny / sum).round().clamp(0., 255.) as u8; } for map in [&mut diffuse, &mut normals, &mut specular] { map[i * 4 + 3] = alpha; } depth[i * 2] = d; depth_t[i] = closest.clamp(0., 1.); depth[i * 2 + 1] = u16::from(alpha) * 257; } } Ok(Target { diffuse: image(w, h, diffuse), normal: image(w, h, normals), specular: image(w, h, specular), depth, depth_t, }) } /// Binary alpha (t1031u): coverage ≥ 0.5 becomes fully opaque in every map, /// anything below becomes all zeros in every map, so the masks always match. pub fn binarize_alpha(t: &mut Target) { for i in 0..t.depth_t.len() { if t.diffuse.rgba[i * 4 + 3] >= 128 { for map in [&mut t.diffuse, &mut t.normal, &mut t.specular] { map.rgba[i * 4 + 3] = 255; } t.depth[i * 2 + 1] = 65535; } else { for map in [&mut t.diffuse, &mut t.normal, &mut t.specular] { map.rgba[i * 4..i * 4 + 4].fill(0); } t.depth[i * 2] = 0; t.depth[i * 2 + 1] = 0; t.depth_t[i] = 0.; } } } fn safe_parent(root: &Path, relative: &Path) -> Result { let relative = relative .to_str() .ok_or("ongeldig uitvoerpad")? .replace('\\', "/"); let relative = crate::project::portable_relative(&relative)?; let relative = relative.as_str(); let canonical = root.canonicalize().map_err(|e| e.to_string())?; let path = root.join(relative); let parent = path.parent().ok_or("uitvoermap ontbreekt")?; let mut prefix = root.to_path_buf(); for part in Path::new(relative) .parent() .unwrap_or(Path::new("")) .components() { prefix.push(part); if !prefix.exists() { std::fs::create_dir(&prefix).map_err(|e| e.to_string())?; } let real = prefix.canonicalize().map_err(|e| e.to_string())?; if !real.starts_with(&canonical) { return Err("uitvoer verlaat projectmap".into()); } } if !parent .canonicalize() .map_err(|e| e.to_string())? .starts_with(canonical) { return Err("onveilig uitvoerpad".into()); } Ok(path) } pub(crate) fn atomic_file( root: &Path, relative: &Path, write: impl FnOnce(&mut std::fs::File) -> Result<(), String>, ) -> Result<(), String> { let path = safe_parent(root, relative)?; static TEMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let temp = path.with_extension(format!( "part-{}-{}", std::process::id(), TEMP_SEQ.fetch_add(1, Ordering::Relaxed) )); let mut owned = false; let result = (|| { let mut file = std::fs::OpenOptions::new() .write(true) .create_new(true) .open(&temp) .map_err(|e| e.to_string())?; owned = true; write(&mut file)?; file.sync_all().map_err(|e| e.to_string())?; std::fs::hard_link(&temp, &path).map_err(|e| e.to_string())?; Ok(()) })(); if owned { let _ = std::fs::remove_file(temp); } result } fn encode_rgba_png(img: &ImageBuf, diffuse_srgb: bool) -> Result, String> { if img.width == 0 || img.height == 0 || img.width > MAX_HIGHRES / SCALE || img.height > MAX_HIGHRES / SCALE || img.rgba.len() != img.width as usize * img.height as usize * 4 { return Err("ongeldige PNG-afmetingen".into()); } let mut cursor = std::io::Cursor::new(Vec::new()); { let mut encoder = png::Encoder::new(&mut cursor, img.width, img.height); encoder.set_color(png::ColorType::Rgba); encoder.set_depth(png::BitDepth::Eight); if diffuse_srgb { encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual); } let mut writer = encoder.write_header().map_err(|e| e.to_string())?; writer .write_image_data(&img.rgba) .map_err(|e| e.to_string())?; writer.finish().map_err(|e| e.to_string())?; } Ok(cursor.into_inner()) } fn encode_depth_png(w: u32, h: u32, depth: &[u16]) -> Result, String> { if w == 0 || h == 0 || w > MAX_HIGHRES / SCALE || h > MAX_HIGHRES / SCALE || depth.len() != w as usize * h as usize * 2 { return Err("ongeldige 16-bit depthafmetingen".into()); } let samples: Vec = depth.iter().flat_map(|x| x.to_be_bytes()).collect(); let mut cursor = std::io::Cursor::new(Vec::new()); { let mut encoder = png::Encoder::new(&mut cursor, w, h); encoder.set_color(png::ColorType::GrayscaleAlpha); encoder.set_depth(png::BitDepth::Sixteen); let mut writer = encoder.write_header().map_err(|e| e.to_string())?; writer .write_image_data(&samples) .map_err(|e| e.to_string())?; writer.finish().map_err(|e| e.to_string())?; } Ok(cursor.into_inner()) } /// Atomically publish an RGBA8 target PNG; returns the SHA-256 hex of the file bytes. pub fn write_rgba(root: &Path, relative: &Path, img: &ImageBuf) -> Result { let diffuse_srgb = relative .file_name() .and_then(|p| p.to_str()) .map(|p| p.ends_with("_diffuse.png")) .unwrap_or(false); let bytes = encode_rgba_png(img, diffuse_srgb)?; let digest = crate::digest::sha256_hex(&bytes); atomic_file(root, relative, |file| { use std::io::Write; file.write_all(&bytes).map_err(|e| e.to_string()) })?; Ok(digest) } /// Atomically publish a 16-bit gray+alpha depth PNG; returns SHA-256 hex. /// Encode depth pixels `(t, alpha)` (both 0..1) as a PNG in `fmt`. pub fn encode_depth_png_fmt( w: u32, h: u32, pixels: &[(f64, f64)], fmt: crate::camera::DepthFormat, ) -> Result, String> { if w == 0 || h == 0 || pixels.len() != w as usize * h as usize { return Err("ongeldige depthafmetingen".into()); } let mut samples = Vec::with_capacity(pixels.len() * fmt.bytes_per_pixel()); for &(t, a) in pixels { // Transparent pixels carry no depth (alpha rule of the map contract). let t = if a > 0. { t } else { 0. }; fmt.encode_pixel(t, a, &mut samples); } let mut cursor = std::io::Cursor::new(Vec::new()); { let mut encoder = png::Encoder::new(&mut cursor, w, h); encoder.set_color(fmt.color_type()); encoder.set_depth(fmt.bit_depth()); let mut writer = encoder.write_header().map_err(|e| e.to_string())?; writer .write_image_data(&samples) .map_err(|e| e.to_string())?; writer.finish().map_err(|e| e.to_string())?; } Ok(cursor.into_inner()) } /// Atomically publish a depth PNG in `fmt` from `(t, alpha)` pixels; returns SHA-256 hex. pub fn write_depth_fmt( root: &Path, relative: &Path, w: u32, h: u32, pixels: &[(f64, f64)], fmt: crate::camera::DepthFormat, ) -> Result { let bytes = encode_depth_png_fmt(w, h, pixels, fmt)?; let digest = crate::digest::sha256_hex(&bytes); atomic_file(root, relative, |file| { use std::io::Write; file.write_all(&bytes).map_err(|e| e.to_string()) })?; Ok(digest) } pub fn write_depth( root: &Path, relative: &Path, w: u32, h: u32, depth: &[u16], ) -> Result { let bytes = encode_depth_png(w, h, depth)?; let digest = crate::digest::sha256_hex(&bytes); atomic_file(root, relative, |file| { use std::io::Write; file.write_all(&bytes).map_err(|e| e.to_string()) })?; Ok(digest) } /// Atomic no-clobber publication of already encoded bytes under the run root. pub fn publish_bytes(root: &Path, relative: &Path, bytes: &[u8]) -> Result<(), String> { atomic_file(root, relative, |file| { use std::io::Write; file.write_all(bytes).map_err(|e| e.to_string()) }) } fn write_json(root: &Path, name: &str, value: serde_json::Value) -> Result<(), String> { use std::io::Write; let data = serde_json::to_vec_pretty(&value).map_err(|e| e.to_string())?; atomic_file(root, Path::new(name), |f| { f.write_all(&data).map_err(|e| e.to_string()) }) } pub struct GpuRequest { pub scene: Scene, reply: mpsc::SyncSender>, } impl GpuRequest { pub fn fail(self, error: String) { let _ = self.reply.send(Err(error)); } /// # Safety /// `gl` must be current on this UI thread. pub unsafe fn service(self, renderer: &mut GpuRenderer, gl: &glow::Context) { renderer.submit(gl, &self.scene, self.reply); } } /// Latest-wins live preview frame (Tim t1064u). The render worker overwrites this /// slot without waiting for the UI; the UI may skip intermediate frames. #[derive(Debug, Clone)] pub struct PreviewFrame { pub image: ImageBuf, pub label: String, pub revision: u64, } #[derive(Clone, Default)] pub struct PreviewSlot(Arc>>); impl PreviewSlot { /// Overwrite the slot. Critical section is only the swap — never texture upload. pub fn publish(&self, image: ImageBuf, label: String) { if let Ok(mut guard) = self.0.lock() { let revision = guard .as_ref() .map(|f| f.revision.wrapping_add(1)) .unwrap_or(1); *guard = Some(PreviewFrame { image, label, revision, }); } } /// Take the newest frame if any (UI side). Returns None when unchanged/empty. pub fn take(&self) -> Option { self.0.lock().ok()?.take() } /// Apply a taken frame into GUI state (revision/label/pixels). pub fn apply_to(&self, ui: &mut crate::render_job::RenderUiState) { if let Some(frame) = self.take() { ui.preview_label = frame.label; ui.preview_revision = frame.revision; ui.preview = Some(frame.image); } } } #[derive(Default)] struct Pending { pass: Option, progress: Option, terminal: Option, } #[derive(Clone, Default)] pub struct Mailbox(Arc>); impl Mailbox { pub fn publish(&self, event: RenderEvent) { if let Ok(mut state) = self.0.lock() { match event { RenderEvent::Pass { .. } => { state.pass = Some(event); state.progress = None; } RenderEvent::Step { .. } => state.progress = Some(event), _ => state.terminal = Some(event), } } } pub fn drain(&self) -> Vec { let mut events = Vec::new(); if let Ok(mut s) = self.0.lock() { events.extend(s.pass.take()); events.extend(s.progress.take()); events.extend(s.terminal.take()); } events } } pub struct RenderThread { pub mailbox: Mailbox, /// Latest rendered preview image; independent of the progress mailbox (t1064u). pub preview: PreviewSlot, pub gpu: mpsc::Receiver, pub cancel: Arc, pub handle: Option>, } impl Drop for RenderThread { fn drop(&mut self) { self.cancel.store(true, Ordering::Relaxed); } } /// Where a production batch writes: a fresh run directory, or an existing /// incomplete run that is recovered after contract and checksum verification. #[derive(Debug, Clone, PartialEq, Eq)] pub enum RunTarget { Fresh, Resume(PathBuf), } pub fn spawn(job: RenderJob, ctx: Option) -> RenderThread { spawn_target(job, RunTarget::Fresh, ctx) } /// Resume a selected incomplete run (see `resume`). pub fn spawn_resume( job: RenderJob, run_dir: PathBuf, ctx: Option, ) -> RenderThread { spawn_target(job, RunTarget::Resume(run_dir), ctx) } fn spawn_target( job: RenderJob, target: RunTarget, ctx: Option, ) -> RenderThread { let mailbox = Mailbox::default(); let output = mailbox.clone(); let preview = PreviewSlot::default(); let preview_w = preview.clone(); let cancel = Arc::new(AtomicBool::new(false)); let flag = cancel.clone(); let (tx, gpu) = mpsc::channel(); let handle = thread::spawn(move || { // Progress/terminal go through the mailbox and may request a repaint. // Preview pixels go only to PreviewSlot (latest-wins) and never block // the worker on texture upload or UI ack (Tim t1064u). let mut emit = |event: RenderEvent| { match event { RenderEvent::Pass { index, total, label, image: Some(image), } => { preview_w.publish(image, label.clone()); output.publish(RenderEvent::Pass { index, total, label, image: None, }); // No request_repaint here: the UI pulls the slot on its own // frames (GPU service already keeps the loop alive). } other => { output.publish(other); if let Some(ctx) = &ctx { ctx.request_repaint(); } } } }; let result = execute_target(job, target, &tx, &flag, ctx.as_ref(), &mut emit); if let Err(error) = result { if flag.load(Ordering::Relaxed) { emit(RenderEvent::Cancelled); } else { emit(RenderEvent::Failed(error)); } } }); RenderThread { mailbox, preview, gpu, cancel, handle: Some(handle), } } /// The immutable export contract of a job: validated/resolved job, plan, /// set token and the exact `render.json` document. pub struct Contract { pub job: RenderJob, pub plan: Plan, pub set: String, pub render_doc: serde_json::Value, } /// Validate a job and build its `render.json` contract without touching disk. pub fn contract(mut job: RenderJob) -> Result { job.framing.validate()?; crate::camera::DepthFormat::new(job.depth_format.bits, job.depth_format.encoding)?; let pivot = crate::camera::resolve(&job.character, job.framing); job.framing = pivot.framing; if job.target_width == 0 || job.target_height == 0 || job.target_width > MAX_HIGHRES / SCALE || job.target_height > MAX_HIGHRES / SCALE { return Err("8× highres vereist targetafmetingen 1–256".into()); } if !["male", "female"].contains(&job.sex.as_str()) { return Err("ongeldig geslacht".into()); } if let Some(p) = &job.preset { if job.character.is_static() { return Err( "statisch lichaam zonder skeleton: equipment kan niet aan een bot hangen".into(), ); } p.validate()?; if !p.applies[usize::from(job.sex == "female")] { return Err("equipment niet geldig voor dit geslacht".into()); } if p.attachments.is_empty() { return Err("equipment heeft geen onderdelen".into()); } } let plan = plan(&job.character, job.fps)?; let output_bytes = u64::from(plan.images) * u64::from(job.target_width) * u64::from(job.target_height) * 16; if output_bytes > 16 * 1024 * 1024 * 1024 { return Err("batch groter dan 16 GiB ongecomprimeerde targetmaps".into()); } let set = job .preset .as_ref() .map(|p| p.name.as_str()) .unwrap_or("body") .to_string(); // Refuse up front when the per-map atlas would exceed the PNG/GPU limit, // instead of rendering for hours and failing (or splitting) at the end. crate::atlas::AtlasLayout::for_plan(&plan, job.target_width, job.target_height)?; if job.project_id.is_empty() || job.project_id.len() > 128 { return Err("project.id ongeldig voor export-identiteit".into()); } if job.project_name.len() > 256 { return Err("project.name te lang voor export-identiteit".into()); } if !crate::digest::is_sha256_hex(&job.body_model.sha256) { return Err("body_model.sha256 ongeldig".into()); } crate::project::portable_relative(&job.body_model.path)?; match (&job.preset, &job.preset_sha256) { (None, None) => {} (Some(p), Some(hash)) if p.name == set && crate::digest::is_sha256_hex(hash) => {} _ => return Err("equipment fingerprint past niet bij de job".into()), } let equipment_preset = job.preset.as_ref().map(|p| { serde_json::json!({ "name": p.name, "sha256": job.preset_sha256.as_ref().expect("checked"), }) }); let clips_json = plan .clips .iter() .enumerate() .map(|(i, c)| { serde_json::json!({ "name": c.source_name, "token": c.token, "frames": c.frames, "duration": job.character.clips.get(i).map(|c| c.duration).unwrap_or(0.), }) }) .collect::>(); let (depth_near, depth_far) = crate::camera::depth_range(job.framing); let render_doc = serde_json::json!({ "format_version": crate::export_set::OUTPUT_META_VERSION, "status": "incomplete", "project": {"id": job.project_id, "name": job.project_name}, "sex": job.sex, "set": set, "body_model": {"path": job.body_model.path, "sha256": job.body_model.sha256}, "equipment_preset": equipment_preset, "target": [job.target_width, job.target_height], "fps": job.fps, "highres_scale": SCALE, "framing": job.framing, "pivot_source": pivot.source, "near": depth_near, "far": depth_far, "depth_near": depth_near, "depth_far": depth_far, "depth_encoding": job.depth_format.encoding.token(), "depth_bits": job.depth_format.bits, "semi_transparency": job.semi_transparency, "normal_space": "view (+Y up), PNG rows top-down", "camera_distance": crate::camera::CAMERA_DISTANCE, "yaw_zero": "camera on +Z", "pitches": preview::PRODUCTION_PITCHES, "yaws": production_yaws(), "yaw_step": 30, "sampling": "clip -> frame (t=frame/fps) -> pitch -> yaw -> pass; ceil(duration*fps) snapped within 4*f32_epsilon*max(1,samples) of an integer to omit floating terminal duplicates; root motion retained", "alpha": "straight, opaque/cutout, coverage resolve", "diffuse": "unlit sRGB albedo × vertex color, linear-light detail resolve", "depth": job.depth_format.description(), "specular": "linear scalar; body defaults zero", "pass_order": PASSES, "saved_maps": ["diffuse", "depth", "normal", "specular"], "clips": clips_json, "images": plan.images, "passes": plan.passes, "png_files": plan.files, }); Ok(Contract { job, plan, set, render_doc, }) } /// Saved map tokens in per-image publication order. pub const SAVED_ORDER: [&str; 4] = ["diffuse", "depth", "normal", "specular"]; /// Every planned target PNG (relative, `/`-separated) in render order, four per image. pub fn planned_paths(plan: &Plan, sex: &str, set: &str) -> Result, String> { let mut out = Vec::with_capacity(plan.files as usize); for s in samples(plan) { for map in SAVED_ORDER { let path = output_path( sex, set, &plan.clips[s.clip].token, s.frame, s.pitch, s.yaw, map, )?; out.push(path.to_str().ok_or("ongeldig PNG-pad")?.replace('\\', "/")); } } Ok(out) } #[cfg(test)] fn execute( job: RenderJob, gpu: &mpsc::Sender, cancel: &Arc, ctx: Option<&eframe::egui::Context>, emit: &mut dyn FnMut(RenderEvent), ) -> Result<(), String> { execute_target(job, RunTarget::Fresh, gpu, cancel, ctx, emit) } fn execute_target( job: RenderJob, target: RunTarget, gpu: &mpsc::Sender, cancel: &Arc, ctx: Option<&eframe::egui::Context>, emit: &mut dyn FnMut(RenderEvent), ) -> Result<(), String> { let Contract { mut job, plan, set, render_doc, } = contract(job)?; let planned = planned_paths(&plan, &job.sex, &set)?; std::fs::create_dir_all(&job.output).map_err(|e| e.to_string())?; for note in crate::resume::recover(&job.output)? { emit(RenderEvent::Step { index: 0, total: plan.passes, label: format!("Herstel: {note}"), }); } let session = match target { RunTarget::Fresh => { for name in crate::resume::discard_stale(&job.output, &job.sex, &set)? { emit(RenderEvent::Step { index: 0, total: plan.passes, label: format!("Oude onvoltooide render verwijderd: {name}"), }); } let stamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_err(|e| e.to_string())? .as_nanos(); let root = job.output.join(crate::resume::in_progress_name( stamp, std::process::id(), &job.sex, &set, )); std::fs::create_dir(&root).map_err(|e| e.to_string())?; let session = crate::resume::RunSession::fresh(&root)?; write_json(&root, "render.json", render_doc.clone())?; session } RunTarget::Resume(dir) => { let total = plan.passes; crate::resume::RunSession::resume( &job.output, &dir, &render_doc, &planned, &mut |done, all| { emit(RenderEvent::Step { index: 0, total, label: format!("Controle bestaande uitvoer: {done}/{all} PNG's"), }) }, )? } }; for clip in &job.character.clips { for reason in &clip.skipped { emit(RenderEvent::Step { index: 0, total: plan.passes, label: format!( "Let op: clip '{}': kanaal niet toegepast: {reason}", clip.name ), }); } } // Internal unique names select every source clip, even duplicate display names. for (i, c) in job.character.clips.iter_mut().enumerate() { c.name = format!("render-clip-{i}"); } let mut completed = 0u32; let mut clamped_views = 0u32; let mut clamped_pixels = 0u64; let verified = session.verified.clone(); let attempt = session.attempt; let root = session.root.clone(); let mut cache = equipment::TextureCache::default(); struct InflightGpu { rx: mpsc::Receiver>, paths: Vec, pose: String, } let seed_sha = verified.clone(); let mut atlas_cells: BTreeMap>> = BTreeMap::new(); let mut io = Some(IoQueue::start(session, seed_sha, Arc::clone(cancel))); let work = (|| { let io = io .as_ref() .ok_or_else(|| "IO-queue ontbreekt".to_string())?; let mut inflight: VecDeque = VecDeque::new(); let drain_one = |item: InflightGpu, completed: &mut u32, clamped_views: &mut u32, clamped_pixels: &mut u64, atlas_cells: &mut BTreeMap>>, io: &IoQueue, job: &RenderJob, render_doc: &serde_json::Value, plan_passes: u32, cancel: &AtomicBool, emit: &mut dyn FnMut(RenderEvent)| -> Result<(), String> { let raw = loop { match item.rx.recv_timeout(std::time::Duration::from_millis(100)) { Ok(result) => break result?, Err(mpsc::RecvTimeoutError::Timeout) => { if cancel.load(Ordering::Relaxed) { return Err("geannuleerd".into()); } } Err(_) => return Err("GPU-service gestopt".into()), } }; let pose = &item.pose; let paths = &item.paths; let clamped = clamped_depth_pixels(&raw); if clamped > 0 { *clamped_views += 1; *clamped_pixels += clamped as u64; emit(RenderEvent::Step { index: *completed, total: plan_passes, label: format!( "Waarschuwing: {pose} · {clamped} highres-pixels buiten dieptebereik {:.2}–{:.2} m (geklemd)", render_doc["depth_near"].as_f64().unwrap_or(0.), render_doc["depth_far"].as_f64().unwrap_or(0.) ), }); } *completed += 1; emit(RenderEvent::Pass { index: *completed - 1, total: plan_passes, label: format!("{pose} · highres"), image: Some(image(raw.width, raw.height, raw.diffuse.clone())), }); if cancel.load(Ordering::Relaxed) { return Err("geannuleerd".into()); } emit(RenderEvent::Step { index: *completed, total: plan_passes, label: format!("{pose} · detail"), }); let detail = detail_map(&raw)?; *completed += 1; emit(RenderEvent::Pass { index: *completed - 1, total: plan_passes, label: format!("{pose} · detail"), image: Some(detail_image(&raw, &detail)), }); if cancel.load(Ordering::Relaxed) { return Err("geannuleerd".into()); } emit(RenderEvent::Step { index: *completed, total: plan_passes, label: format!("{pose} · target-res / diffuse"), }); let mut target = resolve(&raw, &detail, job.target_width, job.target_height)?; if !job.semi_transparency { binarize_alpha(&mut target); } let depth_placeholder = image(job.target_width, job.target_height, Vec::new()); for ((map, mut preview), rel) in [ ("diffuse", target.diffuse.clone()), ("depth", depth_placeholder), ("normal", target.normal.clone()), ("specular", target.specular.clone()), ] .into_iter() .zip(paths.iter()) { if cancel.load(Ordering::Relaxed) { return Err("geannuleerd".into()); } emit(RenderEvent::Step { index: *completed, total: plan_passes, label: format!("{pose} · {map}"), }); debug_assert!(rel.ends_with(&format!("_{map}.png"))); let bytes = if map == "depth" { let pixels: Vec<(f64, f64)> = target .depth_t .iter() .enumerate() .map(|(i, t)| (f64::from(*t), f64::from(target.depth[i * 2 + 1]) / 65535.)) .collect(); let bytes = encode_depth_png_fmt( job.target_width, job.target_height, &pixels, job.depth_format, )?; preview = stored_depth_preview( &bytes, job.target_width, job.target_height, job.depth_format, )?; bytes } else { encode_rgba_png(&preview, map == "diffuse")? }; atlas_cells.insert(rel.clone(), Arc::new(bytes.clone())); // Publish per map (ordered) so cancel mid-view still leaves // already-finished maps on disk for Resume, like before. io.submit(ViewPublish { maps: vec![EncodedMap { rel: rel.clone(), bytes, }], })?; *completed += 1; let pass = if map == "diffuse" { "target-res / diffuse" } else { map }; emit(RenderEvent::Pass { index: *completed - 1, total: plan_passes, label: format!("{pose} · {pass}"), image: Some(preview), }); } Ok(()) }; for (index, sample) in samples(&plan).enumerate() { let clip = &plan.clips[sample.clip]; let frame = sample.frame; let pitch = sample.pitch; let yaw = sample.yaw; let paths = planned[index * 4..index * 4 + 4].to_vec(); if cancel.load(Ordering::Relaxed) { return Err("geannuleerd".into()); } let pose = format!( "{} {set} · {} f{frame}/{} · p{pitch} y{yaw}", job.sex, clip.source_name, clip.frames ); if paths.iter().all(|p| verified.contains_key(p)) { completed += PASSES.len() as u32; emit(RenderEvent::Step { index: completed, total: plan.passes, label: format!("{pose} · al aanwezig, checksums kloppen"), }); continue; } // Backpressure: keep at most GPU_PIPELINE_DEPTH views waiting on the GPU. while inflight.len() >= GPU_PIPELINE_DEPTH { let item = inflight.pop_front().unwrap(); drain_one( item, &mut completed, &mut clamped_views, &mut clamped_pixels, &mut atlas_cells, &io, &job, &render_doc, plan.passes, cancel.as_ref(), emit, )?; } let key = if job.character.clips.is_empty() { String::new() } else { format!("render-clip-{}", sample.clip) }; emit(RenderEvent::Step { index: completed, total: plan.passes, label: format!("{pose} · highres"), }); let scene = scene( &job, &key, frame as f32 / job.fps as f32, pitch as f32, yaw as f32, &mut cache, )?; let (tx, rx) = mpsc::sync_channel(1); gpu.send(GpuRequest { scene, reply: tx }) .map_err(|_| "GPU-service gesloten")?; if let Some(ctx) = ctx { ctx.request_repaint(); } inflight.push_back(InflightGpu { rx, paths, pose }); } while let Some(item) = inflight.pop_front() { drain_one( item, &mut completed, &mut clamped_views, &mut clamped_pixels, &mut atlas_cells, &io, &job, &render_doc, plan.passes, cancel.as_ref(), emit, )?; } Ok(()) })(); // Always drain the IO queue so cancel keeps journaled PNGs (Resume). let io_finish = io.take().map(|q| q.finish()); let (png_sha256, io_error) = match io_finish { Some(Ok((_session, digests))) => (digests, None), Some(Err(e)) => (BTreeMap::new(), Some(e)), None => (BTreeMap::new(), Some("IO-queue ontbreekt".into())), }; if let Err(error) = work { let name = if cancel.load(Ordering::Relaxed) { "cancelled.json" } else { "failed.json" }; let _ = write_json( &root, name, serde_json::json!({ "error": error, "completed_passes": completed, "total_passes": plan.passes, "png_files_done": png_sha256.len(), "png_files": plan.files, "attempt": attempt, }), ); return Err(error); } if let Some(e) = io_error { let _ = write_json( &root, "failed.json", serde_json::json!({ "error": e, "completed_passes": completed, "total_passes": plan.passes, "png_files_done": png_sha256.len(), "png_files": plan.files, "attempt": attempt, }), ); return Err(e); } if png_sha256.len() as u32 != plan.files { let error = format!( "checksumtelling {} wijkt af van geplande {} PNGs", png_sha256.len(), plan.files ); let _ = write_json( &root, "failed.json", serde_json::json!({ "error": error, "completed_passes": completed, "total_passes": plan.passes, "png_files_done": png_sha256.len(), "png_files": plan.files, "attempt": attempt, }), ); return Err(error); } let atlas = crate::atlas::write_atlases( &crate::atlas::AtlasJob { root: &root, render_doc: &render_doc, plan: &plan, sex: &job.sex, set: &set, cell_width: job.target_width, cell_height: job.target_height, png_sha256: &png_sha256, attempt, cancel: cancel.as_ref(), trim: job.atlas_trim, cells: Some(&atlas_cells), }, &mut |label| { emit(RenderEvent::Step { index: completed, total: plan.passes, label, }) }, )?; let identity = crate::export_set::complete_identity_json(&render_doc)?; // In-progress completion marker only; removed again by // strip_bookkeeping once the .ppda (which embeds the export // contract and per-PNG checksums) is verified (Tim t993u/t994u). write_json( &root, crate::resume::COMPLETE_MARKER, serde_json::json!({ "format_version": crate::export_set::OUTPUT_META_VERSION, "status": "complete", "identity": identity, "passes": completed, "png_files": plan.files, "png_sha256": png_sha256, "atlas": atlas.to_json(), "depth_clamped": { "views": clamped_views, "highres_pixels": clamped_pixels, "note": "rendered this attempt; kept files from a resumed run are not re-counted", }, }), )?; if clamped_views > 0 { emit(RenderEvent::Step { index: completed, total: plan.passes, label: format!( "Waarschuwing: diepte geklemd in {clamped_views} beelden ({clamped_pixels} highres-pixels buiten het vaste bereik)" ), }); } // Verify the finished build before it replaces the last good result. crate::atlas::AtlasSet::open_run(&root) .map_err(|e| format!("voltooide render faalt controle: {e}"))?; crate::export_set::ExportRun::open(&root) .map_err(|e| format!("voltooide render faalt controle: {e}"))?; // Lock, journal and resume bookkeeping stay out of the result folder. crate::resume::strip_bookkeeping(&root)?; let final_dir = crate::resume::promote( &job.output, &root, &crate::resume::result_dir_name(&job.sex, &set), )?; emit(RenderEvent::Finished { saved: Some(final_dir.display().to_string()), }); Ok(()) } /// Real readback regression: nearest-surface depth/normal/specular, preserved /// texture × vertex albedo/alpha and independence from opposed light directions. /// # Safety /// Run on a current disposable native context. pub unsafe fn map_regression_probe(gl: &glow::Context) -> Result<(), String> { let triangle = |z, normal, color, specular| Draw { positions: Arc::new(vec![[-0.9, -0.9, z], [0.9, -0.9, z], [0., 0.9, z]]), normals: Arc::new(vec![normal; 3]), uvs: Arc::new(vec![[0.; 2]; 3]), vertex_colors: Arc::new(Vec::new()), indices: vec![0, 1, 2], color, albedo: None, shiny: None, specular, cutoff: 0.001, flat_normals: false, blend: false, }; let mut renderer = GpuRenderer::default(); let mut scene = Scene { draws: vec![ triangle(-0.5, [0., 0., 1.], [1., 0., 0., 1.], 0.75), triangle(0.5, [1., 0., 0.], [0., 1., 0., 1.], 0.), ], view: preview::identity(), projection: preview::identity(), width: 32, height: 32, }; let result = (|| { let raw = renderer.render(gl, &scene)?; let i = 16 * 32 + 16; if raw.diffuse[i * 4..i * 4 + 4] != [255, 0, 0, 255] || (raw.depth[i] - 0.25).abs() > 1e-5 || raw.normal[i * 4..i * 4 + 3] != [128, 128, 255] || raw.specular[i * 4].abs_diff(191) > 1 { return Err(format!( "GPU map winner mismatch: color {:?} depth {} normal {:?} spec {}", &raw.diffuse[i * 4..i * 4 + 4], raw.depth[i], &raw.normal[i * 4..i * 4 + 4], raw.specular[i * 4] )); } scene.draws.reverse(); let reverse = renderer.render(gl, &scene)?; if reverse.diffuse != raw.diffuse || reverse.normal != raw.normal || reverse.depth != raw.depth { return Err("GPU map winner depends on submission order".into()); } scene.draws = vec![triangle(-0.5, [0., 0., 1.], [0.5, 0.75, 1., 1.], 0.)]; scene.draws[0].vertex_colors = Arc::new(vec![[0.5, 1., 0.5, 1.]; 3]); scene.draws[0].albedo = Some(Arc::new(equipment::Texture { content_hash: 99, width: 1, height: 1, rgba: vec![128, 255, 64, 192], })); let expected = [ srgb(linear(128) * 0.25), srgb(0.75), srgb(linear(64) * 0.5), 192, ]; // Production intentionally has no lighting input. This optional conventional // uniform is optimized out now; a future accidental lighting term using it // must fail these actual GPU readbacks under opposed light directions. for light in [[0., 0., 1.], [0., 0., -1.], [1., 0., 0.]] { let p = renderer.program.ok_or("missing probe program")?; gl.use_program(Some(p)); gl.uniform_3_f32( gl.get_uniform_location(p, "u_light_direction").as_ref(), light[0], light[1], light[2], ); let output = renderer.render(gl, &scene)?; let pixel = &output.diffuse[i * 4..i * 4 + 4]; if pixel.iter().zip(expected).any(|(a, b)| a.abs_diff(b) > 1) { return Err(format!("diffuse is lit or alpha/vertex/texture lost under {light:?}: {pixel:?} vs {expected:?}")); } } let detail = detail_map(&raw)?; let target = resolve(&raw, &detail, 4, 4)?; if target.diffuse.width != 4 || target.depth.len() != 32 || target .normal .rgba .chunks_exact(4) .filter(|p| p[3] > 0) .any(|p| { let n = p[..3] .iter() .map(|x| (*x as f32 / 255. * 2. - 1.).powi(2)) .sum::() .sqrt(); (n - 1.).abs() > 0.03 }) { return Err("target map dimensions/normals invalid".into()); } let mut flat = Scene { draws: vec![triangle(-0.5, [1., 0., 0.], [1., 0., 0., 1.], 0.)], view: preview::identity(), projection: preview::identity(), width: 32, height: 32, }; flat.draws[0].flat_normals = true; let derived = renderer.render(gl, &flat)?; if derived.normal[i * 4..i * 4 + 3] != [128, 128, 255] { return Err(format!( "flat normals are not geometry-derived: {:?}", &derived.normal[i * 4..i * 4 + 4] )); } println!("Production GPU maps PASS: nearest depth=0.25, normal=[128,128,255], specular=191, albedo texture×vertex={expected:?}, alpha preserved, diffuse identical under 3 opposed lights; target maps valid; missing source normals use actual GPU triangle derivatives"); Ok(()) })(); renderer.destroy(gl); result } #[cfg(test)] mod tests { use super::*; struct Temp(PathBuf); impl Temp { fn new() -> Self { let p = std::env::temp_dir().join(format!( "paperdoll-production-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); std::fs::create_dir(&p).unwrap(); Self(p) } } impl Drop for Temp { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } fn body() -> PreviewCharacter { preview::load_gltf_file( &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"), ) .unwrap() } fn raw(w: u32, h: u32) -> Buffers { let n = (w * h) as usize; Buffers { width: w, height: h, diffuse: [255, 0, 0, 255].repeat(n), normal: [128, 128, 255, 255].repeat(n), specular: [191, 191, 191, 255].repeat(n), depth: vec![0.25; n], } } #[test] fn job_counts_every_clip_frame_angle_and_exact_order() { let mut b = body(); let mut c = b.clips[0].clone(); c.name = "../A \u{2603}".into(); c.duration = 0.5; b.clips = vec![c.clone(), c]; let p = plan(&b, 3).unwrap(); // 2 clips × 2 frames × 60 views. assert_eq!((p.images, p.passes, p.files), (240, 1440, 960)); let s = samples(&p).collect::>(); assert_eq!(s.len(), 240); assert_eq!( s[0], Sample { clip: 0, frame: 0, pitch: -60, yaw: 0 } ); assert_eq!(s[11].yaw, 330); assert_eq!(s[12].pitch, -30); assert_eq!(s[24].pitch, 0); assert_eq!(s[36].pitch, 30); assert_eq!(s[59].pitch, 60); assert_eq!(s[60].frame, 1); assert_eq!(s[120].clip, 1); assert_ne!(p.clips[0].token, p.clips[1].token); assert!(p.clips.iter().all(|c| equipment::valid_name(&c.token))); assert_eq!( PASSES, [ "highres", "detail", "target-res / diffuse", "depth", "normal", "specular" ] ); b.clips.clear(); let p = plan(&b, 12).unwrap(); assert_eq!((p.images, p.files), (60, 240)); } #[test] fn floating_terminal_timestamp_does_not_duplicate_the_loop_endpoint() { let mut b = body(); b.clips.truncate(1); b.clips[0].duration = f32::from_bits(1f32.to_bits() + 1); assert_eq!(plan(&b, 2).unwrap().clips[0].frames, 2); b.clips[0].duration = 1.00001; assert_eq!(plan(&b, 2).unwrap().clips[0].frames, 3); } #[test] fn planner_rejects_invalid_or_unbounded_inputs() { let mut b = body(); assert!(plan(&b, 0).is_err()); assert!(plan(&b, 241).is_err()); for duration in [f32::NAN, f32::INFINITY, -1., 10000.] { b.clips[0].duration = duration; assert!(plan(&b, 12).is_err()); } } #[test] fn output_tokens_only_four_target_maps() { let expected = PathBuf::from("raw files/male/body/clip000-walk/f000012/p-60_y330_diffuse.png"); assert_eq!( output_path("male", "body", "clip000-walk", 12, -60, 330, "diffuse").unwrap(), expected ); assert!(output_path("male", "body", "clip000-walk", 0, 60, 0, "depth").is_ok()); assert!(output_path("male", "body", "clip000-walk", 0, 45, 0, "depth").is_err()); assert!(output_path("male", "body", "clip000-walk", 0, 90, 0, "depth").is_err()); for map in ["diffuse", "normal", "depth", "specular"] { assert!(output_path("female", "sword", "clip000-bind", 0, 30, 0, map).is_ok()); } for map in ["highres", "detail", "shiny", "../x"] { assert!(output_path("male", "body", "clip000-bind", 0, 0, 0, map).is_err()); } for token in ["../escape", "A", "a/b", "a\\b", ".", "a b"] { assert!(output_path("male", token, "clip000-bind", 0, 0, 0, "diffuse").is_err()); } assert!(output_path("male", "body", "walk", 0, 1, 0, "depth").is_err()); assert!(output_path("male", "body", "walk", 0, 0, 360, "depth").is_err()); } #[test] fn binary_alpha_when_off_unchanged_when_on() { // 2×1 target: left pixel 75% covered, right pixel 25% covered. let mut r = raw(16, 8); r.depth.fill(0.5); for y in 0..8 { for x in 0..16 { let j = (y * 16 + x) * 4; let covered = if x < 8 { x < 6 } else { x < 10 }; r.diffuse[j..j + 4].copy_from_slice(&[200, 100, 50, if covered { 255 } else { 0 }]); r.normal[j + 3] = r.diffuse[j + 3]; r.specular[j + 3] = r.diffuse[j + 3]; } } let d = detail_map(&r).unwrap(); let soft = resolve(&r, &d, 2, 1).unwrap(); let a = |t: &Target| [t.diffuse.rgba[3], t.diffuse.rgba[7]]; assert!(a(&soft)[0] > 128 && a(&soft)[0] < 255, "{:?}", a(&soft)); assert!(a(&soft)[1] > 0 && a(&soft)[1] < 128, "{:?}", a(&soft)); let mut hard = resolve(&r, &d, 2, 1).unwrap(); binarize_alpha(&mut hard); // Covered pixel: opaque in every map, values kept. for m in [&hard.diffuse, &hard.normal, &hard.specular] { assert_eq!(m.rgba[3], 255); assert_eq!(m.rgba[4..8], [0, 0, 0, 0]); } assert_eq!(hard.diffuse.rgba[..3], soft.diffuse.rgba[..3]); assert_eq!(hard.normal.rgba[..3], soft.normal.rgba[..3]); assert_eq!(hard.depth[..2], [soft.depth[0], 65535]); assert_eq!(hard.depth[2..], [0, 0]); assert_eq!(hard.depth_t[1], 0.); // Every alpha is 0 or 1 and the masks of all maps match. for i in 0..2 { let da = hard.diffuse.rgba[i * 4 + 3]; assert!(da == 0 || da == 255); assert_eq!(hard.normal.rgba[i * 4 + 3], da); assert_eq!(hard.specular.rgba[i * 4 + 3], da); assert_eq!(hard.depth[i * 2 + 1], u16::from(da) * 257); } } #[test] fn resolve_preserves_thin_foreground_and_matching_maps() { let mut r = raw(8, 8); r.diffuse = [0, 255, 0, 255].repeat(64); r.depth.fill(0.75); r.diffuse[..4].copy_from_slice(&[255, 0, 0, 255]); r.depth[0] = 0.25; let d = detail_map(&r).unwrap(); assert!(d.iter().any(|x| *x > 0)); let t = resolve(&r, &d, 1, 1).unwrap(); assert_eq!(t.diffuse.rgba, [255, 0, 0, 255]); assert_eq!(t.specular.rgba, [191, 191, 191, 255]); assert!((t.depth[0] as i32 - 16384).abs() <= 1); let n = &t.normal.rgba; let len = (0..3) .map(|c| (n[c] as f32 / 255. * 2. - 1.).powi(2)) .sum::() .sqrt(); assert!((len - 1.).abs() < 0.01); r.diffuse.fill(0); r.diffuse[..4].copy_from_slice(&[255, 0, 0, 255]); let t = resolve(&r, &detail_map(&r).unwrap(), 1, 1).unwrap(); assert_eq!(t.diffuse.rgba[3], 4); assert_eq!(t.depth[1], 1028); assert_eq!(t.normal.rgba[3], 4); assert_eq!(t.specular.rgba[3], 4); } fn meta_framing(meta: &serde_json::Value) -> CameraFraming { serde_json::from_value(meta["framing"].clone()).unwrap() } #[test] fn clamped_depth_counts_only_covered_pixels_on_the_planes() { let mut raw = Buffers { width: 4, height: 1, diffuse: vec![255; 16], normal: vec![0; 16], specular: vec![0; 16], depth: vec![0., 0.5, 1., 1.], }; raw.diffuse[15] = 0; // uncovered background at far plane assert_eq!(clamped_depth_pixels(&raw), 2); } #[test] fn invalid_map_dimensions_and_depth_fail_without_panics() { let mut r = raw(8, 8); r.normal.pop(); assert!(detail_map(&r).is_err()); assert!(resolve(&r, &[0; 64], 1, 1).is_err()); let mut r = raw(8, 8); r.depth[0] = f32::NAN; assert!(resolve(&r, &[0; 64], 1, 1).is_err()); let r = raw(8, 8); assert!(resolve(&r, &[0; 63], 1, 1).is_err()); assert!(resolve(&r, &[0; 64], 0, 1).is_err()); assert!(resolve(&r, &[0; 64], 257, 1).is_err()); } #[test] fn atomic_pngs_roundtrip_no_overwrite_or_temp_deletion() { let t = Temp::new(); let p = Path::new("map_diffuse.png"); write_rgba(&t.0, p, &image(1, 1, vec![128, 64, 32, 192])).unwrap(); let original = std::fs::read(t.0.join(p)).unwrap(); assert!(write_rgba(&t.0, p, &image(1, 1, vec![0; 4])).is_err()); assert_eq!(std::fs::read(t.0.join(p)).unwrap(), original); let mut reader = png::Decoder::new(std::io::Cursor::new(original)) .read_info() .unwrap(); assert!(reader.info().srgb.is_some()); let mut out = vec![0; reader.output_buffer_size().unwrap()]; reader.next_frame(&mut out).unwrap(); assert_eq!(out, [128, 64, 32, 192]); write_depth(&t.0, Path::new("depth.png"), 1, 1, &[16384, 32896]).unwrap(); let mut reader = png::Decoder::new(std::io::BufReader::new( std::fs::File::open(t.0.join("depth.png")).unwrap(), )) .read_info() .unwrap(); assert_eq!(reader.info().bit_depth, png::BitDepth::Sixteen); assert_eq!(reader.info().color_type, png::ColorType::GrayscaleAlpha); assert!(reader.info().srgb.is_none()); let mut out = vec![0; reader.output_buffer_size().unwrap()]; reader.next_frame(&mut out).unwrap(); assert_eq!(out, [64, 0, 128, 128]); let tmp = t.0.join("blocked.png") .with_extension(format!("part-{}", std::process::id())); std::fs::write(&tmp, b"keep").unwrap(); // A foreign/stale temp (e.g. left by a crashed process with a reused PID) // is never deleted or reused; per-write unique temp names still publish. write_rgba(&t.0, Path::new("blocked.png"), &image(1, 1, vec![0; 4])).unwrap(); assert_eq!(std::fs::read(tmp).unwrap(), b"keep"); let temps = std::fs::read_dir(&t.0) .unwrap() .filter_map(|e| e.ok()) .filter(|e| e.file_name().to_string_lossy().starts_with("blocked.part-")) .count(); assert_eq!(temps, 1, "own temp files are removed, foreign ones kept"); assert!(write_depth(&t.0, Path::new("bad.png"), 1, 1, &[0]).is_err()); assert!(write_rgba(&t.0, Path::new("bad.png"), &image(257, 1, vec![0; 1028])).is_err()); assert!(write_rgba(&t.0, Path::new("../escape.png"), &image(1, 1, vec![0; 4])).is_err()); } #[cfg(unix)] #[test] fn output_rejects_escaping_symlinks() { let root = Temp::new(); let outside = Temp::new(); std::os::unix::fs::symlink(&outside.0, root.0.join("link")).unwrap(); assert!(write_rgba( &root.0, Path::new("link/sub/x.png"), &image(1, 1, vec![0; 4]) ) .is_err()); assert!(!outside.0.join("sub").exists()); } #[test] fn preview_slot_latest_wins_without_blocking_on_slow_consumer() { use std::time::{Duration, Instant}; let slot = PreviewSlot::default(); let slot_c = slot.clone(); // Slow consumer: takes frames then sleeps *outside* the slot lock. let consumer = std::thread::spawn(move || { let mut seen = 0u64; let mut last_rev = 0u64; let end = Instant::now() + Duration::from_millis(200); while Instant::now() < end { if let Some(frame) = slot_c.take() { seen += 1; last_rev = frame.revision; std::thread::sleep(Duration::from_millis(5)); } else { std::thread::sleep(Duration::from_millis(1)); } } (seen, last_rev) }); let t0 = Instant::now(); let n = 2_000u64; for i in 0..n { slot.publish( ImageBuf { width: 1, height: 1, rgba: vec![i as u8, 0, 0, 255], }, format!("frame-{i}"), ); } let producer = t0.elapsed(); let (seen, last_rev) = consumer.join().unwrap(); // Producer must stay well under the consumer's cumulative sleep budget // (2000 * 5ms would be 10s if it waited on each frame). assert!( producer < Duration::from_millis(500), "producer blocked on preview consumer: {producer:?}" ); assert!(seen >= 1, "consumer saw no frames"); assert!( seen < n, "consumer must be allowed to skip frames (saw {seen}/{n})" ); // Latest-wins: if a frame remains, it must be the final publish. The // slow consumer may exit before taking it, or may have taken it already. if let Some(frame) = slot.take() { assert_eq!(frame.revision, n); assert_eq!(frame.label, format!("frame-{}", n - 1)); } assert!(last_rev >= 1 && last_rev <= n, "last_rev={last_rev}"); let _ = last_rev; } #[test] fn latest_mailbox_is_bounded_and_retains_terminal() { let m = Mailbox::default(); for i in 0..1000 { m.publish(RenderEvent::Pass { index: i, total: 1000, label: i.to_string(), image: Some(image(1, 1, vec![0; 4])), }); } m.publish(RenderEvent::Step { index: 999, total: 1000, label: "working".into(), }); m.publish(RenderEvent::Cancelled); let events = m.drain(); assert_eq!(events.len(), 3); assert!(matches!(&events[0], RenderEvent::Pass { index: 999, .. })); assert!(matches!(&events[2], RenderEvent::Cancelled)); assert!(m.drain().is_empty()); } #[test] fn fixed_body_and_equipment_camera_agree_and_body_is_not_a_draw() { let temp = Temp::new(); let mut job = RenderJob { depth_format: crate::camera::DepthFormat::GRAY16, atlas_trim: false, semi_transparency: true, character: body(), sex: "male".into(), preset: None, target_width: 64, target_height: 96, fps: 12, framing: CameraFraming { height_milli: 3100, center_y_milli: 1200, pivot_y_milli: Some(1200), depth_range_milli: None, }, output: temp.0.clone(), project_id: "test-project".into(), project_name: "Test".into(), body_model: SourceFingerprint { path: "assets/body/test.glb".into(), sha256: crate::digest::sha256_hex(b"test-body"), }, preset_sha256: None, }; let mut cache = equipment::TextureCache::default(); let b = scene(&job, "walk", 0.5, 30., 90., &mut cache).unwrap(); let mut preset = equipment::Preset::new("sword".into()); let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment/sword.obj"); equipment::import_obj(&mut preset, &fixture).unwrap(); let joint = job .character .joint_names .iter() .position(|n| n == "RightHand") .unwrap(); preset.bind(0, &job.character, 0, joint).unwrap(); let count = preset .attachments .iter() .map(|a| a.meshes.len()) .sum::(); job.preset = Some(preset); let e = scene(&job, "walk", 0.5, 30., 90., &mut cache).unwrap(); assert_eq!(e.draws.len(), count); assert_eq!(b.view, e.view); assert_eq!(b.projection, e.projection); assert_eq!((e.width, e.height), (512, 768)); assert_eq!(e.projection[5], 2. / 3.1); let center = point(&e.view, [0., 1.2, 0.]); assert!(center[0].abs() < 0.0001 && center[1].abs() < 0.0001); assert!((center[2] + 10.).abs() < 0.0001); let early = scene(&job, "attack", 0., 30., 90., &mut cache).unwrap(); let late = scene(&job, "attack", 0.5, 30., 90., &mut cache).unwrap(); assert_eq!(early.view, late.view); assert_eq!(early.projection, late.projection); assert_ne!(early.draws[0].positions, late.draws[0].positions); let mut invalid = e.clone(); invalid.view[0] = f32::NAN; assert!(invalid.validate().is_err()); } #[test] fn body_import_retains_texture_material_and_linear_vertex_color() { let temp = Temp::new(); let source = std::fs::read( Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"), ) .unwrap(); let glb = gltf::binary::Glb::from_slice(&source).unwrap(); let mut json: serde_json::Value = serde_json::from_slice(&glb.json).unwrap(); let mut bin = glb.bin.unwrap().into_owned(); while !bin.len().is_multiple_of(4) { bin.push(0); } let position = json["meshes"][0]["primitives"][0]["attributes"]["POSITION"] .as_u64() .unwrap() as usize; let count = json["accessors"][position]["count"].as_u64().unwrap() as usize; let offset = bin.len(); for _ in 0..count { for value in [0.25f32, 0.75, 0.5, 0.5] { bin.extend(value.to_le_bytes()); } } let view = json["bufferViews"].as_array().unwrap().len(); json["bufferViews"] .as_array_mut() .unwrap() .push(serde_json::json!({"buffer":0,"byteOffset":offset,"byteLength":count*16})); let accessor = json["accessors"].as_array().unwrap().len(); json["accessors"].as_array_mut().unwrap().push( serde_json::json!({"bufferView":view,"componentType":5126,"count":count,"type":"VEC4"}), ); json["meshes"][0]["primitives"][0]["attributes"]["COLOR_0"] = serde_json::json!(accessor); json["buffers"][0] = serde_json::json!({"uri":"geometry.bin","byteLength":bin.len()}); json["images"] = serde_json::json!([{"uri":"albedo.png"}]); json["textures"] = serde_json::json!([{"source":0}]); let material = json["meshes"][0]["primitives"][0]["material"] .as_u64() .unwrap() as usize; json["materials"][material]["pbrMetallicRoughness"]["baseColorTexture"] = serde_json::json!({"index":0}); json["materials"][material]["pbrMetallicRoughness"]["baseColorFactor"] = serde_json::json!([0.5, 0.75, 1., 1.]); std::fs::write(temp.0.join("geometry.bin"), bin).unwrap(); std::fs::write(temp.0.join("body.gltf"), serde_json::to_vec(&json).unwrap()).unwrap(); write_rgba( &temp.0, Path::new("albedo.png"), &image(1, 1, vec![128, 255, 64, 192]), ) .unwrap(); let body = preview::load_gltf_file(&temp.0.join("body.gltf")).unwrap(); assert_eq!(body.colors[0], [0.25, 0.75, 0.5, 0.5]); assert_eq!(body.surfaces[0].color, [0.5, 0.75, 1., 1.]); assert_eq!( body.surfaces[0].albedo.as_ref().unwrap().rgba, [128, 255, 64, 192] ); assert_eq!(body.colors.len(), body.positions.len()); assert_eq!(body.uvs.len(), body.positions.len()); assert!(body.surfaces[0].flat_normals); json["meshes"][0]["primitives"][0]["attributes"] .as_object_mut() .unwrap() .remove("NORMAL"); std::fs::write(temp.0.join("body.gltf"), serde_json::to_vec(&json).unwrap()).unwrap(); let missing = preview::load_gltf_file(&temp.0.join("body.gltf")).unwrap(); assert!(!missing.source_normals_complete); let job = RenderJob { depth_format: crate::camera::DepthFormat::GRAY16, atlas_trim: false, semi_transparency: true, character: missing, sex: "male".into(), preset: None, target_width: 1, target_height: 1, fps: 12, framing: CameraFraming::default(), output: temp.0.clone(), project_id: "test-project".into(), project_name: "Test".into(), body_model: SourceFingerprint { path: "assets/body/test.glb".into(), sha256: crate::digest::sha256_hex(b"test-body"), }, preset_sha256: None, }; assert!( scene( &job, "", 0., 0., 0., &mut equipment::TextureCache::default() ) .unwrap() .draws[0] .flat_normals ); } #[test] fn complete_and_cancelled_batches_have_truthful_atomic_outputs() { for stop in [false, true] { let tmp = Temp::new(); let mut b = body(); b.clips.clear(); let job = RenderJob { depth_format: crate::camera::DepthFormat::GRAY16, atlas_trim: false, semi_transparency: true, character: b, sex: "male".into(), preset: None, target_width: 1, target_height: 1, fps: 12, framing: CameraFraming::default(), output: tmp.0.clone(), project_id: "test-project".into(), project_name: "Test".into(), body_model: SourceFingerprint { path: "assets/body/test.glb".into(), sha256: crate::digest::sha256_hex(b"test-body"), }, preset_sha256: None, }; let flag = Arc::new(AtomicBool::new(false)); let (tx, rx) = mpsc::channel::(); let gpu = std::thread::spawn(move || { for request in rx { let _ = request .reply .send(Ok(raw(request.scene.width, request.scene.height))); } }); let mut labels = Vec::new(); let result = execute(job, &tx, &flag, None, &mut |event| { if let RenderEvent::Pass { label, .. } = event { labels.push(label); if stop && labels.len() == 3 { flag.store(true, Ordering::Relaxed); } } }); drop(tx); gpu.join().unwrap(); let run = std::fs::read_dir(&tmp.0) .unwrap() .next() .unwrap() .unwrap() .path(); // A finished folder has no bookkeeping JSON; a cancelled build keeps it. assert_eq!(run.join("render.json").exists(), stop); assert!(!run.join("complete.json").exists()); assert_eq!(run.join(crate::resume::JOURNAL).exists(), stop); assert_eq!(run.join(crate::resume::LOCK).exists(), stop); assert_eq!(run.join("cancelled.json").exists(), stop); assert_eq!(result.is_ok(), !stop); let ppda_export = (!stop).then(|| { crate::atlas::AtlasSet::open_run(&run) .unwrap() .ppda .export .unwrap() }); let meta: serde_json::Value = match &ppda_export { Some(export) => export.render.clone(), None => { serde_json::from_str(&std::fs::read_to_string(run.join("render.json")).unwrap()) .unwrap() } }; assert_eq!(meta["format_version"], 6); let (near, far) = crate::camera::depth_range(meta_framing(&meta)); assert_eq!(meta["depth_near"].as_f64().unwrap() as f32, near); assert_eq!(meta["depth_far"].as_f64().unwrap() as f32, far); assert_eq!(meta["near"], meta["depth_near"]); assert_eq!(meta["depth_encoding"], "gray"); assert_eq!(meta["depth_bits"], 16); assert_eq!(meta["pitches"], serde_json::json!([-60, -30, 0, 30, 60])); assert_eq!(meta["yaws"].as_array().unwrap().len(), 12); assert_eq!(meta["fps"], 12); assert_eq!(meta["project"]["id"], "test-project"); assert_eq!( meta["body_model"]["sha256"], crate::digest::sha256_hex(b"test-body") ); assert_eq!(meta["framing"]["pivot_y_milli"], 880); assert_eq!(meta["pivot_source"]["kind"], "bounds_center"); let (lo, hi) = ( meta["pivot_source"]["min_y_milli"].as_i64().unwrap(), meta["pivot_source"]["max_y_milli"].as_i64().unwrap(), ); assert_eq!((lo + hi) / 2, 880); assert_eq!( meta["saved_maps"] .as_array() .unwrap() .iter() .map(|v| v.as_str().unwrap()) .collect::>(), ["diffuse", "depth", "normal", "specular"] ); if !stop { let checksums = &ppda_export.as_ref().unwrap().png_sha256; assert_eq!(checksums.len(), 240); for (rel, digest) in checksums { assert!(rel.starts_with("raw files/"), "{rel}"); assert!(crate::digest::is_sha256_hex(digest)); let bytes = std::fs::read(run.join(rel)).unwrap(); assert_eq!(*digest, crate::digest::sha256_hex(&bytes)); } let mut top: Vec = std::fs::read_dir(&run) .unwrap() .map(|e| e.unwrap().file_name().to_string_lossy().to_string()) .filter(|n| !n.ends_with(".png")) .collect(); top.sort(); assert_eq!(top, ["male-body-atlas.ppda", "raw files"]); let opened = crate::export_set::ExportRun::open(&run).unwrap(); let sample = opened .load_map( "clip000-bind", 0, -60, 0, crate::export_set::MapKind::Diffuse, ) .unwrap(); assert_eq!(sample.width(), 1); // Atlases + .ppda: one per map, written before complete.json. for map in ["diffuse", "normal", "depth", "specular"] { let name = format!("male-body-{map}-atlas.png"); assert!(run.join(&name).is_file(), "{name}"); assert_eq!(opened.complete.atlas.maps[map].file, name); } assert!(!stop); let atlas = crate::atlas::AtlasSet::open_run(&run).unwrap(); assert_eq!(atlas.ppda.fps, 12); assert_eq!((atlas.layout.width, atlas.layout.height), (60, 1)); let loaded = atlas.load().unwrap(); for (pitch, yaw) in [(-60, 0), (0, 150), (60, 330)] { for kind in crate::atlas::ATLAS_MAPS { assert_eq!( loaded.cell("clip000-bind", 0, pitch, yaw, kind).unwrap(), opened .load_map("clip000-bind", 0, pitch, yaw, kind) .unwrap() ); } } } else { assert!(!run.join("male-body-atlas.ppda").exists()); assert!(!run.join("male-body-diffuse-atlas.png").exists()); } let frame = run.join("raw files/male/body/clip000-bind/f000000"); assert!(frame.join("p-60_y000_diffuse.png").exists()); assert_eq!(frame.join("p-60_y000_depth.png").exists(), !stop); assert!(!frame.join("p-60_y000_highres.png").exists()); assert!(!frame.join("p-60_y000_detail.png").exists()); if !stop { assert_eq!(labels.len(), 360); for labels in labels.chunks_exact(6) { for (label, pass) in labels.iter().zip([ "highres", "detail", "target-res / diffuse", "depth", "normal", "specular", ]) { assert!(label.ends_with(pass)); } } } } } // ---- Resume (design §7/§12.6) ---- fn resume_job(out: &Path) -> RenderJob { let mut b = body(); b.clips.clear(); RenderJob { depth_format: crate::camera::DepthFormat::GRAY16, atlas_trim: false, semi_transparency: true, character: b, sex: "male".into(), preset: None, target_width: 2, target_height: 2, fps: 12, framing: CameraFraming::default(), output: out.to_path_buf(), project_id: "resume-project".into(), project_name: "Resume".into(), body_model: SourceFingerprint { path: "assets/body/test.glb".into(), sha256: crate::digest::sha256_hex(b"resume-body"), }, preset_sha256: None, } } /// Deterministic pose-dependent fake GPU output (view matrix drives the colour). fn fake_raw(scene: &Scene) -> Buffers { let mut b = raw(scene.width, scene.height); let v = ((scene.view[0] * 97. + scene.view[2] * 31. + scene.view[6] * 13.).abs() * 50.) as u32 % 200; for px in b.diffuse.chunks_exact_mut(4) { px[1] = v as u8; px[2] = (255 - v) as u8; } b } /// Run a job; cancel after `stop` completed Pass events (None = run to the end). fn drive( job: RenderJob, target: RunTarget, stop: Option, ) -> (Result<(), String>, Vec) { let flag = Arc::new(AtomicBool::new(false)); let (tx, rx) = mpsc::channel::(); let gpu = std::thread::spawn(move || { for request in rx { let reply = fake_raw(&request.scene); let _ = request.reply.send(Ok(reply)); } }); let mut events = Vec::new(); let mut passes = 0; let result = execute_target(job, target, &tx, &flag, None, &mut |event| { if matches!(event, RenderEvent::Pass { .. }) { passes += 1; if stop == Some(passes) { flag.store(true, Ordering::Relaxed); } } events.push(event); }); drop(tx); gpu.join().unwrap(); (result, events) } fn runs(out: &Path) -> Vec { let mut v = std::fs::read_dir(out) .unwrap() .map(|e| e.unwrap().path()) .collect::>(); v.sort(); v } fn checksums(run: &Path) -> serde_json::Value { let export = crate::atlas::AtlasSet::open_run(run) .unwrap() .ppda .export .unwrap(); serde_json::to_value(export.png_sha256).unwrap() } fn tree(run: &Path) -> std::collections::BTreeMap { let mut out = std::collections::BTreeMap::new(); let mut stack = vec![run.to_path_buf()]; while let Some(dir) = stack.pop() { for e in std::fs::read_dir(dir).unwrap() { let p = e.unwrap().path(); if p.is_dir() { stack.push(p); } else { out.insert( p.clone(), crate::digest::sha256_hex(&std::fs::read(&p).unwrap()), ); } } } out } #[cfg(unix)] fn inode(p: &Path) -> u64 { use std::os::unix::fs::MetadataExt; std::fs::metadata(p).unwrap().ino() } #[test] #[cfg(unix)] fn cancel_resume_cancel_again_then_complete_equals_uninterrupted_run() { let reference_dir = Temp::new(); let (ok, _) = drive(resume_job(&reference_dir.0), RunTarget::Fresh, None); ok.unwrap(); let reference = checksums(&runs(&reference_dir.0)[0]); assert_eq!(reference.as_object().unwrap().len(), 240); let tmp = Temp::new(); // An earlier completed run in the same output must stay byte-identical. drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap(); let earlier = runs(&tmp.0)[0].clone(); let earlier_tree = tree(&earlier); std::thread::sleep(std::time::Duration::from_millis(2)); let (first, _) = drive(resume_job(&tmp.0), RunTarget::Fresh, Some(40)); assert!(first.is_err()); let run = runs(&tmp.0).into_iter().find(|r| *r != earlier).unwrap(); let cancelled: serde_json::Value = serde_json::from_slice(&std::fs::read(run.join("cancelled.json")).unwrap()).unwrap(); let done_first = cancelled["png_files_done"].as_u64().unwrap(); assert!(done_first > 0 && done_first < 240); assert_eq!(cancelled["attempt"], 0); let first_file = run.join("raw files/male/body/clip000-bind/f000000/p-60_y000_diffuse.png"); let first_inode = inode(&first_file); assert!(crate::export_set::ExportRun::open(&run).is_err()); let found = crate::resume::candidates(&tmp.0, &contract(resume_job(&tmp.0)).unwrap().render_doc) .unwrap(); assert_eq!(found.len(), 1); assert_eq!(found[0].state, "geannuleerd"); assert_eq!(found[0].journaled as u64, done_first); assert!(found[0].blocked.is_none()); // Second attempt: cancelled again after more work. let (second, events) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), Some(60)); assert!(second.is_err()); // Progress resumes at the verified work, not at zero, and skipped images are labelled. let first_step = events .iter() .find_map(|e| match e { RenderEvent::Step { index, label, .. } if label.contains("al aanwezig") => { Some(*index) } _ => None, }) .unwrap(); assert_eq!(first_step, 6); let cancelled2: serde_json::Value = serde_json::from_slice(&std::fs::read(run.join("cancelled.json")).unwrap()).unwrap(); assert_eq!(cancelled2["attempt"], 1); assert!(cancelled2["png_files_done"].as_u64().unwrap() > done_first); assert!(run.join("attempt-000-cancelled.json").exists()); assert_eq!( inode(&first_file), first_inode, "verified PNG was rewritten" ); // Cancelled/unfinished builds never touch the last good result. assert_eq!(tree(&earlier), earlier_tree); // Third attempt completes and replaces the result folder. let (third, _) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None); third.unwrap(); let built = run.clone(); let done = tmp.0.join("male-body-atlas"); assert_eq!(earlier, done); assert!(!built.exists()); assert_eq!( runs(&tmp.0), vec![done.clone()], "only the newest result remains" ); let first_file = done.join(first_file.strip_prefix(&built).unwrap()); let run = done; // Resume bookkeeping (markers, logs, journal, lock) stays out of the result. for name in [ "cancelled.json", "attempt-001-cancelled.json", "render.json", "complete.json", crate::resume::RESUME_LOG, crate::resume::JOURNAL, crate::resume::LOCK, ] { assert!(!run.join(name).exists(), "{name}"); } assert_eq!(inode(&first_file), first_inode); assert_eq!(checksums(&run), reference); crate::export_set::ExportRun::open(&run).unwrap(); // A completed run is no longer offered and cannot be resumed. let doc = contract(resume_job(&tmp.0)).unwrap().render_doc; assert!(crate::resume::candidates(&tmp.0, &doc).unwrap().is_empty()); let (again, _) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None); assert!(again.unwrap_err().contains("uitvoermap")); let (gone, _) = drive(resume_job(&tmp.0), RunTarget::Resume(built), None); assert!(gone.is_err()); } #[test] fn live_depth_preview_shows_the_written_depth_bytes_in_every_format() { 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 job = resume_job(&tmp.0); job.depth_format = fmt; let (result, events) = drive(job, RunTarget::Fresh, None); result.unwrap(); let run = tmp.0.join("male-body-atlas"); let export = crate::atlas::AtlasSet::open_run(&run) .unwrap() .ppda .export .unwrap(); let written: Vec> = export .png_sha256 .keys() .filter(|k| k.ends_with("_depth.png")) .map(|k| { let bytes = std::fs::read(run.join(k)).unwrap(); let native = crate::export_set::decode_depth_native( &bytes, 2, 2, crate::export_set::MAX_TARGET, fmt, ) .unwrap(); crate::export_set::depth_display_rgba(&native, fmt) }) .collect(); let shown: Vec<&ImageBuf> = events .iter() .filter_map(|e| match e { RenderEvent::Pass { label, image: Some(img), .. } if label.ends_with("· depth") => Some(img), _ => None, }) .collect(); assert_eq!(shown.len(), written.len(), "{fmt:?}"); for img in shown { assert_eq!((img.width, img.height), (2, 2)); assert!( written.contains(&img.rgba), "{fmt:?}: preview is not the written bytes" ); } } } } #[test] #[cfg(unix)] fn fresh_render_discards_older_unfinished_builds_of_the_same_target_only() { let tmp = Temp::new(); drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap(); let done = tmp.0.join("male-body-atlas"); let done_tree = tree(&done); assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(10)) .0 .is_err()); let first: Vec = runs(&tmp.0).into_iter().filter(|r| *r != done).collect(); assert_eq!(first.len(), 1); // Another target and a locked build of this target must survive. let other = tmp .0 .join(crate::resume::in_progress_name(1, 1, "female", "body")); std::fs::create_dir(&other).unwrap(); std::fs::write( other.join("render.json"), br#"{"sex":"female","set":"body"}"#, ) .unwrap(); let locked = tmp .0 .join(crate::resume::in_progress_name(2, 2, "male", "body")); std::fs::create_dir(&locked).unwrap(); let lock = std::fs::File::create(locked.join(crate::resume::LOCK)).unwrap(); lock.try_lock().unwrap(); std::thread::sleep(std::time::Duration::from_millis(2)); let (second, events) = drive(resume_job(&tmp.0), RunTarget::Fresh, Some(10)); assert!(second.is_err()); assert!(!first[0].exists(), "older cancelled build kept"); assert!(other.exists() && locked.exists()); assert!(events.iter().any(|e| matches!(e, RenderEvent::Step { label, .. } if label.contains("Oude onvoltooide render verwijderd")))); assert_eq!(tree(&done), done_tree, "finished result touched"); drop(lock); let doc = contract(resume_job(&tmp.0)).unwrap().render_doc; let offered = crate::resume::candidates(&tmp.0, &doc).unwrap(); let newest = offered.iter().find(|c| c.journaled > 0).unwrap(); assert!(newest.dir.exists() && newest.dir != locked && newest.dir != first[0]); // Resuming deletes nothing. let before = runs(&tmp.0); drive( resume_job(&tmp.0), RunTarget::Resume(newest.dir.clone()), Some(1), ) .0 .unwrap_err(); assert_eq!(runs(&tmp.0), before); } #[test] #[cfg(unix)] fn crash_recovery_regenerates_missing_corrupt_and_keeps_unjournaled_identical_files() { let reference_dir = Temp::new(); drive(resume_job(&reference_dir.0), RunTarget::Fresh, None) .0 .unwrap(); let reference = checksums(&runs(&reference_dir.0)[0]); let tmp = Temp::new(); assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(60)) .0 .is_err()); let run = runs(&tmp.0)[0].clone(); // Simulate a hard crash: no terminal marker, torn last journal line. std::fs::remove_file(run.join("cancelled.json")).unwrap(); let journal = std::fs::read_to_string(run.join(crate::resume::JOURNAL)).unwrap(); let mut lines = journal.lines().map(str::to_string).collect::>(); let torn: serde_json::Value = serde_json::from_str(lines.last().unwrap()).unwrap(); let torn_path = run.join(torn["path"].as_str().unwrap()); let last = lines.pop().unwrap(); let keep = lines.join("\n") + "\n" + &last[..last.len() / 2]; std::fs::write(run.join(crate::resume::JOURNAL), keep).unwrap(); let torn_inode = inode(&torn_path); // Corrupt one journaled PNG, delete another, leave a foreign temp file. let frame = run.join("raw files/male/body/clip000-bind/f000000"); let corrupt = frame.join("p-60_y030_normal.png"); std::fs::write(&corrupt, b"not a png").unwrap(); let missing = frame.join("p-60_y060_depth.png"); std::fs::remove_file(&missing).unwrap(); let stale = frame.join("p-60_y090_diffuse.part-999999-0"); std::fs::write(&stale, b"partial").unwrap(); let doc = contract(resume_job(&tmp.0)).unwrap().render_doc; let found = crate::resume::candidates(&tmp.0, &doc).unwrap(); assert_eq!(found[0].state, "afgebroken"); drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None) .0 .unwrap(); let done = tmp.0.join("male-body-atlas"); let rebase = |p: &Path| done.join(p.strip_prefix(&run).unwrap()); let (frame, missing, stale, torn_path) = ( rebase(&frame), rebase(&missing), rebase(&stale), rebase(&torn_path), ); let run = done.clone(); assert_eq!(checksums(&run), reference); crate::export_set::ExportRun::open(&run).unwrap(); assert!(missing.exists()); assert_eq!( std::fs::read(frame.join("p-60_y030_normal.png.corrupt-a001-0")).unwrap(), b"not a png" ); assert_eq!(std::fs::read(&stale).unwrap(), b"partial"); assert_eq!( inode(&torn_path), torn_inode, "identical unjournaled file was replaced" ); assert!(!run.join(crate::resume::RESUME_LOG).exists()); } #[test] fn contract_refuses_atlas_beyond_gpu_limit_before_rendering() { let tmp = Temp::new(); let mut job = resume_job(&tmp.0); job.character = body(); let total: f32 = job.character.clips.iter().map(|c| c.duration).sum(); assert!(total > 0.); // ~100 frame rows of 256 px > 16384, while staying under the 16 GiB cap. job.fps = (100. / total).ceil() as u32; job.target_width = 256; job.target_height = 256; let err = contract(job).err().unwrap(); assert!(err.contains("atlas") && err.contains("16384"), "{err}"); assert!(std::fs::read_dir(&tmp.0).unwrap().next().is_none()); } #[test] #[cfg(unix)] fn recover_cleans_and_places_builds_that_crashed_after_completion() { let tmp = Temp::new(); drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap(); let reference = tree(&runs(&tmp.0)[0]); let consumer = |dir: &Path| { let mut names: Vec = std::fs::read_dir(dir) .unwrap() .map(|e| e.unwrap().file_name().to_string_lossy().to_string()) .collect(); names.sort(); names }; let clean = consumer(&runs(&tmp.0)[0]); assert_eq!( clean, [ "male-body-atlas.ppda", "male-body-depth-atlas.png", "male-body-diffuse-atlas.png", "male-body-normal-atlas.png", "male-body-specular-atlas.png", "raw files", ] ); // (a) crash after complete.json, before cleanup: all bookkeeping present. // (b) crash after cleanup, before the swap: only consumer output. for with_bookkeeping in [true, false] { let built = tmp .0 .join(crate::resume::in_progress_name(7, 7, "male", "body")); std::fs::rename(tmp.0.join("male-body-atlas"), &built).unwrap(); if with_bookkeeping { for name in [ "render.json", "complete.json", crate::resume::JOURNAL, crate::resume::LOCK, crate::resume::RESUME_LOG, "attempt-000-cancelled.json", ] { std::fs::write(built.join(name), b"{}").unwrap(); } } let notes = crate::resume::recover(&tmp.0).unwrap(); assert_eq!(notes.len(), 1, "{notes:?}"); let done = tmp.0.join("male-body-atlas"); assert!(!built.exists()); assert_eq!(consumer(&done), clean); let rebase = |t: std::collections::BTreeMap| { t.into_values().collect::>() }; assert_eq!(rebase(tree(&done)), rebase(reference.clone())); crate::export_set::ExportRun::open(&done).unwrap(); } } #[test] #[cfg(unix)] fn crash_after_atlases_keeps_identical_and_quarantines_damaged_atlas() { let reference_dir = Temp::new(); drive(resume_job(&reference_dir.0), RunTarget::Fresh, None) .0 .unwrap(); let reference_run = runs(&reference_dir.0)[0].clone(); let tmp = Temp::new(); drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap(); // Simulate a crash after the atlases/.ppda were published but before // complete.json (so still the in-progress build), one atlas damaged. let run = tmp .0 .join(crate::resume::in_progress_name(1, 1, "male", "body")); std::fs::rename(&runs(&tmp.0)[0], &run).unwrap(); // Recreate the in-progress bookkeeping that existed before complete.json. let export = crate::atlas::AtlasSet::open_run(&run) .unwrap() .ppda .export .unwrap(); std::fs::write( run.join("render.json"), serde_json::to_vec_pretty(&export.render).unwrap(), ) .unwrap(); let journal: String = export .png_sha256 .iter() .map(|(path, sha)| serde_json::json!({"path": path, "sha256": sha}).to_string() + "\n") .collect(); std::fs::write(run.join(crate::resume::JOURNAL), journal).unwrap(); let diffuse = run.join("male-body-diffuse-atlas.png"); let normal = run.join("male-body-normal-atlas.png"); let normal_inode = inode(&normal); let good = std::fs::read(&diffuse).unwrap(); std::fs::remove_file(&diffuse).unwrap(); std::fs::write(&diffuse, b"damaged").unwrap(); drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None) .0 .unwrap(); let built = run; let run = tmp.0.join("male-body-atlas"); let (normal, diffuse) = ( run.join(normal.file_name().unwrap()), run.join(diffuse.file_name().unwrap()), ); assert!(!built.exists()); assert_eq!(inode(&normal), normal_inode, "identical atlas rewritten"); assert_eq!(std::fs::read(&diffuse).unwrap(), good); assert_eq!( std::fs::read(run.join("male-body-diffuse-atlas.png.corrupt-a001-0")).unwrap(), b"damaged" ); crate::atlas::AtlasSet::open_run(&run).unwrap(); crate::export_set::ExportRun::open(&run).unwrap(); // Byte-identical to an uninterrupted run (deterministic encoding). for name in [ "male-body-atlas.ppda", "male-body-diffuse-atlas.png", "male-body-depth-atlas.png", ] { assert_eq!( std::fs::read(run.join(name)).unwrap(), std::fs::read(reference_run.join(name)).unwrap(), "{name}" ); } } #[test] fn result_folder_swap_keeps_last_good_and_recovers_interrupted_swaps() { let tmp = Temp::new(); drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap(); let done = tmp.0.join("male-body-atlas"); let good = tree(&done); // A legacy run-* folder is left alone. let legacy = tmp.0.join("run-123-4"); std::fs::create_dir(&legacy).unwrap(); std::fs::write(legacy.join("x"), b"old").unwrap(); // Cancelled re-render: last good result untouched, build stays resumable. assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(10)) .0 .is_err()); assert_eq!(tree(&done), good); let doc = contract(resume_job(&tmp.0)).unwrap().render_doc; assert_eq!(crate::resume::candidates(&tmp.0, &doc).unwrap().len(), 1); // Crash after moving the old result aside but before placing the new one: // the old result is restored. let aside = tmp.0.join(".replaced-male-body-atlas-77"); std::fs::rename(&done, &aside).unwrap(); let notes = crate::resume::recover(&tmp.0).unwrap(); assert_eq!(notes.len(), 1, "{notes:?}"); assert!(!aside.exists()); assert_eq!(tree(&done), good); // Crash after completing a build but before the swap: promoted, old removed. let fresh = Temp::new(); drive(resume_job(&fresh.0), RunTarget::Fresh, None) .0 .unwrap(); let built = tmp .0 .join(crate::resume::in_progress_name(9, 9, "male", "body")); std::fs::rename(fresh.0.join("male-body-atlas"), &built).unwrap(); std::fs::write(tmp.0.join("male-body-atlas/marker-old"), b"1").unwrap(); let notes = crate::resume::recover(&tmp.0).unwrap(); assert_eq!(notes.len(), 1, "{notes:?}"); assert!(!built.exists()); assert!(!done.join("marker-old").exists()); crate::atlas::AtlasSet::open_run(&done).unwrap(); assert!(std::fs::read_dir(&tmp.0).unwrap().all(|e| !e .unwrap() .file_name() .to_string_lossy() .starts_with(".replaced-"))); assert_eq!(std::fs::read(legacy.join("x")).unwrap(), b"old"); } #[test] fn unjournaled_different_file_is_quarantined_not_overwritten() { let tmp = Temp::new(); assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(6)) .0 .is_err()); let run = runs(&tmp.0)[0].clone(); // A planned PNG appears that the journal never recorded and that differs. let foreign = run.join("raw files/male/body/clip000-bind/f000000/p-60_y030_specular.png"); std::fs::create_dir_all(foreign.parent().unwrap()).unwrap(); std::fs::write(&foreign, b"foreign").unwrap(); drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None) .0 .unwrap(); let done = tmp.0.join("male-body-atlas"); let foreign = done.join(foreign.strip_prefix(&run).unwrap()); let run = done; crate::export_set::ExportRun::open(&run).unwrap(); assert_eq!( std::fs::read(foreign.with_file_name("p-60_y030_specular.png.corrupt-a001-0")).unwrap(), b"foreign" ); } #[test] fn resume_refuses_mismatch_foreign_dirs_unknown_journal_and_locked_runs() { let tmp = Temp::new(); assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(12)) .0 .is_err()); let run = runs(&tmp.0)[0].clone(); let before = std::fs::read(run.join("cancelled.json")).unwrap(); // Contract drift: FPS, body bytes, target size. let mut job = resume_job(&tmp.0); job.fps = 24; let err = drive(job, RunTarget::Resume(run.clone()), None) .0 .unwrap_err(); assert!(err.contains("fps"), "{err}"); let mut job = resume_job(&tmp.0); job.body_model.sha256 = crate::digest::sha256_hex(b"edited body"); let err = drive(job, RunTarget::Resume(run.clone()), None) .0 .unwrap_err(); assert!(err.contains("body_model"), "{err}"); let mut job = resume_job(&tmp.0); job.target_width = 3; let doc = contract(job.clone()).unwrap().render_doc; let found = crate::resume::candidates(&tmp.0, &doc).unwrap(); assert!(found[0].blocked.as_ref().unwrap().contains("target")); assert!(drive(job, RunTarget::Resume(run.clone()), None).0.is_err()); // Refusals leave the run and its marker untouched. assert_eq!(std::fs::read(run.join("cancelled.json")).unwrap(), before); assert!(!run.join(crate::resume::RESUME_LOG).exists()); // Directory not directly inside the output directory. let other = Temp::new(); let err = drive(resume_job(&other.0), RunTarget::Resume(run.clone()), None) .0 .unwrap_err(); assert!(err.contains("uitvoermap"), "{err}"); // Locked by a running worker. let lock = std::fs::OpenOptions::new() .write(true) .open(run.join(crate::resume::LOCK)) .unwrap(); lock.lock().unwrap(); let err = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None) .0 .unwrap_err(); assert!(err.contains("run.lock"), "{err}"); let doc = contract(resume_job(&tmp.0)).unwrap().render_doc; assert!(crate::resume::candidates(&tmp.0, &doc).unwrap()[0] .blocked .is_some()); drop(lock); // Journal naming a path outside the plan. use std::io::Write; let mut j = std::fs::OpenOptions::new() .append(true) .open(run.join(crate::resume::JOURNAL)) .unwrap(); writeln!( j, "{}", serde_json::json!({"path":"../escape.png","sha256":crate::digest::sha256_hex(b"x")}) ) .unwrap(); let err = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None) .0 .unwrap_err(); assert!(err.contains("onbekend pad"), "{err}"); } }