BlueGit

golden-age paperdoll-renderer

Orbit pivot at rest-pose vertical centre (bounds_center), weaker free-view perspective, render warnings kept, depth_clamp_probe (Tim t1008u)

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 6 days ago

Hash
a4d27fd9364c03a5be4ad3bf3205fa10ebb99208
Parent
418294c6
14 files changed +243 −140
README.md +5 −3
@@ -25,7 +25,7 @@ The first project view shows the bodies. **Choose male / Choose female** selects
25 25
26 26 **Open male / Open female** opens the body. A skinned glTF/GLB or FBX model, or a static body (see below), is shown in the live preview:
27 27
28 - Drag to orbit. **Show fixed render frame** shows the yellow orthographic export frame; set **Fixed render height**, **Center Y** (image center) and **Pivot Y** (orbit point) per male/female and save. Without a manual pivot the editor uses the rest-pose neck height (or a fallback with a diagnostic); **Auto neck** clears a manual override. **Fit animations** computes once a render height and Center Y that contain all clips, all frames (at project fps) and all 60 angles, with a 5% margin on each side; the pivot stays. Afterwards the frame is fixed again: there is no automatic zoom per frame, clip or layer. Equipment that sticks out beyond the body needs extra height set by hand. Body and equipment share exactly the same camera. Unchecking gives a free perspective preview with scroll zoom; that zoom does not change the export. A real 24-bit depth buffer keeps front surfaces in front.
28 - Drag to orbit. **Show fixed render frame** shows the yellow orthographic export frame; set **Fixed render height**, **Center Y** (image center) and **Pivot Y** (orbit point) per male/female and save. Without a manual pivot the camera orbits around the vertical centre of the model's rest-pose bounds (or **Center Y** as a fallback with a diagnostic when there is no geometry); **Auto centre** clears a manual override. The fixed frame and production are orthographic, so there is no perspective distortion at any pitch. **Fit animations** computes once a render height and Center Y that contain all clips, all frames (at project fps) and all 60 angles, with a 5% margin on each side; the pivot stays. Afterwards the frame is fixed again: there is no automatic zoom per frame, clip or layer. Equipment that sticks out beyond the body needs extra height set by hand. Body and equipment share exactly the same camera. Unchecking gives a free perspective preview with scroll zoom, seen from 2.5× further away with a narrower field of view (≈15.4°) for weak perspective; that zoom does not change the export. A real 24-bit depth buffer keeps front surfaces in front.
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.
@@ -44,7 +44,7 @@ A body without a skin can also be chosen as male or female model: **OBJ+MTL** (w
44 44
45 45 - OBJ and FBX go through the same rigid reader as equipment (FBX with the same axis/unit normalization, once); for glTF the scene's node transforms are baked into the geometry once (mirrored transforms flip the winding). Animations in a glTF without skin are not applied; the message says how many.
46 46 - The body view reports that the body is static. The animation choice and **Play/Pause** are disabled.
47 - **Pivot Y:** without a neck bone the camera uses **Center Y** as orbit point (diagnostic *Static body without skeleton*); a manual **Pivot Y** works as for skinned bodies. Fixed render height/zoom per body stays the same.
47 - **Pivot Y:** as for skinned bodies, the orbit point is the vertical centre of the stored geometry; a manual **Pivot Y** overrides it. Fixed render height/zoom per body stays the same.
48 48 - **Render all angles** makes one bind-pose sample `clip000-bind` × 60 angles with the same four maps (diffuse as unlit albedo, normal, depth, specular) at target resolution, plus atlases and `.ppda`; the set opens through `export_set` and the 2D preview.
49 49 - Equipment needs a bone: in the equipment editor **Skeleton / bones** and **Render equipment · all angles** are disabled with an explanation, and the live view draws no equipment on a static body. An equipment render on a static body is refused.
50 50
@@ -191,7 +191,7 @@ let body = AtlasSet::open_run(&body_run_dir)?.load()?;
191 191 let layer = LayerMaps::from_atlas(&body, "body", "clip000-walk", 0, 60, 330)?;
192 192 ```
193 193
194 Highres is exactly 8× on both axes; batch targets are currently **1–256 pixels per axis** (highres max 2048). Larger project settings give a clear render error, not silently reduced supersampling. At most 500000 PNGs / 16 GiB of estimated uncompressed target data per run; output requires hard links. Production renders all glTF/GLB meshes with triangle primitives (see *Multiple meshes and skins*), base color factor × 8-bit albedo textures per material, and vertex colors. An FBX body goes through the normalized scene (`fbx_scene`) in the same batch: all animation stacks × frames × 60 angles, the same fixed camera, neck pivot and fixed zoom per body, and the same four maps. Equipment hangs on the animated bones of that FBX skeleton. Static bodies render one bind-pose sample per angle (see *Static bodies*). Live placement remains a lit, simplified view.
194 Highres is exactly 8× on both axes; batch targets are currently **1–256 pixels per axis** (highres max 2048). Larger project settings give a clear render error, not silently reduced supersampling. At most 500000 PNGs / 16 GiB of estimated uncompressed target data per run; output requires hard links. Production renders all glTF/GLB meshes with triangle primitives (see *Multiple meshes and skins*), base color factor × 8-bit albedo textures per material, and vertex colors. An FBX body goes through the normalized scene (`fbx_scene`) in the same batch: all animation stacks × frames × 60 angles, the same fixed camera, centre pivot and fixed zoom per body, and the same four maps. Equipment hangs on the animated bones of that FBX skeleton. Static bodies render one bind-pose sample per angle (see *Static bodies*). Live placement remains a lit, simplified view.
195 195
196 196 ## Open with
197 197
@@ -224,6 +224,8 @@ xvfb-run -a cargo run --example fbx_live_fixture --locked -- /tmp/fbx-live /tmp/
224 224 # Multi-material glTF on a real GPU: external .bin + PNG textures, three materials (factor, texture, texture × factor);
225 225 # screenshots the live view and checks every material colour in the production diffuse
226 226 xvfb-run -a -s '-screen 0 1280x1024x24' cargo run --release --example textured_body_fixture --locked -- /tmp/tex /tmp/tex-proof tests/fixtures/bodies/multi-material body.gltf --expect-multi-material
227 # Depth range check on the CPU: per pitch the depth extent of every clip frame and view versus near/far
228 cargo run --release --example depth_clamp_probe --locked -- tests/fixtures/bodies/test-body-male.glb 2600 1000 4
227 229 # Static bodies on a real GPU: OBJ male and glTF female without skin, live view, equipment refusal, production and export_set
228 230 xvfb-run -a -s '-screen 0 1280x1024x24' cargo run --example static_body_fixture --locked -- /tmp/static-body /tmp/static-body-proof 32 48 4
229 231 ```
@@ -25,7 +25,7 @@ The first project view shows the bodies. **Choose male / Choose female** selects
25
25
26**Open male / Open female** opens the body. A skinned glTF/GLB or FBX model, or a static body (see below), is shown in the live preview:
26**Open male / Open female** opens the body. A skinned glTF/GLB or FBX model, or a static body (see below), is shown in the live preview:
27
27
28- Drag to orbit. **Show fixed render frame** shows the yellow orthographic export frame; set **Fixed render height**, **Center Y** (image center) and **Pivot Y** (orbit point) per male/female and save. Without a manual pivot the editor uses the rest-pose neck height (or a fallback with a diagnostic); **Auto neck** clears a manual override. **Fit animations** computes once a render height and Center Y that contain all clips, all frames (at project fps) and all 60 angles, with a 5% margin on each side; the pivot stays. Afterwards the frame is fixed again: there is no automatic zoom per frame, clip or layer. Equipment that sticks out beyond the body needs extra height set by hand. Body and equipment share exactly the same camera. Unchecking gives a free perspective preview with scroll zoom; that zoom does not change the export. A real 24-bit depth buffer keeps front surfaces in front.
28- Drag to orbit. **Show fixed render frame** shows the yellow orthographic export frame; set **Fixed render height**, **Center Y** (image center) and **Pivot Y** (orbit point) per male/female and save. Without a manual pivot the camera orbits around the vertical centre of the model's rest-pose bounds (or **Center Y** as a fallback with a diagnostic when there is no geometry); **Auto centre** clears a manual override. The fixed frame and production are orthographic, so there is no perspective distortion at any pitch. **Fit animations** computes once a render height and Center Y that contain all clips, all frames (at project fps) and all 60 angles, with a 5% margin on each side; the pivot stays. Afterwards the frame is fixed again: there is no automatic zoom per frame, clip or layer. Equipment that sticks out beyond the body needs extra height set by hand. Body and equipment share exactly the same camera. Unchecking gives a free perspective preview with scroll zoom, seen from 2.5× further away with a narrower field of view (≈15.4°) for weak perspective; that zoom does not change the export. A real 24-bit depth buffer keeps front surfaces in front.
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.
@@ -44,7 +44,7 @@ A body without a skin can also be chosen as male or female model: **OBJ+MTL** (w
44
44
45- OBJ and FBX go through the same rigid reader as equipment (FBX with the same axis/unit normalization, once); for glTF the scene's node transforms are baked into the geometry once (mirrored transforms flip the winding). Animations in a glTF without skin are not applied; the message says how many.
45- OBJ and FBX go through the same rigid reader as equipment (FBX with the same axis/unit normalization, once); for glTF the scene's node transforms are baked into the geometry once (mirrored transforms flip the winding). Animations in a glTF without skin are not applied; the message says how many.
46- The body view reports that the body is static. The animation choice and **Play/Pause** are disabled.
46- The body view reports that the body is static. The animation choice and **Play/Pause** are disabled.
47- **Pivot Y:** without a neck bone the camera uses **Center Y** as orbit point (diagnostic *Static body without skeleton*); a manual **Pivot Y** works as for skinned bodies. Fixed render height/zoom per body stays the same.
47- **Pivot Y:** as for skinned bodies, the orbit point is the vertical centre of the stored geometry; a manual **Pivot Y** overrides it. Fixed render height/zoom per body stays the same.
48- **Render all angles** makes one bind-pose sample `clip000-bind` × 60 angles with the same four maps (diffuse as unlit albedo, normal, depth, specular) at target resolution, plus atlases and `.ppda`; the set opens through `export_set` and the 2D preview.
48- **Render all angles** makes one bind-pose sample `clip000-bind` × 60 angles with the same four maps (diffuse as unlit albedo, normal, depth, specular) at target resolution, plus atlases and `.ppda`; the set opens through `export_set` and the 2D preview.
49- Equipment needs a bone: in the equipment editor **Skeleton / bones** and **Render equipment · all angles** are disabled with an explanation, and the live view draws no equipment on a static body. An equipment render on a static body is refused.
49- Equipment needs a bone: in the equipment editor **Skeleton / bones** and **Render equipment · all angles** are disabled with an explanation, and the live view draws no equipment on a static body. An equipment render on a static body is refused.
50
50
@@ -191,7 +191,7 @@ let body = AtlasSet::open_run(&body_run_dir)?.load()?;
191let layer = LayerMaps::from_atlas(&body, "body", "clip000-walk", 0, 60, 330)?;
191let layer = LayerMaps::from_atlas(&body, "body", "clip000-walk", 0, 60, 330)?;
192```
192```
193
193
194Highres is exactly 8× on both axes; batch targets are currently **1–256 pixels per axis** (highres max 2048). Larger project settings give a clear render error, not silently reduced supersampling. At most 500000 PNGs / 16 GiB of estimated uncompressed target data per run; output requires hard links. Production renders all glTF/GLB meshes with triangle primitives (see *Multiple meshes and skins*), base color factor × 8-bit albedo textures per material, and vertex colors. An FBX body goes through the normalized scene (`fbx_scene`) in the same batch: all animation stacks × frames × 60 angles, the same fixed camera, neck pivot and fixed zoom per body, and the same four maps. Equipment hangs on the animated bones of that FBX skeleton. Static bodies render one bind-pose sample per angle (see *Static bodies*). Live placement remains a lit, simplified view.
194Highres is exactly 8× on both axes; batch targets are currently **1–256 pixels per axis** (highres max 2048). Larger project settings give a clear render error, not silently reduced supersampling. At most 500000 PNGs / 16 GiB of estimated uncompressed target data per run; output requires hard links. Production renders all glTF/GLB meshes with triangle primitives (see *Multiple meshes and skins*), base color factor × 8-bit albedo textures per material, and vertex colors. An FBX body goes through the normalized scene (`fbx_scene`) in the same batch: all animation stacks × frames × 60 angles, the same fixed camera, centre pivot and fixed zoom per body, and the same four maps. Equipment hangs on the animated bones of that FBX skeleton. Static bodies render one bind-pose sample per angle (see *Static bodies*). Live placement remains a lit, simplified view.
195
195
196## Open with
196## Open with
197
197
@@ -224,6 +224,8 @@ xvfb-run -a cargo run --example fbx_live_fixture --locked -- /tmp/fbx-live /tmp/
224# Multi-material glTF on a real GPU: external .bin + PNG textures, three materials (factor, texture, texture × factor);
224# Multi-material glTF on a real GPU: external .bin + PNG textures, three materials (factor, texture, texture × factor);
225# screenshots the live view and checks every material colour in the production diffuse
225# screenshots the live view and checks every material colour in the production diffuse
226xvfb-run -a -s '-screen 0 1280x1024x24' cargo run --release --example textured_body_fixture --locked -- /tmp/tex /tmp/tex-proof tests/fixtures/bodies/multi-material body.gltf --expect-multi-material
226xvfb-run -a -s '-screen 0 1280x1024x24' cargo run --release --example textured_body_fixture --locked -- /tmp/tex /tmp/tex-proof tests/fixtures/bodies/multi-material body.gltf --expect-multi-material
227# Depth range check on the CPU: per pitch the depth extent of every clip frame and view versus near/far
228cargo run --release --example depth_clamp_probe --locked -- tests/fixtures/bodies/test-body-male.glb 2600 1000 4
227# Static bodies on a real GPU: OBJ male and glTF female without skin, live view, equipment refusal, production and export_set
229# Static bodies on a real GPU: OBJ male and glTF female without skin, live view, equipment refusal, production and export_set
228xvfb-run -a -s '-screen 0 1280x1024x24' cargo run --example static_body_fixture --locked -- /tmp/static-body /tmp/static-body-proof 32 48 4
230xvfb-run -a -s '-screen 0 1280x1024x24' cargo run --example static_body_fixture --locked -- /tmp/static-body /tmp/static-body-proof 32 48 4
229```
231```
examples/depth_clamp_probe.rs added +22 −0
@@ -0,0 +1,22 @@
1 //! CPU cross-check of the fixed depth range: for every clip/frame (project fps)
2 //! and all 60 views, the view-space depth of every skinned vertex versus
3 //! near/far. Usage: depth_clamp_probe BODY HEIGHT_MILLI CENTER_MILLI [FPS]
4 use paperdoll_renderer::{camera, preview, project::CameraFraming};
5
6 fn main() -> Result<(), Box<dyn std::error::Error>> {
7 let a: Vec<String> = std::env::args().skip(1).collect();
8 let body = preview::load_body_file(std::path::Path::new(&a[0]))?;
9 let fps: u32 = a.get(3).map(|v| v.parse()).transpose()?.unwrap_or(4);
10 let framing = CameraFraming {
11 height_milli: a[1].parse()?,
12 center_y_milli: a[2].parse()?,
13 ..CameraFraming::default()
14 };
15 let resolved = camera::resolve(&body, framing);
16 let (near, far) = camera::depth_range(resolved.framing);
17 println!("{} near {near:.3} far {far:.3}", resolved.description());
18 for (pitch, lo, hi, out) in camera::depth_extents(&body, resolved.framing, fps) {
19 println!("pitch {pitch:+}: depth {lo:.3}..{hi:.3}, vertices outside {out}");
20 }
21 Ok(())
22 }
@@ -0,0 +1,22 @@
1//! CPU cross-check of the fixed depth range: for every clip/frame (project fps)
2//! and all 60 views, the view-space depth of every skinned vertex versus
3//! near/far. Usage: depth_clamp_probe BODY HEIGHT_MILLI CENTER_MILLI [FPS]
4use paperdoll_renderer::{camera, preview, project::CameraFraming};
5
6fn main() -> Result<(), Box<dyn std::error::Error>> {
7 let a: Vec<String> = std::env::args().skip(1).collect();
8 let body = preview::load_body_file(std::path::Path::new(&a[0]))?;
9 let fps: u32 = a.get(3).map(|v| v.parse()).transpose()?.unwrap_or(4);
10 let framing = CameraFraming {
11 height_milli: a[1].parse()?,
12 center_y_milli: a[2].parse()?,
13 ..CameraFraming::default()
14 };
15 let resolved = camera::resolve(&body, framing);
16 let (near, far) = camera::depth_range(resolved.framing);
17 println!("{} near {near:.3} far {far:.3}", resolved.description());
18 for (pitch, lo, hi, out) in camera::depth_extents(&body, resolved.framing, fps) {
19 println!("pitch {pitch:+}: depth {lo:.3}..{hi:.3}, vertices outside {out}");
20 }
21 Ok(())
22}
examples/production_fixture.rs +9 −2
@@ -1,5 +1,5 @@
1 1 //! Actual native end-to-end batch smoke; no GUI input automation or preview delays.
2 //! Usage: production_fixture ROOT MALE.(glb|fbx) FEMALE.(glb|fbx) male|female WIDTH HEIGHT FPS [equipment] [--height-milli=N]
2 //! Usage: production_fixture ROOT MALE.(glb|fbx) FEMALE.(glb|fbx) male|female WIDTH HEIGHT FPS [equipment] [--height-milli=N] [--center-milli=N]
3 3 use eframe::App;
4 4 use paperdoll_renderer::{
5 5 editor::EditorApp,
@@ -33,6 +33,9 @@ impl App for Smoke {
33 33 serde_json::to_vec_pretty(&value).unwrap(),
34 34 )
35 35 .unwrap();
36 for warning in self.editor.render_warnings() {
37 println!("WARNING {warning}");
38 }
36 39 println!("RESULT {value}");
37 40 ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Close);
38 41 }
@@ -45,7 +48,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
45 48 let a = std::env::args().skip(1).collect::<Vec<_>>();
46 49 if a.len() < 7 {
47 50 return Err(
48 "ROOT MALE FEMALE male|female WIDTH HEIGHT FPS [equipment] [--height-milli=N]".into(),
51 "ROOT MALE FEMALE male|female WIDTH HEIGHT FPS [equipment] [--height-milli=N] [--center-milli=N]".into(),
49 52 );
50 53 }
51 54 let root = PathBuf::from(&a[0]);
@@ -66,6 +69,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
66 69 s.opened.as_mut().unwrap().document.render.framing[usize::from(sex == Sex::Female)]
67 70 .height_milli = value.parse()?;
68 71 }
72 if let Some(value) = a.iter().find_map(|v| v.strip_prefix("--center-milli=")) {
73 s.opened.as_mut().unwrap().document.render.framing[usize::from(sex == Sex::Female)]
74 .center_y_milli = value.parse()?;
75 }
69 76
70 77 if equipment {
71 78 let assets = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment");
@@ -1,5 +1,5 @@
1//! Actual native end-to-end batch smoke; no GUI input automation or preview delays.
1//! Actual native end-to-end batch smoke; no GUI input automation or preview delays.
2//! Usage: production_fixture ROOT MALE.(glb|fbx) FEMALE.(glb|fbx) male|female WIDTH HEIGHT FPS [equipment] [--height-milli=N]
2//! Usage: production_fixture ROOT MALE.(glb|fbx) FEMALE.(glb|fbx) male|female WIDTH HEIGHT FPS [equipment] [--height-milli=N] [--center-milli=N]
3use eframe::App;
3use eframe::App;
4use paperdoll_renderer::{
4use paperdoll_renderer::{
5 editor::EditorApp,
5 editor::EditorApp,
@@ -33,6 +33,9 @@ impl App for Smoke {
33 serde_json::to_vec_pretty(&value).unwrap(),
33 serde_json::to_vec_pretty(&value).unwrap(),
34 )
34 )
35 .unwrap();
35 .unwrap();
36 for warning in self.editor.render_warnings() {
37 println!("WARNING {warning}");
38 }
36 println!("RESULT {value}");
39 println!("RESULT {value}");
37 ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Close);
40 ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Close);
38 }
41 }
@@ -45,7 +48,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
45 let a = std::env::args().skip(1).collect::<Vec<_>>();
48 let a = std::env::args().skip(1).collect::<Vec<_>>();
46 if a.len() < 7 {
49 if a.len() < 7 {
47 return Err(
50 return Err(
48 "ROOT MALE FEMALE male|female WIDTH HEIGHT FPS [equipment] [--height-milli=N]".into(),
51 "ROOT MALE FEMALE male|female WIDTH HEIGHT FPS [equipment] [--height-milli=N] [--center-milli=N]".into(),
49 );
52 );
50 }
53 }
51 let root = PathBuf::from(&a[0]);
54 let root = PathBuf::from(&a[0]);
@@ -66,6 +69,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
66 s.opened.as_mut().unwrap().document.render.framing[usize::from(sex == Sex::Female)]
69 s.opened.as_mut().unwrap().document.render.framing[usize::from(sex == Sex::Female)]
67 .height_milli = value.parse()?;
70 .height_milli = value.parse()?;
68 }
71 }
72 if let Some(value) = a.iter().find_map(|v| v.strip_prefix("--center-milli=")) {
73 s.opened.as_mut().unwrap().document.render.framing[usize::from(sex == Sex::Female)]
74 .center_y_milli = value.parse()?;
75 }
69
76
70 if equipment {
77 if equipment {
71 let assets = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment");
78 let assets = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/equipment");
src/atlas.rs +1 −1
@@ -665,7 +665,7 @@ fn validate_ppda(p: &Ppda, file_name: &str) -> Result<AtlasLayout, ExportError>
665 665 }
666 666 }
667 667 id.framing.validate().map_err(ExportError::Identity)?;
668 if !["manual", "neck", "fallback"].contains(&id.pivot_source.as_str()) {
668 if !["manual", "bounds_center", "neck", "fallback"].contains(&id.pivot_source.as_str()) {
669 669 return Err(ident(".ppda: onbekende pivot_source"));
670 670 }
671 671 let (near, far) = crate::camera::depth_range(id.framing);
@@ -665,7 +665,7 @@ fn validate_ppda(p: &Ppda, file_name: &str) -> Result<AtlasLayout, ExportError>
665 }
665 }
666 }
666 }
667 id.framing.validate().map_err(ExportError::Identity)?;
667 id.framing.validate().map_err(ExportError::Identity)?;
668 if !["manual", "neck", "fallback"].contains(&id.pivot_source.as_str()) {
668 if !["manual", "bounds_center", "neck", "fallback"].contains(&id.pivot_source.as_str()) {
669 return Err(ident(".ppda: onbekende pivot_source"));
669 return Err(ident(".ppda: onbekende pivot_source"));
670 }
670 }
671 let (near, far) = crate::camera::depth_range(id.framing);
671 let (near, far) = crate::camera::depth_range(id.framing);
src/camera.rs +131 −121
@@ -8,9 +8,10 @@ use serde::Serialize;
8 8 #[serde(tag = "kind", rename_all = "snake_case")]
9 9 pub enum PivotSource {
10 10 Manual,
11 Neck {
12 joint_index: usize,
13 joint_name: String,
11 /// Vertical centre of the rest-pose bounds (Tim t1008u).
12 BoundsCenter {
13 min_y_milli: i32,
14 max_y_milli: i32,
14 15 },
15 16 Fallback {
16 17 reason: String,
@@ -30,8 +31,8 @@ impl Resolved {
30 31 / 1000.;
31 32 match &self.source {
32 33 PivotSource::Manual => format!("Vaste pivot Y {y:.3} m"),
33 PivotSource::Neck { joint_name, .. } => {
34 format!("Nekpivot {joint_name}: Y {y:.3} m (rustpose)")
34 PivotSource::BoundsCenter { .. } => {
35 format!("Pivot in het midden van het model: Y {y:.3} m (rustpose)")
35 36 }
36 37 PivotSource::Fallback { reason } => {
37 38 format!("{reason}; centrum Y {y:.3} m als fallback — stel de pivot handmatig in")
@@ -39,70 +40,39 @@ impl Resolved {
39 40 }
40 41 }
41 42 }
42 fn neck_name(name: &str) -> bool {
43 let lower = name.to_ascii_lowercase();
44 let is_neck = |token: &str| {
45 token
46 .strip_prefix("neck")
47 .is_some_and(|suffix| suffix.chars().all(|c| c.is_ascii_digit()))
48 };
49 lower
50 .split(|c: char| !c.is_ascii_alphanumeric())
51 .any(is_neck)
52 || ["mixamorig", "bip001", "bip01", "ccbase"]
53 .iter()
54 .any(|prefix| lower.strip_prefix(prefix).is_some_and(is_neck))
43 /// Rest-pose vertex positions (skinned with the rest joint transforms; a
44 /// static body is already in its stored pose).
45 pub fn rest_points(body: &PreviewCharacter) -> Vec<[f32; 3]> {
46 if body.is_static() || body.joint_nodes.is_empty() {
47 return body.positions.clone();
48 }
49 let bones: Vec<Mat4> = (0..body.joint_nodes.len())
50 .map(|joint| {
51 let world = preview::rest_joint_world(body, joint).unwrap_or_else(preview::identity);
52 let inverse = body
53 .inverse_binds
54 .get(joint)
55 .copied()
56 .unwrap_or_else(preview::identity);
57 preview::mul(world, inverse)
58 })
59 .collect();
60 skin_points(body, &bones)
55 61 }
56 fn ancestor(body: &PreviewCharacter, parent: usize, child: usize) -> bool {
57 let mut current = Some(child);
58 for _ in 0..body.nodes.len() {
59 let Some(node) = current else { return false };
60 if node == parent {
61 return true;
62 /// Vertical extent of the rest pose in millimetres: (min Y, max Y).
63 pub fn rest_bounds_y(body: &PreviewCharacter) -> Result<(i32, i32), String> {
64 let (mut lo, mut hi) = (f32::INFINITY, f32::NEG_INFINITY);
65 for p in rest_points(body) {
66 if p[1].is_finite() {
67 lo = lo.min(p[1]);
68 hi = hi.max(p[1]);
62 69 }
63 current = body.nodes.get(node).and_then(|n| n.parent);
64 }
65 false
66 }
67 /// Select the base of one neck chain, not a similarly named unrelated skeleton.
68 pub fn neck_height(body: &PreviewCharacter) -> Result<(usize, i32), String> {
69 if body.is_static() {
70 // Documented fallback: a static body has no bones, so no neck joint
71 // is guessed from geometry; `resolve` uses center_y (or a manual pivot).
72 return Err("Statisch lichaam zonder skeleton: geen nekbot".into());
73 }
74 let candidates = body
75 .joint_names
76 .iter()
77 .enumerate()
78 .filter(|(_, n)| neck_name(n))
79 .map(|(j, _)| j)
80 .collect::<Vec<_>>();
81 if candidates.is_empty() {
82 return Err("Geen nekbot gevonden".into());
83 }
84 let roots = candidates
85 .iter()
86 .copied()
87 .filter(|j| {
88 candidates.iter().all(|other| {
89 match (body.joint_nodes.get(*j), body.joint_nodes.get(*other)) {
90 (Some(a), Some(b)) => ancestor(body, *a, *b),
91 _ => false,
92 }
93 })
94 })
95 .collect::<Vec<_>>();
96 if roots.len() != 1 {
97 return Err("Meerdere losstaande/ongeldige nekketens".into());
98 70 }
99 let joint = roots[0];
100 let world = preview::rest_joint_world(body, joint).ok_or("Ongeldige rustpose van nekbot")?;
101 let height = (world[13] * 1000.).round();
102 if !(-10000.0..=10000.0).contains(&height) {
103 return Err("Nekhoogte buiten -10…10 m".into());
71 if !lo.is_finite() || !hi.is_finite() {
72 return Err("Model zonder geldige geometrie".into());
104 73 }
105 Ok((joint, height as i32))
74 let milli = |v: f32| (v * 1000.).round().clamp(-10000., 10000.) as i32;
75 Ok((milli(lo), milli(hi)))
106 76 }
107 77 pub fn resolve(body: &PreviewCharacter, mut framing: CameraFraming) -> Resolved {
108 78 if framing.depth_range_milli.is_none() {
@@ -114,12 +84,13 @@ pub fn resolve(body: &PreviewCharacter, mut framing: CameraFraming) -> Resolved
114 84 source: PivotSource::Manual,
115 85 };
116 86 }
117 let source = match neck_height(body) {
118 Ok((joint, y)) => {
119 framing.pivot_y_milli = Some(y);
120 PivotSource::Neck {
121 joint_index: joint,
122 joint_name: body.joint_names[joint].clone(),
87 let source = match rest_bounds_y(body) {
88 Ok((min_y_milli, max_y_milli)) => {
89 framing.pivot_y_milli =
90 Some(((i64::from(min_y_milli) + i64::from(max_y_milli)) / 2) as i32);
91 PivotSource::BoundsCenter {
92 min_y_milli,
93 max_y_milli,
123 94 }
124 95 }
125 96 Err(reason) => {
@@ -129,7 +100,7 @@ pub fn resolve(body: &PreviewCharacter, mut framing: CameraFraming) -> Resolved
129 100 };
130 101 Resolved { framing, source }
131 102 }
132 /// Translation around the neck is separate from the image's vertical framing.
103 /// Translation around the pivot is separate from the image's vertical framing.
133 104 /// Keeping the orthographic offset constant makes the orbit pivot pixel stable
134 105 /// for every yaw/pitch without cropping the feet merely by raising the pivot.
135 106 /// Orthographic camera distance from the orbit pivot (metres).
@@ -237,6 +208,47 @@ pub fn animation_envelope(
237 208 ))
238 209 }
239 210
211 /// Per production pitch: (pitch, min, max view depth, vertex samples outside
212 /// near/far) over every clip frame at `fps` and all twelve yaws.
213 pub fn depth_extents(
214 body: &PreviewCharacter,
215 framing: CameraFraming,
216 fps: u32,
217 ) -> Vec<(f32, f32, f32, usize)> {
218 let mut poses: Vec<Vec<[f32; 3]>> = Vec::new();
219 if body.is_static() || body.clips.is_empty() {
220 poses.push(body.positions.clone());
221 } else {
222 let step = 1. / fps.max(1) as f32;
223 for clip in &body.clips {
224 let frames = ((clip.duration / step).ceil() as usize).max(1);
225 for frame in 0..frames {
226 let bones = preview::sample_skin_matrices(body, &clip.name, frame as f32 * step);
227 poses.push(skin_points(body, &bones));
228 }
229 }
230 }
231 let (near, far) = depth_range(framing);
232 preview::PAPERDOLL_PITCHES_DEG
233 .iter()
234 .map(|&pitch| {
235 let (mut lo, mut hi, mut out) = (f32::INFINITY, f32::NEG_INFINITY, 0);
236 for yaw in preview::paperdoll_yaws_deg() {
237 let (view, _) = matrices(framing, yaw, pitch, 1.);
238 for p in poses.iter().flatten() {
239 let z = -(view[2] * p[0] + view[6] * p[1] + view[10] * p[2] + view[14]);
240 if z.is_finite() {
241 lo = lo.min(z);
242 hi = hi.max(z);
243 out += usize::from(z < near || z > far);
244 }
245 }
246 }
247 (pitch, lo, hi, out)
248 })
249 .collect()
250 }
251
240 252 fn skin_points(body: &PreviewCharacter, bones: &[Mat4]) -> Vec<[f32; 3]> {
241 253 body.positions
242 254 .iter()
@@ -718,40 +730,22 @@ mod tests {
718 730 }
719 731
720 732 #[test]
721 fn neck_name_variants_and_missing_ambiguous() {
722 let cases = [
723 ("Neck", 1.5),
724 ("mixamorigNeck", 1.6),
725 ("Bip001 Neck", 1.7),
726 ("Bip01 Neck", 1.55),
727 ("CC_Base_Neck", 1.45),
728 ("neck2", 1.42),
729 ];
730 for (name, y) in cases {
731 let body = rig(&["Hips", name], &[None, Some(0)], &[1.0, y - 1.0]);
732 let (joint, milli) = neck_height(&body).unwrap();
733 assert_eq!(body.joint_names[joint], name);
734 assert_eq!(milli, (y * 1000.).round() as i32);
735 }
736 assert!(neck_height(&rig(&["Hips", "Head"], &[None, Some(0)], &[1., 0.5])).is_err());
737 let ambiguous = rig(
738 &["Hips", "Neck", "Prop Neck"],
739 &[None, Some(0), None],
740 &[1., 0.5, 2.],
741 );
742 assert!(neck_height(&ambiguous).is_err());
743 let chain = rig(
744 &["Hips", "Neck", "Neck1"],
745 &[None, Some(0), Some(1)],
746 &[1., 0.5, 0.1],
747 );
748 let (joint, _) = neck_height(&chain).unwrap();
749 assert_eq!(chain.joint_names[joint], "Neck");
733 fn free_view_is_further_away_with_the_same_on_screen_size() {
734 let fov = preview::free_fov_deg();
735 assert!((15.0..16.0).contains(&fov), "{fov}");
736 let d = 3.0;
737 let old = (preview::FREE_BASE_FOV_DEG.to_radians() / 2.).tan() * d;
738 let new = (fov.to_radians() / 2.).tan() * d * preview::FREE_DISTANCE_FACTOR;
739 assert!((old - new).abs() < 1e-5);
750 740 }
751 741
752 742 #[test]
753 743 fn resolve_manual_override_wins_and_fallback_uses_center() {
754 let body = rig(&["Hips", "Neck"], &[None, Some(0)], &[1.0, 0.5]);
744 let mut body = rig(&["Hips", "Neck"], &[None, Some(0)], &[1.0, 0.5]);
745 // Two vertices bound to Hips (rest Y = 1.0 + local): Y 0.2 and 2.0.
746 body.positions = vec![[0., -0.8, 0.], [0., 1.0, 0.]];
747 body.joints = vec![[0; 4]; 2];
748 body.weights = vec![[1., 0., 0., 0.]; 2];
755 749 let auto = resolve(
756 750 &body,
757 751 CameraFraming {
@@ -761,8 +755,14 @@ mod tests {
761 755 depth_range_milli: None,
762 756 },
763 757 );
764 assert!(matches!(auto.source, PivotSource::Neck { .. }));
765 assert_eq!(auto.framing.pivot_y_milli, Some(1500));
758 assert_eq!(
759 auto.source,
760 PivotSource::BoundsCenter {
761 min_y_milli: 200,
762 max_y_milli: 2000
763 }
764 );
765 assert_eq!(auto.framing.pivot_y_milli, Some(1100));
766 766 let manual = resolve(
767 767 &body,
768 768 CameraFraming {
@@ -864,21 +864,26 @@ mod tests {
864 864 }
865 865
866 866 #[test]
867 fn male_fixture_neck_is_detected() {
867 fn male_fixture_pivot_is_rest_pose_vertical_centre() {
868 868 let body = preview::load_gltf_file(
869 869 &std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
870 870 .join("tests/fixtures/bodies/test-body-male.glb"),
871 871 )
872 872 .unwrap();
873 873 let resolved = resolve(&body, CameraFraming::default());
874 assert!(matches!(
875 resolved.source,
876 PivotSource::Neck {
877 joint_name: ref name,
878 ..
879 } if name == "Neck"
880 ));
881 assert_eq!(resolved.framing.pivot_y_milli, Some(1460));
874 let PivotSource::BoundsCenter {
875 min_y_milli,
876 max_y_milli,
877 } = resolved.source
878 else {
879 panic!("expected bounds-centre pivot: {:?}", resolved.source);
880 };
881 assert!(min_y_milli < 100 && max_y_milli > 1600);
882 assert_eq!(
883 resolved.framing.pivot_y_milli,
884 Some((min_y_milli + max_y_milli) / 2)
885 );
886 assert_eq!(resolved.framing.pivot_y_milli, Some(880));
882 887 assert_ne!(
883 888 resolved.framing.pivot_y_milli.unwrap(),
884 889 resolved.framing.center_y_milli
@@ -965,14 +970,19 @@ mod tests {
965 970 "arms-down width is smaller than the T-pose span"
966 971 );
967 972 let resolved = resolve(&body, CameraFraming::default());
968 assert!(matches!(
969 resolved.source,
970 PivotSource::Neck {
971 joint_name: ref name,
972 ..
973 } if name == "Neck"
974 ));
975 assert_eq!(resolved.framing.pivot_y_milli, Some(1460));
973 let PivotSource::BoundsCenter {
974 min_y_milli,
975 max_y_milli,
976 } = resolved.source
977 else {
978 panic!("expected bounds-centre pivot: {:?}", resolved.source);
979 };
980 assert!(min_y_milli < 100 && max_y_milli > 1600);
981 assert_eq!(
982 resolved.framing.pivot_y_milli,
983 Some((min_y_milli + max_y_milli) / 2)
984 );
985 assert_eq!(resolved.framing.pivot_y_milli, Some(880));
976 986 assert_ne!(
977 987 resolved.framing.pivot_y_milli.unwrap(),
978 988 resolved.framing.center_y_milli
@@ -8,9 +8,10 @@ use serde::Serialize;
8#[serde(tag = "kind", rename_all = "snake_case")]
8#[serde(tag = "kind", rename_all = "snake_case")]
9pub enum PivotSource {
9pub enum PivotSource {
10 Manual,
10 Manual,
11 Neck {
11 /// Vertical centre of the rest-pose bounds (Tim t1008u).
12 joint_index: usize,
12 BoundsCenter {
13 joint_name: String,
13 min_y_milli: i32,
14 max_y_milli: i32,
14 },
15 },
15 Fallback {
16 Fallback {
16 reason: String,
17 reason: String,
@@ -30,8 +31,8 @@ impl Resolved {
30 / 1000.;
31 / 1000.;
31 match &self.source {
32 match &self.source {
32 PivotSource::Manual => format!("Vaste pivot Y {y:.3} m"),
33 PivotSource::Manual => format!("Vaste pivot Y {y:.3} m"),
33 PivotSource::Neck { joint_name, .. } => {
34 PivotSource::BoundsCenter { .. } => {
34 format!("Nekpivot {joint_name}: Y {y:.3} m (rustpose)")
35 format!("Pivot in het midden van het model: Y {y:.3} m (rustpose)")
35 }
36 }
36 PivotSource::Fallback { reason } => {
37 PivotSource::Fallback { reason } => {
37 format!("{reason}; centrum Y {y:.3} m als fallback — stel de pivot handmatig in")
38 format!("{reason}; centrum Y {y:.3} m als fallback — stel de pivot handmatig in")
@@ -39,70 +40,39 @@ impl Resolved {
39 }
40 }
40 }
41 }
41}
42}
42fn neck_name(name: &str) -> bool {
43/// Rest-pose vertex positions (skinned with the rest joint transforms; a
43 let lower = name.to_ascii_lowercase();
44/// static body is already in its stored pose).
44 let is_neck = |token: &str| {
45pub fn rest_points(body: &PreviewCharacter) -> Vec<[f32; 3]> {
45 token
46 if body.is_static() || body.joint_nodes.is_empty() {
46 .strip_prefix("neck")
47 return body.positions.clone();
47 .is_some_and(|suffix| suffix.chars().all(|c| c.is_ascii_digit()))
48 }
48 };
49 let bones: Vec<Mat4> = (0..body.joint_nodes.len())
49 lower
50 .map(|joint| {
50 .split(|c: char| !c.is_ascii_alphanumeric())
51 let world = preview::rest_joint_world(body, joint).unwrap_or_else(preview::identity);
51 .any(is_neck)
52 let inverse = body
52 || ["mixamorig", "bip001", "bip01", "ccbase"]
53 .inverse_binds
53 .iter()
54 .get(joint)
54 .any(|prefix| lower.strip_prefix(prefix).is_some_and(is_neck))
55 .copied()
56 .unwrap_or_else(preview::identity);
57 preview::mul(world, inverse)
58 })
59 .collect();
60 skin_points(body, &bones)
55}
61}
56fn ancestor(body: &PreviewCharacter, parent: usize, child: usize) -> bool {
62/// Vertical extent of the rest pose in millimetres: (min Y, max Y).
57 let mut current = Some(child);
63pub fn rest_bounds_y(body: &PreviewCharacter) -> Result<(i32, i32), String> {
58 for _ in 0..body.nodes.len() {
64 let (mut lo, mut hi) = (f32::INFINITY, f32::NEG_INFINITY);
59 let Some(node) = current else { return false };
65 for p in rest_points(body) {
60 if node == parent {
66 if p[1].is_finite() {
61 return true;
67 lo = lo.min(p[1]);
68 hi = hi.max(p[1]);
62 }
69 }
63 current = body.nodes.get(node).and_then(|n| n.parent);
64 }
65 false
66}
67/// Select the base of one neck chain, not a similarly named unrelated skeleton.
68pub fn neck_height(body: &PreviewCharacter) -> Result<(usize, i32), String> {
69 if body.is_static() {
70 // Documented fallback: a static body has no bones, so no neck joint
71 // is guessed from geometry; `resolve` uses center_y (or a manual pivot).
72 return Err("Statisch lichaam zonder skeleton: geen nekbot".into());
73 }
74 let candidates = body
75 .joint_names
76 .iter()
77 .enumerate()
78 .filter(|(_, n)| neck_name(n))
79 .map(|(j, _)| j)
80 .collect::<Vec<_>>();
81 if candidates.is_empty() {
82 return Err("Geen nekbot gevonden".into());
83 }
84 let roots = candidates
85 .iter()
86 .copied()
87 .filter(|j| {
88 candidates.iter().all(|other| {
89 match (body.joint_nodes.get(*j), body.joint_nodes.get(*other)) {
90 (Some(a), Some(b)) => ancestor(body, *a, *b),
91 _ => false,
92 }
93 })
94 })
95 .collect::<Vec<_>>();
96 if roots.len() != 1 {
97 return Err("Meerdere losstaande/ongeldige nekketens".into());
98 }
70 }
99 let joint = roots[0];
71 if !lo.is_finite() || !hi.is_finite() {
100 let world = preview::rest_joint_world(body, joint).ok_or("Ongeldige rustpose van nekbot")?;
72 return Err("Model zonder geldige geometrie".into());
101 let height = (world[13] * 1000.).round();
102 if !(-10000.0..=10000.0).contains(&height) {
103 return Err("Nekhoogte buiten -10…10 m".into());
104 }
73 }
105 Ok((joint, height as i32))
74 let milli = |v: f32| (v * 1000.).round().clamp(-10000., 10000.) as i32;
75 Ok((milli(lo), milli(hi)))
106}
76}
107pub fn resolve(body: &PreviewCharacter, mut framing: CameraFraming) -> Resolved {
77pub fn resolve(body: &PreviewCharacter, mut framing: CameraFraming) -> Resolved {
108 if framing.depth_range_milli.is_none() {
78 if framing.depth_range_milli.is_none() {
@@ -114,12 +84,13 @@ pub fn resolve(body: &PreviewCharacter, mut framing: CameraFraming) -> Resolved
114 source: PivotSource::Manual,
84 source: PivotSource::Manual,
115 };
85 };
116 }
86 }
117 let source = match neck_height(body) {
87 let source = match rest_bounds_y(body) {
118 Ok((joint, y)) => {
88 Ok((min_y_milli, max_y_milli)) => {
119 framing.pivot_y_milli = Some(y);
89 framing.pivot_y_milli =
120 PivotSource::Neck {
90 Some(((i64::from(min_y_milli) + i64::from(max_y_milli)) / 2) as i32);
121 joint_index: joint,
91 PivotSource::BoundsCenter {
122 joint_name: body.joint_names[joint].clone(),
92 min_y_milli,
93 max_y_milli,
123 }
94 }
124 }
95 }
125 Err(reason) => {
96 Err(reason) => {
@@ -129,7 +100,7 @@ pub fn resolve(body: &PreviewCharacter, mut framing: CameraFraming) -> Resolved
129 };
100 };
130 Resolved { framing, source }
101 Resolved { framing, source }
131}
102}
132/// Translation around the neck is separate from the image's vertical framing.
103/// Translation around the pivot is separate from the image's vertical framing.
133/// Keeping the orthographic offset constant makes the orbit pivot pixel stable
104/// Keeping the orthographic offset constant makes the orbit pivot pixel stable
134/// for every yaw/pitch without cropping the feet merely by raising the pivot.
105/// for every yaw/pitch without cropping the feet merely by raising the pivot.
135/// Orthographic camera distance from the orbit pivot (metres).
106/// Orthographic camera distance from the orbit pivot (metres).
@@ -237,6 +208,47 @@ pub fn animation_envelope(
237 ))
208 ))
238}
209}
239
210
211/// Per production pitch: (pitch, min, max view depth, vertex samples outside
212/// near/far) over every clip frame at `fps` and all twelve yaws.
213pub fn depth_extents(
214 body: &PreviewCharacter,
215 framing: CameraFraming,
216 fps: u32,
217) -> Vec<(f32, f32, f32, usize)> {
218 let mut poses: Vec<Vec<[f32; 3]>> = Vec::new();
219 if body.is_static() || body.clips.is_empty() {
220 poses.push(body.positions.clone());
221 } else {
222 let step = 1. / fps.max(1) as f32;
223 for clip in &body.clips {
224 let frames = ((clip.duration / step).ceil() as usize).max(1);
225 for frame in 0..frames {
226 let bones = preview::sample_skin_matrices(body, &clip.name, frame as f32 * step);
227 poses.push(skin_points(body, &bones));
228 }
229 }
230 }
231 let (near, far) = depth_range(framing);
232 preview::PAPERDOLL_PITCHES_DEG
233 .iter()
234 .map(|&pitch| {
235 let (mut lo, mut hi, mut out) = (f32::INFINITY, f32::NEG_INFINITY, 0);
236 for yaw in preview::paperdoll_yaws_deg() {
237 let (view, _) = matrices(framing, yaw, pitch, 1.);
238 for p in poses.iter().flatten() {
239 let z = -(view[2] * p[0] + view[6] * p[1] + view[10] * p[2] + view[14]);
240 if z.is_finite() {
241 lo = lo.min(z);
242 hi = hi.max(z);
243 out += usize::from(z < near || z > far);
244 }
245 }
246 }
247 (pitch, lo, hi, out)
248 })
249 .collect()
250}
251
240fn skin_points(body: &PreviewCharacter, bones: &[Mat4]) -> Vec<[f32; 3]> {
252fn skin_points(body: &PreviewCharacter, bones: &[Mat4]) -> Vec<[f32; 3]> {
241 body.positions
253 body.positions
242 .iter()
254 .iter()
@@ -718,40 +730,22 @@ mod tests {
718 }
730 }
719
731
720 #[test]
732 #[test]
721 fn neck_name_variants_and_missing_ambiguous() {
733 fn free_view_is_further_away_with_the_same_on_screen_size() {
722 let cases = [
734 let fov = preview::free_fov_deg();
723 ("Neck", 1.5),
735 assert!((15.0..16.0).contains(&fov), "{fov}");
724 ("mixamorigNeck", 1.6),
736 let d = 3.0;
725 ("Bip001 Neck", 1.7),
737 let old = (preview::FREE_BASE_FOV_DEG.to_radians() / 2.).tan() * d;
726 ("Bip01 Neck", 1.55),
738 let new = (fov.to_radians() / 2.).tan() * d * preview::FREE_DISTANCE_FACTOR;
727 ("CC_Base_Neck", 1.45),
739 assert!((old - new).abs() < 1e-5);
728 ("neck2", 1.42),
729 ];
730 for (name, y) in cases {
731 let body = rig(&["Hips", name], &[None, Some(0)], &[1.0, y - 1.0]);
732 let (joint, milli) = neck_height(&body).unwrap();
733 assert_eq!(body.joint_names[joint], name);
734 assert_eq!(milli, (y * 1000.).round() as i32);
735 }
736 assert!(neck_height(&rig(&["Hips", "Head"], &[None, Some(0)], &[1., 0.5])).is_err());
737 let ambiguous = rig(
738 &["Hips", "Neck", "Prop Neck"],
739 &[None, Some(0), None],
740 &[1., 0.5, 2.],
741 );
742 assert!(neck_height(&ambiguous).is_err());
743 let chain = rig(
744 &["Hips", "Neck", "Neck1"],
745 &[None, Some(0), Some(1)],
746 &[1., 0.5, 0.1],
747 );
748 let (joint, _) = neck_height(&chain).unwrap();
749 assert_eq!(chain.joint_names[joint], "Neck");
750 }
740 }
751
741
752 #[test]
742 #[test]
753 fn resolve_manual_override_wins_and_fallback_uses_center() {
743 fn resolve_manual_override_wins_and_fallback_uses_center() {
754 let body = rig(&["Hips", "Neck"], &[None, Some(0)], &[1.0, 0.5]);
744 let mut body = rig(&["Hips", "Neck"], &[None, Some(0)], &[1.0, 0.5]);
745 // Two vertices bound to Hips (rest Y = 1.0 + local): Y 0.2 and 2.0.
746 body.positions = vec![[0., -0.8, 0.], [0., 1.0, 0.]];
747 body.joints = vec![[0; 4]; 2];
748 body.weights = vec![[1., 0., 0., 0.]; 2];
755 let auto = resolve(
749 let auto = resolve(
756 &body,
750 &body,
757 CameraFraming {
751 CameraFraming {
@@ -761,8 +755,14 @@ mod tests {
761 depth_range_milli: None,
755 depth_range_milli: None,
762 },
756 },
763 );
757 );
764 assert!(matches!(auto.source, PivotSource::Neck { .. }));
758 assert_eq!(
765 assert_eq!(auto.framing.pivot_y_milli, Some(1500));
759 auto.source,
760 PivotSource::BoundsCenter {
761 min_y_milli: 200,
762 max_y_milli: 2000
763 }
764 );
765 assert_eq!(auto.framing.pivot_y_milli, Some(1100));
766 let manual = resolve(
766 let manual = resolve(
767 &body,
767 &body,
768 CameraFraming {
768 CameraFraming {
@@ -864,21 +864,26 @@ mod tests {
864 }
864 }
865
865
866 #[test]
866 #[test]
867 fn male_fixture_neck_is_detected() {
867 fn male_fixture_pivot_is_rest_pose_vertical_centre() {
868 let body = preview::load_gltf_file(
868 let body = preview::load_gltf_file(
869 &std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
869 &std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
870 .join("tests/fixtures/bodies/test-body-male.glb"),
870 .join("tests/fixtures/bodies/test-body-male.glb"),
871 )
871 )
872 .unwrap();
872 .unwrap();
873 let resolved = resolve(&body, CameraFraming::default());
873 let resolved = resolve(&body, CameraFraming::default());
874 assert!(matches!(
874 let PivotSource::BoundsCenter {
875 resolved.source,
875 min_y_milli,
876 PivotSource::Neck {
876 max_y_milli,
877 joint_name: ref name,
877 } = resolved.source
878 ..
878 else {
879 } if name == "Neck"
879 panic!("expected bounds-centre pivot: {:?}", resolved.source);
880 ));
880 };
881 assert_eq!(resolved.framing.pivot_y_milli, Some(1460));
881 assert!(min_y_milli < 100 && max_y_milli > 1600);
882 assert_eq!(
883 resolved.framing.pivot_y_milli,
884 Some((min_y_milli + max_y_milli) / 2)
885 );
886 assert_eq!(resolved.framing.pivot_y_milli, Some(880));
882 assert_ne!(
887 assert_ne!(
883 resolved.framing.pivot_y_milli.unwrap(),
888 resolved.framing.pivot_y_milli.unwrap(),
884 resolved.framing.center_y_milli
889 resolved.framing.center_y_milli
@@ -965,14 +970,19 @@ mod tests {
965 "arms-down width is smaller than the T-pose span"
970 "arms-down width is smaller than the T-pose span"
966 );
971 );
967 let resolved = resolve(&body, CameraFraming::default());
972 let resolved = resolve(&body, CameraFraming::default());
968 assert!(matches!(
973 let PivotSource::BoundsCenter {
969 resolved.source,
974 min_y_milli,
970 PivotSource::Neck {
975 max_y_milli,
971 joint_name: ref name,
976 } = resolved.source
972 ..
977 else {
973 } if name == "Neck"
978 panic!("expected bounds-centre pivot: {:?}", resolved.source);
974 ));
979 };
975 assert_eq!(resolved.framing.pivot_y_milli, Some(1460));
980 assert!(min_y_milli < 100 && max_y_milli > 1600);
981 assert_eq!(
982 resolved.framing.pivot_y_milli,
983 Some((min_y_milli + max_y_milli) / 2)
984 );
985 assert_eq!(resolved.framing.pivot_y_milli, Some(880));
976 assert_ne!(
986 assert_ne!(
977 resolved.framing.pivot_y_milli.unwrap(),
987 resolved.framing.pivot_y_milli.unwrap(),
978 resolved.framing.center_y_milli
988 resolved.framing.center_y_milli
src/editor.rs +8 −3
@@ -622,6 +622,11 @@ impl EditorApp {
622 622 pub fn render_is_running(&self) -> bool {
623 623 self.render_ui.running
624 624 }
625 /// Warning steps of the current/last render, in order.
626 pub fn render_warnings(&self) -> &[String] {
627 &self.render_ui.warnings
628 }
629
625 630 pub fn render_progress(&self) -> (u32, u32, String) {
626 631 (
627 632 self.render_ui.progress.completed,
@@ -1143,9 +1148,9 @@ impl EditorApp {
1143 1148 }
1144 1149 if camera.pivot_y_milli.is_some()
1145 1150 && ui
1146 .button(crate::i18n::t("Auto nek"))
1151 .button(crate::i18n::t("Auto midden"))
1147 1152 .on_hover_text(crate::i18n::t(
1148 "Wis handmatige pivot; gebruik rustpose-nekhoogte",
1153 "Wis handmatige pivot; gebruik het midden van het model (rustpose)",
1149 1154 ))
1150 1155 .clicked()
1151 1156 {
@@ -1216,7 +1221,7 @@ impl EditorApp {
1216 1221 ui.label(crate::i18n::t(resolved.description()));
1217 1222 } else {
1218 1223 ui.label(crate::i18n::t(
1219 "Laad een glTF/GLB-, FBX- of OBJ-lichaam om de nekpivot te bepalen.",
1224 "Laad een glTF/GLB-, FBX- of OBJ-lichaam om de pivot te bepalen.",
1220 1225 ));
1221 1226 }
1222 1227 }
@@ -622,6 +622,11 @@ impl EditorApp {
622 pub fn render_is_running(&self) -> bool {
622 pub fn render_is_running(&self) -> bool {
623 self.render_ui.running
623 self.render_ui.running
624 }
624 }
625 /// Warning steps of the current/last render, in order.
626 pub fn render_warnings(&self) -> &[String] {
627 &self.render_ui.warnings
628 }
629
625 pub fn render_progress(&self) -> (u32, u32, String) {
630 pub fn render_progress(&self) -> (u32, u32, String) {
626 (
631 (
627 self.render_ui.progress.completed,
632 self.render_ui.progress.completed,
@@ -1143,9 +1148,9 @@ impl EditorApp {
1143 }
1148 }
1144 if camera.pivot_y_milli.is_some()
1149 if camera.pivot_y_milli.is_some()
1145 && ui
1150 && ui
1146 .button(crate::i18n::t("Auto nek"))
1151 .button(crate::i18n::t("Auto midden"))
1147 .on_hover_text(crate::i18n::t(
1152 .on_hover_text(crate::i18n::t(
1148 "Wis handmatige pivot; gebruik rustpose-nekhoogte",
1153 "Wis handmatige pivot; gebruik het midden van het model (rustpose)",
1149 ))
1154 ))
1150 .clicked()
1155 .clicked()
1151 {
1156 {
@@ -1216,7 +1221,7 @@ impl EditorApp {
1216 ui.label(crate::i18n::t(resolved.description()));
1221 ui.label(crate::i18n::t(resolved.description()));
1217 } else {
1222 } else {
1218 ui.label(crate::i18n::t(
1223 ui.label(crate::i18n::t(
1219 "Laad een glTF/GLB-, FBX- of OBJ-lichaam om de nekpivot te bepalen.",
1224 "Laad een glTF/GLB-, FBX- of OBJ-lichaam om de pivot te bepalen.",
1220 ));
1225 ));
1221 }
1226 }
1222 }
1227 }
src/export_set.rs +1 −1
@@ -542,7 +542,7 @@ fn validate_render(m: &RenderManifest) -> Result<(), ExportError> {
542 542 }
543 543 }
544 544 match m.pivot_source.kind.as_str() {
545 "manual" | "neck" | "fallback" => {}
545 "manual" | "bounds_center" | "neck" | "fallback" => {}
546 546 other => {
547 547 return Err(ExportError::Identity(format!(
548 548 "onbekende pivot_source.kind '{other}'"
@@ -542,7 +542,7 @@ fn validate_render(m: &RenderManifest) -> Result<(), ExportError> {
542 }
542 }
543 }
543 }
544 match m.pivot_source.kind.as_str() {
544 match m.pivot_source.kind.as_str() {
545 "manual" | "neck" | "fallback" => {}
545 "manual" | "bounds_center" | "neck" | "fallback" => {}
546 other => {
546 other => {
547 return Err(ExportError::Identity(format!(
547 return Err(ExportError::Identity(format!(
548 "onbekende pivot_source.kind '{other}'"
548 "onbekende pivot_source.kind '{other}'"
src/i18n_table.rs +5 −0
@@ -813,6 +813,11 @@ pub const TABLE: &[(&str, &str)] = &[
813 813 ("Instellingen", "Settings"),
814 814 ("Taal", "Language"),
815 815 ("Sluiten", "Close"),
816 ("Auto midden", "Auto centre"),
817 ("Wis handmatige pivot; gebruik het midden van het model (rustpose)", "Clear manual pivot; use the model's centre (rest pose)"),
818 ("Laad een glTF/GLB-, FBX- of OBJ-lichaam om de pivot te bepalen.", "Load a glTF/GLB, FBX or OBJ body to determine the pivot."),
819 ("Pivot in het midden van het model: Y {y:.3} m (rustpose)", "Pivot at the model's centre: Y {y:.3} m (rest pose)"),
820 ("Model zonder geldige geometrie", "Model without valid geometry"),
816 821 ("voorste materiaal niet als texture × factor getekend: {surface_front:?}", "front material not drawn as texture × factor: {surface_front:?}"),
817 822 ("materiaal per primitive verwisseld: {surface_swap:?}", "material per primitive swapped: {surface_swap:?}"),
818 823 ("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}", "GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}"),
@@ -813,6 +813,11 @@ pub const TABLE: &[(&str, &str)] = &[
813 ("Instellingen", "Settings"),
813 ("Instellingen", "Settings"),
814 ("Taal", "Language"),
814 ("Taal", "Language"),
815 ("Sluiten", "Close"),
815 ("Sluiten", "Close"),
816 ("Auto midden", "Auto centre"),
817 ("Wis handmatige pivot; gebruik het midden van het model (rustpose)", "Clear manual pivot; use the model's centre (rest pose)"),
818 ("Laad een glTF/GLB-, FBX- of OBJ-lichaam om de pivot te bepalen.", "Load a glTF/GLB, FBX or OBJ body to determine the pivot."),
819 ("Pivot in het midden van het model: Y {y:.3} m (rustpose)", "Pivot at the model's centre: Y {y:.3} m (rest pose)"),
820 ("Model zonder geldige geometrie", "Model without valid geometry"),
816 ("voorste materiaal niet als texture × factor getekend: {surface_front:?}", "front material not drawn as texture × factor: {surface_front:?}"),
821 ("voorste materiaal niet als texture × factor getekend: {surface_front:?}", "front material not drawn as texture × factor: {surface_front:?}"),
817 ("materiaal per primitive verwisseld: {surface_swap:?}", "material per primitive swapped: {surface_swap:?}"),
822 ("materiaal per primitive verwisseld: {surface_swap:?}", "material per primitive swapped: {surface_swap:?}"),
818 ("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}", "GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}"),
823 ("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}", "GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}"),
src/preview.rs +13 −0
@@ -7,6 +7,19 @@
7 7
8 8 use std::path::Path;
9 9
10 /// Free perspective view (Tim t1008u): the camera sits this much further away
11 /// than `PreviewControls::distance` with a correspondingly narrower field of
12 /// view, so the on-screen size stays the same with weaker perspective.
13 pub const FREE_DISTANCE_FACTOR: f32 = 2.5;
14 /// Original field of view of the free view at the unscaled distance.
15 pub const FREE_BASE_FOV_DEG: f32 = 38.0;
16 /// `2·atan(tan(38°/2) / 2.5)` ≈ 15.4°.
17 pub fn free_fov_deg() -> f32 {
18 2. * ((FREE_BASE_FOV_DEG.to_radians() / 2.).tan() / FREE_DISTANCE_FACTOR)
19 .atan()
20 .to_degrees()
21 }
22
10 23 pub const PAPERDOLL_PITCHES_DEG: [f32; 5] = [-60.0, -30.0, 0.0, 30.0, 60.0];
11 24 /// Production pitches in integer degrees, atlas column order (t955u).
12 25 pub const PRODUCTION_PITCHES: [i32; 5] = [-60, -30, 0, 30, 60];
@@ -7,6 +7,19 @@
7
7
8use std::path::Path;
8use std::path::Path;
9
9
10/// Free perspective view (Tim t1008u): the camera sits this much further away
11/// than `PreviewControls::distance` with a correspondingly narrower field of
12/// view, so the on-screen size stays the same with weaker perspective.
13pub const FREE_DISTANCE_FACTOR: f32 = 2.5;
14/// Original field of view of the free view at the unscaled distance.
15pub const FREE_BASE_FOV_DEG: f32 = 38.0;
16/// `2·atan(tan(38°/2) / 2.5)` ≈ 15.4°.
17pub fn free_fov_deg() -> f32 {
18 2. * ((FREE_BASE_FOV_DEG.to_radians() / 2.).tan() / FREE_DISTANCE_FACTOR)
19 .atan()
20 .to_degrees()
21}
22
10pub const PAPERDOLL_PITCHES_DEG: [f32; 5] = [-60.0, -30.0, 0.0, 30.0, 60.0];
23pub const PAPERDOLL_PITCHES_DEG: [f32; 5] = [-60.0, -30.0, 0.0, 30.0, 60.0];
11/// Production pitches in integer degrees, atlas column order (t955u).
24/// Production pitches in integer degrees, atlas column order (t955u).
12pub const PRODUCTION_PITCHES: [i32; 5] = [-60, -30, 0, 30, 60];
25pub const PRODUCTION_PITCHES: [i32; 5] = [-60, -30, 0, 30, 60];
src/preview_gl.rs +2 −2
@@ -198,7 +198,7 @@ impl Viewport {
198 198 if framing.is_some() {
199 199 10.
200 200 } else {
201 controls.distance
201 controls.distance * crate::preview::FREE_DISTANCE_FACTOR
202 202 },
203 203 );
204 204 let eye = [
@@ -207,7 +207,7 @@ impl Viewport {
207 207 target[2] + offset[2],
208 208 ];
209 209 let view_proj = mul(
210 perspective(38.0, aspect, 0.05, 40.0),
210 perspective(crate::preview::free_fov_deg(), aspect, 0.05, 100.0),
211 211 view_matrix(eye, target),
212 212 );
213 213 let view_proj = if let Some((camera, _)) = framing {
@@ -198,7 +198,7 @@ impl Viewport {
198 if framing.is_some() {
198 if framing.is_some() {
199 10.
199 10.
200 } else {
200 } else {
201 controls.distance
201 controls.distance * crate::preview::FREE_DISTANCE_FACTOR
202 },
202 },
203 );
203 );
204 let eye = [
204 let eye = [
@@ -207,7 +207,7 @@ impl Viewport {
207 target[2] + offset[2],
207 target[2] + offset[2],
208 ];
208 ];
209 let view_proj = mul(
209 let view_proj = mul(
210 perspective(38.0, aspect, 0.05, 40.0),
210 perspective(crate::preview::free_fov_deg(), aspect, 0.05, 100.0),
211 view_matrix(eye, target),
211 view_matrix(eye, target),
212 );
212 );
213 let view_proj = if let Some((camera, _)) = framing {
213 let view_proj = if let Some((camera, _)) = framing {
src/production.rs +7 −3
@@ -2564,9 +2564,13 @@ mod tests {
2564 2564 meta["body_model"]["sha256"],
2565 2565 crate::digest::sha256_hex(b"test-body")
2566 2566 );
2567 assert_eq!(meta["framing"]["pivot_y_milli"], 1460);
2568 assert_eq!(meta["pivot_source"]["kind"], "neck");
2569 assert_eq!(meta["pivot_source"]["joint_name"], "Neck");
2567 assert_eq!(meta["framing"]["pivot_y_milli"], 880);
2568 assert_eq!(meta["pivot_source"]["kind"], "bounds_center");
2569 let (lo, hi) = (
2570 meta["pivot_source"]["min_y_milli"].as_i64().unwrap(),
2571 meta["pivot_source"]["max_y_milli"].as_i64().unwrap(),
2572 );
2573 assert_eq!((lo + hi) / 2, 880);
2570 2574 assert_eq!(
2571 2575 meta["saved_maps"]
2572 2576 .as_array()
@@ -2564,9 +2564,13 @@ mod tests {
2564 meta["body_model"]["sha256"],
2564 meta["body_model"]["sha256"],
2565 crate::digest::sha256_hex(b"test-body")
2565 crate::digest::sha256_hex(b"test-body")
2566 );
2566 );
2567 assert_eq!(meta["framing"]["pivot_y_milli"], 1460);
2567 assert_eq!(meta["framing"]["pivot_y_milli"], 880);
2568 assert_eq!(meta["pivot_source"]["kind"], "neck");
2568 assert_eq!(meta["pivot_source"]["kind"], "bounds_center");
2569 assert_eq!(meta["pivot_source"]["joint_name"], "Neck");
2569 let (lo, hi) = (
2570 meta["pivot_source"]["min_y_milli"].as_i64().unwrap(),
2571 meta["pivot_source"]["max_y_milli"].as_i64().unwrap(),
2572 );
2573 assert_eq!((lo + hi) / 2, 880);
2570 assert_eq!(
2574 assert_eq!(
2571 meta["saved_maps"]
2575 meta["saved_maps"]
2572 .as_array()
2576 .as_array()
src/render_job.rs +26 −0
@@ -96,8 +96,13 @@ pub struct RenderUiState {
96 96 pub saved: Option<String>,
97 97 pub error: Option<String>,
98 98 pub running: bool,
99 /// Every warning step of this render (e.g. clamped depth per view),
100 /// kept so a later pass label cannot hide it. Capped at `MAX_WARNINGS`.
101 pub warnings: Vec<String>,
99 102 }
100 103
104 pub const MAX_WARNINGS: usize = 100_000;
105
101 106 impl RenderUiState {
102 107 pub fn idle() -> Self {
103 108 Self {
@@ -112,6 +117,7 @@ impl RenderUiState {
112 117 saved: None,
113 118 error: None,
114 119 running: false,
120 warnings: Vec::new(),
115 121 }
116 122 }
117 123
@@ -122,6 +128,9 @@ impl RenderUiState {
122 128 total,
123 129 label,
124 130 } => {
131 if label.starts_with("Waarschuwing") && self.warnings.len() < MAX_WARNINGS {
132 self.warnings.push(label.clone());
133 }
125 134 self.progress = Progress {
126 135 completed: index,
127 136 total,
@@ -328,4 +337,21 @@ mod tests {
328 337 assert_eq!(ui.preview_revision, 1);
329 338 assert_eq!(ui.preview.as_ref().unwrap().rgba, vec![1, 2, 3, 255]);
330 339 }
340
341 #[test]
342 fn warning_steps_are_kept_after_later_passes() {
343 let mut ui = RenderUiState::idle();
344 for label in [
345 "Waarschuwing: x · 3 highres-pixels buiten dieptebereik (geklemd)",
346 "target",
347 ] {
348 ui.apply(RenderEvent::Step {
349 index: 1,
350 total: 2,
351 label: label.into(),
352 });
353 }
354 assert_eq!(ui.progress.label, "target");
355 assert_eq!(ui.warnings.len(), 1);
356 }
331 357 }
@@ -96,8 +96,13 @@ pub struct RenderUiState {
96 pub saved: Option<String>,
96 pub saved: Option<String>,
97 pub error: Option<String>,
97 pub error: Option<String>,
98 pub running: bool,
98 pub running: bool,
99 /// Every warning step of this render (e.g. clamped depth per view),
100 /// kept so a later pass label cannot hide it. Capped at `MAX_WARNINGS`.
101 pub warnings: Vec<String>,
99}
102}
100
103
104pub const MAX_WARNINGS: usize = 100_000;
105
101impl RenderUiState {
106impl RenderUiState {
102 pub fn idle() -> Self {
107 pub fn idle() -> Self {
103 Self {
108 Self {
@@ -112,6 +117,7 @@ impl RenderUiState {
112 saved: None,
117 saved: None,
113 error: None,
118 error: None,
114 running: false,
119 running: false,
120 warnings: Vec::new(),
115 }
121 }
116 }
122 }
117
123
@@ -122,6 +128,9 @@ impl RenderUiState {
122 total,
128 total,
123 label,
129 label,
124 } => {
130 } => {
131 if label.starts_with("Waarschuwing") && self.warnings.len() < MAX_WARNINGS {
132 self.warnings.push(label.clone());
133 }
125 self.progress = Progress {
134 self.progress = Progress {
126 completed: index,
135 completed: index,
127 total,
136 total,
@@ -328,4 +337,21 @@ mod tests {
328 assert_eq!(ui.preview_revision, 1);
337 assert_eq!(ui.preview_revision, 1);
329 assert_eq!(ui.preview.as_ref().unwrap().rgba, vec![1, 2, 3, 255]);
338 assert_eq!(ui.preview.as_ref().unwrap().rgba, vec![1, 2, 3, 255]);
330 }
339 }
340
341 #[test]
342 fn warning_steps_are_kept_after_later_passes() {
343 let mut ui = RenderUiState::idle();
344 for label in [
345 "Waarschuwing: x · 3 highres-pixels buiten dieptebereik (geklemd)",
346 "target",
347 ] {
348 ui.apply(RenderEvent::Step {
349 index: 1,
350 total: 2,
351 label: label.into(),
352 });
353 }
354 assert_eq!(ui.progress.label, "target");
355 assert_eq!(ui.warnings.len(), 1);
356 }
331}
357}
src/still.rs +11 −2
@@ -22,14 +22,23 @@ pub fn raster_body(
22 22 let height = height as usize;
23 23 let bones = sample_skin_matrices(character, &controls.clip_name, controls.time);
24 24 let target = [0.0, 0.9, 0.0];
25 let offset = eye_offset(controls.yaw_deg, controls.pitch_deg, controls.distance);
25 let offset = eye_offset(
26 controls.yaw_deg,
27 controls.pitch_deg,
28 controls.distance * crate::preview::FREE_DISTANCE_FACTOR,
29 );
26 30 let eye = [
27 31 target[0] + offset[0],
28 32 target[1] + offset[1],
29 33 target[2] + offset[2],
30 34 ];
31 35 let view_proj = mul(
32 perspective(38.0, width as f32 / height as f32, 0.05, 40.0),
36 perspective(
37 crate::preview::free_fov_deg(),
38 width as f32 / height as f32,
39 0.05,
40 100.0,
41 ),
33 42 view_matrix(eye, target),
34 43 );
35 44 let mut color = [18u8, 22, 28, 255].repeat(width * height);
@@ -22,14 +22,23 @@ pub fn raster_body(
22 let height = height as usize;
22 let height = height as usize;
23 let bones = sample_skin_matrices(character, &controls.clip_name, controls.time);
23 let bones = sample_skin_matrices(character, &controls.clip_name, controls.time);
24 let target = [0.0, 0.9, 0.0];
24 let target = [0.0, 0.9, 0.0];
25 let offset = eye_offset(controls.yaw_deg, controls.pitch_deg, controls.distance);
25 let offset = eye_offset(
26 controls.yaw_deg,
27 controls.pitch_deg,
28 controls.distance * crate::preview::FREE_DISTANCE_FACTOR,
29 );
26 let eye = [
30 let eye = [
27 target[0] + offset[0],
31 target[0] + offset[0],
28 target[1] + offset[1],
32 target[1] + offset[1],
29 target[2] + offset[2],
33 target[2] + offset[2],
30 ];
34 ];
31 let view_proj = mul(
35 let view_proj = mul(
32 perspective(38.0, width as f32 / height as f32, 0.05, 40.0),
36 perspective(
37 crate::preview::free_fov_deg(),
38 width as f32 / height as f32,
39 0.05,
40 100.0,
41 ),
33 view_matrix(eye, target),
42 view_matrix(eye, target),
34 );
43 );
35 let mut color = [18u8, 22, 28, 255].repeat(width * height);
44 let mut color = [18u8, 22, 28, 255].repeat(width * height);
tests/fbx_integration.rs +2 −2
@@ -169,13 +169,13 @@ fn fbx_body_production_scene_matches_glb_silhouette_without_double_normalization
169 169 let gp = paperdoll_renderer::camera::resolve(&glb_job.character, glb_job.framing);
170 170 assert!(matches!(
171 171 fp.source,
172 paperdoll_renderer::camera::PivotSource::Neck { .. }
172 paperdoll_renderer::camera::PivotSource::BoundsCenter { .. }
173 173 ));
174 174 let (fy, gy) = (
175 175 fp.framing.pivot_y_milli.unwrap(),
176 176 gp.framing.pivot_y_milli.unwrap(),
177 177 );
178 assert!((fy - gy).abs() <= 30, "neck pivot fbx {fy} vs glb {gy}");
178 assert!((fy - gy).abs() <= 30, "centre pivot fbx {fy} vs glb {gy}");
179 179 }
180 180
181 181 #[test]
@@ -169,13 +169,13 @@ fn fbx_body_production_scene_matches_glb_silhouette_without_double_normalization
169 let gp = paperdoll_renderer::camera::resolve(&glb_job.character, glb_job.framing);
169 let gp = paperdoll_renderer::camera::resolve(&glb_job.character, glb_job.framing);
170 assert!(matches!(
170 assert!(matches!(
171 fp.source,
171 fp.source,
172 paperdoll_renderer::camera::PivotSource::Neck { .. }
172 paperdoll_renderer::camera::PivotSource::BoundsCenter { .. }
173 ));
173 ));
174 let (fy, gy) = (
174 let (fy, gy) = (
175 fp.framing.pivot_y_milli.unwrap(),
175 fp.framing.pivot_y_milli.unwrap(),
176 gp.framing.pivot_y_milli.unwrap(),
176 gp.framing.pivot_y_milli.unwrap(),
177 );
177 );
178 assert!((fy - gy).abs() <= 30, "neck pivot fbx {fy} vs glb {gy}");
178 assert!((fy - gy).abs() <= 30, "centre pivot fbx {fy} vs glb {gy}");
179}
179}
180
180
181#[test]
181#[test]