From 93e2d20412d68dab101c87530490195aec167b12 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 19 Jun 2026 15:21:21 +0300 Subject: [PATCH] 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 --- Cargo.lock | 4 +- src/scene/convert/acis_to_truck.rs | 69 ++++++++++++++++++++++++------ src/scene/convert/solid3d_tess.rs | 51 ++++++++++++++++------ src/scene/pipeline/mod.rs | 4 +- 4 files changed, 98 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d26d5b00..54ea17d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/src/scene/convert/acis_to_truck.rs b/src/scene/convert/acis_to_truck.rs index 4c079f4d..f0e04d66 100644 --- a/src/scene/convert/acis_to_truck.rs +++ b/src/scene/convert/acis_to_truck.rs @@ -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 { - 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 { + 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::>() + } else { + pts.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(); + 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 = 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] } } + + diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index 42ad2719..299982fd 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -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 = 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> { + let mut loops: Vec> = Vec::new(); + let mut loop_ptr = face.first_loop(); + let mut seen: HashSet = 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]); diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 7cc3850d..cca865d7 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -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 {