BlueGit

golden-age paperdoll-renderer

Merge feature/new-project-defaults (Tim t1022u)

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 6 days ago

Hash
6d6aee96867fb4a9f5b7800f6b87874b5e3b821f
Pipeline
Passed
5 files changed +81 −11
README.md +1 −1
@@ -29,7 +29,7 @@ The first project view shows the bodies. **Choose male / Choose female** selects
29 29 - Pick an animation clip and use **Pause / Play**.
30 30 - **Yaw − / Yaw +** step through twelve 30° angles; **pitch -60 / -30 / 0 / 30 / 60** choose the height. Yaw 0 looks at the front of every model. The orientation is fixed by the glTF standard (+Y up, the front faces +Z, the left side is +X); models and animations load as-is, without automatic correction: yaw 0 = front (right hand on the left of the image), 90 = left side, 180 = back, 270 = right side; positive pitch looks from above. A test with labelled markers guards this convention.
31 31 - **Render all angles** renders all animations × all frames at project fps × 60 angles (5 pitches × 12 yaws). After the loose maps it writes one atlas per map type plus a `.ppda` file (see *Atlases and .ppda*). The preview is replaced immediately by every finished pass: highres → detail → target/diffuse → depth → normal → specular. All passes keep their aspect with centered letterboxing and nearest-neighbor display. Progress counts real passes; **Cancel render** keeps valid partial output. **Live 3D** returns to the model; a next finished render pass may replace the preview again.
32 - Set width, height and fps in the Bodies view. New projects start at 4 fps; an existing `.ppd` keeps its saved fps.
32 - Set width, height and fps in the Bodies view. New projects start at 92 × 128 pixels and 8 fps with 8-bit gray depth; an existing `.ppd` keeps its saved values. A new project with the default bodies already has **Fit animations** applied for both sexes.
33 33 - **Look for interrupted runs** (also in the equipment editor) lists cancelled, failed or aborted runs of the same body/equipment set with the number of recorded PNGs. **Resume** only continues if source files and settings are exactly equal to that run; otherwise the difference is shown. After cancelling, the run appears in this list immediately. See *Resuming* below.
34 34
35 35 ![Body view with animation and camera controls](docs/screenshots/body-live.png)
@@ -29,7 +29,7 @@ The first project view shows the bodies. **Choose male / Choose female** selects
29- Pick an animation clip and use **Pause / Play**.
29- Pick an animation clip and use **Pause / Play**.
30- **Yaw − / Yaw +** step through twelve 30° angles; **pitch -60 / -30 / 0 / 30 / 60** choose the height. Yaw 0 looks at the front of every model. The orientation is fixed by the glTF standard (+Y up, the front faces +Z, the left side is +X); models and animations load as-is, without automatic correction: yaw 0 = front (right hand on the left of the image), 90 = left side, 180 = back, 270 = right side; positive pitch looks from above. A test with labelled markers guards this convention.
30- **Yaw − / Yaw +** step through twelve 30° angles; **pitch -60 / -30 / 0 / 30 / 60** choose the height. Yaw 0 looks at the front of every model. The orientation is fixed by the glTF standard (+Y up, the front faces +Z, the left side is +X); models and animations load as-is, without automatic correction: yaw 0 = front (right hand on the left of the image), 90 = left side, 180 = back, 270 = right side; positive pitch looks from above. A test with labelled markers guards this convention.
31- **Render all angles** renders all animations × all frames at project fps × 60 angles (5 pitches × 12 yaws). After the loose maps it writes one atlas per map type plus a `.ppda` file (see *Atlases and .ppda*). The preview is replaced immediately by every finished pass: highres → detail → target/diffuse → depth → normal → specular. All passes keep their aspect with centered letterboxing and nearest-neighbor display. Progress counts real passes; **Cancel render** keeps valid partial output. **Live 3D** returns to the model; a next finished render pass may replace the preview again.
31- **Render all angles** renders all animations × all frames at project fps × 60 angles (5 pitches × 12 yaws). After the loose maps it writes one atlas per map type plus a `.ppda` file (see *Atlases and .ppda*). The preview is replaced immediately by every finished pass: highres → detail → target/diffuse → depth → normal → specular. All passes keep their aspect with centered letterboxing and nearest-neighbor display. Progress counts real passes; **Cancel render** keeps valid partial output. **Live 3D** returns to the model; a next finished render pass may replace the preview again.
32- Set width, height and fps in the Bodies view. New projects start at 4 fps; an existing `.ppd` keeps its saved fps.
32- Set width, height and fps in the Bodies view. New projects start at 92 × 128 pixels and 8 fps with 8-bit gray depth; an existing `.ppd` keeps its saved values. A new project with the default bodies already has **Fit animations** applied for both sexes.
33- **Look for interrupted runs** (also in the equipment editor) lists cancelled, failed or aborted runs of the same body/equipment set with the number of recorded PNGs. **Resume** only continues if source files and settings are exactly equal to that run; otherwise the difference is shown. After cancelling, the run appears in this list immediately. See *Resuming* below.
33- **Look for interrupted runs** (also in the equipment editor) lists cancelled, failed or aborted runs of the same body/equipment set with the number of recorded PNGs. **Resume** only continues if source files and settings are exactly equal to that run; otherwise the difference is shown. After cancelling, the run appears in this list immediately. See *Resuming* below.
34
34
35![Body view with animation and camera controls](docs/screenshots/body-live.png)
35![Body view with animation and camera controls](docs/screenshots/body-live.png)
src/atlas.rs +1 −1
@@ -1256,7 +1256,7 @@ mod tests {
1256 1256 assert_eq!((p.identity.near, p.identity.far), (near, far));
1257 1257 assert_eq!(p.depth_encoding, "gray");
1258 1258 assert_eq!(p.depth_bits, Some(16));
1259 assert_eq!(p.fps, 4);
1259 assert_eq!(p.fps, crate::project::DEFAULT_FPS);
1260 1260 assert_eq!((p.cell_width, p.cell_height), (3, 2));
1261 1261 assert_eq!(p.pitches, [-60, -30, 0, 30, 60]);
1262 1262 assert_eq!(p.yaws.len(), 12);
@@ -1256,7 +1256,7 @@ mod tests {
1256 assert_eq!((p.identity.near, p.identity.far), (near, far));
1256 assert_eq!((p.identity.near, p.identity.far), (near, far));
1257 assert_eq!(p.depth_encoding, "gray");
1257 assert_eq!(p.depth_encoding, "gray");
1258 assert_eq!(p.depth_bits, Some(16));
1258 assert_eq!(p.depth_bits, Some(16));
1259 assert_eq!(p.fps, 4);
1259 assert_eq!(p.fps, crate::project::DEFAULT_FPS);
1260 assert_eq!((p.cell_width, p.cell_height), (3, 2));
1260 assert_eq!((p.cell_width, p.cell_height), (3, 2));
1261 assert_eq!(p.pitches, [-60, -30, 0, 30, 60]);
1261 assert_eq!(p.pitches, [-60, -30, 0, 30, 60]);
1262 assert_eq!(p.yaws.len(), 12);
1262 assert_eq!(p.yaws.len(), 12);
src/preview2d.rs +1 −1
@@ -1333,7 +1333,7 @@ mod tests {
1333 1333 assert_eq!(state.pitch_deg(), -60);
1334 1334 assert_eq!(pitch_row_from_deg(30), 3);
1335 1335 assert_eq!(pitch_row_from_deg(7), ZERO_PITCH_ROW);
1336 assert_eq!(state.fps, 4);
1336 assert_eq!(state.fps, crate::project::DEFAULT_FPS);
1337 1337 }
1338 1338
1339 1339 /// Regression (t964u): a light button held for several seconds keeps
@@ -1333,7 +1333,7 @@ mod tests {
1333 assert_eq!(state.pitch_deg(), -60);
1333 assert_eq!(state.pitch_deg(), -60);
1334 assert_eq!(pitch_row_from_deg(30), 3);
1334 assert_eq!(pitch_row_from_deg(30), 3);
1335 assert_eq!(pitch_row_from_deg(7), ZERO_PITCH_ROW);
1335 assert_eq!(pitch_row_from_deg(7), ZERO_PITCH_ROW);
1336 assert_eq!(state.fps, 4);
1336 assert_eq!(state.fps, crate::project::DEFAULT_FPS);
1337 }
1337 }
1338
1338
1339 /// Regression (t964u): a light button held for several seconds keeps
1339 /// Regression (t964u): a light button held for several seconds keeps
src/project.rs +18 −8
@@ -95,8 +95,8 @@ fn default_depth_bits() -> u8 {
95 95 impl Default for RenderSettings {
96 96 fn default() -> Self {
97 97 Self {
98 target_width: 128,
99 target_height: 128,
98 target_width: DEFAULT_TARGET[0],
99 target_height: DEFAULT_TARGET[1],
100 100 fps: DEFAULT_FPS,
101 101 framing: [CameraFraming::default(); 2],
102 102 depth_bits: 8,
@@ -105,8 +105,10 @@ impl Default for RenderSettings {
105 105 }
106 106 }
107 107
108 /// Framerate for new projects (t955u). Existing `.ppd` files keep their stored fps.
109 pub const DEFAULT_FPS: u32 = 4;
108 /// Framerate for new projects (t1022u). Existing `.ppd` files keep their stored fps.
109 pub const DEFAULT_FPS: u32 = 8;
110 /// Target size for new projects (t1022u).
111 pub const DEFAULT_TARGET: [u32; 2] = [92, 128];
110 112
111 113 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112 114 #[serde(deny_unknown_fields)]
@@ -1016,10 +1018,18 @@ mod tests {
1016 1018 mod framing_tests {
1017 1019 use super::*;
1018 1020 #[test]
1019 fn new_projects_default_to_4_fps_and_stored_fps_is_kept() {
1020 assert_eq!(DEFAULT_FPS, 4);
1021 assert_eq!(RenderSettings::default().fps, 4);
1022 assert_eq!(ProjectFile::new_empty("Nieuw").render.fps, 4);
1021 fn new_projects_default_to_8_fps_92x128_and_stored_fps_is_kept() {
1022 assert_eq!(DEFAULT_FPS, 8);
1023 assert_eq!(RenderSettings::default().fps, 8);
1024 assert_eq!(
1025 (
1026 RenderSettings::default().target_width,
1027 RenderSettings::default().target_height
1028 ),
1029 (92, 128)
1030 );
1031 assert_eq!(RenderSettings::default().depth_bits, 8);
1032 assert_eq!(ProjectFile::new_empty("Nieuw").render.fps, 8);
1023 1033 let mut stored = ProjectFile::new_empty("Oud");
1024 1034 stored.render.fps = 12;
1025 1035 let text = serde_json::to_string(&stored).unwrap();
@@ -95,8 +95,8 @@ fn default_depth_bits() -> u8 {
95impl Default for RenderSettings {
95impl Default for RenderSettings {
96 fn default() -> Self {
96 fn default() -> Self {
97 Self {
97 Self {
98 target_width: 128,
98 target_width: DEFAULT_TARGET[0],
99 target_height: 128,
99 target_height: DEFAULT_TARGET[1],
100 fps: DEFAULT_FPS,
100 fps: DEFAULT_FPS,
101 framing: [CameraFraming::default(); 2],
101 framing: [CameraFraming::default(); 2],
102 depth_bits: 8,
102 depth_bits: 8,
@@ -105,8 +105,10 @@ impl Default for RenderSettings {
105 }
105 }
106}
106}
107
107
108/// Framerate for new projects (t955u). Existing `.ppd` files keep their stored fps.
108/// Framerate for new projects (t1022u). Existing `.ppd` files keep their stored fps.
109pub const DEFAULT_FPS: u32 = 4;
109pub const DEFAULT_FPS: u32 = 8;
110/// Target size for new projects (t1022u).
111pub const DEFAULT_TARGET: [u32; 2] = [92, 128];
110
112
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[serde(deny_unknown_fields)]
114#[serde(deny_unknown_fields)]
@@ -1016,10 +1018,18 @@ mod tests {
1016mod framing_tests {
1018mod framing_tests {
1017 use super::*;
1019 use super::*;
1018 #[test]
1020 #[test]
1019 fn new_projects_default_to_4_fps_and_stored_fps_is_kept() {
1021 fn new_projects_default_to_8_fps_92x128_and_stored_fps_is_kept() {
1020 assert_eq!(DEFAULT_FPS, 4);
1022 assert_eq!(DEFAULT_FPS, 8);
1021 assert_eq!(RenderSettings::default().fps, 4);
1023 assert_eq!(RenderSettings::default().fps, 8);
1022 assert_eq!(ProjectFile::new_empty("Nieuw").render.fps, 4);
1024 assert_eq!(
1025 (
1026 RenderSettings::default().target_width,
1027 RenderSettings::default().target_height
1028 ),
1029 (92, 128)
1030 );
1031 assert_eq!(RenderSettings::default().depth_bits, 8);
1032 assert_eq!(ProjectFile::new_empty("Nieuw").render.fps, 8);
1023 let mut stored = ProjectFile::new_empty("Oud");
1033 let mut stored = ProjectFile::new_empty("Oud");
1024 stored.render.fps = 12;
1034 stored.render.fps = 12;
1025 let text = serde_json::to_string(&stored).unwrap();
1035 let text = serde_json::to_string(&stored).unwrap();
src/session.rs +60 −0
@@ -468,6 +468,32 @@ pub fn create_default_project_named(
468 468 }
469 469 opened.document.male_model = Some(crate::default_bodies::MALE_MODEL.to_string());
470 470 opened.document.female_model = Some(crate::default_bodies::FEMALE_MODEL.to_string());
471 // Fit animations already applied (t1022u): the one-shot envelope of the
472 // editor button, per sex, for the bundled bodies and project defaults.
473 let render = &opened.document.render;
474 let aspect = render.target_width.max(1) as f32 / render.target_height.max(1) as f32;
475 let fps = render.fps;
476 for (index, model) in [
477 crate::default_bodies::MALE_MODEL,
478 crate::default_bodies::FEMALE_MODEL,
479 ]
480 .into_iter()
481 .enumerate()
482 {
483 let body = crate::preview::load_body_file(&root.join(model)).map_err(|message| {
484 ProjectError::Io {
485 path: model.to_string(),
486 message,
487 }
488 })?;
489 let camera = &mut opened.document.render.framing[index];
490 if let Some((h, c)) =
491 crate::camera::animation_envelope(&body, *camera, aspect, fps, 0.05, 0.)
492 {
493 camera.height_milli = h.clamp(100, 10_000);
494 camera.center_y_milli = c.clamp(-10_000, 10_000);
495 }
496 }
471 497 // Bundled female hair preset (t1019u), listed in the equipment presets.
472 498 let path = checked_project_path(root, crate::default_bodies::FEMALE_HAIR_PRESET)
473 499 .map_err(|error| ProjectError::Validation(vec![error]))?;
@@ -912,6 +938,40 @@ mod tests {
912 938 (hi - 1.621).abs() < 0.002 && (1.2..1.35).contains(&lo),
913 939 "hair Y {lo}..{hi}"
914 940 );
941 // Defaults (t1022u) and Fit animations already applied per sex.
942 let render = &document.render;
943 assert_eq!(
944 (render.target_width, render.target_height, render.fps),
945 (92, 128, 8)
946 );
947 for (index, model) in [
948 crate::default_bodies::MALE_MODEL,
949 crate::default_bodies::FEMALE_MODEL,
950 ]
951 .into_iter()
952 .enumerate()
953 {
954 let body = crate::preview::load_body_file(&root.join(model)).unwrap();
955 let fitted = crate::camera::animation_envelope(
956 &body,
957 crate::project::CameraFraming::default(),
958 92. / 128.,
959 8,
960 0.05,
961 0.,
962 )
963 .unwrap();
964 let stored = render.framing[index];
965 assert_eq!(
966 (stored.height_milli, stored.center_y_milli),
967 fitted,
968 "{model}"
969 );
970 assert!(
971 (1500..2300).contains(&stored.height_milli),
972 "{model} {stored:?}"
973 );
974 }
915 975 // The saved manifest reopens with the same bodies.
916 976 let reopened = crate::project::open_project(&root.join("character.ppd")).unwrap();
917 977 assert_eq!(
@@ -468,6 +468,32 @@ pub fn create_default_project_named(
468 }
468 }
469 opened.document.male_model = Some(crate::default_bodies::MALE_MODEL.to_string());
469 opened.document.male_model = Some(crate::default_bodies::MALE_MODEL.to_string());
470 opened.document.female_model = Some(crate::default_bodies::FEMALE_MODEL.to_string());
470 opened.document.female_model = Some(crate::default_bodies::FEMALE_MODEL.to_string());
471 // Fit animations already applied (t1022u): the one-shot envelope of the
472 // editor button, per sex, for the bundled bodies and project defaults.
473 let render = &opened.document.render;
474 let aspect = render.target_width.max(1) as f32 / render.target_height.max(1) as f32;
475 let fps = render.fps;
476 for (index, model) in [
477 crate::default_bodies::MALE_MODEL,
478 crate::default_bodies::FEMALE_MODEL,
479 ]
480 .into_iter()
481 .enumerate()
482 {
483 let body = crate::preview::load_body_file(&root.join(model)).map_err(|message| {
484 ProjectError::Io {
485 path: model.to_string(),
486 message,
487 }
488 })?;
489 let camera = &mut opened.document.render.framing[index];
490 if let Some((h, c)) =
491 crate::camera::animation_envelope(&body, *camera, aspect, fps, 0.05, 0.)
492 {
493 camera.height_milli = h.clamp(100, 10_000);
494 camera.center_y_milli = c.clamp(-10_000, 10_000);
495 }
496 }
471 // Bundled female hair preset (t1019u), listed in the equipment presets.
497 // Bundled female hair preset (t1019u), listed in the equipment presets.
472 let path = checked_project_path(root, crate::default_bodies::FEMALE_HAIR_PRESET)
498 let path = checked_project_path(root, crate::default_bodies::FEMALE_HAIR_PRESET)
473 .map_err(|error| ProjectError::Validation(vec![error]))?;
499 .map_err(|error| ProjectError::Validation(vec![error]))?;
@@ -912,6 +938,40 @@ mod tests {
912 (hi - 1.621).abs() < 0.002 && (1.2..1.35).contains(&lo),
938 (hi - 1.621).abs() < 0.002 && (1.2..1.35).contains(&lo),
913 "hair Y {lo}..{hi}"
939 "hair Y {lo}..{hi}"
914 );
940 );
941 // Defaults (t1022u) and Fit animations already applied per sex.
942 let render = &document.render;
943 assert_eq!(
944 (render.target_width, render.target_height, render.fps),
945 (92, 128, 8)
946 );
947 for (index, model) in [
948 crate::default_bodies::MALE_MODEL,
949 crate::default_bodies::FEMALE_MODEL,
950 ]
951 .into_iter()
952 .enumerate()
953 {
954 let body = crate::preview::load_body_file(&root.join(model)).unwrap();
955 let fitted = crate::camera::animation_envelope(
956 &body,
957 crate::project::CameraFraming::default(),
958 92. / 128.,
959 8,
960 0.05,
961 0.,
962 )
963 .unwrap();
964 let stored = render.framing[index];
965 assert_eq!(
966 (stored.height_milli, stored.center_y_milli),
967 fitted,
968 "{model}"
969 );
970 assert!(
971 (1500..2300).contains(&stored.height_milli),
972 "{model} {stored:?}"
973 );
974 }
915 // The saved manifest reopens with the same bodies.
975 // The saved manifest reopens with the same bodies.
916 let reopened = crate::project::open_project(&root.join("character.ppd")).unwrap();
976 let reopened = crate::project::open_project(&root.join("character.ppd")).unwrap();
917 assert_eq!(
977 assert_eq!(