From 54c769027155502cc0256e1fd42650dc00c175a4 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 13:34:10 +0000 Subject: [PATCH] Viewport: silhouette outlines on curved surfaces (back faces pushed out on screen, drawn behind the model) in the styles with edges; new visual style Shaded with Hidden Edges (edges behind the model drawn faintly) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/ui-egui/src/gpu.rs | 70 ++++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/prefs.rs | 4 +- crates/ui-egui/src/viewport.rs | 11 +++++- 4 files changed, 82 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index e75263c3..bdca8cfc 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -27,6 +27,8 @@ pub struct GpuScene { pub glass: Vec, /// Depth-tested lines. pub lines: Vec, + /// Lines drawn only where the model hides them (hidden edges). + pub hidden: Vec, /// Lines drawn over everything (active sketch, highlights). pub overlay: Vec, /// Translucent triangles pushed slightly back in depth, so they only show where nothing @@ -52,6 +54,14 @@ impl GpuScene { } self.trans.extend_from_slice(&c); } + /// A line shown only where the model hides it. + pub fn hidden_line(&mut self, a: [f32; 3], b: [f32; 3], c: [u8; 4], width: f32) { + for v in a.iter().chain(&b) { + self.hidden.extend_from_slice(&v.to_le_bytes()); + } + self.hidden.extend_from_slice(&c); + self.hidden.extend_from_slice(&width.to_le_bytes()); + } pub fn glass_tri(&mut self, p: [f32; 3], n: [f32; 3], c: [u8; 4]) { for v in p.iter().chain(&n) { self.glass.extend_from_slice(&v.to_le_bytes()); @@ -134,6 +144,8 @@ pub struct ViewportCallback { pub analysis: [f32; 8], /// Accessibility: the depth map seen from the access direction. pub access: Option, + /// Draw silhouette outlines around the model (visual styles with edges). + pub outline: bool, } /// Heights of the model seen from a direction (an orthographic depth map): a point is reachable @@ -162,6 +174,7 @@ struct Batches { trans: Option, glass: Option, lines: Option, + hidden: Option, overlays: Option, ghost: Option, xray: Option, @@ -178,6 +191,7 @@ impl Batches { self.trans = upload(device, "sc_trans", &sc.trans, TRI_SIZE); self.glass = upload(device, "sc_glass", &sc.glass, TRI_SIZE); self.lines = upload(device, "sc_lines", &sc.lines, LINE_SIZE); + self.hidden = upload(device, "sc_hidden", &sc.hidden, LINE_SIZE); self.overlays = upload(device, "sc_overlay", &sc.overlay, LINE_SIZE); self.ghost = upload(device, "sc_ghost", &sc.ghost, TRI_SIZE); self.xray = upload(device, "sc_xray", &sc.xray, TRI_SIZE); @@ -198,6 +212,10 @@ struct Resources { ghost: wgpu::RenderPipeline, xray: wgpu::RenderPipeline, line: wgpu::RenderPipeline, + /// Lines behind the model (depth test >). + line_hidden: wgpu::RenderPipeline, + /// Silhouettes: back faces pushed out a little on screen, drawn dark behind the model. + outline: wgpu::RenderPipeline, overlay: wgpu::RenderPipeline, uniform: wgpu::Buffer, bind: wgpu::BindGroup, @@ -447,6 +465,28 @@ fn fs_line(i: LOut) -> @location(0) vec4 { return out_color(i.c); } +/// Silhouettes: back faces pushed out along their screen-space normal by about 1.5 px. +@vertex +fn vs_outline(@location(0) p: vec3, @location(1) n: vec3, @location(2) c: vec4) -> TOut { + var o: TOut; + o.pos = u.vp * vec4(p, 1.0); + let nc = (u.vp * vec4(n, 0.0)).xy * u.screen.xy; + if (length(nc) > 1e-6) { + let d = normalize(nc) * 1.5 * 2.0 / u.screen.xy; + o.pos = vec4(o.pos.xy + d * o.pos.w, o.pos.zw); + } + o.n = vec3(0.0); + o.c = c; + o.wp = p; + return o; +} + +@fragment +fn fs_outline(i: TOut) -> @location(0) vec4 { + if (clipped(i.wp)) { discard; } + return out_color(vec4(0.07, 0.08, 0.10, 1.0)); +} + struct IOut { @builtin(position) pos: vec4, @location(0) uv: vec2, @@ -701,6 +741,32 @@ impl Resources { ghost: pipeline("sc_ghost", "vs_ghost", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), xray: pipeline("sc_xray", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::Always, false), alpha), line: pipeline("sc_lines", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), + line_hidden: pipeline("sc_lines_hidden", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::Greater, false), alpha), + outline: device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("sc_outline"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &module, + entry_point: Some("vs_outline"), + buffers: &[Some(tri_layout())], + compilation_options: Default::default(), + }, + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + cull_mode: Some(wgpu::Face::Front), + ..Default::default() + }, + depth_stencil: depth(wgpu::CompareFunction::Less, false), + 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_outline"), + targets: &[Some(wgpu::ColorTargetState { format: t.format, blend: alpha, write_mask: wgpu::ColorWrites::ALL })], + compilation_options: Default::default(), + }), + multiview_mask: None, + cache: None, + }), overlay: pipeline("sc_overlay", "vs_line", "fs_line_all", line_layout(), depth(wgpu::CompareFunction::Always, false), alpha), uniform, bind, @@ -834,7 +900,11 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { tris(pass, &res.tri, &m.tris); tris(pass, &res.tri, &pv.tris); tris(pass, &res.tri_hl, &h.tris); + if self.outline { + tris(pass, &res.outline, &m.tris); + } lines(pass, &res.line, &m.lines); + lines(pass, &res.line_hidden, &m.hidden); lines(pass, &res.line, &pv.lines); lines(pass, &res.line, &h.lines); for (id, q) in &res.quads { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 667bd0ef..6bb572e8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -300,7 +300,7 @@ impl SolveApp { self.autosave_minutes = m.clamp(0.0, 600.0); } if let Some(s) = v.get("visual_style").and_then(Value::as_u64) { - self.ui.visual_style = u8::try_from(s.min(2)).unwrap_or(0); + self.ui.visual_style = u8::try_from(s.min(3)).unwrap_or(0); } // Browser folders the user opened or closed. let keys = |k: &str| -> std::collections::BTreeSet { diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index c086e583..fe6d2cf9 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -284,8 +284,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ui.checkbox(&mut app.ui.perspective, ""); ui.end_row(); ui.label("Visual style"); - let styles = ["Shaded with edges", "Shaded", "Wireframe"]; - egui::ComboBox::from_id_salt("pref_style").selected_text(styles[usize::from(app.ui.visual_style.min(2))]).show_ui( + let styles = ["Shaded with edges", "Shaded", "Wireframe", "Shaded with hidden edges"]; + egui::ComboBox::from_id_salt("pref_style").selected_text(styles[usize::from(app.ui.visual_style.min(3))]).show_ui( ui, |ui| { for (i, s) in styles.iter().enumerate() { diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 71cf13f5..67479d61 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -226,6 +226,9 @@ fn build_scene(app: &SolveApp) -> GpuScene { _ => (true, true), }; let edge_col = if shaded { c4(tk.body_edge) } else { c4(tk.text) }; + // Shaded with hidden edges: edges behind the model show faintly. + let hidden_edges = app.ui.visual_style == 3; + let hidden_col = c4(tk.text.gamma_multiply(0.35)); // Appearances: the body's opacity, and faces with looks of their own. let opacity = s.doc.body_look(&b.name, b.feature).map_or(1.0, |l| l.opacity); let face_looks: std::collections::HashMap = @@ -261,6 +264,9 @@ fn build_scene(app: &SolveApp) -> GpuScene { } for w in e.windows(2) { sc.line(w[0].to_f32(), w[1].to_f32(), edge_col, 1.3, false); + if hidden_edges { + sc.hidden_line(w[0].to_f32(), w[1].to_f32(), hidden_col, 1.0); + } } } } @@ -1223,6 +1229,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { images: crate::ref_images::gpu_images(app), analysis: analysis_uniform(app), access: crate::access::map(app), + outline: matches!(app.ui.visual_style, 0 | 3), }; painter.add(egui_wgpu::Callback::new_paint_callback(rect, cb)); } else { @@ -1852,7 +1859,7 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { ("lookat", "Look At the selected face or plane (or the active sketch)"), ("home", "Home view"), ("perspective", "Perspective / orthographic"), - ("style", "Visual style: shaded with edges, shaded, wireframe"), + ("style", "Visual style: shaded with edges, shaded, wireframe, shaded with hidden edges"), ("settings", "Grid on/off"), ]; let w = items.len() as f32 * 30.0 + 10.0; @@ -1883,7 +1890,7 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { "fit" => app.animate_view("fit"), "home" => app.animate_view("home"), "lookat" => look_at_selection(app), - "style" => app.ui.visual_style = (app.ui.visual_style + 1) % 3, + "style" => app.ui.visual_style = (app.ui.visual_style + 1) % 4, "perspective" => app.ui.perspective = !app.ui.perspective, _ => app.ui.show_grid = !app.ui.show_grid, }