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:
parent
128672186e
commit
93e2d20412
4 changed files with 98 additions and 30 deletions
4
Cargo.lock
generated
4
Cargo.lock
generated
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue