BlueGit

golden-age paperdoll-renderer

WIP: Preview2d via .ppda atlases, synthetic export fixture, tests for 60 views

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 7 days ago

Hash
4a5fbc5cd46d57e464fa0daacd78bd64e581f908
Parent
bcc9bf42
9 files changed +763 −606
src/compositor.rs +87 −168
@@ -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
155fn expect_rgba(image: &MapImage, kind: &str) -> Result<(u32, u32, Vec<u8>), ComposeError> {
171fn expect_rgba(image: &MapImage, kind: &str) -> Result<(u32, u32, Vec<u8>), ComposeError> {
@@ -213,14 +229,40 @@ impl CompatKey {
213 }
229 }
214}
230}
215
231
232impl 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/// Reject runs that cannot be depth-composited together.
253/// Reject runs that cannot be depth-composited together.
217pub fn assert_compatible(runs: &[&ExportRun]) -> Result<(), ComposeError> {
254pub 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.
260pub 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 {
505mod tests {
547mod 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 map
921 }
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": 4
962 });
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": checksums
978 }),
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();
src/editor.rs +63 −12
@@ -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};
12use crate::preview2d::{
12use 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};
17use crate::preview_gl::Viewport;
17use crate::preview_gl::Viewport;
18use crate::project::open_project;
18use 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).
59struct PreviewSelection {
60 paths: Vec<std::path::PathBuf>,
61 body: crate::atlas::LoadedAtlas,
62 gear: Vec<crate::atlas::LoadedAtlas>,
63}
64
54impl EditorApp {
65impl 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 = 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 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");
src/export_fixture.rs added +223 −0
@@ -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-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.
7use 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};
15use std::{collections::BTreeMap, path::Path, sync::atomic::AtomicBool};
16
17/// Uniform per-cell map values.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub 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)]
27pub 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
40impl 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
220fn 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}
src/export_set.rs +35 −30
@@ -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 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 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": checksums
1042 "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 = run
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 .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 = run
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 .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": checksums
1091 "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": checksums
1118 "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": checksums
1150 "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": checksums
1192 "png_sha256": checksums,
1193 "atlas": atlas_ref()
1189 }),
1194 }),
1190 );
1195 );
1191 assert!(matches!(
1196 assert!(matches!(
src/lib.rs +2 −0
@@ -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;
14pub mod digest;
14pub mod digest;
15pub mod editor;
15pub mod editor;
16pub mod equipment;
16pub mod equipment;
17#[doc(hidden)]
18pub mod export_fixture;
17pub mod export_set;
19pub mod export_set;
18pub mod fbx_scene;
20pub mod fbx_scene;
19pub mod file_assoc;
21pub mod file_assoc;
src/preview.rs +6 −3
@@ -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");
src/preview2d.rs +232 −358
@@ -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 atlases
4//! (`atlas::AtlasSet`, the validating consumer reader; Tim t957u) and
5//! `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
6use std::collections::BTreeMap;
8use std::collections::BTreeMap;
7use std::fs;
9use std::fs;
8use std::path::{Path, PathBuf};
10use std::path::{Path, PathBuf};
9
11
12use crate::atlas::{AtlasSet, LoadedAtlas};
10use crate::compositor::{
13use 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};
14use crate::export_set::{ExportError, ExportRun};
17use crate::export_set::ExportError;
15use crate::preview::{PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT};
18use crate::preview::{PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT};
16use crate::render_job::ImageBuf;
19use crate::render_job::ImageBuf;
17use crate::session::Sex;
20use 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)]
141pub struct CatalogEntry {
144pub 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
154impl CatalogEntry {
158impl 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: run
168 clips: p
164 .render
165 .clips
169 .clips
166 .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: run
174 equipment_preset_sha256: p.identity.equipment_preset_sha256.clone(),
171 .render
175 key: CompatKey::from_ppda(p),
172 .equipment_preset
173 .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.
185pub 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.
185pub fn discover_completed_runs(project_root: &Path) -> Vec<CatalogEntry> {
190pub 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.
195pub 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 out
213 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)]
457pub struct ComposeRequest<'a> {
466pub 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.
519pub fn compose_preview_frame(req: ComposeRequest<'_>) -> Result<ImageBuf, Preview2dError> {
528pub 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> = atlases
524 }
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, Previe
571 }
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) that
575pub fn open_selection(
564/// [`open_selection`] would load; used as a cache key by the editor.
565pub 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 = catalog
574 let body = catalog
585 .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 = catalog
581 let entry = catalog
593 .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 one
599/// sex, re-validating checksums and compatibility.
600pub 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/// 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: &[CatalogEnt
679mod tests {
686mod 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 map
799 }
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<_> = clips
747 spec.framing = framing();
748 spec.set = set.into();
749 spec.equipment_sha256 = equipment_sha.map(str::to_string);
750 spec.clips = clips
814 .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": duration
821 })
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_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 );
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 depth
1234 ));
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 ..base
1101 ..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 ..base
1313 })
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.json
1131 // no .ppda at all
1326 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 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}
src/production.rs +109 −31
@@ -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.
1172pub(crate) fn publish_bytes(root: &Path, relative: &Path, bytes: &[u8]) -> Result<(), String> {
1172pub 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: 0
1917 yaw: 0
1917 }
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 expected
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 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 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 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 .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 #[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 }
src/render_job.rs +6 −4
@@ -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);