Atlas reader tests, cross-check helper, examples on 60 views + atlases
@@ -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, Str154
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 = name161
let map = name@@ -190,7 +191,9 @@ fn verify(root: &Path, width: u32, height: u32) -> Result<serde_json::Value, Str190
}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,@@ -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 synthetic2
//! export through its `.ppda` + atlases (loose PNGs are deleted first).2
//! Usage: preview2d_smoke OUTPUT_DIR3
//! Usage: preview2d_smoke OUTPUT_DIR3
4
4
use eframe::App;5
use eframe::App;5
use paperdoll_renderer::digest;6
use paperdoll_renderer::editor::EditorApp;6
use paperdoll_renderer::editor::EditorApp;7
use paperdoll_renderer::export_set::complete_identity_json;7
use paperdoll_renderer::export_fixture::{Pixel, SyntheticRun};8
use paperdoll_renderer::preview2d::discover_completed_runs;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
use paperdoll_renderer::render_job::ImageBuf;9
use paperdoll_renderer::render_job::ImageBuf;12
use paperdoll_renderer::session::Session;10
use paperdoll_renderer::session::Session;13
use std::collections::BTreeMap;14
use std::fs;11
use std::fs;15
use std::path::{Path, PathBuf};12
use std::path::{Path, PathBuf};16
use std::time::{Duration, Instant};13
use 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
86
fn 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 its87
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)]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
171
fn install_body_export(project: &Path) -> Result<(), String> {85
fn 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.166794
(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_files237
});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": checksums250
}),251
)?;252
Ok(())253
}103
}254
104
255
fn main() -> Result<(), Box<dyn std::error::Error>> {105
fn 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
)?;@@ -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
}@@ -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_files173
.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_files178
)179
}175
Err(e) => println!("OPEN-REJECTED {}: {e}", dir.display()),180
Err(e) => println!("OPEN-REJECTED {}: {e}", dir.display()),176
}181
}177
}182
}@@ -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 unskinned3
//! Builds a project with the static OBJ+MTL+PNG body as male and the unskinned4
//! glTF body as female, screenshots the live viewport for both and the4
//! glTF body as female, screenshots the live viewport for both and the5
//! equipment screen (bone binding unavailable), runs the production batch for5
//! equipment screen (bone binding unavailable), runs the production batch for6
//! both bodies (one bind sample × 36 views × four maps), checks that an6
//! both bodies (one bind sample × 60 views × four maps), checks that an7
//! equipment render on the static body is refused, verifies the runs with7
//! equipment render on the static body is refused, verifies the runs with8
//! `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.9
use eframe::App;9
use 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() == 2402
let runs_ok = checks.len() == 2398
&& equipment_runs == 0403
&& equipment_runs == 0399
&& checks.iter().all(|c| {404
&& checks.iter().all(|c| {400
c.run.complete.png_files == 144405
c.run.complete.png_files == 240406
&& c.report["atlas"]["cells_equal_to_loose_pngs"] == 240401
&& c.run.render.clips.len() == 1407
&& c.run.render.clips.len() == 1402
&& c.run.render.clips[0].token == "clip000-bind"408
&& c.run.render.clips[0].token == "clip000-bind"403
});409
});@@ -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 of893
/// 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 = loaded904
.cell(&clip.token, frame.index, view.pitch, view.yaw, kind)905
.map_err(|e| e.to_string())?;906
let b = run907
.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_files928
));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
tmp980
}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!(loaded1050
.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
before1181
);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
..job1191
},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
}@@ -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
}@@ -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
}@@ -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() {@@ -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>, ProjectErr492
}492
}493
493
494
pub fn memory_estimate(width: u32, height: u32) -> Option<u64> {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 a497
// time, so the 60-view plan (t955u) does not change memory use; the498
// 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 {942
mod framing_tests {945
mod 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(),@@ -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 the2
//! They load in their stored pose with no skeleton and no clips, use the3
//! documented center-Y pivot fallback, render one bind sample × 36 views and3
//! documented center-Y pivot fallback, render one bind sample × 60 views and4
//! refuse bone-dependent equipment instead of inventing a rig.4
//! refuse bone-dependent equipment instead of inventing a rig.5
use paperdoll_renderer::{5
use 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]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
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
paths214
paths215
.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
36218
60219
);219
);220
}220
}221
let contract = production::contract(job(obj(), None)).unwrap();221
let contract = production::contract(job(obj(), None)).unwrap();