diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index 2da08ee7..4ebbe116 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -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 { + 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 { + 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). diff --git a/src/scene/mod.rs b/src/scene/mod.rs index ddde37f0..83580e7c 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -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; diff --git a/src/scene/model/mesh_model.rs b/src/scene/model/mesh_model.rs index 262fb214..add6f224 100644 --- a/src/scene/model/mesh_model.rs +++ b/src/scene/model/mesh_model.rs @@ -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, @@ -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, /// 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, } diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 52898f6b..71be8808 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -115,6 +115,11 @@ pub struct Pipeline { /// it back into the base set (issue #316). text_preview_vbuf: Option, 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, + 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 = 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)) +} diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index fe8372f7..3207ace3 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -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