From b5fd55cce6382ec10f0dd8ac7500535dff3e607d Mon Sep 17 00:00:00 2001 From: CypherNaugh_0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:19:46 +0200 Subject: [PATCH] Section Analysis cut faces hide the inside of the body: the cap was drawn on the far walls' back faces at their own depth, so every edge between the cut and the far wall (hole and boss edges, corner seams) showed through it like a pane of glass. While a section is shown the solids mark in the stencil where the cap is the visible surface, then draw the cap again there at the depth where the view ray meets the section plane (orthographic and perspective), so holes stay open and the inside is hidden. The native app asks for an 8-bit stencil; without one (eframe's web painter has none) the cap draws as before. Not covered by an automated test (no GPU in the test suite); checked in the app with before/after screenshots --- apps/solvecraft-web/src/main.rs | 3 +- apps/solvecraft/src/main.rs | 5 +- crates/ui-egui/src/gpu.rs | 135 +++++++++++++++++++++++++------- crates/ui-egui/src/lib.rs | 8 +- crates/ui-egui/src/viewport.rs | 4 + 5 files changed, 121 insertions(+), 34 deletions(-) diff --git a/apps/solvecraft-web/src/main.rs b/apps/solvecraft-web/src/main.rs index ad31f8a2..a62b041b 100644 --- a/apps/solvecraft-web/src/main.rs +++ b/apps/solvecraft-web/src/main.rs @@ -376,7 +376,8 @@ mod web { app.load_prefs(&p); } if let Some(rs) = &cc.wgpu_render_state { - app.set_wgpu(rs, DEPTH_BITS, 1); + // eframe's web painter has no stencil buffer. + app.set_wgpu(rs, DEPTH_BITS, 0, 1); } if query().contains("sample") { if let Err(e) = app.session.run_script(&solvecraft_engine::sample::script()) { diff --git a/apps/solvecraft/src/main.rs b/apps/solvecraft/src/main.rs index 21132f54..4dcdcc4d 100644 --- a/apps/solvecraft/src/main.rs +++ b/apps/solvecraft/src/main.rs @@ -94,6 +94,8 @@ fn msaa() -> u16 { const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos"); const DEPTH_BITS: u8 = 24; +/// The section cap marks where it shows in the stencil. +const STENCIL_BITS: u8 = 8; const MSAA: u16 = 4; fn main() -> eframe::Result { @@ -129,6 +131,7 @@ fn main() -> eframe::Result { .with_title_shown(false) .with_app_id("ai.storyteller.solvecraft"), depth_buffer: DEPTH_BITS, + stencil_buffer: STENCIL_BITS, multisampling: samples, ..Default::default() }; @@ -155,7 +158,7 @@ fn main() -> eframe::Result { app.load_prefs(&p); } if let Some(rs) = &cc.wgpu_render_state { - app.set_wgpu(rs, DEPTH_BITS, u32::from(samples)); + app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, u32::from(samples)); } if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index beef00be..13acae14 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -11,7 +11,7 @@ use egui_wgpu::wgpu::util::DeviceExt; pub const TRI_SIZE: usize = 28; /// Line instance: a, b (3 × f32 each), colour (4 × u8), width in pixels (f32). pub const LINE_SIZE: usize = 32; -const UNIFORM_SIZE: u64 = 192; +const UNIFORM_SIZE: u64 = 208; /// CPU-side geometry waiting to be uploaded. The model scene changes with the design; the /// highlight scene (hover, selection, the origin widget) changes often and is small. @@ -146,6 +146,8 @@ pub struct ViewportCallback { pub access: Option, /// Draw silhouette outlines around the model (visual styles with edges). pub outline: bool, + /// Eye position and 1 for a perspective camera (0: orthographic), for the section cap's depth. + pub eye: [f32; 4], } /// Heights of the model seen from a direction (an orthographic depth map): a point is reachable @@ -208,6 +210,9 @@ impl Batches { struct Resources { tri: wgpu::RenderPipeline, + /// While a section is shown (when the depth buffer has a stencil): `tri` marking where the + /// cap shows, then the cap again at the section plane's depth, so it hides the inside. + tri_cut: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>, /// Highlight triangles over the model (depth test ≤, no depth write). tri_hl: wgpu::RenderPipeline, trans: wgpu::RenderPipeline, @@ -281,6 +286,7 @@ struct U { pull: vec4, // draft pull direction acc_u: vec4, // accessibility map axes (xyz, offset) acc_v: vec4, + eye: vec4, // eye position, w = 1 for perspective (0: orthographic, looking along -back) }; @group(1) @binding(2) var acc_t: texture_2d; @@ -321,6 +327,41 @@ fn vs_tri(@location(0) p: vec3, @location(1) n: vec3, @location(2) c: /// Opaque model surfaces: cut by the section plane, the inside shows as a flat cap colour. @fragment fn fs_solid(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4 { + let c = solid(i, front); + return c; +} + +struct CutOut { + @location(0) c: vec4, + @builtin(frag_depth) depth: f32, +}; + +/// The section cap (the inside seen through the cut, drawn on the far walls' back faces where +/// they are the visible surface) again, at the depth of the section plane rather than of the +/// far wall, so the cut face hides what lies inside the body instead of showing it like a +/// pane of glass. +@fragment +fn fs_solid_cut(i: TOut, @builtin(front_facing) front: bool) -> CutOut { + var o: CutOut; + o.c = solid(i, front); + o.depth = cap_depth(i.wp, i.pos.z); + return o; +} + +/// Depth of the section plane on the view ray through `wp` (a far wall seen through the cut), +/// never behind the wall itself (`z`). +fn cap_depth(wp: vec3, z: f32) -> f32 { + var dir = -u.back.xyz; + if (u.eye.w > 0.5) { dir = wp - u.eye.xyz; } + let dn = dot(u.clip.xyz, dir); + if (abs(dn) < 1e-9) { return z; } + let p = wp + dir * ((u.clip.w - dot(u.clip.xyz, wp)) / dn); + let c = u.vp * vec4(p, 1.0); + if (c.w <= 0.0) { return z; } + return clamp(c.z / c.w, 0.0, z); +} + +fn solid(i: TOut, front: bool) -> vec4 { // Derivatives first, in uniform control flow (browsers' WGSL compilers reject them after // a branch that returns; the whole shader module then fails and nothing is drawn). let k = curvature(i); @@ -699,27 +740,29 @@ impl Resources { bind_group_layouts: &[Some(&bgl), Some(&access_bgl)], immediate_size: 0, }); - let solid = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("sc_tris"), - layout: Some(&solid_layout), - vertex: wgpu::VertexState { - module: &module, - entry_point: Some("vs_tri"), - buffers: &[Some(tri_layout())], - compilation_options: Default::default(), - }, - primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, - depth_stencil: depth(wgpu::CompareFunction::Less, true), - multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false }, - fragment: Some(wgpu::FragmentState { - module: &module, - entry_point: Some("fs_solid"), - targets: &[Some(wgpu::ColorTargetState { format: t.format, blend: None, write_mask: wgpu::ColorWrites::ALL })], - compilation_options: Default::default(), - }), - multiview_mask: None, - cache: None, - }); + let solid = |label: &str, fs: &str, cull: Option, ds: Option| { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(&solid_layout), + vertex: wgpu::VertexState { + module: &module, + entry_point: Some("vs_tri"), + buffers: &[Some(tri_layout())], + compilation_options: Default::default(), + }, + primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, cull_mode: cull, ..Default::default() }, + depth_stencil: ds, + multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false }, + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some(fs), + targets: &[Some(wgpu::ColorTargetState { format: t.format, blend: None, write_mask: wgpu::ColorWrites::ALL })], + compilation_options: Default::default(), + }), + multiview_mask: None, + cache: None, + }) + }; let access_bind = access_texture(device, None, &access_bgl, 1, &[]); let sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("sc_image"), @@ -733,7 +776,29 @@ impl Resources { sampler, textures: Default::default(), quads: Vec::new(), - tri: solid, + tri: solid("sc_tris", "fs_solid", None, depth(wgpu::CompareFunction::Less, true)), + tri_cut: t.depth.filter(|f| f.has_stencil_aspect()).map(|format| { + // The visible surface marks the stencil: 1 where it is a back face (the cap). + use wgpu::StencilOperation::{Keep, Replace, Zero}; + let face = |pass_op| wgpu::StencilFaceState { compare: wgpu::CompareFunction::Always, fail_op: Keep, depth_fail_op: Keep, pass_op }; + let mark = wgpu::DepthStencilState { + format, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::Less), + stencil: wgpu::StencilState { front: face(Zero), back: face(Replace), read_mask: 0xff, write_mask: 0xff }, + bias: Default::default(), + }; + // Then the cap's back faces again, only there, at the section plane's depth. + let only = wgpu::StencilFaceState { compare: wgpu::CompareFunction::Equal, ..face(Keep) }; + let cap = wgpu::DepthStencilState { + format, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::Always), + stencil: wgpu::StencilState { front: only, back: only, read_mask: 0xff, write_mask: 0 }, + bias: Default::default(), + }; + (solid("sc_tris_mark", "fs_solid", None, Some(mark)), solid("sc_tris_cap", "fs_solid_cut", Some(wgpu::Face::Front), Some(cap))) + }), access_bgl, access_bind, access_version: 0, @@ -795,9 +860,10 @@ impl Batches { } } -/// Create the pipelines. Call once with the app's render state and its depth/MSAA settings. -pub fn install(rs: &egui_wgpu::RenderState, depth_bits: u8, samples: u32) -> GpuTarget { - let t = GpuTarget { format: rs.target_format, depth: egui_wgpu::depth_format_from_bits(depth_bits, 0), samples }; +/// Create the pipelines. Call once with the app's render state and its depth/stencil/MSAA +/// settings. Without a stencil the section cap does not hide the inside of cut bodies. +pub fn install(rs: &egui_wgpu::RenderState, depth_bits: u8, stencil_bits: u8, samples: u32) -> GpuTarget { + let t = GpuTarget { format: rs.target_format, depth: egui_wgpu::depth_format_from_bits(depth_bits, stencil_bits), samples }; let res = Resources::new(&rs.device, t); rs.renderer.write().callback_resources.insert(res); t @@ -901,8 +967,20 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { let (m, h, pv) = (&res.model, &res.highlight, &res.preview); lines(pass, &res.overlay, &m.under); pass.set_bind_group(1, &res.access_bind, &[]); - tris(pass, &res.tri, &m.tris); - tris(pass, &res.tri, &pv.tris); + let cut = res.tri_cut.as_ref().filter(|_| self.clip[..3].iter().map(|c| c * c).sum::() > 0.25); + match cut { + Some((mark, cap)) => { + pass.set_stencil_reference(1); + tris(pass, mark, &m.tris); + tris(pass, mark, &pv.tris); + tris(pass, cap, &m.tris); + tris(pass, cap, &pv.tris); + } + None => { + tris(pass, &res.tri, &m.tris); + tris(pass, &res.tri, &pv.tris); + } + } tris(pass, &res.tri_hl, &h.tris); lines(pass, &res.line, &m.lines); lines(pass, &res.line_hidden, &m.hidden); @@ -943,6 +1021,7 @@ pub fn uniform_bytes(cb: &ViewportCallback, w: f32, h: f32, linear: bool) -> Vec } None => v.extend([0.0; 8]), } + v.extend(cb.eye); v.iter().flat_map(|x| x.to_le_bytes()).collect() } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index dad3b43e..8b422eb0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -294,10 +294,10 @@ impl SolveApp { } } - /// Draw the viewport on the GPU (eframe's wgpu render state, with the app's depth buffer - /// bits and MSAA sample count). Without it the viewport renders on the CPU. - pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState, depth_bits: u8, samples: u32) { - self.viewport.gpu = Some(gpu::install(rs, depth_bits, samples)); + /// Draw the viewport on the GPU (eframe's wgpu render state, with the app's depth and + /// stencil buffer bits and MSAA sample count). Without it the viewport renders on the CPU. + pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState, depth_bits: u8, stencil_bits: u8, samples: u32) { + self.viewport.gpu = Some(gpu::install(rs, depth_bits, stencil_bits, samples)); let info = rs.adapter.get_info(); self.help.gpu = Some(format!("{} ({:?}, {:?})", info.name.trim(), info.backend, info.device_type)); } diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index e8e375b0..c70fefff 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -1478,6 +1478,10 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { analysis: analysis_uniform(app), access: crate::access::map(app), outline: matches!(app.ui.visual_style, 0 | 3), + eye: { + let e = proj.cam.eye().to_f32(); + [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }] + }, }; painter.add(egui_wgpu::Callback::new_paint_callback(rect, cb)); } else {