BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / production.rs

production.rs

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