feat(solid3d): view-dependent silhouettes (DISPSILH) on curved faces

ISOLINES show a curved solid from any angle, but its outline still cut across
whichever fixed longitudinal lines happened to face the eye. DISPSILH draws the
true silhouette — the two lines where a cylinder (or cone) turns edge-on to the
view — and it moves with the camera, so it cannot be a baked edge.

Tessellation stores a world-space generator per cone/cylinder face on the mesh
set (axis, radial frame, radius, cone taper, extent), sharing the face-extent
extraction with the ISOLINES collector. Each frame `upload_silhouettes` walks
those generators, solves `du·cosθ + dv·sinθ = -tanα·da` for the two edge-on
angles (`φ ± acos(...)`, `φ ± π/2` for a cylinder), and builds a line list in
the mesh vertex format — double-single, so it stays precise at UTM. It draws
through the existing wireframe / edge pipelines in the wireframe and
shaded-with-edges passes.

Gated on the document's DISPSILH header flag, which defaults off — so the
per-frame pass is a no-op unless the drawing asks for it. Scoped to top-level
solids: a block instance's generators aren't transformed by the INSTANCE pose
yet, so they are cleared on the block path (its isolines and feature edges
still draw).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-07-17 20:02:56 +03:00
commit ae5231427b
5 changed files with 329 additions and 51 deletions

View file

@ -171,6 +171,8 @@ fn tessellate_acis(
/// double-single `edge_verts` / `edge_verts_low`.
fn attach_feature_edges(set: &mut MeshLodSet, sat: &SatDocument, isolines: usize) {
let xform = body_transform(sat);
// World-space curved-face generators for the per-frame silhouette pass.
set.curved_gens = collect_curved_gens(sat, xform);
let mut seg_pts = collect_feature_edges(sat);
// ISOLINES ride the same line list and the same body transform as the
// feature edges, so they inherit the offset / per-INSTANCE re-split for free.
@ -199,76 +201,159 @@ fn attach_feature_edges(set: &mut MeshLodSet, sat: &SatDocument, isolines: usize
/// rather than showing only its two rim circles. Points are body-local
/// (pre-transform), matching [`collect_feature_edges`], so the caller applies
/// the body transform uniformly.
/// Body-local geometry of one cone/cylinder face: its frame, radius, cone taper
/// and the height/angular extent recovered the same way `tess_cone_face` does.
/// Shared by the ISOLINES and silhouette-generator collectors.
struct ConeFaceGeom {
center: [f64; 3],
axis: [f64; 3],
u_dir: [f64; 3],
v_dir: [f64; 3],
radius: f64,
tan_a: f64,
h_min: f64,
h_max: f64,
theta_min: f64,
theta_span: f64,
full: bool,
}
fn cone_face_geom(sat: &SatDocument, face: &SatFace) -> Option<ConeFaceGeom> {
let surf_rec = sat.resolve(face.surface())?;
if surf_rec.entity_type != "cone-surface" {
return None;
}
let cone = SatConeSurface::from_record(surf_rec)?;
let (cx, cy, cz) = cone.center();
let (ax, ay, az) = cone.axis();
let (ux, uy, uz) = cone.major_axis();
let radius = cone.radius();
let sin_a = cone.sin_half_angle();
let cos_a = cone.cos_half_angle();
let axis = norm3([ax, ay, az]);
let u_dir = norm3([ux, uy, uz]);
let v_dir = cross3(axis, u_dir);
let poly = collect_face_polygon(sat, face, 48);
let (mut h_min, mut h_max, mut theta_min, mut theta_max, full) =
angular_range(cx, cy, cz, axis, u_dir, v_dir, &poly);
if (h_max - h_min).abs() < 1e-9 {
if let Some((vmin, vmax)) = cone_axis_span(sat, &cone, axis, [cx, cy, cz]) {
h_min = vmin;
h_max = vmax;
if full {
theta_min = 0.0;
theta_max = TAU;
}
}
}
let theta_span = if full { TAU } else { theta_max - theta_min };
if (h_max - h_min).abs() < 1e-10 || theta_span.abs() < 1e-10 {
return None;
}
let tan_a = if cos_a.abs() > 1e-9 { sin_a / cos_a } else { 0.0 };
Some(ConeFaceGeom {
center: [cx, cy, cz],
axis,
u_dir,
v_dir,
radius,
tan_a,
h_min,
h_max,
theta_min,
theta_span,
full,
})
}
fn collect_isolines(sat: &SatDocument, count: usize) -> Vec<[f64; 3]> {
if count == 0 {
return Vec::new();
}
let mut out: Vec<[f64; 3]> = Vec::new();
for face in sat.faces() {
let Some(surf_rec) = sat.resolve(face.surface()) else {
let Some(g) = cone_face_geom(sat, &face) else {
continue;
};
if surf_rec.entity_type != "cone-surface" {
continue;
}
let Some(cone) = SatConeSurface::from_record(surf_rec) else {
continue;
};
let (cx, cy, cz) = cone.center();
let (ax, ay, az) = cone.axis();
let (ux, uy, uz) = cone.major_axis();
let radius = cone.radius();
let sin_a = cone.sin_half_angle();
let cos_a = cone.cos_half_angle();
let axis = norm3([ax, ay, az]);
let u_dir = norm3([ux, uy, uz]);
let v_dir = cross3(axis, u_dir);
// Same face-extent recovery as `tess_cone_face`: read the height and
// angular span from the boundary, and when the boundary is a single
// closed rim (no height span), recover the top/bottom from the coaxial
// circle rims.
let poly = collect_face_polygon(sat, &face, 48);
let (mut h_min, mut h_max, mut theta_min, mut theta_max, full_circle) =
angular_range(cx, cy, cz, axis, u_dir, v_dir, &poly);
if (h_max - h_min).abs() < 1e-9 {
if let Some((vmin, vmax)) = cone_axis_span(sat, &cone, axis, [cx, cy, cz]) {
h_min = vmin;
h_max = vmax;
if full_circle {
theta_min = 0.0;
theta_max = TAU;
}
}
}
let theta_span = if full_circle { TAU } else { theta_max - theta_min };
if (h_max - h_min).abs() < 1e-10 || theta_span.abs() < 1e-10 {
continue;
}
let r_at = |h: f64| {
if cos_a.abs() > 1e-9 {
radius + h * sin_a / cos_a
} else {
radius
}
};
let (r0, r1) = (r_at(h_min), r_at(h_max));
let [cx, cy, cz] = g.center;
let (r0, r1) = (g.radius + g.h_min * g.tan_a, g.radius + g.h_max * g.tan_a);
// A closed rim's line at theta and theta+TAU coincide, so step across
// [0, TAU) with `count` divisions; a bounded arc gets interior lines
// spanning its own arc (its two ends are already drawn as rim edges).
for k in 0..count {
let a = if full_circle {
theta_min + theta_span * (k as f64 / count as f64)
let a = if g.full {
g.theta_min + g.theta_span * (k as f64 / count as f64)
} else {
theta_min + theta_span * ((k as f64 + 1.0) / (count as f64 + 1.0))
g.theta_min + g.theta_span * ((k as f64 + 1.0) / (count as f64 + 1.0))
};
out.push(cone_pt(cx, cy, cz, axis, u_dir, v_dir, r0, a, h_min));
out.push(cone_pt(cx, cy, cz, axis, u_dir, v_dir, r1, a, h_max));
out.push(cone_pt(cx, cy, cz, g.axis, g.u_dir, g.v_dir, r0, a, g.h_min));
out.push(cone_pt(cx, cy, cz, g.axis, g.u_dir, g.v_dir, r1, a, g.h_max));
}
}
out
}
/// World-space silhouette generators for each cone/cylinder face — the params a
/// per-frame DISPSILH pass needs. `xform` is the solid's body transform (or
/// `None`); directions are rotated by it, the base point is placed by it and
/// split into the double-single pair.
fn collect_curved_gens(
sat: &SatDocument,
xform: Option<([f64; 9], [f64; 3], f64)>,
) -> Vec<crate::scene::model::mesh_model::CurvedGen> {
use crate::scene::model::mesh_model::CurvedGen;
let rot_dir = |d: [f64; 3]| -> [f32; 3] {
let w = match xform {
Some((m, _, _)) => norm3([
d[0] * m[0] + d[1] * m[3] + d[2] * m[6],
d[0] * m[1] + d[1] * m[4] + d[2] * m[7],
d[0] * m[2] + d[1] * m[5] + d[2] * m[8],
]),
None => d,
};
[w[0] as f32, w[1] as f32, w[2] as f32]
};
let mut out = Vec::new();
for face in sat.faces() {
let Some(g) = cone_face_geom(sat, &face) else {
continue;
};
// Base circle centre in world space (double-single) — heights are along
// the unit axis, so the base sits at h_min.
let base_local = [
g.center[0] + g.h_min * g.axis[0],
g.center[1] + g.h_min * g.axis[1],
g.center[2] + g.h_min * g.axis[2],
];
let (bx, by, bz, scale) = match xform {
Some((m, tr, s)) => (
s * (base_local[0] * m[0] + base_local[1] * m[3] + base_local[2] * m[6]) + tr[0],
s * (base_local[0] * m[1] + base_local[1] * m[4] + base_local[2] * m[7]) + tr[1],
s * (base_local[0] * m[2] + base_local[1] * m[5] + base_local[2] * m[8]) + tr[2],
s,
),
None => (base_local[0], base_local[1], base_local[2], 1.0),
};
let (hx, hy, hz) = (bx as f32, by as f32, bz as f32);
out.push(CurvedGen {
base: [hx, hy, hz],
base_low: [(bx - hx as f64) as f32, (by - hy as f64) as f32, (bz - hz as f64) as f32],
axis: rot_dir(g.axis),
u_dir: rot_dir(g.u_dir),
v_dir: rot_dir(g.v_dir),
radius: (g.radius * scale) as f32,
tan_a: g.tan_a as f32,
h_min: 0.0,
h_max: ((g.h_max - g.h_min) * scale) as f32,
theta_min: g.theta_min as f32,
theta_span: g.theta_span as f32,
full: g.full,
});
}
out
}
/// Line-list endpoints (pairs) for every `edge` record: straight edges emit
/// their two vertex endpoints; ellipse/circle edges are sampled along their
/// bounded parametric arc. Points are in body-local space (pre-transform).

View file

@ -693,6 +693,12 @@ fn transform_block_mesh_lod_set(
) -> MeshLodSet {
use acadrust::types::Vector3;
let mut out = set.clone();
// Silhouette generators are world-space and untransformed here, so a block
// instance would silhouette against the block-local pose. Drop them: a
// block-internal solid keeps its baked isolines and feature edges, just not
// the per-frame silhouette (deferred until the generators track the INSTANCE
// transform).
out.curved_gens.clear();
let mut min_x = f32::INFINITY;
let mut min_y = f32::INFINITY;
let mut max_x = f32::NEG_INFINITY;

View file

@ -36,6 +36,36 @@ pub struct MeshModel {
///
/// `lods` holds up to one MeshModel per LOD level (high → low). Empty
/// slots fall back to the nearest available LOD at render time.
/// A curved face's generator, kept so a view-dependent silhouette (DISPSILH)
/// can be computed per frame — the silhouette is where the surface turns away
/// from the eye, which no baked edge can capture. World-space, post body
/// transform; the base point carries a double-single low half so it stays
/// precise at UTM scale like the mesh verts.
#[derive(Clone, Copy, Debug)]
pub struct CurvedGen {
/// Base-circle centre (world), high half of the double-single.
pub base: [f32; 3],
/// Low residual paired with `base`.
pub base_low: [f32; 3],
/// Unit axis direction (world).
pub axis: [f32; 3],
/// Radial frame: `u` is the θ=0 direction, `v = axis × u`.
pub u_dir: [f32; 3],
pub v_dir: [f32; 3],
/// Radius at the base (`h = 0`).
pub radius: f32,
/// `tan(half-angle)`: radius at height `h` is `radius + h * tan_a`
/// (0 for a cylinder, non-zero for a cone).
pub tan_a: f32,
/// Height span along the axis the face covers.
pub h_min: f32,
pub h_max: f32,
/// Angular span the face covers; `full` = a closed revolution.
pub theta_min: f32,
pub theta_span: f32,
pub full: bool,
}
#[derive(Clone, Debug)]
pub struct MeshLodSet {
pub lods: Vec<MeshModel>,
@ -46,6 +76,9 @@ pub struct MeshLodSet {
pub edge_verts: Vec<[f32; 3]>,
/// Low residual paired with `edge_verts`.
pub edge_verts_low: Vec<[f32; 3]>,
/// Curved-face generators for per-frame silhouette (DISPSILH). Empty for a
/// solid with no curved faces, or when silhouettes aren't wanted.
pub curved_gens: Vec<CurvedGen>,
/// World XY AABB `[min_x, min_y, max_x, max_y]` of the mesh — used
/// by the per-frame LOD selector to compute the projected pixel
/// diagonal.
@ -87,6 +120,7 @@ impl MeshLodSet {
lods,
edge_verts: Vec::new(),
edge_verts_low: Vec::new(),
curved_gens: Vec::new(),
world_aabb,
z_aabb,
}

View file

@ -115,6 +115,11 @@ pub struct Pipeline {
/// it back into the base set (issue #316).
text_preview_vbuf: Option<wgpu::Buffer>,
text_preview_vcount: u32,
/// Per-frame DISPSILH silhouette line list — rebuilt every prepare() from
/// the mesh sets' curved-face generators and the current view direction, so
/// the outline tracks the camera. Reuses the mesh vertex format / pipeline.
silhouette_vbuf: Option<wgpu::Buffer>,
silhouette_vcount: u32,
/// Last requested render size (the full viewport rect, in pixels). The
/// geometry passes render at this size; the blit UV is scaled by
/// `depth_texture_size / alloc_size` so it samples only the filled region.
@ -1295,6 +1300,8 @@ impl Pipeline {
text_highlight_vcount: 0,
text_preview_vbuf: None,
text_preview_vcount: 0,
silhouette_vbuf: None,
silhouette_vcount: 0,
mesh_pipeline,
mesh_transparent_pipeline,
mesh_highlight_pipeline,
@ -1567,6 +1574,102 @@ impl Pipeline {
self.text_preview_vcount = verts.len() as u32;
}
// The math lives outside the GPU method so it can be unit-tested; see the
// module test below.
/// Rebuild the per-frame DISPSILH silhouette line list from the mesh sets'
/// curved-face generators and the current eye. For each cone/cylinder face
/// the silhouette runs at the two angles where the surface turns edge-on to
/// the view — `θ = φ ± acos(-tanα·(view·axis) / |view⊥|)`, which reduces to
/// `φ ± π/2` for a cylinder. Segments are uploaded in the mesh vertex format
/// so they draw through the existing wireframe pipeline.
pub fn upload_silhouettes(
&mut self,
device: &wgpu::Device,
sets: &[crate::scene::model::mesh_model::MeshLodSet],
eye: glam::DVec3,
) {
use crate::scene::pipeline::mesh_gpu::MeshVertex;
let mut verts: Vec<MeshVertex> = Vec::new();
let dot = |a: [f32; 3], b: glam::DVec3| {
a[0] as f64 * b.x + a[1] as f64 * b.y + a[2] as f64 * b.z
};
for set in sets {
for g in &set.curved_gens {
let base = glam::DVec3::new(
g.base[0] as f64 + g.base_low[0] as f64,
g.base[1] as f64 + g.base_low[1] as f64,
g.base[2] as f64 + g.base_low[2] as f64,
);
// View direction from eye toward the face (accurate for both
// orthographic and perspective).
let view = (base - eye).normalize();
let Some((t0, t1)) = silhouette_thetas(
dot(g.u_dir, view),
dot(g.v_dir, view),
dot(g.axis, view),
g.tan_a as f64,
) else {
continue;
};
let r0 = g.radius as f64;
let r1 = g.radius as f64 + g.h_max as f64 * g.tan_a as f64;
for theta in [t0, t1] {
if !g.full {
// Only within the face's own arc; normalise the offset
// from theta_min into [0, TAU).
let mut off = theta - g.theta_min as f64;
let tau = std::f64::consts::TAU;
off = off.rem_euclid(tau);
if off > g.theta_span as f64 {
continue;
}
}
let (c, s) = (theta.cos(), theta.sin());
let radial = |r: f64| {
[
g.u_dir[0] as f64 * r * c + g.v_dir[0] as f64 * r * s,
g.u_dir[1] as f64 * r * c + g.v_dir[1] as f64 * r * s,
g.u_dir[2] as f64 * r * c + g.v_dir[2] as f64 * r * s,
]
};
let rb = radial(r0);
let rt = radial(r1);
let mk = |off: [f64; 3], h: f64| -> MeshVertex {
let wx = base.x + off[0] + h * g.axis[0] as f64;
let wy = base.y + off[1] + h * g.axis[1] as f64;
let wz = base.z + off[2] + h * g.axis[2] as f64;
let (hx, hy, hz) = (wx as f32, wy as f32, wz as f32);
MeshVertex {
position: [hx, hy, hz],
normal: [0.0, 1.0, 0.0],
color: set.lods.first().map(|m| m.color).unwrap_or([0.0, 0.0, 0.0, 1.0]),
position_low: [
(wx - hx as f64) as f32,
(wy - hy as f64) as f32,
(wz - hz as f64) as f32,
],
}
};
verts.push(mk(rb, 0.0));
verts.push(mk(rt, g.h_max as f64));
}
}
}
if verts.is_empty() {
self.silhouette_vbuf = None;
self.silhouette_vcount = 0;
return;
}
use wgpu::util::DeviceExt;
self.silhouette_vbuf = Some(device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mesh.silhouette.vbuf"),
contents: bytemuck::cast_slice(&verts),
usage: wgpu::BufferUsages::VERTEX,
}));
self.silhouette_vcount = verts.len() as u32;
}
/// Recompute pixel scissor rects for viewport-clipped wires from the current view_proj.
/// Called every frame from prepare() because scissor pixels shift with pan/zoom.
pub fn compute_wire_scissors(&mut self, view_rot: glam::Mat4, eye: glam::DVec3, clip_w: u32, clip_h: u32) {
@ -2090,6 +2193,11 @@ impl Pipeline {
pass.draw(0..c.edge_vertex_count, 0..1);
}
}
// DISPSILH silhouettes (whole batch, one buffer).
if let Some(ref vb) = self.silhouette_vbuf {
pass.set_vertex_buffer(0, vb.slice(..));
pass.draw(0..self.silhouette_vcount, 0..1);
}
} else {
if mesh_wireframe {
pass.set_pipeline(&self.mesh_wireframe_pipeline);
@ -2107,6 +2215,11 @@ impl Pipeline {
pass.draw(0..c.edge_vertex_count, 0..1);
}
}
// DISPSILH silhouettes.
if let Some(ref vb) = self.silhouette_vbuf {
pass.set_vertex_buffer(0, vb.slice(..));
pass.draw(0..self.silhouette_vcount, 0..1);
}
} else {
// Opaque fills first (they write depth).
pass.set_pipeline(&self.mesh_pipeline);
@ -2167,6 +2280,11 @@ impl Pipeline {
pass.draw(0..c.edge_vertex_count, 0..1);
}
}
// DISPSILH silhouettes over the shaded fill.
if let Some(ref vb) = self.silhouette_vbuf {
pass.set_vertex_buffer(0, vb.slice(..));
pass.draw(0..self.silhouette_vcount, 0..1);
}
}
}
}
@ -2939,3 +3057,26 @@ impl iced::widget::shader::Pipeline for MultiPipeline {
}
}
}
/// The two silhouette angles of a cone/cylinder face for a view direction,
/// expressed in the face's `(u, v, axis)` frame via the view's components on
/// each: `du = view·u`, `dv = view·v`, `da = view·axis`. `tan_a` is the cone
/// taper (0 for a cylinder).
///
/// The outward normal is edge-on to the view where `du·cosθ + dv·sinθ =
/// -tanα·da`, i.e. `θ = φ ± acos(-tanα·da / |view⊥|)` with `φ = atan2(dv, du)`.
/// `None` when the view runs down the axis (no outline) or the whole cone faces
/// toward/away (`|arg| > 1`).
fn silhouette_thetas(du: f64, dv: f64, da: f64, tan_a: f64) -> Option<(f64, f64)> {
let r_perp = (du * du + dv * dv).sqrt();
if r_perp < 1e-6 {
return None;
}
let arg = -tan_a * da / r_perp;
if arg.abs() > 1.0 {
return None;
}
let phi = dv.atan2(du);
let delta = arg.acos();
Some((phi + delta, phi - delta))
}

View file

@ -87,6 +87,9 @@ pub struct ViewportData {
/// rendered on top of fills. Most shaded modes turn this off; the
/// `*WithEdges` variants and the pure wireframes leave it on.
pub(in crate::scene) show_3d_edges: bool,
/// DISPSILH — draw view-dependent silhouette outlines on curved solid faces.
/// A document-header global (default off), orthogonal to the visual style.
pub(in crate::scene) display_silhouette: bool,
/// HiddenLine routes 3D fills through a depth-only prepass so edges
/// occluded by closer geometry are culled by the LessEqual depth
/// test on the wire passes that follow.
@ -555,6 +558,14 @@ impl shader::Primitive for Primitive {
vp.uniforms.eye_high[1] as f64 + vp.uniforms.eye_low[1] as f64,
vp.uniforms.eye_high[2] as f64 + vp.uniforms.eye_low[2] as f64,
);
// DISPSILH: rebuild view-dependent silhouettes each frame (off by
// default, so this is a no-op unless the drawing enables it). Only
// in modes that draw edges — pure shaded hides them.
if vp.display_silhouette && (vp.view_wireframe || vp.show_3d_edges) {
inner.upload_silhouettes(device, &vp.meshes[..], eye);
} else {
inner.upload_silhouettes(device, &[], eye);
}
inner.compute_wire_scissors(view_rot, eye, clip_size.width, clip_size.height);
inner.compute_wipeout_scissors(view_rot, eye, clip_size.width, clip_size.height);
inner.compute_image_scissors(view_rot, eye, clip_size.width, clip_size.height);
@ -1362,6 +1373,7 @@ impl Scene {
view_wireframe,
mesh_fill: flags.mesh_fill,
show_3d_edges: flags.show_3d_edges,
display_silhouette: self.document.header.display_silhouette,
hidden_line: flags.hidden_line,
// Interaction LOD: suppress the costly hatch pass while the view is
// actively moving; the scene-render cache holds the full-quality