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 <[email protected]>
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>,
/// Depth-tested lines.
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).
pub overlay: Vec<u8>,
/// 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<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
@@ -162,6 +174,7 @@ struct Batches {
trans: Option<Batch>,
glass: Option<Batch>,
lines: Option<Batch>,
hidden: Option<Batch>,
overlays: Option<Batch>,
ghost: Option<Batch>,
xray: Option<Batch>,
@@ -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<f32> {
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 {
@builtin(position) pos: vec4<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),
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 {
+1 -1
View File
@@ -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<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.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() {
+9 -2
View File
@@ -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<usize, solvecraft_engine::doc::appearance::Look> =
@@ -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,
}