use crate::mesh::Mesh;
use nalgebra::{Point3, Vector3};
use rustc_hash::FxHashMap;
use super::ClippingProcessor;
pub(crate) fn tri_is_needle(v: &[Point3<f64>; 3]) -> bool {
let d = |a: &Point3<f64>, b: &Point3<f64>| (a - b).norm();
let (e0, e1, e2) = (d(&v[0], &v[1]), d(&v[1], &v[2]), d(&v[2], &v[0]));
let mn = e0.min(e1).min(e2);
let mx = e0.max(e1).max(e2);
if !mx.is_finite() || mx <= 0.0 {
return true; }
mn < floor_pow2(mx) * 2.0_f64.powi(-13)
}
fn emit_triangle(mesh: &mut Mesh, v: &[Point3<f64>; 3], normal: &Vector3<f64>) {
if tri_is_needle(v) {
return;
}
let base = mesh.vertex_count() as u32;
mesh.add_vertex(v[0], *normal);
mesh.add_vertex(v[1], *normal);
mesh.add_vertex(v[2], *normal);
mesh.add_triangle(base, base + 1, base + 2);
}
fn count_open_boundary_edges(mesh: &Mesh) -> usize {
if mesh.positions.len() < 9 || mesh.indices.len() < 3 {
return 0;
}
let q = |v: f32| (v as f64 * 1.0e3).round() as i64;
let mut vid: FxHashMap<(i64, i64, i64), u32> = FxHashMap::default();
let mut id_of = |i: usize| -> u32 {
let k = (
q(mesh.positions[i * 3]),
q(mesh.positions[i * 3 + 1]),
q(mesh.positions[i * 3 + 2]),
);
let next = vid.len() as u32;
*vid.entry(k).or_insert(next)
};
let mut bal: FxHashMap<(u32, u32), i32> = FxHashMap::default();
for tri in mesh.indices.chunks_exact(3) {
let (a, b, c) = (
id_of(tri[0] as usize),
id_of(tri[1] as usize),
id_of(tri[2] as usize),
);
for (x, y) in [(a, b), (b, c), (c, a)] {
let (key, s) = if x < y { ((x, y), 1) } else { ((y, x), -1) };
*bal.entry(key).or_insert(0) += s;
}
}
bal.values().filter(|&&v| v != 0).count()
}
fn count_spike_triangles(mesh: &Mesh) -> usize {
let mut n = 0usize;
for tri in mesh.indices.chunks_exact(3) {
let p = |i: u32| {
let i = i as usize;
[
mesh.positions[i * 3],
mesh.positions[i * 3 + 1],
mesh.positions[i * 3 + 2],
]
};
let (a, b, c) = (p(tri[0]), p(tri[1]), p(tri[2]));
let d = |u: [f32; 3], v: [f32; 3]| {
((u[0] - v[0]).powi(2) + (u[1] - v[1]).powi(2) + (u[2] - v[2]).powi(2)).sqrt()
};
let (e0, e1, e2) = (d(a, b), d(b, c), d(c, a));
let mn = e0.min(e1).min(e2);
let mx = e0.max(e1).max(e2);
if mn > 1.0e-6 && mx / mn > 50.0 {
n += 1;
}
}
n
}
fn simplify_2d_collinear(ring: &[nalgebra::Point2<f64>]) -> Vec<nalgebra::Point2<f64>> {
let n = ring.len();
if n < 4 {
return ring.to_vec();
}
let mut keep = vec![true; n];
let mut changed = true;
while changed {
changed = false;
for i in 0..n {
if !keep[i] {
continue;
}
let prev = (1..n).map(|k| (i + n - k) % n).find(|&k| keep[k]);
let next = (1..n).map(|k| (i + k) % n).find(|&k| keep[k]);
let (prev, next) = match (prev, next) {
(Some(p), Some(n)) if p != i && n != i && p != n => (p, n),
_ => continue,
};
let a = ring[prev];
let b = ring[i];
let c = ring[next];
let e1x = b.x - a.x;
let e1y = b.y - a.y;
let e2x = c.x - b.x;
let e2y = c.y - b.y;
let cross = e1x * e2y - e1y * e2x;
let len1 = (e1x * e1x + e1y * e1y).sqrt();
let len2 = (e2x * e2x + e2y * e2y).sqrt();
let denom = len1 * len2;
if denom < 1.0e-18 || (cross.abs() / denom) < 1.0e-4 {
keep[i] = false;
changed = true;
}
}
}
ring.iter()
.zip(keep.iter())
.filter_map(|(p, k)| if *k { Some(*p) } else { None })
.collect()
}
#[inline]
fn floor_pow2(x: f64) -> f64 {
if !x.is_finite() || x <= 0.0 {
return 0.0;
}
let exp = x.to_bits() >> 52 & 0x7ff; let unbiased = exp as i64 - 1023;
2.0_f64.powi(unbiased as i32)
}
fn weld_near_coincident_2d(ring: &[nalgebra::Point2<f64>]) -> Vec<nalgebra::Point2<f64>> {
let n = ring.len();
if n < 4 {
return ring.to_vec();
}
let (mut minx, mut miny, mut maxx, mut maxy) =
(f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for p in ring {
minx = minx.min(p.x);
miny = miny.min(p.y);
maxx = maxx.max(p.x);
maxy = maxy.max(p.y);
}
let extent = (maxx - minx).max(maxy - miny);
if !extent.is_finite() || extent <= 0.0 {
return ring.to_vec();
}
let eps = (floor_pow2(extent) * 2.0_f64.powi(-13)).min(2.0_f64.powi(-12));
let eps2 = eps * eps;
let mut kept: Vec<nalgebra::Point2<f64>> = Vec::with_capacity(n);
for &p in ring {
let dup = kept.last().is_some_and(|q| {
let dx = p.x - q.x;
let dy = p.y - q.y;
dx * dx + dy * dy < eps2
});
if !dup {
kept.push(p);
}
}
if kept.len() >= 2 {
let (first, last) = (kept[0], *kept.last().unwrap());
let dx = last.x - first.x;
let dy = last.y - first.y;
if dx * dx + dy * dy < eps2 {
kept.pop();
}
}
if kept.len() >= 3 {
kept
} else {
ring.to_vec()
}
}
impl ClippingProcessor {
pub(crate) fn consolidate_coplanar(mesh: Mesh) -> Mesh {
use crate::grid::NORMAL_QUANT_F64 as NORMAL_QUANT;
use crate::triangulation::{
project_to_2d_with_basis, triangulate_polygon_with_holes_refined,
};
use i_overlay::core::fill_rule::FillRule;
use i_overlay::core::overlay_rule::OverlayRule;
use i_overlay::float::single::SingleFloatOverlay;
if mesh.indices.len() < 6 {
return mesh;
}
const POS_QUANT: f64 = 1.0e6;
let qpos = |p: f64| (p * POS_QUANT).round() as i64;
let qnorm = |n: f64| (n * NORMAL_QUANT).round() as i64;
struct PlaneTri {
v: [Point3<f64>; 3],
normal: Vector3<f64>,
}
let positions = &mesh.positions;
let vertex_count = positions.len() / 3;
let mut buckets: std::collections::BTreeMap<(i64, i64, i64, i64), Vec<PlaneTri>> =
std::collections::BTreeMap::new();
for chunk in mesh.indices.chunks_exact(3) {
let (i0, i1, i2) = (chunk[0] as usize, chunk[1] as usize, chunk[2] as usize);
if i0 >= vertex_count || i1 >= vertex_count || i2 >= vertex_count {
continue;
}
let v0 = Point3::new(
positions[i0 * 3] as f64,
positions[i0 * 3 + 1] as f64,
positions[i0 * 3 + 2] as f64,
);
let v1 = Point3::new(
positions[i1 * 3] as f64,
positions[i1 * 3 + 1] as f64,
positions[i1 * 3 + 2] as f64,
);
let v2 = Point3::new(
positions[i2 * 3] as f64,
positions[i2 * 3 + 1] as f64,
positions[i2 * 3 + 2] as f64,
);
let edge1 = v1 - v0;
let edge2 = v2 - v0;
let cross = edge1.cross(&edge2);
let len = cross.norm();
if len < 1.0e-10 {
continue;
}
let normal = cross / len;
let offset = normal.dot(&v0.coords);
let key = (
qnorm(normal.x),
qnorm(normal.y),
qnorm(normal.z),
qpos(offset),
);
buckets.entry(key).or_default().push(PlaneTri {
v: [v0, v1, v2],
normal,
});
}
let mut output = Mesh::new();
for tris in buckets.values() {
if tris.is_empty() {
continue;
}
let normal = tris[0].normal;
let origin = tris[0].v[0];
let abs = (normal.x.abs(), normal.y.abs(), normal.z.abs());
let reference = if abs.0 <= abs.1 && abs.0 <= abs.2 {
Vector3::new(1.0, 0.0, 0.0)
} else if abs.1 <= abs.2 {
Vector3::new(0.0, 1.0, 0.0)
} else {
Vector3::new(0.0, 0.0, 1.0)
};
let u_axis = normal.cross(&reference).normalize();
let v_axis = normal.cross(&u_axis).normalize();
if tris.len() == 1 {
emit_triangle(&mut output, &tris[0].v, &normal);
continue;
}
let mut subject: Vec<Vec<[f64; 2]>> = Vec::with_capacity(1);
let mut clip: Vec<Vec<[f64; 2]>> = Vec::with_capacity(tris.len() - 1);
for (idx, tri) in tris.iter().enumerate() {
let pts_2d = project_to_2d_with_basis(&tri.v, &u_axis, &v_axis, &origin);
let signed_area = (pts_2d[1].x - pts_2d[0].x)
* (pts_2d[2].y - pts_2d[0].y)
- (pts_2d[2].x - pts_2d[0].x)
* (pts_2d[1].y - pts_2d[0].y);
let path: Vec<[f64; 2]> = if signed_area >= 0.0 {
pts_2d.iter().map(|p| [p.x, p.y]).collect()
} else {
pts_2d.iter().rev().map(|p| [p.x, p.y]).collect()
};
if idx == 0 {
subject.push(path);
} else {
clip.push(path);
}
}
let shapes = subject.overlay(&clip, OverlayRule::Union, FillRule::NonZero);
if shapes.is_empty() {
for t in tris {
emit_triangle(&mut output, &t.v, &normal);
}
continue;
}
let bucket_area: f64 = tris
.iter()
.map(|t| {
let pts =
project_to_2d_with_basis(&t.v, &u_axis, &v_axis, &origin);
0.5_f64
* ((pts[1].x - pts[0].x) * (pts[2].y - pts[0].y)
- (pts[2].x - pts[0].x) * (pts[1].y - pts[0].y))
.abs()
})
.sum();
let min_significant = (bucket_area * 1.0e-4).max(1.0e-8);
let signed_area_2d = |ring: &[nalgebra::Point2<f64>]| -> f64 {
let n = ring.len();
if n < 3 {
return 0.0;
}
let mut s = 0.0;
for i in 0..n {
let j = (i + 1) % n;
s += ring[i].x * ring[j].y - ring[j].x * ring[i].y;
}
s * 0.5
};
for shape in shapes {
if shape.is_empty() {
continue;
}
let outer_2d: Vec<nalgebra::Point2<f64>> = shape[0]
.iter()
.map(|p| nalgebra::Point2::new(p[0], p[1]))
.collect();
let outer_welded = weld_near_coincident_2d(&outer_2d);
let outer_simplified = simplify_2d_collinear(&outer_welded);
if outer_simplified.len() < 3 {
continue;
}
let outer_area = signed_area_2d(&outer_simplified).abs();
if outer_area < min_significant {
continue;
}
let holes_simplified: Vec<Vec<nalgebra::Point2<f64>>> = shape
.iter()
.skip(1)
.filter_map(|c| {
let pts: Vec<_> = c
.iter()
.map(|p| nalgebra::Point2::new(p[0], p[1]))
.collect();
let welded = weld_near_coincident_2d(&pts);
let simplified = simplify_2d_collinear(&welded);
if simplified.len() < 3 {
return None;
}
let area = signed_area_2d(&simplified).abs();
if area < min_significant {
return None;
}
Some(simplified)
})
.collect();
let (all_2d, indices) = match triangulate_polygon_with_holes_refined(
&outer_simplified,
&holes_simplified,
) {
Ok((pts, idx)) => (pts, idx),
Err(_) => continue,
};
let lift = |p: nalgebra::Point2<f64>| -> Point3<f64> {
let off = u_axis * p.x + v_axis * p.y;
origin + off
};
let mut verts_3d: Vec<Point3<f64>> = Vec::with_capacity(all_2d.len());
for p in &all_2d {
verts_3d.push(lift(*p));
}
let base = output.vertex_count() as u32;
for vp in &verts_3d {
output.add_vertex(*vp, normal);
}
for tri in indices.chunks_exact(3) {
let v = [
verts_3d[tri[0]],
verts_3d[tri[1]],
verts_3d[tri[2]],
];
if tri_is_needle(&v) {
continue;
}
output.add_triangle(
base + tri[0] as u32,
base + tri[1] as u32,
base + tri[2] as u32,
);
}
}
}
if output.is_empty() {
return mesh;
}
let mut bnorms: Vec<Vector3<f64>> = Vec::new();
for tris in buckets.values() {
if let Some(t0) = tris.first() {
if !bnorms.iter().any(|m| m.dot(&t0.normal).abs() > 0.99999) {
bnorms.push(t0.normal);
}
}
}
let nonorthogonal = (0..bnorms.len()).any(|i| {
((i + 1)..bnorms.len()).any(|j| {
let d = bnorms[i].dot(&bnorms[j]).abs();
d > 0.01 && d < 0.9999 })
});
let offset_jittered = buckets.len() > 4 * bnorms.len().max(1);
if nonorthogonal || offset_jittered {
let cons_open = count_open_boundary_edges(&output);
if cons_open > 0 {
let raw_bad = count_open_boundary_edges(&mesh) + count_spike_triangles(&mesh);
let cons_bad = cons_open + count_spike_triangles(&output);
if raw_bad < cons_bad {
return mesh;
}
}
}
output.rtc_applied = mesh.rtc_applied;
output.origin = mesh.origin;
output.local_bounds = mesh.local_bounds;
output.local_to_world = mesh.local_to_world;
output
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn floor_pow2_is_exact_and_deterministic() {
assert_eq!(floor_pow2(1.0), 1.0);
assert_eq!(floor_pow2(2.0), 2.0);
assert_eq!(floor_pow2(8.0), 8.0);
assert_eq!(floor_pow2(1.9), 1.0);
assert_eq!(floor_pow2(5.657), 4.0);
assert_eq!(floor_pow2(0.2), 0.125);
assert_eq!(floor_pow2(0.0), 0.0);
assert_eq!(floor_pow2(-3.0), 0.0);
for x in [0.3_f64, 1.7, 3.0, 17.9, 1024.0, 1e-3, 1e6] {
let p = floor_pow2(x);
assert!(p > 0.0 && p <= x);
assert_eq!(p.to_bits() & 0x000f_ffff_ffff_ffff, 0, "floor_pow2({x}) not a clean power of two");
}
}
#[test]
fn tri_is_needle_flags_hairline_slivers_not_real_thin_faces() {
let needle = [
Point3::new(4.672253608703613, -1.0, 12.385885238647461),
Point3::new(1.047027587890625, -5.0, 14.07635498046875),
Point3::new(4.672259330749512, -1.0, 12.385882377624512),
];
assert!(tri_is_needle(&needle), "the #1007 diagonal sliver was not flagged");
let real_thin = [
Point3::new(0.0, 0.0, 0.0),
Point3::new(2.0, 0.0, 0.0),
Point3::new(2.0, 0.2, 0.0),
];
assert!(!tri_is_needle(&real_thin), "a real 0.2×2 m sliver was wrongly flagged");
let healthy = [
Point3::new(0.0, 0.0, 0.0),
Point3::new(1.0, 0.0, 0.0),
Point3::new(0.5, 0.9, 0.0),
];
assert!(!tri_is_needle(&healthy));
let collapsed = [Point3::new(1.0, 1.0, 1.0); 3];
assert!(tri_is_needle(&collapsed));
}
#[test]
fn weld_near_coincident_2d_collapses_um_rim_duplicates() {
use nalgebra::Point2;
let ring = vec![
Point2::new(0.0, 0.0),
Point2::new(1.9, 0.0),
Point2::new(1.9, 1.0),
Point2::new(0.000_006_6, 1.0),
Point2::new(0.0, 1.0),
];
let welded = weld_near_coincident_2d(&ring);
assert_eq!(welded.len(), 4, "near-coincident rim duplicate not welded: {welded:?}");
let clean = vec![
Point2::new(0.0, 0.0),
Point2::new(2.0, 0.0),
Point2::new(2.0, 0.2),
Point2::new(0.0, 0.2),
];
assert_eq!(weld_near_coincident_2d(&clean).len(), 4, "a clean ring was over-welded");
}
#[test]
fn weld_near_coincident_2d_keeps_mm_features_on_large_rings() {
use nalgebra::Point2;
let chamfered = vec![
Point2::new(0.0, 0.0),
Point2::new(12.0, 0.0),
Point2::new(12.0, 0.999),
Point2::new(11.999, 1.0), Point2::new(0.0, 1.0),
];
let welded = weld_near_coincident_2d(&chamfered);
assert_eq!(
welded.len(),
5,
"1 mm chamfer on a 12 m ring was over-welded: {welded:?}"
);
let ring = vec![
Point2::new(0.0, 0.0),
Point2::new(12.0, 0.0),
Point2::new(12.0, 1.0),
Point2::new(0.000_02, 1.0), Point2::new(0.0, 1.0),
];
assert_eq!(
weld_near_coincident_2d(&ring).len(),
4,
"µm rim duplicate on a large ring not welded"
);
}
#[test]
fn merge_coplanar_collapses_subdivided_quad() {
let mut mesh = Mesh::new();
for p in [
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[1.0, 1.0, 0.0],
[0.0, 1.0, 0.0],
[0.5, 0.5, 0.0],
] {
mesh.add_vertex(
Point3::new(p[0], p[1], p[2]),
Vector3::new(0.0, 0.0, 1.0),
);
}
mesh.add_triangle(0, 1, 4);
mesh.add_triangle(1, 2, 4);
mesh.add_triangle(2, 3, 4);
mesh.add_triangle(3, 0, 4);
let consolidated = ClippingProcessor::consolidate_coplanar(mesh);
assert_eq!(
consolidated.indices.len() / 3,
2,
"consolidated quad should triangulate to 2 tris, got {}",
consolidated.indices.len() / 3
);
}
#[test]
fn consolidate_preserves_local_frame_origin() {
let mut mesh = Mesh::new();
for p in [
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[1.0, 1.0, 0.0],
[0.0, 1.0, 0.0],
[0.5, 0.5, 0.0],
] {
mesh.add_vertex(Point3::new(p[0], p[1], p[2]), Vector3::new(0.0, 0.0, 1.0));
}
mesh.add_triangle(0, 1, 4);
mesh.add_triangle(1, 2, 4);
mesh.add_triangle(2, 3, 4);
mesh.add_triangle(3, 0, 4);
mesh.origin = [11.85, 5.0, 1.35];
mesh.rtc_applied = true;
let consolidated = ClippingProcessor::consolidate_coplanar(mesh);
assert_eq!(
consolidated.indices.len() / 3,
2,
"test precondition: the mesh must consolidate so the rebuilt-output frame carry is exercised"
);
assert_eq!(
consolidated.origin,
[11.85, 5.0, 1.35],
"consolidate_coplanar dropped the local-frame origin — the cut host would render at the world origin"
);
assert!(
consolidated.rtc_applied,
"consolidate_coplanar dropped rtc_applied"
);
}
#[test]
fn merge_coplanar_collapses_edge_split_quad() {
let mut mesh = Mesh::new();
for p in [
[0.0, 0.0, 0.0],
[0.5, 0.0, 0.0],
[1.5, 0.0, 0.0],
[2.0, 0.0, 0.0],
[2.0, 1.0, 0.0],
[0.0, 1.0, 0.0],
] {
mesh.add_vertex(
Point3::new(p[0], p[1], p[2]),
Vector3::new(0.0, 0.0, 1.0),
);
}
mesh.add_triangle(0, 1, 5);
mesh.add_triangle(1, 2, 5);
mesh.add_triangle(2, 4, 5);
mesh.add_triangle(2, 3, 4);
let consolidated = ClippingProcessor::consolidate_coplanar(mesh);
assert_eq!(
consolidated.indices.len() / 3,
2,
"edge-split quad must collapse to 2 tris after collinear cleanup, got {}",
consolidated.indices.len() / 3
);
}
fn curved_wall(n: usize, r: f64, t: f64, h: f64) -> Mesh {
use std::f64::consts::PI;
let mut m = Mesh::with_capacity(0, 0);
let nrm = Vector3::new(0.0, 0.0, 0.0);
let mut verts = Vec::new();
for i in 0..=n {
let a = (i as f64) / (n as f64) * (PI / 2.0);
let (c, s) = (a.cos(), a.sin());
verts.push(Point3::new(r * c, r * s, 0.0)); verts.push(Point3::new(r * c, r * s, h)); verts.push(Point3::new((r - t) * c, (r - t) * s, 0.0)); verts.push(Point3::new((r - t) * c, (r - t) * s, h)); }
for p in &verts {
m.add_vertex(*p, nrm);
}
let (ob, ot, ib, it) = (
|i: usize| 4 * i as u32,
|i: usize| 4 * i as u32 + 1,
|i: usize| 4 * i as u32 + 2,
|i: usize| 4 * i as u32 + 3,
);
let quad = |a: u32, b: u32, c: u32, d: u32, m: &mut Mesh| {
m.add_triangle(a, b, c);
m.add_triangle(a, c, d);
};
for i in 0..n {
quad(ob(i), ob(i + 1), ot(i + 1), ot(i), &mut m); quad(ib(i + 1), ib(i), it(i), it(i + 1), &mut m); quad(ot(i), ot(i + 1), it(i + 1), it(i), &mut m); quad(ib(i), ib(i + 1), ob(i + 1), ob(i), &mut m); }
quad(ob(0), ot(0), it(0), ib(0), &mut m); quad(ib(n), it(n), ot(n), ob(n), &mut m); m
}
fn axis_box(lo: [f64; 3], hi: [f64; 3]) -> Mesh {
let mut m = Mesh::with_capacity(8, 36);
let n = Vector3::new(0.0, 0.0, 0.0);
let c = [
Point3::new(lo[0], lo[1], lo[2]),
Point3::new(hi[0], lo[1], lo[2]),
Point3::new(hi[0], hi[1], lo[2]),
Point3::new(lo[0], hi[1], lo[2]),
Point3::new(lo[0], lo[1], hi[2]),
Point3::new(hi[0], lo[1], hi[2]),
Point3::new(hi[0], hi[1], hi[2]),
Point3::new(lo[0], hi[1], hi[2]),
];
for p in c.iter() {
m.add_vertex(*p, n);
}
for tri in [
[0u32, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7],
[0, 1, 5], [0, 5, 4], [2, 3, 7], [2, 7, 6],
[1, 2, 6], [1, 6, 5], [3, 0, 4], [3, 4, 7],
] {
m.add_triangle(tri[0], tri[1], tri[2]);
}
m
}
fn count_open_edges(mesh: &Mesh) -> usize {
use std::collections::HashMap;
let q = |v: f32| (v as f64 * 1.0e6).round() as i64;
let mut vid: HashMap<(i64, i64, i64), u32> = HashMap::new();
let mut id = |i: usize| -> u32 {
let k = (
q(mesh.positions[i * 3]),
q(mesh.positions[i * 3 + 1]),
q(mesh.positions[i * 3 + 2]),
);
let n = vid.len() as u32;
*vid.entry(k).or_insert(n)
};
let mut edge: HashMap<(u32, u32), i32> = HashMap::new();
for tri in mesh.indices.chunks_exact(3) {
let (a, b, c) = (id(tri[0] as usize), id(tri[1] as usize), id(tri[2] as usize));
for (x, y) in [(a, b), (b, c), (c, a)] {
let (k, s) = if x < y { ((x, y), 1) } else { ((y, x), -1) };
*edge.entry(k).or_insert(0) += s;
}
}
edge.values().filter(|&&v| v != 0).count()
}
#[test]
fn curved_wall_opening_seam_is_watertight() {
let host = curved_wall(8, 5.0, 0.3, 3.0); assert_eq!(count_open_edges(&host), 0, "host must be watertight");
let cutter = axis_box([2.4, 2.4, 1.0], [4.4, 4.4, 2.0]);
let raw = crate::kernel::mesh_bridge::subtract(&host, &cutter);
let raw_open = count_open_edges(&raw);
let consolidated = ClippingProcessor::consolidate_coplanar(raw.clone());
let cons_open = count_open_edges(&consolidated);
eprintln!(
"SEAMTEST raw_tris={} raw_open={} cons_tris={} cons_open={}",
raw.triangle_count(),
raw_open,
consolidated.triangle_count(),
cons_open
);
assert_eq!(raw_open, 0, "raw kernel output must be watertight");
assert_eq!(
cons_open, 0,
"consolidate must preserve the curved-wall opening seam (was torn)"
);
}
}