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 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-04 17:57:00 +03:00
1 parent a08cf477c1
commit 1065bc12b2
6 files changed
+285 -63

No files matched your search

+78 -17
View File
@@ -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<Mesh
let mut indices: Vec<u32> = Vec::new();
let mut transp_indices: Vec<u32> = Vec::new();
let mut wire_indices: Vec<u32> = Vec::new();
let mut edge_verts: Vec<MeshVertex> = 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<Mesh
let mesh_tris = mesh.indices.len() / 3;
total_tris += mesh_tris as u64;
// Feature edges present (ACIS solid) → emit the B-rep edges as a line
// list and skip the triangulation wireframe. Absent (plain mesh) → keep
// the triangle edges so the mesh still shows a wireframe.
let has_feat = !set.edge_verts.is_empty();
if has_feat {
for i in 0..set.edge_verts.len() {
edge_verts.push(MeshVertex {
position: set.edge_verts[i],
normal: [0.0, 1.0, 0.0],
color: mesh.color,
position_low: set.edge_verts_low.get(i).copied().unwrap_or([0.0; 3]),
});
}
}
// A single mesh larger than a whole chunk: emit as triangle-soup
// sub-chunks (corners expanded, no vertex sharing) so each buffer fits.
if mesh.verts.len() > 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<Mesh
sv.push(vtx(ix[1] as usize));
sv.push(vtx(ix[2] as usize));
si.extend_from_slice(&[b, b + 1, b + 2]);
swi.extend_from_slice(&[b, b + 1, b + 1, b + 2, b + 2, b]);
if !has_feat {
swi.extend_from_slice(&[b, b + 1, b + 1, b + 2, b + 2, b]);
}
}
// The whole mesh shares one colour, so a sub-chunk is entirely
// opaque or entirely transparent.
if is_transp {
chunks.push(make_chunk(device, &sv, &[], &si, &swi));
chunks.push(make_chunk(device, &sv, &[], &si, &swi, &[]));
} else {
chunks.push(make_chunk(device, &sv, &si, &[], &swi));
chunks.push(make_chunk(device, &sv, &si, &[], &swi, &[]));
}
t = end;
}
@@ -242,11 +286,19 @@ pub fn build_mesh_batch(device: &wgpu::Device, sets: &[MeshLodSet]) -> (Vec<Mesh
&& (verts.len() + mesh.verts.len() > 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<Mesh
for &idx in &mesh.indices {
fill.push(base + idx);
}
for tri in mesh.indices.chunks_exact(3) {
let (a, b, c) = (base + tri[0], base + tri[1], base + tri[2]);
wire_indices.extend_from_slice(&[a, b, b, c, c, a]);
if !has_feat {
for tri in mesh.indices.chunks_exact(3) {
let (a, b, c) = (base + tri[0], base + tri[1], base + tri[2]);
wire_indices.extend_from_slice(&[a, b, b, c, c, a]);
}
}
}
if !indices.is_empty() || !transp_indices.is_empty() {
chunks.push(make_chunk(device, &verts, &indices, &transp_indices, &wire_indices));
if !indices.is_empty() || !transp_indices.is_empty() || !edge_verts.is_empty() {
chunks.push(make_chunk(
device,
&verts,
&indices,
&transp_indices,
&wire_indices,
&edge_verts,
));
}
(chunks, total_tris)
}
+53 -33
View File
@@ -53,6 +53,8 @@ pub struct Pipeline {
/// Wireframe 2D or Wireframe 3D so 3D solids draw as their
/// triangle edges instead of filled faces.
mesh_wireframe_pipeline: wgpu::RenderPipeline,
/// Edge pipeline that forces black, for the edge overlay in filled modes.
mesh_edge_black_pipeline: wgpu::RenderPipeline,
/// Depth-only variant of the mesh pipeline (TriangleList, no color
/// writes, writes depth). Used in HiddenLine mode so 3D solids
/// occlude wires behind them without painting visible pixels.
@@ -631,9 +633,12 @@ impl Pipeline {
// only the input topology changes (LineList) and back-face
// culling drops out (each triangle edge is shared between two
// faces, one of which would otherwise hide the edge).
let mesh_wireframe_pipeline =
// Edge/wireframe pipeline (LineList). `fs_edge` outputs the flat entity
// colour — no lighting — for the lines-only modes. A `fs_edge_black`
// twin (below) forces black for the edge overlay in filled modes.
let make_edge_pipeline = |label: &'static str, fs: &'static str| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("mesh.wireframe.pipeline"),
label: Some(label),
layout: Some(&mesh_layout),
vertex: wgpu::VertexState {
module: &mesh_shader,
@@ -660,7 +665,7 @@ impl Pipeline {
},
fragment: Some(wgpu::FragmentState {
module: &mesh_shader,
entry_point: Some("fs_main"),
entry_point: Some(fs),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
@@ -670,7 +675,10 @@ impl Pipeline {
}),
multiview: None,
cache: None,
});
})
};
let mesh_wireframe_pipeline = make_edge_pipeline("mesh.wireframe.pipeline", "fs_edge");
let mesh_edge_black_pipeline = make_edge_pipeline("mesh.edge_black.pipeline", "fs_edge_black");
// Depth-only variant — TriangleList, back-face culling stays on
// (we only want front-facing fragments to write depth so wires
@@ -1057,6 +1065,7 @@ impl Pipeline {
mesh_transparent_pipeline,
mesh_highlight_pipeline,
mesh_wireframe_pipeline,
mesh_edge_black_pipeline,
mesh_depth_pipeline,
face3d_pipeline,
face3d_depth_pipeline,
@@ -1642,23 +1651,8 @@ impl Pipeline {
}
pass.set_pipeline(&self.mesh_wireframe_pipeline);
for c in &self.gpu_mesh_batch {
if c.wire_index_count == 0 {
continue;
}
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
pass.set_index_buffer(
c.wire_index_buffer.slice(..),
wgpu::IndexFormat::Uint32,
);
pass.draw_indexed(0..c.wire_index_count, 0, 0..1);
}
} else {
if mesh_wireframe {
pass.set_pipeline(&self.mesh_wireframe_pipeline);
for c in &self.gpu_mesh_batch {
if c.wire_index_count == 0 {
continue;
}
// Plain-mesh triangulation edges.
if c.wire_index_count != 0 {
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
pass.set_index_buffer(
c.wire_index_buffer.slice(..),
@@ -1666,6 +1660,29 @@ impl Pipeline {
);
pass.draw_indexed(0..c.wire_index_count, 0, 0..1);
}
// ACIS solid B-rep feature edges (LineList, non-indexed).
if c.edge_vertex_count != 0 {
pass.set_vertex_buffer(0, c.edge_vertex_buffer.slice(..));
pass.draw(0..c.edge_vertex_count, 0..1);
}
}
} else {
if mesh_wireframe {
pass.set_pipeline(&self.mesh_wireframe_pipeline);
for c in &self.gpu_mesh_batch {
if c.wire_index_count != 0 {
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
pass.set_index_buffer(
c.wire_index_buffer.slice(..),
wgpu::IndexFormat::Uint32,
);
pass.draw_indexed(0..c.wire_index_count, 0, 0..1);
}
if c.edge_vertex_count != 0 {
pass.set_vertex_buffer(0, c.edge_vertex_buffer.slice(..));
pass.draw(0..c.edge_vertex_count, 0..1);
}
}
} else {
// Opaque fills first (they write depth).
pass.set_pipeline(&self.mesh_pipeline);
@@ -1707,21 +1724,24 @@ impl Pipeline {
pass.draw_indexed(0..g.index_count, 0, 0..1);
}
}
// *WithEdges variants: overlay wire-edge segments on top of the
// shaded fill. The LessEqual depth test on the wireframe pipeline
// keeps the edges visible over the fragments the solid pass wrote.
// *WithEdges variants: overlay edge segments on top of the shaded
// fill in black (mesh_edge_black_pipeline). The LessEqual depth
// test keeps the edges visible over the fragments the fill wrote.
if want_solid_with_edges {
pass.set_pipeline(&self.mesh_wireframe_pipeline);
pass.set_pipeline(&self.mesh_edge_black_pipeline);
for c in &self.gpu_mesh_batch {
if c.wire_index_count == 0 {
continue;
if c.wire_index_count != 0 {
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
pass.set_index_buffer(
c.wire_index_buffer.slice(..),
wgpu::IndexFormat::Uint32,
);
pass.draw_indexed(0..c.wire_index_count, 0, 0..1);
}
if c.edge_vertex_count != 0 {
pass.set_vertex_buffer(0, c.edge_vertex_buffer.slice(..));
pass.draw(0..c.edge_vertex_count, 0..1);
}
pass.set_vertex_buffer(0, c.vertex_buffer.slice(..));
pass.set_index_buffer(
c.wire_index_buffer.slice(..),
wgpu::IndexFormat::Uint32,
);
pass.draw_indexed(0..c.wire_index_count, 0, 0..1);
}
}
}