BlueGit

golden-age paperdoll-renderer

Atlas reader tests, cross-check helper, examples on 60 views + atlases

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 7 days ago

Hash
c8ede4e7d4518a2db1c1631a442ca6fa1c82647e
Parent
4a5fbc5c
11 files changed +414 −192
examples/fbx_live_fixture.rs +4 −1
@@ -154,7 +154,8 @@ fn verify(root: &Path, width: u32, height: u32) -> Result<serde_json::Value, Str
154 154 continue;
155 155 }
156 156 let name = path.file_name().unwrap().to_string_lossy().to_string();
157 if !name.ends_with(".png") {
157 if !name.ends_with(".png") || name.ends_with("-atlas.png") {
158 // Atlases are verified cell-by-cell by atlas::cross_check_run.
158 159 continue;
159 160 }
160 161 let map = name
@@ -190,7 +191,9 @@ fn verify(root: &Path, width: u32, height: u32) -> Result<serde_json::Value, Str
190 191 }
191 192 }
192 193 }
194 let atlas = paperdoll_renderer::atlas::cross_check_run(&dir)?;
193 195 runs.push(serde_json::json!({
196 "atlas": atlas,
194 197 "dir": dir.display().to_string(),
195 198 "png_files": run.complete.png_files,
196 199 "maps": counts,
@@ -154,7 +154,8 @@ fn verify(root: &Path, width: u32, height: u32) -> Result<serde_json::Value, Str
154 continue;
154 continue;
155 }
155 }
156 let name = path.file_name().unwrap().to_string_lossy().to_string();
156 let name = path.file_name().unwrap().to_string_lossy().to_string();
157 if !name.ends_with(".png") {
157 if !name.ends_with(".png") || name.ends_with("-atlas.png") {
158 // Atlases are verified cell-by-cell by atlas::cross_check_run.
158 continue;
159 continue;
159 }
160 }
160 let map = name
161 let map = name
@@ -190,7 +191,9 @@ fn verify(root: &Path, width: u32, height: u32) -> Result<serde_json::Value, Str
190 }
191 }
191 }
192 }
192 }
193 }
194 let atlas = paperdoll_renderer::atlas::cross_check_run(&dir)?;
193 runs.push(serde_json::json!({
195 runs.push(serde_json::json!({
196 "atlas": atlas,
194 "dir": dir.display().to_string(),
197 "dir": dir.display().to_string(),
195 "png_files": run.complete.png_files,
198 "png_files": run.complete.png_files,
196 "maps": counts,
199 "maps": counts,
examples/preview2d_smoke.rs +24 −171
@@ -1,16 +1,13 @@
1 //! Native GUI smoke for the rendered 2D preview tab (schema-3 export fixtures).
1 //! Native GUI smoke for the rendered 2D preview tab: it loads a synthetic
2 //! export through its `.ppda` + atlases (loose PNGs are deleted first).
2 3 //! Usage: preview2d_smoke OUTPUT_DIR
3 4
4 5 use eframe::App;
5 use paperdoll_renderer::digest;
6 6 use paperdoll_renderer::editor::EditorApp;
7 use paperdoll_renderer::export_set::complete_identity_json;
7 use paperdoll_renderer::export_fixture::{Pixel, SyntheticRun};
8 8 use paperdoll_renderer::preview2d::discover_completed_runs;
9 use paperdoll_renderer::production::{write_depth, write_rgba, FAR, NEAR, PASSES};
10 use paperdoll_renderer::project::CameraFraming;
11 9 use paperdoll_renderer::render_job::ImageBuf;
12 10 use paperdoll_renderer::session::Session;
13 use std::collections::BTreeMap;
14 11 use std::fs;
15 12 use std::path::{Path, PathBuf};
16 13 use std::time::{Duration, Instant};
@@ -83,173 +80,26 @@ fn write_png(path: &Path, img: &ImageBuf) -> Result<(), String> {
83 80 .map_err(|e| e.to_string())
84 81 }
85 82
86 fn write_atomic_json(root: &Path, name: &str, value: &serde_json::Value) -> Result<(), String> {
87 let data = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
88 let path = root.join(name);
89 let temp = path.with_extension(format!("part-{}", std::process::id()));
90 fs::write(&temp, &data).map_err(|e| e.to_string())?;
91 fs::hard_link(&temp, &path).map_err(|e| e.to_string())?;
92 let _ = fs::remove_file(temp);
93 Ok(())
94 }
95
96 #[allow(clippy::too_many_arguments)]
97 fn publish_pose(
98 root: &Path,
99 sex: &str,
100 set: &str,
101 clip: &str,
102 frame: u32,
103 pitch: i32,
104 yaw: u32,
105 diffuse: [u8; 4],
106 depth_gray: u16,
107 ) -> Result<BTreeMap<String, String>, String> {
108 let mut map = BTreeMap::new();
109 let base = format!("{sex}/{set}/{clip}/f{frame:06}/p{pitch:+03}_y{yaw:03}");
110 let normal = [128u8, 128, 255, 255];
111 let specular = [0u8, 0, 0, 255];
112 for (rel, kind) in [
113 (format!("{base}_diffuse.png"), "diffuse"),
114 (format!("{base}_depth.png"), "depth"),
115 (format!("{base}_normal.png"), "normal"),
116 (format!("{base}_specular.png"), "specular"),
117 ] {
118 let path = Path::new(&rel);
119 match kind {
120 "depth" => {
121 write_depth(
122 root,
123 path,
124 2,
125 2,
126 &[
127 depth_gray, 65535, depth_gray, 65535, depth_gray, 65535, depth_gray, 65535,
128 ],
129 )?;
130 }
131 "normal" => {
132 write_rgba(
133 root,
134 path,
135 &ImageBuf {
136 width: 2,
137 height: 2,
138 rgba: normal.repeat(4),
139 },
140 )?;
141 }
142 "specular" => {
143 write_rgba(
144 root,
145 path,
146 &ImageBuf {
147 width: 2,
148 height: 2,
149 rgba: specular.repeat(4),
150 },
151 )?;
152 }
153 _ => {
154 write_rgba(
155 root,
156 path,
157 &ImageBuf {
158 width: 2,
159 height: 2,
160 rgba: diffuse.repeat(4),
161 },
162 )?;
163 }
164 }
165 let bytes = fs::read(root.join(&rel)).map_err(|e| e.to_string())?;
166 map.insert(rel, digest::sha256_hex(&bytes));
167 }
168 Ok(map)
169 }
170
83 /// Body export: 2 frames, every view gets a distinct colour from its
84 /// pitch/yaw, written with real loose PNGs + atlases + .ppda.
171 85 fn install_body_export(project: &Path) -> Result<(), String> {
172 let run = project.join("renders/run-preview2d-body");
173 fs::create_dir_all(&run).map_err(|e| e.to_string())?;
174 let body_bytes = b"preview2d-smoke-body";
175 let body_hash = digest::sha256_hex(body_bytes);
176 let clips = vec![serde_json::json!({
177 "name": "walk",
178 "token": "clip000-walk",
179 "frames": 2,
180 "duration": 0.1667
181 })];
182 let mut checksums = BTreeMap::new();
183 for frame in [0u32, 1] {
184 for pitch in [-30i32, 0, 30] {
185 for yaw in [0u32, 30, 90] {
186 let tint = [40u8.wrapping_add(frame as u8 * 80), 90, 140, 255];
187 checksums.extend(publish_pose(
188 &run,
189 "male",
190 "body",
191 "clip000-walk",
192 frame,
193 pitch,
194 yaw,
195 tint,
196 25000 - frame as u16 * 1000,
197 )?);
198 }
199 }
200 }
201 let png_files = checksums.len() as u32;
202 let render = serde_json::json!({
203 "format_version": 3,
204 "status": "incomplete",
205 "project": {"id": "smoke-preview2d", "name": "Preview2d Smoke"},
206 "sex": "male",
207 "set": "body",
208 "body_model": {"path": "assets/body/test-body-male.glb", "sha256": body_hash},
209 "equipment_preset": null,
210 "target": [2, 2],
211 "fps": 12,
212 "highres_scale": 8,
213 "framing": CameraFraming {
214 height_milli: 1800,
215 center_y_milli: 900,
216 pivot_y_milli: Some(1500),
86 let mut spec = SyntheticRun::body("smoke-preview2d", "male", b"preview2d-smoke-body");
87 spec.target = [2, 2];
88 spec.clips = vec![("walk".into(), 2, 0.5)];
89 spec.write(
90 &project.join("renders/run-preview2d-body"),
91 |_, frame, pitch, yaw| Pixel {
92 diffuse: [
93 40u8.wrapping_add(frame as u8 * 80),
94 (pitch + 90) as u8,
95 (yaw / 2) as u8,
96 255,
97 ],
98 normal: [128, 128, 255, 255],
99 specular: [0, 0, 0, 255],
100 depth: 25000 - frame as u16 * 1000,
217 101 },
218 "pivot_source": {"kind": "manual"},
219 "near": NEAR,
220 "far": FAR,
221 "camera_distance": 10.0,
222 "yaw_zero": "camera on -Z",
223 "normal_space": "view (+Y up), PNG rows top-down",
224 "pitches": [-30, 0, 30],
225 "yaw_step": 30,
226 "sampling": "smoke",
227 "alpha": "straight",
228 "diffuse": "unlit",
229 "depth": "16-bit",
230 "specular": "scalar",
231 "pass_order": PASSES,
232 "saved_maps": ["diffuse", "depth", "normal", "specular"],
233 "clips": clips,
234 "images": 2 * 36,
235 "passes": 2 * 36 * 6,
236 "png_files": png_files
237 });
238 write_atomic_json(&run, "render.json", &render)?;
239 let identity = complete_identity_json(&render)?;
240 write_atomic_json(
241 &run,
242 "complete.json",
243 &serde_json::json!({
244 "format_version": 3,
245 "status": "complete",
246 "identity": identity,
247 "passes": 2 * 36 * 6,
248 "png_files": png_files,
249 "png_sha256": checksums
250 }),
251 )?;
252 Ok(())
102 )
253 103 }
254 104
255 105 fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -264,11 +114,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
264 114 session.install_new_project(&project)?;
265 115 session.save()?;
266 116 install_body_export(&project)?;
117 // Prove the preview reads the atlases: remove every loose PNG.
118 fs::remove_dir_all(project.join("renders/run-preview2d-body/male"))?;
267 119 let catalog = discover_completed_runs(&project);
268 120 fs::write(
269 121 out.join("catalog.json"),
270 122 serde_json::to_vec_pretty(&serde_json::json!({
271 123 "count": catalog.len(),
124 "fps": catalog.iter().map(|e| e.fps).collect::<Vec<_>>(),
272 125 "sets": catalog.iter().map(|e| format!("{}:{}", e.sex, e.set)).collect::<Vec<_>>(),
273 126 }))?,
274 127 )?;
@@ -1,16 +1,13 @@
1//! Native GUI smoke for the rendered 2D preview tab (schema-3 export fixtures).
1//! Native GUI smoke for the rendered 2D preview tab: it loads a synthetic
2//! export through its `.ppda` + atlases (loose PNGs are deleted first).
2//! Usage: preview2d_smoke OUTPUT_DIR
3//! Usage: preview2d_smoke OUTPUT_DIR
3
4
4use eframe::App;
5use eframe::App;
5use paperdoll_renderer::digest;
6use paperdoll_renderer::editor::EditorApp;
6use paperdoll_renderer::editor::EditorApp;
7use paperdoll_renderer::export_set::complete_identity_json;
7use paperdoll_renderer::export_fixture::{Pixel, SyntheticRun};
8use paperdoll_renderer::preview2d::discover_completed_runs;
8use paperdoll_renderer::preview2d::discover_completed_runs;
9use paperdoll_renderer::production::{write_depth, write_rgba, FAR, NEAR, PASSES};
10use paperdoll_renderer::project::CameraFraming;
11use paperdoll_renderer::render_job::ImageBuf;
9use paperdoll_renderer::render_job::ImageBuf;
12use paperdoll_renderer::session::Session;
10use paperdoll_renderer::session::Session;
13use std::collections::BTreeMap;
14use std::fs;
11use std::fs;
15use std::path::{Path, PathBuf};
12use std::path::{Path, PathBuf};
16use std::time::{Duration, Instant};
13use std::time::{Duration, Instant};
@@ -83,173 +80,26 @@ fn write_png(path: &Path, img: &ImageBuf) -> Result<(), String> {
83 .map_err(|e| e.to_string())
80 .map_err(|e| e.to_string())
84}
81}
85
82
86fn write_atomic_json(root: &Path, name: &str, value: &serde_json::Value) -> Result<(), String> {
83/// Body export: 2 frames, every view gets a distinct colour from its
87 let data = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
84/// pitch/yaw, written with real loose PNGs + atlases + .ppda.
88 let path = root.join(name);
89 let temp = path.with_extension(format!("part-{}", std::process::id()));
90 fs::write(&temp, &data).map_err(|e| e.to_string())?;
91 fs::hard_link(&temp, &path).map_err(|e| e.to_string())?;
92 let _ = fs::remove_file(temp);
93 Ok(())
94}
95
96#[allow(clippy::too_many_arguments)]
97fn publish_pose(
98 root: &Path,
99 sex: &str,
100 set: &str,
101 clip: &str,
102 frame: u32,
103 pitch: i32,
104 yaw: u32,
105 diffuse: [u8; 4],
106 depth_gray: u16,
107) -> Result<BTreeMap<String, String>, String> {
108 let mut map = BTreeMap::new();
109 let base = format!("{sex}/{set}/{clip}/f{frame:06}/p{pitch:+03}_y{yaw:03}");
110 let normal = [128u8, 128, 255, 255];
111 let specular = [0u8, 0, 0, 255];
112 for (rel, kind) in [
113 (format!("{base}_diffuse.png"), "diffuse"),
114 (format!("{base}_depth.png"), "depth"),
115 (format!("{base}_normal.png"), "normal"),
116 (format!("{base}_specular.png"), "specular"),
117 ] {
118 let path = Path::new(&rel);
119 match kind {
120 "depth" => {
121 write_depth(
122 root,
123 path,
124 2,
125 2,
126 &[
127 depth_gray, 65535, depth_gray, 65535, depth_gray, 65535, depth_gray, 65535,
128 ],
129 )?;
130 }
131 "normal" => {
132 write_rgba(
133 root,
134 path,
135 &ImageBuf {
136 width: 2,
137 height: 2,
138 rgba: normal.repeat(4),
139 },
140 )?;
141 }
142 "specular" => {
143 write_rgba(
144 root,
145 path,
146 &ImageBuf {
147 width: 2,
148 height: 2,
149 rgba: specular.repeat(4),
150 },
151 )?;
152 }
153 _ => {
154 write_rgba(
155 root,
156 path,
157 &ImageBuf {
158 width: 2,
159 height: 2,
160 rgba: diffuse.repeat(4),
161 },
162 )?;
163 }
164 }
165 let bytes = fs::read(root.join(&rel)).map_err(|e| e.to_string())?;
166 map.insert(rel, digest::sha256_hex(&bytes));
167 }
168 Ok(map)
169}
170
171fn install_body_export(project: &Path) -> Result<(), String> {
85fn install_body_export(project: &Path) -> Result<(), String> {
172 let run = project.join("renders/run-preview2d-body");
86 let mut spec = SyntheticRun::body("smoke-preview2d", "male", b"preview2d-smoke-body");
173 fs::create_dir_all(&run).map_err(|e| e.to_string())?;
87 spec.target = [2, 2];
174 let body_bytes = b"preview2d-smoke-body";
88 spec.clips = vec![("walk".into(), 2, 0.5)];
175 let body_hash = digest::sha256_hex(body_bytes);
89 spec.write(
176 let clips = vec![serde_json::json!({
90 &project.join("renders/run-preview2d-body"),
177 "name": "walk",
91 |_, frame, pitch, yaw| Pixel {
178 "token": "clip000-walk",
92 diffuse: [
179 "frames": 2,
93 40u8.wrapping_add(frame as u8 * 80),
180 "duration": 0.1667
94 (pitch + 90) as u8,
181 })];
95 (yaw / 2) as u8,
182 let mut checksums = BTreeMap::new();
96 255,
183 for frame in [0u32, 1] {
97 ],
184 for pitch in [-30i32, 0, 30] {
98 normal: [128, 128, 255, 255],
185 for yaw in [0u32, 30, 90] {
99 specular: [0, 0, 0, 255],
186 let tint = [40u8.wrapping_add(frame as u8 * 80), 90, 140, 255];
100 depth: 25000 - frame as u16 * 1000,
187 checksums.extend(publish_pose(
188 &run,
189 "male",
190 "body",
191 "clip000-walk",
192 frame,
193 pitch,
194 yaw,
195 tint,
196 25000 - frame as u16 * 1000,
197 )?);
198 }
199 }
200 }
201 let png_files = checksums.len() as u32;
202 let render = serde_json::json!({
203 "format_version": 3,
204 "status": "incomplete",
205 "project": {"id": "smoke-preview2d", "name": "Preview2d Smoke"},
206 "sex": "male",
207 "set": "body",
208 "body_model": {"path": "assets/body/test-body-male.glb", "sha256": body_hash},
209 "equipment_preset": null,
210 "target": [2, 2],
211 "fps": 12,
212 "highres_scale": 8,
213 "framing": CameraFraming {
214 height_milli: 1800,
215 center_y_milli: 900,
216 pivot_y_milli: Some(1500),
217 },
101 },
218 "pivot_source": {"kind": "manual"},
102 )
219 "near": NEAR,
220 "far": FAR,
221 "camera_distance": 10.0,
222 "yaw_zero": "camera on -Z",
223 "normal_space": "view (+Y up), PNG rows top-down",
224 "pitches": [-30, 0, 30],
225 "yaw_step": 30,
226 "sampling": "smoke",
227 "alpha": "straight",
228 "diffuse": "unlit",
229 "depth": "16-bit",
230 "specular": "scalar",
231 "pass_order": PASSES,
232 "saved_maps": ["diffuse", "depth", "normal", "specular"],
233 "clips": clips,
234 "images": 2 * 36,
235 "passes": 2 * 36 * 6,
236 "png_files": png_files
237 });
238 write_atomic_json(&run, "render.json", &render)?;
239 let identity = complete_identity_json(&render)?;
240 write_atomic_json(
241 &run,
242 "complete.json",
243 &serde_json::json!({
244 "format_version": 3,
245 "status": "complete",
246 "identity": identity,
247 "passes": 2 * 36 * 6,
248 "png_files": png_files,
249 "png_sha256": checksums
250 }),
251 )?;
252 Ok(())
253}
103}
254
104
255fn main() -> Result<(), Box<dyn std::error::Error>> {
105fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -264,11 +114,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
264 session.install_new_project(&project)?;
114 session.install_new_project(&project)?;
265 session.save()?;
115 session.save()?;
266 install_body_export(&project)?;
116 install_body_export(&project)?;
117 // Prove the preview reads the atlases: remove every loose PNG.
118 fs::remove_dir_all(project.join("renders/run-preview2d-body/male"))?;
267 let catalog = discover_completed_runs(&project);
119 let catalog = discover_completed_runs(&project);
268 fs::write(
120 fs::write(
269 out.join("catalog.json"),
121 out.join("catalog.json"),
270 serde_json::to_vec_pretty(&serde_json::json!({
122 serde_json::to_vec_pretty(&serde_json::json!({
271 "count": catalog.len(),
123 "count": catalog.len(),
124 "fps": catalog.iter().map(|e| e.fps).collect::<Vec<_>>(),
272 "sets": catalog.iter().map(|e| format!("{}:{}", e.sex, e.set)).collect::<Vec<_>>(),
125 "sets": catalog.iter().map(|e| format!("{}:{}", e.sex, e.set)).collect::<Vec<_>>(),
273 }))?,
126 }))?,
274 )?;
127 )?;
examples/production_fixture.rs +13 −0
@@ -124,5 +124,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
124 124 if report["success"] != true {
125 125 return Err("production smoke failed; see smoke-result.json".into());
126 126 }
127 // Newest run: loose maps and .ppda atlases must agree cell by cell.
128 let mut runs = std::fs::read_dir(root.join("renders"))?
129 .map(|e| e.map(|e| e.path()))
130 .collect::<Result<Vec<_>, _>>()?;
131 runs.retain(|p| p.is_dir());
132 runs.sort();
133 let run = runs.last().ok_or("geen run")?;
134 let atlas = paperdoll_renderer::atlas::cross_check_run(run)?;
135 println!("ATLAS {atlas}");
136 std::fs::write(
137 root.join("atlas-check.json"),
138 serde_json::to_vec_pretty(&atlas)?,
139 )?;
127 140 Ok(())
128 141 }
@@ -124,5 +124,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
124 if report["success"] != true {
124 if report["success"] != true {
125 return Err("production smoke failed; see smoke-result.json".into());
125 return Err("production smoke failed; see smoke-result.json".into());
126 }
126 }
127 // Newest run: loose maps and .ppda atlases must agree cell by cell.
128 let mut runs = std::fs::read_dir(root.join("renders"))?
129 .map(|e| e.map(|e| e.path()))
130 .collect::<Result<Vec<_>, _>>()?;
131 runs.retain(|p| p.is_dir());
132 runs.sort();
133 let run = runs.last().ok_or("geen run")?;
134 let atlas = paperdoll_renderer::atlas::cross_check_run(run)?;
135 println!("ATLAS {atlas}");
136 std::fs::write(
137 root.join("atlas-check.json"),
138 serde_json::to_vec_pretty(&atlas)?,
139 )?;
127 Ok(())
140 Ok(())
128}
141}
examples/resume_fixture.rs +11 −6
@@ -160,18 +160,23 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
160 160 let mode = a.first().ok_or("mode")?.clone();
161 161 let root = PathBuf::from(a.get(1).ok_or("ROOT")?);
162 162 if mode == "verify" {
163 // Open every run directory through the strict schema-3 export_set reader.
163 // Open every run directory through export_set and the .ppda atlas reader.
164 164 let mut dirs = std::fs::read_dir(root.join("renders"))?
165 165 .map(|e| e.map(|e| e.path()))
166 166 .collect::<Result<Vec<_>, _>>()?;
167 167 dirs.sort();
168 168 for dir in dirs {
169 169 match paperdoll_renderer::export_set::ExportRun::open(&dir) {
170 Ok(run) => println!(
171 "OPEN-OK {} png_files={}",
172 dir.display(),
173 run.complete.png_files
174 ),
170 Ok(run) => {
171 let atlas = paperdoll_renderer::atlas::cross_check_run(&dir)
172 .map(|a| a["cells_equal_to_loose_pngs"].to_string())
173 .unwrap_or_else(|e| format!("FOUT {e}"));
174 println!(
175 "OPEN-OK {} png_files={} atlas_cells={atlas}",
176 dir.display(),
177 run.complete.png_files
178 )
179 }
175 180 Err(e) => println!("OPEN-REJECTED {}: {e}", dir.display()),
176 181 }
177 182 }
@@ -160,18 +160,23 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
160 let mode = a.first().ok_or("mode")?.clone();
160 let mode = a.first().ok_or("mode")?.clone();
161 let root = PathBuf::from(a.get(1).ok_or("ROOT")?);
161 let root = PathBuf::from(a.get(1).ok_or("ROOT")?);
162 if mode == "verify" {
162 if mode == "verify" {
163 // Open every run directory through the strict schema-3 export_set reader.
163 // Open every run directory through export_set and the .ppda atlas reader.
164 let mut dirs = std::fs::read_dir(root.join("renders"))?
164 let mut dirs = std::fs::read_dir(root.join("renders"))?
165 .map(|e| e.map(|e| e.path()))
165 .map(|e| e.map(|e| e.path()))
166 .collect::<Result<Vec<_>, _>>()?;
166 .collect::<Result<Vec<_>, _>>()?;
167 dirs.sort();
167 dirs.sort();
168 for dir in dirs {
168 for dir in dirs {
169 match paperdoll_renderer::export_set::ExportRun::open(&dir) {
169 match paperdoll_renderer::export_set::ExportRun::open(&dir) {
170 Ok(run) => println!(
170 Ok(run) => {
171 "OPEN-OK {} png_files={}",
171 let atlas = paperdoll_renderer::atlas::cross_check_run(&dir)
172 dir.display(),
172 .map(|a| a["cells_equal_to_loose_pngs"].to_string())
173 run.complete.png_files
173 .unwrap_or_else(|e| format!("FOUT {e}"));
174 ),
174 println!(
175 "OPEN-OK {} png_files={} atlas_cells={atlas}",
176 dir.display(),
177 run.complete.png_files
178 )
179 }
175 Err(e) => println!("OPEN-REJECTED {}: {e}", dir.display()),
180 Err(e) => println!("OPEN-REJECTED {}: {e}", dir.display()),
176 }
181 }
177 }
182 }
examples/static_body_fixture.rs +9 −3
@@ -3,7 +3,7 @@
3 3 //! Builds a project with the static OBJ+MTL+PNG body as male and the unskinned
4 4 //! glTF body as female, screenshots the live viewport for both and the
5 5 //! equipment screen (bone binding unavailable), runs the production batch for
6 //! both bodies (one bind sample × 36 views × four maps), checks that an
6 //! both bodies (one bind sample × 60 views × four maps), checks that an
7 7 //! equipment render on the static body is refused, verifies the runs with
8 8 //! `export_set`, and compares OBJ vs glTF coverage/depth view by view.
9 9 use eframe::App;
@@ -158,7 +158,10 @@ fn walk_pngs(dir: &Path) -> Result<Vec<PathBuf>, String> {
158 158 let path = entry.map_err(|e| e.to_string())?.path();
159 159 if path.is_dir() {
160 160 stack.push(path);
161 } else if path.extension().is_some_and(|x| x == "png") {
161 } else if path.extension().is_some_and(|x| x == "png")
162 && !path.to_string_lossy().ends_with("-atlas.png")
163 {
164 // Loose target maps only; per-map atlases are checked separately.
162 165 out.push(path);
163 166 }
164 167 }
@@ -194,7 +197,9 @@ fn check_run(dir: &Path, width: u32, height: u32) -> Result<RunCheck, String> {
194 197 return Err(format!("{name}: {w}x{h} ≠ doel {width}x{height}"));
195 198 }
196 199 }
200 let atlas = paperdoll_renderer::atlas::cross_check_run(dir)?;
197 201 let report = serde_json::json!({
202 "atlas": atlas,
198 203 "dir": dir.display().to_string(),
199 204 "png_files": run.complete.png_files,
200 205 "maps": counts,
@@ -397,7 +402,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
397 402 let runs_ok = checks.len() == 2
398 403 && equipment_runs == 0
399 404 && checks.iter().all(|c| {
400 c.run.complete.png_files == 144
405 c.run.complete.png_files == 240
406 && c.report["atlas"]["cells_equal_to_loose_pngs"] == 240
401 407 && c.run.render.clips.len() == 1
402 408 && c.run.render.clips[0].token == "clip000-bind"
403 409 });
@@ -3,7 +3,7 @@
3//! Builds a project with the static OBJ+MTL+PNG body as male and the unskinned
3//! Builds a project with the static OBJ+MTL+PNG body as male and the unskinned
4//! glTF body as female, screenshots the live viewport for both and the
4//! glTF body as female, screenshots the live viewport for both and the
5//! equipment screen (bone binding unavailable), runs the production batch for
5//! equipment screen (bone binding unavailable), runs the production batch for
6//! both bodies (one bind sample × 36 views × four maps), checks that an
6//! both bodies (one bind sample × 60 views × four maps), checks that an
7//! equipment render on the static body is refused, verifies the runs with
7//! equipment render on the static body is refused, verifies the runs with
8//! `export_set`, and compares OBJ vs glTF coverage/depth view by view.
8//! `export_set`, and compares OBJ vs glTF coverage/depth view by view.
9use eframe::App;
9use eframe::App;
@@ -158,7 +158,10 @@ fn walk_pngs(dir: &Path) -> Result<Vec<PathBuf>, String> {
158 let path = entry.map_err(|e| e.to_string())?.path();
158 let path = entry.map_err(|e| e.to_string())?.path();
159 if path.is_dir() {
159 if path.is_dir() {
160 stack.push(path);
160 stack.push(path);
161 } else if path.extension().is_some_and(|x| x == "png") {
161 } else if path.extension().is_some_and(|x| x == "png")
162 && !path.to_string_lossy().ends_with("-atlas.png")
163 {
164 // Loose target maps only; per-map atlases are checked separately.
162 out.push(path);
165 out.push(path);
163 }
166 }
164 }
167 }
@@ -194,7 +197,9 @@ fn check_run(dir: &Path, width: u32, height: u32) -> Result<RunCheck, String> {
194 return Err(format!("{name}: {w}x{h} ≠ doel {width}x{height}"));
197 return Err(format!("{name}: {w}x{h} ≠ doel {width}x{height}"));
195 }
198 }
196 }
199 }
200 let atlas = paperdoll_renderer::atlas::cross_check_run(dir)?;
197 let report = serde_json::json!({
201 let report = serde_json::json!({
202 "atlas": atlas,
198 "dir": dir.display().to_string(),
203 "dir": dir.display().to_string(),
199 "png_files": run.complete.png_files,
204 "png_files": run.complete.png_files,
200 "maps": counts,
205 "maps": counts,
@@ -397,7 +402,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
397 let runs_ok = checks.len() == 2
402 let runs_ok = checks.len() == 2
398 && equipment_runs == 0
403 && equipment_runs == 0
399 && checks.iter().all(|c| {
404 && checks.iter().all(|c| {
400 c.run.complete.png_files == 144
405 c.run.complete.png_files == 240
406 && c.report["atlas"]["cells_equal_to_loose_pngs"] == 240
401 && c.run.render.clips.len() == 1
407 && c.run.render.clips.len() == 1
402 && c.run.render.clips[0].token == "clip000-bind"
408 && c.run.render.clips[0].token == "clip000-bind"
403 });
409 });
src/atlas.rs +313 −0
@@ -888,3 +888,316 @@ impl LoadedAtlas {
888 888 }
889 889 }
890 890 }
891
892 /// Open a completed run through both readers and compare every atlas cell of
893 /// every map with its loose PNG. Returns a JSON summary (used by GPU proofs).
894 pub fn cross_check_run(run_dir: &Path) -> Result<serde_json::Value, String> {
895 let run = export_set::ExportRun::open(run_dir).map_err(|e| e.to_string())?;
896 let set = AtlasSet::open_run(run_dir).map_err(|e| e.to_string())?;
897 let loaded = set.load().map_err(|e| e.to_string())?;
898 let mut cells = 0u64;
899 for clip in &set.ppda.clips {
900 for frame in &clip.frames {
901 for view in &frame.views {
902 for kind in ATLAS_MAPS {
903 let a = loaded
904 .cell(&clip.token, frame.index, view.pitch, view.yaw, kind)
905 .map_err(|e| e.to_string())?;
906 let b = run
907 .load_map(&clip.token, frame.index, view.pitch, view.yaw, kind)
908 .map_err(|e| e.to_string())?;
909 if a != b {
910 return Err(format!(
911 "atlascel {} f{} p{} y{} {} wijkt af van losse PNG",
912 clip.token,
913 frame.index,
914 view.pitch,
915 view.yaw,
916 kind.token()
917 ));
918 }
919 cells += 1;
920 }
921 }
922 }
923 }
924 if cells != u64::from(run.complete.png_files) {
925 return Err(format!(
926 "{cells} atlascellen ≠ {} losse PNG's",
927 run.complete.png_files
928 ));
929 }
930 Ok(serde_json::json!({
931 "ppda": ppda_name(&set.ppda.sex, &set.ppda.settype),
932 "atlas_size": [set.layout.width, set.layout.height],
933 "cell": [set.layout.cell_width, set.layout.cell_height],
934 "rows": set.layout.rows,
935 "columns": set.layout.columns,
936 "fps": set.ppda.fps,
937 "cells_equal_to_loose_pngs": cells,
938 "maps": set.ppda.maps.iter().map(|(k, m)| (k.clone(), serde_json::json!({"file": m.file, "sha256": m.sha256}))).collect::<serde_json::Map<_, _>>(),
939 }))
940 }
941
942 #[cfg(test)]
943 mod tests {
944 use super::*;
945 use crate::export_fixture::{Pixel, SyntheticRun};
946 use std::sync::atomic::AtomicU64;
947
948 struct Temp(PathBuf);
949 impl Temp {
950 fn new() -> Self {
951 static N: AtomicU64 = AtomicU64::new(0);
952 let path = std::env::temp_dir().join(format!(
953 "paperdoll-atlas-{}-{}",
954 std::process::id(),
955 N.fetch_add(1, Ordering::Relaxed)
956 ));
957 let _ = fs::remove_dir_all(&path);
958 Self(path)
959 }
960 }
961 impl Drop for Temp {
962 fn drop(&mut self) {
963 let _ = fs::remove_dir_all(&self.0);
964 }
965 }
966
967 fn fixture() -> Temp {
968 let tmp = Temp::new();
969 let mut spec = SyntheticRun::body("atlas-test", "female", b"body");
970 spec.clips = vec![("walk".into(), 3, 0.75), ("idle".into(), 1, 0.0)];
971 spec.target = [3, 2];
972 spec.write(&tmp.0, |c, f, p, y| Pixel {
973 diffuse: [c as u8 * 100 + f as u8, (p + 60) as u8, (y / 30) as u8, 255],
974 normal: [128, 128, 255, 255],
975 specular: [9, 9, 9, 255],
976 depth: 1000 + f as u16,
977 })
978 .unwrap();
979 tmp
980 }
981
982 fn rewrite_ppda(dir: &Path, edit: impl FnOnce(&mut serde_json::Value)) {
983 let path = dir.join("female-body-atlas.ppda");
984 let mut doc: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
985 edit(&mut doc);
986 fs::remove_file(&path).unwrap();
987 fs::write(&path, serde_json::to_vec_pretty(&doc).unwrap()).unwrap();
988 }
989
990 #[test]
991 fn layout_is_pitch_major_columns_and_frame_rows() {
992 let l = AtlasLayout::new(32, 48, 5).unwrap();
993 assert_eq!((l.columns, l.width, l.height), (60, 60 * 32, 5 * 48));
994 assert_eq!(AtlasLayout::column(-60, 0), Some(0));
995 assert_eq!(AtlasLayout::column(-60, 330), Some(11));
996 assert_eq!(AtlasLayout::column(-30, 0), Some(12));
997 assert_eq!(AtlasLayout::column(60, 330), Some(59));
998 assert_eq!(AtlasLayout::column(45, 0), None);
999 let v = l.cell(2, 13);
1000 assert_eq!(
1001 (v.pitch, v.yaw, v.x, v.y, v.w, v.h),
1002 (-30, 30, 13 * 32, 96, 32, 48)
1003 );
1004 // 256-pixel cells allow 64 frame rows; 65 exceeds the GPU/PNG limit.
1005 assert!(AtlasLayout::new(256, 256, 64).is_ok());
1006 let err = AtlasLayout::new(256, 256, 65).unwrap_err();
1007 assert!(err.contains("niet gesplitst"), "{err}");
1008 assert!(AtlasLayout::new(0, 1, 1).is_err());
1009 assert!(AtlasLayout::new(257, 1, 1).is_err());
1010 }
1011
1012 #[test]
1013 fn written_ppda_has_exact_coordinates_names_fps_and_checksums() {
1014 let tmp = fixture();
1015 let set = AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).unwrap();
1016 let p = &set.ppda;
1017 assert_eq!(p.format_version, 1);
1018 assert_eq!((p.sex.as_str(), p.settype.as_str()), ("female", "body"));
1019 assert_eq!(p.fps, 4);
1020 assert_eq!((p.cell_width, p.cell_height), (3, 2));
1021 assert_eq!(p.pitches, [-60, -30, 0, 30, 60]);
1022 assert_eq!(p.yaws.len(), 12);
1023 assert_eq!(p.maps["diffuse"].file, "female-body-diffuse-atlas.png");
1024 assert_eq!(p.maps["depth"].file, "female-body-depth-atlas.png");
1025 assert_eq!((p.maps["normal"].width, p.maps["normal"].height), (180, 8));
1026 for kind in ATLAS_MAPS {
1027 let m = &p.maps[kind.token()];
1028 let bytes = fs::read(tmp.0.join(&m.file)).unwrap();
1029 assert_eq!(digest::sha256_hex(&bytes), m.sha256);
1030 }
1031 assert_eq!(p.clips.len(), 2);
1032 assert_eq!(p.clips[0].frame_count, 3);
1033 assert_eq!(p.clips[1].token, "clip001-idle");
1034 let idle = &p.clips[1].frames[0].views[59];
1035 assert_eq!((idle.pitch, idle.yaw, idle.x, idle.y), (60, 330, 177, 6));
1036 // Cells hold exactly the loose map bytes.
1037 let loaded = set.load().unwrap();
1038 let run = crate::export_set::ExportRun::open(&tmp.0).unwrap();
1039 for (clip, frame) in [("clip000-walk", 2), ("clip001-idle", 0)] {
1040 for (pitch, yaw) in [(-60, 0), (30, 210), (60, 330)] {
1041 for kind in ATLAS_MAPS {
1042 assert_eq!(
1043 loaded.cell(clip, frame, pitch, yaw, kind).unwrap(),
1044 run.load_map(clip, frame, pitch, yaw, kind).unwrap()
1045 );
1046 }
1047 }
1048 }
1049 assert!(loaded
1050 .cell("clip000-walk", 3, 0, 0, MapKind::Diffuse)
1051 .is_err());
1052 assert!(loaded.cell("clip009-x", 0, 0, 0, MapKind::Diffuse).is_err());
1053 // The diffuse atlas carries the sRGB chunk like the loose diffuse maps.
1054 let bytes = fs::read(tmp.0.join("female-body-diffuse-atlas.png")).unwrap();
1055 let info = png::Decoder::new(std::io::Cursor::new(&bytes[..]))
1056 .read_info()
1057 .unwrap()
1058 .info()
1059 .srgb;
1060 assert!(info.is_some());
1061 }
1062
1063 #[test]
1064 fn reader_rejects_tampering_unknown_fields_versions_paths_and_coordinates() {
1065 let tmp = fixture();
1066 let ppda = tmp.0.join("female-body-atlas.ppda");
1067 let original = fs::read(&ppda).unwrap();
1068 let restore = || {
1069 fs::remove_file(&ppda).unwrap();
1070 fs::write(&ppda, &original).unwrap();
1071 };
1072 AtlasSet::open(&ppda).unwrap();
1073
1074 rewrite_ppda(&tmp.0, |d| d["format_version"] = 2.into());
1075 assert!(matches!(
1076 AtlasSet::open(&ppda),
1077 Err(ExportError::Identity(_))
1078 ));
1079 restore();
1080 rewrite_ppda(&tmp.0, |d| d["extra"] = true.into());
1081 assert!(matches!(
1082 AtlasSet::open(&ppda),
1083 Err(ExportError::Corrupt(_))
1084 ));
1085 restore();
1086 rewrite_ppda(&tmp.0, |d| {
1087 d["clips"][0]["frames"][1]["views"][5]["x"] = 1.into()
1088 });
1089 assert!(matches!(
1090 AtlasSet::open(&ppda),
1091 Err(ExportError::Corrupt(_))
1092 ));
1093 restore();
1094 rewrite_ppda(&tmp.0, |d| {
1095 d["maps"]["diffuse"]["file"] = "../female-body-diffuse-atlas.png".into()
1096 });
1097 assert!(matches!(
1098 AtlasSet::open(&ppda),
1099 Err(ExportError::Traversal(_))
1100 ));
1101 restore();
1102 rewrite_ppda(&tmp.0, |d| d["pitches"] = serde_json::json!([-30, 0, 30]));
1103 assert!(AtlasSet::open(&ppda).is_err());
1104 restore();
1105 rewrite_ppda(&tmp.0, |d| d["maps"]["depth"]["height"] = 10.into());
1106 assert!(AtlasSet::open(&ppda).is_err());
1107 restore();
1108 rewrite_ppda(&tmp.0, |d| d["clips"][0]["frame_count"] = 2.into());
1109 assert!(AtlasSet::open(&ppda).is_err());
1110 restore();
1111 AtlasSet::open(&ppda).unwrap();
1112
1113 // Damaged atlas bytes fail the SHA-256 check.
1114 let normal = tmp.0.join("female-body-normal-atlas.png");
1115 let good = fs::read(&normal).unwrap();
1116 let mut bad = good.clone();
1117 let last = bad.len() - 20;
1118 bad[last] ^= 0xff;
1119 fs::remove_file(&normal).unwrap();
1120 fs::write(&normal, &bad).unwrap();
1121 assert!(matches!(
1122 AtlasSet::open(&ppda),
1123 Err(ExportError::Corrupt(_))
1124 ));
1125 fs::remove_file(&normal).unwrap();
1126 #[cfg(unix)]
1127 {
1128 // A symlinked atlas is refused even with matching bytes.
1129 let outside = Temp::new();
1130 fs::create_dir_all(&outside.0).unwrap();
1131 fs::write(outside.0.join("n.png"), &good).unwrap();
1132 std::os::unix::fs::symlink(outside.0.join("n.png"), &normal).unwrap();
1133 assert!(matches!(
1134 AtlasSet::open(&ppda),
1135 Err(ExportError::Traversal(_))
1136 ));
1137 fs::remove_file(&normal).unwrap();
1138 }
1139 fs::write(&normal, &good).unwrap();
1140 AtlasSet::open(&ppda).unwrap();
1141 // A renamed .ppda no longer matches its sex/settype.
1142 fs::copy(&ppda, tmp.0.join("male-body-atlas.ppda")).unwrap();
1143 assert!(AtlasSet::open(&tmp.0.join("male-body-atlas.ppda")).is_err());
1144 assert!(matches!(
1145 AtlasSet::open_run(&tmp.0),
1146 Err(ExportError::Identity(_))
1147 ));
1148 }
1149
1150 #[test]
1151 fn atlases_never_clobber_and_are_refused_when_too_large() {
1152 let tmp = fixture();
1153 let render: serde_json::Value =
1154 serde_json::from_slice(&fs::read(tmp.0.join("render.json")).unwrap()).unwrap();
1155 let complete: serde_json::Value =
1156 serde_json::from_slice(&fs::read(tmp.0.join("complete.json")).unwrap()).unwrap();
1157 let sums: BTreeMap<String, String> =
1158 serde_json::from_value(complete["png_sha256"].clone()).unwrap();
1159 let mut spec = SyntheticRun::body("atlas-test", "female", b"body");
1160 spec.clips = vec![("walk".into(), 3, 0.75), ("idle".into(), 1, 0.0)];
1161 let plan = spec.plan();
1162 let cancel = AtomicBool::new(false);
1163 let job = AtlasJob {
1164 root: &tmp.0,
1165 render_doc: &render,
1166 plan: &plan,
1167 sex: "female",
1168 set: "body",
1169 cell_width: 3,
1170 cell_height: 2,
1171 png_sha256: &sums,
1172 attempt: 1,
1173 cancel: &cancel,
1174 };
1175 // Re-running over identical files keeps them (no clobber, same result).
1176 let before = fs::read(tmp.0.join("female-body-atlas.ppda")).unwrap();
1177 let out = write_atlases(&job, &mut |_| {}).unwrap();
1178 assert_eq!(
1179 fs::read(tmp.0.join("female-body-atlas.ppda")).unwrap(),
1180 before
1181 );
1182 assert_eq!(out.ppda_sha256, complete["atlas"]["ppda_sha256"]);
1183 // A loose map changed after verification is refused.
1184 let rel = "female/body/clip000-walk/f000001/p+30_y090_specular.png";
1185 let mut tampered = sums.clone();
1186 tampered.insert(rel.into(), digest::sha256_hex(b"x"));
1187 let err = write_atlases(
1188 &AtlasJob {
1189 png_sha256: &tampered,
1190 ..job
1191 },
1192 &mut |_| {},
1193 )
1194 .unwrap_err();
1195 assert!(err.contains("checksum"), "{err}");
1196 // Too many frames for 256-pixel cells: clear refusal, never split.
1197 let mut big = spec.clone();
1198 big.clips = vec![("long".into(), 65, 16.25)];
1199 assert!(AtlasLayout::for_plan(&big.plan(), 256, 256)
1200 .unwrap_err()
1201 .contains("16384"));
1202 }
1203 }
@@ -888,3 +888,316 @@ impl LoadedAtlas {
888 }
888 }
889 }
889 }
890}
890}
891
892/// Open a completed run through both readers and compare every atlas cell of
893/// every map with its loose PNG. Returns a JSON summary (used by GPU proofs).
894pub fn cross_check_run(run_dir: &Path) -> Result<serde_json::Value, String> {
895 let run = export_set::ExportRun::open(run_dir).map_err(|e| e.to_string())?;
896 let set = AtlasSet::open_run(run_dir).map_err(|e| e.to_string())?;
897 let loaded = set.load().map_err(|e| e.to_string())?;
898 let mut cells = 0u64;
899 for clip in &set.ppda.clips {
900 for frame in &clip.frames {
901 for view in &frame.views {
902 for kind in ATLAS_MAPS {
903 let a = loaded
904 .cell(&clip.token, frame.index, view.pitch, view.yaw, kind)
905 .map_err(|e| e.to_string())?;
906 let b = run
907 .load_map(&clip.token, frame.index, view.pitch, view.yaw, kind)
908 .map_err(|e| e.to_string())?;
909 if a != b {
910 return Err(format!(
911 "atlascel {} f{} p{} y{} {} wijkt af van losse PNG",
912 clip.token,
913 frame.index,
914 view.pitch,
915 view.yaw,
916 kind.token()
917 ));
918 }
919 cells += 1;
920 }
921 }
922 }
923 }
924 if cells != u64::from(run.complete.png_files) {
925 return Err(format!(
926 "{cells} atlascellen ≠ {} losse PNG's",
927 run.complete.png_files
928 ));
929 }
930 Ok(serde_json::json!({
931 "ppda": ppda_name(&set.ppda.sex, &set.ppda.settype),
932 "atlas_size": [set.layout.width, set.layout.height],
933 "cell": [set.layout.cell_width, set.layout.cell_height],
934 "rows": set.layout.rows,
935 "columns": set.layout.columns,
936 "fps": set.ppda.fps,
937 "cells_equal_to_loose_pngs": cells,
938 "maps": set.ppda.maps.iter().map(|(k, m)| (k.clone(), serde_json::json!({"file": m.file, "sha256": m.sha256}))).collect::<serde_json::Map<_, _>>(),
939 }))
940}
941
942#[cfg(test)]
943mod tests {
944 use super::*;
945 use crate::export_fixture::{Pixel, SyntheticRun};
946 use std::sync::atomic::AtomicU64;
947
948 struct Temp(PathBuf);
949 impl Temp {
950 fn new() -> Self {
951 static N: AtomicU64 = AtomicU64::new(0);
952 let path = std::env::temp_dir().join(format!(
953 "paperdoll-atlas-{}-{}",
954 std::process::id(),
955 N.fetch_add(1, Ordering::Relaxed)
956 ));
957 let _ = fs::remove_dir_all(&path);
958 Self(path)
959 }
960 }
961 impl Drop for Temp {
962 fn drop(&mut self) {
963 let _ = fs::remove_dir_all(&self.0);
964 }
965 }
966
967 fn fixture() -> Temp {
968 let tmp = Temp::new();
969 let mut spec = SyntheticRun::body("atlas-test", "female", b"body");
970 spec.clips = vec![("walk".into(), 3, 0.75), ("idle".into(), 1, 0.0)];
971 spec.target = [3, 2];
972 spec.write(&tmp.0, |c, f, p, y| Pixel {
973 diffuse: [c as u8 * 100 + f as u8, (p + 60) as u8, (y / 30) as u8, 255],
974 normal: [128, 128, 255, 255],
975 specular: [9, 9, 9, 255],
976 depth: 1000 + f as u16,
977 })
978 .unwrap();
979 tmp
980 }
981
982 fn rewrite_ppda(dir: &Path, edit: impl FnOnce(&mut serde_json::Value)) {
983 let path = dir.join("female-body-atlas.ppda");
984 let mut doc: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
985 edit(&mut doc);
986 fs::remove_file(&path).unwrap();
987 fs::write(&path, serde_json::to_vec_pretty(&doc).unwrap()).unwrap();
988 }
989
990 #[test]
991 fn layout_is_pitch_major_columns_and_frame_rows() {
992 let l = AtlasLayout::new(32, 48, 5).unwrap();
993 assert_eq!((l.columns, l.width, l.height), (60, 60 * 32, 5 * 48));
994 assert_eq!(AtlasLayout::column(-60, 0), Some(0));
995 assert_eq!(AtlasLayout::column(-60, 330), Some(11));
996 assert_eq!(AtlasLayout::column(-30, 0), Some(12));
997 assert_eq!(AtlasLayout::column(60, 330), Some(59));
998 assert_eq!(AtlasLayout::column(45, 0), None);
999 let v = l.cell(2, 13);
1000 assert_eq!(
1001 (v.pitch, v.yaw, v.x, v.y, v.w, v.h),
1002 (-30, 30, 13 * 32, 96, 32, 48)
1003 );
1004 // 256-pixel cells allow 64 frame rows; 65 exceeds the GPU/PNG limit.
1005 assert!(AtlasLayout::new(256, 256, 64).is_ok());
1006 let err = AtlasLayout::new(256, 256, 65).unwrap_err();
1007 assert!(err.contains("niet gesplitst"), "{err}");
1008 assert!(AtlasLayout::new(0, 1, 1).is_err());
1009 assert!(AtlasLayout::new(257, 1, 1).is_err());
1010 }
1011
1012 #[test]
1013 fn written_ppda_has_exact_coordinates_names_fps_and_checksums() {
1014 let tmp = fixture();
1015 let set = AtlasSet::open(&tmp.0.join("female-body-atlas.ppda")).unwrap();
1016 let p = &set.ppda;
1017 assert_eq!(p.format_version, 1);
1018 assert_eq!((p.sex.as_str(), p.settype.as_str()), ("female", "body"));
1019 assert_eq!(p.fps, 4);
1020 assert_eq!((p.cell_width, p.cell_height), (3, 2));
1021 assert_eq!(p.pitches, [-60, -30, 0, 30, 60]);
1022 assert_eq!(p.yaws.len(), 12);
1023 assert_eq!(p.maps["diffuse"].file, "female-body-diffuse-atlas.png");
1024 assert_eq!(p.maps["depth"].file, "female-body-depth-atlas.png");
1025 assert_eq!((p.maps["normal"].width, p.maps["normal"].height), (180, 8));
1026 for kind in ATLAS_MAPS {
1027 let m = &p.maps[kind.token()];
1028 let bytes = fs::read(tmp.0.join(&m.file)).unwrap();
1029 assert_eq!(digest::sha256_hex(&bytes), m.sha256);
1030 }
1031 assert_eq!(p.clips.len(), 2);
1032 assert_eq!(p.clips[0].frame_count, 3);
1033 assert_eq!(p.clips[1].token, "clip001-idle");
1034 let idle = &p.clips[1].frames[0].views[59];
1035 assert_eq!((idle.pitch, idle.yaw, idle.x, idle.y), (60, 330, 177, 6));
1036 // Cells hold exactly the loose map bytes.
1037 let loaded = set.load().unwrap();
1038 let run = crate::export_set::ExportRun::open(&tmp.0).unwrap();
1039 for (clip, frame) in [("clip000-walk", 2), ("clip001-idle", 0)] {
1040 for (pitch, yaw) in [(-60, 0), (30, 210), (60, 330)] {
1041 for kind in ATLAS_MAPS {
1042 assert_eq!(
1043 loaded.cell(clip, frame, pitch, yaw, kind).unwrap(),
1044 run.load_map(clip, frame, pitch, yaw, kind).unwrap()
1045 );
1046 }
1047 }
1048 }
1049 assert!(loaded
1050 .cell("clip000-walk", 3, 0, 0, MapKind::Diffuse)
1051 .is_err());
1052 assert!(loaded.cell("clip009-x", 0, 0, 0, MapKind::Diffuse).is_err());
1053 // The diffuse atlas carries the sRGB chunk like the loose diffuse maps.
1054 let bytes = fs::read(tmp.0.join("female-body-diffuse-atlas.png")).unwrap();
1055 let info = png::Decoder::new(std::io::Cursor::new(&bytes[..]))
1056 .read_info()
1057 .unwrap()
1058 .info()
1059 .srgb;
1060 assert!(info.is_some());
1061 }
1062
1063 #[test]
1064 fn reader_rejects_tampering_unknown_fields_versions_paths_and_coordinates() {
1065 let tmp = fixture();
1066 let ppda = tmp.0.join("female-body-atlas.ppda");
1067 let original = fs::read(&ppda).unwrap();
1068 let restore = || {
1069 fs::remove_file(&ppda).unwrap();
1070 fs::write(&ppda, &original).unwrap();
1071 };
1072 AtlasSet::open(&ppda).unwrap();
1073
1074 rewrite_ppda(&tmp.0, |d| d["format_version"] = 2.into());
1075 assert!(matches!(
1076 AtlasSet::open(&ppda),
1077 Err(ExportError::Identity(_))
1078 ));
1079 restore();
1080 rewrite_ppda(&tmp.0, |d| d["extra"] = true.into());
1081 assert!(matches!(
1082 AtlasSet::open(&ppda),
1083 Err(ExportError::Corrupt(_))
1084 ));
1085 restore();
1086 rewrite_ppda(&tmp.0, |d| {
1087 d["clips"][0]["frames"][1]["views"][5]["x"] = 1.into()
1088 });
1089 assert!(matches!(
1090 AtlasSet::open(&ppda),
1091 Err(ExportError::Corrupt(_))
1092 ));
1093 restore();
1094 rewrite_ppda(&tmp.0, |d| {
1095 d["maps"]["diffuse"]["file"] = "../female-body-diffuse-atlas.png".into()
1096 });
1097 assert!(matches!(
1098 AtlasSet::open(&ppda),
1099 Err(ExportError::Traversal(_))
1100 ));
1101 restore();
1102 rewrite_ppda(&tmp.0, |d| d["pitches"] = serde_json::json!([-30, 0, 30]));
1103 assert!(AtlasSet::open(&ppda).is_err());
1104 restore();
1105 rewrite_ppda(&tmp.0, |d| d["maps"]["depth"]["height"] = 10.into());
1106 assert!(AtlasSet::open(&ppda).is_err());
1107 restore();
1108 rewrite_ppda(&tmp.0, |d| d["clips"][0]["frame_count"] = 2.into());
1109 assert!(AtlasSet::open(&ppda).is_err());
1110 restore();
1111 AtlasSet::open(&ppda).unwrap();
1112
1113 // Damaged atlas bytes fail the SHA-256 check.
1114 let normal = tmp.0.join("female-body-normal-atlas.png");
1115 let good = fs::read(&normal).unwrap();
1116 let mut bad = good.clone();
1117 let last = bad.len() - 20;
1118 bad[last] ^= 0xff;
1119 fs::remove_file(&normal).unwrap();
1120 fs::write(&normal, &bad).unwrap();
1121 assert!(matches!(
1122 AtlasSet::open(&ppda),
1123 Err(ExportError::Corrupt(_))
1124 ));
1125 fs::remove_file(&normal).unwrap();
1126 #[cfg(unix)]
1127 {
1128 // A symlinked atlas is refused even with matching bytes.
1129 let outside = Temp::new();
1130 fs::create_dir_all(&outside.0).unwrap();
1131 fs::write(outside.0.join("n.png"), &good).unwrap();
1132 std::os::unix::fs::symlink(outside.0.join("n.png"), &normal).unwrap();
1133 assert!(matches!(
1134 AtlasSet::open(&ppda),
1135 Err(ExportError::Traversal(_))
1136 ));
1137 fs::remove_file(&normal).unwrap();
1138 }
1139 fs::write(&normal, &good).unwrap();
1140 AtlasSet::open(&ppda).unwrap();
1141 // A renamed .ppda no longer matches its sex/settype.
1142 fs::copy(&ppda, tmp.0.join("male-body-atlas.ppda")).unwrap();
1143 assert!(AtlasSet::open(&tmp.0.join("male-body-atlas.ppda")).is_err());
1144 assert!(matches!(
1145 AtlasSet::open_run(&tmp.0),
1146 Err(ExportError::Identity(_))
1147 ));
1148 }
1149
1150 #[test]
1151 fn atlases_never_clobber_and_are_refused_when_too_large() {
1152 let tmp = fixture();
1153 let render: serde_json::Value =
1154 serde_json::from_slice(&fs::read(tmp.0.join("render.json")).unwrap()).unwrap();
1155 let complete: serde_json::Value =
1156 serde_json::from_slice(&fs::read(tmp.0.join("complete.json")).unwrap()).unwrap();
1157 let sums: BTreeMap<String, String> =
1158 serde_json::from_value(complete["png_sha256"].clone()).unwrap();
1159 let mut spec = SyntheticRun::body("atlas-test", "female", b"body");
1160 spec.clips = vec![("walk".into(), 3, 0.75), ("idle".into(), 1, 0.0)];
1161 let plan = spec.plan();
1162 let cancel = AtomicBool::new(false);
1163 let job = AtlasJob {
1164 root: &tmp.0,
1165 render_doc: &render,
1166 plan: &plan,
1167 sex: "female",
1168 set: "body",
1169 cell_width: 3,
1170 cell_height: 2,
1171 png_sha256: &sums,
1172 attempt: 1,
1173 cancel: &cancel,
1174 };
1175 // Re-running over identical files keeps them (no clobber, same result).
1176 let before = fs::read(tmp.0.join("female-body-atlas.ppda")).unwrap();
1177 let out = write_atlases(&job, &mut |_| {}).unwrap();
1178 assert_eq!(
1179 fs::read(tmp.0.join("female-body-atlas.ppda")).unwrap(),
1180 before
1181 );
1182 assert_eq!(out.ppda_sha256, complete["atlas"]["ppda_sha256"]);
1183 // A loose map changed after verification is refused.
1184 let rel = "female/body/clip000-walk/f000001/p+30_y090_specular.png";
1185 let mut tampered = sums.clone();
1186 tampered.insert(rel.into(), digest::sha256_hex(b"x"));
1187 let err = write_atlases(
1188 &AtlasJob {
1189 png_sha256: &tampered,
1190 ..job
1191 },
1192 &mut |_| {},
1193 )
1194 .unwrap_err();
1195 assert!(err.contains("checksum"), "{err}");
1196 // Too many frames for 256-pixel cells: clear refusal, never split.
1197 let mut big = spec.clone();
1198 big.clips = vec![("long".into(), 65, 16.25)];
1199 assert!(AtlasLayout::for_plan(&big.plan(), 256, 256)
1200 .unwrap_err()
1201 .contains("16384"));
1202 }
1203}
src/camera.rs +3 −3
@@ -294,14 +294,14 @@ mod tests {
294 294 }
295 295
296 296 #[test]
297 fn all_36_angles_share_pivot_and_neck_projects_stably() {
297 fn all_60_angles_share_pivot_and_neck_projects_stably() {
298 298 let framing = CameraFraming {
299 299 height_milli: 3000,
300 300 center_y_milli: 1100,
301 301 pivot_y_milli: Some(1500),
302 302 };
303 303 let mut pivot_ndcs = Vec::new();
304 for pitch in [-30., 0., 30.] {
304 for pitch in crate::preview::PAPERDOLL_PITCHES_DEG {
305 305 for yaw in (0..12).map(|i| i as f32 * 30.) {
306 306 let (view, projection) = matrices(framing, yaw, pitch, 64. / 96.);
307 307 let pivot = project(view, projection, [0., 1.5, 0.]);
@@ -339,7 +339,7 @@ mod tests {
339 339 }
340 340 }
341 341 }
342 assert_eq!(pivot_ndcs.len(), 36);
342 assert_eq!(pivot_ndcs.len(), 60);
343 343 let first = pivot_ndcs[0];
344 344 assert!(pivot_ndcs.iter().all(|y| (y - first).abs() < 1e-4));
345 345 }
@@ -294,14 +294,14 @@ mod tests {
294 }
294 }
295
295
296 #[test]
296 #[test]
297 fn all_36_angles_share_pivot_and_neck_projects_stably() {
297 fn all_60_angles_share_pivot_and_neck_projects_stably() {
298 let framing = CameraFraming {
298 let framing = CameraFraming {
299 height_milli: 3000,
299 height_milli: 3000,
300 center_y_milli: 1100,
300 center_y_milli: 1100,
301 pivot_y_milli: Some(1500),
301 pivot_y_milli: Some(1500),
302 };
302 };
303 let mut pivot_ndcs = Vec::new();
303 let mut pivot_ndcs = Vec::new();
304 for pitch in [-30., 0., 30.] {
304 for pitch in crate::preview::PAPERDOLL_PITCHES_DEG {
305 for yaw in (0..12).map(|i| i as f32 * 30.) {
305 for yaw in (0..12).map(|i| i as f32 * 30.) {
306 let (view, projection) = matrices(framing, yaw, pitch, 64. / 96.);
306 let (view, projection) = matrices(framing, yaw, pitch, 64. / 96.);
307 let pivot = project(view, projection, [0., 1.5, 0.]);
307 let pivot = project(view, projection, [0., 1.5, 0.]);
@@ -339,7 +339,7 @@ mod tests {
339 }
339 }
340 }
340 }
341 }
341 }
342 assert_eq!(pivot_ndcs.len(), 36);
342 assert_eq!(pivot_ndcs.len(), 60);
343 let first = pivot_ndcs[0];
343 let first = pivot_ndcs[0];
344 assert!(pivot_ndcs.iter().all(|y| (y - first).abs() < 1e-4));
344 assert!(pivot_ndcs.iter().all(|y| (y - first).abs() < 1e-4));
345 }
345 }
src/editor.rs +1 −1
@@ -1018,7 +1018,7 @@ impl EditorApp {
1018 1018 ui.colored_label(
1019 1019 egui::Color32::from_rgb(230, 190, 90),
1020 1020 format!(
1021 "{reason}. Render alle hoeken maakt één bindpose-sample (clip000-bind) × 36 hoeken."
1021 "{reason}. Render alle hoeken maakt één bindpose-sample (clip000-bind) × 60 hoeken."
1022 1022 ),
1023 1023 );
1024 1024 }
@@ -1018,7 +1018,7 @@ impl EditorApp {
1018 ui.colored_label(
1018 ui.colored_label(
1019 egui::Color32::from_rgb(230, 190, 90),
1019 egui::Color32::from_rgb(230, 190, 90),
1020 format!(
1020 format!(
1021 "{reason}. Render alle hoeken maakt één bindpose-sample (clip000-bind) × 36 hoeken."
1021 "{reason}. Render alle hoeken maakt één bindpose-sample (clip000-bind) × 60 hoeken."
1022 ),
1022 ),
1023 );
1023 );
1024 }
1024 }
src/production.rs +16 −0
@@ -2663,6 +2663,22 @@ mod tests {
2663 2663 assert_eq!(log["quarantined"].as_array().unwrap().len(), 1);
2664 2664 }
2665 2665
2666 #[test]
2667 fn contract_refuses_atlas_beyond_gpu_limit_before_rendering() {
2668 let tmp = Temp::new();
2669 let mut job = resume_job(&tmp.0);
2670 job.character = body();
2671 let total: f32 = job.character.clips.iter().map(|c| c.duration).sum();
2672 assert!(total > 0.);
2673 // ~100 frame rows of 256 px > 16384, while staying under the 16 GiB cap.
2674 job.fps = (100. / total).ceil() as u32;
2675 job.target_width = 256;
2676 job.target_height = 256;
2677 let err = contract(job).err().unwrap();
2678 assert!(err.contains("atlas") && err.contains("16384"), "{err}");
2679 assert!(std::fs::read_dir(&tmp.0).unwrap().next().is_none());
2680 }
2681
2666 2682 #[test]
2667 2683 #[cfg(unix)]
2668 2684 fn crash_after_atlases_keeps_identical_and_quarantines_damaged_atlas() {
@@ -2663,6 +2663,22 @@ mod tests {
2663 assert_eq!(log["quarantined"].as_array().unwrap().len(), 1);
2663 assert_eq!(log["quarantined"].as_array().unwrap().len(), 1);
2664 }
2664 }
2665
2665
2666 #[test]
2667 fn contract_refuses_atlas_beyond_gpu_limit_before_rendering() {
2668 let tmp = Temp::new();
2669 let mut job = resume_job(&tmp.0);
2670 job.character = body();
2671 let total: f32 = job.character.clips.iter().map(|c| c.duration).sum();
2672 assert!(total > 0.);
2673 // ~100 frame rows of 256 px > 16384, while staying under the 16 GiB cap.
2674 job.fps = (100. / total).ceil() as u32;
2675 job.target_width = 256;
2676 job.target_height = 256;
2677 let err = contract(job).err().unwrap();
2678 assert!(err.contains("atlas") && err.contains("16384"), "{err}");
2679 assert!(std::fs::read_dir(&tmp.0).unwrap().next().is_none());
2680 }
2681
2666 #[test]
2682 #[test]
2667 #[cfg(unix)]
2683 #[cfg(unix)]
2668 fn crash_after_atlases_keeps_identical_and_quarantines_damaged_atlas() {
2684 fn crash_after_atlases_keeps_identical_and_quarantines_damaged_atlas() {
src/project.rs +14 −1
@@ -492,7 +492,10 @@ pub fn list_dir(root: &Path, relative: &str) -> Result<Vec<DirEntry>, ProjectErr
492 492 }
493 493
494 494 pub fn memory_estimate(width: u32, height: u32) -> Option<u64> {
495 // 8× width and 8× height, 36 views, 3 maps, 4 bytes per pixel.
495 // Shape heuristic from the original brief: 8× width and 8× height,
496 // 36 views, 3 maps, 4 bytes per pixel. Production streams one view at a
497 // time, so the 60-view plan (t955u) does not change memory use; the
498 // factor stays 36 so that 256×256 targets remain valid.
496 499 u64::from(width)
497 500 .checked_mul(u64::from(height))?
498 501 .checked_mul(64)?
@@ -942,6 +945,16 @@ mod tests {
942 945 mod framing_tests {
943 946 use super::*;
944 947 #[test]
948 fn new_projects_default_to_4_fps_and_stored_fps_is_kept() {
949 assert_eq!(DEFAULT_FPS, 4);
950 assert_eq!(RenderSettings::default().fps, 4);
951 assert_eq!(ProjectFile::new_empty("Nieuw").render.fps, 4);
952 let mut stored = ProjectFile::new_empty("Oud");
953 stored.render.fps = 12;
954 let text = serde_json::to_string(&stored).unwrap();
955 assert_eq!(parse_manifest(&text).unwrap().render.fps, 12);
956 }
957 #[test]
945 958 fn per_sex_fixed_framing_roundtrip_and_v1_migration() {
946 959 let mut p = assemble(ProjectDraft {
947 960 id: "fixed-framing".into(),
@@ -492,7 +492,10 @@ pub fn list_dir(root: &Path, relative: &str) -> Result<Vec<DirEntry>, ProjectErr
492}
492}
493
493
494pub fn memory_estimate(width: u32, height: u32) -> Option<u64> {
494pub fn memory_estimate(width: u32, height: u32) -> Option<u64> {
495 // 8× width and 8× height, 36 views, 3 maps, 4 bytes per pixel.
495 // Shape heuristic from the original brief: 8× width and 8× height,
496 // 36 views, 3 maps, 4 bytes per pixel. Production streams one view at a
497 // time, so the 60-view plan (t955u) does not change memory use; the
498 // factor stays 36 so that 256×256 targets remain valid.
496 u64::from(width)
499 u64::from(width)
497 .checked_mul(u64::from(height))?
500 .checked_mul(u64::from(height))?
498 .checked_mul(64)?
501 .checked_mul(64)?
@@ -942,6 +945,16 @@ mod tests {
942mod framing_tests {
945mod framing_tests {
943 use super::*;
946 use super::*;
944 #[test]
947 #[test]
948 fn new_projects_default_to_4_fps_and_stored_fps_is_kept() {
949 assert_eq!(DEFAULT_FPS, 4);
950 assert_eq!(RenderSettings::default().fps, 4);
951 assert_eq!(ProjectFile::new_empty("Nieuw").render.fps, 4);
952 let mut stored = ProjectFile::new_empty("Oud");
953 stored.render.fps = 12;
954 let text = serde_json::to_string(&stored).unwrap();
955 assert_eq!(parse_manifest(&text).unwrap().render.fps, 12);
956 }
957 #[test]
945 fn per_sex_fixed_framing_roundtrip_and_v1_migration() {
958 fn per_sex_fixed_framing_roundtrip_and_v1_migration() {
946 let mut p = assemble(ProjectDraft {
959 let mut p = assemble(ProjectDraft {
947 id: "fixed-framing".into(),
960 id: "fixed-framing".into(),
tests/static_body.rs +6 −6
@@ -1,6 +1,6 @@
1 1 //! Static (unskinned) bodies: OBJ+MTL, glTF without a skin, FBX without a skin.
2 2 //! They load in their stored pose with no skeleton and no clips, use the
3 //! documented center-Y pivot fallback, render one bind sample × 36 views and
3 //! documented center-Y pivot fallback, render one bind sample × 60 views and
4 4 //! refuse bone-dependent equipment instead of inventing a rig.
5 5 use paperdoll_renderer::{
6 6 camera, equipment, preview,
@@ -200,22 +200,22 @@ fn static_body_pivot_falls_back_to_center_y_and_manual_pivot_still_wins() {
200 200 }
201 201
202 202 #[test]
203 fn static_body_plans_one_bind_sample_times_36_views_and_four_maps() {
203 fn static_body_plans_one_bind_sample_times_60_views_and_four_maps() {
204 204 let plan = production::plan(&obj(), 12).unwrap();
205 205 assert_eq!(plan.clips.len(), 1);
206 206 assert_eq!(plan.clips[0].token, "clip000-bind");
207 207 assert_eq!(plan.clips[0].frames, 1);
208 assert_eq!(plan.images, 36);
209 assert_eq!(plan.files, 144);
208 assert_eq!(plan.images, 60);
209 assert_eq!(plan.files, 240);
210 210 let paths = production::planned_paths(&plan, "male", "body").unwrap();
211 assert_eq!(paths.len(), 144);
211 assert_eq!(paths.len(), 240);
212 212 for map in ["diffuse", "normal", "depth", "specular"] {
213 213 assert_eq!(
214 214 paths
215 215 .iter()
216 216 .filter(|p| p.ends_with(&format!("_{map}.png")))
217 217 .count(),
218 36
218 60
219 219 );
220 220 }
221 221 let contract = production::contract(job(obj(), None)).unwrap();
@@ -1,6 +1,6 @@
1//! Static (unskinned) bodies: OBJ+MTL, glTF without a skin, FBX without a skin.
1//! Static (unskinned) bodies: OBJ+MTL, glTF without a skin, FBX without a skin.
2//! They load in their stored pose with no skeleton and no clips, use the
2//! They load in their stored pose with no skeleton and no clips, use the
3//! documented center-Y pivot fallback, render one bind sample × 36 views and
3//! documented center-Y pivot fallback, render one bind sample × 60 views and
4//! refuse bone-dependent equipment instead of inventing a rig.
4//! refuse bone-dependent equipment instead of inventing a rig.
5use paperdoll_renderer::{
5use paperdoll_renderer::{
6 camera, equipment, preview,
6 camera, equipment, preview,
@@ -200,22 +200,22 @@ fn static_body_pivot_falls_back_to_center_y_and_manual_pivot_still_wins() {
200}
200}
201
201
202#[test]
202#[test]
203fn static_body_plans_one_bind_sample_times_36_views_and_four_maps() {
203fn static_body_plans_one_bind_sample_times_60_views_and_four_maps() {
204 let plan = production::plan(&obj(), 12).unwrap();
204 let plan = production::plan(&obj(), 12).unwrap();
205 assert_eq!(plan.clips.len(), 1);
205 assert_eq!(plan.clips.len(), 1);
206 assert_eq!(plan.clips[0].token, "clip000-bind");
206 assert_eq!(plan.clips[0].token, "clip000-bind");
207 assert_eq!(plan.clips[0].frames, 1);
207 assert_eq!(plan.clips[0].frames, 1);
208 assert_eq!(plan.images, 36);
208 assert_eq!(plan.images, 60);
209 assert_eq!(plan.files, 144);
209 assert_eq!(plan.files, 240);
210 let paths = production::planned_paths(&plan, "male", "body").unwrap();
210 let paths = production::planned_paths(&plan, "male", "body").unwrap();
211 assert_eq!(paths.len(), 144);
211 assert_eq!(paths.len(), 240);
212 for map in ["diffuse", "normal", "depth", "specular"] {
212 for map in ["diffuse", "normal", "depth", "specular"] {
213 assert_eq!(
213 assert_eq!(
214 paths
214 paths
215 .iter()
215 .iter()
216 .filter(|p| p.ends_with(&format!("_{map}.png")))
216 .filter(|p| p.ends_with(&format!("_{map}.png")))
217 .count(),
217 .count(),
218 36
218 60
219 );
219 );
220 }
220 }
221 let contract = production::contract(job(obj(), None)).unwrap();
221 let contract = production::contract(job(obj(), None)).unwrap();