BlueGit

golden-age paperdoll-renderer

fix: restore native preview depth and preserve pass aspect ratio

timfa Grok Bot · pushed by timfa

Authored 2026-11-06 · 7 days ago

Hash
04b00257c128e874f9e772ad29225afc653a8d9f
Parent
60e63750
11 files changed +322 −16
README.md +4 −2
@@ -21,10 +21,10 @@ De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert
21 21
22 22 **Open man / Open vrouw** opent de body. Een geskind glTF/GLB-model is in de live preview zichtbaar:
23 23
24 - Sleep om te orbiten, scroll om te zoomen.
24 - Sleep om te orbiten, scroll om te zoomen. Een 24-bits depthbuffer zorgt dat voorste oppervlakken achterliggende delen afdekken.
25 25 - Kies een animatieclip en gebruik **Pauze / Speel**.
26 26 - **Yaw − / Yaw +** volgen twaalf hoeken van 30°; **pitch -30 / 0 / 30** kiezen de hoogte. Yaw 0 kijkt naar de voorkant van de testlichamen (−Z).
27 - **Render** maakt een gekleurd PNG-stilbeeld van de huidige pose en camera in `renders/body-male-still.png` of `body-female-still.png`. De preview toont achtereenvolgens de hoge resolutie en de ingestelde doelresolutie, met nearest-neighbor uitvergroot en een voortgangsbalk. **Live 3D** keert terug naar het model.
27 - **Render** maakt een gekleurd PNG-stilbeeld van de huidige pose en camera in `renders/body-male-still.png` of `body-female-still.png`. De preview toont achtereenvolgens de hoge resolutie en de ingestelde doelresolutie, met nearest-neighbor uitvergroot en een voortgangsbalk. Iedere pass behoudt zijn beeldverhouding en staat gecentreerd met zwarte balken waar nodig. **Live 3D** keert terug naar het model.
28 28 - Stel breedte, hoogte en fps in de Bodies-weergave in.
29 29
30 30 ![Bodyweergave met animatie- en camerabediening](docs/screenshots/body-live.png)
@@ -50,6 +50,8 @@ cargo fmt --check
50 50 cargo check --workspace --all-targets --locked
51 51 cargo clippy --workspace --all-targets --locked -- -D warnings
52 52 cargo test --workspace --locked
53 # Linux GPU-regressie (Xvfb + Mesa): echte overlappende geometrie en pixelreadback
54 xvfb-run -a cargo run --example preview_depth_probe --locked
53 55 ```
54 56
55 57 De testlichamen komen uit `scripts/generate_test_bodies.py`. Het FBX-testbestand wordt met Blender via `scripts/convert_test_body_to_fbx.py` gemaakt.
@@ -21,10 +21,10 @@ De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert
21
21
22**Open man / Open vrouw** opent de body. Een geskind glTF/GLB-model is in de live preview zichtbaar:
22**Open man / Open vrouw** opent de body. Een geskind glTF/GLB-model is in de live preview zichtbaar:
23
23
24- Sleep om te orbiten, scroll om te zoomen.
24- Sleep om te orbiten, scroll om te zoomen. Een 24-bits depthbuffer zorgt dat voorste oppervlakken achterliggende delen afdekken.
25- Kies een animatieclip en gebruik **Pauze / Speel**.
25- Kies een animatieclip en gebruik **Pauze / Speel**.
26- **Yaw − / Yaw +** volgen twaalf hoeken van 30°; **pitch -30 / 0 / 30** kiezen de hoogte. Yaw 0 kijkt naar de voorkant van de testlichamen (−Z).
26- **Yaw − / Yaw +** volgen twaalf hoeken van 30°; **pitch -30 / 0 / 30** kiezen de hoogte. Yaw 0 kijkt naar de voorkant van de testlichamen (−Z).
27- **Render** maakt een gekleurd PNG-stilbeeld van de huidige pose en camera in `renders/body-male-still.png` of `body-female-still.png`. De preview toont achtereenvolgens de hoge resolutie en de ingestelde doelresolutie, met nearest-neighbor uitvergroot en een voortgangsbalk. **Live 3D** keert terug naar het model.
27- **Render** maakt een gekleurd PNG-stilbeeld van de huidige pose en camera in `renders/body-male-still.png` of `body-female-still.png`. De preview toont achtereenvolgens de hoge resolutie en de ingestelde doelresolutie, met nearest-neighbor uitvergroot en een voortgangsbalk. Iedere pass behoudt zijn beeldverhouding en staat gecentreerd met zwarte balken waar nodig. **Live 3D** keert terug naar het model.
28- Stel breedte, hoogte en fps in de Bodies-weergave in.
28- Stel breedte, hoogte en fps in de Bodies-weergave in.
29
29
30![Bodyweergave met animatie- en camerabediening](docs/screenshots/body-live.png)
30![Bodyweergave met animatie- en camerabediening](docs/screenshots/body-live.png)
@@ -50,6 +50,8 @@ cargo fmt --check
50cargo check --workspace --all-targets --locked
50cargo check --workspace --all-targets --locked
51cargo clippy --workspace --all-targets --locked -- -D warnings
51cargo clippy --workspace --all-targets --locked -- -D warnings
52cargo test --workspace --locked
52cargo test --workspace --locked
53# Linux GPU-regressie (Xvfb + Mesa): echte overlappende geometrie en pixelreadback
54xvfb-run -a cargo run --example preview_depth_probe --locked
53```
55```
54
56
55De testlichamen komen uit `scripts/generate_test_bodies.py`. Het FBX-testbestand wordt met Blender via `scripts/convert_test_body_to_fbx.py` gemaakt.
57De testlichamen komen uit `scripts/generate_test_bodies.py`. Het FBX-testbestand wordt met Blender via `scripts/convert_test_body_to_fbx.py` gemaakt.
design.md +2 −2
@@ -161,7 +161,7 @@ After every merge, `README.md` describes only the current behavior: what the pro
161 161
162 162 The application icon is artwork supplied by Tim Falken. The original file is `assets/icons/paperdoll-renderer.png` (57×60 RGBA). Package bitmaps are scaled from that file with a premultiplied Lanczos filter, so the drawing is not redrawn. The eframe window uses the 128×128 raster as its window icon. Larger hicolor sizes stay in the packages. Linux AppImage, `.deb` and Arch packages install the hicolor sizes and name the icon `paperdoll-renderer` from both the desktop file and the AppStream metainfo. The MinGW Windows build embeds the same artwork in `paperdoll-renderer.exe` as `RT_GROUP_ICON`; the zip does not carry a separate `.ico`.
163 163
164 Live 3D preview, as built: the body screen of the eframe window draws the skinned glTF/GLB mesh with a perspective orbit camera. Drag orbits, scroll zooms, and buttons snap to pitches -30/0/+30 and the twelve yaw steps of 30 degrees. Yaw 0 places the camera on -Z because the test bodies face -Z. Walk and attack are sampled on the CPU and uploaded as joint matrices. The preview light is a placement shade. It is not the orthographic paperdoll export, and it does not draw OBJ or FBX.
164 Live 3D preview, as built: the body screen of the eframe window draws the skinned glTF/GLB mesh with a perspective orbit camera. Drag orbits, scroll zooms, and buttons snap to pitches -30/0/+30 and the twelve yaw steps of 30 degrees. Yaw 0 places the camera on -Z because the test bodies face -Z. Walk and attack are sampled on the CPU and uploaded as joint matrices. The preview light is a placement shade. It is not the orthographic paperdoll export, and it does not draw OBJ or FBX. The shell explicitly requests a native 24-bit depth buffer in eframe's **Glow/OpenGL** backend (not wgpu). Every body callback owns depth-test LESS, depth writes and clear-to-1 each frame; subsequent equipment layers must share that attachment without clearing it. A real GPU overlap/readback probe checks draw-order independence and depth clearing between frames. The CPU still rasterizer also depth-tests finite in-frustum fragments; neither preview is allowed to use painter's submission order as occlusion.
165 165
166 166 CI filename requested: `blue-ci-cd.yml`. **Unverified infrastructure detail:** do not invent BlueGit YAML syntax. Inspect documented BlueGit runner schema and narrow existing `timfa/posprint` and `legacy-cycles` pipeline/release examples before writing configuration. Respect runner/image selection, artifact retention and release API semantics. No GitLab mirror is requested for this new project.
167 167
@@ -182,7 +182,7 @@ These requirements are durable even where the implementation is staged. They sup
182 182 - Closing the OS window or choosing another/new project with unsaved changes asks **Opslaan / Niet opslaan / Annuleren**. Failed saves do not close or switch. A canceled picker does not discard edits, even after choosing Niet opslaan: the actual replacement only happens after another project is loaded successfully. Asset adoption is copy-on-write so discarding metadata cannot secretly change an existing saved body. Equipment config snapshots remain immutable until the manifest’s atomic save succeeds; unreferenced snapshots are retained for recovery.
183 183 - `.ppd` files support the file manager’s **Open with / Openen met** on every supported packaging platform (Windows, Linux AppImage, Debian and Arch). The process accepts a single project path, including spaces, and directly opens that project. Invalid files give an actionable error without corrupting the current session. Linux uses MIME XML, desktop `MimeType` and `%f`; portable binaries/AppImages provide `--install-desktop`. Windows registers a per-user OpenWithProgids entry and a quoted executable/file command, preserving the user’s existing default application. No administrator privileges are required for the Windows entry.
184 184 - Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.
185 - During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor** to fill the preview box, then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.
185 - During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.
186 186
187 187 Current implementation checkpoint: startup/native pickers, seeded projects, body-centric navigation, equipment name/model CRUD, unsaved-change gate, CLI project opening, platform integration assets, icon and live animated glTF preview are implemented. Equipment records currently store name and model in immutable project-side JSON snapshots; bone attachment controls and the binary embedded preset format remain stage 4. The current **Render** action exports a shaded color still of the selected glTF pose/camera through high-resolution rasterization and target-resolution nearest sampling, with two real pass previews and progress. It does not claim production sprite exports: full 36-view/animation batching, orthographic framing, detail-aware reconstruction and diffuse/depth/normal/shiny/rim maps remain stages 5–6 above. The complete requested preview order is preserved here and in `PASS_ORDER` for that work; unimplemented passes are excluded from the running job’s progress. High-resolution stills use up to 8× dimensions within a 1024-pixel high-resolution staging limit for small targets; larger valid targets use scale 1. This is a preview/still renderer, not the production contract in §8.
188 188
@@ -161,7 +161,7 @@ After every merge, `README.md` describes only the current behavior: what the pro
161
161
162The application icon is artwork supplied by Tim Falken. The original file is `assets/icons/paperdoll-renderer.png` (57×60 RGBA). Package bitmaps are scaled from that file with a premultiplied Lanczos filter, so the drawing is not redrawn. The eframe window uses the 128×128 raster as its window icon. Larger hicolor sizes stay in the packages. Linux AppImage, `.deb` and Arch packages install the hicolor sizes and name the icon `paperdoll-renderer` from both the desktop file and the AppStream metainfo. The MinGW Windows build embeds the same artwork in `paperdoll-renderer.exe` as `RT_GROUP_ICON`; the zip does not carry a separate `.ico`.
162The application icon is artwork supplied by Tim Falken. The original file is `assets/icons/paperdoll-renderer.png` (57×60 RGBA). Package bitmaps are scaled from that file with a premultiplied Lanczos filter, so the drawing is not redrawn. The eframe window uses the 128×128 raster as its window icon. Larger hicolor sizes stay in the packages. Linux AppImage, `.deb` and Arch packages install the hicolor sizes and name the icon `paperdoll-renderer` from both the desktop file and the AppStream metainfo. The MinGW Windows build embeds the same artwork in `paperdoll-renderer.exe` as `RT_GROUP_ICON`; the zip does not carry a separate `.ico`.
163
163
164Live 3D preview, as built: the body screen of the eframe window draws the skinned glTF/GLB mesh with a perspective orbit camera. Drag orbits, scroll zooms, and buttons snap to pitches -30/0/+30 and the twelve yaw steps of 30 degrees. Yaw 0 places the camera on -Z because the test bodies face -Z. Walk and attack are sampled on the CPU and uploaded as joint matrices. The preview light is a placement shade. It is not the orthographic paperdoll export, and it does not draw OBJ or FBX.
164Live 3D preview, as built: the body screen of the eframe window draws the skinned glTF/GLB mesh with a perspective orbit camera. Drag orbits, scroll zooms, and buttons snap to pitches -30/0/+30 and the twelve yaw steps of 30 degrees. Yaw 0 places the camera on -Z because the test bodies face -Z. Walk and attack are sampled on the CPU and uploaded as joint matrices. The preview light is a placement shade. It is not the orthographic paperdoll export, and it does not draw OBJ or FBX. The shell explicitly requests a native 24-bit depth buffer in eframe's **Glow/OpenGL** backend (not wgpu). Every body callback owns depth-test LESS, depth writes and clear-to-1 each frame; subsequent equipment layers must share that attachment without clearing it. A real GPU overlap/readback probe checks draw-order independence and depth clearing between frames. The CPU still rasterizer also depth-tests finite in-frustum fragments; neither preview is allowed to use painter's submission order as occlusion.
165
165
166CI filename requested: `blue-ci-cd.yml`. **Unverified infrastructure detail:** do not invent BlueGit YAML syntax. Inspect documented BlueGit runner schema and narrow existing `timfa/posprint` and `legacy-cycles` pipeline/release examples before writing configuration. Respect runner/image selection, artifact retention and release API semantics. No GitLab mirror is requested for this new project.
166CI filename requested: `blue-ci-cd.yml`. **Unverified infrastructure detail:** do not invent BlueGit YAML syntax. Inspect documented BlueGit runner schema and narrow existing `timfa/posprint` and `legacy-cycles` pipeline/release examples before writing configuration. Respect runner/image selection, artifact retention and release API semantics. No GitLab mirror is requested for this new project.
167
167
@@ -182,7 +182,7 @@ These requirements are durable even where the implementation is staged. They sup
182- Closing the OS window or choosing another/new project with unsaved changes asks **Opslaan / Niet opslaan / Annuleren**. Failed saves do not close or switch. A canceled picker does not discard edits, even after choosing Niet opslaan: the actual replacement only happens after another project is loaded successfully. Asset adoption is copy-on-write so discarding metadata cannot secretly change an existing saved body. Equipment config snapshots remain immutable until the manifest’s atomic save succeeds; unreferenced snapshots are retained for recovery.
182- Closing the OS window or choosing another/new project with unsaved changes asks **Opslaan / Niet opslaan / Annuleren**. Failed saves do not close or switch. A canceled picker does not discard edits, even after choosing Niet opslaan: the actual replacement only happens after another project is loaded successfully. Asset adoption is copy-on-write so discarding metadata cannot secretly change an existing saved body. Equipment config snapshots remain immutable until the manifest’s atomic save succeeds; unreferenced snapshots are retained for recovery.
183- `.ppd` files support the file manager’s **Open with / Openen met** on every supported packaging platform (Windows, Linux AppImage, Debian and Arch). The process accepts a single project path, including spaces, and directly opens that project. Invalid files give an actionable error without corrupting the current session. Linux uses MIME XML, desktop `MimeType` and `%f`; portable binaries/AppImages provide `--install-desktop`. Windows registers a per-user OpenWithProgids entry and a quoted executable/file command, preserving the user’s existing default application. No administrator privileges are required for the Windows entry.
183- `.ppd` files support the file manager’s **Open with / Openen met** on every supported packaging platform (Windows, Linux AppImage, Debian and Arch). The process accepts a single project path, including spaces, and directly opens that project. Invalid files give an actionable error without corrupting the current session. Linux uses MIME XML, desktop `MimeType` and `%f`; portable binaries/AppImages provide `--install-desktop`. Windows registers a per-user OpenWithProgids entry and a quoted executable/file command, preserving the user’s existing default application. No administrator privileges are required for the Windows entry.
184- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.
184- Tim’s PNG is the authoritative app/window/executable/package icon; the original is retained and derived sizes are tested. After every merge the README describes only current abilities and their usage. Add screenshots only when they clarify usage. Merged feature branches may be removed after ancestry verification; never remove master or active work.
185- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor** to fill the preview box, then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.
185- During a body render, a single preview area is replaced immediately after each completed pass: high-resolution image, internal detail map, target-resolution image enlarged with **nearest-neighbor**, preserving aspect ratio with one uniform scale, centered in the preview box with letterboxing (never independently stretch X/Y; applies to every pass/map), then each produced output map, then the next sampled pose/view. Show progress across actual completed work. Do not add sleeps, playback delays or wait for UI acknowledgment; fast passes may be visually skipped. Never count an unimplemented map as rendered, invent a map image or substitute a 3D viewport for exported maps.
186
186
187Current implementation checkpoint: startup/native pickers, seeded projects, body-centric navigation, equipment name/model CRUD, unsaved-change gate, CLI project opening, platform integration assets, icon and live animated glTF preview are implemented. Equipment records currently store name and model in immutable project-side JSON snapshots; bone attachment controls and the binary embedded preset format remain stage 4. The current **Render** action exports a shaded color still of the selected glTF pose/camera through high-resolution rasterization and target-resolution nearest sampling, with two real pass previews and progress. It does not claim production sprite exports: full 36-view/animation batching, orthographic framing, detail-aware reconstruction and diffuse/depth/normal/shiny/rim maps remain stages 5–6 above. The complete requested preview order is preserved here and in `PASS_ORDER` for that work; unimplemented passes are excluded from the running job’s progress. High-resolution stills use up to 8× dimensions within a 1024-pixel high-resolution staging limit for small targets; larger valid targets use scale 1. This is a preview/still renderer, not the production contract in §8.
187Current implementation checkpoint: startup/native pickers, seeded projects, body-centric navigation, equipment name/model CRUD, unsaved-change gate, CLI project opening, platform integration assets, icon and live animated glTF preview are implemented. Equipment records currently store name and model in immutable project-side JSON snapshots; bone attachment controls and the binary embedded preset format remain stage 4. The current **Render** action exports a shaded color still of the selected glTF pose/camera through high-resolution rasterization and target-resolution nearest sampling, with two real pass previews and progress. It does not claim production sprite exports: full 36-view/animation batching, orthographic framing, detail-aware reconstruction and diffuse/depth/normal/shiny/rim maps remain stages 5–6 above. The complete requested preview order is preserved here and in `PASS_ORDER` for that work; unimplemented passes are excluded from the running job’s progress. High-resolution stills use up to 8× dimensions within a 1024-pixel high-resolution staging limit for small targets; larger valid targets use scale 1. This is a preview/still renderer, not the production contract in §8.
188
188
docs/screenshots/body-live.png Binary file +0 −0

The contents of this binary file are not shown.

docs/screenshots/render-pass.png added Binary file +0 −0

The contents of this binary file are not shown.

examples/preview_depth_probe.rs added +37 −0
@@ -0,0 +1,37 @@
1 //! Run with a real GL context: xvfb-run -a cargo run --example preview_depth_probe.
2 use std::sync::{Arc, Mutex};
3 struct Quit;
4 impl eframe::App for Quit {
5 fn update(&mut self, ctx: &eframe::egui::Context, _: &mut eframe::Frame) {
6 ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Close);
7 }
8 }
9 fn main() -> Result<(), Box<dyn std::error::Error>> {
10 let outcome = Arc::new(Mutex::new(None));
11 let result = outcome.clone();
12 let options = eframe::NativeOptions {
13 viewport: eframe::egui::ViewportBuilder::default().with_inner_size([64., 64.]),
14 ..paperdoll_renderer::preview_gl::native_options()
15 };
16 eframe::run_native(
17 "Paperdoll depth regression",
18 options,
19 Box::new(move |creation| {
20 let test = creation
21 .gl
22 .as_ref()
23 .ok_or_else(|| "geen Glow-context".to_string())
24 .and_then(|gl| unsafe {
25 paperdoll_renderer::preview_gl::depth_regression_probe(gl)
26 });
27 *result.lock().unwrap() = Some(test);
28 Ok(Box::new(Quit))
29 }),
30 )?;
31 outcome
32 .lock()
33 .unwrap()
34 .take()
35 .ok_or("GPU-test is niet uitgevoerd")??;
36 Ok(())
37 }
@@ -0,0 +1,37 @@
1//! Run with a real GL context: xvfb-run -a cargo run --example preview_depth_probe.
2use std::sync::{Arc, Mutex};
3struct Quit;
4impl eframe::App for Quit {
5 fn update(&mut self, ctx: &eframe::egui::Context, _: &mut eframe::Frame) {
6 ctx.send_viewport_cmd(eframe::egui::ViewportCommand::Close);
7 }
8}
9fn main() -> Result<(), Box<dyn std::error::Error>> {
10 let outcome = Arc::new(Mutex::new(None));
11 let result = outcome.clone();
12 let options = eframe::NativeOptions {
13 viewport: eframe::egui::ViewportBuilder::default().with_inner_size([64., 64.]),
14 ..paperdoll_renderer::preview_gl::native_options()
15 };
16 eframe::run_native(
17 "Paperdoll depth regression",
18 options,
19 Box::new(move |creation| {
20 let test = creation
21 .gl
22 .as_ref()
23 .ok_or_else(|| "geen Glow-context".to_string())
24 .and_then(|gl| unsafe {
25 paperdoll_renderer::preview_gl::depth_regression_probe(gl)
26 });
27 *result.lock().unwrap() = Some(test);
28 Ok(Box::new(Quit))
29 }),
30 )?;
31 outcome
32 .lock()
33 .unwrap()
34 .take()
35 .ok_or("GPU-test is niet uitgevoerd")??;
36 Ok(())
37}
examples/preview_pass_probe.rs added +17 −0
@@ -0,0 +1,17 @@
1 //! Start an actual still-render job and show its letterboxed result.
2 fn main() -> eframe::Result<()> {
3 let options = eframe::NativeOptions {
4 viewport: eframe::egui::ViewportBuilder::default().with_inner_size([1100., 720.]),
5 ..paperdoll_renderer::preview_gl::native_options()
6 };
7 eframe::run_native(
8 "Paperdoll render preview",
9 options,
10 Box::new(|_| {
11 let mut app = paperdoll_renderer::editor::EditorApp::default();
12 app.open_fixture_project();
13 app.render_current_pose();
14 Ok(Box::new(app))
15 }),
16 )
17 }
@@ -0,0 +1,17 @@
1//! Start an actual still-render job and show its letterboxed result.
2fn main() -> eframe::Result<()> {
3 let options = eframe::NativeOptions {
4 viewport: eframe::egui::ViewportBuilder::default().with_inner_size([1100., 720.]),
5 ..paperdoll_renderer::preview_gl::native_options()
6 };
7 eframe::run_native(
8 "Paperdoll render preview",
9 options,
10 Box::new(|_| {
11 let mut app = paperdoll_renderer::editor::EditorApp::default();
12 app.open_fixture_project();
13 app.render_current_pose();
14 Ok(Box::new(app))
15 }),
16 )
17}
src/editor.rs +21 −2
@@ -265,6 +265,11 @@ impl EditorApp {
265 265 self.session.set_render(width, height, fps);
266 266 }
267 267
268 /// Start the same worker used by the Render button (also used by the GUI smoke fixture).
269 pub fn render_current_pose(&mut self) {
270 self.start_render([480, 640]);
271 }
272
268 273 fn start_render(&mut self, preview: [u32; 2]) {
269 274 if let Some(opened) = &self.session.opened {
270 275 if let Err(error) = opened.document.validate_shape() {
@@ -605,8 +610,22 @@ impl EditorApp {
605 610 ..Default::default()
606 611 },
607 612 );
608 let available = ui.available_size();
609 ui.image((texture.id(), available));
613 let (bounds, _) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover());
614 let fit = crate::render_job::fit_preview(
615 [image.width, image.height],
616 [bounds.width(), bounds.height()],
617 );
618 let rect = egui::Rect::from_min_size(
619 bounds.min + egui::vec2(fit.offset[0], fit.offset[1]),
620 egui::vec2(fit.size[0], fit.size[1]),
621 );
622 ui.painter().rect_filled(bounds, 0.0, egui::Color32::BLACK);
623 ui.painter().image(
624 texture.id(),
625 rect,
626 egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
627 egui::Color32::WHITE,
628 );
610 629 }
611 630
612 631 fn show_equipment_list(&mut self, ctx: &egui::Context) {
@@ -265,6 +265,11 @@ impl EditorApp {
265 self.session.set_render(width, height, fps);
265 self.session.set_render(width, height, fps);
266 }
266 }
267
267
268 /// Start the same worker used by the Render button (also used by the GUI smoke fixture).
269 pub fn render_current_pose(&mut self) {
270 self.start_render([480, 640]);
271 }
272
268 fn start_render(&mut self, preview: [u32; 2]) {
273 fn start_render(&mut self, preview: [u32; 2]) {
269 if let Some(opened) = &self.session.opened {
274 if let Some(opened) = &self.session.opened {
270 if let Err(error) = opened.document.validate_shape() {
275 if let Err(error) = opened.document.validate_shape() {
@@ -605,8 +610,22 @@ impl EditorApp {
605 ..Default::default()
610 ..Default::default()
606 },
611 },
607 );
612 );
608 let available = ui.available_size();
613 let (bounds, _) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover());
609 ui.image((texture.id(), available));
614 let fit = crate::render_job::fit_preview(
615 [image.width, image.height],
616 [bounds.width(), bounds.height()],
617 );
618 let rect = egui::Rect::from_min_size(
619 bounds.min + egui::vec2(fit.offset[0], fit.offset[1]),
620 egui::vec2(fit.size[0], fit.size[1]),
621 );
622 ui.painter().rect_filled(bounds, 0.0, egui::Color32::BLACK);
623 ui.painter().image(
624 texture.id(),
625 rect,
626 egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
627 egui::Color32::WHITE,
628 );
610 }
629 }
611
630
612 fn show_equipment_list(&mut self, ctx: &egui::Context) {
631 fn show_equipment_list(&mut self, ctx: &egui::Context) {
src/main.rs +1 −1
@@ -112,7 +112,7 @@ fn run_window(fixtures: bool, path: Option<std::path::PathBuf>) -> eframe::Resul
112 112 width,
113 113 height,
114 114 }),
115 ..Default::default()
115 ..paperdoll_renderer::preview_gl::native_options()
116 116 };
117 117 eframe::run_native(
118 118 "Paperdoll Renderer",
@@ -112,7 +112,7 @@ fn run_window(fixtures: bool, path: Option<std::path::PathBuf>) -> eframe::Resul
112 width,
112 width,
113 height,
113 height,
114 }),
114 }),
115 ..Default::default()
115 ..paperdoll_renderer::preview_gl::native_options()
116 };
116 };
117 eframe::run_native(
117 eframe::run_native(
118 "Paperdoll Renderer",
118 "Paperdoll Renderer",
src/preview_gl.rs +167 −0
@@ -14,6 +14,16 @@ use crate::preview::{
14 14 PreviewCharacter, PreviewControls, PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT,
15 15 };
16 16
17 /// egui itself requests no depth buffer. The custom 3D callback needs a real
18 /// 24-bit attachment in the native Glow render target.
19 pub fn native_options() -> eframe::NativeOptions {
20 eframe::NativeOptions {
21 depth_buffer: 24,
22 renderer: eframe::Renderer::Glow,
23 ..Default::default()
24 }
25 }
26
17 27 const MAX_BONES: usize = crate::preview::MAX_JOINTS;
18 28
19 29 pub struct Viewport {
@@ -234,6 +244,8 @@ impl GpuCache {
234 244 viewport.height_px,
235 245 );
236 246 gl.enable(glow::DEPTH_TEST);
247 gl.depth_mask(true);
248 gl.clear_depth_f32(1.0);
237 249 gl.depth_func(glow::LESS);
238 250 gl.disable(glow::BLEND);
239 251 gl.clear_color(0.08, 0.09, 0.12, 1.0);
@@ -396,3 +408,158 @@ void main() {
396 408 frag = vec4(u_color.rgb * shade, 1.0);
397 409 }
398 410 "#;
411
412 /// Real GPU regression using the same cache/shader/paint path as the callback.
413 /// Tests front-first/back-first overlap, depth writes, and the next-frame clear.
414 ///
415 /// # Safety
416 /// `gl` must be a current native OpenGL context. Run in a disposable test window;
417 /// this probe changes GL state and restores the default framebuffer afterwards.
418 pub unsafe fn depth_regression_probe(gl: &glow::Context) -> Result<(), String> {
419 let native_bits = gl.get_framebuffer_attachment_parameter_i32(
420 glow::FRAMEBUFFER,
421 glow::DEPTH,
422 glow::FRAMEBUFFER_ATTACHMENT_DEPTH_SIZE,
423 );
424 if native_bits < 24 {
425 return Err(format!(
426 "native depth attachment ontbreekt: {native_bits} bits"
427 ));
428 }
429 let framebuffer = gl.create_framebuffer()?;
430 let texture = gl.create_texture()?;
431 let depth = gl.create_renderbuffer()?;
432 gl.bind_framebuffer(glow::FRAMEBUFFER, Some(framebuffer));
433 gl.bind_texture(glow::TEXTURE_2D, Some(texture));
434 gl.tex_parameter_i32(
435 glow::TEXTURE_2D,
436 glow::TEXTURE_MIN_FILTER,
437 glow::NEAREST as i32,
438 );
439 gl.tex_parameter_i32(
440 glow::TEXTURE_2D,
441 glow::TEXTURE_MAG_FILTER,
442 glow::NEAREST as i32,
443 );
444 gl.tex_image_2d(
445 glow::TEXTURE_2D,
446 0,
447 glow::RGBA8 as i32,
448 32,
449 32,
450 0,
451 glow::RGBA,
452 glow::UNSIGNED_BYTE,
453 None,
454 );
455 gl.framebuffer_texture_2d(
456 glow::FRAMEBUFFER,
457 glow::COLOR_ATTACHMENT0,
458 glow::TEXTURE_2D,
459 Some(texture),
460 0,
461 );
462 gl.bind_renderbuffer(glow::RENDERBUFFER, Some(depth));
463 gl.renderbuffer_storage(glow::RENDERBUFFER, glow::DEPTH_COMPONENT24, 32, 32);
464 gl.framebuffer_renderbuffer(
465 glow::FRAMEBUFFER,
466 glow::DEPTH_ATTACHMENT,
467 glow::RENDERBUFFER,
468 Some(depth),
469 );
470 gl.draw_buffers(&[glow::COLOR_ATTACHMENT0]);
471 gl.read_buffer(glow::COLOR_ATTACHMENT0);
472 if gl.check_framebuffer_status(glow::FRAMEBUFFER) != glow::FRAMEBUFFER_COMPLETE {
473 return Err("onvolledig offscreen depth-target".into());
474 }
475 let info = egui::PaintCallbackInfo {
476 viewport: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(32., 32.)),
477 clip_rect: egui::Rect::EVERYTHING,
478 pixels_per_point: 1.,
479 screen_size_px: [32, 32],
480 };
481 let make_frame = |indices: Vec<u32>, generation: u64| PendingFrame {
482 generation,
483 positions: vec![
484 [-0.9, -0.9, -0.5],
485 [0.9, -0.9, -0.5],
486 [0., 0.9, -0.5],
487 [-0.9, -0.9, 0.5],
488 [0.9, -0.9, 0.5],
489 [0., 0.9, 0.5],
490 ],
491 normals: vec![
492 [0.35, 0.85, 0.4],
493 [0.35, 0.85, 0.4],
494 [0.35, 0.85, 0.4],
495 [-0.35, -0.85, -0.4],
496 [-0.35, -0.85, -0.4],
497 [-0.35, -0.85, -0.4],
498 ],
499 joints: vec![[0; 4]; 6],
500 weights: vec![[1., 0., 0., 0.]; 6],
501 indices,
502 bones: vec![crate::preview::identity()],
503 view_proj: crate::preview::identity(),
504 color: [1.; 4],
505 };
506 let mut cache = GpuCache::default();
507 let read = || {
508 let mut rgba = [0u8; 4];
509 gl.read_pixels(
510 16,
511 16,
512 1,
513 1,
514 glow::RGBA,
515 glow::UNSIGNED_BYTE,
516 glow::PixelPackData::Slice(&mut rgba),
517 );
518 rgba
519 };
520 let result = (|| {
521 // Deliberately poison the incoming state: the callback owns its depth setup.
522 gl.depth_mask(false);
523 gl.clear_depth_f32(0.0);
524 gl.depth_func(glow::GREATER);
525 cache.pending = Some(make_frame(vec![0, 1, 2, 3, 4, 5], 1));
526 cache.paint(gl, &info);
527 let near = read();
528 if near[..3].iter().any(|c| *c < 250) {
529 return Err(format!("achterste vlak overschrijft voorste: {near:?}"));
530 }
531 cache.pending = Some(make_frame(vec![3, 4, 5], 2));
532 cache.paint(gl, &info);
533 let next = read();
534 if next[..3].iter().any(|c| !(69..=73).contains(c)) {
535 return Err(format!("depth niet gewist voor volgende frame: {next:?}"));
536 }
537 cache.pending = Some(make_frame(vec![3, 4, 5, 0, 1, 2], 3));
538 cache.paint(gl, &info);
539 let reverse = read();
540 if near != reverse {
541 return Err(format!(
542 "draw-order afhankelijke occlusie: {near:?} / {reverse:?}"
543 ));
544 }
545 println!("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}");
546 Ok(())
547 })();
548 if let Some(p) = cache.program {
549 gl.delete_program(p)
550 }
551 if let Some(v) = cache.vao {
552 gl.delete_vertex_array(v)
553 }
554 if let Some(v) = cache.vbo {
555 gl.delete_buffer(v)
556 }
557 if let Some(v) = cache.ebo {
558 gl.delete_buffer(v)
559 }
560 gl.bind_framebuffer(glow::FRAMEBUFFER, None);
561 gl.delete_framebuffer(framebuffer);
562 gl.delete_texture(texture);
563 gl.delete_renderbuffer(depth);
564 result
565 }
@@ -14,6 +14,16 @@ use crate::preview::{
14 PreviewCharacter, PreviewControls, PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT,
14 PreviewCharacter, PreviewControls, PAPERDOLL_PITCHES_DEG, PAPERDOLL_YAW_COUNT,
15};
15};
16
16
17/// egui itself requests no depth buffer. The custom 3D callback needs a real
18/// 24-bit attachment in the native Glow render target.
19pub fn native_options() -> eframe::NativeOptions {
20 eframe::NativeOptions {
21 depth_buffer: 24,
22 renderer: eframe::Renderer::Glow,
23 ..Default::default()
24 }
25}
26
17const MAX_BONES: usize = crate::preview::MAX_JOINTS;
27const MAX_BONES: usize = crate::preview::MAX_JOINTS;
18
28
19pub struct Viewport {
29pub struct Viewport {
@@ -234,6 +244,8 @@ impl GpuCache {
234 viewport.height_px,
244 viewport.height_px,
235 );
245 );
236 gl.enable(glow::DEPTH_TEST);
246 gl.enable(glow::DEPTH_TEST);
247 gl.depth_mask(true);
248 gl.clear_depth_f32(1.0);
237 gl.depth_func(glow::LESS);
249 gl.depth_func(glow::LESS);
238 gl.disable(glow::BLEND);
250 gl.disable(glow::BLEND);
239 gl.clear_color(0.08, 0.09, 0.12, 1.0);
251 gl.clear_color(0.08, 0.09, 0.12, 1.0);
@@ -396,3 +408,158 @@ void main() {
396 frag = vec4(u_color.rgb * shade, 1.0);
408 frag = vec4(u_color.rgb * shade, 1.0);
397}
409}
398"#;
410"#;
411
412/// Real GPU regression using the same cache/shader/paint path as the callback.
413/// Tests front-first/back-first overlap, depth writes, and the next-frame clear.
414///
415/// # Safety
416/// `gl` must be a current native OpenGL context. Run in a disposable test window;
417/// this probe changes GL state and restores the default framebuffer afterwards.
418pub unsafe fn depth_regression_probe(gl: &glow::Context) -> Result<(), String> {
419 let native_bits = gl.get_framebuffer_attachment_parameter_i32(
420 glow::FRAMEBUFFER,
421 glow::DEPTH,
422 glow::FRAMEBUFFER_ATTACHMENT_DEPTH_SIZE,
423 );
424 if native_bits < 24 {
425 return Err(format!(
426 "native depth attachment ontbreekt: {native_bits} bits"
427 ));
428 }
429 let framebuffer = gl.create_framebuffer()?;
430 let texture = gl.create_texture()?;
431 let depth = gl.create_renderbuffer()?;
432 gl.bind_framebuffer(glow::FRAMEBUFFER, Some(framebuffer));
433 gl.bind_texture(glow::TEXTURE_2D, Some(texture));
434 gl.tex_parameter_i32(
435 glow::TEXTURE_2D,
436 glow::TEXTURE_MIN_FILTER,
437 glow::NEAREST as i32,
438 );
439 gl.tex_parameter_i32(
440 glow::TEXTURE_2D,
441 glow::TEXTURE_MAG_FILTER,
442 glow::NEAREST as i32,
443 );
444 gl.tex_image_2d(
445 glow::TEXTURE_2D,
446 0,
447 glow::RGBA8 as i32,
448 32,
449 32,
450 0,
451 glow::RGBA,
452 glow::UNSIGNED_BYTE,
453 None,
454 );
455 gl.framebuffer_texture_2d(
456 glow::FRAMEBUFFER,
457 glow::COLOR_ATTACHMENT0,
458 glow::TEXTURE_2D,
459 Some(texture),
460 0,
461 );
462 gl.bind_renderbuffer(glow::RENDERBUFFER, Some(depth));
463 gl.renderbuffer_storage(glow::RENDERBUFFER, glow::DEPTH_COMPONENT24, 32, 32);
464 gl.framebuffer_renderbuffer(
465 glow::FRAMEBUFFER,
466 glow::DEPTH_ATTACHMENT,
467 glow::RENDERBUFFER,
468 Some(depth),
469 );
470 gl.draw_buffers(&[glow::COLOR_ATTACHMENT0]);
471 gl.read_buffer(glow::COLOR_ATTACHMENT0);
472 if gl.check_framebuffer_status(glow::FRAMEBUFFER) != glow::FRAMEBUFFER_COMPLETE {
473 return Err("onvolledig offscreen depth-target".into());
474 }
475 let info = egui::PaintCallbackInfo {
476 viewport: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(32., 32.)),
477 clip_rect: egui::Rect::EVERYTHING,
478 pixels_per_point: 1.,
479 screen_size_px: [32, 32],
480 };
481 let make_frame = |indices: Vec<u32>, generation: u64| PendingFrame {
482 generation,
483 positions: vec![
484 [-0.9, -0.9, -0.5],
485 [0.9, -0.9, -0.5],
486 [0., 0.9, -0.5],
487 [-0.9, -0.9, 0.5],
488 [0.9, -0.9, 0.5],
489 [0., 0.9, 0.5],
490 ],
491 normals: vec![
492 [0.35, 0.85, 0.4],
493 [0.35, 0.85, 0.4],
494 [0.35, 0.85, 0.4],
495 [-0.35, -0.85, -0.4],
496 [-0.35, -0.85, -0.4],
497 [-0.35, -0.85, -0.4],
498 ],
499 joints: vec![[0; 4]; 6],
500 weights: vec![[1., 0., 0., 0.]; 6],
501 indices,
502 bones: vec![crate::preview::identity()],
503 view_proj: crate::preview::identity(),
504 color: [1.; 4],
505 };
506 let mut cache = GpuCache::default();
507 let read = || {
508 let mut rgba = [0u8; 4];
509 gl.read_pixels(
510 16,
511 16,
512 1,
513 1,
514 glow::RGBA,
515 glow::UNSIGNED_BYTE,
516 glow::PixelPackData::Slice(&mut rgba),
517 );
518 rgba
519 };
520 let result = (|| {
521 // Deliberately poison the incoming state: the callback owns its depth setup.
522 gl.depth_mask(false);
523 gl.clear_depth_f32(0.0);
524 gl.depth_func(glow::GREATER);
525 cache.pending = Some(make_frame(vec![0, 1, 2, 3, 4, 5], 1));
526 cache.paint(gl, &info);
527 let near = read();
528 if near[..3].iter().any(|c| *c < 250) {
529 return Err(format!("achterste vlak overschrijft voorste: {near:?}"));
530 }
531 cache.pending = Some(make_frame(vec![3, 4, 5], 2));
532 cache.paint(gl, &info);
533 let next = read();
534 if next[..3].iter().any(|c| !(69..=73).contains(c)) {
535 return Err(format!("depth niet gewist voor volgende frame: {next:?}"));
536 }
537 cache.pending = Some(make_frame(vec![3, 4, 5, 0, 1, 2], 3));
538 cache.paint(gl, &info);
539 let reverse = read();
540 if near != reverse {
541 return Err(format!(
542 "draw-order afhankelijke occlusie: {near:?} / {reverse:?}"
543 ));
544 }
545 println!("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}");
546 Ok(())
547 })();
548 if let Some(p) = cache.program {
549 gl.delete_program(p)
550 }
551 if let Some(v) = cache.vao {
552 gl.delete_vertex_array(v)
553 }
554 if let Some(v) = cache.vbo {
555 gl.delete_buffer(v)
556 }
557 if let Some(v) = cache.ebo {
558 gl.delete_buffer(v)
559 }
560 gl.bind_framebuffer(glow::FRAMEBUFFER, None);
561 gl.delete_framebuffer(framebuffer);
562 gl.delete_texture(texture);
563 gl.delete_renderbuffer(depth);
564 result
565}
src/render_job.rs +40 −8
@@ -228,6 +228,27 @@ pub fn nearest_stretch(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8
228 228 out
229 229 }
230 230
231 #[derive(Debug, Clone, Copy, PartialEq)]
232 pub struct PreviewFit {
233 pub size: [f32; 2],
234 pub offset: [f32; 2],
235 }
236
237 pub fn fit_preview(image: [u32; 2], bounds: [f32; 2]) -> PreviewFit {
238 if image.contains(&0) || bounds.iter().any(|v| !v.is_finite() || *v <= 0.0) {
239 return PreviewFit {
240 size: [0.; 2],
241 offset: [0.; 2],
242 };
243 }
244 let scale = (bounds[0] / image[0] as f32).min(bounds[1] / image[1] as f32);
245 let size = [image[0] as f32 * scale, image[1] as f32 * scale];
246 PreviewFit {
247 size,
248 offset: [(bounds[0] - size[0]) * 0.5, (bounds[1] - size[1]) * 0.5],
249 }
250 }
251
231 252 pub struct RenderJob {
232 253 pub character: PreviewCharacter,
233 254 pub controls: PreviewControls,
@@ -337,12 +358,8 @@ pub fn execute(job: &RenderJob, emit: &mut dyn FnMut(RenderEvent)) {
337 358 return;
338 359 }
339 360 }
340 let (box_w, box_h) = (job.preview_width.max(1), job.preview_height.max(1));
341 Some(ImageBuf {
342 width: box_w,
343 height: box_h,
344 rgba: nearest_stretch(&target.rgba, target.width, target.height, box_w, box_h),
345 })
361 // Keep native dimensions; only the GUI scales for presentation.
362 Some(target)
346 363 }
347 364 _ => None,
348 365 };
@@ -419,6 +436,21 @@ mod tests {
419 436 assert_eq!(steps[8].kind, PassKind::HighresImage);
420 437 }
421 438
439 #[test]
440 fn preview_uniform_scale_centers_and_letterboxes_all_aspects() {
441 let square = fit_preview([128, 128], [480., 640.]);
442 assert_eq!(square.size, [480., 480.]);
443 assert_eq!(square.offset, [0., 80.]);
444 let landscape = fit_preview([640, 320], [300., 300.]);
445 assert_eq!(landscape.size, [300., 150.]);
446 assert_eq!(landscape.offset, [0., 75.]);
447 let portrait = fit_preview([64, 128], [400., 200.]);
448 assert_eq!(portrait.size, [100., 200.]);
449 assert_eq!(portrait.offset, [150., 0.]);
450 assert_eq!(fit_preview([0, 128], [400., 200.]).size, [0., 0.]);
451 assert_eq!(fit_preview([128, 128], [f32::NAN, 200.]).size, [0., 0.]);
452 }
453
422 454 #[test]
423 455 fn nearest_non_square_uses_source_height_for_rows() {
424 456 let src = [255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255];
@@ -476,9 +508,9 @@ mod tests {
476 508 assert_eq!(ui.progress.total, 2);
477 509 assert!(ui.error.is_none());
478 510 let preview = ui.preview.expect("a real pass image");
479 assert_eq!((preview.width, preview.height), (64, 80));
511 assert_eq!((preview.width, preview.height), (32, 40));
480 512 let filled = preview.rgba.chunks_exact(4).filter(|p| p[3] == 255).count();
481 assert_eq!(filled, 64 * 80);
513 assert_eq!(filled, 32 * 40);
482 514 assert!(output.is_file());
483 515 let _ = std::fs::remove_file(&output);
484 516 }
@@ -228,6 +228,27 @@ pub fn nearest_stretch(src: &[u8], sw: u32, sh: u32, dw: u32, dh: u32) -> Vec<u8
228 out
228 out
229}
229}
230
230
231#[derive(Debug, Clone, Copy, PartialEq)]
232pub struct PreviewFit {
233 pub size: [f32; 2],
234 pub offset: [f32; 2],
235}
236
237pub fn fit_preview(image: [u32; 2], bounds: [f32; 2]) -> PreviewFit {
238 if image.contains(&0) || bounds.iter().any(|v| !v.is_finite() || *v <= 0.0) {
239 return PreviewFit {
240 size: [0.; 2],
241 offset: [0.; 2],
242 };
243 }
244 let scale = (bounds[0] / image[0] as f32).min(bounds[1] / image[1] as f32);
245 let size = [image[0] as f32 * scale, image[1] as f32 * scale];
246 PreviewFit {
247 size,
248 offset: [(bounds[0] - size[0]) * 0.5, (bounds[1] - size[1]) * 0.5],
249 }
250}
251
231pub struct RenderJob {
252pub struct RenderJob {
232 pub character: PreviewCharacter,
253 pub character: PreviewCharacter,
233 pub controls: PreviewControls,
254 pub controls: PreviewControls,
@@ -337,12 +358,8 @@ pub fn execute(job: &RenderJob, emit: &mut dyn FnMut(RenderEvent)) {
337 return;
358 return;
338 }
359 }
339 }
360 }
340 let (box_w, box_h) = (job.preview_width.max(1), job.preview_height.max(1));
361 // Keep native dimensions; only the GUI scales for presentation.
341 Some(ImageBuf {
362 Some(target)
342 width: box_w,
343 height: box_h,
344 rgba: nearest_stretch(&target.rgba, target.width, target.height, box_w, box_h),
345 })
346 }
363 }
347 _ => None,
364 _ => None,
348 };
365 };
@@ -419,6 +436,21 @@ mod tests {
419 assert_eq!(steps[8].kind, PassKind::HighresImage);
436 assert_eq!(steps[8].kind, PassKind::HighresImage);
420 }
437 }
421
438
439 #[test]
440 fn preview_uniform_scale_centers_and_letterboxes_all_aspects() {
441 let square = fit_preview([128, 128], [480., 640.]);
442 assert_eq!(square.size, [480., 480.]);
443 assert_eq!(square.offset, [0., 80.]);
444 let landscape = fit_preview([640, 320], [300., 300.]);
445 assert_eq!(landscape.size, [300., 150.]);
446 assert_eq!(landscape.offset, [0., 75.]);
447 let portrait = fit_preview([64, 128], [400., 200.]);
448 assert_eq!(portrait.size, [100., 200.]);
449 assert_eq!(portrait.offset, [150., 0.]);
450 assert_eq!(fit_preview([0, 128], [400., 200.]).size, [0., 0.]);
451 assert_eq!(fit_preview([128, 128], [f32::NAN, 200.]).size, [0., 0.]);
452 }
453
422 #[test]
454 #[test]
423 fn nearest_non_square_uses_source_height_for_rows() {
455 fn nearest_non_square_uses_source_height_for_rows() {
424 let src = [255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255];
456 let src = [255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255];
@@ -476,9 +508,9 @@ mod tests {
476 assert_eq!(ui.progress.total, 2);
508 assert_eq!(ui.progress.total, 2);
477 assert!(ui.error.is_none());
509 assert!(ui.error.is_none());
478 let preview = ui.preview.expect("a real pass image");
510 let preview = ui.preview.expect("a real pass image");
479 assert_eq!((preview.width, preview.height), (64, 80));
511 assert_eq!((preview.width, preview.height), (32, 40));
480 let filled = preview.rgba.chunks_exact(4).filter(|p| p[3] == 255).count();
512 let filled = preview.rgba.chunks_exact(4).filter(|p| p[3] == 255).count();
481 assert_eq!(filled, 64 * 80);
513 assert_eq!(filled, 32 * 40);
482 assert!(output.is_file());
514 assert!(output.is_file());
483 let _ = std::fs::remove_file(&output);
515 let _ = std::fs::remove_file(&output);
484 }
516 }
src/still.rs +33 −1
@@ -68,7 +68,7 @@ pub fn raster_body(
68 68 }
69 69 let z = w0 * a.z + w1 * b.z + w2 * c.z;
70 70 let index = y * width + x;
71 if z < depth[index] {
71 if z.is_finite() && (-1.0..=1.0).contains(&z) && z < depth[index] {
72 72 depth[index] = z;
73 73 let pixel = &mut color[index * 4..index * 4 + 4];
74 74 pixel[0] = shade[0];
@@ -179,6 +179,38 @@ mod tests {
179 179 use crate::preview::load_gltf_file;
180 180 use std::path::PathBuf;
181 181
182 #[test]
183 fn front_surface_wins_independent_of_triangle_submission_order() {
184 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
185 .join("tests/fixtures/bodies/test-body-male.glb");
186 let mut character = load_gltf_file(&path).unwrap();
187 character.positions = vec![
188 [-0.5, 0.4, -0.25],
189 [0.5, 0.4, -0.25],
190 [0., 1.5, -0.25],
191 [-0.5, 0.4, 0.25],
192 [0.5, 0.4, 0.25],
193 [0., 1.5, 0.95],
194 ];
195 character.joint_nodes.clear();
196 character.inverse_binds.clear();
197 character.clips.clear();
198 character.base_color = [1., 1., 1., 1.];
199 let controls = PreviewControls::default();
200 character.indices = vec![0, 1, 2];
201 let front = raster_body(&character, &controls, 96, 128).unwrap();
202 character.indices = vec![3, 4, 5];
203 let back = raster_body(&character, &controls, 96, 128).unwrap();
204 character.indices = vec![0, 1, 2, 3, 4, 5];
205 let first = raster_body(&character, &controls, 96, 128).unwrap();
206 character.indices = vec![3, 4, 5, 0, 1, 2];
207 let reversed = raster_body(&character, &controls, 96, 128).unwrap();
208 assert_eq!(first, reversed);
209 let pixel = (64 * 96 + 48) * 4;
210 assert_ne!(&front[pixel..pixel + 4], &back[pixel..pixel + 4]);
211 assert_eq!(&first[pixel..pixel + 4], &front[pixel..pixel + 4]);
212 }
213
182 214 #[test]
183 215 fn male_walk_still_has_a_visible_body() {
184 216 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
@@ -68,7 +68,7 @@ pub fn raster_body(
68 }
68 }
69 let z = w0 * a.z + w1 * b.z + w2 * c.z;
69 let z = w0 * a.z + w1 * b.z + w2 * c.z;
70 let index = y * width + x;
70 let index = y * width + x;
71 if z < depth[index] {
71 if z.is_finite() && (-1.0..=1.0).contains(&z) && z < depth[index] {
72 depth[index] = z;
72 depth[index] = z;
73 let pixel = &mut color[index * 4..index * 4 + 4];
73 let pixel = &mut color[index * 4..index * 4 + 4];
74 pixel[0] = shade[0];
74 pixel[0] = shade[0];
@@ -179,6 +179,38 @@ mod tests {
179 use crate::preview::load_gltf_file;
179 use crate::preview::load_gltf_file;
180 use std::path::PathBuf;
180 use std::path::PathBuf;
181
181
182 #[test]
183 fn front_surface_wins_independent_of_triangle_submission_order() {
184 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
185 .join("tests/fixtures/bodies/test-body-male.glb");
186 let mut character = load_gltf_file(&path).unwrap();
187 character.positions = vec![
188 [-0.5, 0.4, -0.25],
189 [0.5, 0.4, -0.25],
190 [0., 1.5, -0.25],
191 [-0.5, 0.4, 0.25],
192 [0.5, 0.4, 0.25],
193 [0., 1.5, 0.95],
194 ];
195 character.joint_nodes.clear();
196 character.inverse_binds.clear();
197 character.clips.clear();
198 character.base_color = [1., 1., 1., 1.];
199 let controls = PreviewControls::default();
200 character.indices = vec![0, 1, 2];
201 let front = raster_body(&character, &controls, 96, 128).unwrap();
202 character.indices = vec![3, 4, 5];
203 let back = raster_body(&character, &controls, 96, 128).unwrap();
204 character.indices = vec![0, 1, 2, 3, 4, 5];
205 let first = raster_body(&character, &controls, 96, 128).unwrap();
206 character.indices = vec![3, 4, 5, 0, 1, 2];
207 let reversed = raster_body(&character, &controls, 96, 128).unwrap();
208 assert_eq!(first, reversed);
209 let pixel = (64 * 96 + 48) * 4;
210 assert_ne!(&front[pixel..pixel + 4], &back[pixel..pixel + 4]);
211 assert_eq!(&first[pixel..pixel + 4], &front[pixel..pixel + 4]);
212 }
213
182 #[test]
214 #[test]
183 fn male_walk_still_has_a_visible_body() {
215 fn male_walk_still_has_a_visible_body() {
184 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
216 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))