WIP: Preview2d via .ppda atlases, synthetic export fixture, tests for 60 views
@@ -150,6 +150,22 @@ impl LayerMaps {
150
150
let depth = run.load_map(clip_token, frame, pitch, yaw, MapKind::Depth)?;
151
151
Self::from_maps(label, &diffuse, &normal, &specular, &depth)
152
152
}
153
154
/// Crop the four map cells for one pose from decoded `.ppda` atlases.
155
pub fn from_atlas(
156
atlas: &crate::atlas::LoadedAtlas,
157
label: impl Into<String>,
158
clip_token: &str,
159
frame: u32,
160
pitch: i32,
161
yaw: u32,
162
) -> Result<Self, ComposeError> {
163
let diffuse = atlas.cell(clip_token, frame, pitch, yaw, MapKind::Diffuse)?;
164
let normal = atlas.cell(clip_token, frame, pitch, yaw, MapKind::Normal)?;
165
let specular = atlas.cell(clip_token, frame, pitch, yaw, MapKind::Specular)?;
166
let depth = atlas.cell(clip_token, frame, pitch, yaw, MapKind::Depth)?;
167
Self::from_maps(label, &diffuse, &normal, &specular, &depth)
168
}
153
169
}
154
170
155
171
fn expect_rgba(image: &MapImage, kind: &str) -> Result<(u32, u32, Vec<u8>), ComposeError> {
@@ -213,14 +229,40 @@ impl CompatKey {
213
229
}
214
230
}
215
231
232
impl CompatKey {
233
/// Same contract read from a validated `.ppda` (consumer format).
234
pub fn from_ppda(p: &crate::atlas::Ppda) -> Self {
235
Self {
236
project_id: p.project.id.clone(),
237
sex: p.sex.clone(),
238
body_model_sha256: p.identity.body_model_sha256.clone(),
239
target: [p.cell_width, p.cell_height],
240
fps: p.fps,
241
framing: p.identity.framing,
242
near: p.identity.near,
243
far: p.identity.far,
244
clip_tokens: p
245
.clips
246
.iter()
247
.map(|c| (c.token.clone(), c.frame_count))
248
.collect(),
249
}
250
}
251
}
252
216
253
/// Reject runs that cannot be depth-composited together.
217
254
pub fn assert_compatible(runs: &[&ExportRun]) -> Result<(), ComposeError> {
218
if runs.is_empty() {
255
let keys: Vec<CompatKey> = runs.iter().map(|r| CompatKey::from_run(r)).collect();
256
assert_compatible_keys(&keys)
257
}
258
259
/// Reject layer contracts that cannot be depth-composited together.
260
pub fn assert_compatible_keys(keys: &[CompatKey]) -> Result<(), ComposeError> {
261
if keys.is_empty() {
219
262
return Err(ComposeError::Empty);
220
263
}
221
let first = CompatKey::from_run(runs[0]);
222
for run in runs.iter().skip(1) {
223
let key = CompatKey::from_run(run);
264
let first = &keys[0];
265
for key in keys.iter().skip(1) {
224
266
if key.project_id != first.project_id {
225
267
return Err(ComposeError::Incompatible(
226
268
"project.id verschilt tussen exportsets".into(),
@@ -505,10 +547,9 @@ fn encode_srgb(x: f32) -> u8 {
505
547
mod tests {
506
548
use super::*;
507
549
use crate::digest;
508
use crate::export_set::{complete_identity_json, ExportRun};
509
use crate::production::{write_depth, write_rgba, FAR, NEAR, PASSES};
550
use crate::export_fixture::{Pixel, SyntheticRun};
551
use crate::export_set::ExportRun;
510
552
use crate::project::CameraFraming;
511
use std::collections::BTreeMap;
512
553
use std::fs;
513
554
use std::path::{Path, PathBuf};
514
555
use std::sync::atomic::{AtomicU64, Ordering};
@@ -845,138 +886,27 @@ mod tests {
845
886
assert!(matches!(compose(&[a, b]), Err(ComposeError::Dimension(_))));
846
887
}
847
888
848
fn write_atomic_json(root: &Path, name: &str, value: &serde_json::Value) {
849
let data = serde_json::to_vec_pretty(value).unwrap();
850
let path = root.join(name);
851
let temp = path.with_extension(format!("part-{}", std::process::id()));
852
fs::write(&temp, &data).unwrap();
853
fs::hard_link(&temp, &path).unwrap();
854
let _ = fs::remove_file(temp);
855
}
856
857
fn publish_pose(
858
root: &Path,
859
sex: &str,
860
set: &str,
861
diffuse: [u8; 4],
862
normal: [u8; 4],
863
specular: [u8; 4],
864
depth_gray: u16,
865
) -> BTreeMap<String, String> {
866
let mut map = BTreeMap::new();
867
let base = format!("{sex}/{set}/clip000-bind/f000000/p+00_y000");
868
let rels = [
869
(format!("{base}_diffuse.png"), "diffuse"),
870
(format!("{base}_depth.png"), "depth"),
871
(format!("{base}_normal.png"), "normal"),
872
(format!("{base}_specular.png"), "specular"),
873
];
874
for (rel, kind) in rels {
875
let path = Path::new(&rel);
876
match kind {
877
"depth" => {
878
write_depth(root, path, 1, 1, &[depth_gray, 65535]).unwrap();
879
}
880
"normal" => {
881
write_rgba(
882
root,
883
path,
884
&ImageBuf {
885
width: 1,
886
height: 1,
887
rgba: normal.to_vec(),
888
},
889
)
890
.unwrap();
891
}
892
"specular" => {
893
write_rgba(
894
root,
895
path,
896
&ImageBuf {
897
width: 1,
898
height: 1,
899
rgba: specular.to_vec(),
900
},
901
)
902
.unwrap();
903
}
904
_ => {
905
write_rgba(
906
root,
907
path,
908
&ImageBuf {
909
width: 1,
910
height: 1,
911
rgba: diffuse.to_vec(),
912
},
913
)
914
.unwrap();
915
}
916
}
917
let bytes = fs::read(root.join(&rel)).unwrap();
918
map.insert(rel, digest::sha256_hex(&bytes));
919
}
920
map
921
}
922
923
889
fn write_completed_run(
924
890
root: &Path,
925
891
set: &str,
926
892
equipment_sha: Option<&str>,
927
checksums: BTreeMap<String, String>,
893
px: Pixel,
928
894
body_bytes: &[u8],
929
895
) {
930
let body_hash = digest::sha256_hex(body_bytes);
931
let mut render = serde_json::json!({
932
"format_version": 3,
933
"status": "incomplete",
934
"project": {"id": "demo", "name": "Demo"},
935
"sex": "male",
936
"set": set,
937
"body_model": {"path": "assets/body/x.glb", "sha256": body_hash},
938
"equipment_preset": null,
939
"target": [1, 1],
940
"fps": 12,
941
"highres_scale": 8,
942
"framing": framing(),
943
"pivot_source": {"kind": "manual"},
944
"near": NEAR,
945
"far": FAR,
946
"camera_distance": 10.0,
947
"yaw_zero": "camera on -Z",
948
"normal_space": "view (+Y up), PNG rows top-down",
949
"pitches": [-30, 0, 30],
950
"yaw_step": 30,
951
"sampling": "test",
952
"alpha": "straight",
953
"diffuse": "unlit",
954
"depth": "16-bit",
955
"specular": "scalar",
956
"pass_order": PASSES,
957
"saved_maps": ["diffuse", "depth", "normal", "specular"],
958
"clips": [{"name": "", "token": "clip000-bind", "frames": 1, "duration": 0.0}],
959
"images": 1,
960
"passes": 6,
961
"png_files": 4
962
});
963
if let Some(sha) = equipment_sha {
964
render["equipment_preset"] = serde_json::json!({"name": set, "sha256": sha});
896
let mut spec = SyntheticRun::body("demo", "male", body_bytes);
897
spec.framing = framing();
898
spec.set = set.into();
899
spec.equipment_sha256 = equipment_sha.map(str::to_string);
900
spec.write(root, |_, _, _, _| px).unwrap();
901
}
902
903
fn px(diffuse: [u8; 4], normal: [u8; 4], specular: [u8; 4], depth: u16) -> Pixel {
904
Pixel {
905
diffuse,
906
normal,
907
specular,
908
depth,
965
909
}
966
write_atomic_json(root, "render.json", &render);
967
let identity = complete_identity_json(&render).unwrap();
968
write_atomic_json(
969
root,
970
"complete.json",
971
&serde_json::json!({
972
"format_version": 3,
973
"status": "complete",
974
"identity": identity,
975
"passes": 6,
976
"png_files": 4,
977
"png_sha256": checksums
978
}),
979
);
980
910
}
981
911
982
912
#[test]
@@ -984,33 +914,21 @@ mod tests {
984
914
let body_tmp = Temp::new();
985
915
let gear_tmp = Temp::new();
986
916
let body_bytes = b"shared-body";
987
let body_maps = publish_pose(
988
&body_tmp.0,
989
"male",
990
"body",
917
let body_px = px(
991
918
[20, 40, 60, 255],
992
919
[128, 128, 255, 255],
993
920
[0, 0, 0, 255],
994
921
30000,
995
922
);
996
write_completed_run(&body_tmp.0, "body", None, body_maps, body_bytes);
923
write_completed_run(&body_tmp.0, "body", None, body_px, body_bytes);
997
924
let gear_hash = digest::sha256_hex(b"sword-ppe");
998
let gear_maps = publish_pose(
999
&gear_tmp.0,
1000
"male",
1001
"sword",
925
let gear_px = px(
1002
926
[220, 10, 10, 255],
1003
927
[255, 128, 128, 255],
1004
928
[200, 200, 200, 255],
1005
929
12000,
1006
930
);
1007
write_completed_run(
1008
&gear_tmp.0,
1009
"sword",
1010
Some(&gear_hash),
1011
gear_maps,
1012
body_bytes,
1013
);
931
write_completed_run(&gear_tmp.0, "sword", Some(&gear_hash), gear_px, body_bytes);
1014
932
1015
933
let body_run = ExportRun::open(&body_tmp.0).unwrap();
1016
934
let gear_run = ExportRun::open(&gear_tmp.0).unwrap();
@@ -1027,32 +945,33 @@ mod tests {
1027
945
assert_eq!(&composed.specular[..4], &[200, 200, 200, 255]);
1028
946
assert_eq!(lit.rgba[3], 255);
1029
947
assert!(lit.rgba[0] > 0);
948
949
// The .ppda/atlas path yields the identical layer maps.
950
let body_atlas = crate::atlas::AtlasSet::open_run(&body_tmp.0)
951
.unwrap()
952
.load()
953
.unwrap();
954
let from_atlas =
955
LayerMaps::from_atlas(&body_atlas, "body", "clip000-bind", 0, 60, 300).unwrap();
956
let loose =
957
LayerMaps::from_export_run(&body_run, "body", "clip000-bind", 0, 60, 300).unwrap();
958
assert_eq!(from_atlas.diffuse, loose.diffuse);
959
assert_eq!(from_atlas.normal, loose.normal);
960
assert_eq!(from_atlas.specular, loose.specular);
961
assert_eq!(from_atlas.depth, loose.depth);
962
assert_eq!(
963
CompatKey::from_ppda(body_atlas.ppda()),
964
CompatKey::from_run(&body_run)
965
);
1030
966
}
1031
967
1032
968
#[test]
1033
969
fn incompatible_export_sets_are_rejected() {
1034
970
let a = Temp::new();
1035
971
let b = Temp::new();
1036
let maps_a = publish_pose(
1037
&a.0,
1038
"male",
1039
"body",
1040
[1, 2, 3, 255],
1041
[128, 128, 255, 255],
1042
[0, 0, 0, 255],
1043
1000,
1044
);
1045
write_completed_run(&a.0, "body", None, maps_a, b"body-a");
1046
let maps_b = publish_pose(
1047
&b.0,
1048
"male",
1049
"body",
1050
[1, 2, 3, 255],
1051
[128, 128, 255, 255],
1052
[0, 0, 0, 255],
1053
1000,
1054
);
1055
write_completed_run(&b.0, "body", None, maps_b, b"body-b");
972
let same = px([1, 2, 3, 255], [128, 128, 255, 255], [0, 0, 0, 255], 1000);
973
write_completed_run(&a.0, "body", None, same, b"body-a");
974
write_completed_run(&b.0, "body", None, same, b"body-b");
1056
975
let run_a = ExportRun::open(&a.0).unwrap();
1057
976
let run_b = ExportRun::open(&b.0).unwrap();
1058
977
let err = assert_compatible(&[&run_a, &run_b]).unwrap_err();
@@ -150,6 +150,22 @@ impl LayerMaps {150
let depth = run.load_map(clip_token, frame, pitch, yaw, MapKind::Depth)?;150
let depth = run.load_map(clip_token, frame, pitch, yaw, MapKind::Depth)?;151
Self::from_maps(label, &diffuse, &normal, &specular, &depth)151
Self::from_maps(label, &diffuse, &normal, &specular, &depth)152
}152
}153
154
/// Crop the four map cells for one pose from decoded `.ppda` atlases.155
pub fn from_atlas(156
atlas: &crate::atlas::LoadedAtlas,157
label: impl Into<String>,158
clip_token: &str,159
frame: u32,160
pitch: i32,161
yaw: u32,162
) -> Result<Self, ComposeError> {163
let diffuse = atlas.cell(clip_token, frame, pitch, yaw, MapKind::Diffuse)?;164
let normal = atlas.cell(clip_token, frame, pitch, yaw, MapKind::Normal)?;165
let specular = atlas.cell(clip_token, frame, pitch, yaw, MapKind::Specular)?;166
let depth = atlas.cell(clip_token, frame, pitch, yaw, MapKind::Depth)?;167
Self::from_maps(label, &diffuse, &normal, &specular, &depth)168
}153
}169
}154
170
155
fn expect_rgba(image: &MapImage, kind: &str) -> Result<(u32, u32, Vec<u8>), ComposeError> {171
fn expect_rgba(image: &MapImage, kind: &str) -> Result<(u32, u32, Vec<u8>), ComposeError> {@@ -213,14 +229,40 @@ impl CompatKey {213
}229
}214
}230
}215
231
232
impl CompatKey {233
/// Same contract read from a validated `.ppda` (consumer format).234
pub fn from_ppda(p: &crate::atlas::Ppda) -> Self {235
Self {236
project_id: p.project.id.clone(),237
sex: p.sex.clone(),238
body_model_sha256: p.identity.body_model_sha256.clone(),239
target: [p.cell_width, p.cell_height],240
fps: p.fps,241
framing: p.identity.framing,242
near: p.identity.near,243
far: p.identity.far,244
clip_tokens: p245
.clips246
.iter()247
.map(|c| (c.token.clone(), c.frame_count))248
.collect(),249
}250
}251
}252
216
/// Reject runs that cannot be depth-composited together.253
/// Reject runs that cannot be depth-composited together.217
pub fn assert_compatible(runs: &[&ExportRun]) -> Result<(), ComposeError> {254
pub fn assert_compatible(runs: &[&ExportRun]) -> Result<(), ComposeError> {218
if runs.is_empty() {255
let keys: Vec<CompatKey> = runs.iter().map(|r| CompatKey::from_run(r)).collect();256
assert_compatible_keys(&keys)257
}258
259
/// Reject layer contracts that cannot be depth-composited together.260
pub fn assert_compatible_keys(keys: &[CompatKey]) -> Result<(), ComposeError> {261
if keys.is_empty() {219
return Err(ComposeError::Empty);262
return Err(ComposeError::Empty);220
}263
}221
let first = CompatKey::from_run(runs[0]);264
let first = &keys[0];222
for run in runs.iter().skip(1) {265
for key in keys.iter().skip(1) {223
let key = CompatKey::from_run(run);224
if key.project_id != first.project_id {266
if key.project_id != first.project_id {225
return Err(ComposeError::Incompatible(267
return Err(ComposeError::Incompatible(226
"project.id verschilt tussen exportsets".into(),268
"project.id verschilt tussen exportsets".into(),@@ -505,10 +547,9 @@ fn encode_srgb(x: f32) -> u8 {505
mod tests {547
mod tests {506
use super::*;548
use super::*;507
use crate::digest;549
use crate::digest;508
use crate::export_set::{complete_identity_json, ExportRun};550
use crate::export_fixture::{Pixel, SyntheticRun};509
use crate::production::{write_depth, write_rgba, FAR, NEAR, PASSES};551
use crate::export_set::ExportRun;510
use crate::project::CameraFraming;552
use crate::project::CameraFraming;511
use std::collections::BTreeMap;512
use std::fs;553
use std::fs;513
use std::path::{Path, PathBuf};554
use std::path::{Path, PathBuf};514
use std::sync::atomic::{AtomicU64, Ordering};555
use std::sync::atomic::{AtomicU64, Ordering};@@ -845,138 +886,27 @@ mod tests {845
assert!(matches!(compose(&[a, b]), Err(ComposeError::Dimension(_))));886
assert!(matches!(compose(&[a, b]), Err(ComposeError::Dimension(_))));846
}887
}847
888
848
fn write_atomic_json(root: &Path, name: &str, value: &serde_json::Value) {849
let data = serde_json::to_vec_pretty(value).unwrap();850
let path = root.join(name);851
let temp = path.with_extension(format!("part-{}", std::process::id()));852
fs::write(&temp, &data).unwrap();853
fs::hard_link(&temp, &path).unwrap();854
let _ = fs::remove_file(temp);855
}856
857
fn publish_pose(858
root: &Path,859
sex: &str,860
set: &str,861
diffuse: [u8; 4],862
normal: [u8; 4],863
specular: [u8; 4],864
depth_gray: u16,865
) -> BTreeMap<String, String> {866
let mut map = BTreeMap::new();867
let base = format!("{sex}/{set}/clip000-bind/f000000/p+00_y000");868
let rels = [869
(format!("{base}_diffuse.png"), "diffuse"),870
(format!("{base}_depth.png"), "depth"),871
(format!("{base}_normal.png"), "normal"),872
(format!("{base}_specular.png"), "specular"),873
];874
for (rel, kind) in rels {875
let path = Path::new(&rel);876
match kind {877
"depth" => {878
write_depth(root, path, 1, 1, &[depth_gray, 65535]).unwrap();879
}880
"normal" => {881
write_rgba(882
root,883
path,884
&ImageBuf {885
width: 1,886
height: 1,887
rgba: normal.to_vec(),888
},889
)890
.unwrap();891
}892
"specular" => {893
write_rgba(894
root,895
path,896
&ImageBuf {897
width: 1,898
height: 1,899
rgba: specular.to_vec(),900
},901
)902
.unwrap();903
}904
_ => {905
write_rgba(906
root,907
path,908
&ImageBuf {909
width: 1,910
height: 1,911
rgba: diffuse.to_vec(),912
},913
)914
.unwrap();915
}916
}917
let bytes = fs::read(root.join(&rel)).unwrap();918
map.insert(rel, digest::sha256_hex(&bytes));919
}920
map921
}922
923
fn write_completed_run(889
fn write_completed_run(924
root: &Path,890
root: &Path,925
set: &str,891
set: &str,926
equipment_sha: Option<&str>,892
equipment_sha: Option<&str>,927
checksums: BTreeMap<String, String>,893
px: Pixel,928
body_bytes: &[u8],894
body_bytes: &[u8],929
) {895
) {930
let body_hash = digest::sha256_hex(body_bytes);896
let mut spec = SyntheticRun::body("demo", "male", body_bytes);931
let mut render = serde_json::json!({897
spec.framing = framing();932
"format_version": 3,898
spec.set = set.into();933
"status": "incomplete",899
spec.equipment_sha256 = equipment_sha.map(str::to_string);934
"project": {"id": "demo", "name": "Demo"},900
spec.write(root, |_, _, _, _| px).unwrap();935
"sex": "male",901
}936
"set": set,902
937
"body_model": {"path": "assets/body/x.glb", "sha256": body_hash},903
fn px(diffuse: [u8; 4], normal: [u8; 4], specular: [u8; 4], depth: u16) -> Pixel {938
"equipment_preset": null,904
Pixel {939
"target": [1, 1],905
diffuse,940
"fps": 12,906
normal,941
"highres_scale": 8,907
specular,942
"framing": framing(),908
depth,943
"pivot_source": {"kind": "manual"},944
"near": NEAR,945
"far": FAR,946
"camera_distance": 10.0,947
"yaw_zero": "camera on -Z",948
"normal_space": "view (+Y up), PNG rows top-down",949
"pitches": [-30, 0, 30],950
"yaw_step": 30,951
"sampling": "test",952
"alpha": "straight",953
"diffuse": "unlit",954
"depth": "16-bit",955
"specular": "scalar",956
"pass_order": PASSES,957
"saved_maps": ["diffuse", "depth", "normal", "specular"],958
"clips": [{"name": "", "token": "clip000-bind", "frames": 1, "duration": 0.0}],959
"images": 1,960
"passes": 6,961
"png_files": 4962
});963
if let Some(sha) = equipment_sha {964
render["equipment_preset"] = serde_json::json!({"name": set, "sha256": sha});965
}909
}966
write_atomic_json(root, "render.json", &render);967
let identity = complete_identity_json(&render).unwrap();968
write_atomic_json(969
root,970
"complete.json",971
&serde_json::json!({972
"format_version": 3,973
"status": "complete",974
"identity": identity,975
"passes": 6,976
"png_files": 4,977
"png_sha256": checksums978
}),979
);980
}910
}981
911
982
#[test]912
#[test]@@ -984,33 +914,21 @@ mod tests {984
let body_tmp = Temp::new();914
let body_tmp = Temp::new();985
let gear_tmp = Temp::new();915
let gear_tmp = Temp::new();986
let body_bytes = b"shared-body";916
let body_bytes = b"shared-body";987
let body_maps = publish_pose(917
let body_px = px(988
&body_tmp.0,989
"male",990
"body",991
[20, 40, 60, 255],918
[20, 40, 60, 255],992
[128, 128, 255, 255],919
[128, 128, 255, 255],993
[0, 0, 0, 255],920
[0, 0, 0, 255],994
30000,921
30000,995
);922
);996
write_completed_run(&body_tmp.0, "body", None, body_maps, body_bytes);923
write_completed_run(&body_tmp.0, "body", None, body_px, body_bytes);997
let gear_hash = digest::sha256_hex(b"sword-ppe");924
let gear_hash = digest::sha256_hex(b"sword-ppe");998
let gear_maps = publish_pose(925
let gear_px = px(999
&gear_tmp.0,1000
"male",1001
"sword",1002
[220, 10, 10, 255],926
[220, 10, 10, 255],1003
[255, 128, 128, 255],927
[255, 128, 128, 255],1004
[200, 200, 200, 255],928
[200, 200, 200, 255],1005
12000,929
12000,1006
);930
);1007
write_completed_run(931
write_completed_run(&gear_tmp.0, "sword", Some(&gear_hash), gear_px, body_bytes);1008
&gear_tmp.0,1009
"sword",1010
Some(&gear_hash),1011
gear_maps,1012
body_bytes,1013
);1014
932
1015
let body_run = ExportRun::open(&body_tmp.0).unwrap();933
let body_run = ExportRun::open(&body_tmp.0).unwrap();1016
let gear_run = ExportRun::open(&gear_tmp.0).unwrap();934
let gear_run = ExportRun::open(&gear_tmp.0).unwrap();@@ -1027,32 +945,33 @@ mod tests {1027
assert_eq!(&composed.specular[..4], &[200, 200, 200, 255]);945
assert_eq!(&composed.specular[..4], &[200, 200, 200, 255]);1028
assert_eq!(lit.rgba[3], 255);946
assert_eq!(lit.rgba[3], 255);1029
assert!(lit.rgba[0] > 0);947
assert!(lit.rgba[0] > 0);948
949
// The .ppda/atlas path yields the identical layer maps.950
let body_atlas = crate::atlas::AtlasSet::open_run(&body_tmp.0)951
.unwrap()952
.load()953
.unwrap();954
let from_atlas =955
LayerMaps::from_atlas(&body_atlas, "body", "clip000-bind", 0, 60, 300).unwrap();956
let loose =957
LayerMaps::from_export_run(&body_run, "body", "clip000-bind", 0, 60, 300).unwrap();958
assert_eq!(from_atlas.diffuse, loose.diffuse);959
assert_eq!(from_atlas.normal, loose.normal);960
assert_eq!(from_atlas.specular, loose.specular);961
assert_eq!(from_atlas.depth, loose.depth);962
assert_eq!(963
CompatKey::from_ppda(body_atlas.ppda()),964
CompatKey::from_run(&body_run)965
);1030
}966
}1031
967
1032
#[test]968
#[test]1033
fn incompatible_export_sets_are_rejected() {969
fn incompatible_export_sets_are_rejected() {1034
let a = Temp::new();970
let a = Temp::new();1035
let b = Temp::new();971
let b = Temp::new();1036
let maps_a = publish_pose(972
let same = px([1, 2, 3, 255], [128, 128, 255, 255], [0, 0, 0, 255], 1000);1037
&a.0,973
write_completed_run(&a.0, "body", None, same, b"body-a");1038
"male",974
write_completed_run(&b.0, "body", None, same, b"body-b");1039
"body",1040
[1, 2, 3, 255],1041
[128, 128, 255, 255],1042
[0, 0, 0, 255],1043
1000,1044
);1045
write_completed_run(&a.0, "body", None, maps_a, b"body-a");1046
let maps_b = publish_pose(1047
&b.0,1048
"male",1049
"body",1050
[1, 2, 3, 255],1051
[128, 128, 255, 255],1052
[0, 0, 0, 255],1053
1000,1054
);1055
write_completed_run(&b.0, "body", None, maps_b, b"body-b");1056
let run_a = ExportRun::open(&a.0).unwrap();975
let run_a = ExportRun::open(&a.0).unwrap();1057
let run_b = ExportRun::open(&b.0).unwrap();976
let run_b = ExportRun::open(&b.0).unwrap();1058
let err = assert_compatible(&[&run_a, &run_b]).unwrap_err();977
let err = assert_compatible(&[&run_a, &run_b]).unwrap_err();@@ -10,9 +10,9 @@ use crate::preview::{
10
10
is_drawable_body, load_body_file, PreviewCharacter, PreviewControls, PreviewSex,
11
11
};
12
12
use crate::preview2d::{
13
compatible_equipment_sets, compose_preview_frame, discover_completed_runs, open_selection,
14
sync_state_from_catalog, CatalogEntry, ComposeRequest, HoldSign, LightAxis, Preview2dState,
15
ViewMode,
13
compatible_equipment_sets, compose_preview_frame, discover_exports, open_selection,
14
selection_paths, sync_state_from_catalog, CatalogEntry, ComposeRequest, HoldSign, LightAxis,
15
Preview2dState, ViewMode,
16
16
};
17
17
use crate::preview_gl::Viewport;
18
18
use crate::project::open_project;
@@ -42,6 +42,10 @@ pub struct EditorApp {
42
42
equipment_part: usize,
43
43
preview2d: Preview2dState,
44
44
preview2d_catalog: Vec<CatalogEntry>,
45
/// Runs under `renders/` without a valid `.ppda` (reason shown on hover).
46
preview2d_skipped: Vec<(std::path::PathBuf, String)>,
47
/// Decoded `.ppda` atlases of the current selection, keyed by run paths.
48
preview2d_loaded: Option<PreviewSelection>,
45
49
preview2d_texture: Option<egui::TextureHandle>,
46
50
preview2d_texture_key: Option<(String, u32, i32, u32, u32, String)>,
47
51
preview2d_image: Option<ImageBuf>,
@@ -51,6 +55,13 @@ pub struct EditorApp {
51
55
resume_scan_error: Option<String>,
52
56
}
53
57
58
/// Decoded atlases for the 2D preview selection (body first).
59
struct PreviewSelection {
60
paths: Vec<std::path::PathBuf>,
61
body: crate::atlas::LoadedAtlas,
62
gear: Vec<crate::atlas::LoadedAtlas>,
63
}
64
54
65
impl EditorApp {
55
66
pub fn new(dialogs: Box<dyn Dialogs>) -> Self {
56
67
Self {
@@ -76,6 +87,8 @@ impl EditorApp {
76
87
equipment_part: 0,
77
88
preview2d: Preview2dState::default(),
78
89
preview2d_catalog: Vec::new(),
90
preview2d_skipped: Vec::new(),
91
preview2d_loaded: None,
79
92
preview2d_texture: None,
80
93
preview2d_texture_key: None,
81
94
preview2d_image: None,
@@ -1391,10 +1404,11 @@ impl EditorApp {
1391
1404
}
1392
1405
1393
1406
fn refresh_preview2d_catalog(&mut self) {
1394
self.preview2d_catalog = match self.session.project_root() {
1395
Some(root) => discover_completed_runs(&root),
1396
None => Vec::new(),
1407
(self.preview2d_catalog, self.preview2d_skipped) = match self.session.project_root() {
1408
Some(root) => discover_exports(&root),
1409
None => (Vec::new(), Vec::new()),
1397
1410
};
1411
self.preview2d_loaded = None;
1398
1412
}
1399
1413
1400
1414
fn rebuild_preview2d_frame(&mut self) {
@@ -1411,10 +1425,31 @@ impl EditorApp {
1411
1425
self.preview2d_image = None;
1412
1426
return;
1413
1427
}
1414
match open_selection(&self.preview2d_catalog, sex, &equipment) {
1415
Ok((body, gear)) => match compose_preview_frame(ComposeRequest {
1416
body: &body,
1417
equipment: &gear,
1428
let paths = match selection_paths(&self.preview2d_catalog, sex, &equipment) {
1429
Ok(paths) => paths,
1430
Err(err) => {
1431
self.preview2d.diagnostic = Some(err.to_string());
1432
self.preview2d_image = None;
1433
return;
1434
}
1435
};
1436
if self.preview2d_loaded.as_ref().map(|l| &l.paths) != Some(&paths) {
1437
self.preview2d_loaded = None;
1438
match open_selection(&self.preview2d_catalog, sex, &equipment) {
1439
Ok((body, gear)) => {
1440
self.preview2d_loaded = Some(PreviewSelection { paths, body, gear })
1441
}
1442
Err(err) => {
1443
self.preview2d.diagnostic = Some(err.to_string());
1444
self.preview2d_image = None;
1445
return;
1446
}
1447
}
1448
}
1449
match self.preview2d_loaded.as_ref().ok_or("geen selectie") {
1450
Ok(PreviewSelection { body, gear, .. }) => match compose_preview_frame(ComposeRequest {
1451
body,
1452
equipment: gear,
1418
1453
clip_token: &clip,
1419
1454
frame,
1420
1455
pitch,
@@ -1503,9 +1538,25 @@ impl EditorApp {
1503
1538
refresh_catalog = true;
1504
1539
}
1505
1540
ui.label(format!(
1506
"{} voltooide exportset(s)",
1541
"{} voltooide exportset(s) (.ppda + atlassen)",
1507
1542
self.preview2d_catalog.len()
1508
1543
));
1544
if !self.preview2d_skipped.is_empty() {
1545
let reasons = self
1546
.preview2d_skipped
1547
.iter()
1548
.map(|(path, why)| {
1549
let name = path.file_name().unwrap_or_default().to_string_lossy();
1550
format!("{name}: {why}")
1551
})
1552
.collect::<Vec<_>>()
1553
.join("\n");
1554
ui.weak(format!(
1555
"{} run(s) zonder geldige .ppda overgeslagen",
1556
self.preview2d_skipped.len()
1557
))
1558
.on_hover_text(reasons);
1559
}
1509
1560
ui.separator();
1510
1561
ui.label("Equipment (zelfde project/rig/contract)");
1511
1562
let allowed =
@@ -2024,7 +2075,7 @@ mod tests {
2024
2075
assert_eq!(job.body_model.path, relative);
2025
2076
assert!(job.character.is_static());
2026
2077
let contract = crate::production::contract(job).unwrap();
2027
assert_eq!(contract.plan.images, 36);
2078
assert_eq!(contract.plan.images, 60);
2028
2079
assert_eq!(contract.plan.clips[0].token, "clip000-bind");
2029
2080
app.open_equipment_configuration(index, Sex::Male);
2030
2081
let job = app.build_job().expect("equipment job object");
@@ -10,9 +10,9 @@ use crate::preview::{10
is_drawable_body, load_body_file, PreviewCharacter, PreviewControls, PreviewSex,10
is_drawable_body, load_body_file, PreviewCharacter, PreviewControls, PreviewSex,11
};11
};12
use crate::preview2d::{12
use crate::preview2d::{13
compatible_equipment_sets, compose_preview_frame, discover_completed_runs, open_selection,13
compatible_equipment_sets, compose_preview_frame, discover_exports, open_selection,14
sync_state_from_catalog, CatalogEntry, ComposeRequest, HoldSign, LightAxis, Preview2dState,14
selection_paths, sync_state_from_catalog, CatalogEntry, ComposeRequest, HoldSign, LightAxis,15
ViewMode,15
Preview2dState, ViewMode,16
};16
};17
use crate::preview_gl::Viewport;17
use crate::preview_gl::Viewport;18
use crate::project::open_project;18
use crate::project::open_project;@@ -42,6 +42,10 @@ pub struct EditorApp {42
equipment_part: usize,42
equipment_part: usize,43
preview2d: Preview2dState,43
preview2d: Preview2dState,44
preview2d_catalog: Vec<CatalogEntry>,44
preview2d_catalog: Vec<CatalogEntry>,45
/// Runs under `renders/` without a valid `.ppda` (reason shown on hover).46
preview2d_skipped: Vec<(std::path::PathBuf, String)>,47
/// Decoded `.ppda` atlases of the current selection, keyed by run paths.48
preview2d_loaded: Option<PreviewSelection>,45
preview2d_texture: Option<egui::TextureHandle>,49
preview2d_texture: Option<egui::TextureHandle>,46
preview2d_texture_key: Option<(String, u32, i32, u32, u32, String)>,50
preview2d_texture_key: Option<(String, u32, i32, u32, u32, String)>,47
preview2d_image: Option<ImageBuf>,51
preview2d_image: Option<ImageBuf>,@@ -51,6 +55,13 @@ pub struct EditorApp {51
resume_scan_error: Option<String>,55
resume_scan_error: Option<String>,52
}56
}53
57
58
/// Decoded atlases for the 2D preview selection (body first).59
struct PreviewSelection {60
paths: Vec<std::path::PathBuf>,61
body: crate::atlas::LoadedAtlas,62
gear: Vec<crate::atlas::LoadedAtlas>,63
}64
54
impl EditorApp {65
impl EditorApp {55
pub fn new(dialogs: Box<dyn Dialogs>) -> Self {66
pub fn new(dialogs: Box<dyn Dialogs>) -> Self {56
Self {67
Self {@@ -76,6 +87,8 @@ impl EditorApp {76
equipment_part: 0,87
equipment_part: 0,77
preview2d: Preview2dState::default(),88
preview2d: Preview2dState::default(),78
preview2d_catalog: Vec::new(),89
preview2d_catalog: Vec::new(),90
preview2d_skipped: Vec::new(),91
preview2d_loaded: None,79
preview2d_texture: None,92
preview2d_texture: None,80
preview2d_texture_key: None,93
preview2d_texture_key: None,81
preview2d_image: None,94
preview2d_image: None,@@ -1391,10 +1404,11 @@ impl EditorApp {1391
}1404
}1392
1405
1393
fn refresh_preview2d_catalog(&mut self) {1406
fn refresh_preview2d_catalog(&mut self) {1394
self.preview2d_catalog = match self.session.project_root() {1407
(self.preview2d_catalog, self.preview2d_skipped) = match self.session.project_root() {1395
Some(root) => discover_completed_runs(&root),1408
Some(root) => discover_exports(&root),1396
None => Vec::new(),1409
None => (Vec::new(), Vec::new()),1397
};1410
};1411
self.preview2d_loaded = None;1398
}1412
}1399
1413
1400
fn rebuild_preview2d_frame(&mut self) {1414
fn rebuild_preview2d_frame(&mut self) {@@ -1411,10 +1425,31 @@ impl EditorApp {1411
self.preview2d_image = None;1425
self.preview2d_image = None;1412
return;1426
return;1413
}1427
}1414
match open_selection(&self.preview2d_catalog, sex, &equipment) {1428
let paths = match selection_paths(&self.preview2d_catalog, sex, &equipment) {1415
Ok((body, gear)) => match compose_preview_frame(ComposeRequest {1429
Ok(paths) => paths,1416
body: &body,1430
Err(err) => {1417
equipment: &gear,1431
self.preview2d.diagnostic = Some(err.to_string());1432
self.preview2d_image = None;1433
return;1434
}1435
};1436
if self.preview2d_loaded.as_ref().map(|l| &l.paths) != Some(&paths) {1437
self.preview2d_loaded = None;1438
match open_selection(&self.preview2d_catalog, sex, &equipment) {1439
Ok((body, gear)) => {1440
self.preview2d_loaded = Some(PreviewSelection { paths, body, gear })1441
}1442
Err(err) => {1443
self.preview2d.diagnostic = Some(err.to_string());1444
self.preview2d_image = None;1445
return;1446
}1447
}1448
}1449
match self.preview2d_loaded.as_ref().ok_or("geen selectie") {1450
Ok(PreviewSelection { body, gear, .. }) => match compose_preview_frame(ComposeRequest {1451
body,1452
equipment: gear,1418
clip_token: &clip,1453
clip_token: &clip,1419
frame,1454
frame,1420
pitch,1455
pitch,@@ -1503,9 +1538,25 @@ impl EditorApp {1503
refresh_catalog = true;1538
refresh_catalog = true;1504
}1539
}1505
ui.label(format!(1540
ui.label(format!(1506
"{} voltooide exportset(s)",1541
"{} voltooide exportset(s) (.ppda + atlassen)",1507
self.preview2d_catalog.len()1542
self.preview2d_catalog.len()1508
));1543
));1544
if !self.preview2d_skipped.is_empty() {1545
let reasons = self1546
.preview2d_skipped1547
.iter()1548
.map(|(path, why)| {1549
let name = path.file_name().unwrap_or_default().to_string_lossy();1550
format!("{name}: {why}")1551
})1552
.collect::<Vec<_>>()1553
.join("\n");1554
ui.weak(format!(1555
"{} run(s) zonder geldige .ppda overgeslagen",1556
self.preview2d_skipped.len()1557
))1558
.on_hover_text(reasons);1559
}1509
ui.separator();1560
ui.separator();1510
ui.label("Equipment (zelfde project/rig/contract)");1561
ui.label("Equipment (zelfde project/rig/contract)");1511
let allowed =1562
let allowed =@@ -2024,7 +2075,7 @@ mod tests {2024
assert_eq!(job.body_model.path, relative);2075
assert_eq!(job.body_model.path, relative);2025
assert!(job.character.is_static());2076
assert!(job.character.is_static());2026
let contract = crate::production::contract(job).unwrap();2077
let contract = crate::production::contract(job).unwrap();2027
assert_eq!(contract.plan.images, 36);2078
assert_eq!(contract.plan.images, 60);2028
assert_eq!(contract.plan.clips[0].token, "clip000-bind");2079
assert_eq!(contract.plan.clips[0].token, "clip000-bind");2029
app.open_equipment_configuration(index, Sex::Male);2080
app.open_equipment_configuration(index, Sex::Male);2030
let job = app.build_job().expect("equipment job object");2081
let job = app.build_job().expect("equipment job object");@@ -0,0 +1,223 @@
1
//! Synthetic completed export runs for tests, examples and GUI smokes.
2
//!
3
//! Writes real loose target PNGs (uniform colour per cell), schema-4
4
//! `render.json`, the per-map atlases + `.ppda` through the production
5
//! [`crate::atlas::write_atlases`] path, and `complete.json`. This replaces
6
//! hand-written partial manifests; it is not used by the renderer itself.
7
use crate::{
8
atlas, digest,
9
export_set::{complete_identity_json, OUTPUT_META_VERSION},
10
preview::PRODUCTION_PITCHES,
11
production::{self, ClipPlan, Plan, FAR, NEAR, PASSES},
12
project::CameraFraming,
13
render_job::ImageBuf,
14
};
15
use std::{collections::BTreeMap, path::Path, sync::atomic::AtomicBool};
16
17
/// Uniform per-cell map values.
18
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19
pub struct Pixel {
20
pub diffuse: [u8; 4],
21
pub normal: [u8; 4],
22
pub specular: [u8; 4],
23
pub depth: u16,
24
}
25
26
#[derive(Debug, Clone)]
27
pub struct SyntheticRun {
28
pub project_id: String,
29
pub sex: String,
30
pub set: String,
31
pub equipment_sha256: Option<String>,
32
pub body_bytes: Vec<u8>,
33
pub target: [u32; 2],
34
pub fps: u32,
35
pub framing: CameraFraming,
36
/// (source name, frames, duration seconds)
37
pub clips: Vec<(String, u32, f32)>,
38
}
39
40
impl SyntheticRun {
41
pub fn body(project_id: &str, sex: &str, body_bytes: &[u8]) -> Self {
42
Self {
43
project_id: project_id.into(),
44
sex: sex.into(),
45
set: "body".into(),
46
equipment_sha256: None,
47
body_bytes: body_bytes.to_vec(),
48
target: [1, 1],
49
fps: crate::project::DEFAULT_FPS,
50
framing: CameraFraming {
51
height_milli: 1800,
52
center_y_milli: 900,
53
pivot_y_milli: Some(1500),
54
},
55
clips: vec![("bind".into(), 1, 0.0)],
56
}
57
}
58
59
pub fn plan(&self) -> Plan {
60
let clips: Vec<ClipPlan> = self
61
.clips
62
.iter()
63
.enumerate()
64
.map(|(i, (name, frames, _))| ClipPlan {
65
source_name: name.clone(),
66
token: production::clip_token(i, name),
67
frames: *frames,
68
})
69
.collect();
70
let frames: u32 = clips.iter().map(|c| c.frames).sum();
71
let images = frames * crate::preview::PRODUCTION_VIEWS;
72
Plan {
73
clips,
74
images,
75
passes: images * PASSES.len() as u32,
76
files: images * 4,
77
}
78
}
79
80
/// `render.json` with the same keys production writes.
81
pub fn render_doc(&self) -> serde_json::Value {
82
let plan = self.plan();
83
let clips: Vec<_> = plan
84
.clips
85
.iter()
86
.zip(&self.clips)
87
.map(|(c, (_, _, duration))| {
88
serde_json::json!({
89
"name": c.source_name,
90
"token": c.token,
91
"frames": c.frames,
92
"duration": duration,
93
})
94
})
95
.collect();
96
let equipment = self
97
.equipment_sha256
98
.as_ref()
99
.map(|sha| serde_json::json!({"name": self.set, "sha256": sha}));
100
serde_json::json!({
101
"format_version": OUTPUT_META_VERSION,
102
"status": "incomplete",
103
"project": {"id": self.project_id, "name": "Synthetic"},
104
"sex": self.sex,
105
"set": self.set,
106
"body_model": {"path": "assets/body/synthetic.glb", "sha256": digest::sha256_hex(&self.body_bytes)},
107
"equipment_preset": equipment,
108
"target": self.target,
109
"fps": self.fps,
110
"highres_scale": production::SCALE,
111
"framing": self.framing,
112
"pivot_source": {"kind": "manual"},
113
"near": NEAR,
114
"far": FAR,
115
"normal_space": "view (+Y up), PNG rows top-down",
116
"camera_distance": 10,
117
"yaw_zero": "camera on -Z",
118
"pitches": PRODUCTION_PITCHES,
119
"yaws": production::production_yaws(),
120
"yaw_step": 30,
121
"sampling": "synthetic fixture",
122
"alpha": "straight",
123
"diffuse": "unlit",
124
"depth": "16-bit gray+alpha; distance=near+(sample/65535)*(far-near)",
125
"specular": "scalar",
126
"pass_order": PASSES,
127
"saved_maps": ["diffuse", "depth", "normal", "specular"],
128
"clips": clips,
129
"images": plan.images,
130
"passes": plan.passes,
131
"png_files": plan.files,
132
})
133
}
134
135
/// Write the complete run into `root` (created if needed).
136
pub fn write(
137
&self,
138
root: &Path,
139
pixel: impl Fn(usize, u32, i32, u32) -> Pixel,
140
) -> Result<(), String> {
141
std::fs::create_dir_all(root).map_err(|e| e.to_string())?;
142
let plan = self.plan();
143
let render = self.render_doc();
144
let [w, h] = self.target;
145
let n = w as usize * h as usize;
146
let mut sums = BTreeMap::new();
147
for s in production::samples(&plan) {
148
let px = pixel(s.clip, s.frame, s.pitch, s.yaw);
149
for map in production::SAVED_ORDER {
150
let rel = production::output_path(
151
&self.sex,
152
&self.set,
153
&plan.clips[s.clip].token,
154
s.frame,
155
s.pitch,
156
s.yaw,
157
map,
158
)?;
159
let digest = if map == "depth" {
160
let pair = if px.diffuse[3] == 0 {
161
[0, 0]
162
} else {
163
[px.depth, 65535]
164
};
165
let samples: Vec<u16> = pair.repeat(n);
166
production::write_depth(root, &rel, w, h, &samples)?
167
} else {
168
let rgba = match map {
169
"diffuse" => px.diffuse,
170
"normal" => px.normal,
171
_ => px.specular,
172
};
173
production::write_rgba(
174
root,
175
&rel,
176
&ImageBuf {
177
width: w,
178
height: h,
179
rgba: rgba.repeat(n),
180
},
181
)?
182
};
183
sums.insert(rel.to_string_lossy().replace('\\', "/"), digest);
184
}
185
}
186
write_json(root, "render.json", &render)?;
187
let cancel = AtomicBool::new(false);
188
let atlas = atlas::write_atlases(
189
&atlas::AtlasJob {
190
root,
191
render_doc: &render,
192
plan: &plan,
193
sex: &self.sex,
194
set: &self.set,
195
cell_width: w,
196
cell_height: h,
197
png_sha256: &sums,
198
attempt: 0,
199
cancel: &cancel,
200
},
201
&mut |_| {},
202
)?;
203
let identity = complete_identity_json(&render)?;
204
write_json(
205
root,
206
"complete.json",
207
&serde_json::json!({
208
"format_version": OUTPUT_META_VERSION,
209
"status": "complete",
210
"identity": identity,
211
"passes": plan.passes,
212
"png_files": plan.files,
213
"png_sha256": sums,
214
"atlas": atlas.to_json(),
215
}),
216
)
217
}
218
}
219
220
fn write_json(root: &Path, name: &str, value: &serde_json::Value) -> Result<(), String> {
221
let bytes = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
222
production::publish_bytes(root, Path::new(name), &bytes)
223
}
@@ -0,0 +1,223 @@1
//! Synthetic completed export runs for tests, examples and GUI smokes.2
//!3
//! Writes real loose target PNGs (uniform colour per cell), schema-44
//! `render.json`, the per-map atlases + `.ppda` through the production5
//! [`crate::atlas::write_atlases`] path, and `complete.json`. This replaces6
//! hand-written partial manifests; it is not used by the renderer itself.7
use crate::{8
atlas, digest,9
export_set::{complete_identity_json, OUTPUT_META_VERSION},10
preview::PRODUCTION_PITCHES,11
production::{self, ClipPlan, Plan, FAR, NEAR, PASSES},12
project::CameraFraming,13
render_job::ImageBuf,14
};15
use std::{collections::BTreeMap, path::Path, sync::atomic::AtomicBool};16
17
/// Uniform per-cell map values.18
#[derive(Debug, Clone, Copy, PartialEq, Eq)]19
pub struct Pixel {20
pub diffuse: [u8; 4],21
pub normal: [u8; 4],22
pub specular: [u8; 4],23
pub depth: u16,24
}25
26
#[derive(Debug, Clone)]27
pub struct SyntheticRun {28
pub project_id: String,29
pub sex: String,30
pub set: String,31
pub equipment_sha256: Option<String>,32
pub body_bytes: Vec<u8>,33
pub target: [u32; 2],34
pub fps: u32,35
pub framing: CameraFraming,36
/// (source name, frames, duration seconds)37
pub clips: Vec<(String, u32, f32)>,38
}39
40
impl SyntheticRun {41
pub fn body(project_id: &str, sex: &str, body_bytes: &[u8]) -> Self {42
Self {43
project_id: project_id.into(),44
sex: sex.into(),45
set: "body".into(),46
equipment_sha256: None,47
body_bytes: body_bytes.to_vec(),48
target: [1, 1],49
fps: crate::project::DEFAULT_FPS,50
framing: CameraFraming {51
height_milli: 1800,52
center_y_milli: 900,53
pivot_y_milli: Some(1500),54
},55
clips: vec![("bind".into(), 1, 0.0)],56
}57
}58
59
pub fn plan(&self) -> Plan {60
let clips: Vec<ClipPlan> = self61
.clips62
.iter()63
.enumerate()64
.map(|(i, (name, frames, _))| ClipPlan {65
source_name: name.clone(),66
token: production::clip_token(i, name),67
frames: *frames,68
})69
.collect();70
let frames: u32 = clips.iter().map(|c| c.frames).sum();71
let images = frames * crate::preview::PRODUCTION_VIEWS;72
Plan {73
clips,74
images,75
passes: images * PASSES.len() as u32,76
files: images * 4,77
}78
}79
80
/// `render.json` with the same keys production writes.81
pub fn render_doc(&self) -> serde_json::Value {82
let plan = self.plan();83
let clips: Vec<_> = plan84
.clips85
.iter()86
.zip(&self.clips)87
.map(|(c, (_, _, duration))| {88
serde_json::json!({89
"name": c.source_name,90
"token": c.token,91
"frames": c.frames,92
"duration": duration,93
})94
})95
.collect();96
let equipment = self97
.equipment_sha25698
.as_ref()99
.map(|sha| serde_json::json!({"name": self.set, "sha256": sha}));100
serde_json::json!({101
"format_version": OUTPUT_META_VERSION,102
"status": "incomplete",103
"project": {"id": self.project_id, "name": "Synthetic"},104
"sex": self.sex,105
"set": self.set,106
"body_model": {"path": "assets/body/synthetic.glb", "sha256": digest::sha256_hex(&self.body_bytes)},107
"equipment_preset": equipment,108
"target": self.target,109
"fps": self.fps,110
"highres_scale": production::SCALE,111
"framing": self.framing,112
"pivot_source": {"kind": "manual"},113
"near": NEAR,114
"far": FAR,115
"normal_space": "view (+Y up), PNG rows top-down",116
"camera_distance": 10,117
"yaw_zero": "camera on -Z",118
"pitches": PRODUCTION_PITCHES,119
"yaws": production::production_yaws(),120
"yaw_step": 30,121
"sampling": "synthetic fixture",122
"alpha": "straight",123
"diffuse": "unlit",124
"depth": "16-bit gray+alpha; distance=near+(sample/65535)*(far-near)",125
"specular": "scalar",126
"pass_order": PASSES,127
"saved_maps": ["diffuse", "depth", "normal", "specular"],128
"clips": clips,129
"images": plan.images,130
"passes": plan.passes,131
"png_files": plan.files,132
})133
}134
135
/// Write the complete run into `root` (created if needed).136
pub fn write(137
&self,138
root: &Path,139
pixel: impl Fn(usize, u32, i32, u32) -> Pixel,140
) -> Result<(), String> {141
std::fs::create_dir_all(root).map_err(|e| e.to_string())?;142
let plan = self.plan();143
let render = self.render_doc();144
let [w, h] = self.target;145
let n = w as usize * h as usize;146
let mut sums = BTreeMap::new();147
for s in production::samples(&plan) {148
let px = pixel(s.clip, s.frame, s.pitch, s.yaw);149
for map in production::SAVED_ORDER {150
let rel = production::output_path(151
&self.sex,152
&self.set,153
&plan.clips[s.clip].token,154
s.frame,155
s.pitch,156
s.yaw,157
map,158
)?;159
let digest = if map == "depth" {160
let pair = if px.diffuse[3] == 0 {161
[0, 0]162
} else {163
[px.depth, 65535]164
};165
let samples: Vec<u16> = pair.repeat(n);166
production::write_depth(root, &rel, w, h, &samples)?167
} else {168
let rgba = match map {169
"diffuse" => px.diffuse,170
"normal" => px.normal,171
_ => px.specular,172
};173
production::write_rgba(174
root,175
&rel,176
&ImageBuf {177
width: w,178
height: h,179
rgba: rgba.repeat(n),180
},181
)?182
};183
sums.insert(rel.to_string_lossy().replace('\\', "/"), digest);184
}185
}186
write_json(root, "render.json", &render)?;187
let cancel = AtomicBool::new(false);188
let atlas = atlas::write_atlases(189
&atlas::AtlasJob {190
root,191
render_doc: &render,192
plan: &plan,193
sex: &self.sex,194
set: &self.set,195
cell_width: w,196
cell_height: h,197
png_sha256: &sums,198
attempt: 0,199
cancel: &cancel,200
},201
&mut |_| {},202
)?;203
let identity = complete_identity_json(&render)?;204
write_json(205
root,206
"complete.json",207
&serde_json::json!({208
"format_version": OUTPUT_META_VERSION,209
"status": "complete",210
"identity": identity,211
"passes": plan.passes,212
"png_files": plan.files,213
"png_sha256": sums,214
"atlas": atlas.to_json(),215
}),216
)217
}218
}219
220
fn write_json(root: &Path, name: &str, value: &serde_json::Value) -> Result<(), String> {221
let bytes = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;222
production::publish_bytes(root, Path::new(name), &bytes)223
}@@ -285,17 +285,6 @@ impl ExportRun {
285
285
)));
286
286
}
287
287
}
288
// The `.ppda` is small; verify it here. Atlas PNGs are verified by
289
// `atlas::AtlasSet`, the consumer reader.
290
let ppda = resolve_inside(&root, &complete.atlas.ppda)?;
291
verify_regular_file(&ppda, &root)?;
292
let bytes = read_bounded_file(&ppda, crate::atlas::MAX_PPDA_BYTES)?;
293
if digest::sha256_hex(&bytes) != complete.atlas.ppda_sha256 {
294
return Err(ExportError::Corrupt(format!(
295
"checksum mismatch voor '{}'",
296
complete.atlas.ppda
297
)));
298
}
299
288
Ok(Self {
300
289
root,
301
290
render,
@@ -927,9 +916,19 @@ mod tests {
927
916
let _ = fs::remove_file(temp);
928
917
}
929
918
919
fn atlas_ref() -> serde_json::Value {
920
let sha = digest::sha256_hex(b"atlas");
921
let map = |m: &str| serde_json::json!({"file": format!("male-body-{m}-atlas.png"), "sha256": sha});
922
serde_json::json!({
923
"ppda": "male-body-atlas.ppda",
924
"ppda_sha256": sha,
925
"maps": {"diffuse": map("diffuse"), "normal": map("normal"), "depth": map("depth"), "specular": map("specular")}
926
})
927
}
928
930
929
fn base_render(png_files: u32) -> serde_json::Value {
931
930
serde_json::json!({
932
"format_version": 3,
931
"format_version": OUTPUT_META_VERSION,
933
932
"status": "incomplete",
934
933
"project": {"id": "demo", "name": "Demo"},
935
934
"sex": "male",
@@ -946,7 +945,8 @@ mod tests {
946
945
"camera_distance": 10.0,
947
946
"yaw_zero": "camera on -Z",
948
947
"normal_space": "view (+Y up), PNG rows top-down",
949
"pitches": [-30, 0, 30],
948
"pitches": crate::preview::PRODUCTION_PITCHES,
949
"yaws": crate::production::production_yaws(),
950
950
"yaw_step": 30,
951
951
"sampling": "test",
952
952
"alpha": "straight",
@@ -966,19 +966,19 @@ mod tests {
966
966
let mut map = BTreeMap::new();
967
967
let rels = [
968
968
(
969
"male/body/clip000-bind/f000000/p-30_y000_diffuse.png",
969
"male/body/clip000-bind/f000000/p-60_y000_diffuse.png",
970
970
"diffuse",
971
971
),
972
972
(
973
"male/body/clip000-bind/f000000/p-30_y000_depth.png",
973
"male/body/clip000-bind/f000000/p-60_y000_depth.png",
974
974
"depth",
975
975
),
976
976
(
977
"male/body/clip000-bind/f000000/p-30_y000_normal.png",
977
"male/body/clip000-bind/f000000/p-60_y000_normal.png",
978
978
"normal",
979
979
),
980
980
(
981
"male/body/clip000-bind/f000000/p-30_y000_specular.png",
981
"male/body/clip000-bind/f000000/p-60_y000_specular.png",
982
982
"specular",
983
983
),
984
984
];
@@ -1034,17 +1034,18 @@ mod tests {
1034
1034
&tmp.0,
1035
1035
"complete.json",
1036
1036
&serde_json::json!({
1037
"format_version": 3,
1037
"format_version": OUTPUT_META_VERSION,
1038
1038
"status": "complete",
1039
1039
"identity": identity,
1040
1040
"passes": 6,
1041
1041
"png_files": 4,
1042
"png_sha256": checksums
1042
"png_sha256": checksums,
1043
"atlas": atlas_ref()
1043
1044
}),
1044
1045
);
1045
1046
let run = ExportRun::open(&tmp.0).unwrap();
1046
1047
let diffuse = run
1047
.load_map("clip000-bind", 0, -30, 0, MapKind::Diffuse)
1048
.load_map("clip000-bind", 0, -60, 0, MapKind::Diffuse)
1048
1049
.unwrap();
1049
1050
match diffuse {
1050
1051
MapImage::Rgba8 {
@@ -1058,7 +1059,7 @@ mod tests {
1058
1059
_ => panic!("expected rgba"),
1059
1060
}
1060
1061
let depth = run
1061
.load_map("clip000-bind", 0, -30, 0, MapKind::Depth)
1062
.load_map("clip000-bind", 0, -60, 0, MapKind::Depth)
1062
1063
.unwrap();
1063
1064
match depth {
1064
1065
MapImage::Depth16 { samples, .. } => assert_eq!(samples, [20000, 65535]),
@@ -1082,12 +1083,13 @@ mod tests {
1082
1083
&tmp.0,
1083
1084
"complete.json",
1084
1085
&serde_json::json!({
1085
"format_version": 3,
1086
"format_version": OUTPUT_META_VERSION,
1086
1087
"status": "complete",
1087
1088
"identity": identity,
1088
1089
"passes": 6,
1089
1090
"png_files": 4,
1090
"png_sha256": checksums
1091
"png_sha256": checksums,
1092
"atlas": atlas_ref()
1091
1093
}),
1092
1094
);
1093
1095
assert!(matches!(
@@ -1108,12 +1110,13 @@ mod tests {
1108
1110
&tmp.0,
1109
1111
"complete.json",
1110
1112
&serde_json::json!({
1111
"format_version": 3,
1113
"format_version": OUTPUT_META_VERSION,
1112
1114
"status": "complete",
1113
1115
"identity": identity,
1114
1116
"passes": 6,
1115
1117
"png_files": 5,
1116
"png_sha256": checksums
1118
"png_sha256": checksums,
1119
"atlas": atlas_ref()
1117
1120
}),
1118
1121
);
1119
1122
assert!(matches!(
@@ -1126,7 +1129,7 @@ mod tests {
1126
1129
let tmp = Temp::new();
1127
1130
let outside = Temp::new();
1128
1131
let checksums = publish_four(&tmp.0);
1129
let evil = "male/body/clip000-bind/f000000/p-30_y000_diffuse.png";
1132
let evil = "male/body/clip000-bind/f000000/p-60_y000_diffuse.png";
1130
1133
let _ = fs::remove_file(tmp.0.join(evil));
1131
1134
std::os::unix::fs::symlink(outside.0.join("secret.png"), tmp.0.join(evil)).unwrap();
1132
1135
fs::write(outside.0.join("secret.png"), b"not-a-png").unwrap();
@@ -1139,12 +1142,13 @@ mod tests {
1139
1142
&tmp.0,
1140
1143
"complete.json",
1141
1144
&serde_json::json!({
1142
"format_version": 3,
1145
"format_version": OUTPUT_META_VERSION,
1143
1146
"status": "complete",
1144
1147
"identity": identity,
1145
1148
"passes": 6,
1146
1149
"png_files": 4,
1147
"png_sha256": checksums
1150
"png_sha256": checksums,
1151
"atlas": atlas_ref()
1148
1152
}),
1149
1153
);
1150
1154
assert!(matches!(
@@ -1165,7 +1169,7 @@ mod tests {
1165
1169
&tmp.0,
1166
1170
"complete.json",
1167
1171
&serde_json::json!({
1168
"format_version": 3,
1172
"format_version": OUTPUT_META_VERSION,
1169
1173
"status": "complete",
1170
1174
"identity": {
1171
1175
"project_id": "demo",
@@ -1185,7 +1189,8 @@ mod tests {
1185
1189
},
1186
1190
"passes": 6,
1187
1191
"png_files": 4,
1188
"png_sha256": checksums
1192
"png_sha256": checksums,
1193
"atlas": atlas_ref()
1189
1194
}),
1190
1195
);
1191
1196
assert!(matches!(
@@ -285,17 +285,6 @@ impl ExportRun {285
)));285
)));286
}286
}287
}287
}288
// The `.ppda` is small; verify it here. Atlas PNGs are verified by289
// `atlas::AtlasSet`, the consumer reader.290
let ppda = resolve_inside(&root, &complete.atlas.ppda)?;291
verify_regular_file(&ppda, &root)?;292
let bytes = read_bounded_file(&ppda, crate::atlas::MAX_PPDA_BYTES)?;293
if digest::sha256_hex(&bytes) != complete.atlas.ppda_sha256 {294
return Err(ExportError::Corrupt(format!(295
"checksum mismatch voor '{}'",296
complete.atlas.ppda297
)));298
}299
Ok(Self {288
Ok(Self {300
root,289
root,301
render,290
render,@@ -927,9 +916,19 @@ mod tests {927
let _ = fs::remove_file(temp);916
let _ = fs::remove_file(temp);928
}917
}929
918
919
fn atlas_ref() -> serde_json::Value {920
let sha = digest::sha256_hex(b"atlas");921
let map = |m: &str| serde_json::json!({"file": format!("male-body-{m}-atlas.png"), "sha256": sha});922
serde_json::json!({923
"ppda": "male-body-atlas.ppda",924
"ppda_sha256": sha,925
"maps": {"diffuse": map("diffuse"), "normal": map("normal"), "depth": map("depth"), "specular": map("specular")}926
})927
}928
930
fn base_render(png_files: u32) -> serde_json::Value {929
fn base_render(png_files: u32) -> serde_json::Value {931
serde_json::json!({930
serde_json::json!({932
"format_version": 3,931
"format_version": OUTPUT_META_VERSION,933
"status": "incomplete",932
"status": "incomplete",934
"project": {"id": "demo", "name": "Demo"},933
"project": {"id": "demo", "name": "Demo"},935
"sex": "male",934
"sex": "male",@@ -946,7 +945,8 @@ mod tests {946
"camera_distance": 10.0,945
"camera_distance": 10.0,947
"yaw_zero": "camera on -Z",946
"yaw_zero": "camera on -Z",948
"normal_space": "view (+Y up), PNG rows top-down",947
"normal_space": "view (+Y up), PNG rows top-down",949
"pitches": [-30, 0, 30],948
"pitches": crate::preview::PRODUCTION_PITCHES,949
"yaws": crate::production::production_yaws(),950
"yaw_step": 30,950
"yaw_step": 30,951
"sampling": "test",951
"sampling": "test",952
"alpha": "straight",952
"alpha": "straight",@@ -966,19 +966,19 @@ mod tests {966
let mut map = BTreeMap::new();966
let mut map = BTreeMap::new();967
let rels = [967
let rels = [968
(968
(969
"male/body/clip000-bind/f000000/p-30_y000_diffuse.png",969
"male/body/clip000-bind/f000000/p-60_y000_diffuse.png",970
"diffuse",970
"diffuse",971
),971
),972
(972
(973
"male/body/clip000-bind/f000000/p-30_y000_depth.png",973
"male/body/clip000-bind/f000000/p-60_y000_depth.png",974
"depth",974
"depth",975
),975
),976
(976
(977
"male/body/clip000-bind/f000000/p-30_y000_normal.png",977
"male/body/clip000-bind/f000000/p-60_y000_normal.png",978
"normal",978
"normal",979
),979
),980
(980
(981
"male/body/clip000-bind/f000000/p-30_y000_specular.png",981
"male/body/clip000-bind/f000000/p-60_y000_specular.png",982
"specular",982
"specular",983
),983
),984
];984
];@@ -1034,17 +1034,18 @@ mod tests {1034
&tmp.0,1034
&tmp.0,1035
"complete.json",1035
"complete.json",1036
&serde_json::json!({1036
&serde_json::json!({1037
"format_version": 3,1037
"format_version": OUTPUT_META_VERSION,1038
"status": "complete",1038
"status": "complete",1039
"identity": identity,1039
"identity": identity,1040
"passes": 6,1040
"passes": 6,1041
"png_files": 4,1041
"png_files": 4,1042
"png_sha256": checksums1042
"png_sha256": checksums,1043
"atlas": atlas_ref()1043
}),1044
}),1044
);1045
);1045
let run = ExportRun::open(&tmp.0).unwrap();1046
let run = ExportRun::open(&tmp.0).unwrap();1046
let diffuse = run1047
let diffuse = run1047
.load_map("clip000-bind", 0, -30, 0, MapKind::Diffuse)1048
.load_map("clip000-bind", 0, -60, 0, MapKind::Diffuse)1048
.unwrap();1049
.unwrap();1049
match diffuse {1050
match diffuse {1050
MapImage::Rgba8 {1051
MapImage::Rgba8 {@@ -1058,7 +1059,7 @@ mod tests {1058
_ => panic!("expected rgba"),1059
_ => panic!("expected rgba"),1059
}1060
}1060
let depth = run1061
let depth = run1061
.load_map("clip000-bind", 0, -30, 0, MapKind::Depth)1062
.load_map("clip000-bind", 0, -60, 0, MapKind::Depth)1062
.unwrap();1063
.unwrap();1063
match depth {1064
match depth {1064
MapImage::Depth16 { samples, .. } => assert_eq!(samples, [20000, 65535]),1065
MapImage::Depth16 { samples, .. } => assert_eq!(samples, [20000, 65535]),@@ -1082,12 +1083,13 @@ mod tests {1082
&tmp.0,1083
&tmp.0,1083
"complete.json",1084
"complete.json",1084
&serde_json::json!({1085
&serde_json::json!({1085
"format_version": 3,1086
"format_version": OUTPUT_META_VERSION,1086
"status": "complete",1087
"status": "complete",1087
"identity": identity,1088
"identity": identity,1088
"passes": 6,1089
"passes": 6,1089
"png_files": 4,1090
"png_files": 4,1090
"png_sha256": checksums1091
"png_sha256": checksums,1092
"atlas": atlas_ref()1091
}),1093
}),1092
);1094
);1093
assert!(matches!(1095
assert!(matches!(@@ -1108,12 +1110,13 @@ mod tests {1108
&tmp.0,1110
&tmp.0,1109
"complete.json",1111
"complete.json",1110
&serde_json::json!({1112
&serde_json::json!({1111
"format_version": 3,1113
"format_version": OUTPUT_META_VERSION,1112
"status": "complete",1114
"status": "complete",1113
"identity": identity,1115
"identity": identity,1114
"passes": 6,1116
"passes": 6,1115
"png_files": 5,1117
"png_files": 5,1116
"png_sha256": checksums1118
"png_sha256": checksums,1119
"atlas": atlas_ref()1117
}),1120
}),1118
);1121
);1119
assert!(matches!(1122
assert!(matches!(@@ -1126,7 +1129,7 @@ mod tests {1126
let tmp = Temp::new();1129
let tmp = Temp::new();1127
let outside = Temp::new();1130
let outside = Temp::new();1128
let checksums = publish_four(&tmp.0);1131
let checksums = publish_four(&tmp.0);1129
let evil = "male/body/clip000-bind/f000000/p-30_y000_diffuse.png";1132
let evil = "male/body/clip000-bind/f000000/p-60_y000_diffuse.png";1130
let _ = fs::remove_file(tmp.0.join(evil));1133
let _ = fs::remove_file(tmp.0.join(evil));1131
std::os::unix::fs::symlink(outside.0.join("secret.png"), tmp.0.join(evil)).unwrap();1134
std::os::unix::fs::symlink(outside.0.join("secret.png"), tmp.0.join(evil)).unwrap();1132
fs::write(outside.0.join("secret.png"), b"not-a-png").unwrap();1135
fs::write(outside.0.join("secret.png"), b"not-a-png").unwrap();@@ -1139,12 +1142,13 @@ mod tests {1139
&tmp.0,1142
&tmp.0,1140
"complete.json",1143
"complete.json",1141
&serde_json::json!({1144
&serde_json::json!({1142
"format_version": 3,1145
"format_version": OUTPUT_META_VERSION,1143
"status": "complete",1146
"status": "complete",1144
"identity": identity,1147
"identity": identity,1145
"passes": 6,1148
"passes": 6,1146
"png_files": 4,1149
"png_files": 4,1147
"png_sha256": checksums1150
"png_sha256": checksums,1151
"atlas": atlas_ref()1148
}),1152
}),1149
);1153
);1150
assert!(matches!(1154
assert!(matches!(@@ -1165,7 +1169,7 @@ mod tests {1165
&tmp.0,1169
&tmp.0,1166
"complete.json",1170
"complete.json",1167
&serde_json::json!({1171
&serde_json::json!({1168
"format_version": 3,1172
"format_version": OUTPUT_META_VERSION,1169
"status": "complete",1173
"status": "complete",1170
"identity": {1174
"identity": {1171
"project_id": "demo",1175
"project_id": "demo",@@ -1185,7 +1189,8 @@ mod tests {1185
},1189
},1186
"passes": 6,1190
"passes": 6,1187
"png_files": 4,1191
"png_files": 4,1188
"png_sha256": checksums1192
"png_sha256": checksums,1193
"atlas": atlas_ref()1189
}),1194
}),1190
);1195
);1191
assert!(matches!(1196
assert!(matches!(@@ -14,6 +14,8 @@ pub mod dialogs;
14
14
pub mod digest;
15
15
pub mod editor;
16
16
pub mod equipment;
17
#[doc(hidden)]
18
pub mod export_fixture;
17
19
pub mod export_set;
18
20
pub mod fbx_scene;
19
21
pub mod file_assoc;
@@ -14,6 +14,8 @@ pub mod dialogs;14
pub mod digest;14
pub mod digest;15
pub mod editor;15
pub mod editor;16
pub mod equipment;16
pub mod equipment;17
#[doc(hidden)]18
pub mod export_fixture;17
pub mod export_set;19
pub mod export_set;18
pub mod fbx_scene;20
pub mod fbx_scene;19
pub mod file_assoc;21
pub mod file_assoc;@@ -1349,7 +1349,8 @@ mod tests {
1349
1349
let low = eye_offset(0.0, -30.0, 4.0);
1350
1350
let high = eye_offset(0.0, 30.0, 4.0);
1351
1351
assert!(low[1] < 0.0 && high[1] > 0.0);
1352
assert_eq!(PAPERDOLL_PITCHES_DEG.len(), 3);
1352
assert_eq!(PAPERDOLL_PITCHES_DEG.len(), 5);
1353
assert_eq!(PRODUCTION_VIEWS as usize, PRODUCTION_PITCHES.len() * 12);
1353
1354
}
1354
1355
1355
1356
#[test]
@@ -1363,9 +1364,11 @@ mod tests {
1363
1364
controls.snap_yaw_index(-1);
1364
1365
assert!((controls.yaw_deg - 330.0).abs() < 0.01);
1365
1366
controls.snap_pitch_row(99);
1366
assert!((controls.pitch_deg - 30.0).abs() < 0.01);
1367
assert!((controls.pitch_deg - 60.0).abs() < 0.01);
1367
1368
controls.snap_pitch_row(-4);
1368
assert!((controls.pitch_deg + 30.0).abs() < 0.01);
1369
assert!((controls.pitch_deg + 60.0).abs() < 0.01);
1370
controls.snap_pitch_row(3);
1371
assert!((controls.pitch_deg - 30.0).abs() < 0.01);
1369
1372
controls.time = 0.4;
1370
1373
controls.select_clip("attack");
1371
1374
assert_eq!(controls.clip_name, "attack");
@@ -1349,7 +1349,8 @@ mod tests {1349
let low = eye_offset(0.0, -30.0, 4.0);1349
let low = eye_offset(0.0, -30.0, 4.0);1350
let high = eye_offset(0.0, 30.0, 4.0);1350
let high = eye_offset(0.0, 30.0, 4.0);1351
assert!(low[1] < 0.0 && high[1] > 0.0);1351
assert!(low[1] < 0.0 && high[1] > 0.0);1352
assert_eq!(PAPERDOLL_PITCHES_DEG.len(), 3);1352
assert_eq!(PAPERDOLL_PITCHES_DEG.len(), 5);1353
assert_eq!(PRODUCTION_VIEWS as usize, PRODUCTION_PITCHES.len() * 12);1353
}1354
}1354
1355
1355
#[test]1356
#[test]@@ -1363,9 +1364,11 @@ mod tests {1363
controls.snap_yaw_index(-1);1364
controls.snap_yaw_index(-1);1364
assert!((controls.yaw_deg - 330.0).abs() < 0.01);1365
assert!((controls.yaw_deg - 330.0).abs() < 0.01);1365
controls.snap_pitch_row(99);1366
controls.snap_pitch_row(99);1366
assert!((controls.pitch_deg - 30.0).abs() < 0.01);1367
assert!((controls.pitch_deg - 60.0).abs() < 0.01);1367
controls.snap_pitch_row(-4);1368
controls.snap_pitch_row(-4);1368
assert!((controls.pitch_deg + 30.0).abs() < 0.01);1369
assert!((controls.pitch_deg + 60.0).abs() < 0.01);1370
controls.snap_pitch_row(3);1371
assert!((controls.pitch_deg - 30.0).abs() < 0.01);1369
controls.time = 0.4;1372
controls.time = 0.4;1370
controls.select_clip("attack");1373
controls.select_clip("attack");1371
assert_eq!(controls.clip_name, "attack");1374
assert_eq!(controls.clip_name, "attack");@@ -1,17 +1,20 @@
1
1
//! Rendered 2D preview: playback, discrete views, continuous light, export catalog.
2
2
//!
3
//! Consumes completed schema-3 export runs through `export_set` + `compositor`.
3
//! Consumes completed exports through their `.ppda` + per-map atlases
4
//! (`atlas::AtlasSet`, the validating consumer reader; Tim t957u) and
5
//! `compositor`. Loose PNGs are not read here.
4
6
//! Saved production diffuse stays unlit albedo; lighting is preview-only (Resultaat mode).
5
7
6
8
use std::collections::BTreeMap;
7
9
use std::fs;
8
10
use std::path::{Path, PathBuf};
9
11
12
use crate::atlas::{AtlasSet, LoadedAtlas};
10
13
use crate::compositor::{
11
assert_compatible, compose, compose_and_relight, CompatKey, ComposeError, ComposedMaps,
14
assert_compatible_keys, compose, compose_and_relight, CompatKey, ComposeError, ComposedMaps,
12
15
LayerMaps, Light,
13
16
};
14
use crate::export_set::{ExportError, ExportRun};
17
use crate::export_set::ExportError;
15
18
use crate::preview::{PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT};
16
19
use crate::render_job::ImageBuf;
17
20
use crate::session::Sex;
@@ -136,9 +139,10 @@ pub fn composed_map_image(composed: &ComposedMaps, mode: ViewMode) -> ImageBuf {
136
139
}
137
140
}
138
141
139
/// One completed export run discovered under a project `renders/` tree.
142
/// One export (`.ppda` + atlases) discovered under a project `renders/` tree.
140
143
#[derive(Debug, Clone)]
141
144
pub struct CatalogEntry {
145
/// Run directory containing the `.ppda`.
142
146
pub path: PathBuf,
143
147
pub sex: String,
144
148
pub set: String,
@@ -152,27 +156,23 @@ pub struct CatalogEntry {
152
156
}
153
157
154
158
impl CatalogEntry {
155
pub fn from_run(path: PathBuf, run: &ExportRun) -> Self {
159
pub fn from_atlas(path: PathBuf, set: &AtlasSet) -> Self {
160
let p = &set.ppda;
156
161
Self {
157
162
path,
158
sex: run.render.sex.clone(),
159
set: run.render.set.clone(),
160
project_id: run.render.project.id.clone(),
161
fps: run.render.fps,
162
target: run.render.target,
163
clips: run
164
.render
163
sex: p.sex.clone(),
164
set: p.settype.clone(),
165
project_id: p.project.id.clone(),
166
fps: p.fps,
167
target: [p.cell_width, p.cell_height],
168
clips: p
165
169
.clips
166
170
.iter()
167
.map(|c| (c.token.clone(), c.name.clone(), c.frames, c.duration))
171
.map(|c| (c.token.clone(), c.name.clone(), c.frame_count, c.duration))
168
172
.collect(),
169
body_model_sha256: run.render.body_model.sha256.clone(),
170
equipment_preset_sha256: run
171
.render
172
.equipment_preset
173
.as_ref()
174
.map(|p| p.sha256.clone()),
175
key: CompatKey::from_run(run),
173
body_model_sha256: p.identity.body_model_sha256.clone(),
174
equipment_preset_sha256: p.identity.equipment_preset_sha256.clone(),
175
key: CompatKey::from_ppda(p),
176
176
}
177
177
}
178
178
@@ -181,28 +181,37 @@ impl CatalogEntry {
181
181
}
182
182
}
183
183
184
/// Scan `project_root/renders/*/complete.json` and open only valid completed runs.
184
/// Run directories under `renders/` that were skipped, with the reason.
185
pub type Skipped = Vec<(PathBuf, String)>;
186
187
/// Scan `project_root/renders/*/` and open every export whose `.ppda` and
188
/// atlases validate. Runs without a valid `.ppda` (incomplete, older schema,
189
/// damaged) are skipped.
185
190
pub fn discover_completed_runs(project_root: &Path) -> Vec<CatalogEntry> {
191
discover_exports(project_root).0
192
}
193
194
/// Like [`discover_completed_runs`], also returning skipped runs + reasons.
195
pub fn discover_exports(project_root: &Path) -> (Vec<CatalogEntry>, Skipped) {
186
196
let renders = project_root.join("renders");
187
197
let Ok(entries) = fs::read_dir(&renders) else {
188
return Vec::new();
198
return (Vec::new(), Vec::new());
189
199
};
190
200
let mut out = Vec::new();
201
let mut skipped = Vec::new();
191
202
for entry in entries.flatten() {
192
203
let path = entry.path();
193
204
if !path.is_dir() {
194
205
continue;
195
206
}
196
if !path.join("complete.json").is_file() {
197
continue;
198
}
199
match ExportRun::open(&path) {
200
Ok(run) => out.push(CatalogEntry::from_run(path, &run)),
201
Err(_) => continue,
207
match AtlasSet::open_run(&path) {
208
Ok(set) => out.push(CatalogEntry::from_atlas(path, &set)),
209
Err(err) => skipped.push((path, err.to_string())),
202
210
}
203
211
}
204
212
out.sort_by(|a, b| (&a.sex, &a.set, &a.path).cmp(&(&b.sex, &b.set, &b.path)));
205
out
213
skipped.sort();
214
(out, skipped)
206
215
}
207
216
208
217
/// Discrete yaw index 0..11 → degrees 0,30,…,330.
@@ -455,8 +464,8 @@ impl Preview2dState {
455
464
456
465
#[derive(Debug, Clone)]
457
466
pub struct ComposeRequest<'a> {
458
pub body: &'a ExportRun,
459
pub equipment: &'a [ExportRun],
467
pub body: &'a LoadedAtlas,
468
pub equipment: &'a [LoadedAtlas],
460
469
pub clip_token: &'a str,
461
470
pub frame: u32,
462
471
pub pitch: i32,
@@ -517,38 +526,18 @@ impl From<ExportError> for Preview2dError {
517
526
518
527
/// Compose one view for the selected [`ViewMode`]. Missing maps become an explicit error.
519
528
pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result<ImageBuf, Preview2dError> {
520
let mut runs: Vec<&ExportRun> = Vec::with_capacity(1 + req.equipment.len());
521
runs.push(req.body);
522
for run in req.equipment {
523
runs.push(run);
524
}
525
assert_compatible(&runs)?;
526
527
let mut layers = Vec::with_capacity(runs.len());
528
let body_set = req.body.render.set.as_str();
529
match LayerMaps::from_export_run(
530
req.body,
531
body_set,
532
req.clip_token,
533
req.frame,
534
req.pitch,
535
req.yaw,
536
) {
537
Ok(layer) => layers.push(layer),
538
Err(err) => {
539
return Err(Preview2dError::Missing {
540
set: body_set.to_string(),
541
clip: req.clip_token.to_string(),
542
frame: req.frame,
543
pitch: req.pitch,
544
yaw: req.yaw,
545
detail: err.to_string(),
546
});
547
}
548
}
549
for run in req.equipment {
550
let set = run.render.set.as_str();
551
match LayerMaps::from_export_run(run, set, req.clip_token, req.frame, req.pitch, req.yaw) {
529
let mut atlases: Vec<&LoadedAtlas> = Vec::with_capacity(1 + req.equipment.len());
530
atlases.push(req.body);
531
atlases.extend(req.equipment.iter());
532
let keys: Vec<CompatKey> = atlases
533
.iter()
534
.map(|a| CompatKey::from_ppda(a.ppda()))
535
.collect();
536
assert_compatible_keys(&keys)?;
537
let mut layers = Vec::with_capacity(atlases.len());
538
for atlas in atlases {
539
let set = atlas.ppda().settype.as_str();
540
match LayerMaps::from_atlas(atlas, set, req.clip_token, req.frame, req.pitch, req.yaw) {
552
541
Ok(layer) => layers.push(layer),
553
542
Err(err) => {
554
543
return Err(Preview2dError::Missing {
@@ -571,23 +560,23 @@ pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result<ImageBuf, Previe
571
560
}
572
561
}
573
562
574
/// Open body + selected equipment runs for one sex, validating compatibility.
575
pub fn open_selection(
563
/// Run directories (body first, then equipment in the given order) that
564
/// [`open_selection`] would load; used as a cache key by the editor.
565
pub fn selection_paths(
576
566
catalog: &[CatalogEntry],
577
567
sex: Sex,
578
568
equipment_sets: &[String],
579
) -> Result<(ExportRun, Vec<ExportRun>), Preview2dError> {
569
) -> Result<Vec<PathBuf>, Preview2dError> {
580
570
let sex_token = match sex {
581
571
Sex::Male => "male",
582
572
Sex::Female => "female",
583
573
};
584
let body_entry = catalog
574
let body = catalog
585
575
.iter()
586
576
.filter(|e| e.sex == sex_token && e.is_body())
587
577
.max_by_key(|e| e.path.clone())
588
578
.ok_or(Preview2dError::NoBody)?;
589
let body = ExportRun::open(&body_entry.path)?;
590
let mut equipment = Vec::new();
579
let mut paths = vec![body.path.clone()];
591
580
for set in equipment_sets {
592
581
let entry = catalog
593
582
.iter()
@@ -601,12 +590,30 @@ pub fn open_selection(
601
590
yaw: 0,
602
591
detail: "geen voltooide exportset voor dit equipment".into(),
603
592
})?;
604
equipment.push(ExportRun::open(&entry.path)?);
593
paths.push(entry.path.clone());
605
594
}
606
let mut refs: Vec<&ExportRun> = vec![&body];
607
refs.extend(equipment.iter());
608
assert_compatible(&refs)?;
609
Ok((body, equipment))
595
Ok(paths)
596
}
597
598
/// Open and decode the `.ppda` atlases of body + selected equipment for one
599
/// sex, re-validating checksums and compatibility.
600
pub fn open_selection(
601
catalog: &[CatalogEntry],
602
sex: Sex,
603
equipment_sets: &[String],
604
) -> Result<(LoadedAtlas, Vec<LoadedAtlas>), Preview2dError> {
605
let paths = selection_paths(catalog, sex, equipment_sets)?;
606
let mut loaded = Vec::with_capacity(paths.len());
607
for path in &paths {
608
loaded.push(AtlasSet::open_run(path)?.load()?);
609
}
610
let keys: Vec<CompatKey> = loaded
611
.iter()
612
.map(|a| CompatKey::from_ppda(a.ppda()))
613
.collect();
614
assert_compatible_keys(&keys)?;
615
let body = loaded.remove(0);
616
Ok((body, loaded))
610
617
}
611
618
612
619
/// Equipment sets for `sex` that share the body CompatKey (same project/rig/contract).
@@ -679,10 +686,8 @@ pub fn sync_state_from_catalog(state: &mut Preview2dState, catalog: &[CatalogEnt
679
686
mod tests {
680
687
use super::*;
681
688
use crate::digest;
682
use crate::export_set::complete_identity_json;
683
use crate::production::{write_depth, write_rgba, FAR, NEAR, PASSES};
689
use crate::export_fixture::{Pixel, SyntheticRun};
684
690
use crate::project::CameraFraming;
685
use std::collections::BTreeMap as Map;
686
691
use std::sync::atomic::{AtomicU64, Ordering};
687
692
688
693
static COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -718,164 +723,39 @@ mod tests {
718
723
}
719
724
}
720
725
721
fn write_atomic_json(root: &Path, name: &str, value: &serde_json::Value) {
722
let data = serde_json::to_vec_pretty(value).unwrap();
723
let path = root.join(name);
724
let temp = path.with_extension(format!("part-{}", std::process::id()));
725
fs::write(&temp, &data).unwrap();
726
fs::hard_link(&temp, &path).unwrap();
727
let _ = fs::remove_file(temp);
728
}
729
730
#[allow(clippy::too_many_arguments)]
731
fn publish_pose(
732
root: &Path,
733
sex: &str,
734
set: &str,
735
clip: &str,
736
frame: u32,
737
pitch: i32,
738
yaw: u32,
739
diffuse: [u8; 4],
740
normal: [u8; 4],
741
specular: [u8; 4],
742
depth_gray: u16,
743
) -> Map<String, String> {
744
let mut map = Map::new();
745
let base = format!("{sex}/{set}/{clip}/f{frame:06}/p{pitch:+03}_y{yaw:03}");
746
let rels = [
747
(format!("{base}_diffuse.png"), "diffuse"),
748
(format!("{base}_depth.png"), "depth"),
749
(format!("{base}_normal.png"), "normal"),
750
(format!("{base}_specular.png"), "specular"),
751
];
752
for (rel, kind) in rels {
753
let path = Path::new(&rel);
754
match kind {
755
"depth" => {
756
write_depth(root, path, 1, 1, &[depth_gray, 65535]).unwrap();
757
}
758
"normal" => {
759
write_rgba(
760
root,
761
path,
762
&ImageBuf {
763
width: 1,
764
height: 1,
765
rgba: normal.to_vec(),
766
},
767
)
768
.unwrap();
769
}
770
"specular" => {
771
write_rgba(
772
root,
773
path,
774
&ImageBuf {
775
width: 1,
776
height: 1,
777
rgba: specular.to_vec(),
778
},
779
)
780
.unwrap();
781
}
782
_ => {
783
write_rgba(
784
root,
785
path,
786
&ImageBuf {
787
width: 1,
788
height: 1,
789
rgba: diffuse.to_vec(),
790
},
791
)
792
.unwrap();
793
}
794
}
795
let bytes = fs::read(root.join(&rel)).unwrap();
796
map.insert(rel, digest::sha256_hex(&bytes));
726
fn pixel(diffuse: [u8; 4], normal: [u8; 4], specular: [u8; 4], depth: u16) -> Pixel {
727
Pixel {
728
diffuse,
729
normal,
730
specular,
731
depth,
797
732
}
798
map
799
733
}
800
734
735
/// Complete synthetic run (all 60 views) with uniform maps per run.
801
736
#[allow(clippy::too_many_arguments)]
802
737
fn write_run(
803
738
root: &Path,
804
739
project_id: &str,
805
sex: &str,
806
740
set: &str,
807
741
equipment_sha: Option<&str>,
808
742
body_bytes: &[u8],
809
clips: &[(&str, &str, u32, f32)],
810
checksums: Map<String, String>,
743
clips: &[(&str, u32, f32)],
744
px: Pixel,
811
745
) {
812
let body_hash = digest::sha256_hex(body_bytes);
813
let clips_json: Vec<_> = clips
746
let mut spec = SyntheticRun::body(project_id, "male", body_bytes);
747
spec.framing = framing();
748
spec.set = set.into();
749
spec.equipment_sha256 = equipment_sha.map(str::to_string);
750
spec.clips = clips
814
751
.iter()
815
.map(|(name, token, frames, duration)| {
816
serde_json::json!({
817
"name": name,
818
"token": token,
819
"frames": frames,
820
"duration": duration
821
})
822
})
752
.map(|(n, f, d)| (n.to_string(), *f, *d))
823
753
.collect();
824
let images: u32 = clips.iter().map(|(_, _, f, _)| *f).sum::<u32>() * 36;
825
let png_files = checksums.len() as u32;
826
let mut render = serde_json::json!({
827
"format_version": 3,
828
"status": "incomplete",
829
"project": {"id": project_id, "name": "Demo"},
830
"sex": sex,
831
"set": set,
832
"body_model": {"path": "assets/body/x.glb", "sha256": body_hash},
833
"equipment_preset": null,
834
"target": [1, 1],
835
"fps": 12,
836
"highres_scale": 8,
837
"framing": framing(),
838
"pivot_source": {"kind": "manual"},
839
"near": NEAR,
840
"far": FAR,
841
"camera_distance": 10.0,
842
"yaw_zero": "camera on -Z",
843
"normal_space": "view (+Y up), PNG rows top-down",
844
"pitches": [-30, 0, 30],
845
"yaw_step": 30,
846
"sampling": "test",
847
"alpha": "straight",
848
"diffuse": "unlit",
849
"depth": "16-bit",
850
"specular": "scalar",
851
"pass_order": PASSES,
852
"saved_maps": ["diffuse", "depth", "normal", "specular"],
853
"clips": clips_json,
854
"images": images,
855
"passes": images * 6,
856
"png_files": png_files
857
});
858
if let Some(sha) = equipment_sha {
859
render["equipment_preset"] = serde_json::json!({"name": set, "sha256": sha});
860
}
861
write_atomic_json(root, "render.json", &render);
862
let identity = complete_identity_json(&render).unwrap();
863
write_atomic_json(
864
root,
865
"complete.json",
866
&serde_json::json!({
867
"format_version": 3,
868
"status": "complete",
869
"identity": identity,
870
"passes": images * 6,
871
"png_files": png_files,
872
"png_sha256": checksums
873
}),
874
);
754
spec.write(root, |_, _, _, _| px).unwrap();
875
755
}
876
756
877
757
#[test]
878
fn yaw_wraps_twelve_steps_pitch_clamps_three_rows() {
758
fn yaw_wraps_twelve_steps_pitch_clamps_five_rows() {
879
759
let mut state = Preview2dState {
880
760
elapsed_secs: 1.25,
881
761
..Preview2dState::default()
@@ -892,17 +772,19 @@ mod tests {
892
772
assert_eq!(state.yaw_deg(), 0);
893
773
assert_eq!(PAPERDOLL_YAW_COUNT, 12);
894
774
895
state.pitch_row = 1;
775
state.pitch_row = ZERO_PITCH_ROW;
896
776
state.step_pitch(1);
897
777
assert_eq!(state.pitch_deg(), 30);
898
778
state.step_pitch(1);
899
assert_eq!(state.pitch_deg(), 30);
900
state.step_pitch(-1);
901
assert_eq!(state.pitch_deg(), 0);
902
state.step_pitch(-1);
779
assert_eq!(state.pitch_deg(), 60);
780
state.step_pitch(1);
781
assert_eq!(state.pitch_deg(), 60);
782
state.step_pitch(-3);
903
783
assert_eq!(state.pitch_deg(), -30);
904
784
state.step_pitch(-1);
905
assert_eq!(state.pitch_deg(), -30);
785
assert_eq!(state.pitch_deg(), -60);
786
state.step_pitch(-1);
787
assert_eq!(state.pitch_deg(), -60);
906
788
assert_eq!(state.elapsed_secs, 1.25);
907
789
}
908
790
@@ -978,64 +860,43 @@ mod tests {
978
860
979
861
#[test]
980
862
fn missing_frame_is_diagnostic_not_silent_substitute() {
981
let body_tmp = Temp::new();
982
let clips = [("walk", "clip000-walk", 2u32, 0.1667f32)];
983
let mut checksums = Map::new();
984
// Only frame 0 / pitch 0 / yaw 0 published — frame 1 missing.
985
checksums.extend(publish_pose(
986
&body_tmp.0,
987
"male",
988
"body",
989
"clip000-walk",
990
0,
991
0,
992
0,
993
[10, 20, 30, 255],
994
[128, 128, 255, 255],
995
[0, 0, 0, 255],
996
20000,
997
));
863
let project = Temp::new();
864
let dir = project.0.join("renders/run-body");
998
865
write_run(
999
&body_tmp.0,
866
&dir,
1000
867
"demo",
1001
"male",
1002
868
"body",
1003
869
None,
1004
870
b"shared-body",
1005
&clips,
1006
checksums,
871
&[("walk", 2, 0.5)],
872
pixel(
873
[10, 20, 30, 255],
874
[128, 128, 255, 255],
875
[0, 0, 0, 255],
876
20000,
877
),
1007
878
);
1008
let run = ExportRun::open(&body_tmp.0).unwrap();
1009
let ok = compose_preview_frame(ComposeRequest {
1010
body: &run,
1011
equipment: &[],
1012
clip_token: "clip000-walk",
1013
frame: 0,
1014
pitch: 0,
1015
yaw: 0,
1016
light: Light::default(),
1017
view_mode: ViewMode::Resultaat,
1018
})
1019
.unwrap();
1020
assert_eq!(ok.width, 1);
1021
let err = compose_preview_frame(ComposeRequest {
1022
body: &run,
879
let catalog = discover_completed_runs(&project.0);
880
let (body, _) = open_selection(&catalog, Sex::Male, &[]).unwrap();
881
let req = ComposeRequest {
882
body: &body,
1023
883
equipment: &[],
1024
884
clip_token: "clip000-walk",
1025
885
frame: 1,
1026
pitch: 0,
1027
yaw: 0,
886
pitch: 60,
887
yaw: 330,
1028
888
light: Light::default(),
1029
889
view_mode: ViewMode::Resultaat,
1030
})
1031
.unwrap_err();
890
};
891
assert_eq!(compose_preview_frame(req.clone()).unwrap().width, 1);
892
let err = compose_preview_frame(ComposeRequest { frame: 2, ..req }).unwrap_err();
1032
893
match err {
1033
894
Preview2dError::Missing {
1034
895
set, clip, frame, ..
1035
896
} => {
1036
897
assert_eq!(set, "body");
1037
898
assert_eq!(clip, "clip000-walk");
1038
assert_eq!(frame, 1);
899
assert_eq!(frame, 2);
1039
900
}
1040
901
other => panic!("expected Missing, got {other}"),
1041
902
}
@@ -1045,98 +906,58 @@ mod tests {
1045
906
fn catalog_compatibility_filters_equipment_by_export_contract() {
1046
907
let project = Temp::new();
1047
908
let renders = project.0.join("renders");
1048
fs::create_dir_all(&renders).unwrap();
1049
let body_dir = renders.join("run-body");
1050
let good_dir = renders.join("run-sword");
1051
let bad_dir = renders.join("run-bad");
1052
fs::create_dir_all(&body_dir).unwrap();
1053
fs::create_dir_all(&good_dir).unwrap();
1054
fs::create_dir_all(&bad_dir).unwrap();
1055
let clips = [("bind", "clip000-bind", 1u32, 0.0f32)];
909
let clips = [("bind", 1u32, 0.0f32)];
1056
910
let body_bytes = b"shared-body";
1057
let mut body_maps = Map::new();
1058
body_maps.extend(publish_pose(
1059
&body_dir,
1060
"male",
1061
"body",
1062
"clip000-bind",
1063
0,
1064
0,
1065
0,
1066
[1, 2, 3, 255],
1067
[128, 128, 255, 255],
1068
[0, 0, 0, 255],
1069
1000,
1070
));
1071
911
write_run(
1072
&body_dir, "proj-a", "male", "body", None, body_bytes, &clips, body_maps,
912
&renders.join("run-body"),
913
"proj-a",
914
"body",
915
None,
916
body_bytes,
917
&clips,
918
pixel([1, 2, 3, 255], [128, 128, 255, 255], [0, 0, 0, 255], 1000),
1073
919
);
1074
let mut good_maps = Map::new();
1075
good_maps.extend(publish_pose(
1076
&good_dir,
1077
"male",
1078
"sword",
1079
"clip000-bind",
1080
0,
1081
0,
1082
0,
1083
[200, 10, 10, 255],
1084
[255, 128, 128, 255],
1085
[180, 180, 180, 255],
1086
500,
1087
));
1088
920
write_run(
1089
&good_dir,
921
&renders.join("run-sword"),
1090
922
"proj-a",
1091
"male",
1092
923
"sword",
1093
924
Some(&digest::sha256_hex(b"sword")),
1094
925
body_bytes,
1095
926
&clips,
1096
good_maps,
927
pixel(
928
[200, 10, 10, 255],
929
[255, 128, 128, 255],
930
[180, 180, 180, 255],
931
500,
932
),
1097
933
);
1098
let mut bad_maps = Map::new();
1099
bad_maps.extend(publish_pose(
1100
&bad_dir,
1101
"male",
1102
"helm",
1103
"clip000-bind",
1104
0,
1105
0,
1106
0,
1107
[9, 9, 9, 255],
1108
[128, 128, 255, 255],
1109
[0, 0, 0, 255],
1110
800,
1111
));
1112
934
// Different body fingerprint → incompatible.
1113
935
write_run(
1114
&bad_dir,
936
&renders.join("run-bad"),
1115
937
"proj-a",
1116
"male",
1117
938
"helm",
1118
939
Some(&digest::sha256_hex(b"helm")),
1119
940
b"other-body",
1120
941
&clips,
1121
bad_maps,
942
pixel([9, 9, 9, 255], [128, 128, 255, 255], [0, 0, 0, 255], 800),
1122
943
);
1123
944
1124
945
let catalog = discover_completed_runs(&project.0);
1125
946
assert_eq!(catalog.len(), 3);
1126
947
let allowed = compatible_equipment_sets(&catalog, Sex::Male);
1127
948
assert_eq!(allowed, vec!["sword".to_string()]);
1128
assert!(!allowed.iter().any(|s| s == "helm"));
949
assert!(open_selection(&catalog, Sex::Male, &["helm".to_string()]).is_err());
1129
950
1130
951
let (body, gear) = open_selection(&catalog, Sex::Male, &["sword".to_string()]).unwrap();
1131
assert_eq!(body.render.set, "body");
952
assert_eq!(body.ppda().settype, "body");
1132
953
assert_eq!(gear.len(), 1);
1133
954
let lit = compose_preview_frame(ComposeRequest {
1134
955
body: &body,
1135
956
equipment: &gear,
1136
957
clip_token: "clip000-bind",
1137
958
frame: 0,
1138
pitch: 0,
1139
yaw: 0,
959
pitch: -60,
960
yaw: 90,
1140
961
light: Light::default(),
1141
962
view_mode: ViewMode::Resultaat,
1142
963
})
@@ -1215,34 +1036,24 @@ mod tests {
1215
1036
1216
1037
#[test]
1217
1038
fn view_modes_show_composited_maps_unlit_except_resultaat() {
1218
let body_tmp = Temp::new();
1219
let clips = [("bind", "clip000-bind", 1u32, 0.0f32)];
1220
let mut checksums = Map::new();
1221
// Distinct channels so modes are distinguishable.
1222
checksums.extend(publish_pose(
1223
&body_tmp.0,
1224
"male",
1225
"body",
1226
"clip000-bind",
1227
0,
1228
0,
1229
0,
1230
[10, 20, 30, 255],
1231
[40, 50, 60, 255],
1232
[70, 70, 70, 255], // scalar specular (equal RGB)
1233
32768, // mid gray depth
1234
));
1039
let project = Temp::new();
1040
// Distinct channels so modes are distinguishable; scalar specular.
1235
1041
write_run(
1236
&body_tmp.0,
1042
&project.0.join("renders/run-body"),
1237
1043
"demo",
1238
"male",
1239
1044
"body",
1240
1045
None,
1241
1046
b"shared-body",
1242
&clips,
1243
checksums,
1047
&[("bind", 1, 0.0)],
1048
pixel(
1049
[10, 20, 30, 255],
1050
[40, 50, 60, 255],
1051
[70, 70, 70, 255],
1052
32768,
1053
),
1244
1054
);
1245
let run = ExportRun::open(&body_tmp.0).unwrap();
1055
let catalog = discover_completed_runs(&project.0);
1056
let (run, _) = open_selection(&catalog, Sex::Male, &[]).unwrap();
1246
1057
let base = ComposeRequest {
1247
1058
body: &run,
1248
1059
equipment: &[],
@@ -1287,7 +1098,7 @@ mod tests {
1287
1098
1288
1099
let result = compose_preview_frame(ComposeRequest {
1289
1100
view_mode: ViewMode::Resultaat,
1290
..base
1101
..base.clone()
1291
1102
})
1292
1103
.unwrap();
1293
1104
// Relit differs from raw diffuse under default light.
@@ -1302,14 +1113,9 @@ mod tests {
1302
1113
..Light::default()
1303
1114
};
1304
1115
let diffuse_dark = compose_preview_frame(ComposeRequest {
1305
body: &run,
1306
equipment: &[],
1307
clip_token: "clip000-bind",
1308
frame: 0,
1309
pitch: 0,
1310
yaw: 0,
1311
1116
light: dark,
1312
1117
view_mode: ViewMode::DiffuseOnly,
1118
..base
1313
1119
})
1314
1120
.unwrap();
1315
1121
assert_eq!(diffuse_dark.rgba, diffuse.rgba);
@@ -1322,8 +1128,76 @@ mod tests {
1322
1128
let incomplete = renders.join("run-incomplete");
1323
1129
fs::create_dir_all(&incomplete).unwrap();
1324
1130
fs::write(incomplete.join("render.json"), b"{}").unwrap();
1325
// no complete.json
1326
let catalog = discover_completed_runs(&project.0);
1131
// no .ppda at all
1132
let (catalog, skipped) = discover_exports(&project.0);
1327
1133
assert!(catalog.is_empty());
1134
assert_eq!(skipped.len(), 1);
1135
assert!(skipped[0].1.contains(".ppda"), "{}", skipped[0].1);
1136
}
1137
1138
#[test]
1139
fn preview_reads_atlas_not_loose_pngs() {
1140
// Each view gets a distinct colour; the preview must return exactly the
1141
// atlas cell even after every loose PNG is deleted.
1142
let project = Temp::new();
1143
let dir = project.0.join("renders/run-body");
1144
let mut spec = SyntheticRun::body("demo", "male", b"b");
1145
spec.clips = vec![("walk".into(), 2, 0.5)];
1146
spec.target = [2, 3];
1147
spec.write(&dir, |_, f, p, y| {
1148
pixel(
1149
[(y / 30) as u8 * 20, (p + 60) as u8, f as u8 * 100 + 1, 255],
1150
[128, 128, 255, 255],
1151
[0, 0, 0, 255],
1152
1000,
1153
)
1154
})
1155
.unwrap();
1156
fs::remove_dir_all(dir.join("male")).unwrap();
1157
let catalog = discover_completed_runs(&project.0);
1158
assert_eq!(catalog.len(), 1);
1159
assert_eq!(catalog[0].fps, crate::project::DEFAULT_FPS);
1160
let (body, _) = open_selection(&catalog, Sex::Male, &[]).unwrap();
1161
for (frame, pitch, yaw) in [(0u32, -60i32, 0u32), (1, 60, 330), (1, -30, 150)] {
1162
let img = compose_preview_frame(ComposeRequest {
1163
body: &body,
1164
equipment: &[],
1165
clip_token: "clip000-walk",
1166
frame,
1167
pitch,
1168
yaw,
1169
light: Light::default(),
1170
view_mode: ViewMode::DiffuseOnly,
1171
})
1172
.unwrap();
1173
assert_eq!((img.width, img.height), (2, 3));
1174
let want = [
1175
(yaw / 30) as u8 * 20,
1176
(pitch + 60) as u8,
1177
frame as u8 * 100 + 1,
1178
255,
1179
];
1180
for px in img.rgba.chunks_exact(4) {
1181
assert_eq!(px, want);
1182
}
1183
}
1184
}
1185
1186
#[test]
1187
fn five_pitch_rows_clamp_and_default_is_zero() {
1188
let mut state = Preview2dState::default();
1189
assert_eq!(state.pitch_deg(), 0);
1190
state.step_pitch(1);
1191
state.step_pitch(1);
1192
assert_eq!(state.pitch_deg(), 60);
1193
state.step_pitch(1);
1194
assert_eq!(state.pitch_deg(), 60);
1195
for _ in 0..10 {
1196
state.step_pitch(-1);
1197
}
1198
assert_eq!(state.pitch_deg(), -60);
1199
assert_eq!(pitch_row_from_deg(30), 3);
1200
assert_eq!(pitch_row_from_deg(7), ZERO_PITCH_ROW);
1201
assert_eq!(state.fps, 4);
1328
1202
}
1329
1203
}
@@ -1,17 +1,20 @@1
//! Rendered 2D preview: playback, discrete views, continuous light, export catalog.1
//! Rendered 2D preview: playback, discrete views, continuous light, export catalog.2
//!2
//!3
//! Consumes completed schema-3 export runs through `export_set` + `compositor`.3
//! Consumes completed exports through their `.ppda` + per-map atlases4
//! (`atlas::AtlasSet`, the validating consumer reader; Tim t957u) and5
//! `compositor`. Loose PNGs are not read here.4
//! Saved production diffuse stays unlit albedo; lighting is preview-only (Resultaat mode).6
//! Saved production diffuse stays unlit albedo; lighting is preview-only (Resultaat mode).5
7
6
use std::collections::BTreeMap;8
use std::collections::BTreeMap;7
use std::fs;9
use std::fs;8
use std::path::{Path, PathBuf};10
use std::path::{Path, PathBuf};9
11
12
use crate::atlas::{AtlasSet, LoadedAtlas};10
use crate::compositor::{13
use crate::compositor::{11
assert_compatible, compose, compose_and_relight, CompatKey, ComposeError, ComposedMaps,14
assert_compatible_keys, compose, compose_and_relight, CompatKey, ComposeError, ComposedMaps,12
LayerMaps, Light,15
LayerMaps, Light,13
};16
};14
use crate::export_set::{ExportError, ExportRun};17
use crate::export_set::ExportError;15
use crate::preview::{PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT};18
use crate::preview::{PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT};16
use crate::render_job::ImageBuf;19
use crate::render_job::ImageBuf;17
use crate::session::Sex;20
use crate::session::Sex;@@ -136,9 +139,10 @@ pub fn composed_map_image(composed: &ComposedMaps, mode: ViewMode) -> ImageBuf {136
}139
}137
}140
}138
141
139
/// One completed export run discovered under a project `renders/` tree.142
/// One export (`.ppda` + atlases) discovered under a project `renders/` tree.140
#[derive(Debug, Clone)]143
#[derive(Debug, Clone)]141
pub struct CatalogEntry {144
pub struct CatalogEntry {145
/// Run directory containing the `.ppda`.142
pub path: PathBuf,146
pub path: PathBuf,143
pub sex: String,147
pub sex: String,144
pub set: String,148
pub set: String,@@ -152,27 +156,23 @@ pub struct CatalogEntry {152
}156
}153
157
154
impl CatalogEntry {158
impl CatalogEntry {155
pub fn from_run(path: PathBuf, run: &ExportRun) -> Self {159
pub fn from_atlas(path: PathBuf, set: &AtlasSet) -> Self {160
let p = &set.ppda;156
Self {161
Self {157
path,162
path,158
sex: run.render.sex.clone(),163
sex: p.sex.clone(),159
set: run.render.set.clone(),164
set: p.settype.clone(),160
project_id: run.render.project.id.clone(),165
project_id: p.project.id.clone(),161
fps: run.render.fps,166
fps: p.fps,162
target: run.render.target,167
target: [p.cell_width, p.cell_height],163
clips: run168
clips: p164
.render165
.clips169
.clips166
.iter()170
.iter()167
.map(|c| (c.token.clone(), c.name.clone(), c.frames, c.duration))171
.map(|c| (c.token.clone(), c.name.clone(), c.frame_count, c.duration))168
.collect(),172
.collect(),169
body_model_sha256: run.render.body_model.sha256.clone(),173
body_model_sha256: p.identity.body_model_sha256.clone(),170
equipment_preset_sha256: run174
equipment_preset_sha256: p.identity.equipment_preset_sha256.clone(),171
.render175
key: CompatKey::from_ppda(p),172
.equipment_preset173
.as_ref()174
.map(|p| p.sha256.clone()),175
key: CompatKey::from_run(run),176
}176
}177
}177
}178
178
@@ -181,28 +181,37 @@ impl CatalogEntry {181
}181
}182
}182
}183
183
184
/// Scan `project_root/renders/*/complete.json` and open only valid completed runs.184
/// Run directories under `renders/` that were skipped, with the reason.185
pub type Skipped = Vec<(PathBuf, String)>;186
187
/// Scan `project_root/renders/*/` and open every export whose `.ppda` and188
/// atlases validate. Runs without a valid `.ppda` (incomplete, older schema,189
/// damaged) are skipped.185
pub fn discover_completed_runs(project_root: &Path) -> Vec<CatalogEntry> {190
pub fn discover_completed_runs(project_root: &Path) -> Vec<CatalogEntry> {191
discover_exports(project_root).0192
}193
194
/// Like [`discover_completed_runs`], also returning skipped runs + reasons.195
pub fn discover_exports(project_root: &Path) -> (Vec<CatalogEntry>, Skipped) {186
let renders = project_root.join("renders");196
let renders = project_root.join("renders");187
let Ok(entries) = fs::read_dir(&renders) else {197
let Ok(entries) = fs::read_dir(&renders) else {188
return Vec::new();198
return (Vec::new(), Vec::new());189
};199
};190
let mut out = Vec::new();200
let mut out = Vec::new();201
let mut skipped = Vec::new();191
for entry in entries.flatten() {202
for entry in entries.flatten() {192
let path = entry.path();203
let path = entry.path();193
if !path.is_dir() {204
if !path.is_dir() {194
continue;205
continue;195
}206
}196
if !path.join("complete.json").is_file() {207
match AtlasSet::open_run(&path) {197
continue;208
Ok(set) => out.push(CatalogEntry::from_atlas(path, &set)),198
}209
Err(err) => skipped.push((path, err.to_string())),199
match ExportRun::open(&path) {200
Ok(run) => out.push(CatalogEntry::from_run(path, &run)),201
Err(_) => continue,202
}210
}203
}211
}204
out.sort_by(|a, b| (&a.sex, &a.set, &a.path).cmp(&(&b.sex, &b.set, &b.path)));212
out.sort_by(|a, b| (&a.sex, &a.set, &a.path).cmp(&(&b.sex, &b.set, &b.path)));205
out213
skipped.sort();214
(out, skipped)206
}215
}207
216
208
/// Discrete yaw index 0..11 → degrees 0,30,…,330.217
/// Discrete yaw index 0..11 → degrees 0,30,…,330.@@ -455,8 +464,8 @@ impl Preview2dState {455
464
456
#[derive(Debug, Clone)]465
#[derive(Debug, Clone)]457
pub struct ComposeRequest<'a> {466
pub struct ComposeRequest<'a> {458
pub body: &'a ExportRun,467
pub body: &'a LoadedAtlas,459
pub equipment: &'a [ExportRun],468
pub equipment: &'a [LoadedAtlas],460
pub clip_token: &'a str,469
pub clip_token: &'a str,461
pub frame: u32,470
pub frame: u32,462
pub pitch: i32,471
pub pitch: i32,@@ -517,38 +526,18 @@ impl From<ExportError> for Preview2dError {517
526
518
/// Compose one view for the selected [`ViewMode`]. Missing maps become an explicit error.527
/// Compose one view for the selected [`ViewMode`]. Missing maps become an explicit error.519
pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result<ImageBuf, Preview2dError> {528
pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result<ImageBuf, Preview2dError> {520
let mut runs: Vec<&ExportRun> = Vec::with_capacity(1 + req.equipment.len());529
let mut atlases: Vec<&LoadedAtlas> = Vec::with_capacity(1 + req.equipment.len());521
runs.push(req.body);530
atlases.push(req.body);522
for run in req.equipment {531
atlases.extend(req.equipment.iter());523
runs.push(run);532
let keys: Vec<CompatKey> = atlases524
}533
.iter()525
assert_compatible(&runs)?;534
.map(|a| CompatKey::from_ppda(a.ppda()))526
535
.collect();527
let mut layers = Vec::with_capacity(runs.len());536
assert_compatible_keys(&keys)?;528
let body_set = req.body.render.set.as_str();537
let mut layers = Vec::with_capacity(atlases.len());529
match LayerMaps::from_export_run(538
for atlas in atlases {530
req.body,539
let set = atlas.ppda().settype.as_str();531
body_set,540
match LayerMaps::from_atlas(atlas, set, req.clip_token, req.frame, req.pitch, req.yaw) {532
req.clip_token,533
req.frame,534
req.pitch,535
req.yaw,536
) {537
Ok(layer) => layers.push(layer),538
Err(err) => {539
return Err(Preview2dError::Missing {540
set: body_set.to_string(),541
clip: req.clip_token.to_string(),542
frame: req.frame,543
pitch: req.pitch,544
yaw: req.yaw,545
detail: err.to_string(),546
});547
}548
}549
for run in req.equipment {550
let set = run.render.set.as_str();551
match LayerMaps::from_export_run(run, set, req.clip_token, req.frame, req.pitch, req.yaw) {552
Ok(layer) => layers.push(layer),541
Ok(layer) => layers.push(layer),553
Err(err) => {542
Err(err) => {554
return Err(Preview2dError::Missing {543
return Err(Preview2dError::Missing {@@ -571,23 +560,23 @@ pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result<ImageBuf, Previe571
}560
}572
}561
}573
562
574
/// Open body + selected equipment runs for one sex, validating compatibility.563
/// Run directories (body first, then equipment in the given order) that575
pub fn open_selection(564
/// [`open_selection`] would load; used as a cache key by the editor.565
pub fn selection_paths(576
catalog: &[CatalogEntry],566
catalog: &[CatalogEntry],577
sex: Sex,567
sex: Sex,578
equipment_sets: &[String],568
equipment_sets: &[String],579
) -> Result<(ExportRun, Vec<ExportRun>), Preview2dError> {569
) -> Result<Vec<PathBuf>, Preview2dError> {580
let sex_token = match sex {570
let sex_token = match sex {581
Sex::Male => "male",571
Sex::Male => "male",582
Sex::Female => "female",572
Sex::Female => "female",583
};573
};584
let body_entry = catalog574
let body = catalog585
.iter()575
.iter()586
.filter(|e| e.sex == sex_token && e.is_body())576
.filter(|e| e.sex == sex_token && e.is_body())587
.max_by_key(|e| e.path.clone())577
.max_by_key(|e| e.path.clone())588
.ok_or(Preview2dError::NoBody)?;578
.ok_or(Preview2dError::NoBody)?;589
let body = ExportRun::open(&body_entry.path)?;579
let mut paths = vec![body.path.clone()];590
let mut equipment = Vec::new();591
for set in equipment_sets {580
for set in equipment_sets {592
let entry = catalog581
let entry = catalog593
.iter()582
.iter()@@ -601,12 +590,30 @@ pub fn open_selection(601
yaw: 0,590
yaw: 0,602
detail: "geen voltooide exportset voor dit equipment".into(),591
detail: "geen voltooide exportset voor dit equipment".into(),603
})?;592
})?;604
equipment.push(ExportRun::open(&entry.path)?);593
paths.push(entry.path.clone());605
}594
}606
let mut refs: Vec<&ExportRun> = vec![&body];595
Ok(paths)607
refs.extend(equipment.iter());596
}608
assert_compatible(&refs)?;597
609
Ok((body, equipment))598
/// Open and decode the `.ppda` atlases of body + selected equipment for one599
/// sex, re-validating checksums and compatibility.600
pub fn open_selection(601
catalog: &[CatalogEntry],602
sex: Sex,603
equipment_sets: &[String],604
) -> Result<(LoadedAtlas, Vec<LoadedAtlas>), Preview2dError> {605
let paths = selection_paths(catalog, sex, equipment_sets)?;606
let mut loaded = Vec::with_capacity(paths.len());607
for path in &paths {608
loaded.push(AtlasSet::open_run(path)?.load()?);609
}610
let keys: Vec<CompatKey> = loaded611
.iter()612
.map(|a| CompatKey::from_ppda(a.ppda()))613
.collect();614
assert_compatible_keys(&keys)?;615
let body = loaded.remove(0);616
Ok((body, loaded))610
}617
}611
618
612
/// Equipment sets for `sex` that share the body CompatKey (same project/rig/contract).619
/// Equipment sets for `sex` that share the body CompatKey (same project/rig/contract).@@ -679,10 +686,8 @@ pub fn sync_state_from_catalog(state: &mut Preview2dState, catalog: &[CatalogEnt679
mod tests {686
mod tests {680
use super::*;687
use super::*;681
use crate::digest;688
use crate::digest;682
use crate::export_set::complete_identity_json;689
use crate::export_fixture::{Pixel, SyntheticRun};683
use crate::production::{write_depth, write_rgba, FAR, NEAR, PASSES};684
use crate::project::CameraFraming;690
use crate::project::CameraFraming;685
use std::collections::BTreeMap as Map;686
use std::sync::atomic::{AtomicU64, Ordering};691
use std::sync::atomic::{AtomicU64, Ordering};687
692
688
static COUNTER: AtomicU64 = AtomicU64::new(0);693
static COUNTER: AtomicU64 = AtomicU64::new(0);@@ -718,164 +723,39 @@ mod tests {718
}723
}719
}724
}720
725
721
fn write_atomic_json(root: &Path, name: &str, value: &serde_json::Value) {726
fn pixel(diffuse: [u8; 4], normal: [u8; 4], specular: [u8; 4], depth: u16) -> Pixel {722
let data = serde_json::to_vec_pretty(value).unwrap();727
Pixel {723
let path = root.join(name);728
diffuse,724
let temp = path.with_extension(format!("part-{}", std::process::id()));729
normal,725
fs::write(&temp, &data).unwrap();730
specular,726
fs::hard_link(&temp, &path).unwrap();731
depth,727
let _ = fs::remove_file(temp);728
}729
730
#[allow(clippy::too_many_arguments)]731
fn publish_pose(732
root: &Path,733
sex: &str,734
set: &str,735
clip: &str,736
frame: u32,737
pitch: i32,738
yaw: u32,739
diffuse: [u8; 4],740
normal: [u8; 4],741
specular: [u8; 4],742
depth_gray: u16,743
) -> Map<String, String> {744
let mut map = Map::new();745
let base = format!("{sex}/{set}/{clip}/f{frame:06}/p{pitch:+03}_y{yaw:03}");746
let rels = [747
(format!("{base}_diffuse.png"), "diffuse"),748
(format!("{base}_depth.png"), "depth"),749
(format!("{base}_normal.png"), "normal"),750
(format!("{base}_specular.png"), "specular"),751
];752
for (rel, kind) in rels {753
let path = Path::new(&rel);754
match kind {755
"depth" => {756
write_depth(root, path, 1, 1, &[depth_gray, 65535]).unwrap();757
}758
"normal" => {759
write_rgba(760
root,761
path,762
&ImageBuf {763
width: 1,764
height: 1,765
rgba: normal.to_vec(),766
},767
)768
.unwrap();769
}770
"specular" => {771
write_rgba(772
root,773
path,774
&ImageBuf {775
width: 1,776
height: 1,777
rgba: specular.to_vec(),778
},779
)780
.unwrap();781
}782
_ => {783
write_rgba(784
root,785
path,786
&ImageBuf {787
width: 1,788
height: 1,789
rgba: diffuse.to_vec(),790
},791
)792
.unwrap();793
}794
}795
let bytes = fs::read(root.join(&rel)).unwrap();796
map.insert(rel, digest::sha256_hex(&bytes));797
}732
}798
map799
}733
}800
734
735
/// Complete synthetic run (all 60 views) with uniform maps per run.801
#[allow(clippy::too_many_arguments)]736
#[allow(clippy::too_many_arguments)]802
fn write_run(737
fn write_run(803
root: &Path,738
root: &Path,804
project_id: &str,739
project_id: &str,805
sex: &str,806
set: &str,740
set: &str,807
equipment_sha: Option<&str>,741
equipment_sha: Option<&str>,808
body_bytes: &[u8],742
body_bytes: &[u8],809
clips: &[(&str, &str, u32, f32)],743
clips: &[(&str, u32, f32)],810
checksums: Map<String, String>,744
px: Pixel,811
) {745
) {812
let body_hash = digest::sha256_hex(body_bytes);746
let mut spec = SyntheticRun::body(project_id, "male", body_bytes);813
let clips_json: Vec<_> = clips747
spec.framing = framing();748
spec.set = set.into();749
spec.equipment_sha256 = equipment_sha.map(str::to_string);750
spec.clips = clips814
.iter()751
.iter()815
.map(|(name, token, frames, duration)| {752
.map(|(n, f, d)| (n.to_string(), *f, *d))816
serde_json::json!({817
"name": name,818
"token": token,819
"frames": frames,820
"duration": duration821
})822
})823
.collect();753
.collect();824
let images: u32 = clips.iter().map(|(_, _, f, _)| *f).sum::<u32>() * 36;754
spec.write(root, |_, _, _, _| px).unwrap();825
let png_files = checksums.len() as u32;826
let mut render = serde_json::json!({827
"format_version": 3,828
"status": "incomplete",829
"project": {"id": project_id, "name": "Demo"},830
"sex": sex,831
"set": set,832
"body_model": {"path": "assets/body/x.glb", "sha256": body_hash},833
"equipment_preset": null,834
"target": [1, 1],835
"fps": 12,836
"highres_scale": 8,837
"framing": framing(),838
"pivot_source": {"kind": "manual"},839
"near": NEAR,840
"far": FAR,841
"camera_distance": 10.0,842
"yaw_zero": "camera on -Z",843
"normal_space": "view (+Y up), PNG rows top-down",844
"pitches": [-30, 0, 30],845
"yaw_step": 30,846
"sampling": "test",847
"alpha": "straight",848
"diffuse": "unlit",849
"depth": "16-bit",850
"specular": "scalar",851
"pass_order": PASSES,852
"saved_maps": ["diffuse", "depth", "normal", "specular"],853
"clips": clips_json,854
"images": images,855
"passes": images * 6,856
"png_files": png_files857
});858
if let Some(sha) = equipment_sha {859
render["equipment_preset"] = serde_json::json!({"name": set, "sha256": sha});860
}861
write_atomic_json(root, "render.json", &render);862
let identity = complete_identity_json(&render).unwrap();863
write_atomic_json(864
root,865
"complete.json",866
&serde_json::json!({867
"format_version": 3,868
"status": "complete",869
"identity": identity,870
"passes": images * 6,871
"png_files": png_files,872
"png_sha256": checksums873
}),874
);875
}755
}876
756
877
#[test]757
#[test]878
fn yaw_wraps_twelve_steps_pitch_clamps_three_rows() {758
fn yaw_wraps_twelve_steps_pitch_clamps_five_rows() {879
let mut state = Preview2dState {759
let mut state = Preview2dState {880
elapsed_secs: 1.25,760
elapsed_secs: 1.25,881
..Preview2dState::default()761
..Preview2dState::default()@@ -892,17 +772,19 @@ mod tests {892
assert_eq!(state.yaw_deg(), 0);772
assert_eq!(state.yaw_deg(), 0);893
assert_eq!(PAPERDOLL_YAW_COUNT, 12);773
assert_eq!(PAPERDOLL_YAW_COUNT, 12);894
774
895
state.pitch_row = 1;775
state.pitch_row = ZERO_PITCH_ROW;896
state.step_pitch(1);776
state.step_pitch(1);897
assert_eq!(state.pitch_deg(), 30);777
assert_eq!(state.pitch_deg(), 30);898
state.step_pitch(1);778
state.step_pitch(1);899
assert_eq!(state.pitch_deg(), 30);779
assert_eq!(state.pitch_deg(), 60);900
state.step_pitch(-1);780
state.step_pitch(1);901
assert_eq!(state.pitch_deg(), 0);781
assert_eq!(state.pitch_deg(), 60);902
state.step_pitch(-1);782
state.step_pitch(-3);903
assert_eq!(state.pitch_deg(), -30);783
assert_eq!(state.pitch_deg(), -30);904
state.step_pitch(-1);784
state.step_pitch(-1);905
assert_eq!(state.pitch_deg(), -30);785
assert_eq!(state.pitch_deg(), -60);786
state.step_pitch(-1);787
assert_eq!(state.pitch_deg(), -60);906
assert_eq!(state.elapsed_secs, 1.25);788
assert_eq!(state.elapsed_secs, 1.25);907
}789
}908
790
@@ -978,64 +860,43 @@ mod tests {978
860
979
#[test]861
#[test]980
fn missing_frame_is_diagnostic_not_silent_substitute() {862
fn missing_frame_is_diagnostic_not_silent_substitute() {981
let body_tmp = Temp::new();863
let project = Temp::new();982
let clips = [("walk", "clip000-walk", 2u32, 0.1667f32)];864
let dir = project.0.join("renders/run-body");983
let mut checksums = Map::new();984
// Only frame 0 / pitch 0 / yaw 0 published — frame 1 missing.985
checksums.extend(publish_pose(986
&body_tmp.0,987
"male",988
"body",989
"clip000-walk",990
0,991
0,992
0,993
[10, 20, 30, 255],994
[128, 128, 255, 255],995
[0, 0, 0, 255],996
20000,997
));998
write_run(865
write_run(999
&body_tmp.0,866
&dir,1000
"demo",867
"demo",1001
"male",1002
"body",868
"body",1003
None,869
None,1004
b"shared-body",870
b"shared-body",1005
&clips,871
&[("walk", 2, 0.5)],1006
checksums,872
pixel(873
[10, 20, 30, 255],874
[128, 128, 255, 255],875
[0, 0, 0, 255],876
20000,877
),1007
);878
);1008
let run = ExportRun::open(&body_tmp.0).unwrap();879
let catalog = discover_completed_runs(&project.0);1009
let ok = compose_preview_frame(ComposeRequest {880
let (body, _) = open_selection(&catalog, Sex::Male, &[]).unwrap();1010
body: &run,881
let req = ComposeRequest {1011
equipment: &[],882
body: &body,1012
clip_token: "clip000-walk",1013
frame: 0,1014
pitch: 0,1015
yaw: 0,1016
light: Light::default(),1017
view_mode: ViewMode::Resultaat,1018
})1019
.unwrap();1020
assert_eq!(ok.width, 1);1021
let err = compose_preview_frame(ComposeRequest {1022
body: &run,1023
equipment: &[],883
equipment: &[],1024
clip_token: "clip000-walk",884
clip_token: "clip000-walk",1025
frame: 1,885
frame: 1,1026
pitch: 0,886
pitch: 60,1027
yaw: 0,887
yaw: 330,1028
light: Light::default(),888
light: Light::default(),1029
view_mode: ViewMode::Resultaat,889
view_mode: ViewMode::Resultaat,1030
})890
};1031
.unwrap_err();891
assert_eq!(compose_preview_frame(req.clone()).unwrap().width, 1);892
let err = compose_preview_frame(ComposeRequest { frame: 2, ..req }).unwrap_err();1032
match err {893
match err {1033
Preview2dError::Missing {894
Preview2dError::Missing {1034
set, clip, frame, ..895
set, clip, frame, ..1035
} => {896
} => {1036
assert_eq!(set, "body");897
assert_eq!(set, "body");1037
assert_eq!(clip, "clip000-walk");898
assert_eq!(clip, "clip000-walk");1038
assert_eq!(frame, 1);899
assert_eq!(frame, 2);1039
}900
}1040
other => panic!("expected Missing, got {other}"),901
other => panic!("expected Missing, got {other}"),1041
}902
}@@ -1045,98 +906,58 @@ mod tests {1045
fn catalog_compatibility_filters_equipment_by_export_contract() {906
fn catalog_compatibility_filters_equipment_by_export_contract() {1046
let project = Temp::new();907
let project = Temp::new();1047
let renders = project.0.join("renders");908
let renders = project.0.join("renders");1048
fs::create_dir_all(&renders).unwrap();909
let clips = [("bind", 1u32, 0.0f32)];1049
let body_dir = renders.join("run-body");1050
let good_dir = renders.join("run-sword");1051
let bad_dir = renders.join("run-bad");1052
fs::create_dir_all(&body_dir).unwrap();1053
fs::create_dir_all(&good_dir).unwrap();1054
fs::create_dir_all(&bad_dir).unwrap();1055
let clips = [("bind", "clip000-bind", 1u32, 0.0f32)];1056
let body_bytes = b"shared-body";910
let body_bytes = b"shared-body";1057
let mut body_maps = Map::new();1058
body_maps.extend(publish_pose(1059
&body_dir,1060
"male",1061
"body",1062
"clip000-bind",1063
0,1064
0,1065
0,1066
[1, 2, 3, 255],1067
[128, 128, 255, 255],1068
[0, 0, 0, 255],1069
1000,1070
));1071
write_run(911
write_run(1072
&body_dir, "proj-a", "male", "body", None, body_bytes, &clips, body_maps,912
&renders.join("run-body"),913
"proj-a",914
"body",915
None,916
body_bytes,917
&clips,918
pixel([1, 2, 3, 255], [128, 128, 255, 255], [0, 0, 0, 255], 1000),1073
);919
);1074
let mut good_maps = Map::new();1075
good_maps.extend(publish_pose(1076
&good_dir,1077
"male",1078
"sword",1079
"clip000-bind",1080
0,1081
0,1082
0,1083
[200, 10, 10, 255],1084
[255, 128, 128, 255],1085
[180, 180, 180, 255],1086
500,1087
));1088
write_run(920
write_run(1089
&good_dir,921
&renders.join("run-sword"),1090
"proj-a",922
"proj-a",1091
"male",1092
"sword",923
"sword",1093
Some(&digest::sha256_hex(b"sword")),924
Some(&digest::sha256_hex(b"sword")),1094
body_bytes,925
body_bytes,1095
&clips,926
&clips,1096
good_maps,927
pixel(928
[200, 10, 10, 255],929
[255, 128, 128, 255],930
[180, 180, 180, 255],931
500,932
),1097
);933
);1098
let mut bad_maps = Map::new();1099
bad_maps.extend(publish_pose(1100
&bad_dir,1101
"male",1102
"helm",1103
"clip000-bind",1104
0,1105
0,1106
0,1107
[9, 9, 9, 255],1108
[128, 128, 255, 255],1109
[0, 0, 0, 255],1110
800,1111
));1112
// Different body fingerprint → incompatible.934
// Different body fingerprint → incompatible.1113
write_run(935
write_run(1114
&bad_dir,936
&renders.join("run-bad"),1115
"proj-a",937
"proj-a",1116
"male",1117
"helm",938
"helm",1118
Some(&digest::sha256_hex(b"helm")),939
Some(&digest::sha256_hex(b"helm")),1119
b"other-body",940
b"other-body",1120
&clips,941
&clips,1121
bad_maps,942
pixel([9, 9, 9, 255], [128, 128, 255, 255], [0, 0, 0, 255], 800),1122
);943
);1123
944
1124
let catalog = discover_completed_runs(&project.0);945
let catalog = discover_completed_runs(&project.0);1125
assert_eq!(catalog.len(), 3);946
assert_eq!(catalog.len(), 3);1126
let allowed = compatible_equipment_sets(&catalog, Sex::Male);947
let allowed = compatible_equipment_sets(&catalog, Sex::Male);1127
assert_eq!(allowed, vec!["sword".to_string()]);948
assert_eq!(allowed, vec!["sword".to_string()]);1128
assert!(!allowed.iter().any(|s| s == "helm"));949
assert!(open_selection(&catalog, Sex::Male, &["helm".to_string()]).is_err());1129
950
1130
let (body, gear) = open_selection(&catalog, Sex::Male, &["sword".to_string()]).unwrap();951
let (body, gear) = open_selection(&catalog, Sex::Male, &["sword".to_string()]).unwrap();1131
assert_eq!(body.render.set, "body");952
assert_eq!(body.ppda().settype, "body");1132
assert_eq!(gear.len(), 1);953
assert_eq!(gear.len(), 1);1133
let lit = compose_preview_frame(ComposeRequest {954
let lit = compose_preview_frame(ComposeRequest {1134
body: &body,955
body: &body,1135
equipment: &gear,956
equipment: &gear,1136
clip_token: "clip000-bind",957
clip_token: "clip000-bind",1137
frame: 0,958
frame: 0,1138
pitch: 0,959
pitch: -60,1139
yaw: 0,960
yaw: 90,1140
light: Light::default(),961
light: Light::default(),1141
view_mode: ViewMode::Resultaat,962
view_mode: ViewMode::Resultaat,1142
})963
})@@ -1215,34 +1036,24 @@ mod tests {1215
1036
1216
#[test]1037
#[test]1217
fn view_modes_show_composited_maps_unlit_except_resultaat() {1038
fn view_modes_show_composited_maps_unlit_except_resultaat() {1218
let body_tmp = Temp::new();1039
let project = Temp::new();1219
let clips = [("bind", "clip000-bind", 1u32, 0.0f32)];1040
// Distinct channels so modes are distinguishable; scalar specular.1220
let mut checksums = Map::new();1221
// Distinct channels so modes are distinguishable.1222
checksums.extend(publish_pose(1223
&body_tmp.0,1224
"male",1225
"body",1226
"clip000-bind",1227
0,1228
0,1229
0,1230
[10, 20, 30, 255],1231
[40, 50, 60, 255],1232
[70, 70, 70, 255], // scalar specular (equal RGB)1233
32768, // mid gray depth1234
));1235
write_run(1041
write_run(1236
&body_tmp.0,1042
&project.0.join("renders/run-body"),1237
"demo",1043
"demo",1238
"male",1239
"body",1044
"body",1240
None,1045
None,1241
b"shared-body",1046
b"shared-body",1242
&clips,1047
&[("bind", 1, 0.0)],1243
checksums,1048
pixel(1049
[10, 20, 30, 255],1050
[40, 50, 60, 255],1051
[70, 70, 70, 255],1052
32768,1053
),1244
);1054
);1245
let run = ExportRun::open(&body_tmp.0).unwrap();1055
let catalog = discover_completed_runs(&project.0);1056
let (run, _) = open_selection(&catalog, Sex::Male, &[]).unwrap();1246
let base = ComposeRequest {1057
let base = ComposeRequest {1247
body: &run,1058
body: &run,1248
equipment: &[],1059
equipment: &[],@@ -1287,7 +1098,7 @@ mod tests {1287
1098
1288
let result = compose_preview_frame(ComposeRequest {1099
let result = compose_preview_frame(ComposeRequest {1289
view_mode: ViewMode::Resultaat,1100
view_mode: ViewMode::Resultaat,1290
..base1101
..base.clone()1291
})1102
})1292
.unwrap();1103
.unwrap();1293
// Relit differs from raw diffuse under default light.1104
// Relit differs from raw diffuse under default light.@@ -1302,14 +1113,9 @@ mod tests {1302
..Light::default()1113
..Light::default()1303
};1114
};1304
let diffuse_dark = compose_preview_frame(ComposeRequest {1115
let diffuse_dark = compose_preview_frame(ComposeRequest {1305
body: &run,1306
equipment: &[],1307
clip_token: "clip000-bind",1308
frame: 0,1309
pitch: 0,1310
yaw: 0,1311
light: dark,1116
light: dark,1312
view_mode: ViewMode::DiffuseOnly,1117
view_mode: ViewMode::DiffuseOnly,1118
..base1313
})1119
})1314
.unwrap();1120
.unwrap();1315
assert_eq!(diffuse_dark.rgba, diffuse.rgba);1121
assert_eq!(diffuse_dark.rgba, diffuse.rgba);@@ -1322,8 +1128,76 @@ mod tests {1322
let incomplete = renders.join("run-incomplete");1128
let incomplete = renders.join("run-incomplete");1323
fs::create_dir_all(&incomplete).unwrap();1129
fs::create_dir_all(&incomplete).unwrap();1324
fs::write(incomplete.join("render.json"), b"{}").unwrap();1130
fs::write(incomplete.join("render.json"), b"{}").unwrap();1325
// no complete.json1131
// no .ppda at all1326
let catalog = discover_completed_runs(&project.0);1132
let (catalog, skipped) = discover_exports(&project.0);1327
assert!(catalog.is_empty());1133
assert!(catalog.is_empty());1134
assert_eq!(skipped.len(), 1);1135
assert!(skipped[0].1.contains(".ppda"), "{}", skipped[0].1);1136
}1137
1138
#[test]1139
fn preview_reads_atlas_not_loose_pngs() {1140
// Each view gets a distinct colour; the preview must return exactly the1141
// atlas cell even after every loose PNG is deleted.1142
let project = Temp::new();1143
let dir = project.0.join("renders/run-body");1144
let mut spec = SyntheticRun::body("demo", "male", b"b");1145
spec.clips = vec![("walk".into(), 2, 0.5)];1146
spec.target = [2, 3];1147
spec.write(&dir, |_, f, p, y| {1148
pixel(1149
[(y / 30) as u8 * 20, (p + 60) as u8, f as u8 * 100 + 1, 255],1150
[128, 128, 255, 255],1151
[0, 0, 0, 255],1152
1000,1153
)1154
})1155
.unwrap();1156
fs::remove_dir_all(dir.join("male")).unwrap();1157
let catalog = discover_completed_runs(&project.0);1158
assert_eq!(catalog.len(), 1);1159
assert_eq!(catalog[0].fps, crate::project::DEFAULT_FPS);1160
let (body, _) = open_selection(&catalog, Sex::Male, &[]).unwrap();1161
for (frame, pitch, yaw) in [(0u32, -60i32, 0u32), (1, 60, 330), (1, -30, 150)] {1162
let img = compose_preview_frame(ComposeRequest {1163
body: &body,1164
equipment: &[],1165
clip_token: "clip000-walk",1166
frame,1167
pitch,1168
yaw,1169
light: Light::default(),1170
view_mode: ViewMode::DiffuseOnly,1171
})1172
.unwrap();1173
assert_eq!((img.width, img.height), (2, 3));1174
let want = [1175
(yaw / 30) as u8 * 20,1176
(pitch + 60) as u8,1177
frame as u8 * 100 + 1,1178
255,1179
];1180
for px in img.rgba.chunks_exact(4) {1181
assert_eq!(px, want);1182
}1183
}1184
}1185
1186
#[test]1187
fn five_pitch_rows_clamp_and_default_is_zero() {1188
let mut state = Preview2dState::default();1189
assert_eq!(state.pitch_deg(), 0);1190
state.step_pitch(1);1191
state.step_pitch(1);1192
assert_eq!(state.pitch_deg(), 60);1193
state.step_pitch(1);1194
assert_eq!(state.pitch_deg(), 60);1195
for _ in 0..10 {1196
state.step_pitch(-1);1197
}1198
assert_eq!(state.pitch_deg(), -60);1199
assert_eq!(pitch_row_from_deg(30), 3);1200
assert_eq!(pitch_row_from_deg(7), ZERO_PITCH_ROW);1201
assert_eq!(state.fps, 4);1328
}1202
}1329
}1203
}@@ -1169,7 +1169,7 @@ pub fn write_depth(
1169
1169
}
1170
1170
1171
1171
/// Atomic no-clobber publication of already encoded bytes under the run root.
1172
pub(crate) fn publish_bytes(root: &Path, relative: &Path, bytes: &[u8]) -> Result<(), String> {
1172
pub fn publish_bytes(root: &Path, relative: &Path, bytes: &[u8]) -> Result<(), String> {
1173
1173
atomic_file(root, relative, |file| {
1174
1174
use std::io::Write;
1175
1175
file.write_all(bytes).map_err(|e| e.to_string())
@@ -1904,23 +1904,26 @@ mod tests {
1904
1904
c.duration = 0.5;
1905
1905
b.clips = vec![c.clone(), c];
1906
1906
let p = plan(&b, 3).unwrap();
1907
assert_eq!((p.images, p.passes, p.files), (144, 864, 576));
1907
// 2 clips × 2 frames × 60 views.
1908
assert_eq!((p.images, p.passes, p.files), (240, 1440, 960));
1908
1909
let s = samples(&p).collect::<Vec<_>>();
1909
assert_eq!(s.len(), 144);
1910
assert_eq!(s.len(), 240);
1910
1911
assert_eq!(
1911
1912
s[0],
1912
1913
Sample {
1913
1914
clip: 0,
1914
1915
frame: 0,
1915
pitch: -30,
1916
pitch: -60,
1916
1917
yaw: 0
1917
1918
}
1918
1919
);
1919
1920
assert_eq!(s[11].yaw, 330);
1920
assert_eq!(s[12].pitch, 0);
1921
assert_eq!(s[35].pitch, 30);
1922
assert_eq!(s[36].frame, 1);
1923
assert_eq!(s[72].clip, 1);
1921
assert_eq!(s[12].pitch, -30);
1922
assert_eq!(s[24].pitch, 0);
1923
assert_eq!(s[36].pitch, 30);
1924
assert_eq!(s[59].pitch, 60);
1925
assert_eq!(s[60].frame, 1);
1926
assert_eq!(s[120].clip, 1);
1924
1927
assert_ne!(p.clips[0].token, p.clips[1].token);
1925
1928
assert!(p.clips.iter().all(|c| equipment::valid_name(&c.token)));
1926
1929
assert_eq!(
@@ -1936,7 +1939,7 @@ mod tests {
1936
1939
);
1937
1940
b.clips.clear();
1938
1941
let p = plan(&b, 12).unwrap();
1939
assert_eq!((p.images, p.files), (36, 144));
1942
assert_eq!((p.images, p.files), (60, 240));
1940
1943
}
1941
1944
#[test]
1942
1945
fn floating_terminal_timestamp_does_not_duplicate_the_loop_endpoint() {
@@ -1959,11 +1962,14 @@ mod tests {
1959
1962
}
1960
1963
#[test]
1961
1964
fn output_tokens_only_four_target_maps() {
1962
let expected = PathBuf::from("male/body/clip000-walk/f000012/p-30_y330_diffuse.png");
1965
let expected = PathBuf::from("male/body/clip000-walk/f000012/p-60_y330_diffuse.png");
1963
1966
assert_eq!(
1964
output_path("male", "body", "clip000-walk", 12, -30, 330, "diffuse").unwrap(),
1967
output_path("male", "body", "clip000-walk", 12, -60, 330, "diffuse").unwrap(),
1965
1968
expected
1966
1969
);
1970
assert!(output_path("male", "body", "clip000-walk", 0, 60, 0, "depth").is_ok());
1971
assert!(output_path("male", "body", "clip000-walk", 0, 45, 0, "depth").is_err());
1972
assert!(output_path("male", "body", "clip000-walk", 0, 90, 0, "depth").is_err());
1967
1973
for map in ["diffuse", "normal", "depth", "specular"] {
1968
1974
assert!(output_path("female", "sword", "clip000-bind", 0, 30, 0, map).is_ok());
1969
1975
}
@@ -2316,7 +2322,10 @@ mod tests {
2316
2322
let meta: serde_json::Value =
2317
2323
serde_json::from_str(&std::fs::read_to_string(run.join("render.json")).unwrap())
2318
2324
.unwrap();
2319
assert_eq!(meta["format_version"], 3);
2325
assert_eq!(meta["format_version"], 4);
2326
assert_eq!(meta["pitches"], serde_json::json!([-60, -30, 0, 30, 60]));
2327
assert_eq!(meta["yaws"].as_array().unwrap().len(), 12);
2328
assert_eq!(meta["fps"], 12);
2320
2329
assert_eq!(meta["project"]["id"], "test-project");
2321
2330
assert_eq!(
2322
2331
meta["body_model"]["sha256"],
@@ -2339,11 +2348,11 @@ mod tests {
2339
2348
&std::fs::read_to_string(run.join("complete.json")).unwrap(),
2340
2349
)
2341
2350
.unwrap();
2342
assert_eq!(complete["format_version"], 3);
2351
assert_eq!(complete["format_version"], 4);
2343
2352
assert_eq!(complete["status"], "complete");
2344
assert_eq!(complete["png_files"], 144);
2353
assert_eq!(complete["png_files"], 240);
2345
2354
let checksums = complete["png_sha256"].as_object().unwrap();
2346
assert_eq!(checksums.len(), 144);
2355
assert_eq!(checksums.len(), 240);
2347
2356
for (rel, digest) in checksums {
2348
2357
assert!(crate::digest::is_sha256_hex(digest.as_str().unwrap()));
2349
2358
let bytes = std::fs::read(run.join(rel)).unwrap();
@@ -2354,20 +2363,44 @@ mod tests {
2354
2363
.load_map(
2355
2364
"clip000-bind",
2356
2365
0,
2357
-30,
2366
-60,
2358
2367
0,
2359
2368
crate::export_set::MapKind::Diffuse,
2360
2369
)
2361
2370
.unwrap();
2362
2371
assert_eq!(sample.width(), 1);
2372
// Atlases + .ppda: one per map, written before complete.json.
2373
for map in ["diffuse", "normal", "depth", "specular"] {
2374
let name = format!("male-body-{map}-atlas.png");
2375
assert!(run.join(&name).is_file(), "{name}");
2376
assert_eq!(complete["atlas"]["maps"][map]["file"], name);
2377
}
2378
assert!(!stop);
2379
let atlas = crate::atlas::AtlasSet::open_run(&run).unwrap();
2380
assert_eq!(atlas.ppda.fps, 12);
2381
assert_eq!((atlas.layout.width, atlas.layout.height), (60, 1));
2382
let loaded = atlas.load().unwrap();
2383
for (pitch, yaw) in [(-60, 0), (0, 150), (60, 330)] {
2384
for kind in crate::atlas::ATLAS_MAPS {
2385
assert_eq!(
2386
loaded.cell("clip000-bind", 0, pitch, yaw, kind).unwrap(),
2387
opened
2388
.load_map("clip000-bind", 0, pitch, yaw, kind)
2389
.unwrap()
2390
);
2391
}
2392
}
2393
} else {
2394
assert!(!run.join("male-body-atlas.ppda").exists());
2395
assert!(!run.join("male-body-diffuse-atlas.png").exists());
2363
2396
}
2364
2397
let frame = run.join("male/body/clip000-bind/f000000");
2365
assert!(frame.join("p-30_y000_diffuse.png").exists());
2366
assert_eq!(frame.join("p-30_y000_depth.png").exists(), !stop);
2367
assert!(!frame.join("p-30_y000_highres.png").exists());
2368
assert!(!frame.join("p-30_y000_detail.png").exists());
2398
assert!(frame.join("p-60_y000_diffuse.png").exists());
2399
assert_eq!(frame.join("p-60_y000_depth.png").exists(), !stop);
2400
assert!(!frame.join("p-60_y000_highres.png").exists());
2401
assert!(!frame.join("p-60_y000_detail.png").exists());
2369
2402
if !stop {
2370
assert_eq!(labels.len(), 216);
2403
assert_eq!(labels.len(), 360);
2371
2404
for labels in labels.chunks_exact(6) {
2372
2405
for (label, pass) in labels.iter().zip([
2373
2406
"highres",
@@ -2491,7 +2524,7 @@ mod tests {
2491
2524
let (ok, _) = drive(resume_job(&reference_dir.0), RunTarget::Fresh, None);
2492
2525
ok.unwrap();
2493
2526
let reference = checksums(&runs(&reference_dir.0)[0]);
2494
assert_eq!(reference.as_object().unwrap().len(), 144);
2527
assert_eq!(reference.as_object().unwrap().len(), 240);
2495
2528
2496
2529
let tmp = Temp::new();
2497
2530
// An earlier completed run in the same output must stay byte-identical.
@@ -2505,9 +2538,9 @@ mod tests {
2505
2538
let cancelled: serde_json::Value =
2506
2539
serde_json::from_slice(&std::fs::read(run.join("cancelled.json")).unwrap()).unwrap();
2507
2540
let done_first = cancelled["png_files_done"].as_u64().unwrap();
2508
assert!(done_first > 0 && done_first < 144);
2541
assert!(done_first > 0 && done_first < 240);
2509
2542
assert_eq!(cancelled["attempt"], 0);
2510
let first_file = run.join("male/body/clip000-bind/f000000/p-30_y000_diffuse.png");
2543
let first_file = run.join("male/body/clip000-bind/f000000/p-60_y000_diffuse.png");
2511
2544
let first_inode = inode(&first_file);
2512
2545
assert!(crate::export_set::ExportRun::open(&run).is_err());
2513
2546
@@ -2588,11 +2621,11 @@ mod tests {
2588
2621
let torn_inode = inode(&torn_path);
2589
2622
// Corrupt one journaled PNG, delete another, leave a foreign temp file.
2590
2623
let frame = run.join("male/body/clip000-bind/f000000");
2591
let corrupt = frame.join("p-30_y030_normal.png");
2624
let corrupt = frame.join("p-60_y030_normal.png");
2592
2625
std::fs::write(&corrupt, b"not a png").unwrap();
2593
let missing = frame.join("p-30_y060_depth.png");
2626
let missing = frame.join("p-60_y060_depth.png");
2594
2627
std::fs::remove_file(&missing).unwrap();
2595
let stale = frame.join("p-30_y090_diffuse.part-999999-0");
2628
let stale = frame.join("p-60_y090_diffuse.part-999999-0");
2596
2629
std::fs::write(&stale, b"partial").unwrap();
2597
2630
2598
2631
let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;
@@ -2606,7 +2639,7 @@ mod tests {
2606
2639
crate::export_set::ExportRun::open(&run).unwrap();
2607
2640
assert!(missing.exists());
2608
2641
assert_eq!(
2609
std::fs::read(frame.join("p-30_y030_normal.png.corrupt-a001-0")).unwrap(),
2642
std::fs::read(frame.join("p-60_y030_normal.png.corrupt-a001-0")).unwrap(),
2610
2643
b"not a png"
2611
2644
);
2612
2645
assert_eq!(std::fs::read(&stale).unwrap(), b"partial");
@@ -2626,10 +2659,55 @@ mod tests {
2626
2659
assert_eq!(log["previous_marker"], serde_json::Value::Null);
2627
2660
assert_eq!(log["damaged_journal_lines"], 1);
2628
2661
assert_eq!(log["unjournaled_existing"], 1);
2629
assert_eq!(log["missing"].as_u64().unwrap(), 144 - 60 / 6 * 4 + 1);
2662
assert_eq!(log["missing"].as_u64().unwrap(), 240 - 60 / 6 * 4 + 1);
2630
2663
assert_eq!(log["quarantined"].as_array().unwrap().len(), 1);
2631
2664
}
2632
2665
2666
#[test]
2667
#[cfg(unix)]
2668
fn crash_after_atlases_keeps_identical_and_quarantines_damaged_atlas() {
2669
let reference_dir = Temp::new();
2670
drive(resume_job(&reference_dir.0), RunTarget::Fresh, None)
2671
.0
2672
.unwrap();
2673
let reference_run = runs(&reference_dir.0)[0].clone();
2674
let tmp = Temp::new();
2675
drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();
2676
let run = runs(&tmp.0)[0].clone();
2677
// Simulate a crash after the atlases/.ppda were published but before
2678
// complete.json, with one atlas damaged afterwards.
2679
std::fs::remove_file(run.join("complete.json")).unwrap();
2680
let diffuse = run.join("male-body-diffuse-atlas.png");
2681
let normal = run.join("male-body-normal-atlas.png");
2682
let normal_inode = inode(&normal);
2683
let good = std::fs::read(&diffuse).unwrap();
2684
std::fs::remove_file(&diffuse).unwrap();
2685
std::fs::write(&diffuse, b"damaged").unwrap();
2686
drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
2687
.0
2688
.unwrap();
2689
assert_eq!(inode(&normal), normal_inode, "identical atlas rewritten");
2690
assert_eq!(std::fs::read(&diffuse).unwrap(), good);
2691
assert_eq!(
2692
std::fs::read(run.join("male-body-diffuse-atlas.png.corrupt-a001-0")).unwrap(),
2693
b"damaged"
2694
);
2695
crate::atlas::AtlasSet::open_run(&run).unwrap();
2696
crate::export_set::ExportRun::open(&run).unwrap();
2697
// Byte-identical to an uninterrupted run (deterministic encoding).
2698
for name in [
2699
"male-body-atlas.ppda",
2700
"male-body-diffuse-atlas.png",
2701
"male-body-depth-atlas.png",
2702
] {
2703
assert_eq!(
2704
std::fs::read(run.join(name)).unwrap(),
2705
std::fs::read(reference_run.join(name)).unwrap(),
2706
"{name}"
2707
);
2708
}
2709
}
2710
2633
2711
#[test]
2634
2712
fn unjournaled_different_file_is_quarantined_not_overwritten() {
2635
2713
let tmp = Temp::new();
@@ -2638,7 +2716,7 @@ mod tests {
2638
2716
.is_err());
2639
2717
let run = runs(&tmp.0)[0].clone();
2640
2718
// A planned PNG appears that the journal never recorded and that differs.
2641
let foreign = run.join("male/body/clip000-bind/f000000/p-30_y030_specular.png");
2719
let foreign = run.join("male/body/clip000-bind/f000000/p-60_y030_specular.png");
2642
2720
std::fs::create_dir_all(foreign.parent().unwrap()).unwrap();
2643
2721
std::fs::write(&foreign, b"foreign").unwrap();
2644
2722
drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)
@@ -2646,7 +2724,7 @@ mod tests {
2646
2724
.unwrap();
2647
2725
crate::export_set::ExportRun::open(&run).unwrap();
2648
2726
assert_eq!(
2649
std::fs::read(foreign.with_file_name("p-30_y030_specular.png.corrupt-a001-0")).unwrap(),
2727
std::fs::read(foreign.with_file_name("p-60_y030_specular.png.corrupt-a001-0")).unwrap(),
2650
2728
b"foreign"
2651
2729
);
2652
2730
}
@@ -1169,7 +1169,7 @@ pub fn write_depth(1169
}1169
}1170
1170
1171
/// Atomic no-clobber publication of already encoded bytes under the run root.1171
/// Atomic no-clobber publication of already encoded bytes under the run root.1172
pub(crate) fn publish_bytes(root: &Path, relative: &Path, bytes: &[u8]) -> Result<(), String> {1172
pub fn publish_bytes(root: &Path, relative: &Path, bytes: &[u8]) -> Result<(), String> {1173
atomic_file(root, relative, |file| {1173
atomic_file(root, relative, |file| {1174
use std::io::Write;1174
use std::io::Write;1175
file.write_all(bytes).map_err(|e| e.to_string())1175
file.write_all(bytes).map_err(|e| e.to_string())@@ -1904,23 +1904,26 @@ mod tests {1904
c.duration = 0.5;1904
c.duration = 0.5;1905
b.clips = vec![c.clone(), c];1905
b.clips = vec![c.clone(), c];1906
let p = plan(&b, 3).unwrap();1906
let p = plan(&b, 3).unwrap();1907
assert_eq!((p.images, p.passes, p.files), (144, 864, 576));1907
// 2 clips × 2 frames × 60 views.1908
assert_eq!((p.images, p.passes, p.files), (240, 1440, 960));1908
let s = samples(&p).collect::<Vec<_>>();1909
let s = samples(&p).collect::<Vec<_>>();1909
assert_eq!(s.len(), 144);1910
assert_eq!(s.len(), 240);1910
assert_eq!(1911
assert_eq!(1911
s[0],1912
s[0],1912
Sample {1913
Sample {1913
clip: 0,1914
clip: 0,1914
frame: 0,1915
frame: 0,1915
pitch: -30,1916
pitch: -60,1916
yaw: 01917
yaw: 01917
}1918
}1918
);1919
);1919
assert_eq!(s[11].yaw, 330);1920
assert_eq!(s[11].yaw, 330);1920
assert_eq!(s[12].pitch, 0);1921
assert_eq!(s[12].pitch, -30);1921
assert_eq!(s[35].pitch, 30);1922
assert_eq!(s[24].pitch, 0);1922
assert_eq!(s[36].frame, 1);1923
assert_eq!(s[36].pitch, 30);1923
assert_eq!(s[72].clip, 1);1924
assert_eq!(s[59].pitch, 60);1925
assert_eq!(s[60].frame, 1);1926
assert_eq!(s[120].clip, 1);1924
assert_ne!(p.clips[0].token, p.clips[1].token);1927
assert_ne!(p.clips[0].token, p.clips[1].token);1925
assert!(p.clips.iter().all(|c| equipment::valid_name(&c.token)));1928
assert!(p.clips.iter().all(|c| equipment::valid_name(&c.token)));1926
assert_eq!(1929
assert_eq!(@@ -1936,7 +1939,7 @@ mod tests {1936
);1939
);1937
b.clips.clear();1940
b.clips.clear();1938
let p = plan(&b, 12).unwrap();1941
let p = plan(&b, 12).unwrap();1939
assert_eq!((p.images, p.files), (36, 144));1942
assert_eq!((p.images, p.files), (60, 240));1940
}1943
}1941
#[test]1944
#[test]1942
fn floating_terminal_timestamp_does_not_duplicate_the_loop_endpoint() {1945
fn floating_terminal_timestamp_does_not_duplicate_the_loop_endpoint() {@@ -1959,11 +1962,14 @@ mod tests {1959
}1962
}1960
#[test]1963
#[test]1961
fn output_tokens_only_four_target_maps() {1964
fn output_tokens_only_four_target_maps() {1962
let expected = PathBuf::from("male/body/clip000-walk/f000012/p-30_y330_diffuse.png");1965
let expected = PathBuf::from("male/body/clip000-walk/f000012/p-60_y330_diffuse.png");1963
assert_eq!(1966
assert_eq!(1964
output_path("male", "body", "clip000-walk", 12, -30, 330, "diffuse").unwrap(),1967
output_path("male", "body", "clip000-walk", 12, -60, 330, "diffuse").unwrap(),1965
expected1968
expected1966
);1969
);1970
assert!(output_path("male", "body", "clip000-walk", 0, 60, 0, "depth").is_ok());1971
assert!(output_path("male", "body", "clip000-walk", 0, 45, 0, "depth").is_err());1972
assert!(output_path("male", "body", "clip000-walk", 0, 90, 0, "depth").is_err());1967
for map in ["diffuse", "normal", "depth", "specular"] {1973
for map in ["diffuse", "normal", "depth", "specular"] {1968
assert!(output_path("female", "sword", "clip000-bind", 0, 30, 0, map).is_ok());1974
assert!(output_path("female", "sword", "clip000-bind", 0, 30, 0, map).is_ok());1969
}1975
}@@ -2316,7 +2322,10 @@ mod tests {2316
let meta: serde_json::Value =2322
let meta: serde_json::Value =2317
serde_json::from_str(&std::fs::read_to_string(run.join("render.json")).unwrap())2323
serde_json::from_str(&std::fs::read_to_string(run.join("render.json")).unwrap())2318
.unwrap();2324
.unwrap();2319
assert_eq!(meta["format_version"], 3);2325
assert_eq!(meta["format_version"], 4);2326
assert_eq!(meta["pitches"], serde_json::json!([-60, -30, 0, 30, 60]));2327
assert_eq!(meta["yaws"].as_array().unwrap().len(), 12);2328
assert_eq!(meta["fps"], 12);2320
assert_eq!(meta["project"]["id"], "test-project");2329
assert_eq!(meta["project"]["id"], "test-project");2321
assert_eq!(2330
assert_eq!(2322
meta["body_model"]["sha256"],2331
meta["body_model"]["sha256"],@@ -2339,11 +2348,11 @@ mod tests {2339
&std::fs::read_to_string(run.join("complete.json")).unwrap(),2348
&std::fs::read_to_string(run.join("complete.json")).unwrap(),2340
)2349
)2341
.unwrap();2350
.unwrap();2342
assert_eq!(complete["format_version"], 3);2351
assert_eq!(complete["format_version"], 4);2343
assert_eq!(complete["status"], "complete");2352
assert_eq!(complete["status"], "complete");2344
assert_eq!(complete["png_files"], 144);2353
assert_eq!(complete["png_files"], 240);2345
let checksums = complete["png_sha256"].as_object().unwrap();2354
let checksums = complete["png_sha256"].as_object().unwrap();2346
assert_eq!(checksums.len(), 144);2355
assert_eq!(checksums.len(), 240);2347
for (rel, digest) in checksums {2356
for (rel, digest) in checksums {2348
assert!(crate::digest::is_sha256_hex(digest.as_str().unwrap()));2357
assert!(crate::digest::is_sha256_hex(digest.as_str().unwrap()));2349
let bytes = std::fs::read(run.join(rel)).unwrap();2358
let bytes = std::fs::read(run.join(rel)).unwrap();@@ -2354,20 +2363,44 @@ mod tests {2354
.load_map(2363
.load_map(2355
"clip000-bind",2364
"clip000-bind",2356
0,2365
0,2357
-30,2366
-60,2358
0,2367
0,2359
crate::export_set::MapKind::Diffuse,2368
crate::export_set::MapKind::Diffuse,2360
)2369
)2361
.unwrap();2370
.unwrap();2362
assert_eq!(sample.width(), 1);2371
assert_eq!(sample.width(), 1);2372
// Atlases + .ppda: one per map, written before complete.json.2373
for map in ["diffuse", "normal", "depth", "specular"] {2374
let name = format!("male-body-{map}-atlas.png");2375
assert!(run.join(&name).is_file(), "{name}");2376
assert_eq!(complete["atlas"]["maps"][map]["file"], name);2377
}2378
assert!(!stop);2379
let atlas = crate::atlas::AtlasSet::open_run(&run).unwrap();2380
assert_eq!(atlas.ppda.fps, 12);2381
assert_eq!((atlas.layout.width, atlas.layout.height), (60, 1));2382
let loaded = atlas.load().unwrap();2383
for (pitch, yaw) in [(-60, 0), (0, 150), (60, 330)] {2384
for kind in crate::atlas::ATLAS_MAPS {2385
assert_eq!(2386
loaded.cell("clip000-bind", 0, pitch, yaw, kind).unwrap(),2387
opened2388
.load_map("clip000-bind", 0, pitch, yaw, kind)2389
.unwrap()2390
);2391
}2392
}2393
} else {2394
assert!(!run.join("male-body-atlas.ppda").exists());2395
assert!(!run.join("male-body-diffuse-atlas.png").exists());2363
}2396
}2364
let frame = run.join("male/body/clip000-bind/f000000");2397
let frame = run.join("male/body/clip000-bind/f000000");2365
assert!(frame.join("p-30_y000_diffuse.png").exists());2398
assert!(frame.join("p-60_y000_diffuse.png").exists());2366
assert_eq!(frame.join("p-30_y000_depth.png").exists(), !stop);2399
assert_eq!(frame.join("p-60_y000_depth.png").exists(), !stop);2367
assert!(!frame.join("p-30_y000_highres.png").exists());2400
assert!(!frame.join("p-60_y000_highres.png").exists());2368
assert!(!frame.join("p-30_y000_detail.png").exists());2401
assert!(!frame.join("p-60_y000_detail.png").exists());2369
if !stop {2402
if !stop {2370
assert_eq!(labels.len(), 216);2403
assert_eq!(labels.len(), 360);2371
for labels in labels.chunks_exact(6) {2404
for labels in labels.chunks_exact(6) {2372
for (label, pass) in labels.iter().zip([2405
for (label, pass) in labels.iter().zip([2373
"highres",2406
"highres",@@ -2491,7 +2524,7 @@ mod tests {2491
let (ok, _) = drive(resume_job(&reference_dir.0), RunTarget::Fresh, None);2524
let (ok, _) = drive(resume_job(&reference_dir.0), RunTarget::Fresh, None);2492
ok.unwrap();2525
ok.unwrap();2493
let reference = checksums(&runs(&reference_dir.0)[0]);2526
let reference = checksums(&runs(&reference_dir.0)[0]);2494
assert_eq!(reference.as_object().unwrap().len(), 144);2527
assert_eq!(reference.as_object().unwrap().len(), 240);2495
2528
2496
let tmp = Temp::new();2529
let tmp = Temp::new();2497
// An earlier completed run in the same output must stay byte-identical.2530
// An earlier completed run in the same output must stay byte-identical.@@ -2505,9 +2538,9 @@ mod tests {2505
let cancelled: serde_json::Value =2538
let cancelled: serde_json::Value =2506
serde_json::from_slice(&std::fs::read(run.join("cancelled.json")).unwrap()).unwrap();2539
serde_json::from_slice(&std::fs::read(run.join("cancelled.json")).unwrap()).unwrap();2507
let done_first = cancelled["png_files_done"].as_u64().unwrap();2540
let done_first = cancelled["png_files_done"].as_u64().unwrap();2508
assert!(done_first > 0 && done_first < 144);2541
assert!(done_first > 0 && done_first < 240);2509
assert_eq!(cancelled["attempt"], 0);2542
assert_eq!(cancelled["attempt"], 0);2510
let first_file = run.join("male/body/clip000-bind/f000000/p-30_y000_diffuse.png");2543
let first_file = run.join("male/body/clip000-bind/f000000/p-60_y000_diffuse.png");2511
let first_inode = inode(&first_file);2544
let first_inode = inode(&first_file);2512
assert!(crate::export_set::ExportRun::open(&run).is_err());2545
assert!(crate::export_set::ExportRun::open(&run).is_err());2513
2546
@@ -2588,11 +2621,11 @@ mod tests {2588
let torn_inode = inode(&torn_path);2621
let torn_inode = inode(&torn_path);2589
// Corrupt one journaled PNG, delete another, leave a foreign temp file.2622
// Corrupt one journaled PNG, delete another, leave a foreign temp file.2590
let frame = run.join("male/body/clip000-bind/f000000");2623
let frame = run.join("male/body/clip000-bind/f000000");2591
let corrupt = frame.join("p-30_y030_normal.png");2624
let corrupt = frame.join("p-60_y030_normal.png");2592
std::fs::write(&corrupt, b"not a png").unwrap();2625
std::fs::write(&corrupt, b"not a png").unwrap();2593
let missing = frame.join("p-30_y060_depth.png");2626
let missing = frame.join("p-60_y060_depth.png");2594
std::fs::remove_file(&missing).unwrap();2627
std::fs::remove_file(&missing).unwrap();2595
let stale = frame.join("p-30_y090_diffuse.part-999999-0");2628
let stale = frame.join("p-60_y090_diffuse.part-999999-0");2596
std::fs::write(&stale, b"partial").unwrap();2629
std::fs::write(&stale, b"partial").unwrap();2597
2630
2598
let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;2631
let doc = contract(resume_job(&tmp.0)).unwrap().render_doc;@@ -2606,7 +2639,7 @@ mod tests {2606
crate::export_set::ExportRun::open(&run).unwrap();2639
crate::export_set::ExportRun::open(&run).unwrap();2607
assert!(missing.exists());2640
assert!(missing.exists());2608
assert_eq!(2641
assert_eq!(2609
std::fs::read(frame.join("p-30_y030_normal.png.corrupt-a001-0")).unwrap(),2642
std::fs::read(frame.join("p-60_y030_normal.png.corrupt-a001-0")).unwrap(),2610
b"not a png"2643
b"not a png"2611
);2644
);2612
assert_eq!(std::fs::read(&stale).unwrap(), b"partial");2645
assert_eq!(std::fs::read(&stale).unwrap(), b"partial");@@ -2626,10 +2659,55 @@ mod tests {2626
assert_eq!(log["previous_marker"], serde_json::Value::Null);2659
assert_eq!(log["previous_marker"], serde_json::Value::Null);2627
assert_eq!(log["damaged_journal_lines"], 1);2660
assert_eq!(log["damaged_journal_lines"], 1);2628
assert_eq!(log["unjournaled_existing"], 1);2661
assert_eq!(log["unjournaled_existing"], 1);2629
assert_eq!(log["missing"].as_u64().unwrap(), 144 - 60 / 6 * 4 + 1);2662
assert_eq!(log["missing"].as_u64().unwrap(), 240 - 60 / 6 * 4 + 1);2630
assert_eq!(log["quarantined"].as_array().unwrap().len(), 1);2663
assert_eq!(log["quarantined"].as_array().unwrap().len(), 1);2631
}2664
}2632
2665
2666
#[test]2667
#[cfg(unix)]2668
fn crash_after_atlases_keeps_identical_and_quarantines_damaged_atlas() {2669
let reference_dir = Temp::new();2670
drive(resume_job(&reference_dir.0), RunTarget::Fresh, None)2671
.02672
.unwrap();2673
let reference_run = runs(&reference_dir.0)[0].clone();2674
let tmp = Temp::new();2675
drive(resume_job(&tmp.0), RunTarget::Fresh, None).0.unwrap();2676
let run = runs(&tmp.0)[0].clone();2677
// Simulate a crash after the atlases/.ppda were published but before2678
// complete.json, with one atlas damaged afterwards.2679
std::fs::remove_file(run.join("complete.json")).unwrap();2680
let diffuse = run.join("male-body-diffuse-atlas.png");2681
let normal = run.join("male-body-normal-atlas.png");2682
let normal_inode = inode(&normal);2683
let good = std::fs::read(&diffuse).unwrap();2684
std::fs::remove_file(&diffuse).unwrap();2685
std::fs::write(&diffuse, b"damaged").unwrap();2686
drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)2687
.02688
.unwrap();2689
assert_eq!(inode(&normal), normal_inode, "identical atlas rewritten");2690
assert_eq!(std::fs::read(&diffuse).unwrap(), good);2691
assert_eq!(2692
std::fs::read(run.join("male-body-diffuse-atlas.png.corrupt-a001-0")).unwrap(),2693
b"damaged"2694
);2695
crate::atlas::AtlasSet::open_run(&run).unwrap();2696
crate::export_set::ExportRun::open(&run).unwrap();2697
// Byte-identical to an uninterrupted run (deterministic encoding).2698
for name in [2699
"male-body-atlas.ppda",2700
"male-body-diffuse-atlas.png",2701
"male-body-depth-atlas.png",2702
] {2703
assert_eq!(2704
std::fs::read(run.join(name)).unwrap(),2705
std::fs::read(reference_run.join(name)).unwrap(),2706
"{name}"2707
);2708
}2709
}2710
2633
#[test]2711
#[test]2634
fn unjournaled_different_file_is_quarantined_not_overwritten() {2712
fn unjournaled_different_file_is_quarantined_not_overwritten() {2635
let tmp = Temp::new();2713
let tmp = Temp::new();@@ -2638,7 +2716,7 @@ mod tests {2638
.is_err());2716
.is_err());2639
let run = runs(&tmp.0)[0].clone();2717
let run = runs(&tmp.0)[0].clone();2640
// A planned PNG appears that the journal never recorded and that differs.2718
// A planned PNG appears that the journal never recorded and that differs.2641
let foreign = run.join("male/body/clip000-bind/f000000/p-30_y030_specular.png");2719
let foreign = run.join("male/body/clip000-bind/f000000/p-60_y030_specular.png");2642
std::fs::create_dir_all(foreign.parent().unwrap()).unwrap();2720
std::fs::create_dir_all(foreign.parent().unwrap()).unwrap();2643
std::fs::write(&foreign, b"foreign").unwrap();2721
std::fs::write(&foreign, b"foreign").unwrap();2644
drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)2722
drive(resume_job(&tmp.0), RunTarget::Resume(run.clone()), None)@@ -2646,7 +2724,7 @@ mod tests {2646
.unwrap();2724
.unwrap();2647
crate::export_set::ExportRun::open(&run).unwrap();2725
crate::export_set::ExportRun::open(&run).unwrap();2648
assert_eq!(2726
assert_eq!(2649
std::fs::read(foreign.with_file_name("p-30_y030_specular.png.corrupt-a001-0")).unwrap(),2727
std::fs::read(foreign.with_file_name("p-60_y030_specular.png.corrupt-a001-0")).unwrap(),2650
b"foreign"2728
b"foreign"2651
);2729
);2652
}2730
}@@ -242,13 +242,15 @@ mod tests {
242
242
#[test]
243
243
fn full_view_order_is_pitch_then_yaw_then_passes() {
244
244
let views = export_views();
245
assert_eq!(views.len(), 36);
246
assert_eq!(views[0].pitch_deg, -30.0);
245
assert_eq!(views.len(), 60);
246
assert_eq!(views[0].pitch_deg, -60.0);
247
247
assert_eq!(views[0].yaw_deg, 0.0);
248
248
assert_eq!(views[11].yaw_deg, 330.0);
249
assert_eq!(views[12].pitch_deg, 0.0);
249
assert_eq!(views[12].pitch_deg, -30.0);
250
assert_eq!(views[24].pitch_deg, 0.0);
251
assert_eq!(views[59].pitch_deg, 60.0);
250
252
let steps = pass_sequence(&views);
251
assert_eq!(steps.len(), 36 * 6);
253
assert_eq!(steps.len(), 60 * 6);
252
254
let first: Vec<_> = steps.iter().take(6).map(|step| step.kind).collect();
253
255
assert_eq!(first, PASS_ORDER.to_vec());
254
256
assert_eq!(steps[6].yaw_deg, 30.0);
@@ -242,13 +242,15 @@ mod tests {242
#[test]242
#[test]243
fn full_view_order_is_pitch_then_yaw_then_passes() {243
fn full_view_order_is_pitch_then_yaw_then_passes() {244
let views = export_views();244
let views = export_views();245
assert_eq!(views.len(), 36);245
assert_eq!(views.len(), 60);246
assert_eq!(views[0].pitch_deg, -30.0);246
assert_eq!(views[0].pitch_deg, -60.0);247
assert_eq!(views[0].yaw_deg, 0.0);247
assert_eq!(views[0].yaw_deg, 0.0);248
assert_eq!(views[11].yaw_deg, 330.0);248
assert_eq!(views[11].yaw_deg, 330.0);249
assert_eq!(views[12].pitch_deg, 0.0);249
assert_eq!(views[12].pitch_deg, -30.0);250
assert_eq!(views[24].pitch_deg, 0.0);251
assert_eq!(views[59].pitch_deg, 60.0);250
let steps = pass_sequence(&views);252
let steps = pass_sequence(&views);251
assert_eq!(steps.len(), 36 * 6);253
assert_eq!(steps.len(), 60 * 6);252
let first: Vec<_> = steps.iter().take(6).map(|step| step.kind).collect();254
let first: Vec<_> = steps.iter().take(6).map(|step| step.kind).collect();253
assert_eq!(first, PASS_ORDER.to_vec());255
assert_eq!(first, PASS_ORDER.to_vec());254
assert_eq!(steps[6].yaw_deg, 30.0);256
assert_eq!(steps[6].yaw_deg, 30.0);