From 1065bc12b2b9e954871e874dada16c3c8371d19d Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 4 Jul 2026 17:57:00 +0300 Subject: [PATCH] feat(render): draw ACIS solids as B-rep feature edges, meshes as tri edges Solid wireframes rendered every triangle edge of the tessellation, so a curved solid read as a dense mesh instead of its real outline. Now each ACIS solid's B-rep face-boundary edges are collected from the SAT `edge` records (straight edges as segments, ellipse/circle edges sampled along their bounded arc), body-transformed and split into the double-single pair, and stored on the MeshLodSet. The block-instance and top-level offset paths transform these edges alongside the mesh vertices. The batch builder routes a solid with feature edges into a dedicated LineList vertex buffer and skips its triangle wireframe; a mesh with no B-rep edges keeps its triangulation edges. Edge colour follows the display mode: the flat `fs_edge` shader draws the entity colour in the lines-only modes (wireframe, hidden-line) and a `fs_edge_black` twin forces black for the edge overlay in filled render modes. Verified: the Clinic storefront arch bars now yield ~14-16 clean edge segments (vs the full triangulation) bounded to the solid's true extent. Co-Authored-By: Claude Opus 4.8 --- src/scene/convert/solid3d_tess.rs | 106 ++++++++++++++++++++++++++---- src/scene/mod.rs | 32 +++++++++ src/scene/model/mesh_model.rs | 15 ++++- src/scene/pipeline/mesh_gpu.rs | 95 +++++++++++++++++++++----- src/scene/pipeline/mod.rs | 86 ++++++++++++++---------- src/shaders/mesh.wgsl | 14 ++++ 6 files changed, 285 insertions(+), 63 deletions(-) diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index 2426e020b..de99ba107 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -142,20 +142,101 @@ fn tessellate_acis( color: [f32; 4], facet_res: f64, ) -> Option { - if let Some(set) = crate::scene::convert::acis_to_truck::tessellate_sat_truck(sat, name.clone(), color, facet_res) { - if std::env::var_os("OCS_TESS_DEBUG").is_some() { - let tris = set.lods.first().map(|m| m.indices.len() / 3).unwrap_or(0); - eprintln!("acis_tess[{name}]: truck ({tris} tris)"); + let mut set = + if let Some(set) = crate::scene::convert::acis_to_truck::tessellate_sat_truck(sat, name.clone(), color, facet_res) { + if std::env::var_os("OCS_TESS_DEBUG").is_some() { + let tris = set.lods.first().map(|m| m.indices.len() / 3).unwrap_or(0); + eprintln!("acis_tess[{name}]: truck ({tris} tris)"); + } + set + } else { + // truck couldn't rebuild the shell — fall back to the bespoke sampler. + let fallback = tessellate_sat_lods(sat, name.clone(), color, facet_res); + eprintln!( + "acis_tess[{name}]: manual fallback ({})", + if fallback.is_some() { "ok" } else { "empty" } + ); + fallback? + }; + // Attach the B-rep face-boundary edges (body-transformed, split into the + // double-single pair) so the solid's wireframe shows real edges. + attach_feature_edges(&mut set, sat); + Some(set) +} + +/// Collect the ACIS `edge` records as world-space polyline segments (pairs of +/// endpoints), body-transform them, and store them on the set as the +/// double-single `edge_verts` / `edge_verts_low`. +fn attach_feature_edges(set: &mut MeshLodSet, sat: &SatDocument) { + let xform = body_transform(sat); + let seg_pts = collect_feature_edges(sat); + set.edge_verts.reserve(seg_pts.len()); + set.edge_verts_low.reserve(seg_pts.len()); + for p in seg_pts { + let (mut x, mut y, mut z) = (p[0], p[1], p[2]); + if let Some((m, tr, scale)) = xform { + let (lx, ly, lz) = (x, y, z); + x = scale * (lx * m[0] + ly * m[3] + lz * m[6]) + tr[0]; + y = scale * (lx * m[1] + ly * m[4] + lz * m[7]) + tr[1]; + z = scale * (lx * m[2] + ly * m[5] + lz * m[8]) + tr[2]; } - return Some(set); + let (hx, hy, hz) = (x as f32, y as f32, z as f32); + set.edge_verts.push([hx, hy, hz]); + set.edge_verts_low + .push([(x - hx as f64) as f32, (y - hy as f64) as f32, (z - hz as f64) as f32]); } - // truck couldn't rebuild the shell — fall back to the bespoke sampler. - let fallback = tessellate_sat_lods(sat, name.clone(), color, facet_res); - eprintln!( - "acis_tess[{name}]: manual fallback ({})", - if fallback.is_some() { "ok" } else { "empty" } - ); - fallback +} + +/// 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). +fn collect_feature_edges(sat: &SatDocument) -> Vec<[f64; 3]> { + const EDGE_SEGS: usize = 48; + let mut out: Vec<[f64; 3]> = Vec::new(); + for er in sat.records_of_type("edge") { + let Some(edge) = SatEdge::from_record(er) else { + continue; + }; + // Ordered points along the edge (≥2). + let mut pts: Vec<[f64; 3]> = Vec::new(); + if let Some(cr) = sat.resolve(edge.curve()) { + if let Some(ellipse) = SatEllipseCurve::from_record(cr) { + pts = sample_ellipse_arc(&ellipse, edge.start_param(), edge.end_param(), EDGE_SEGS); + // sample_ellipse_arc drops the end param; append the true end so + // the polyline closes onto the shared vertex. + if let Some(p) = vertex_point(sat, edge.end_vertex()) { + pts.push(p); + } + } + } + if pts.len() < 2 { + // Straight edge (or unsampled curve): connect the two vertices. + pts.clear(); + if let (Some(a), Some(b)) = ( + vertex_point(sat, edge.start_vertex()), + vertex_point(sat, edge.end_vertex()), + ) { + pts.push(a); + pts.push(b); + } + } + // Emit consecutive points as line-list segment pairs. + for w in pts.windows(2) { + out.push(w[0]); + out.push(w[1]); + } + } + out +} + +/// Resolve a vertex pointer to its point coordinates. +fn vertex_point(sat: &SatDocument, vptr: SatPointer) -> Option<[f64; 3]> { + let vrec = sat.resolve(vptr)?; + let vertex = SatVertex::from_record(vrec)?; + let prec = sat.resolve(vertex.point())?; + let point = SatPoint::from_record(prec)?; + let (x, y, z) = point.position(); + Some([x, y, z]) } /// Tessellate a SAT document at all three LODs and bundle them into a @@ -1016,3 +1097,4 @@ fn norm3(v: [f64; 3]) -> [f64; 3] { } } + diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 3bad1d708..d89bfbecd 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -567,6 +567,21 @@ fn offset_mesh_lod_set(mut set: MeshLodSet) -> MeshLodSet { if hy > max_y { max_y = hy; } } } + // Re-split the feature edges the same way so they track the mesh at scale. + { + let n = set.edge_verts.len(); + if set.edge_verts_low.len() != n { + set.edge_verts_low = vec![[0.0; 3]; n]; + } + for (v, vl) in set.edge_verts.iter_mut().zip(set.edge_verts_low.iter_mut()) { + let ax = v[0] as f64 + vl[0] as f64; + let ay = v[1] as f64 + vl[1] as f64; + let az = v[2] as f64 + vl[2] as f64; + let (hx, hy, hz) = (ax as f32, ay as f32, az as f32); + *v = [hx, hy, hz]; + *vl = [(ax - hx as f64) as f32, (ay - hy as f64) as f32, (az - hz as f64) as f32]; + } + } if min_x.is_finite() { set.world_aabb = [min_x, min_y, max_x, max_y]; } @@ -624,6 +639,23 @@ fn transform_block_mesh_lod_set( } } } + // Apply the same INSERT transform to the feature edges. + { + let n = out.edge_verts.len(); + if out.edge_verts_low.len() != n { + out.edge_verts_low = vec![[0.0; 3]; n]; + } + for (v, vl) in out.edge_verts.iter_mut().zip(out.edge_verts_low.iter_mut()) { + let w = xform.apply(Vector3::new( + v[0] as f64 + vl[0] as f64, + v[1] as f64 + vl[1] as f64, + v[2] as f64 + vl[2] as f64, + )); + let (hx, hy, hz) = (w.x as f32, w.y as f32, w.z as f32); + *v = [hx, hy, hz]; + *vl = [(w.x - hx as f64) as f32, (w.y - hy as f64) as f32, (w.z - hz as f64) as f32]; + } + } if min_x.is_finite() { out.world_aabb = [min_x, min_y, max_x, max_y]; } diff --git a/src/scene/model/mesh_model.rs b/src/scene/model/mesh_model.rs index 07b014a72..262fb2144 100644 --- a/src/scene/model/mesh_model.rs +++ b/src/scene/model/mesh_model.rs @@ -39,6 +39,13 @@ pub struct MeshModel { #[derive(Clone, Debug)] pub struct MeshLodSet { pub lods: Vec, + /// Feature-edge line list (LOD-independent): pairs of endpoints, high half + /// of the double-single. Populated for ACIS solids (the B-rep face-boundary + /// edges) so their wireframe shows real edges rather than the triangulation. + /// Empty for plain meshes — those fall back to triangle edges at batch time. + pub edge_verts: Vec<[f32; 3]>, + /// Low residual paired with `edge_verts`. + pub edge_verts_low: Vec<[f32; 3]>, /// 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. @@ -76,7 +83,13 @@ impl MeshLodSet { /// Build a set from its LODs, computing the 3D AABB. pub fn from_lods(lods: Vec) -> Self { let (world_aabb, z_aabb) = compute_mesh_aabb(&lods); - Self { lods, world_aabb, z_aabb } + Self { + lods, + edge_verts: Vec::new(), + edge_verts_low: Vec::new(), + world_aabb, + z_aabb, + } } /// Wrap a single MeshModel as a one-LOD set. Used by interactive diff --git a/src/scene/pipeline/mesh_gpu.rs b/src/scene/pipeline/mesh_gpu.rs index f9fbf0d1f..49f441227 100644 --- a/src/scene/pipeline/mesh_gpu.rs +++ b/src/scene/pipeline/mesh_gpu.rs @@ -120,8 +120,14 @@ pub struct MeshBatchChunk { /// erase — the geometry behind them. pub transp_index_buffer: wgpu::Buffer, pub transp_index_count: u32, + /// Triangle-edge line list (into `vertex_buffer`) for plain meshes that + /// carry no B-rep edges — the tessellation wireframe. pub wire_index_buffer: wgpu::Buffer, pub wire_index_count: u32, + /// B-rep feature edges of ACIS solids, as a standalone LineList vertex + /// buffer (pairs of endpoints), drawn non-indexed. Empty for plain meshes. + pub edge_vertex_buffer: wgpu::Buffer, + pub edge_vertex_count: u32, } fn make_chunk( @@ -130,6 +136,7 @@ fn make_chunk( indices: &[u32], transp_indices: &[u32], wire_indices: &[u32], + edge_verts: &[MeshVertex], ) -> MeshBatchChunk { // `create_buffer_init` with an empty slice yields a zero-sized buffer that // some backends reject for INDEX usage; a chunk can legitimately hold only @@ -144,19 +151,30 @@ fn make_chunk( usage: wgpu::BufferUsages::INDEX, }) }; - let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("mesh.batch.vbuf"), - contents: bytemuck::cast_slice(verts), - usage: wgpu::BufferUsages::VERTEX, - }); + let mk_vertex = |data: &[MeshVertex], label: &'static str| { + let stub = [MeshVertex { + position: [0.0; 3], + normal: [0.0, 1.0, 0.0], + color: [0.0; 4], + position_low: [0.0; 3], + }]; + let src = if data.is_empty() { &stub[..] } else { data }; + device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some(label), + contents: bytemuck::cast_slice(src), + usage: wgpu::BufferUsages::VERTEX, + }) + }; MeshBatchChunk { - vertex_buffer, + vertex_buffer: mk_vertex(verts, "mesh.batch.vbuf"), index_buffer: mk_index(indices, "mesh.batch.ibuf"), index_count: indices.len() as u32, transp_index_buffer: mk_index(transp_indices, "mesh.batch.transp_ibuf"), transp_index_count: transp_indices.len() as u32, wire_index_buffer: mk_index(wire_indices, "mesh.batch.wire_ibuf"), wire_index_count: wire_indices.len() as u32, + edge_vertex_buffer: mk_vertex(edge_verts, "mesh.batch.edge_vbuf"), + edge_vertex_count: edge_verts.len() as u32, } } @@ -182,6 +200,7 @@ pub fn build_mesh_batch(device: &wgpu::Device, sets: &[MeshLodSet]) -> (Vec = Vec::new(); let mut transp_indices: Vec = Vec::new(); let mut wire_indices: Vec = Vec::new(); + let mut edge_verts: Vec = Vec::new(); let mut total_tris = 0u64; for set in sets { let Some(mesh) = set.lods.iter().find(|m| !m.indices.is_empty()) else { @@ -200,15 +219,38 @@ pub fn build_mesh_batch(device: &wgpu::Device, sets: &[MeshLodSet]) -> (Vec max_verts || mesh_tris > max_tris { - if !verts.is_empty() { - chunks.push(make_chunk(device, &verts, &indices, &transp_indices, &wire_indices)); + if !verts.is_empty() || !edge_verts.is_empty() { + chunks.push(make_chunk( + device, + &verts, + &indices, + &transp_indices, + &wire_indices, + &edge_verts, + )); verts.clear(); indices.clear(); transp_indices.clear(); wire_indices.clear(); + edge_verts.clear(); } let tris_per = (max_verts / 3).min(max_tris).max(1); let mut t = 0; @@ -222,14 +264,16 @@ pub fn build_mesh_batch(device: &wgpu::Device, sets: &[MeshLodSet]) -> (Vec (Vec max_verts || wire_indices.len() / 6 + mesh_tris > max_tris) { - chunks.push(make_chunk(device, &verts, &indices, &transp_indices, &wire_indices)); + chunks.push(make_chunk( + device, + &verts, + &indices, + &transp_indices, + &wire_indices, + &edge_verts, + )); verts.clear(); indices.clear(); transp_indices.clear(); wire_indices.clear(); + edge_verts.clear(); } let base = verts.len() as u32; for i in 0..mesh.verts.len() { @@ -256,13 +308,22 @@ pub fn build_mesh_batch(device: &wgpu::Device, sets: &[MeshLodSet]) -> (Vec @location(0) vec4 { let rgb = in.color.rgb * clamp(diff, 0.0, 1.0); return vec4(rgb, in.color.a); } + +// Edge fragment (LineList): flat entity colour, no lighting. Used for the +// wireframe and hidden-line edge passes so lines read at their true colour. +@fragment +fn fs_edge(in: VertexOut) -> @location(0) vec4 { + return vec4(in.color.rgb, 1.0); +} + +// Edge fragment for filled render modes: force black so edges frame the shaded +// fill regardless of the solid's colour. +@fragment +fn fs_edge_black(in: VertexOut) -> @location(0) vec4 { + return vec4(0.0, 0.0, 0.0, 1.0); +}