//! Synthetic completed export runs for tests, examples and GUI smokes. //! //! Writes real loose target PNGs (uniform colour per cell), schema-4 //! `render.json`, the per-map atlases + `.ppda` through the production //! [`crate::atlas::write_atlases`] path, and `complete.json`. This replaces //! hand-written partial manifests; it is not used by the renderer itself. use crate::{ atlas, digest, export_set::{complete_identity_json, OUTPUT_META_VERSION}, preview::PRODUCTION_PITCHES, production::{self, ClipPlan, Plan, FAR, NEAR, PASSES}, project::CameraFraming, render_job::ImageBuf, }; use std::{collections::BTreeMap, path::Path, sync::atomic::AtomicBool}; /// Uniform per-cell map values. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Pixel { pub diffuse: [u8; 4], pub normal: [u8; 4], pub specular: [u8; 4], pub depth: u16, } #[derive(Debug, Clone)] pub struct SyntheticRun { pub project_id: String, pub sex: String, pub set: String, pub equipment_sha256: Option, pub body_bytes: Vec, pub target: [u32; 2], pub fps: u32, pub framing: CameraFraming, /// (source name, frames, duration seconds) pub clips: Vec<(String, u32, f32)>, } impl SyntheticRun { pub fn body(project_id: &str, sex: &str, body_bytes: &[u8]) -> Self { Self { project_id: project_id.into(), sex: sex.into(), set: "body".into(), equipment_sha256: None, body_bytes: body_bytes.to_vec(), target: [1, 1], fps: crate::project::DEFAULT_FPS, framing: CameraFraming { height_milli: 1800, center_y_milli: 900, pivot_y_milli: Some(1500), }, clips: vec![("bind".into(), 1, 0.0)], } } pub fn plan(&self) -> Plan { let clips: Vec = self .clips .iter() .enumerate() .map(|(i, (name, frames, _))| ClipPlan { source_name: name.clone(), token: production::clip_token(i, name), frames: *frames, }) .collect(); let frames: u32 = clips.iter().map(|c| c.frames).sum(); let images = frames * crate::preview::PRODUCTION_VIEWS; Plan { clips, images, passes: images * PASSES.len() as u32, files: images * 4, } } /// `render.json` with the same keys production writes. pub fn render_doc(&self) -> serde_json::Value { let plan = self.plan(); let clips: Vec<_> = plan .clips .iter() .zip(&self.clips) .map(|(c, (_, _, duration))| { serde_json::json!({ "name": c.source_name, "token": c.token, "frames": c.frames, "duration": duration, }) }) .collect(); let equipment = self .equipment_sha256 .as_ref() .map(|sha| serde_json::json!({"name": self.set, "sha256": sha})); serde_json::json!({ "format_version": OUTPUT_META_VERSION, "status": "incomplete", "project": {"id": self.project_id, "name": "Synthetic"}, "sex": self.sex, "set": self.set, "body_model": {"path": "assets/body/synthetic.glb", "sha256": digest::sha256_hex(&self.body_bytes)}, "equipment_preset": equipment, "target": self.target, "fps": self.fps, "highres_scale": production::SCALE, "framing": self.framing, "pivot_source": {"kind": "manual"}, "near": NEAR, "far": FAR, "normal_space": "view (+Y up), PNG rows top-down", "camera_distance": 10, "yaw_zero": "camera on -Z", "pitches": PRODUCTION_PITCHES, "yaws": production::production_yaws(), "yaw_step": 30, "sampling": "synthetic fixture", "alpha": "straight", "diffuse": "unlit", "depth": "16-bit gray+alpha; distance=near+(sample/65535)*(far-near)", "specular": "scalar", "pass_order": PASSES, "saved_maps": ["diffuse", "depth", "normal", "specular"], "clips": clips, "images": plan.images, "passes": plan.passes, "png_files": plan.files, }) } /// Write the complete run into `root` (created if needed). pub fn write( &self, root: &Path, pixel: impl Fn(usize, u32, i32, u32) -> Pixel, ) -> Result<(), String> { std::fs::create_dir_all(root).map_err(|e| e.to_string())?; let plan = self.plan(); let render = self.render_doc(); let [w, h] = self.target; let n = w as usize * h as usize; let mut sums = BTreeMap::new(); for s in production::samples(&plan) { let px = pixel(s.clip, s.frame, s.pitch, s.yaw); for map in production::SAVED_ORDER { let rel = production::output_path( &self.sex, &self.set, &plan.clips[s.clip].token, s.frame, s.pitch, s.yaw, map, )?; let digest = if map == "depth" { let pair = if px.diffuse[3] == 0 { [0, 0] } else { [px.depth, 65535] }; let samples: Vec = pair.repeat(n); production::write_depth(root, &rel, w, h, &samples)? } else { let rgba = match map { "diffuse" => px.diffuse, "normal" => px.normal, _ => px.specular, }; production::write_rgba( root, &rel, &ImageBuf { width: w, height: h, rgba: rgba.repeat(n), }, )? }; sums.insert(rel.to_string_lossy().replace('\\', "/"), digest); } } write_json(root, "render.json", &render)?; let cancel = AtomicBool::new(false); let atlas = atlas::write_atlases( &atlas::AtlasJob { root, render_doc: &render, plan: &plan, sex: &self.sex, set: &self.set, cell_width: w, cell_height: h, png_sha256: &sums, attempt: 0, cancel: &cancel, }, &mut |_| {}, )?; let identity = complete_identity_json(&render)?; write_json( root, "complete.json", &serde_json::json!({ "format_version": OUTPUT_META_VERSION, "status": "complete", "identity": identity, "passes": plan.passes, "png_files": plan.files, "png_sha256": sums, "atlas": atlas.to_json(), }), ) } } fn write_json(root: &Path, name: &str, value: &serde_json::Value) -> Result<(), String> { let bytes = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?; production::publish_bytes(root, Path::new(name), &bytes) }