BlueGit

golden-age paperdoll-renderer

Merge verified preview depth/aspect fixes into equipment feature

timfa Grok Bot · pushed by timfa

Authored 2026-11-06 · 7 days ago

Hash
9f535e7f2e4f6d6c6446a49b6f38ff85e10bd990
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
@@ -332,6 +332,11 @@ impl EditorApp {
332 332 self.session.set_render(width, height, fps);
333 333 }
334 334
335 /// Start the same worker used by the Render button (also used by the GUI smoke fixture).
336 pub fn render_current_pose(&mut self) {
337 self.start_render([480, 640]);
338 }
339
335 340 fn start_render(&mut self, preview: [u32; 2]) {
336 341 if let Some(opened) = &self.session.opened {
337 342 if let Err(error) = opened.document.validate_shape() {
@@ -677,8 +682,22 @@ impl EditorApp {
677 682 ..Default::default()
678 683 },
679 684 );
680 let available = ui.available_size();
681 ui.image((texture.id(), available));
685 let (bounds, _) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover());
686 let fit = crate::render_job::fit_preview(
687 [image.width, image.height],
688 [bounds.width(), bounds.height()],
689 );
690 let rect = egui::Rect::from_min_size(
691 bounds.min + egui::vec2(fit.offset[0], fit.offset[1]),
692 egui::vec2(fit.size[0], fit.size[1]),
693 );
694 ui.painter().rect_filled(bounds, 0.0, egui::Color32::BLACK);
695 ui.painter().image(
696 texture.id(),
697 rect,
698 egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
699 egui::Color32::WHITE,
700 );
682 701 }
683 702
684 703 fn show_equipment_list(&mut self, ctx: &egui::Context) {
@@ -332,6 +332,11 @@ impl EditorApp {
332 self.session.set_render(width, height, fps);
332 self.session.set_render(width, height, fps);
333 }
333 }
334
334
335 /// Start the same worker used by the Render button (also used by the GUI smoke fixture).
336 pub fn render_current_pose(&mut self) {
337 self.start_render([480, 640]);
338 }
339
335 fn start_render(&mut self, preview: [u32; 2]) {
340 fn start_render(&mut self, preview: [u32; 2]) {
336 if let Some(opened) = &self.session.opened {
341 if let Some(opened) = &self.session.opened {
337 if let Err(error) = opened.document.validate_shape() {
342 if let Err(error) = opened.document.validate_shape() {
@@ -677,8 +682,22 @@ impl EditorApp {
677 ..Default::default()
682 ..Default::default()
678 },
683 },
679 );
684 );
680 let available = ui.available_size();
685 let (bounds, _) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover());
681 ui.image((texture.id(), available));
686 let fit = crate::render_job::fit_preview(
687 [image.width, image.height],
688 [bounds.width(), bounds.height()],
689 );
690 let rect = egui::Rect::from_min_size(
691 bounds.min + egui::vec2(fit.offset[0], fit.offset[1]),
692 egui::vec2(fit.size[0], fit.size[1]),
693 );
694 ui.painter().rect_filled(bounds, 0.0, egui::Color32::BLACK);
695 ui.painter().image(
696 texture.id(),
697 rect,
698 egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
699 egui::Color32::WHITE,
700 );
682 }
701 }
683
702
684 fn show_equipment_list(&mut self, ctx: &egui::Context) {
703 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 {
@@ -352,6 +362,8 @@ impl GpuCache {
352 362 viewport.height_px,
353 363 );
354 364 gl.enable(glow::DEPTH_TEST);
365 gl.depth_mask(true);
366 gl.clear_depth_f32(1.0);
355 367 gl.depth_func(glow::LESS);
356 368 gl.disable(glow::BLEND);
357 369 gl.clear_color(0.08, 0.09, 0.12, 1.0);
@@ -634,3 +646,158 @@ void main() {
634 646 frag = vec4(clamp(color.rgb * shade + highlight,0.0,1.0), 1.0);
635 647 }
636 648 "#;
649
650 /// Real GPU regression using the same cache/shader/paint path as the callback.
651 /// Tests front-first/back-first overlap, depth writes, and the next-frame clear.
652 ///
653 /// # Safety
654 /// `gl` must be a current native OpenGL context. Run in a disposable test window;
655 /// this probe changes GL state and restores the default framebuffer afterwards.
656 pub unsafe fn depth_regression_probe(gl: &glow::Context) -> Result<(), String> {
657 let native_bits = gl.get_framebuffer_attachment_parameter_i32(
658 glow::FRAMEBUFFER,
659 glow::DEPTH,
660 glow::FRAMEBUFFER_ATTACHMENT_DEPTH_SIZE,
661 );
662 if native_bits < 24 {
663 return Err(format!(
664 "native depth attachment ontbreekt: {native_bits} bits"
665 ));
666 }
667 let framebuffer = gl.create_framebuffer()?;
668 let texture = gl.create_texture()?;
669 let depth = gl.create_renderbuffer()?;
670 gl.bind_framebuffer(glow::FRAMEBUFFER, Some(framebuffer));
671 gl.bind_texture(glow::TEXTURE_2D, Some(texture));
672 gl.tex_parameter_i32(
673 glow::TEXTURE_2D,
674 glow::TEXTURE_MIN_FILTER,
675 glow::NEAREST as i32,
676 );
677 gl.tex_parameter_i32(
678 glow::TEXTURE_2D,
679 glow::TEXTURE_MAG_FILTER,
680 glow::NEAREST as i32,
681 );
682 gl.tex_image_2d(
683 glow::TEXTURE_2D,
684 0,
685 glow::RGBA8 as i32,
686 32,
687 32,
688 0,
689 glow::RGBA,
690 glow::UNSIGNED_BYTE,
691 None,
692 );
693 gl.framebuffer_texture_2d(
694 glow::FRAMEBUFFER,
695 glow::COLOR_ATTACHMENT0,
696 glow::TEXTURE_2D,
697 Some(texture),
698 0,
699 );
700 gl.bind_renderbuffer(glow::RENDERBUFFER, Some(depth));
701 gl.renderbuffer_storage(glow::RENDERBUFFER, glow::DEPTH_COMPONENT24, 32, 32);
702 gl.framebuffer_renderbuffer(
703 glow::FRAMEBUFFER,
704 glow::DEPTH_ATTACHMENT,
705 glow::RENDERBUFFER,
706 Some(depth),
707 );
708 gl.draw_buffers(&[glow::COLOR_ATTACHMENT0]);
709 gl.read_buffer(glow::COLOR_ATTACHMENT0);
710 if gl.check_framebuffer_status(glow::FRAMEBUFFER) != glow::FRAMEBUFFER_COMPLETE {
711 return Err("onvolledig offscreen depth-target".into());
712 }
713 let info = egui::PaintCallbackInfo {
714 viewport: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(32., 32.)),
715 clip_rect: egui::Rect::EVERYTHING,
716 pixels_per_point: 1.,
717 screen_size_px: [32, 32],
718 };
719 let make_frame = |indices: Vec<u32>, generation: u64| PendingFrame {
720 generation,
721 positions: vec![
722 [-0.9, -0.9, -0.5],
723 [0.9, -0.9, -0.5],
724 [0., 0.9, -0.5],
725 [-0.9, -0.9, 0.5],
726 [0.9, -0.9, 0.5],
727 [0., 0.9, 0.5],
728 ],
729 normals: vec![
730 [0.35, 0.85, 0.4],
731 [0.35, 0.85, 0.4],
732 [0.35, 0.85, 0.4],
733 [-0.35, -0.85, -0.4],
734 [-0.35, -0.85, -0.4],
735 [-0.35, -0.85, -0.4],
736 ],
737 joints: vec![[0; 4]; 6],
738 weights: vec![[1., 0., 0., 0.]; 6],
739 indices,
740 bones: vec![crate::preview::identity()],
741 view_proj: crate::preview::identity(),
742 color: [1.; 4],
743 };
744 let mut cache = GpuCache::default();
745 let read = || {
746 let mut rgba = [0u8; 4];
747 gl.read_pixels(
748 16,
749 16,
750 1,
751 1,
752 glow::RGBA,
753 glow::UNSIGNED_BYTE,
754 glow::PixelPackData::Slice(&mut rgba),
755 );
756 rgba
757 };
758 let result = (|| {
759 // Deliberately poison the incoming state: the callback owns its depth setup.
760 gl.depth_mask(false);
761 gl.clear_depth_f32(0.0);
762 gl.depth_func(glow::GREATER);
763 cache.pending = Some(make_frame(vec![0, 1, 2, 3, 4, 5], 1));
764 cache.paint(gl, &info);
765 let near = read();
766 if near[..3].iter().any(|c| *c < 250) {
767 return Err(format!("achterste vlak overschrijft voorste: {near:?}"));
768 }
769 cache.pending = Some(make_frame(vec![3, 4, 5], 2));
770 cache.paint(gl, &info);
771 let next = read();
772 if next[..3].iter().any(|c| !(69..=73).contains(c)) {
773 return Err(format!("depth niet gewist voor volgende frame: {next:?}"));
774 }
775 cache.pending = Some(make_frame(vec![3, 4, 5, 0, 1, 2], 3));
776 cache.paint(gl, &info);
777 let reverse = read();
778 if near != reverse {
779 return Err(format!(
780 "draw-order afhankelijke occlusie: {near:?} / {reverse:?}"
781 ));
782 }
783 println!("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}");
784 Ok(())
785 })();
786 if let Some(p) = cache.program {
787 gl.delete_program(p)
788 }
789 if let Some(v) = cache.vao {
790 gl.delete_vertex_array(v)
791 }
792 if let Some(v) = cache.vbo {
793 gl.delete_buffer(v)
794 }
795 if let Some(v) = cache.ebo {
796 gl.delete_buffer(v)
797 }
798 gl.bind_framebuffer(glow::FRAMEBUFFER, None);
799 gl.delete_framebuffer(framebuffer);
800 gl.delete_texture(texture);
801 gl.delete_renderbuffer(depth);
802 result
803 }
@@ -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 {
@@ -352,6 +362,8 @@ impl GpuCache {
352 viewport.height_px,
362 viewport.height_px,
353 );
363 );
354 gl.enable(glow::DEPTH_TEST);
364 gl.enable(glow::DEPTH_TEST);
365 gl.depth_mask(true);
366 gl.clear_depth_f32(1.0);
355 gl.depth_func(glow::LESS);
367 gl.depth_func(glow::LESS);
356 gl.disable(glow::BLEND);
368 gl.disable(glow::BLEND);
357 gl.clear_color(0.08, 0.09, 0.12, 1.0);
369 gl.clear_color(0.08, 0.09, 0.12, 1.0);
@@ -634,3 +646,158 @@ void main() {
634 frag = vec4(clamp(color.rgb * shade + highlight,0.0,1.0), 1.0);
646 frag = vec4(clamp(color.rgb * shade + highlight,0.0,1.0), 1.0);
635}
647}
636"#;
648"#;
649
650/// Real GPU regression using the same cache/shader/paint path as the callback.
651/// Tests front-first/back-first overlap, depth writes, and the next-frame clear.
652///
653/// # Safety
654/// `gl` must be a current native OpenGL context. Run in a disposable test window;
655/// this probe changes GL state and restores the default framebuffer afterwards.
656pub unsafe fn depth_regression_probe(gl: &glow::Context) -> Result<(), String> {
657 let native_bits = gl.get_framebuffer_attachment_parameter_i32(
658 glow::FRAMEBUFFER,
659 glow::DEPTH,
660 glow::FRAMEBUFFER_ATTACHMENT_DEPTH_SIZE,
661 );
662 if native_bits < 24 {
663 return Err(format!(
664 "native depth attachment ontbreekt: {native_bits} bits"
665 ));
666 }
667 let framebuffer = gl.create_framebuffer()?;
668 let texture = gl.create_texture()?;
669 let depth = gl.create_renderbuffer()?;
670 gl.bind_framebuffer(glow::FRAMEBUFFER, Some(framebuffer));
671 gl.bind_texture(glow::TEXTURE_2D, Some(texture));
672 gl.tex_parameter_i32(
673 glow::TEXTURE_2D,
674 glow::TEXTURE_MIN_FILTER,
675 glow::NEAREST as i32,
676 );
677 gl.tex_parameter_i32(
678 glow::TEXTURE_2D,
679 glow::TEXTURE_MAG_FILTER,
680 glow::NEAREST as i32,
681 );
682 gl.tex_image_2d(
683 glow::TEXTURE_2D,
684 0,
685 glow::RGBA8 as i32,
686 32,
687 32,
688 0,
689 glow::RGBA,
690 glow::UNSIGNED_BYTE,
691 None,
692 );
693 gl.framebuffer_texture_2d(
694 glow::FRAMEBUFFER,
695 glow::COLOR_ATTACHMENT0,
696 glow::TEXTURE_2D,
697 Some(texture),
698 0,
699 );
700 gl.bind_renderbuffer(glow::RENDERBUFFER, Some(depth));
701 gl.renderbuffer_storage(glow::RENDERBUFFER, glow::DEPTH_COMPONENT24, 32, 32);
702 gl.framebuffer_renderbuffer(
703 glow::FRAMEBUFFER,
704 glow::DEPTH_ATTACHMENT,
705 glow::RENDERBUFFER,
706 Some(depth),
707 );
708 gl.draw_buffers(&[glow::COLOR_ATTACHMENT0]);
709 gl.read_buffer(glow::COLOR_ATTACHMENT0);
710 if gl.check_framebuffer_status(glow::FRAMEBUFFER) != glow::FRAMEBUFFER_COMPLETE {
711 return Err("onvolledig offscreen depth-target".into());
712 }
713 let info = egui::PaintCallbackInfo {
714 viewport: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(32., 32.)),
715 clip_rect: egui::Rect::EVERYTHING,
716 pixels_per_point: 1.,
717 screen_size_px: [32, 32],
718 };
719 let make_frame = |indices: Vec<u32>, generation: u64| PendingFrame {
720 generation,
721 positions: vec![
722 [-0.9, -0.9, -0.5],
723 [0.9, -0.9, -0.5],
724 [0., 0.9, -0.5],
725 [-0.9, -0.9, 0.5],
726 [0.9, -0.9, 0.5],
727 [0., 0.9, 0.5],
728 ],
729 normals: vec![
730 [0.35, 0.85, 0.4],
731 [0.35, 0.85, 0.4],
732 [0.35, 0.85, 0.4],
733 [-0.35, -0.85, -0.4],
734 [-0.35, -0.85, -0.4],
735 [-0.35, -0.85, -0.4],
736 ],
737 joints: vec![[0; 4]; 6],
738 weights: vec![[1., 0., 0., 0.]; 6],
739 indices,
740 bones: vec![crate::preview::identity()],
741 view_proj: crate::preview::identity(),
742 color: [1.; 4],
743 };
744 let mut cache = GpuCache::default();
745 let read = || {
746 let mut rgba = [0u8; 4];
747 gl.read_pixels(
748 16,
749 16,
750 1,
751 1,
752 glow::RGBA,
753 glow::UNSIGNED_BYTE,
754 glow::PixelPackData::Slice(&mut rgba),
755 );
756 rgba
757 };
758 let result = (|| {
759 // Deliberately poison the incoming state: the callback owns its depth setup.
760 gl.depth_mask(false);
761 gl.clear_depth_f32(0.0);
762 gl.depth_func(glow::GREATER);
763 cache.pending = Some(make_frame(vec![0, 1, 2, 3, 4, 5], 1));
764 cache.paint(gl, &info);
765 let near = read();
766 if near[..3].iter().any(|c| *c < 250) {
767 return Err(format!("achterste vlak overschrijft voorste: {near:?}"));
768 }
769 cache.pending = Some(make_frame(vec![3, 4, 5], 2));
770 cache.paint(gl, &info);
771 let next = read();
772 if next[..3].iter().any(|c| !(69..=73).contains(c)) {
773 return Err(format!("depth niet gewist voor volgende frame: {next:?}"));
774 }
775 cache.pending = Some(make_frame(vec![3, 4, 5, 0, 1, 2], 3));
776 cache.paint(gl, &info);
777 let reverse = read();
778 if near != reverse {
779 return Err(format!(
780 "draw-order afhankelijke occlusie: {near:?} / {reverse:?}"
781 ));
782 }
783 println!("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}");
784 Ok(())
785 })();
786 if let Some(p) = cache.program {
787 gl.delete_program(p)
788 }
789 if let Some(v) = cache.vao {
790 gl.delete_vertex_array(v)
791 }
792 if let Some(v) = cache.vbo {
793 gl.delete_buffer(v)
794 }
795 if let Some(v) = cache.ebo {
796 gl.delete_buffer(v)
797 }
798 gl.bind_framebuffer(glow::FRAMEBUFFER, None);
799 gl.delete_framebuffer(framebuffer);
800 gl.delete_texture(texture);
801 gl.delete_renderbuffer(depth);
802 result
803}
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"))