fix(acis): fill a planar face's holes in the fallback sampler too

A face with a hole came out with the hole solid and the material around it
missing. Handle 176 of Annotation-2D-Mesh-Solid-BIM.dwg is one.

`tess_plane_face` read `face.first_loop()` and fan-triangulated it, on the
grounds that holes were the kernel path's job. Two things make that wrong.
Nothing says the outer loop is listed first, so when a hole came first the
hole got filled and the boundary was ignored. And the kernel path does not
always win: when its mesh is incomplete, `tessellate_acis` runs both and keeps
whichever produced more triangles — which is not a measure of being right.

Every loop is read now, flattened into the plane's own frame, and triangulated
with the widest ring bounding and the rest cutting into it — the same rule the
kernel uses, and by area rather than by listing order.

This is interim. The fallback should not exist at all: the kernel already
lifts these bodies with no loss and meshes ten of handle 176's fourteen faces.
The four it drops are planar rectangles whose boundary projects with one
edge's `v` mirrored, so the ring self-crosses and ear clipping declines it —
a `pcurve` fault, not a missing feature. Fixing that makes the mesh complete,
and a complete mesh means the fallback never runs and can go.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 5 committed 2026-08-10 01:56:57 +03:00
1 parent 13d3c7d803
commit 5a2aa13b74
1 file changed
+59 -26
+59 -26
View File
@@ -2017,8 +2017,13 @@ pub(crate) fn tess_plane_face(
normals: &mut Vec<[f32; 3]>,
indices: &mut Vec<u32>,
) {
let mut poly = collect_face_polygon(sat, face, chord_frac);
if poly.len() < 3 {
// Every loop, not just the first. A face with a hole in it carries the
// hole on a second loop, and nothing says the outer one is listed first —
// so reading `first_loop` alone drew whichever came first and ignored the
// rest. When that was a hole, the hole came out solid and the material
// around it came out missing.
let rings = collect_face_loops(sat, face, chord_frac);
if rings.iter().all(|ring| ring.len() < 3) {
return;
}
@@ -2031,21 +2036,63 @@ pub(crate) fn tess_plane_face(
};
let nf = [nx as f32, ny as f32, nz as f32];
if dot3(newell_normal(&poly), [nx, ny, nz]) < 0.0 {
poly.reverse();
// Flattened into the plane's own frame, where "inside" is a question with
// an answer. The kernel's ear clipping bridges the holes into the outer
// ring; a fan from one vertex cannot express a hole at all.
let (px, py, pz) = plane.root_point();
let seed = if nx.abs() < 0.9 {
[1.0, 0.0, 0.0]
} else {
[0.0, 1.0, 0.0]
};
let Some(frame) =
acadrust::kernel::space::Plane::orthonormal([px, py, pz], seed, [nx, ny, nz])
else {
return;
};
let flat: Vec<Vec<[f64; 2]>> = rings
.iter()
.filter(|ring| ring.len() >= 3)
.map(|ring| ring.iter().filter_map(|p| frame.project(*p)).collect())
.collect();
let Some(widest) = flat
.iter()
.enumerate()
.max_by(|a, b| {
acadrust::kernel::geom2d::signed_area(a.1)
.abs()
.total_cmp(&acadrust::kernel::geom2d::signed_area(b.1).abs())
})
.map(|(index, _)| index)
else {
return;
};
let holes: Vec<Vec<[f64; 2]>> = flat
.iter()
.enumerate()
.filter(|(index, _)| *index != widest)
.map(|(_, ring)| ring.clone())
.collect();
let (points, triangles) =
acadrust::kernel::geom2d::triangulate::polygon(&flat[widest], &holes);
if triangles.is_empty() {
return;
}
let base = verts.len() as u32;
for &pt in &poly {
verts.push(pt);
for point in &points {
verts.push(frame.point_at(*point));
normals.push(nf);
}
// Fan triangulation from vertex 0 (outer loop only; holes are handled by
// the kernel B-rep path in `acis_kernel`).
let n = poly.len() as u32;
for i in 1..(n - 1) {
indices.extend_from_slice(&[base, base + i, base + i + 1]);
// Ear clipping hands back counter-clockwise triangles in the frame's own
// coordinates; the frame was built around the outward normal, so that is
// already the outward winding.
for triangle in triangles {
indices.extend_from_slice(&[
base + triangle[0] as u32,
base + triangle[1] as u32,
base + triangle[2] as u32,
]);
}
}
@@ -2632,20 +2679,6 @@ fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
/// Area-weighted polygon normal via Newell's method. Robust for non-planar or
/// slightly noisy loops; its sign encodes the winding direction.
fn newell_normal(poly: &[[f64; 3]]) -> [f64; 3] {
let mut n = [0.0f64; 3];
let len = poly.len();
for i in 0..len {
let a = poly[i];
let b = poly[(i + 1) % len];
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
}
#[inline]
fn cross3(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {