fix(3d): render pierced solid faces with their holes

Build pierced planar faces (a wall with a window opening) as truck faces
with inner hole boundaries: collect every face loop (outer + holes), wind
each hole opposite the outer so try_attach_plane cuts it, and let truck
triangulate. Drop the back-face cull on the solid-mesh pipelines so faces
show regardless of the import's winding (the shader already lights
two-sided). Relies on the acadrust SatLoop next_loop fix that makes hole
loops reachable. (#123)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-19 15:21:21 +03:00
commit 93e2d20412
4 changed files with 98 additions and 30 deletions

4
Cargo.lock generated
View file

@ -67,7 +67,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]]
name = "acadrust"
version = "0.3.4"
source = "git+https://github.com/HakanSeven12/acadrust?branch=main#7066566e7c19c49a436d0051181390261776d803"
source = "git+https://github.com/HakanSeven12/acadrust?branch=main#67db8eeeb63e429d301f7708ee8a4629ed3eb780"
dependencies = [
"ahash",
"anyhow",
@ -3157,7 +3157,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d"
dependencies = [
"bytecount",
"memchr 1.0.2",
"memchr 2.8.1",
"nom 8.0.0",
]

View file

@ -28,7 +28,7 @@ use acadrust::entities::acis::{
use crate::scene::model::mesh_model::{MeshLodSet, MeshModel};
use crate::scene::convert::solid3d_tess::{
apply_body_transform, body_transform, collect_face_polygon, cone_axis_span, mesh_aabb,
apply_body_transform, body_transform, collect_face_loops, cone_axis_span, mesh_aabb,
};
/// Slightly over 2π so revolution builders close the loop.
@ -198,20 +198,61 @@ fn build_face_group(
/// boundary edges (circles) are sampled into line segments, which keeps the
/// wire planar so `try_attach_plane` can fit the plane.
fn plane_face(sat: &SatDocument, face: &SatFace) -> Option<Face> {
let poly = collect_face_polygon(sat, face, BOUNDARY_SEGS);
if poly.len() < 3 {
// Outer boundary first, then inner hole loops — `try_attach_plane` fits the
// plane from the outer wire and cuts the rest as holes, so a pierced face
// (e.g. a wall with a window opening) renders with the opening. (#123)
let loops = collect_face_loops(sat, face, BOUNDARY_SEGS);
if loops.first().map_or(true, |l| l.len() < 3) {
return None;
}
let verts: Vec<_> = poly
.iter()
.map(|p| builder::vertex(Point3::new(p[0], p[1], p[2])))
.collect();
let n = verts.len();
let edges: Vec<_> = (0..n)
.map(|i| builder::line(&verts[i], &verts[(i + 1) % n]))
.collect();
let wire: Wire = edges.into();
builder::try_attach_plane(&[wire]).ok()
let build_wire = |pts: &[[f64; 3]], reverse: bool| -> Option<Wire> {
if pts.len() < 3 {
return None;
}
let verts: Vec<_> = if reverse {
pts.iter()
.rev()
.map(|p| builder::vertex(Point3::new(p[0], p[1], p[2])))
.collect::<Vec<_>>()
} else {
pts.iter()
.map(|p| builder::vertex(Point3::new(p[0], p[1], p[2])))
.collect::<Vec<_>>()
};
let n = verts.len();
let edges: Vec<_> = (0..n)
.map(|i| builder::line(&verts[i], &verts[(i + 1) % n]))
.collect();
Some(edges.into())
};
// `try_attach_plane` only cuts an inner wire as a hole when it winds the
// *opposite* way to the outer boundary. ACIS doesn't guarantee that, so
// reverse any hole loop whose winding matches the outer's. (#123)
let outer_n = loop_normal(&loops[0]);
let mut wires: Vec<Wire> = Vec::new();
let outer = build_wire(&loops[0], false)?;
wires.push(outer);
for lp in &loops[1..] {
let same = vdot(loop_normal(lp), outer_n) > 0.0;
if let Some(w) = build_wire(lp, same) {
wires.push(w);
}
}
builder::try_attach_plane(&wires).ok()
}
/// Newell area-weighted normal of a closed 3-D polygon (orientation only).
fn loop_normal(pts: &[[f64; 3]]) -> [f64; 3] {
let mut n = [0.0f64; 3];
let m = pts.len();
for i in 0..m {
let a = pts[i];
let b = pts[(i + 1) % m];
n[0] += (a[1] - b[1]) * (a[2] + b[2]);
n[1] += (a[2] - b[2]) * (a[0] + b[0]);
n[2] += (a[0] - b[0]) * (a[1] + b[1]);
}
n
}
// ── Cone / cylinder face ─────────────────────────────────────────────────────
@ -429,3 +470,5 @@ fn vnorm(a: [f64; 3]) -> [f64; 3] {
[a[0] / l, a[1] / l, a[2] / l]
}
}

View file

@ -336,24 +336,28 @@ pub fn tessellate_solid3d(solid: &Solid3D, color: [f32; 4], facet_res: f64) -> O
/// Returns an empty `Vec` when the loop topology is broken or has fewer than
/// three distinct points.
pub(crate) fn collect_face_polygon(sat: &SatDocument, face: &SatFace, circ_segs: usize) -> Vec<[f64; 3]> {
let loop_ptr = face.first_loop();
let Some(loop_rec) = sat.resolve(loop_ptr) else {
let Some(loop_rec) = sat.resolve(face.first_loop()) else {
return vec![];
};
let Some(sat_loop) = SatLoop::from_record(loop_rec) else {
return vec![];
};
collect_loop_polygon(sat, &sat_loop, circ_segs)
}
/// Boundary points of a single coedge loop, in order.
pub(crate) fn collect_loop_polygon(
sat: &SatDocument,
sat_loop: &SatLoop,
circ_segs: usize,
) -> Vec<[f64; 3]> {
let first_ptr = sat_loop.first_coedge();
let mut cur = first_ptr;
let mut pts: Vec<[f64; 3]> = Vec::new();
let mut visited: HashSet<i32> = HashSet::default();
loop {
if cur.is_null() {
break;
}
if visited.contains(&cur.0) {
if cur.is_null() || visited.contains(&cur.0) {
break;
}
visited.insert(cur.0);
@ -361,7 +365,6 @@ pub(crate) fn collect_face_polygon(sat: &SatDocument, face: &SatFace, circ_segs:
if let Some(ce_rec) = sat.resolve(cur) {
if let Some(coedge) = SatCoedge::from_record(ce_rec) {
append_coedge_points(sat, &coedge, circ_segs, &mut pts);
let next = coedge.next();
if next == first_ptr {
break;
@ -372,10 +375,35 @@ pub(crate) fn collect_face_polygon(sat: &SatDocument, face: &SatFace, circ_segs:
}
break;
}
pts
}
/// All loops of a face: the outer boundary first, then any inner hole loops.
/// Each loop is returned as an ordered 3-D polygon (≥ 3 points).
pub(crate) fn collect_face_loops(
sat: &SatDocument,
face: &SatFace,
circ_segs: usize,
) -> Vec<Vec<[f64; 3]>> {
let mut loops: Vec<Vec<[f64; 3]>> = Vec::new();
let mut loop_ptr = face.first_loop();
let mut seen: HashSet<i32> = HashSet::default();
while !loop_ptr.is_null() && seen.insert(loop_ptr.0) {
let Some(loop_rec) = sat.resolve(loop_ptr) else {
break;
};
let Some(sat_loop) = SatLoop::from_record(loop_rec) else {
break;
};
let poly = collect_loop_polygon(sat, &sat_loop, circ_segs);
if poly.len() >= 3 {
loops.push(poly);
}
loop_ptr = sat_loop.next_loop();
}
loops
}
/// Append a coedge's boundary points to `pts`. Ellipse/circle curves are
/// sampled along their parametric arc (excluding the end param so the next
/// coedge's start point provides the junction); all other curve types fall
@ -522,10 +550,6 @@ fn tess_plane_face(
};
let nf = [nx as f32, ny as f32, nz as f32];
// Wind the polygon so its CCW area normal agrees with the outward face
// normal. Curved boundaries are sampled in their curve's own orientation,
// which may run either way relative to the face; reversing here keeps the
// fan's winding-derived normal (flat shading) matching `nf` (Gouraud).
if dot3(newell_normal(&poly), [nx, ny, nz]) < 0.0 {
poly.reverse();
}
@ -536,7 +560,8 @@ fn tess_plane_face(
normals.push(nf);
}
// Fan triangulation from vertex 0.
// Fan triangulation from vertex 0 (outer loop only; holes are handled by
// the truck B-rep path in `acis_to_truck`).
let n = poly.len() as u32;
for i in 1..(n - 1) {
indices.extend_from_slice(&[base, base + i, base + i + 1]);

View file

@ -449,7 +449,7 @@ impl Pipeline {
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: Some(wgpu::Face::Back),
cull_mode: None, // two-sided: ACIS import winding is unreliable
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
@ -538,7 +538,7 @@ impl Pipeline {
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: Some(wgpu::Face::Back),
cull_mode: None, // two-sided: ACIS import winding is unreliable
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {