BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / production.rs

production.rs

production.rs 126.7 KiB Download Raw
1//! Bounded orthographic production rendering. GPU work runs on the native
2//! current context, one request per UI update; sampling/filtering/PNG I/O run
3//! on the worker. The worker never waits for a preview acknowledgment.
4use crate::{
5 equipment,
6 preview::{self, Mat4, PreviewCharacter, PreviewControls},
7 project::CameraFraming,
8 render_job::{ImageBuf, RenderEvent},
9};
10use glow::HasContext;
11use std::{
12 path::{Path, PathBuf},
13 sync::{
14 atomic::{AtomicBool, Ordering},
15 mpsc, Arc, Mutex,
16 },
17 thread,
18};
19
20pub const SCALE: u32 = 8;
21pub const MAX_HIGHRES: u32 = 2048;
22pub const PASSES: [&str; 6] = [
23 "highres",
24 "detail",
25 "target-res / diffuse",
26 "depth",
27 "normal",
28 "specular",
29];
30#[derive(Clone)]
31pub struct Draw {
32 pub positions: Arc<Vec<[f32; 3]>>,
33 pub normals: Arc<Vec<[f32; 3]>>,
34 pub uvs: Arc<Vec<[f32; 2]>>,
35 pub vertex_colors: Arc<Vec<[f32; 4]>>,
36 pub indices: Vec<u32>,
37 pub color: [f32; 4],
38 pub albedo: Option<Arc<equipment::Texture>>,
39 pub shiny: Option<Arc<equipment::Texture>>,
40 pub specular: f32,
41 pub cutoff: f32,
42 pub flat_normals: bool,
43 /// glTF BLEND: drawn after the opaque draws, blended (design.md t1016u).
44 pub blend: bool,
45}
46#[derive(Clone)]
47pub struct Scene {
48 pub draws: Vec<Draw>,
49 pub view: Mat4,
50 pub projection: Mat4,
51 pub width: u32,
52 pub height: u32,
53}
54#[derive(Debug)]
55pub struct Buffers {
56 pub width: u32,
57 pub height: u32,
58 pub diffuse: Vec<u8>,
59 pub normal: Vec<u8>,
60 pub specular: Vec<u8>,
61 pub depth: Vec<f32>,
62}
63#[derive(Debug)]
64pub struct Target {
65 pub diffuse: ImageBuf,
66 pub normal: ImageBuf,
67 pub specular: ImageBuf,
68 pub depth: Vec<u16>,
69 /// Unquantized normalized depth t (0..1) per pixel, for the export encoding.
70 pub depth_t: Vec<f32>,
71}
72/// Content fingerprint of a source file used for a production job.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct SourceFingerprint {
75 pub path: String,
76 pub sha256: String,
77}
78
79#[derive(Clone)]
80pub struct RenderJob {
81 pub character: PreviewCharacter,
82 pub sex: String,
83 pub preset: Option<equipment::Preset>,
84 pub target_width: u32,
85 pub target_height: u32,
86 pub fps: u32,
87 pub framing: CameraFraming,
88 pub output: PathBuf,
89 pub project_id: String,
90 pub project_name: String,
91 pub body_model: SourceFingerprint,
92 /// SHA-256 of the encoded `.ppe` bytes when rendering equipment; None for body.
93 pub preset_sha256: Option<String>,
94 /// Depth PNG bit depth + encoding (project setting).
95 pub depth_format: crate::camera::DepthFormat,
96}
97#[derive(Debug, Clone, PartialEq)]
98pub struct ClipPlan {
99 pub source_name: String,
100 pub token: String,
101 pub frames: u32,
102}
103#[derive(Debug, Clone, PartialEq)]
104pub struct Plan {
105 pub clips: Vec<ClipPlan>,
106 pub images: u32,
107 pub passes: u32,
108 pub files: u32,
109}
110pub fn plan(body: &PreviewCharacter, fps: u32) -> Result<Plan, String> {
111 if fps == 0 || fps > 240 {
112 return Err("fps moet 1–240 zijn".into());
113 }
114 let clips = if body.clips.is_empty() {
115 vec![ClipPlan {
116 source_name: String::new(),
117 token: "clip000-bind".into(),
118 frames: 1,
119 }]
120 } else {
121 body.clips
122 .iter()
123 .enumerate()
124 .map(|(i, c)| {
125 let duration = c.duration;
126 if !duration.is_finite() || duration < 0. || duration * fps as f32 > 100000. {
127 return Err("ongeldige of te lange animatie".into());
128 }
129 // Exporter float timestamps commonly put a nominal 1-second
130 // endpoint one ULP above 1. Do not duplicate that terminal pose.
131 let samples = f64::from(duration) * f64::from(fps);
132 let nearest = samples.round();
133 let samples = if (samples - nearest).abs()
134 <= 4. * f64::from(f32::EPSILON) * samples.max(1.)
135 {
136 nearest
137 } else {
138 samples
139 };
140 Ok(ClipPlan {
141 source_name: c.name.clone(),
142 token: clip_token(i, &c.name),
143 frames: samples.ceil().max(1.) as u32,
144 })
145 })
146 .collect::<Result<Vec<_>, String>>()?
147 };
148 let frames = clips
149 .iter()
150 .try_fold(0u32, |n, c| n.checked_add(c.frames))
151 .ok_or("te veel frames")?;
152 let images = frames
153 .checked_mul(preview::PRODUCTION_VIEWS)
154 .ok_or("te veel beelden")?;
155 let passes = images.checked_mul(6).ok_or("te veel passes")?;
156 let files = images.checked_mul(4).ok_or("te veel bestanden")?;
157 if files > 500000 {
158 return Err("batch groter dan 500000 uitvoerbestanden".into());
159 }
160 Ok(Plan {
161 clips,
162 images,
163 passes,
164 files,
165 })
166}
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub struct Sample {
169 pub clip: usize,
170 pub frame: u32,
171 pub pitch: i32,
172 pub yaw: u32,
173}
174pub fn samples(plan: &Plan) -> impl Iterator<Item = Sample> + '_ {
175 plan.clips.iter().enumerate().flat_map(|(clip, c)| {
176 (0..c.frames).flat_map(move |frame| {
177 preview::PRODUCTION_PITCHES
178 .into_iter()
179 .flat_map(move |pitch| {
180 (0..12).map(move |y| Sample {
181 clip,
182 frame,
183 pitch,
184 yaw: y * 30,
185 })
186 })
187 })
188 })
189}
190
191/// Production yaw values in degrees, atlas column order within one pitch.
192pub fn production_yaws() -> [u32; 12] {
193 std::array::from_fn(|i| i as u32 * 30)
194}
195
196pub fn clip_token(index: usize, name: &str) -> String {
197 let slug: String = name
198 .chars()
199 .map(|c| {
200 if c.is_ascii_alphanumeric() {
201 c.to_ascii_lowercase()
202 } else {
203 '-'
204 }
205 })
206 .take(48)
207 .collect();
208 format!(
209 "clip{index:03}-{}",
210 if slug.is_empty() { "animation" } else { &slug }
211 )
212}
213pub fn output_path(
214 sex: &str,
215 set: &str,
216 clip: &str,
217 frame: u32,
218 pitch: i32,
219 yaw: u32,
220 map: &str,
221) -> Result<PathBuf, String> {
222 if !["male", "female"].contains(&sex)
223 || !(set == "body" || equipment::valid_name(set))
224 || !equipment::valid_name(clip)
225 || !preview::PRODUCTION_PITCHES.contains(&pitch)
226 || yaw >= 360
227 || !yaw.is_multiple_of(30)
228 || !["diffuse", "normal", "depth", "specular"].contains(&map)
229 {
230 return Err("ongeldig uitvoerpad/token".into());
231 }
232 Ok(PathBuf::from(crate::resume::RAW_DIR)
233 .join(sex)
234 .join(set)
235 .join(clip)
236 .join(format!("f{frame:06}"))
237 .join(format!("p{pitch:+03}_y{yaw:03}_{map}.png")))
238}
239pub use crate::camera::matrices as camera;
240
241fn point(m: &Mat4, p: [f32; 3]) -> [f32; 3] {
242 std::array::from_fn(|i| m[i] * p[0] + m[i + 4] * p[1] + m[i + 8] * p[2] + m[i + 12])
243}
244fn normalize(v: [f32; 3]) -> [f32; 3] {
245 let l = v.iter().map(|x| x * x).sum::<f32>().sqrt();
246 if l > 1e-10 {
247 v.map(|x| x / l)
248 } else {
249 [0., 0., 1.]
250 }
251}
252fn normal(m: &Mat4, n: [f32; 3]) -> [f32; 3] {
253 let a = [m[0], m[1], m[2]];
254 let b = [m[4], m[5], m[6]];
255 let c = [m[8], m[9], m[10]];
256 let cross = |a: [f32; 3], b: [f32; 3]| {
257 [
258 a[1] * b[2] - a[2] * b[1],
259 a[2] * b[0] - a[0] * b[2],
260 a[0] * b[1] - a[1] * b[0],
261 ]
262 };
263 let x = cross(b, c);
264 let y = cross(c, a);
265 let z = cross(a, b);
266 let det = a.iter().zip(x).map(|(a, b)| a * b).sum::<f32>();
267 if det.abs() < 1e-12 {
268 return normalize(n);
269 }
270 normalize(std::array::from_fn(|i| {
271 (x[i] * n[0] + y[i] * n[1] + z[i] * n[2]) / det
272 }))
273}
274pub fn scene(
275 job: &RenderJob,
276 clip: &str,
277 time: f32,
278 pitch: f32,
279 yaw: f32,
280 cache: &mut equipment::TextureCache,
281) -> Result<Scene, String> {
282 let body = &job.character;
283 let mut draws = Vec::new();
284 if let Some(preset) = &job.preset {
285 let controls = PreviewControls {
286 clip_name: clip.into(),
287 time,
288 ..Default::default()
289 };
290 for layer in equipment::layers_cached(
291 preset,
292 usize::from(job.sex == "female"),
293 body,
294 &controls,
295 cache,
296 )? {
297 let m = layer.model;
298 draws.push(Draw {
299 positions: Arc::new(layer.mesh.positions.iter().map(|p| point(&m, *p)).collect()),
300 normals: Arc::new(layer.mesh.normals.iter().map(|n| normal(&m, *n)).collect()),
301 uvs: Arc::new(
302 layer
303 .mesh
304 .uvs
305 .into_iter()
306 .map(|[u, v]| [u, 1. - v])
307 .collect(),
308 ),
309 vertex_colors: Arc::new(Vec::new()),
310 indices: layer.mesh.indices,
311 color: layer.mesh.color,
312 albedo: layer.albedo,
313 shiny: layer.shiny,
314 specular: layer.specular,
315 cutoff: 0.5,
316 flat_normals: false,
317 blend: false,
318 });
319 }
320 } else {
321 let bones = preview::sample_skin_matrices(body, clip, time);
322 let mut positions = Vec::with_capacity(body.positions.len());
323 let mut normals = Vec::with_capacity(body.positions.len());
324 for (i, p) in body.positions.iter().enumerate() {
325 let mut m = preview::identity();
326 if !bones.is_empty() && body.weights.len() == body.positions.len() {
327 m = [0.; 16];
328 let joints = body.joints.get(i).copied().unwrap_or([0; 4]);
329 for (j, w) in joints.into_iter().zip(body.weights[i]) {
330 let b = bones.get(j as usize).ok_or("ongeldige skinjoint")?;
331 for k in 0..16 {
332 m[k] += b[k] * w;
333 }
334 }
335 }
336 positions.push(point(&m, *p));
337 normals.push(normal(
338 &m,
339 body.normals.get(i).copied().unwrap_or([0., 1., 0.]),
340 ));
341 }
342 let positions = Arc::new(positions);
343 let normals = Arc::new(normals);
344 let uvs = Arc::new(body.uvs.clone());
345 let colors = Arc::new(body.colors.clone());
346 if body.surfaces.is_empty() {
347 draws.push(Draw {
348 positions,
349 normals,
350 uvs,
351 vertex_colors: colors,
352 indices: body.indices.clone(),
353 color: body.base_color,
354 albedo: None,
355 shiny: None,
356 specular: 0.,
357 cutoff: 0.001,
358 flat_normals: !body.source_normals_complete,
359 blend: false,
360 });
361 } else {
362 for surface in &body.surfaces {
363 draws.push(Draw {
364 positions: positions.clone(),
365 normals: normals.clone(),
366 uvs: uvs.clone(),
367 vertex_colors: colors.clone(),
368 indices: body.indices[surface.first..surface.first + surface.count].to_vec(),
369 color: surface.color,
370 albedo: surface.albedo.clone(),
371 shiny: surface.shiny.clone(),
372 specular: surface.specular,
373 cutoff: surface.cutoff,
374 flat_normals: surface.flat_normals,
375 blend: surface.blend,
376 });
377 }
378 }
379 }
380 let (view, projection) = camera(
381 crate::camera::resolve(body, job.framing).framing,
382 yaw,
383 pitch,
384 job.target_width as f32 / job.target_height as f32,
385 );
386 Ok(Scene {
387 draws,
388 view,
389 projection,
390 width: job.target_width * SCALE,
391 height: job.target_height * SCALE,
392 })
393}
394
395impl Scene {
396 pub fn validate(&self) -> Result<(), String> {
397 if self.width == 0
398 || self.height == 0
399 || self.width > MAX_HIGHRES
400 || self.height > MAX_HIGHRES
401 || self.draws.len() > 4096
402 || self
403 .view
404 .iter()
405 .chain(&self.projection)
406 .any(|v| !v.is_finite())
407 {
408 return Err("ongeldige GPU-scène/afmetingen".into());
409 }
410 for d in &self.draws {
411 if d.positions.len() > 1_000_000
412 || d.positions.len() != d.normals.len()
413 || d.indices.len() > 3_000_000
414 || !d.indices.len().is_multiple_of(3)
415 || d.indices.iter().any(|i| *i as usize >= d.positions.len())
416 || d.positions
417 .iter()
418 .chain(d.normals.iter())
419 .flatten()
420 .chain(d.uvs.iter().flatten())
421 .chain(d.vertex_colors.iter().flatten())
422 .chain(d.color.iter())
423 .any(|v| !v.is_finite())
424 || !d.specular.is_finite()
425 || !d.cutoff.is_finite()
426 {
427 return Err("ongeldige of te grote GPU-geometrie".into());
428 }
429 for t in [d.albedo.as_ref(), d.shiny.as_ref()].into_iter().flatten() {
430 if t.width == 0
431 || t.height == 0
432 || t.width > 2048
433 || t.height > 2048
434 || t.rgba.len() != t.width as usize * t.height as usize * 4
435 {
436 return Err("ongeldige GPU-texture".into());
437 }
438 }
439 }
440 Ok(())
441 }
442}
443
444#[derive(Default)]
445pub struct GpuRenderer {
446 program: Option<glow::Program>,
447 vao: Option<glow::VertexArray>,
448 vbo: Option<glow::Buffer>,
449 ebo: Option<glow::Buffer>,
450 fbo: Option<glow::Framebuffer>,
451 colors: Vec<glow::Texture>,
452 depth: Option<glow::Renderbuffer>,
453 extent: [u32; 2],
454}
455impl GpuRenderer {
456 /// # Safety
457 /// A native OpenGL 3.3 context must be current on this thread.
458 pub unsafe fn destroy(&mut self, gl: &glow::Context) {
459 self.destroy_target(gl);
460 if let Some(x) = self.program.take() {
461 gl.delete_program(x);
462 }
463 if let Some(x) = self.vao.take() {
464 gl.delete_vertex_array(x);
465 }
466 if let Some(x) = self.vbo.take() {
467 gl.delete_buffer(x);
468 }
469 if let Some(x) = self.ebo.take() {
470 gl.delete_buffer(x);
471 }
472 }
473 unsafe fn destroy_target(&mut self, gl: &glow::Context) {
474 for x in self.colors.drain(..) {
475 gl.delete_texture(x);
476 }
477 if let Some(x) = self.depth.take() {
478 gl.delete_renderbuffer(x);
479 }
480 if let Some(x) = self.fbo.take() {
481 gl.delete_framebuffer(x);
482 }
483 self.extent = [0, 0];
484 }
485 unsafe fn init(&mut self, gl: &glow::Context) -> Result<(), String> {
486 if self.program.is_some() {
487 return Ok(());
488 }
489 let p = gl.create_program()?;
490 let mut shaders = Vec::new();
491 for (kind, source) in [
492 (glow::VERTEX_SHADER, VERTEX),
493 (glow::FRAGMENT_SHADER, FRAGMENT),
494 ] {
495 let shader = gl.create_shader(kind)?;
496 gl.shader_source(shader, source);
497 gl.compile_shader(shader);
498 if !gl.get_shader_compile_status(shader) {
499 let error = gl.get_shader_info_log(shader);
500 gl.delete_shader(shader);
501 for s in shaders {
502 gl.delete_shader(s);
503 }
504 gl.delete_program(p);
505 return Err(error);
506 }
507 gl.attach_shader(p, shader);
508 shaders.push(shader);
509 }
510 gl.link_program(p);
511 let result = if gl.get_program_link_status(p) {
512 Ok(())
513 } else {
514 Err(gl.get_program_info_log(p))
515 };
516 for s in shaders {
517 gl.detach_shader(p, s);
518 gl.delete_shader(s);
519 }
520 if let Err(e) = result {
521 gl.delete_program(p);
522 return Err(e);
523 }
524 self.program = Some(p);
525 self.vao = Some(gl.create_vertex_array()?);
526 self.vbo = Some(gl.create_buffer()?);
527 self.ebo = Some(gl.create_buffer()?);
528 Ok(())
529 }
530 unsafe fn target(&mut self, gl: &glow::Context, w: u32, h: u32) -> Result<(), String> {
531 if self.extent == [w, h] {
532 return Ok(());
533 }
534 self.destroy_target(gl);
535 self.fbo = Some(gl.create_framebuffer()?);
536 gl.bind_framebuffer(glow::FRAMEBUFFER, self.fbo);
537 for slot in 0..3 {
538 let t = gl.create_texture()?;
539 self.colors.push(t);
540 gl.bind_texture(glow::TEXTURE_2D, Some(t));
541 gl.tex_parameter_i32(
542 glow::TEXTURE_2D,
543 glow::TEXTURE_MIN_FILTER,
544 glow::NEAREST as i32,
545 );
546 gl.tex_parameter_i32(
547 glow::TEXTURE_2D,
548 glow::TEXTURE_MAG_FILTER,
549 glow::NEAREST as i32,
550 );
551 gl.tex_image_2d(
552 glow::TEXTURE_2D,
553 0,
554 glow::RGBA8 as i32,
555 w as i32,
556 h as i32,
557 0,
558 glow::RGBA,
559 glow::UNSIGNED_BYTE,
560 None,
561 );
562 gl.framebuffer_texture_2d(
563 glow::FRAMEBUFFER,
564 glow::COLOR_ATTACHMENT0 + slot,
565 glow::TEXTURE_2D,
566 Some(t),
567 0,
568 );
569 }
570 let d = gl.create_renderbuffer()?;
571 self.depth = Some(d);
572 gl.bind_renderbuffer(glow::RENDERBUFFER, Some(d));
573 gl.renderbuffer_storage(
574 glow::RENDERBUFFER,
575 glow::DEPTH_COMPONENT24,
576 w as i32,
577 h as i32,
578 );
579 gl.framebuffer_renderbuffer(
580 glow::FRAMEBUFFER,
581 glow::DEPTH_ATTACHMENT,
582 glow::RENDERBUFFER,
583 Some(d),
584 );
585 gl.draw_buffers(&[
586 glow::COLOR_ATTACHMENT0,
587 glow::COLOR_ATTACHMENT1,
588 glow::COLOR_ATTACHMENT2,
589 ]);
590 if gl.check_framebuffer_status(glow::FRAMEBUFFER) != glow::FRAMEBUFFER_COMPLETE {
591 return Err("productie-FBO onvolledig".into());
592 }
593 self.extent = [w, h];
594 Ok(())
595 }
596 /// Render and read back unlit color, view-space normals, scalar specular and
597 /// the actual depth attachment. All four use the same winning surface.
598 /// # Safety
599 /// `gl` is current on the UI thread. GL state is reset for egui painting.
600 pub unsafe fn render(&mut self, gl: &glow::Context, scene: &Scene) -> Result<Buffers, String> {
601 if scene.width == 0
602 || scene.height == 0
603 || scene.width > MAX_HIGHRES
604 || scene.height > MAX_HIGHRES
605 {
606 return Err("highres moet 1–2048 pixels zijn (8× target)".into());
607 }
608 scene.validate()?;
609 let previous = gl.get_parameter_i32(glow::FRAMEBUFFER_BINDING);
610 let mut vp = [0; 4];
611 gl.get_parameter_i32_slice(glow::VIEWPORT, &mut vp);
612 let result = (|| {
613 self.init(gl)?;
614 self.target(gl, scene.width, scene.height)?;
615 gl.bind_framebuffer(glow::FRAMEBUFFER, self.fbo);
616 gl.draw_buffers(&[
617 glow::COLOR_ATTACHMENT0,
618 glow::COLOR_ATTACHMENT1,
619 glow::COLOR_ATTACHMENT2,
620 ]);
621 gl.viewport(0, 0, scene.width as i32, scene.height as i32);
622 gl.disable(glow::SCISSOR_TEST);
623 gl.disable(glow::CULL_FACE);
624 gl.disable(glow::BLEND);
625 gl.enable(glow::DEPTH_TEST);
626 gl.depth_mask(true);
627 gl.depth_func(glow::LESS);
628 // Geometry outside the fixed per-sex interval is clamped, not clipped
629 // away; production counts and reports clamped coverage.
630 gl.enable(glow::DEPTH_CLAMP);
631 gl.clear_depth_f32(1.);
632 gl.clear_color(0., 0., 0., 0.);
633 gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
634 let p = self.program.ok_or("shader ontbreekt")?;
635 gl.use_program(Some(p));
636 gl.uniform_matrix_4_f32_slice(
637 gl.get_uniform_location(p, "u_view").as_ref(),
638 false,
639 &scene.view,
640 );
641 gl.uniform_matrix_4_f32_slice(
642 gl.get_uniform_location(p, "u_projection").as_ref(),
643 false,
644 &scene.projection,
645 );
646 gl.bind_vertex_array(self.vao);
647 gl.bind_buffer(glow::ARRAY_BUFFER, self.vbo);
648 gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, self.ebo);
649 for (slot, size, offset) in [(0, 3, 0), (1, 3, 3), (2, 2, 6), (3, 4, 8)] {
650 gl.enable_vertex_attrib_array(slot);
651 gl.vertex_attrib_pointer_f32(slot, size, glow::FLOAT, false, 12 * 4, offset * 4);
652 }
653 // glTF BLEND (design.md t1016u): opaque/mask first, then the
654 // BLEND draws blended without depth writes, then their depth only.
655 let order: Vec<(&Draw, u8)> = scene
656 .draws
657 .iter()
658 .filter(|d| !d.blend)
659 .map(|d| (d, 0))
660 .chain(scene.draws.iter().filter(|d| d.blend).map(|d| (d, 1)))
661 .chain(scene.draws.iter().filter(|d| d.blend).map(|d| (d, 2)))
662 .collect();
663 for (draw, pass) in order {
664 match pass {
665 0 => {
666 gl.disable(glow::BLEND);
667 gl.depth_mask(true);
668 gl.color_mask(true, true, true, true);
669 }
670 1 => {
671 gl.enable(glow::BLEND);
672 gl.blend_func_separate(
673 glow::SRC_ALPHA,
674 glow::ONE_MINUS_SRC_ALPHA,
675 glow::ONE,
676 glow::ONE_MINUS_SRC_ALPHA,
677 );
678 gl.depth_mask(false);
679 gl.color_mask(true, true, true, true);
680 }
681 _ => {
682 gl.disable(glow::BLEND);
683 gl.depth_mask(true);
684 gl.color_mask(false, false, false, false);
685 }
686 }
687 if draw.positions.len() != draw.normals.len()
688 || draw
689 .indices
690 .iter()
691 .any(|i| *i as usize >= draw.positions.len())
692 || draw.indices.len() > i32::MAX as usize
693 {
694 return Err("ongeldige GPU-geometrie".into());
695 }
696 let mut data = Vec::with_capacity(draw.positions.len() * 12);
697 for (i, pos) in draw.positions.iter().enumerate() {
698 data.extend_from_slice(pos);
699 data.extend_from_slice(&draw.normals[i]);
700 data.extend_from_slice(&draw.uvs.get(i).copied().unwrap_or([0.; 2]));
701 data.extend_from_slice(&draw.vertex_colors.get(i).copied().unwrap_or([1.; 4]));
702 }
703 gl.buffer_data_u8_slice(
704 glow::ARRAY_BUFFER,
705 std::slice::from_raw_parts(data.as_ptr().cast(), data.len() * 4),
706 glow::STREAM_DRAW,
707 );
708 gl.buffer_data_u8_slice(
709 glow::ELEMENT_ARRAY_BUFFER,
710 std::slice::from_raw_parts(
711 draw.indices.as_ptr().cast(),
712 draw.indices.len() * 4,
713 ),
714 glow::STREAM_DRAW,
715 );
716 gl.uniform_4_f32(
717 gl.get_uniform_location(p, "u_color").as_ref(),
718 draw.color[0],
719 draw.color[1],
720 draw.color[2],
721 draw.color[3],
722 );
723 gl.uniform_1_f32(
724 gl.get_uniform_location(p, "u_specular").as_ref(),
725 draw.specular,
726 );
727 gl.uniform_1_f32(gl.get_uniform_location(p, "u_cutoff").as_ref(), draw.cutoff);
728 gl.uniform_1_i32(
729 gl.get_uniform_location(p, "u_flat").as_ref(),
730 i32::from(draw.flat_normals),
731 );
732 let mut temp = Vec::new();
733 for (unit, name, texture) in [
734 (0, "u_albedo", draw.albedo.as_ref()),
735 (1, "u_shiny", draw.shiny.as_ref()),
736 ] {
737 gl.active_texture(glow::TEXTURE0 + unit);
738 gl.uniform_1_i32(gl.get_uniform_location(p, name).as_ref(), unit as i32);
739 gl.uniform_1_i32(
740 gl.get_uniform_location(p, &format!("{name}_enabled"))
741 .as_ref(),
742 i32::from(texture.is_some()),
743 );
744 if let Some(image) = texture {
745 let t = gl.create_texture()?;
746 temp.push(t);
747 gl.bind_texture(glow::TEXTURE_2D, Some(t));
748 gl.tex_parameter_i32(
749 glow::TEXTURE_2D,
750 glow::TEXTURE_MIN_FILTER,
751 glow::NEAREST as i32,
752 );
753 gl.tex_parameter_i32(
754 glow::TEXTURE_2D,
755 glow::TEXTURE_MAG_FILTER,
756 glow::NEAREST as i32,
757 );
758 gl.tex_parameter_i32(
759 glow::TEXTURE_2D,
760 glow::TEXTURE_WRAP_S,
761 glow::REPEAT as i32,
762 );
763 gl.tex_parameter_i32(
764 glow::TEXTURE_2D,
765 glow::TEXTURE_WRAP_T,
766 glow::REPEAT as i32,
767 );
768 gl.tex_image_2d(
769 glow::TEXTURE_2D,
770 0,
771 if unit == 0 {
772 glow::SRGB8_ALPHA8 as i32
773 } else {
774 glow::RGBA8 as i32
775 },
776 image.width as i32,
777 image.height as i32,
778 0,
779 glow::RGBA,
780 glow::UNSIGNED_BYTE,
781 Some(&image.rgba),
782 );
783 } else {
784 gl.bind_texture(glow::TEXTURE_2D, None);
785 }
786 }
787 gl.draw_elements(
788 glow::TRIANGLES,
789 draw.indices.len() as i32,
790 glow::UNSIGNED_INT,
791 0,
792 );
793 for t in temp {
794 gl.delete_texture(t);
795 }
796 }
797 gl.disable(glow::BLEND);
798 gl.depth_mask(true);
799 gl.color_mask(true, true, true, true);
800 let pixels = scene.width as usize * scene.height as usize;
801 let mut maps = Vec::new();
802 gl.pixel_store_i32(glow::PACK_ALIGNMENT, 1);
803 for slot in 0..3 {
804 let mut data = vec![0; pixels * 4];
805 gl.read_buffer(glow::COLOR_ATTACHMENT0 + slot);
806 gl.read_pixels(
807 0,
808 0,
809 scene.width as i32,
810 scene.height as i32,
811 glow::RGBA,
812 glow::UNSIGNED_BYTE,
813 glow::PixelPackData::Slice(&mut data),
814 );
815 flip_rows(&mut data, scene.width as usize * 4, scene.height as usize);
816 maps.push(data);
817 }
818 let mut depth = vec![1f32; pixels];
819 let bytes = std::slice::from_raw_parts_mut(depth.as_mut_ptr().cast(), pixels * 4);
820 gl.read_pixels(
821 0,
822 0,
823 scene.width as i32,
824 scene.height as i32,
825 glow::DEPTH_COMPONENT,
826 glow::FLOAT,
827 glow::PixelPackData::Slice(bytes),
828 );
829 flip_rows(&mut depth, scene.width as usize, scene.height as usize);
830 let error = gl.get_error();
831 if error != glow::NO_ERROR {
832 return Err(format!("productie-GL fout 0x{error:x}"));
833 }
834 let specular = maps.pop().unwrap();
835 let normal = maps.pop().unwrap();
836 let diffuse = maps.pop().unwrap();
837 Ok(Buffers {
838 width: scene.width,
839 height: scene.height,
840 diffuse,
841 normal,
842 specular,
843 depth,
844 })
845 })();
846 gl.bind_framebuffer(
847 glow::FRAMEBUFFER,
848 std::num::NonZeroU32::new(previous as u32).map(glow::NativeFramebuffer),
849 );
850 gl.viewport(vp[0], vp[1], vp[2], vp[3]);
851 gl.bind_vertex_array(None);
852 gl.use_program(None);
853 gl.active_texture(glow::TEXTURE0);
854 gl.disable(glow::DEPTH_TEST);
855 gl.disable(glow::DEPTH_CLAMP);
856 gl.disable(glow::SCISSOR_TEST);
857 gl.enable(glow::BLEND);
858 gl.pixel_store_i32(glow::PACK_ALIGNMENT, 4);
859 result
860 }
861}
862/// Covered highres pixels whose depth hit the fixed interval's near or far
863/// plane (GL depth clamp), i.e. geometry outside `[depth_near, depth_far]`.
864pub fn clamped_depth_pixels(raw: &Buffers) -> usize {
865 raw.depth
866 .iter()
867 .enumerate()
868 .filter(|(i, d)| {
869 raw.diffuse.get(i * 4 + 3).is_some_and(|a| *a > 0) && (**d <= 0. || **d >= 1.)
870 })
871 .count()
872}
873fn flip_rows<T>(data: &mut [T], stride: usize, height: usize) {
874 for y in 0..height / 2 {
875 let split = (height - 1 - y) * stride;
876 let (a, b) = data.split_at_mut(split);
877 a[y * stride..(y + 1) * stride].swap_with_slice(&mut b[..stride]);
878 }
879}
880const VERTEX: &str = r#"#version 330
881layout(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;
882uniform mat4 u_view;uniform mat4 u_projection;out vec3 v_normal;out vec3 v_view_position;out vec2 v_uv;out vec4 v_color;
883void 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;}
884"#;
885const FRAGMENT: &str = r#"#version 330
886in 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
887uniform float u_specular;
888uniform sampler2D u_albedo;uniform sampler2D u_shiny;uniform bool u_albedo_enabled;uniform bool u_shiny_enabled;
889layout(location=0)out vec4 diffuse;layout(location=1)out vec4 normal;layout(location=2)out vec4 specular;
890vec3 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));}
891void 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<u_cutoff)discard;float s=u_specular;if(u_shiny_enabled)s*=dot(texture(u_shiny,v_uv).rgb,vec3(0.2126,0.7152,0.0722));diffuse=vec4(encode_srgb(clamp(c.rgb,0.0,1.0)),c.a);normal=vec4(n*0.5+0.5,c.a);specular=vec4(vec3(clamp(s,0.0,1.0)),c.a);}
892"#;
893
894/// Live-preview image of a depth PNG exactly as written (Tim t1005u): the
895/// stored channels in display form, no depth decoding.
896pub fn stored_depth_preview(
897 png_bytes: &[u8],
898 w: u32,
899 h: u32,
900 fmt: crate::camera::DepthFormat,
901) -> Result<ImageBuf, String> {
902 let native =
903 crate::export_set::decode_depth_native(png_bytes, w, h, crate::export_set::MAX_TARGET, fmt)
904 .map_err(|e| e.to_string())?;
905 Ok(image(
906 w,
907 h,
908 crate::export_set::depth_display_rgba(&native, fmt),
909 ))
910}
911
912fn image(w: u32, h: u32, rgba: Vec<u8>) -> ImageBuf {
913 ImageBuf {
914 width: w,
915 height: h,
916 rgba,
917 }
918}
919fn linear(x: u8) -> f32 {
920 static LUT: std::sync::OnceLock<[f32; 256]> = std::sync::OnceLock::new();
921 LUT.get_or_init(|| {
922 std::array::from_fn(|i| {
923 let v = i as f32 / 255.;
924 if v <= 0.04045 {
925 v / 12.92
926 } else {
927 ((v + 0.055) / 1.055).powf(2.4)
928 }
929 })
930 })[x as usize]
931}
932
933fn srgb(x: f32) -> u8 {
934 let v = if x <= 0.0031308 {
935 x * 12.92
936 } else {
937 1.055 * x.powf(1. / 2.4) - 0.055
938 };
939 (v * 255.).round().clamp(0., 255.) as u8
940}
941impl Buffers {
942 pub fn validate(&self) -> Result<(), String> {
943 let pixels = (self.width as usize)
944 .checked_mul(self.height as usize)
945 .ok_or("te veel pixels")?;
946 if self.width == 0
947 || self.height == 0
948 || self.width > MAX_HIGHRES
949 || self.height > MAX_HIGHRES
950 || self.diffuse.len() != pixels * 4
951 || self.normal.len() != pixels * 4
952 || self.specular.len() != pixels * 4
953 || self.depth.len() != pixels
954 || self
955 .depth
956 .iter()
957 .any(|d| !d.is_finite() || !(0.0..=1.0).contains(d))
958 {
959 return Err("ongeldige GPU-mapafmetingen/data".into());
960 }
961 Ok(())
962 }
963}
964pub fn detail_map(raw: &Buffers) -> Result<Vec<u8>, String> {
965 raw.validate()?;
966 let w = raw.width as usize;
967 let h = raw.height as usize;
968 let mut out = vec![0; w * h];
969 for y in 0..h {
970 for x in 0..w {
971 let i = y * w + x;
972 let mut edge = 0f32;
973 for (nx, ny) in [
974 (x.saturating_sub(1), y),
975 ((x + 1).min(w - 1), y),
976 (x, y.saturating_sub(1)),
977 (x, (y + 1).min(h - 1)),
978 ] {
979 let j = ny * w + nx;
980 let a = raw.diffuse[i * 4 + 3] as f32 / 255.;
981 let b = raw.diffuse[j * 4 + 3] as f32 / 255.;
982 edge = edge.max((a - b).abs());
983 if a > 0. && b > 0. {
984 for c in 0..3 {
985 edge = edge.max(
986 (linear(raw.diffuse[i * 4 + c]) - linear(raw.diffuse[j * 4 + c])).abs(),
987 );
988 edge = edge.max(
989 (raw.normal[i * 4 + c] as f32 - raw.normal[j * 4 + c] as f32).abs()
990 / 255.,
991 );
992 }
993 edge = edge.max(((raw.depth[i] - raw.depth[j]).abs() * 200.).min(1.));
994 }
995 }
996 out[i] = (edge * 255.).round().clamp(0., 255.) as u8;
997 }
998 }
999 Ok(out)
1000}
1001pub fn detail_image(raw: &Buffers, detail: &[u8]) -> ImageBuf {
1002 let mut rgba = Vec::with_capacity(detail.len() * 4);
1003 for (i, v) in detail.iter().enumerate() {
1004 rgba.extend_from_slice(&[*v, *v, *v, raw.diffuse[i * 4 + 3]]);
1005 }
1006 image(raw.width, raw.height, rgba)
1007}
1008pub fn resolve(raw: &Buffers, detail: &[u8], w: u32, h: u32) -> Result<Target, String> {
1009 raw.validate()?;
1010 if w == 0 || h == 0 || w > MAX_HIGHRES / SCALE || h > MAX_HIGHRES / SCALE {
1011 return Err("ongeldige target-afmetingen".into());
1012 }
1013 if raw.width != w * SCALE
1014 || raw.height != h * SCALE
1015 || detail.len() != raw.width as usize * raw.height as usize
1016 {
1017 return Err("ongeldige 8× resolve-afmetingen".into());
1018 }
1019 let count = w as usize * h as usize;
1020 let mut diffuse = vec![0; count * 4];
1021 let mut normals = diffuse.clone();
1022 let mut specular = diffuse.clone();
1023 let mut depth = vec![0u16; count * 2];
1024 let mut depth_t = vec![0f32; count];
1025 for y in 0..h {
1026 for x in 0..w {
1027 let i = (y * w + x) as usize;
1028 let mut closest = 1f32;
1029 let mut coverage = 0f32;
1030 for dy in 0..SCALE {
1031 for dx in 0..SCALE {
1032 let j = ((y * SCALE + dy) * raw.width + x * SCALE + dx) as usize;
1033 let a = raw.diffuse[j * 4 + 3] as f32 / 255.;
1034 coverage += a;
1035 if a > 0. && raw.depth[j].is_finite() {
1036 closest = closest.min(raw.depth[j]);
1037 }
1038 }
1039 }
1040 let alpha = (coverage / (SCALE * SCALE) as f32 * 255.)
1041 .round()
1042 .clamp(0., 255.) as u8;
1043 if alpha == 0 {
1044 continue;
1045 }
1046 let mut color = [0.; 3];
1047 let mut n = [0.; 3];
1048 let mut shiny = 0.;
1049 let mut sum = 0.;
1050 // One coherent front-surface cluster; never average foreground with a rear
1051 // surface. Edges get extra weight, and any thin covered feature survives.
1052 for dy in 0..SCALE {
1053 for dx in 0..SCALE {
1054 let j = ((y * SCALE + dy) * raw.width + x * SCALE + dx) as usize;
1055 let a = raw.diffuse[j * 4 + 3] as f32 / 255.;
1056 if a == 0. || raw.depth[j] > closest + 0.0025 {
1057 continue;
1058 }
1059 let weight = a * (1. + 4. * detail[j] as f32 / 255.);
1060 sum += weight;
1061 for c in 0..3 {
1062 color[c] += linear(raw.diffuse[j * 4 + c]) * weight;
1063 n[c] += (raw.normal[j * 4 + c] as f32 / 255. * 2. - 1.) * weight;
1064 }
1065 shiny += raw.specular[j * 4] as f32 * weight;
1066 }
1067 }
1068 if sum <= 0. {
1069 return Err("ongeldige depth/coverage in GPU-resultaat".into());
1070 }
1071 n = normalize(n);
1072 let d = (closest.clamp(0., 1.) * 65535.).round() as u16;
1073 for c in 0..3 {
1074 diffuse[i * 4 + c] = srgb(color[c] / sum);
1075 normals[i * 4 + c] = ((n[c] * 0.5 + 0.5) * 255.).round().clamp(0., 255.) as u8;
1076 specular[i * 4 + c] = (shiny / sum).round().clamp(0., 255.) as u8;
1077 }
1078 for map in [&mut diffuse, &mut normals, &mut specular] {
1079 map[i * 4 + 3] = alpha;
1080 }
1081 depth[i * 2] = d;
1082 depth_t[i] = closest.clamp(0., 1.);
1083 depth[i * 2 + 1] = u16::from(alpha) * 257;
1084 }
1085 }
1086 Ok(Target {
1087 diffuse: image(w, h, diffuse),
1088 normal: image(w, h, normals),
1089 specular: image(w, h, specular),
1090 depth,
1091 depth_t,
1092 })
1093}
1094
1095fn safe_parent(root: &Path, relative: &Path) -> Result<PathBuf, String> {
1096 let relative = relative
1097 .to_str()
1098 .ok_or("ongeldig uitvoerpad")?
1099 .replace('\\', "/");
1100 let relative = crate::project::portable_relative(&relative)?;
1101 let relative = relative.as_str();
1102 let canonical = root.canonicalize().map_err(|e| e.to_string())?;
1103 let path = root.join(relative);
1104 let parent = path.parent().ok_or("uitvoermap ontbreekt")?;
1105 let mut prefix = root.to_path_buf();
1106 for part in Path::new(relative)
1107 .parent()
1108 .unwrap_or(Path::new(""))
1109 .components()
1110 {
1111 prefix.push(part);
1112 if !prefix.exists() {
1113 std::fs::create_dir(&prefix).map_err(|e| e.to_string())?;
1114 }
1115 let real = prefix.canonicalize().map_err(|e| e.to_string())?;
1116 if !real.starts_with(&canonical) {
1117 return Err("uitvoer verlaat projectmap".into());
1118 }
1119 }
1120 if !parent
1121 .canonicalize()
1122 .map_err(|e| e.to_string())?
1123 .starts_with(canonical)
1124 {
1125 return Err("onveilig uitvoerpad".into());
1126 }
1127 Ok(path)
1128}
1129pub(crate) fn atomic_file(
1130 root: &Path,
1131 relative: &Path,
1132 write: impl FnOnce(&mut std::fs::File) -> Result<(), String>,
1133) -> Result<(), String> {
1134 let path = safe_parent(root, relative)?;
1135 static TEMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1136 let temp = path.with_extension(format!(
1137 "part-{}-{}",
1138 std::process::id(),
1139 TEMP_SEQ.fetch_add(1, Ordering::Relaxed)
1140 ));
1141 let mut owned = false;
1142 let result = (|| {
1143 let mut file = std::fs::OpenOptions::new()
1144 .write(true)
1145 .create_new(true)
1146 .open(&temp)
1147 .map_err(|e| e.to_string())?;
1148 owned = true;
1149 write(&mut file)?;
1150 file.sync_all().map_err(|e| e.to_string())?;
1151 std::fs::hard_link(&temp, &path).map_err(|e| e.to_string())?;
1152 Ok(())
1153 })();
1154 if owned {
1155 let _ = std::fs::remove_file(temp);
1156 }
1157 result
1158}
1159fn encode_rgba_png(img: &ImageBuf, diffuse_srgb: bool) -> Result<Vec<u8>, String> {
1160 if img.width == 0
1161 || img.height == 0
1162 || img.width > MAX_HIGHRES / SCALE
1163 || img.height > MAX_HIGHRES / SCALE
1164 || img.rgba.len() != img.width as usize * img.height as usize * 4
1165 {
1166 return Err("ongeldige PNG-afmetingen".into());
1167 }
1168 let mut cursor = std::io::Cursor::new(Vec::new());
1169 {
1170 let mut encoder = png::Encoder::new(&mut cursor, img.width, img.height);
1171 encoder.set_color(png::ColorType::Rgba);
1172 encoder.set_depth(png::BitDepth::Eight);
1173 if diffuse_srgb {
1174 encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual);
1175 }
1176 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
1177 writer
1178 .write_image_data(&img.rgba)
1179 .map_err(|e| e.to_string())?;
1180 writer.finish().map_err(|e| e.to_string())?;
1181 }
1182 Ok(cursor.into_inner())
1183}
1184
1185fn encode_depth_png(w: u32, h: u32, depth: &[u16]) -> Result<Vec<u8>, String> {
1186 if w == 0
1187 || h == 0
1188 || w > MAX_HIGHRES / SCALE
1189 || h > MAX_HIGHRES / SCALE
1190 || depth.len() != w as usize * h as usize * 2
1191 {
1192 return Err("ongeldige 16-bit depthafmetingen".into());
1193 }
1194 let samples: Vec<u8> = depth.iter().flat_map(|x| x.to_be_bytes()).collect();
1195 let mut cursor = std::io::Cursor::new(Vec::new());
1196 {
1197 let mut encoder = png::Encoder::new(&mut cursor, w, h);
1198 encoder.set_color(png::ColorType::GrayscaleAlpha);
1199 encoder.set_depth(png::BitDepth::Sixteen);
1200 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
1201 writer
1202 .write_image_data(&samples)
1203 .map_err(|e| e.to_string())?;
1204 writer.finish().map_err(|e| e.to_string())?;
1205 }
1206 Ok(cursor.into_inner())
1207}
1208
1209/// Atomically publish an RGBA8 target PNG; returns the SHA-256 hex of the file bytes.
1210pub fn write_rgba(root: &Path, relative: &Path, img: &ImageBuf) -> Result<String, String> {
1211 let diffuse_srgb = relative
1212 .file_name()
1213 .and_then(|p| p.to_str())
1214 .map(|p| p.ends_with("_diffuse.png"))
1215 .unwrap_or(false);
1216 let bytes = encode_rgba_png(img, diffuse_srgb)?;
1217 let digest = crate::digest::sha256_hex(&bytes);
1218 atomic_file(root, relative, |file| {
1219 use std::io::Write;
1220 file.write_all(&bytes).map_err(|e| e.to_string())
1221 })?;
1222 Ok(digest)
1223}
1224
1225/// Atomically publish a 16-bit gray+alpha depth PNG; returns SHA-256 hex.
1226/// Encode depth pixels `(t, alpha)` (both 0..1) as a PNG in `fmt`.
1227pub fn encode_depth_png_fmt(
1228 w: u32,
1229 h: u32,
1230 pixels: &[(f64, f64)],
1231 fmt: crate::camera::DepthFormat,
1232) -> Result<Vec<u8>, String> {
1233 if w == 0 || h == 0 || pixels.len() != w as usize * h as usize {
1234 return Err("ongeldige depthafmetingen".into());
1235 }
1236 let mut samples = Vec::with_capacity(pixels.len() * fmt.bytes_per_pixel());
1237 for &(t, a) in pixels {
1238 // Transparent pixels carry no depth (alpha rule of the map contract).
1239 let t = if a > 0. { t } else { 0. };
1240 fmt.encode_pixel(t, a, &mut samples);
1241 }
1242 let mut cursor = std::io::Cursor::new(Vec::new());
1243 {
1244 let mut encoder = png::Encoder::new(&mut cursor, w, h);
1245 encoder.set_color(fmt.color_type());
1246 encoder.set_depth(fmt.bit_depth());
1247 let mut writer = encoder.write_header().map_err(|e| e.to_string())?;
1248 writer
1249 .write_image_data(&samples)
1250 .map_err(|e| e.to_string())?;
1251 writer.finish().map_err(|e| e.to_string())?;
1252 }
1253 Ok(cursor.into_inner())
1254}
1255
1256/// Atomically publish a depth PNG in `fmt` from `(t, alpha)` pixels; returns SHA-256 hex.
1257pub fn write_depth_fmt(
1258 root: &Path,
1259 relative: &Path,
1260 w: u32,
1261 h: u32,
1262 pixels: &[(f64, f64)],
1263 fmt: crate::camera::DepthFormat,
1264) -> Result<String, String> {
1265 let bytes = encode_depth_png_fmt(w, h, pixels, fmt)?;
1266 let digest = crate::digest::sha256_hex(&bytes);
1267 atomic_file(root, relative, |file| {
1268 use std::io::Write;
1269 file.write_all(&bytes).map_err(|e| e.to_string())
1270 })?;
1271 Ok(digest)
1272}
1273
1274pub fn write_depth(
1275 root: &Path,
1276 relative: &Path,
1277 w: u32,
1278 h: u32,
1279 depth: &[u16],
1280) -> Result<String, String> {
1281 let bytes = encode_depth_png(w, h, depth)?;
1282 let digest = crate::digest::sha256_hex(&bytes);
1283 atomic_file(root, relative, |file| {
1284 use std::io::Write;
1285 file.write_all(&bytes).map_err(|e| e.to_string())
1286 })?;
1287 Ok(digest)
1288}
1289
1290/// Atomic no-clobber publication of already encoded bytes under the run root.
1291pub fn publish_bytes(root: &Path, relative: &Path, bytes: &[u8]) -> Result<(), String> {
1292 atomic_file(root, relative, |file| {
1293 use std::io::Write;
1294 file.write_all(bytes).map_err(|e| e.to_string())
1295 })
1296}
1297
1298fn write_json(root: &Path, name: &str, value: serde_json::Value) -> Result<(), String> {
1299 use std::io::Write;
1300 let data = serde_json::to_vec_pretty(&value).map_err(|e| e.to_string())?;
1301 atomic_file(root, Path::new(name), |f| {
1302 f.write_all(&data).map_err(|e| e.to_string())
1303 })
1304}
1305
1306pub struct GpuRequest {
1307 pub scene: Scene,
1308 reply: mpsc::SyncSender<Result<Buffers, String>>,
1309}
1310impl GpuRequest {
1311 pub fn fail(self, error: String) {
1312 let _ = self.reply.send(Err(error));
1313 }
1314 /// # Safety
1315 /// `gl` must be current on this UI thread.
1316 pub unsafe fn service(self, renderer: &mut GpuRenderer, gl: &glow::Context) {
1317 let result = renderer.render(gl, &self.scene);
1318 let _ = self.reply.send(result);
1319 }
1320}
1321#[derive(Default)]
1322struct Pending {
1323 pass: Option<RenderEvent>,
1324 progress: Option<RenderEvent>,
1325 terminal: Option<RenderEvent>,
1326}
1327#[derive(Clone, Default)]
1328pub struct Mailbox(Arc<Mutex<Pending>>);
1329impl Mailbox {
1330 pub fn publish(&self, event: RenderEvent) {
1331 if let Ok(mut state) = self.0.lock() {
1332 match event {
1333 RenderEvent::Pass { .. } => {
1334 state.pass = Some(event);
1335 state.progress = None;
1336 }
1337 RenderEvent::Step { .. } => state.progress = Some(event),
1338 _ => state.terminal = Some(event),
1339 }
1340 }
1341 }
1342 pub fn drain(&self) -> Vec<RenderEvent> {
1343 let mut events = Vec::new();
1344 if let Ok(mut s) = self.0.lock() {
1345 events.extend(s.pass.take());
1346 events.extend(s.progress.take());
1347 events.extend(s.terminal.take());
1348 }
1349 events
1350 }
1351}
1352pub struct RenderThread {
1353 pub mailbox: Mailbox,
1354 pub gpu: mpsc::Receiver<GpuRequest>,
1355 pub cancel: Arc<AtomicBool>,
1356 pub handle: Option<thread::JoinHandle<()>>,
1357}
1358impl Drop for RenderThread {
1359 fn drop(&mut self) {
1360 self.cancel.store(true, Ordering::Relaxed);
1361 }
1362}
1363/// Where a production batch writes: a fresh run directory, or an existing
1364/// incomplete run that is recovered after contract and checksum verification.
1365#[derive(Debug, Clone, PartialEq, Eq)]
1366pub enum RunTarget {
1367 Fresh,
1368 Resume(PathBuf),
1369}
1370
1371pub fn spawn(job: RenderJob, ctx: Option<eframe::egui::Context>) -> RenderThread {
1372 spawn_target(job, RunTarget::Fresh, ctx)
1373}
1374
1375/// Resume a selected incomplete run (see `resume`).
1376pub fn spawn_resume(
1377 job: RenderJob,
1378 run_dir: PathBuf,
1379 ctx: Option<eframe::egui::Context>,
1380) -> RenderThread {
1381 spawn_target(job, RunTarget::Resume(run_dir), ctx)
1382}
1383
1384fn spawn_target(
1385 job: RenderJob,
1386 target: RunTarget,
1387 ctx: Option<eframe::egui::Context>,
1388) -> RenderThread {
1389 let mailbox = Mailbox::default();
1390 let output = mailbox.clone();
1391 let cancel = Arc::new(AtomicBool::new(false));
1392 let flag = cancel.clone();
1393 let (tx, gpu) = mpsc::channel();
1394 let handle = thread::spawn(move || {
1395 let mut emit = |event| {
1396 output.publish(event);
1397 if let Some(ctx) = &ctx {
1398 ctx.request_repaint();
1399 }
1400 };
1401 let result = execute_target(job, target, &tx, &flag, ctx.as_ref(), &mut emit);
1402 if let Err(error) = result {
1403 if flag.load(Ordering::Relaxed) {
1404 emit(RenderEvent::Cancelled);
1405 } else {
1406 emit(RenderEvent::Failed(error));
1407 }
1408 }
1409 });
1410 RenderThread {
1411 mailbox,
1412 gpu,
1413 cancel,
1414 handle: Some(handle),
1415 }
1416}
1417
1418/// The immutable export contract of a job: validated/resolved job, plan,
1419/// set token and the exact `render.json` document.
1420pub struct Contract {
1421 pub job: RenderJob,
1422 pub plan: Plan,
1423 pub set: String,
1424 pub render_doc: serde_json::Value,
1425}
1426
1427/// Validate a job and build its `render.json` contract without touching disk.
1428pub fn contract(mut job: RenderJob) -> Result<Contract, String> {
1429 job.framing.validate()?;
1430 crate::camera::DepthFormat::new(job.depth_format.bits, job.depth_format.encoding)?;
1431 let pivot = crate::camera::resolve(&job.character, job.framing);
1432 job.framing = pivot.framing;
1433 if job.target_width == 0
1434 || job.target_height == 0
1435 || job.target_width > MAX_HIGHRES / SCALE
1436 || job.target_height > MAX_HIGHRES / SCALE
1437 {
1438 return Err("8× highres vereist targetafmetingen 1–256".into());
1439 }
1440 if !["male", "female"].contains(&job.sex.as_str()) {
1441 return Err("ongeldig geslacht".into());
1442 }
1443 if let Some(p) = &job.preset {
1444 if job.character.is_static() {
1445 return Err(
1446 "statisch lichaam zonder skeleton: equipment kan niet aan een bot hangen".into(),
1447 );
1448 }
1449 p.validate()?;
1450 if !p.applies[usize::from(job.sex == "female")] {
1451 return Err("equipment niet geldig voor dit geslacht".into());
1452 }
1453 if p.attachments.is_empty() {
1454 return Err("equipment heeft geen onderdelen".into());
1455 }
1456 }
1457 let plan = plan(&job.character, job.fps)?;
1458 let output_bytes =
1459 u64::from(plan.images) * u64::from(job.target_width) * u64::from(job.target_height) * 16;
1460 if output_bytes > 16 * 1024 * 1024 * 1024 {
1461 return Err("batch groter dan 16 GiB ongecomprimeerde targetmaps".into());
1462 }
1463 let set = job
1464 .preset
1465 .as_ref()
1466 .map(|p| p.name.as_str())
1467 .unwrap_or("body")
1468 .to_string();
1469 // Refuse up front when the per-map atlas would exceed the PNG/GPU limit,
1470 // instead of rendering for hours and failing (or splitting) at the end.
1471 crate::atlas::AtlasLayout::for_plan(&plan, job.target_width, job.target_height)?;
1472 if job.project_id.is_empty() || job.project_id.len() > 128 {
1473 return Err("project.id ongeldig voor export-identiteit".into());
1474 }
1475 if job.project_name.len() > 256 {
1476 return Err("project.name te lang voor export-identiteit".into());
1477 }
1478 if !crate::digest::is_sha256_hex(&job.body_model.sha256) {
1479 return Err("body_model.sha256 ongeldig".into());
1480 }
1481 crate::project::portable_relative(&job.body_model.path)?;
1482 match (&job.preset, &job.preset_sha256) {
1483 (None, None) => {}
1484 (Some(p), Some(hash)) if p.name == set && crate::digest::is_sha256_hex(hash) => {}
1485 _ => return Err("equipment fingerprint past niet bij de job".into()),
1486 }
1487 let equipment_preset = job.preset.as_ref().map(|p| {
1488 serde_json::json!({
1489 "name": p.name,
1490 "sha256": job.preset_sha256.as_ref().expect("checked"),
1491 })
1492 });
1493 let clips_json = plan
1494 .clips
1495 .iter()
1496 .enumerate()
1497 .map(|(i, c)| {
1498 serde_json::json!({
1499 "name": c.source_name,
1500 "token": c.token,
1501 "frames": c.frames,
1502 "duration": job.character.clips.get(i).map(|c| c.duration).unwrap_or(0.),
1503 })
1504 })
1505 .collect::<Vec<_>>();
1506 let (depth_near, depth_far) = crate::camera::depth_range(job.framing);
1507 let render_doc = serde_json::json!({
1508 "format_version": crate::export_set::OUTPUT_META_VERSION,
1509 "status": "incomplete",
1510 "project": {"id": job.project_id, "name": job.project_name},
1511 "sex": job.sex,
1512 "set": set,
1513 "body_model": {"path": job.body_model.path, "sha256": job.body_model.sha256},
1514 "equipment_preset": equipment_preset,
1515 "target": [job.target_width, job.target_height],
1516 "fps": job.fps,
1517 "highres_scale": SCALE,
1518 "framing": job.framing,
1519 "pivot_source": pivot.source,
1520 "near": depth_near,
1521 "far": depth_far,
1522 "depth_near": depth_near,
1523 "depth_far": depth_far,
1524 "depth_encoding": job.depth_format.encoding.token(),
1525 "depth_bits": job.depth_format.bits,
1526 "normal_space": "view (+Y up), PNG rows top-down",
1527 "camera_distance": crate::camera::CAMERA_DISTANCE,
1528 "yaw_zero": "camera on +Z",
1529 "pitches": preview::PRODUCTION_PITCHES,
1530 "yaws": production_yaws(),
1531 "yaw_step": 30,
1532 "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",
1533 "alpha": "straight, opaque/cutout, coverage resolve",
1534 "diffuse": "unlit sRGB albedo × vertex color, linear-light detail resolve",
1535 "depth": job.depth_format.description(),
1536 "specular": "linear scalar; body defaults zero",
1537 "pass_order": PASSES,
1538 "saved_maps": ["diffuse", "depth", "normal", "specular"],
1539 "clips": clips_json,
1540 "images": plan.images,
1541 "passes": plan.passes,
1542 "png_files": plan.files,
1543 });
1544 Ok(Contract {
1545 job,
1546 plan,
1547 set,
1548 render_doc,
1549 })
1550}
1551
1552/// Saved map tokens in per-image publication order.
1553pub const SAVED_ORDER: [&str; 4] = ["diffuse", "depth", "normal", "specular"];
1554
1555/// Every planned target PNG (relative, `/`-separated) in render order, four per image.
1556pub fn planned_paths(plan: &Plan, sex: &str, set: &str) -> Result<Vec<String>, String> {
1557 let mut out = Vec::with_capacity(plan.files as usize);
1558 for s in samples(plan) {
1559 for map in SAVED_ORDER {
1560 let path = output_path(
1561 sex,
1562 set,
1563 &plan.clips[s.clip].token,
1564 s.frame,
1565 s.pitch,
1566 s.yaw,
1567 map,
1568 )?;
1569 out.push(path.to_str().ok_or("ongeldig PNG-pad")?.replace('\\', "/"));
1570 }
1571 }
1572 Ok(out)
1573}
1574
1575#[cfg(test)]
1576fn execute(
1577 job: RenderJob,
1578 gpu: &mpsc::Sender<GpuRequest>,
1579 cancel: &AtomicBool,
1580 ctx: Option<&eframe::egui::Context>,
1581 emit: &mut dyn FnMut(RenderEvent),
1582) -> Result<(), String> {
1583 execute_target(job, RunTarget::Fresh, gpu, cancel, ctx, emit)
1584}
1585
1586fn execute_target(
1587 job: RenderJob,
1588 target: RunTarget,
1589 gpu: &mpsc::Sender<GpuRequest>,
1590 cancel: &AtomicBool,
1591 ctx: Option<&eframe::egui::Context>,
1592 emit: &mut dyn FnMut(RenderEvent),
1593) -> Result<(), String> {
1594 let Contract {
1595 mut job,
1596 plan,
1597 set,
1598 render_doc,
1599 } = contract(job)?;
1600 let planned = planned_paths(&plan, &job.sex, &set)?;
1601 std::fs::create_dir_all(&job.output).map_err(|e| e.to_string())?;
1602 for note in crate::resume::recover(&job.output)? {
1603 emit(RenderEvent::Step {
1604 index: 0,
1605 total: plan.passes,
1606 label: format!("Herstel: {note}"),
1607 });
1608 }
1609 let mut session = match target {
1610 RunTarget::Fresh => {
1611 for name in crate::resume::discard_stale(&job.output, &job.sex, &set)? {
1612 emit(RenderEvent::Step {
1613 index: 0,
1614 total: plan.passes,
1615 label: format!("Oude onvoltooide render verwijderd: {name}"),
1616 });
1617 }
1618 let stamp = std::time::SystemTime::now()
1619 .duration_since(std::time::UNIX_EPOCH)
1620 .map_err(|e| e.to_string())?
1621 .as_nanos();
1622 let root = job.output.join(crate::resume::in_progress_name(
1623 stamp,
1624 std::process::id(),
1625 &job.sex,
1626 &set,
1627 ));
1628 std::fs::create_dir(&root).map_err(|e| e.to_string())?;
1629 let session = crate::resume::RunSession::fresh(&root)?;
1630 write_json(&root, "render.json", render_doc.clone())?;
1631 session
1632 }
1633 RunTarget::Resume(dir) => {
1634 let total = plan.passes;
1635 crate::resume::RunSession::resume(
1636 &job.output,
1637 &dir,
1638 &render_doc,
1639 &planned,
1640 &mut |done, all| {
1641 emit(RenderEvent::Step {
1642 index: 0,
1643 total,
1644 label: format!("Controle bestaande uitvoer: {done}/{all} PNG's"),
1645 })
1646 },
1647 )?
1648 }
1649 };
1650 for clip in &job.character.clips {
1651 for reason in &clip.skipped {
1652 emit(RenderEvent::Step {
1653 index: 0,
1654 total: plan.passes,
1655 label: format!(
1656 "Let op: clip '{}': kanaal niet toegepast: {reason}",
1657 clip.name
1658 ),
1659 });
1660 }
1661 }
1662 let root = session.root.clone();
1663 // Internal unique names select every source clip, even duplicate display names.
1664 for (i, c) in job.character.clips.iter_mut().enumerate() {
1665 c.name = format!("render-clip-{i}");
1666 }
1667 let mut completed = 0u32;
1668 let mut clamped_views = 0u32;
1669 let mut clamped_pixels = 0u64;
1670 let mut png_sha256 = std::collections::BTreeMap::<String, String>::new();
1671 let mut cache = equipment::TextureCache::default();
1672 let result = (|| {
1673 for (index, sample) in samples(&plan).enumerate() {
1674 let clip = &plan.clips[sample.clip];
1675 let frame = sample.frame;
1676 let pitch = sample.pitch;
1677 let yaw = sample.yaw;
1678 let paths = &planned[index * 4..index * 4 + 4];
1679 if cancel.load(Ordering::Relaxed) {
1680 return Err("geannuleerd".into());
1681 }
1682 let pose = format!(
1683 "{} {set} · {} f{frame}/{} · p{pitch} y{yaw}",
1684 job.sex, clip.source_name, clip.frames
1685 );
1686 if paths.iter().all(|p| session.verified.contains_key(p)) {
1687 for p in paths {
1688 png_sha256.insert(p.clone(), session.verified[p].clone());
1689 }
1690 completed += PASSES.len() as u32;
1691 emit(RenderEvent::Step {
1692 index: completed,
1693 total: plan.passes,
1694 label: format!("{pose} · al aanwezig, checksums kloppen"),
1695 });
1696 continue;
1697 }
1698 let key = if job.character.clips.is_empty() {
1699 String::new()
1700 } else {
1701 format!("render-clip-{}", sample.clip)
1702 };
1703 emit(RenderEvent::Step {
1704 index: completed,
1705 total: plan.passes,
1706 label: format!("{pose} · highres"),
1707 });
1708 let scene = scene(
1709 &job,
1710 &key,
1711 frame as f32 / job.fps as f32,
1712 pitch as f32,
1713 yaw as f32,
1714 &mut cache,
1715 )?;
1716 let (tx, rx) = mpsc::sync_channel(1);
1717 gpu.send(GpuRequest { scene, reply: tx })
1718 .map_err(|_| "GPU-service gesloten")?;
1719 if let Some(ctx) = ctx {
1720 ctx.request_repaint();
1721 }
1722 let raw = loop {
1723 match rx.recv_timeout(std::time::Duration::from_millis(100)) {
1724 Ok(result) => break result?,
1725 Err(mpsc::RecvTimeoutError::Timeout) => {
1726 if cancel.load(Ordering::Relaxed) {
1727 return Err("geannuleerd".into());
1728 }
1729 }
1730 Err(_) => return Err("GPU-service gestopt".into()),
1731 }
1732 };
1733 let clamped = clamped_depth_pixels(&raw);
1734 if clamped > 0 {
1735 clamped_views += 1;
1736 clamped_pixels += clamped as u64;
1737 emit(RenderEvent::Step {
1738 index: completed,
1739 total: plan.passes,
1740 label: format!(
1741 "Waarschuwing: {pose} · {clamped} highres-pixels buiten dieptebereik {:.2}–{:.2} m (geklemd)",
1742 render_doc["depth_near"].as_f64().unwrap_or(0.),
1743 render_doc["depth_far"].as_f64().unwrap_or(0.)
1744 ),
1745 });
1746 }
1747 completed += 1;
1748 emit(RenderEvent::Pass {
1749 index: completed - 1,
1750 total: plan.passes,
1751 label: format!("{pose} · highres"),
1752 image: Some(image(raw.width, raw.height, raw.diffuse.clone())),
1753 });
1754 if cancel.load(Ordering::Relaxed) {
1755 return Err("geannuleerd".into());
1756 }
1757 emit(RenderEvent::Step {
1758 index: completed,
1759 total: plan.passes,
1760 label: format!("{pose} · detail"),
1761 });
1762 let detail = detail_map(&raw)?;
1763 completed += 1;
1764 emit(RenderEvent::Pass {
1765 index: completed - 1,
1766 total: plan.passes,
1767 label: format!("{pose} · detail"),
1768 image: Some(detail_image(&raw, &detail)),
1769 });
1770 if cancel.load(Ordering::Relaxed) {
1771 return Err("geannuleerd".into());
1772 }
1773 emit(RenderEvent::Step {
1774 index: completed,
1775 total: plan.passes,
1776 label: format!("{pose} · target-res / diffuse"),
1777 });
1778 let target = resolve(&raw, &detail, job.target_width, job.target_height)?;
1779 let depth_placeholder = image(job.target_width, job.target_height, Vec::new());
1780 for ((map, mut preview), rel) in [
1781 ("diffuse", target.diffuse.clone()),
1782 ("depth", depth_placeholder),
1783 ("normal", target.normal.clone()),
1784 ("specular", target.specular.clone()),
1785 ]
1786 .into_iter()
1787 .zip(paths)
1788 {
1789 if cancel.load(Ordering::Relaxed) {
1790 return Err("geannuleerd".into());
1791 }
1792 emit(RenderEvent::Step {
1793 index: completed,
1794 total: plan.passes,
1795 label: format!("{pose} · {map}"),
1796 });
1797 debug_assert!(rel.ends_with(&format!("_{map}.png")));
1798 let kept = session.verified.contains_key(rel);
1799 let digest = if kept {
1800 if map == "depth" {
1801 let stored = std::fs::read(session.root.join(rel))
1802 .map_err(|e| format!("{rel}: {e}"))?;
1803 preview = stored_depth_preview(
1804 &stored,
1805 job.target_width,
1806 job.target_height,
1807 job.depth_format,
1808 )?;
1809 }
1810 session.verified[rel].clone()
1811 } else {
1812 let bytes = if map == "depth" {
1813 let pixels: Vec<(f64, f64)> = target
1814 .depth_t
1815 .iter()
1816 .enumerate()
1817 .map(|(i, t)| {
1818 (f64::from(*t), f64::from(target.depth[i * 2 + 1]) / 65535.)
1819 })
1820 .collect();
1821 let bytes = encode_depth_png_fmt(
1822 job.target_width,
1823 job.target_height,
1824 &pixels,
1825 job.depth_format,
1826 )?;
1827 preview = stored_depth_preview(
1828 &bytes,
1829 job.target_width,
1830 job.target_height,
1831 job.depth_format,
1832 )?;
1833 bytes
1834 } else {
1835 encode_rgba_png(&preview, map == "diffuse")?
1836 };
1837 session.publish(rel, &bytes)?
1838 };
1839 png_sha256.insert(rel.clone(), digest);
1840 completed += 1;
1841 let pass = if map == "diffuse" {
1842 "target-res / diffuse"
1843 } else {
1844 map
1845 };
1846 emit(RenderEvent::Pass {
1847 index: completed - 1,
1848 total: plan.passes,
1849 label: if kept {
1850 format!("{pose} · {pass} (bestaand bestand behouden)")
1851 } else {
1852 format!("{pose} · {pass}")
1853 },
1854 image: Some(preview),
1855 });
1856 }
1857 }
1858 if png_sha256.len() as u32 != plan.files {
1859 return Err(format!(
1860 "checksumtelling {} wijkt af van geplande {} PNGs",
1861 png_sha256.len(),
1862 plan.files
1863 ));
1864 }
1865 // Atlases + .ppda only after every loose map is published and verified
1866 // (also after a resume); complete.json stays the final marker.
1867 crate::atlas::write_atlases(
1868 &crate::atlas::AtlasJob {
1869 root: &root,
1870 render_doc: &render_doc,
1871 plan: &plan,
1872 sex: &job.sex,
1873 set: &set,
1874 cell_width: job.target_width,
1875 cell_height: job.target_height,
1876 png_sha256: &png_sha256,
1877 attempt: session.attempt,
1878 cancel,
1879 },
1880 &mut |label| {
1881 emit(RenderEvent::Step {
1882 index: completed,
1883 total: plan.passes,
1884 label,
1885 })
1886 },
1887 )
1888 })();
1889 match result {
1890 Ok(atlas) => {
1891 let identity = crate::export_set::complete_identity_json(&render_doc)?;
1892 // In-progress completion marker only; removed again by
1893 // strip_bookkeeping once the .ppda (which embeds the export
1894 // contract and per-PNG checksums) is verified (Tim t993u/t994u).
1895 write_json(
1896 &root,
1897 crate::resume::COMPLETE_MARKER,
1898 serde_json::json!({
1899 "format_version": crate::export_set::OUTPUT_META_VERSION,
1900 "status": "complete",
1901 "identity": identity,
1902 "passes": completed,
1903 "png_files": plan.files,
1904 "png_sha256": png_sha256,
1905 "atlas": atlas.to_json(),
1906 "depth_clamped": {
1907 "views": clamped_views,
1908 "highres_pixels": clamped_pixels,
1909 "note": "rendered this attempt; kept files from a resumed run are not re-counted",
1910 },
1911 }),
1912 )?;
1913 if clamped_views > 0 {
1914 emit(RenderEvent::Step {
1915 index: completed,
1916 total: plan.passes,
1917 label: format!(
1918 "Waarschuwing: diepte geklemd in {clamped_views} beelden ({clamped_pixels} highres-pixels buiten het vaste bereik)"
1919 ),
1920 });
1921 }
1922 // Verify the finished build before it replaces the last good result.
1923 crate::atlas::AtlasSet::open_run(&root)
1924 .map_err(|e| format!("voltooide render faalt controle: {e}"))?;
1925 crate::export_set::ExportRun::open(&root)
1926 .map_err(|e| format!("voltooide render faalt controle: {e}"))?;
1927 drop(session);
1928 // Lock, journal and resume bookkeeping stay out of the result folder.
1929 crate::resume::strip_bookkeeping(&root)?;
1930 let final_dir = crate::resume::promote(
1931 &job.output,
1932 &root,
1933 &crate::resume::result_dir_name(&job.sex, &set),
1934 )?;
1935 emit(RenderEvent::Finished {
1936 saved: Some(final_dir.display().to_string()),
1937 });
1938 Ok(())
1939 }
1940 Err(error) => {
1941 let name = if cancel.load(Ordering::Relaxed) {
1942 "cancelled.json"
1943 } else {
1944 "failed.json"
1945 };
1946 let _ = write_json(
1947 &root,
1948 name,
1949 serde_json::json!({
1950 "error": error,
1951 "completed_passes": completed,
1952 "total_passes": plan.passes,
1953 "png_files_done": png_sha256.len(),
1954 "png_files": plan.files,
1955 "attempt": session.attempt,
1956 }),
1957 );
1958 Err(error)
1959 }
1960 }
1961}
1962
1963/// Real readback regression: nearest-surface depth/normal/specular, preserved
1964/// texture × vertex albedo/alpha and independence from opposed light directions.
1965/// # Safety
1966/// Run on a current disposable native context.
1967pub unsafe fn map_regression_probe(gl: &glow::Context) -> Result<(), String> {
1968 let triangle = |z, normal, color, specular| Draw {
1969 positions: Arc::new(vec![[-0.9, -0.9, z], [0.9, -0.9, z], [0., 0.9, z]]),
1970 normals: Arc::new(vec![normal; 3]),
1971 uvs: Arc::new(vec![[0.; 2]; 3]),
1972 vertex_colors: Arc::new(Vec::new()),
1973 indices: vec![0, 1, 2],
1974 color,
1975 albedo: None,
1976 shiny: None,
1977 specular,
1978 cutoff: 0.001,
1979 flat_normals: false,
1980 blend: false,
1981 };
1982 let mut renderer = GpuRenderer::default();
1983 let mut scene = Scene {
1984 draws: vec![
1985 triangle(-0.5, [0., 0., 1.], [1., 0., 0., 1.], 0.75),
1986 triangle(0.5, [1., 0., 0.], [0., 1., 0., 1.], 0.),
1987 ],
1988 view: preview::identity(),
1989 projection: preview::identity(),
1990 width: 32,
1991 height: 32,
1992 };
1993 let result = (|| {
1994 let raw = renderer.render(gl, &scene)?;
1995 let i = 16 * 32 + 16;
1996 if raw.diffuse[i * 4..i * 4 + 4] != [255, 0, 0, 255]
1997 || (raw.depth[i] - 0.25).abs() > 1e-5
1998 || raw.normal[i * 4..i * 4 + 3] != [128, 128, 255]
1999 || raw.specular[i * 4].abs_diff(191) > 1
2000 {
2001 return Err(format!(
2002 "GPU map winner mismatch: color {:?} depth {} normal {:?} spec {}",
2003 &raw.diffuse[i * 4..i * 4 + 4],
2004 raw.depth[i],
2005 &raw.normal[i * 4..i * 4 + 4],
2006 raw.specular[i * 4]
2007 ));
2008 }
2009 scene.draws.reverse();
2010 let reverse = renderer.render(gl, &scene)?;
2011 if reverse.diffuse != raw.diffuse
2012 || reverse.normal != raw.normal
2013 || reverse.depth != raw.depth
2014 {
2015 return Err("GPU map winner depends on submission order".into());
2016 }
2017 scene.draws = vec![triangle(-0.5, [0., 0., 1.], [0.5, 0.75, 1., 1.], 0.)];
2018 scene.draws[0].vertex_colors = Arc::new(vec![[0.5, 1., 0.5, 1.]; 3]);
2019 scene.draws[0].albedo = Some(Arc::new(equipment::Texture {
2020 content_hash: 99,
2021 width: 1,
2022 height: 1,
2023 rgba: vec![128, 255, 64, 192],
2024 }));
2025 let expected = [
2026 srgb(linear(128) * 0.25),
2027 srgb(0.75),
2028 srgb(linear(64) * 0.5),
2029 192,
2030 ];
2031 // Production intentionally has no lighting input. This optional conventional
2032 // uniform is optimized out now; a future accidental lighting term using it
2033 // must fail these actual GPU readbacks under opposed light directions.
2034 for light in [[0., 0., 1.], [0., 0., -1.], [1., 0., 0.]] {
2035 let p = renderer.program.ok_or("missing probe program")?;
2036 gl.use_program(Some(p));
2037 gl.uniform_3_f32(
2038 gl.get_uniform_location(p, "u_light_direction").as_ref(),
2039 light[0],
2040 light[1],
2041 light[2],
2042 );
2043 let output = renderer.render(gl, &scene)?;
2044 let pixel = &output.diffuse[i * 4..i * 4 + 4];
2045 if pixel.iter().zip(expected).any(|(a, b)| a.abs_diff(b) > 1) {
2046 return Err(format!("diffuse is lit or alpha/vertex/texture lost under {light:?}: {pixel:?} vs {expected:?}"));
2047 }
2048 }
2049 let detail = detail_map(&raw)?;
2050 let target = resolve(&raw, &detail, 4, 4)?;
2051 if target.diffuse.width != 4
2052 || target.depth.len() != 32
2053 || target
2054 .normal
2055 .rgba
2056 .chunks_exact(4)
2057 .filter(|p| p[3] > 0)
2058 .any(|p| {
2059 let n = p[..3]
2060 .iter()
2061 .map(|x| (*x as f32 / 255. * 2. - 1.).powi(2))
2062 .sum::<f32>()
2063 .sqrt();
2064 (n - 1.).abs() > 0.03
2065 })
2066 {
2067 return Err("target map dimensions/normals invalid".into());
2068 }
2069 let mut flat = Scene {
2070 draws: vec![triangle(-0.5, [1., 0., 0.], [1., 0., 0., 1.], 0.)],
2071 view: preview::identity(),
2072 projection: preview::identity(),
2073 width: 32,
2074 height: 32,
2075 };
2076 flat.draws[0].flat_normals = true;
2077 let derived = renderer.render(gl, &flat)?;
2078 if derived.normal[i * 4..i * 4 + 3] != [128, 128, 255] {
2079 return Err(format!(
2080 "flat normals are not geometry-derived: {:?}",
2081 &derived.normal[i * 4..i * 4 + 4]
2082 ));
2083 }
2084 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");
2085 Ok(())
2086 })();
2087 renderer.destroy(gl);
2088 result
2089}
2090
2091#[cfg(test)]
2092mod tests {
2093 use super::*;
2094 struct Temp(PathBuf);
2095 impl Temp {
2096 fn new() -> Self {
2097 let p = std::env::temp_dir().join(format!(
2098 "paperdoll-production-{}-{}",
2099 std::process::id(),
2100 std::time::SystemTime::now()
2101 .duration_since(std::time::UNIX_EPOCH)
2102 .unwrap()
2103 .as_nanos()
2104 ));
2105 std::fs::create_dir(&p).unwrap();
2106 Self(p)
2107 }
2108 }
2109 impl Drop for Temp {
2110 fn drop(&mut self) {
2111 let _ = std::fs::remove_dir_all(&self.0);
2112 }
2113 }
2114 fn body() -> PreviewCharacter {
2115 preview::load_gltf_file(
2116 &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"),
2117 )
2118 .unwrap()
2119 }
2120 fn raw(w: u32, h: u32) -> Buffers {
2121 let n = (w * h) as usize;
2122 Buffers {
2123 width: w,
2124 height: h,
2125 diffuse: [255, 0, 0, 255].repeat(n),
2126 normal: [128, 128, 255, 255].repeat(n),
2127 specular: [191, 191, 191, 255].repeat(n),
2128 depth: vec![0.25; n],
2129 }
2130 }
2131 #[test]
2132 fn job_counts_every_clip_frame_angle_and_exact_order() {
2133 let mut b = body();
2134 let mut c = b.clips[0].clone();
2135 c.name = "../A \u{2603}".into();
2136 c.duration = 0.5;
2137 b.clips = vec![c.clone(), c];
2138 let p = plan(&b, 3).unwrap();
2139 // 2 clips × 2 frames × 60 views.
2140 assert_eq!((p.images, p.passes, p.files), (240, 1440, 960));
2141 let s = samples(&p).collect::<Vec<_>>();
2142 assert_eq!(s.len(), 240);
2143 assert_eq!(
2144 s[0],
2145 Sample {
2146 clip: 0,
2147 frame: 0,
2148 pitch: -60,
2149 yaw: 0
2150 }
2151 );
2152 assert_eq!(s[11].yaw, 330);
2153 assert_eq!(s[12].pitch, -30);
2154 assert_eq!(s[24].pitch, 0);
2155 assert_eq!(s[36].pitch, 30);
2156 assert_eq!(s[59].pitch, 60);
2157 assert_eq!(s[60].frame, 1);
2158 assert_eq!(s[120].clip, 1);
2159 assert_ne!(p.clips[0].token, p.clips[1].token);
2160 assert!(p.clips.iter().all(|c| equipment::valid_name(&c.token)));
2161 assert_eq!(
2162 PASSES,
2163 [
2164 "highres",
2165 "detail",
2166 "target-res / diffuse",
2167 "depth",
2168 "normal",
2169 "specular"
2170 ]
2171 );
2172 b.clips.clear();
2173 let p = plan(&b, 12).unwrap();
2174 assert_eq!((p.images, p.files), (60, 240));
2175 }
2176 #[test]
2177 fn floating_terminal_timestamp_does_not_duplicate_the_loop_endpoint() {
2178 let mut b = body();
2179 b.clips.truncate(1);
2180 b.clips[0].duration = f32::from_bits(1f32.to_bits() + 1);
2181 assert_eq!(plan(&b, 2).unwrap().clips[0].frames, 2);
2182 b.clips[0].duration = 1.00001;
2183 assert_eq!(plan(&b, 2).unwrap().clips[0].frames, 3);
2184 }
2185 #[test]
2186 fn planner_rejects_invalid_or_unbounded_inputs() {
2187 let mut b = body();
2188 assert!(plan(&b, 0).is_err());
2189 assert!(plan(&b, 241).is_err());
2190 for duration in [f32::NAN, f32::INFINITY, -1., 10000.] {
2191 b.clips[0].duration = duration;
2192 assert!(plan(&b, 12).is_err());
2193 }
2194 }
2195 #[test]
2196 fn output_tokens_only_four_target_maps() {
2197 let expected =
2198 PathBuf::from("raw files/male/body/clip000-walk/f000012/p-60_y330_diffuse.png");
2199 assert_eq!(
2200 output_path("male", "body", "clip000-walk", 12, -60, 330, "diffuse").unwrap(),
2201 expected
2202 );
2203 assert!(output_path("male", "body", "clip000-walk", 0, 60, 0, "depth").is_ok());
2204 assert!(output_path("male", "body", "clip000-walk", 0, 45, 0, "depth").is_err());
2205 assert!(output_path("male", "body", "clip000-walk", 0, 90, 0, "depth").is_err());
2206 for map in ["diffuse", "normal", "depth", "specular"] {
2207 assert!(output_path("female", "sword", "clip000-bind", 0, 30, 0, map).is_ok());
2208 }
2209 for map in ["highres", "detail", "shiny", "../x"] {
2210 assert!(output_path("male", "body", "clip000-bind", 0, 0, 0, map).is_err());
2211 }
2212 for token in ["../escape", "A", "a/b", "a\\b", ".", "a b"] {
2213 assert!(output_path("male", token, "clip000-bind", 0, 0, 0, "diffuse").is_err());
2214 }
2215 assert!(output_path("male", "body", "walk", 0, 1, 0, "depth").is_err());
2216 assert!(output_path("male", "body", "walk", 0, 0, 360, "depth").is_err());
2217 }
2218 #[test]
2219 fn resolve_preserves_thin_foreground_and_matching_maps() {
2220 let mut r = raw(8, 8);
2221 r.diffuse = [0, 255, 0, 255].repeat(64);
2222 r.depth.fill(0.75);
2223 r.diffuse[..4].copy_from_slice(&[255, 0, 0, 255]);
2224 r.depth[0] = 0.25;
2225 let d = detail_map(&r).unwrap();
2226 assert!(d.iter().any(|x| *x > 0));
2227 let t = resolve(&r, &d, 1, 1).unwrap();
2228 assert_eq!(t.diffuse.rgba, [255, 0, 0, 255]);
2229 assert_eq!(t.specular.rgba, [191, 191, 191, 255]);
2230 assert!((t.depth[0] as i32 - 16384).abs() <= 1);
2231 let n = &t.normal.rgba;
2232 let len = (0..3)
2233 .map(|c| (n[c] as f32 / 255. * 2. - 1.).powi(2))
2234 .sum::<f32>()
2235 .sqrt();
2236 assert!((len - 1.).abs() < 0.01);
2237 r.diffuse.fill(0);
2238 r.diffuse[..4].copy_from_slice(&[255, 0, 0, 255]);
2239 let t = resolve(&r, &detail_map(&r).unwrap(), 1, 1).unwrap();
2240 assert_eq!(t.diffuse.rgba[3], 4);
2241 assert_eq!(t.depth[1], 1028);
2242 assert_eq!(t.normal.rgba[3], 4);
2243 assert_eq!(t.specular.rgba[3], 4);
2244 }
2245 fn meta_framing(meta: &serde_json::Value) -> CameraFraming {
2246 serde_json::from_value(meta["framing"].clone()).unwrap()
2247 }
2248
2249 #[test]
2250 fn clamped_depth_counts_only_covered_pixels_on_the_planes() {
2251 let mut raw = Buffers {
2252 width: 4,
2253 height: 1,
2254 diffuse: vec![255; 16],
2255 normal: vec![0; 16],
2256 specular: vec![0; 16],
2257 depth: vec![0., 0.5, 1., 1.],
2258 };
2259 raw.diffuse[15] = 0; // uncovered background at far plane
2260 assert_eq!(clamped_depth_pixels(&raw), 2);
2261 }
2262
2263 #[test]
2264 fn invalid_map_dimensions_and_depth_fail_without_panics() {
2265 let mut r = raw(8, 8);
2266 r.normal.pop();
2267 assert!(detail_map(&r).is_err());
2268 assert!(resolve(&r, &[0; 64], 1, 1).is_err());
2269 let mut r = raw(8, 8);
2270 r.depth[0] = f32::NAN;
2271 assert!(resolve(&r, &[0; 64], 1, 1).is_err());
2272 let r = raw(8, 8);
2273 assert!(resolve(&r, &[0; 63], 1, 1).is_err());
2274 assert!(resolve(&r, &[0; 64], 0, 1).is_err());
2275 assert!(resolve(&r, &[0; 64], 257, 1).is_err());
2276 }
2277 #[test]
2278 fn atomic_pngs_roundtrip_no_overwrite_or_temp_deletion() {
2279 let t = Temp::new();
2280 let p = Path::new("map_diffuse.png");
2281 write_rgba(&t.0, p, &image(1, 1, vec![128, 64, 32, 192])).unwrap();
2282 let original = std::fs::read(t.0.join(p)).unwrap();
2283 assert!(write_rgba(&t.0, p, &image(1, 1, vec![0; 4])).is_err());
2284 assert_eq!(std::fs::read(t.0.join(p)).unwrap(), original);
2285 let mut reader = png::Decoder::new(std::io::Cursor::new(original))
2286 .read_info()
2287 .unwrap();
2288 assert!(reader.info().srgb.is_some());
2289 let mut out = vec![0; reader.output_buffer_size().unwrap()];
2290 reader.next_frame(&mut out).unwrap();
2291 assert_eq!(out, [128, 64, 32, 192]);
2292 write_depth(&t.0, Path::new("depth.png"), 1, 1, &[16384, 32896]).unwrap();
2293 let mut reader = png::Decoder::new(std::io::BufReader::new(
2294 std::fs::File::open(t.0.join("depth.png")).unwrap(),
2295 ))
2296 .read_info()
2297 .unwrap();
2298 assert_eq!(reader.info().bit_depth, png::BitDepth::Sixteen);
2299 assert_eq!(reader.info().color_type, png::ColorType::GrayscaleAlpha);
2300 assert!(reader.info().srgb.is_none());
2301 let mut out = vec![0; reader.output_buffer_size().unwrap()];
2302 reader.next_frame(&mut out).unwrap();
2303 assert_eq!(out, [64, 0, 128, 128]);
2304 let tmp =
2305 t.0.join("blocked.png")
2306 .with_extension(format!("part-{}", std::process::id()));
2307 std::fs::write(&tmp, b"keep").unwrap();
2308 // A foreign/stale temp (e.g. left by a crashed process with a reused PID)
2309 // is never deleted or reused; per-write unique temp names still publish.
2310 write_rgba(&t.0, Path::new("blocked.png"), &image(1, 1, vec![0; 4])).unwrap();
2311 assert_eq!(std::fs::read(tmp).unwrap(), b"keep");
2312 let temps = std::fs::read_dir(&t.0)
2313 .unwrap()
2314 .filter_map(|e| e.ok())
2315 .filter(|e| e.file_name().to_string_lossy().starts_with("blocked.part-"))
2316 .count();
2317 assert_eq!(temps, 1, "own temp files are removed, foreign ones kept");
2318 assert!(write_depth(&t.0, Path::new("bad.png"), 1, 1, &[0]).is_err());
2319 assert!(write_rgba(&t.0, Path::new("bad.png"), &image(257, 1, vec![0; 1028])).is_err());
2320 assert!(write_rgba(&t.0, Path::new("../escape.png"), &image(1, 1, vec![0; 4])).is_err());
2321 }
2322 #[cfg(unix)]
2323 #[test]
2324 fn output_rejects_escaping_symlinks() {
2325 let root = Temp::new();
2326 let outside = Temp::new();
2327 std::os::unix::fs::symlink(&outside.0, root.0.join("link")).unwrap();
2328 assert!(write_rgba(
2329 &root.0,
2330 Path::new("link/sub/x.png"),
2331 &image(1, 1, vec![0; 4])
2332 )
2333 .is_err());
2334 assert!(!outside.0.join("sub").exists());
2335 }
2336 #[test]
2337 fn latest_mailbox_is_bounded_and_retains_terminal() {
2338 let m = Mailbox::default();
2339 for i in 0..1000 {
2340 m.publish(RenderEvent::Pass {
2341 index: i,
2342 total: 1000,
2343 label: i.to_string(),
2344 image: Some(image(1, 1, vec![0; 4])),
2345 });
2346 }
2347 m.publish(RenderEvent::Step {
2348 index: 999,
2349 total: 1000,
2350 label: "working".into(),
2351 });
2352 m.publish(RenderEvent::Cancelled);
2353 let events = m.drain();
2354 assert_eq!(events.len(), 3);
2355 assert!(matches!(&events[0], RenderEvent::Pass { index: 999, .. }));
2356 assert!(matches!(&events[2], RenderEvent::Cancelled));
2357 assert!(m.drain().is_empty());
2358 }
2359
2360 #[test]
2361 fn fixed_body_and_equipment_camera_agree_and_body_is_not_a_draw() {
2362 let temp = Temp::new();
2363 let mut job = RenderJob {
2364 depth_format: crate::camera::DepthFormat::GRAY16,
2365 character: body(),
2366 sex: "male".into(),
2367 preset: None,
2368 target_width: 64,
2369 target_height: 96,
2370 fps: 12,
2371 framing: CameraFraming {
2372 height_milli: 3100,
2373 center_y_milli: 1200,
2374 pivot_y_milli: Some(1200),
2375 depth_range_milli: None,
2376 },
2377 output: temp.0.clone(),
2378 project_id: "test-project".into(),
2379 project_name: "Test".into(),
2380 body_model: SourceFingerprint {
2381 path: "assets/body/test.glb".into(),
2382 sha256: crate::digest::sha256_hex(b"test-body"),
2383 },
2384 preset_sha256: None,
2385 };
2386 let mut cache = equipment::TextureCache::default();
2387 let b = scene(&job, "walk", 0.5, 30., 90., &mut cache).unwrap();
2388 let mut preset = equipment::Preset::new("sword".into());
2389 let fixture =
2390 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment/sword.obj");
2391 equipment::import_obj(&mut preset, &fixture).unwrap();
2392 let joint = job
2393 .character
2394 .joint_names
2395 .iter()
2396 .position(|n| n == "RightHand")
2397 .unwrap();
2398 preset.bind(0, &job.character, 0, joint).unwrap();
2399 let count = preset
2400 .attachments
2401 .iter()
2402 .map(|a| a.meshes.len())
2403 .sum::<usize>();
2404 job.preset = Some(preset);
2405 let e = scene(&job, "walk", 0.5, 30., 90., &mut cache).unwrap();
2406 assert_eq!(e.draws.len(), count);
2407 assert_eq!(b.view, e.view);
2408 assert_eq!(b.projection, e.projection);
2409 assert_eq!((e.width, e.height), (512, 768));
2410 assert_eq!(e.projection[5], 2. / 3.1);
2411 let center = point(&e.view, [0., 1.2, 0.]);
2412 assert!(center[0].abs() < 0.0001 && center[1].abs() < 0.0001);
2413 assert!((center[2] + 10.).abs() < 0.0001);
2414 let early = scene(&job, "attack", 0., 30., 90., &mut cache).unwrap();
2415 let late = scene(&job, "attack", 0.5, 30., 90., &mut cache).unwrap();
2416 assert_eq!(early.view, late.view);
2417 assert_eq!(early.projection, late.projection);
2418 assert_ne!(early.draws[0].positions, late.draws[0].positions);
2419 let mut invalid = e.clone();
2420 invalid.view[0] = f32::NAN;
2421 assert!(invalid.validate().is_err());
2422 }
2423 #[test]
2424 fn body_import_retains_texture_material_and_linear_vertex_color() {
2425 let temp = Temp::new();
2426 let source = std::fs::read(
2427 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bodies/test-body-male.glb"),
2428 )
2429 .unwrap();
2430 let glb = gltf::binary::Glb::from_slice(&source).unwrap();
2431 let mut json: serde_json::Value = serde_json::from_slice(&glb.json).unwrap();
2432 let mut bin = glb.bin.unwrap().into_owned();
2433 while !bin.len().is_multiple_of(4) {
2434 bin.push(0);
2435 }
2436 let position = json["meshes"][0]["primitives"][0]["attributes"]["POSITION"]
2437 .as_u64()
2438 .unwrap() as usize;
2439 let count = json["accessors"][position]["count"].as_u64().unwrap() as usize;
2440 let offset = bin.len();
2441 for _ in 0..count {
2442 for value in [0.25f32, 0.75, 0.5, 0.5] {
2443 bin.extend(value.to_le_bytes());
2444 }
2445 }
2446 let view = json["bufferViews"].as_array().unwrap().len();
2447 json["bufferViews"]
2448 .as_array_mut()
2449 .unwrap()
2450 .push(serde_json::json!({"buffer":0,"byteOffset":offset,"byteLength":count*16}));
2451 let accessor = json["accessors"].as_array().unwrap().len();
2452 json["accessors"].as_array_mut().unwrap().push(
2453 serde_json::json!({"bufferView":view,"componentType":5126,"count":count,"type":"VEC4"}),
2454 );
2455 json["meshes"][0]["primitives"][0]["attributes"]["COLOR_0"] = serde_json::json!(accessor);
2456 json["buffers"][0] = serde_json::json!({"uri":"geometry.bin","byteLength":bin.len()});
2457 json["images"] = serde_json::json!([{"uri":"albedo.png"}]);
2458 json["textures"] = serde_json::json!([{"source":0}]);
2459 let material = json["meshes"][0]["primitives"][0]["material"]
2460 .as_u64()
2461 .unwrap() as usize;
2462 json["materials"][material]["pbrMetallicRoughness"]["baseColorTexture"] =
2463 serde_json::json!({"index":0});
2464 json["materials"][material]["pbrMetallicRoughness"]["baseColorFactor"] =
2465 serde_json::json!([0.5, 0.75, 1., 1.]);
2466 std::fs::write(temp.0.join("geometry.bin"), bin).unwrap();
2467 std::fs::write(temp.0.join("body.gltf"), serde_json::to_vec(&json).unwrap()).unwrap();
2468 write_rgba(
2469 &temp.0,
2470 Path::new("albedo.png"),
2471 &image(1, 1, vec![128, 255, 64, 192]),
2472 )
2473 .unwrap();
2474 let body = preview::load_gltf_file(&temp.0.join("body.gltf")).unwrap();
2475 assert_eq!(body.colors[0], [0.25, 0.75, 0.5, 0.5]);
2476 assert_eq!(body.surfaces[0].color, [0.5, 0.75, 1., 1.]);
2477 assert_eq!(
2478 body.surfaces[0].albedo.as_ref().unwrap().rgba,
2479 [128, 255, 64, 192]
2480 );
2481 assert_eq!(body.colors.len(), body.positions.len());
2482 assert_eq!(body.uvs.len(), body.positions.len());
2483 assert!(body.surfaces[0].flat_normals);
2484 json["meshes"][0]["primitives"][0]["attributes"]
2485 .as_object_mut()
2486 .unwrap()
2487 .remove("NORMAL");
2488 std::fs::write(temp.0.join("body.gltf"), serde_json::to_vec(&json).unwrap()).unwrap();
2489 let missing = preview::load_gltf_file(&temp.0.join("body.gltf")).unwrap();
2490 assert!(!missing.source_normals_complete);
2491 let job = RenderJob {
2492 depth_format: crate::camera::DepthFormat::GRAY16,
2493 character: missing,
2494 sex: "male".into(),
2495 preset: None,
2496 target_width: 1,
2497 target_height: 1,
2498 fps: 12,
2499 framing: CameraFraming::default(),
2500 output: temp.0.clone(),
2501 project_id: "test-project".into(),
2502 project_name: "Test".into(),
2503 body_model: SourceFingerprint {
2504 path: "assets/body/test.glb".into(),
2505 sha256: crate::digest::sha256_hex(b"test-body"),
2506 },
2507 preset_sha256: None,
2508 };
2509 assert!(
2510 scene(
2511 &job,
2512 "",
2513 0.,
2514 0.,
2515 0.,
2516 &mut equipment::TextureCache::default()
2517 )
2518 .unwrap()
2519 .draws[0]
2520 .flat_normals
2521 );
2522 }
2523 #[test]
2524 fn complete_and_cancelled_batches_have_truthful_atomic_outputs() {
2525 for stop in [false, true] {
2526 let tmp = Temp::new();
2527 let mut b = body();
2528 b.clips.clear();
2529 let job = RenderJob {
2530 depth_format: crate::camera::DepthFormat::GRAY16,
2531 character: b,
2532 sex: "male".into(),
2533 preset: None,
2534 target_width: 1,
2535 target_height: 1,
2536 fps: 12,
2537 framing: CameraFraming::default(),
2538 output: tmp.0.clone(),
2539 project_id: "test-project".into(),
2540 project_name: "Test".into(),
2541 body_model: SourceFingerprint {
2542 path: "assets/body/test.glb".into(),
2543 sha256: crate::digest::sha256_hex(b"test-body"),
2544 },
2545 preset_sha256: None,
2546 };
2547 let flag = AtomicBool::new(false);
2548 let (tx, rx) = mpsc::channel::<GpuRequest>();
2549 let gpu = std::thread::spawn(move || {
2550 for request in rx {
2551 let _ = request
2552 .reply
2553 .send(Ok(raw(request.scene.width, request.scene.height)));
2554 }
2555 });
2556 let mut labels = Vec::new();
2557 let result = execute(job, &tx, &flag, None, &mut |event| {
2558 if let RenderEvent::Pass { label, .. } = event {
2559 labels.push(label);
2560 if stop && labels.len() == 3 {
2561 flag.store(true, Ordering::Relaxed);
2562 }
2563 }
2564 });
2565 drop(tx);
2566 gpu.join().unwrap();
2567 let run = std::fs::read_dir(&tmp.0)
2568 .unwrap()
2569 .next()
2570 .unwrap()
2571 .unwrap()
2572 .path();
2573 // A finished folder has no bookkeeping JSON; a cancelled build keeps it.
2574 assert_eq!(run.join("render.json").exists(), stop);
2575 assert!(!run.join("complete.json").exists());
2576 assert_eq!(run.join(crate::resume::JOURNAL).exists(), stop);
2577 assert_eq!(run.join(crate::resume::LOCK).exists(), stop);
2578 assert_eq!(run.join("cancelled.json").exists(), stop);
2579 assert_eq!(result.is_ok(), !stop);
2580 let ppda_export = (!stop).then(|| {
2581 crate::atlas::AtlasSet::open_run(&run)
2582 .unwrap()
2583 .ppda
2584 .export
2585 .unwrap()
2586 });
2587 let meta: serde_json::Value = match &ppda_export {
2588 Some(export) => export.render.clone(),
2589 None => {
2590 serde_json::from_str(&std::fs::read_to_string(run.join("render.json")).unwrap())
2591 .unwrap()
2592 }
2593 };
2594 assert_eq!(meta["format_version"], 6);
2595 let (near, far) = crate::camera::depth_range(meta_framing(&meta));
2596 assert_eq!(meta["depth_near"].as_f64().unwrap() as f32, near);
2597 assert_eq!(meta["depth_far"].as_f64().unwrap() as f32, far);
2598 assert_eq!(meta["near"], meta["depth_near"]);
2599 assert_eq!(meta["depth_encoding"], "gray");
2600 assert_eq!(meta["depth_bits"], 16);
2601 assert_eq!(meta["pitches"], serde_json::json!([-60, -30, 0, 30, 60]));
2602 assert_eq!(meta["yaws"].as_array().unwrap().len(), 12);
2603 assert_eq!(meta["fps"], 12);
2604 assert_eq!(meta["project"]["id"], "test-project");
2605 assert_eq!(
2606 meta["body_model"]["sha256"],
2607 crate::digest::sha256_hex(b"test-body")
2608 );
2609 assert_eq!(meta["framing"]["pivot_y_milli"], 880);
2610 assert_eq!(meta["pivot_source"]["kind"], "bounds_center");
2611 let (lo, hi) = (
2612 meta["pivot_source"]["min_y_milli"].as_i64().unwrap(),
2613 meta["pivot_source"]["max_y_milli"].as_i64().unwrap(),
2614 );
2615 assert_eq!((lo + hi) / 2, 880);
2616 assert_eq!(
2617 meta["saved_maps"]
2618 .as_array()
2619 .unwrap()
2620 .iter()
2621 .map(|v| v.as_str().unwrap())
2622 .collect::<Vec<_>>(),
2623 ["diffuse", "depth", "normal", "specular"]
2624 );
2625 if !stop {
2626 let checksums = &ppda_export.as_ref().unwrap().png_sha256;
2627 assert_eq!(checksums.len(), 240);
2628 for (rel, digest) in checksums {
2629 assert!(rel.starts_with("raw files/"), "{rel}");
2630 assert!(crate::digest::is_sha256_hex(digest));
2631 let bytes = std::fs::read(run.join(rel)).unwrap();
2632 assert_eq!(*digest, crate::digest::sha256_hex(&bytes));
2633 }
2634 let mut top: Vec<String> = std::fs::read_dir(&run)
2635 .unwrap()
2636 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
2637 .filter(|n| !n.ends_with(".png"))
2638 .collect();
2639 top.sort();
2640 assert_eq!(top, ["male-body-atlas.ppda", "raw files"]);
2641 let opened = crate::export_set::ExportRun::open(&run).unwrap();
2642 let sample = opened
2643 .load_map(
2644 "clip000-bind",
2645 0,
2646 -60,
2647 0,
2648 crate::export_set::MapKind::Diffuse,
2649 )
2650 .unwrap();
2651 assert_eq!(sample.width(), 1);
2652 // Atlases + .ppda: one per map, written before complete.json.
2653 for map in ["diffuse", "normal", "depth", "specular"] {
2654 let name = format!("male-body-{map}-atlas.png");
2655 assert!(run.join(&name).is_file(), "{name}");
2656 assert_eq!(opened.complete.atlas.maps[map].file, name);
2657 }
2658 assert!(!stop);
2659 let atlas = crate::atlas::AtlasSet::open_run(&run).unwrap();
2660 assert_eq!(atlas.ppda.fps, 12);
2661 assert_eq!((atlas.layout.width, atlas.layout.height), (60, 1));
2662 let loaded = atlas.load().unwrap();
2663 for (pitch, yaw) in [(-60, 0), (0, 150), (60, 330)] {
2664 for kind in crate::atlas::ATLAS_MAPS {
2665 assert_eq!(
2666 loaded.cell("clip000-bind", 0, pitch, yaw, kind).unwrap(),
2667 opened
2668 .load_map("clip000-bind", 0, pitch, yaw, kind)
2669 .unwrap()
2670 );
2671 }
2672 }
2673 } else {
2674 assert!(!run.join("male-body-atlas.ppda").exists());
2675 assert!(!run.join("male-body-diffuse-atlas.png").exists());
2676 }
2677 let frame = run.join("raw files/male/body/clip000-bind/f000000");
2678 assert!(frame.join("p-60_y000_diffuse.png").exists());
2679 assert_eq!(frame.join("p-60_y000_depth.png").exists(), !stop);
2680 assert!(!frame.join("p-60_y000_highres.png").exists());
2681 assert!(!frame.join("p-60_y000_detail.png").exists());
2682 if !stop {
2683 assert_eq!(labels.len(), 360);
2684 for labels in labels.chunks_exact(6) {
2685 for (label, pass) in labels.iter().zip([
2686 "highres",
2687 "detail",
2688 "target-res / diffuse",
2689 "depth",
2690 "normal",
2691 "specular",
2692 ]) {
2693 assert!(label.ends_with(pass));
2694 }
2695 }
2696 }
2697 }
2698 }
2699
2700 // ---- Resume (design §7/§12.6) ----
2701 fn resume_job(out: &Path) -> RenderJob {
2702 let mut b = body();
2703 b.clips.clear();
2704 RenderJob {
2705 depth_format: crate::camera::DepthFormat::GRAY16,
2706 character: b,
2707 sex: "male".into(),
2708 preset: None,
2709 target_width: 2,
2710 target_height: 2,
2711 fps: 12,
2712 framing: CameraFraming::default(),
2713 output: out.to_path_buf(),
2714 project_id: "resume-project".into(),
2715 project_name: "Resume".into(),
2716 body_model: SourceFingerprint {
2717 path: "assets/body/test.glb".into(),
2718 sha256: crate::digest::sha256_hex(b"resume-body"),
2719 },
2720 preset_sha256: None,
2721 }
2722 }
2723 /// Deterministic pose-dependent fake GPU output (view matrix drives the colour).
2724 fn fake_raw(scene: &Scene) -> Buffers {
2725 let mut b = raw(scene.width, scene.height);
2726 let v = ((scene.view[0] * 97. + scene.view[2] * 31. + scene.view[6] * 13.).abs() * 50.)
2727 as u32
2728 % 200;
2729 for px in b.diffuse.chunks_exact_mut(4) {
2730 px[1] = v as u8;
2731 px[2] = (255 - v) as u8;
2732 }
2733 b
2734 }
2735 /// Run a job; cancel after `stop` completed Pass events (None = run to the end).
2736 fn drive(
2737 job: RenderJob,
2738 target: RunTarget,
2739 stop: Option<usize>,
2740 ) -> (Result<(), String>, Vec<RenderEvent>) {
2741 let flag = AtomicBool::new(false);
2742 let (tx, rx) = mpsc::channel::<GpuRequest>();
2743 let gpu = std::thread::spawn(move || {
2744 for request in rx {
2745 let reply = fake_raw(&request.scene);
2746 let _ = request.reply.send(Ok(reply));
2747 }
2748 });
2749 let mut events = Vec::new();
2750 let mut passes = 0;
2751 let result = execute_target(job, target, &tx, &flag, None, &mut |event| {
2752 if matches!(event, RenderEvent::Pass { .. }) {
2753 passes += 1;
2754 if stop == Some(passes) {
2755 flag.store(true, Ordering::Relaxed);
2756 }
2757 }
2758 events.push(event);
2759 });
2760 drop(tx);
2761 gpu.join().unwrap();
2762 (result, events)
2763 }
2764 fn runs(out: &Path) -> Vec<PathBuf> {
2765 let mut v = std::fs::read_dir(out)
2766 .unwrap()
2767 .map(|e| e.unwrap().path())
2768 .collect::<Vec<_>>();
2769 v.sort();
2770 v
2771 }
2772 fn checksums(run: &Path) -> serde_json::Value {
2773 let export = crate::atlas::AtlasSet::open_run(run)
2774 .unwrap()
2775 .ppda
2776 .export
2777 .unwrap();
2778 serde_json::to_value(export.png_sha256).unwrap()
2779 }
2780 fn tree(run: &Path) -> std::collections::BTreeMap<PathBuf, String> {
2781 let mut out = std::collections::BTreeMap::new();
2782 let mut stack = vec![run.to_path_buf()];
2783 while let Some(dir) = stack.pop() {
2784 for e in std::fs::read_dir(dir).unwrap() {
2785 let p = e.unwrap().path();
2786 if p.is_dir() {
2787 stack.push(p);
2788 } else {
2789 out.insert(
2790 p.clone(),
2791 crate::digest::sha256_hex(&std::fs::read(&p).unwrap()),
2792 );
2793 }
2794 }
2795 }
2796 out
2797 }
2798 #[cfg(unix)]
2799 fn inode(p: &Path) -> u64 {
2800 use std::os::unix::fs::MetadataExt;
2801 std::fs::metadata(p).unwrap().ino()
2802 }
2803
2804 #[test]
2805 #[cfg(unix)]
2806 fn cancel_resume_cancel_again_then_complete_equals_uninterrupted_run() {
2807 let reference_dir = Temp::new();
2808 let (ok, _) = drive(resume_job(&reference_dir.0), RunTarget::Fresh, None);
2809 ok.unwrap();
2810 let reference = checksums(&runs(&reference_dir.0)[0]);
2811 assert_eq!(reference.as_object().unwrap().len(), 240);
2812
2813 let tmp = Temp::new();
2814 // An earlier completed run in the same output must stay byte-identical.
2815 drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();
2816 let earlier = runs(&tmp.0)[0].clone();
2817 let earlier_tree = tree(&earlier);
2818 std::thread::sleep(std::time::Duration::from_millis(2));
2819 let (first, _) = drive(resume_job(&tmp.0), RunTarget::Fresh, Some(40));
2820 assert!(first.is_err());
2821 let run = runs(&tmp.0).into_iter().find(|r| *r != earlier).unwrap();
2822 let cancelled: serde_json::Value =
2823 serde_json::from_slice(&std::fs::read(run.join("cancelled.json")).unwrap()).unwrap();
2824 let done_first = cancelled["png_files_done"].as_u64().unwrap();
2825 assert!(done_first > 0 && done_first < 240);
2826 assert_eq!(cancelled["attempt"], 0);
2827 let first_file = run.join("raw files/male/body/clip000-bind/f000000/p-60_y000_diffuse.png");
2828 let first_inode = inode(&first_file);
2829 assert!(crate::export_set::ExportRun::open(&run).is_err());
2830
2831 let found =
2832 crate::resume::candidates(&tmp.0, &contract(resume_job(&tmp.0)).unwrap().render_doc)
2833 .unwrap();
2834 assert_eq!(found.len(), 1);
2835 assert_eq!(found[0].state, "geannuleerd");
2836 assert_eq!(found[0].journaled as u64, done_first);
2837 assert!(found[0].blocked.is_none());
2838
2839 // Second attempt: cancelled again after more work.
2840 let (second, events) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), Some(60));
2841 assert!(second.is_err());
2842 // Progress resumes at the verified work, not at zero, and skipped images are labelled.
2843 let first_step = events
2844 .iter()
2845 .find_map(|e| match e {
2846 RenderEvent::Step { index, label, .. } if label.contains("al aanwezig") => {
2847 Some(*index)
2848 }
2849 _ => None,
2850 })
2851 .unwrap();
2852 assert_eq!(first_step, 6);
2853 let cancelled2: serde_json::Value =
2854 serde_json::from_slice(&std::fs::read(run.join("cancelled.json")).unwrap()).unwrap();
2855 assert_eq!(cancelled2["attempt"], 1);
2856 assert!(cancelled2["png_files_done"].as_u64().unwrap() > done_first);
2857 assert!(run.join("attempt-000-cancelled.json").exists());
2858 assert_eq!(
2859 inode(&first_file),
2860 first_inode,
2861 "verified PNG was rewritten"
2862 );
2863
2864 // Cancelled/unfinished builds never touch the last good result.
2865 assert_eq!(tree(&earlier), earlier_tree);
2866 // Third attempt completes and replaces the result folder.
2867 let (third, _) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None);
2868 third.unwrap();
2869 let built = run.clone();
2870 let done = tmp.0.join("male-body-atlas");
2871 assert_eq!(earlier, done);
2872 assert!(!built.exists());
2873 assert_eq!(
2874 runs(&tmp.0),
2875 vec![done.clone()],
2876 "only the newest result remains"
2877 );
2878 let first_file = done.join(first_file.strip_prefix(&built).unwrap());
2879 let run = done;
2880 // Resume bookkeeping (markers, logs, journal, lock) stays out of the result.
2881 for name in [
2882 "cancelled.json",
2883 "attempt-001-cancelled.json",
2884 "render.json",
2885 "complete.json",
2886 crate::resume::RESUME_LOG,
2887 crate::resume::JOURNAL,
2888 crate::resume::LOCK,
2889 ] {
2890 assert!(!run.join(name).exists(), "{name}");
2891 }
2892 assert_eq!(inode(&first_file), first_inode);
2893 assert_eq!(checksums(&run), reference);
2894 crate::export_set::ExportRun::open(&run).unwrap();
2895 // A completed run is no longer offered and cannot be resumed.
2896 let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
2897 assert!(crate::resume::candidates(&tmp.0, &doc).unwrap().is_empty());
2898 let (again, _) = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None);
2899 assert!(again.unwrap_err().contains("uitvoermap"));
2900 let (gone, _) = drive(resume_job(&tmp.0), RunTarget::Resume(built), None);
2901 assert!(gone.is_err());
2902 }
2903
2904 #[test]
2905 fn live_depth_preview_shows_the_written_depth_bytes_in_every_format() {
2906 use crate::camera::{DepthEncoding, DepthFormat};
2907 for bits in [8u8, 16] {
2908 for encoding in DepthEncoding::ALL {
2909 let fmt = DepthFormat::new(bits, encoding).unwrap();
2910 let tmp = Temp::new();
2911 let mut job = resume_job(&tmp.0);
2912 job.depth_format = fmt;
2913 let (result, events) = drive(job, RunTarget::Fresh, None);
2914 result.unwrap();
2915 let run = tmp.0.join("male-body-atlas");
2916 let export = crate::atlas::AtlasSet::open_run(&run)
2917 .unwrap()
2918 .ppda
2919 .export
2920 .unwrap();
2921 let written: Vec<Vec<u8>> = export
2922 .png_sha256
2923 .keys()
2924 .filter(|k| k.ends_with("_depth.png"))
2925 .map(|k| {
2926 let bytes = std::fs::read(run.join(k)).unwrap();
2927 let native = crate::export_set::decode_depth_native(
2928 &bytes,
2929 2,
2930 2,
2931 crate::export_set::MAX_TARGET,
2932 fmt,
2933 )
2934 .unwrap();
2935 crate::export_set::depth_display_rgba(&native, fmt)
2936 })
2937 .collect();
2938 let shown: Vec<&ImageBuf> = events
2939 .iter()
2940 .filter_map(|e| match e {
2941 RenderEvent::Pass {
2942 label,
2943 image: Some(img),
2944 ..
2945 } if label.ends_with("· depth") => Some(img),
2946 _ => None,
2947 })
2948 .collect();
2949 assert_eq!(shown.len(), written.len(), "{fmt:?}");
2950 for img in shown {
2951 assert_eq!((img.width, img.height), (2, 2));
2952 assert!(
2953 written.contains(&img.rgba),
2954 "{fmt:?}: preview is not the written bytes"
2955 );
2956 }
2957 }
2958 }
2959 }
2960
2961 #[test]
2962 #[cfg(unix)]
2963 fn fresh_render_discards_older_unfinished_builds_of_the_same_target_only() {
2964 let tmp = Temp::new();
2965 drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();
2966 let done = tmp.0.join("male-body-atlas");
2967 let done_tree = tree(&done);
2968 assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(10))
2969 .0
2970 .is_err());
2971 let first: Vec<PathBuf> = runs(&tmp.0).into_iter().filter(|r| *r != done).collect();
2972 assert_eq!(first.len(), 1);
2973 // Another target and a locked build of this target must survive.
2974 let other = tmp
2975 .0
2976 .join(crate::resume::in_progress_name(1, 1, "female", "body"));
2977 std::fs::create_dir(&other).unwrap();
2978 std::fs::write(
2979 other.join("render.json"),
2980 br#"{"sex":"female","set":"body"}"#,
2981 )
2982 .unwrap();
2983 let locked = tmp
2984 .0
2985 .join(crate::resume::in_progress_name(2, 2, "male", "body"));
2986 std::fs::create_dir(&locked).unwrap();
2987 let lock = std::fs::File::create(locked.join(crate::resume::LOCK)).unwrap();
2988 lock.try_lock().unwrap();
2989 std::thread::sleep(std::time::Duration::from_millis(2));
2990 let (second, events) = drive(resume_job(&tmp.0), RunTarget::Fresh, Some(10));
2991 assert!(second.is_err());
2992 assert!(!first[0].exists(), "older cancelled build kept");
2993 assert!(other.exists() && locked.exists());
2994 assert!(events.iter().any(|e| matches!(e,
2995 RenderEvent::Step { label, .. } if label.contains("Oude onvoltooide render verwijderd"))));
2996 assert_eq!(tree(&done), done_tree, "finished result touched");
2997 drop(lock);
2998 let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
2999 let offered = crate::resume::candidates(&tmp.0, &doc).unwrap();
3000 let newest = offered.iter().find(|c| c.journaled > 0).unwrap();
3001 assert!(newest.dir.exists() && newest.dir != locked && newest.dir != first[0]);
3002 // Resuming deletes nothing.
3003 let before = runs(&tmp.0);
3004 drive(
3005 resume_job(&tmp.0),
3006 RunTarget::Resume(newest.dir.clone()),
3007 Some(1),
3008 )
3009 .0
3010 .unwrap_err();
3011 assert_eq!(runs(&tmp.0), before);
3012 }
3013
3014 #[test]
3015 #[cfg(unix)]
3016 fn crash_recovery_regenerates_missing_corrupt_and_keeps_unjournaled_identical_files() {
3017 let reference_dir = Temp::new();
3018 drive(resume_job(&reference_dir.0), RunTarget::Fresh, None)
3019 .0
3020 .unwrap();
3021 let reference = checksums(&runs(&reference_dir.0)[0]);
3022
3023 let tmp = Temp::new();
3024 assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(60))
3025 .0
3026 .is_err());
3027 let run = runs(&tmp.0)[0].clone();
3028 // Simulate a hard crash: no terminal marker, torn last journal line.
3029 std::fs::remove_file(run.join("cancelled.json")).unwrap();
3030 let journal = std::fs::read_to_string(run.join(crate::resume::JOURNAL)).unwrap();
3031 let mut lines = journal.lines().map(str::to_string).collect::<Vec<_>>();
3032 let torn: serde_json::Value = serde_json::from_str(lines.last().unwrap()).unwrap();
3033 let torn_path = run.join(torn["path"].as_str().unwrap());
3034 let last = lines.pop().unwrap();
3035 let keep = lines.join("\n") + "\n" + &last[..last.len() / 2];
3036 std::fs::write(run.join(crate::resume::JOURNAL), keep).unwrap();
3037 let torn_inode = inode(&torn_path);
3038 // Corrupt one journaled PNG, delete another, leave a foreign temp file.
3039 let frame = run.join("raw files/male/body/clip000-bind/f000000");
3040 let corrupt = frame.join("p-60_y030_normal.png");
3041 std::fs::write(&corrupt, b"not a png").unwrap();
3042 let missing = frame.join("p-60_y060_depth.png");
3043 std::fs::remove_file(&missing).unwrap();
3044 let stale = frame.join("p-60_y090_diffuse.part-999999-0");
3045 std::fs::write(&stale, b"partial").unwrap();
3046
3047 let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
3048 let found = crate::resume::candidates(&tmp.0, &doc).unwrap();
3049 assert_eq!(found[0].state, "afgebroken");
3050
3051 drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
3052 .0
3053 .unwrap();
3054 let done = tmp.0.join("male-body-atlas");
3055 let rebase = |p: &Path| done.join(p.strip_prefix(&run).unwrap());
3056 let (frame, missing, stale, torn_path) = (
3057 rebase(&frame),
3058 rebase(&missing),
3059 rebase(&stale),
3060 rebase(&torn_path),
3061 );
3062 let run = done.clone();
3063 assert_eq!(checksums(&run), reference);
3064 crate::export_set::ExportRun::open(&run).unwrap();
3065 assert!(missing.exists());
3066 assert_eq!(
3067 std::fs::read(frame.join("p-60_y030_normal.png.corrupt-a001-0")).unwrap(),
3068 b"not a png"
3069 );
3070 assert_eq!(std::fs::read(&stale).unwrap(), b"partial");
3071 assert_eq!(
3072 inode(&torn_path),
3073 torn_inode,
3074 "identical unjournaled file was replaced"
3075 );
3076 assert!(!run.join(crate::resume::RESUME_LOG).exists());
3077 }
3078
3079 #[test]
3080 fn contract_refuses_atlas_beyond_gpu_limit_before_rendering() {
3081 let tmp = Temp::new();
3082 let mut job = resume_job(&tmp.0);
3083 job.character = body();
3084 let total: f32 = job.character.clips.iter().map(|c| c.duration).sum();
3085 assert!(total > 0.);
3086 // ~100 frame rows of 256 px > 16384, while staying under the 16 GiB cap.
3087 job.fps = (100. / total).ceil() as u32;
3088 job.target_width = 256;
3089 job.target_height = 256;
3090 let err = contract(job).err().unwrap();
3091 assert!(err.contains("atlas") && err.contains("16384"), "{err}");
3092 assert!(std::fs::read_dir(&tmp.0).unwrap().next().is_none());
3093 }
3094
3095 #[test]
3096 #[cfg(unix)]
3097 fn recover_cleans_and_places_builds_that_crashed_after_completion() {
3098 let tmp = Temp::new();
3099 drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();
3100 let reference = tree(&runs(&tmp.0)[0]);
3101 let consumer = |dir: &Path| {
3102 let mut names: Vec<String> = std::fs::read_dir(dir)
3103 .unwrap()
3104 .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
3105 .collect();
3106 names.sort();
3107 names
3108 };
3109 let clean = consumer(&runs(&tmp.0)[0]);
3110 assert_eq!(
3111 clean,
3112 [
3113 "male-body-atlas.ppda",
3114 "male-body-depth-atlas.png",
3115 "male-body-diffuse-atlas.png",
3116 "male-body-normal-atlas.png",
3117 "male-body-specular-atlas.png",
3118 "raw files",
3119 ]
3120 );
3121 // (a) crash after complete.json, before cleanup: all bookkeeping present.
3122 // (b) crash after cleanup, before the swap: only consumer output.
3123 for with_bookkeeping in [true, false] {
3124 let built = tmp
3125 .0
3126 .join(crate::resume::in_progress_name(7, 7, "male", "body"));
3127 std::fs::rename(tmp.0.join("male-body-atlas"), &built).unwrap();
3128 if with_bookkeeping {
3129 for name in [
3130 "render.json",
3131 "complete.json",
3132 crate::resume::JOURNAL,
3133 crate::resume::LOCK,
3134 crate::resume::RESUME_LOG,
3135 "attempt-000-cancelled.json",
3136 ] {
3137 std::fs::write(built.join(name), b"{}").unwrap();
3138 }
3139 }
3140 let notes = crate::resume::recover(&tmp.0).unwrap();
3141 assert_eq!(notes.len(), 1, "{notes:?}");
3142 let done = tmp.0.join("male-body-atlas");
3143 assert!(!built.exists());
3144 assert_eq!(consumer(&done), clean);
3145 let rebase = |t: std::collections::BTreeMap<PathBuf, String>| {
3146 t.into_values().collect::<Vec<_>>()
3147 };
3148 assert_eq!(rebase(tree(&done)), rebase(reference.clone()));
3149 crate::export_set::ExportRun::open(&done).unwrap();
3150 }
3151 }
3152
3153 #[test]
3154 #[cfg(unix)]
3155 fn crash_after_atlases_keeps_identical_and_quarantines_damaged_atlas() {
3156 let reference_dir = Temp::new();
3157 drive(resume_job(&reference_dir.0), RunTarget::Fresh, None)
3158 .0
3159 .unwrap();
3160 let reference_run = runs(&reference_dir.0)[0].clone();
3161 let tmp = Temp::new();
3162 drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();
3163 // Simulate a crash after the atlases/.ppda were published but before
3164 // complete.json (so still the in-progress build), one atlas damaged.
3165 let run = tmp
3166 .0
3167 .join(crate::resume::in_progress_name(1, 1, "male", "body"));
3168 std::fs::rename(&runs(&tmp.0)[0], &run).unwrap();
3169 // Recreate the in-progress bookkeeping that existed before complete.json.
3170 let export = crate::atlas::AtlasSet::open_run(&run)
3171 .unwrap()
3172 .ppda
3173 .export
3174 .unwrap();
3175 std::fs::write(
3176 run.join("render.json"),
3177 serde_json::to_vec_pretty(&export.render).unwrap(),
3178 )
3179 .unwrap();
3180 let journal: String = export
3181 .png_sha256
3182 .iter()
3183 .map(|(path, sha)| serde_json::json!({"path": path, "sha256": sha}).to_string() + "\n")
3184 .collect();
3185 std::fs::write(run.join(crate::resume::JOURNAL), journal).unwrap();
3186 let diffuse = run.join("male-body-diffuse-atlas.png");
3187 let normal = run.join("male-body-normal-atlas.png");
3188 let normal_inode = inode(&normal);
3189 let good = std::fs::read(&diffuse).unwrap();
3190 std::fs::remove_file(&diffuse).unwrap();
3191 std::fs::write(&diffuse, b"damaged").unwrap();
3192 drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
3193 .0
3194 .unwrap();
3195 let built = run;
3196 let run = tmp.0.join("male-body-atlas");
3197 let (normal, diffuse) = (
3198 run.join(normal.file_name().unwrap()),
3199 run.join(diffuse.file_name().unwrap()),
3200 );
3201 assert!(!built.exists());
3202 assert_eq!(inode(&normal), normal_inode, "identical atlas rewritten");
3203 assert_eq!(std::fs::read(&diffuse).unwrap(), good);
3204 assert_eq!(
3205 std::fs::read(run.join("male-body-diffuse-atlas.png.corrupt-a001-0")).unwrap(),
3206 b"damaged"
3207 );
3208 crate::atlas::AtlasSet::open_run(&run).unwrap();
3209 crate::export_set::ExportRun::open(&run).unwrap();
3210 // Byte-identical to an uninterrupted run (deterministic encoding).
3211 for name in [
3212 "male-body-atlas.ppda",
3213 "male-body-diffuse-atlas.png",
3214 "male-body-depth-atlas.png",
3215 ] {
3216 assert_eq!(
3217 std::fs::read(run.join(name)).unwrap(),
3218 std::fs::read(reference_run.join(name)).unwrap(),
3219 "{name}"
3220 );
3221 }
3222 }
3223
3224 #[test]
3225 fn result_folder_swap_keeps_last_good_and_recovers_interrupted_swaps() {
3226 let tmp = Temp::new();
3227 drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();
3228 let done = tmp.0.join("male-body-atlas");
3229 let good = tree(&done);
3230 // A legacy run-* folder is left alone.
3231 let legacy = tmp.0.join("run-123-4");
3232 std::fs::create_dir(&legacy).unwrap();
3233 std::fs::write(legacy.join("x"), b"old").unwrap();
3234 // Cancelled re-render: last good result untouched, build stays resumable.
3235 assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(10))
3236 .0
3237 .is_err());
3238 assert_eq!(tree(&done), good);
3239 let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
3240 assert_eq!(crate::resume::candidates(&tmp.0, &doc).unwrap().len(), 1);
3241 // Crash after moving the old result aside but before placing the new one:
3242 // the old result is restored.
3243 let aside = tmp.0.join(".replaced-male-body-atlas-77");
3244 std::fs::rename(&done, &aside).unwrap();
3245 let notes = crate::resume::recover(&tmp.0).unwrap();
3246 assert_eq!(notes.len(), 1, "{notes:?}");
3247 assert!(!aside.exists());
3248 assert_eq!(tree(&done), good);
3249 // Crash after completing a build but before the swap: promoted, old removed.
3250 let fresh = Temp::new();
3251 drive(resume_job(&fresh.0), RunTarget::Fresh, None)
3252 .0
3253 .unwrap();
3254 let built = tmp
3255 .0
3256 .join(crate::resume::in_progress_name(9, 9, "male", "body"));
3257 std::fs::rename(fresh.0.join("male-body-atlas"), &built).unwrap();
3258 std::fs::write(tmp.0.join("male-body-atlas/marker-old"), b"1").unwrap();
3259 let notes = crate::resume::recover(&tmp.0).unwrap();
3260 assert_eq!(notes.len(), 1, "{notes:?}");
3261 assert!(!built.exists());
3262 assert!(!done.join("marker-old").exists());
3263 crate::atlas::AtlasSet::open_run(&done).unwrap();
3264 assert!(std::fs::read_dir(&tmp.0).unwrap().all(|e| !e
3265 .unwrap()
3266 .file_name()
3267 .to_string_lossy()
3268 .starts_with(".replaced-")));
3269 assert_eq!(std::fs::read(legacy.join("x")).unwrap(), b"old");
3270 }
3271
3272 #[test]
3273 fn unjournaled_different_file_is_quarantined_not_overwritten() {
3274 let tmp = Temp::new();
3275 assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(6))
3276 .0
3277 .is_err());
3278 let run = runs(&tmp.0)[0].clone();
3279 // A planned PNG appears that the journal never recorded and that differs.
3280 let foreign = run.join("raw files/male/body/clip000-bind/f000000/p-60_y030_specular.png");
3281 std::fs::create_dir_all(foreign.parent().unwrap()).unwrap();
3282 std::fs::write(&foreign, b"foreign").unwrap();
3283 drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
3284 .0
3285 .unwrap();
3286 let done = tmp.0.join("male-body-atlas");
3287 let foreign = done.join(foreign.strip_prefix(&run).unwrap());
3288 let run = done;
3289 crate::export_set::ExportRun::open(&run).unwrap();
3290 assert_eq!(
3291 std::fs::read(foreign.with_file_name("p-60_y030_specular.png.corrupt-a001-0")).unwrap(),
3292 b"foreign"
3293 );
3294 }
3295
3296 #[test]
3297 fn resume_refuses_mismatch_foreign_dirs_unknown_journal_and_locked_runs() {
3298 let tmp = Temp::new();
3299 assert!(drive(resume_job(&tmp.0), RunTarget::Fresh, Some(12))
3300 .0
3301 .is_err());
3302 let run = runs(&tmp.0)[0].clone();
3303 let before = std::fs::read(run.join("cancelled.json")).unwrap();
3304 // Contract drift: FPS, body bytes, target size.
3305 let mut job = resume_job(&tmp.0);
3306 job.fps = 24;
3307 let err = drive(job, RunTarget::Resume(run.clone()), None)
3308 .0
3309 .unwrap_err();
3310 assert!(err.contains("fps"), "{err}");
3311 let mut job = resume_job(&tmp.0);
3312 job.body_model.sha256 = crate::digest::sha256_hex(b"edited body");
3313 let err = drive(job, RunTarget::Resume(run.clone()), None)
3314 .0
3315 .unwrap_err();
3316 assert!(err.contains("body_model"), "{err}");
3317 let mut job = resume_job(&tmp.0);
3318 job.target_width = 3;
3319 let doc = contract(job.clone()).unwrap().render_doc;
3320 let found = crate::resume::candidates(&tmp.0, &doc).unwrap();
3321 assert!(found[0].blocked.as_ref().unwrap().contains("target"));
3322 assert!(drive(job, RunTarget::Resume(run.clone()), None).0.is_err());
3323 // Refusals leave the run and its marker untouched.
3324 assert_eq!(std::fs::read(run.join("cancelled.json")).unwrap(), before);
3325 assert!(!run.join(crate::resume::RESUME_LOG).exists());
3326 // Directory not directly inside the output directory.
3327 let other = Temp::new();
3328 let err = drive(resume_job(&other.0), RunTarget::Resume(run.clone()), None)
3329 .0
3330 .unwrap_err();
3331 assert!(err.contains("uitvoermap"), "{err}");
3332 // Locked by a running worker.
3333 let lock = std::fs::OpenOptions::new()
3334 .write(true)
3335 .open(run.join(crate::resume::LOCK))
3336 .unwrap();
3337 lock.lock().unwrap();
3338 let err = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
3339 .0
3340 .unwrap_err();
3341 assert!(err.contains("run.lock"), "{err}");
3342 let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
3343 assert!(crate::resume::candidates(&tmp.0, &doc).unwrap()[0]
3344 .blocked
3345 .is_some());
3346 drop(lock);
3347 // Journal naming a path outside the plan.
3348 use std::io::Write;
3349 let mut j = std::fs::OpenOptions::new()
3350 .append(true)
3351 .open(run.join(crate::resume::JOURNAL))
3352 .unwrap();
3353 writeln!(
3354 j,
3355 "{}",
3356 serde_json::json!({"path":"../escape.png","sha256":crate::digest::sha256_hex(b"x")})
3357 )
3358 .unwrap();
3359 let err = drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
3360 .0
3361 .unwrap_err();
3362 assert!(err.contains("onbekend pad"), "{err}");
3363 }
3364}