New projects: 92x128 @ 8 fps; default-body projects start with Fit animations applied (Tim t1022u)
@@ -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

@@ -29,7 +29,7 @@ The first project view shows the bodies. **Choose male / Choose female** selects29
- 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
35
@@ -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);@@ -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 keeps1339
/// Regression (t964u): a light button held for several seconds keeps@@ -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 {95
impl Default for RenderSettings {95
impl 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.109
pub const DEFAULT_FPS: u32 = 4;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
#[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 {1016
mod framing_tests {1018
mod 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_height1028
),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();@@ -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 the472
// 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!(