BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / production.rs

production.rs

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