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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 14:14:29 +00:00
1 parent aee1e941bf
commit 54c7690271
4 files changed
+82 -5

No files matched your search

+70
View File
@@ -27,6 +27,8 @@ pub struct GpuScene {
pub glass: Vec<u8>, pub glass: Vec<u8>,
/// Depth-tested lines. /// Depth-tested lines.
pub lines: Vec<u8>, pub lines: Vec<u8>,
/// Lines drawn only where the model hides them (hidden edges).
pub hidden: Vec<u8>,
/// Lines drawn over everything (active sketch, highlights). /// Lines drawn over everything (active sketch, highlights).
pub overlay: Vec<u8>, pub overlay: Vec<u8>,
/// Translucent triangles pushed slightly back in depth, so they only show where nothing /// 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); 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]) { pub fn glass_tri(&mut self, p: [f32; 3], n: [f32; 3], c: [u8; 4]) {
for v in p.iter().chain(&n) { for v in p.iter().chain(&n) {
self.glass.extend_from_slice(&v.to_le_bytes()); self.glass.extend_from_slice(&v.to_le_bytes());
@@ -134,6 +144,8 @@ pub struct ViewportCallback {
pub analysis: [f32; 8], pub analysis: [f32; 8],
/// Accessibility: the depth map seen from the access direction. /// Accessibility: the depth map seen from the access direction.
pub access: Option<AccessMap>, pub access: Option<AccessMap>,
/// 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 /// Heights of the model seen from a direction (an orthographic depth map): a point is reachable
@@ -162,6 +174,7 @@ struct Batches {
trans: Option<Batch>, trans: Option<Batch>,
glass: Option<Batch>, glass: Option<Batch>,
lines: Option<Batch>, lines: Option<Batch>,
hidden: Option<Batch>,
overlays: Option<Batch>, overlays: Option<Batch>,
ghost: Option<Batch>, ghost: Option<Batch>,
xray: Option<Batch>, xray: Option<Batch>,
@@ -178,6 +191,7 @@ impl Batches {
self.trans = upload(device, "sc_trans", &sc.trans, TRI_SIZE); self.trans = upload(device, "sc_trans", &sc.trans, TRI_SIZE);
self.glass = upload(device, "sc_glass", &sc.glass, TRI_SIZE); self.glass = upload(device, "sc_glass", &sc.glass, TRI_SIZE);
self.lines = upload(device, "sc_lines", &sc.lines, LINE_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.overlays = upload(device, "sc_overlay", &sc.overlay, LINE_SIZE);
self.ghost = upload(device, "sc_ghost", &sc.ghost, TRI_SIZE); self.ghost = upload(device, "sc_ghost", &sc.ghost, TRI_SIZE);
self.xray = upload(device, "sc_xray", &sc.xray, TRI_SIZE); self.xray = upload(device, "sc_xray", &sc.xray, TRI_SIZE);
@@ -198,6 +212,10 @@ struct Resources {
ghost: wgpu::RenderPipeline, ghost: wgpu::RenderPipeline,
xray: wgpu::RenderPipeline, xray: wgpu::RenderPipeline,
line: 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, overlay: wgpu::RenderPipeline,
uniform: wgpu::Buffer, uniform: wgpu::Buffer,
bind: wgpu::BindGroup, bind: wgpu::BindGroup,
@@ -447,6 +465,28 @@ fn fs_line(i: LOut) -> @location(0) vec4<f32> {
return out_color(i.c); 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<f32>, @location(1) n: vec3<f32>, @location(2) c: vec4<f32>) -> TOut {
var o: TOut;
o.pos = u.vp * vec4<f32>(p, 1.0);
let nc = (u.vp * vec4<f32>(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<f32>(o.pos.xy + d * o.pos.w, o.pos.zw);
}
o.n = vec3<f32>(0.0);
o.c = c;
o.wp = p;
return o;
}
@fragment
fn fs_outline(i: TOut) -> @location(0) vec4<f32> {
if (clipped(i.wp)) { discard; }
return out_color(vec4<f32>(0.07, 0.08, 0.10, 1.0));
}
struct IOut { struct IOut {
@builtin(position) pos: vec4<f32>, @builtin(position) pos: vec4<f32>,
@location(0) uv: vec2<f32>, @location(0) uv: vec2<f32>,
@@ -701,6 +741,32 @@ impl Resources {
ghost: pipeline("sc_ghost", "vs_ghost", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), 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), 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: 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), overlay: pipeline("sc_overlay", "vs_line", "fs_line_all", line_layout(), depth(wgpu::CompareFunction::Always, false), alpha),
uniform, uniform,
bind, bind,
@@ -834,7 +900,11 @@ impl egui_wgpu::CallbackTrait for ViewportCallback {
tris(pass, &res.tri, &m.tris); tris(pass, &res.tri, &m.tris);
tris(pass, &res.tri, &pv.tris); tris(pass, &res.tri, &pv.tris);
tris(pass, &res.tri_hl, &h.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, &m.lines);
lines(pass, &res.line_hidden, &m.hidden);
lines(pass, &res.line, &pv.lines); lines(pass, &res.line, &pv.lines);
lines(pass, &res.line, &h.lines); lines(pass, &res.line, &h.lines);
for (id, q) in &res.quads { for (id, q) in &res.quads {
+1 -1
View File
@@ -300,7 +300,7 @@ impl SolveApp {
self.autosave_minutes = m.clamp(0.0, 600.0); self.autosave_minutes = m.clamp(0.0, 600.0);
} }
if let Some(s) = v.get("visual_style").and_then(Value::as_u64) { 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. // Browser folders the user opened or closed.
let keys = |k: &str| -> std::collections::BTreeSet<String> { let keys = |k: &str| -> std::collections::BTreeSet<String> {
+2 -2
View File
@@ -284,8 +284,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
ui.checkbox(&mut app.ui.perspective, ""); ui.checkbox(&mut app.ui.perspective, "");
ui.end_row(); ui.end_row();
ui.label("Visual style"); ui.label("Visual style");
let styles = ["Shaded with edges", "Shaded", "Wireframe"]; 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(2))]).show_ui( egui::ComboBox::from_id_salt("pref_style").selected_text(styles[usize::from(app.ui.visual_style.min(3))]).show_ui(
ui, ui,
|ui| { |ui| {
for (i, s) in styles.iter().enumerate() { for (i, s) in styles.iter().enumerate() {
+9 -2
View File
@@ -226,6 +226,9 @@ fn build_scene(app: &SolveApp) -> GpuScene {
_ => (true, true), _ => (true, true),
}; };
let edge_col = if shaded { c4(tk.body_edge) } else { c4(tk.text) }; 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. // 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 opacity = s.doc.body_look(&b.name, b.feature).map_or(1.0, |l| l.opacity);
let face_looks: std::collections::HashMap<usize, solvecraft_engine::doc::appearance::Look> = let face_looks: std::collections::HashMap<usize, solvecraft_engine::doc::appearance::Look> =
@@ -261,6 +264,9 @@ fn build_scene(app: &SolveApp) -> GpuScene {
} }
for w in e.windows(2) { for w in e.windows(2) {
sc.line(w[0].to_f32(), w[1].to_f32(), edge_col, 1.3, false); 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), images: crate::ref_images::gpu_images(app),
analysis: analysis_uniform(app), analysis: analysis_uniform(app),
access: crate::access::map(app), access: crate::access::map(app),
outline: matches!(app.ui.visual_style, 0 | 3),
}; };
painter.add(egui_wgpu::Callback::new_paint_callback(rect, cb)); painter.add(egui_wgpu::Callback::new_paint_callback(rect, cb));
} else { } 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)"), ("lookat", "Look At the selected face or plane (or the active sketch)"),
("home", "Home view"), ("home", "Home view"),
("perspective", "Perspective / orthographic"), ("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"), ("settings", "Grid on/off"),
]; ];
let w = items.len() as f32 * 30.0 + 10.0; 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"), "fit" => app.animate_view("fit"),
"home" => app.animate_view("home"), "home" => app.animate_view("home"),
"lookat" => look_at_selection(app), "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, "perspective" => app.ui.perspective = !app.ui.perspective,
_ => app.ui.show_grid = !app.ui.show_grid, _ => app.ui.show_grid = !app.ui.show_grid,
} }