Orbit pivot at rest-pose vertical centre (bounds_center), weaker free-view perspective, render warnings kept, depth_clamp_probe (Tim t1008u)
@@ -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** selects25
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** (w44
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()?;191
let layer = LayerMaps::from_atlas(&body, "body", "clip000-walk", 0, 60, 330)?;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
## Open with196
## Open with197
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 diffuse225
# screenshots the live view and checks every material colour in the production diffuse226
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-material226
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-material227
# Depth range check on the CPU: per pitch the depth extent of every clip frame and view versus near/far228
cargo run --release --example depth_clamp_probe --locked -- tests/fixtures/bodies/test-body-male.glb 2600 1000 4227
# Static bodies on a real GPU: OBJ male and glTF female without skin, live view, equipment refusal, production and export_set229
# Static bodies on a real GPU: OBJ male and glTF female without skin, live view, equipment refusal, production and export_set228
xvfb-run -a -s '-screen 0 1280x1024x24' cargo run --example static_body_fixture --locked -- /tmp/static-body /tmp/static-body-proof 32 48 4230
xvfb-run -a -s '-screen 0 1280x1024x24' cargo run --example static_body_fixture --locked -- /tmp/static-body /tmp/static-body-proof 32 48 4229
```231
```@@ -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 versus3
//! 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
}@@ -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]3
use eframe::App;3
use eframe::App;4
use paperdoll_renderer::{4
use 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");@@ -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);@@ -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")]9
pub enum PivotSource {9
pub 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
}42
fn neck_name(name: &str) -> bool {43
/// Rest-pose vertex positions (skinned with the rest joint transforms; a43
let lower = name.to_ascii_lowercase();44
/// static body is already in its stored pose).44
let is_neck = |token: &str| {45
pub fn rest_points(body: &PreviewCharacter) -> Vec<[f32; 3]> {45
token46
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
lower50
.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 = body52
|| ["mixamorig", "bip001", "bip01", "ccbase"]53
.inverse_binds53
.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
}56
fn 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);63
pub 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
false66
}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 joint71
// 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 = body75
.joint_names76
.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 = candidates85
.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
}107
pub fn resolve(body: &PreviewCharacter, mut framing: CameraFraming) -> Resolved {77
pub 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) -> Resolved114
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) -> Resolved129
};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 stable104
/// Keeping the orthographic offset constant makes the orbit pivot pixel stable134
/// 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 outside212
/// 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_DEG233
.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
fn skin_points(body: &PreviewCharacter, bones: &[Mat4]) -> Vec<[f32; 3]> {252
fn skin_points(body: &PreviewCharacter, bones: &[Mat4]) -> Vec<[f32; 3]> {241
body.positions253
body.positions242
.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: 2000763
}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.source878
..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_milli889
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.source972
..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_milli988
resolved.framing.center_y_milli@@ -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.running623
self.render_ui.running624
}624
}625
/// Warning steps of the current/last render, in order.626
pub fn render_warnings(&self) -> &[String] {627
&self.render_ui.warnings628
}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
&& ui1150
&& ui1146
.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
}@@ -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}'"@@ -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:?}"),@@ -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
8
use std::path::Path;8
use std::path::Path;9
9
10
/// Free perspective view (Tim t1008u): the camera sits this much further away11
/// than `PreviewControls::distance` with a correspondingly narrower field of12
/// 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
pub const PAPERDOLL_PITCHES_DEG: [f32; 5] = [-60.0, -30.0, 0.0, 30.0, 60.0];23
pub 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).12
pub const PRODUCTION_PITCHES: [i32; 5] = [-60, -30, 0, 30, 60];25
pub const PRODUCTION_PITCHES: [i32; 5] = [-60, -30, 0, 30, 60];@@ -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.distance201
controls.distance * crate::preview::FREE_DISTANCE_FACTOR202
},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 {@@ -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()@@ -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
104
pub const MAX_WARNINGS: usize = 100_000;105
101
impl RenderUiState {106
impl 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
}@@ -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);@@ -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_normalization169
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]