BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / production.rs

production.rs

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