Skip to main content

brepkit_operations/
mesh_boolean.rs

1//! Co-refinement mesh boolean operations on triangle meshes.
2//!
3//! Implements mesh booleans (fuse, cut, intersect) using the co-refinement
4//! approach: compute exact triangle-triangle intersections, re-triangulate
5//! both meshes so the intersection polylines appear as conforming triangle
6//! edges on BOTH sides, classify sub-triangles (inside / outside / lying on
7//! the other mesh's surface), and assemble the result.
8//!
9//! This operates directly on [`TriangleMesh`] without requiring topology.
10
11#![allow(clippy::tuple_array_conversions)]
12
13use brepkit_math::aabb::Aabb3;
14use brepkit_math::bvh::Bvh;
15use brepkit_math::cdt::Cdt;
16use brepkit_math::det_hash::DetHashMap;
17use brepkit_math::predicates::orient3d;
18use brepkit_math::vec::{Point2, Point3, Vec3};
19
20use crate::OperationsError;
21use crate::boolean::BooleanOp;
22use crate::tessellate::TriangleMesh;
23
24/// Result of a mesh boolean operation.
25#[derive(Debug, Clone)]
26pub struct MeshBooleanResult {
27    /// The resulting triangle mesh.
28    pub mesh: TriangleMesh,
29    /// Boundary (one-sided) edge count of the output, measured after welding
30    /// vertices by position. Zero for a closed result; nonzero means the
31    /// co-refinement could not produce a watertight mesh for these inputs.
32    pub boundary_edge_count: usize,
33    /// Non-manifold (3+ incidence) edge count of the output, measured after
34    /// welding vertices by position. Zero for a 2-manifold result.
35    pub non_manifold_edge_count: usize,
36}
37
38/// Position-weld grid for the output self-check. Seam vertices are shared
39/// verbatim between the two split meshes, so anything below feature scale
40/// works; sharing the `mesh_ops` coincident-triangle dedupe grid keeps the
41/// self-check and the downstream dedupe measuring on the same weld grid.
42const SELF_CHECK_GRID: f64 = crate::tessellate::COINCIDENT_DEDUPE_GRID;
43
44/// Co-refinement budgets. Exceeding either returns an `Err` instead of running.
45///
46/// Every stage after the broad phase allocates per intersecting PAIR (segments,
47/// per-triangle CDT constraint sets, split sub-triangles), so growth is
48/// superlinear in the pair count. On a 64-bit host that is merely slow, but
49/// wasm32 caps linear memory at 4GB and a failed allocation reaches
50/// `handle_alloc_error` → `abort()` — a trap that stops the whole instance,
51/// strands its `WasmRefCell` borrow flag, and (unlike a panic) leaves no message
52/// behind, so every later call fails with "recursive use of an object". A
53/// rejected boolean is recoverable; an aborted kernel is not.
54///
55/// These are a backstop, not a quality threshold: both sit far above any case
56/// measured here (real fallbacks run in the thousands of triangles, and the
57/// goma export's worst is 7148 triangles / 497 pairs). No observed failure has
58/// yet reached them.
59const MAX_INPUT_TRIANGLES: usize = 4_000_000;
60const MAX_INTERSECTING_PAIRS: usize = 2_000_000;
61
62/// Perform a mesh boolean operation between two triangle meshes.
63///
64/// Uses co-refinement: compute exact triangle-triangle intersections,
65/// re-triangulate every intersected triangle with the intersection segments
66/// as triangulation constraints (so the seam is a shared, conforming
67/// polyline on both meshes), classify sub-triangles by winding number with
68/// explicit handling of coincident-surface (exactly touching) triangles,
69/// and assemble the result.
70///
71/// The returned [`MeshBooleanResult`] carries position-welded boundary and
72/// non-manifold edge counts of the output so callers can detect a
73/// non-watertight result instead of consuming it silently.
74///
75/// # Errors
76/// Returns an error if the operation cannot be completed (e.g. the
77/// intersection of disjoint meshes is empty), or if the operands exceed the
78/// co-refinement budgets below.
79pub fn mesh_boolean(
80    mesh_a: &TriangleMesh,
81    mesh_b: &TriangleMesh,
82    op: BooleanOp,
83    tolerance: f64,
84) -> Result<MeshBooleanResult, OperationsError> {
85    let (tris_a, tris_b) = (mesh_a.indices.len() / 3, mesh_b.indices.len() / 3);
86    log::debug!("mesh_boolean {op:?}: input triangles a={tris_a} b={tris_b}");
87    if tris_a + tris_b > MAX_INPUT_TRIANGLES {
88        return Err(OperationsError::InvalidInput {
89            reason: format!(
90                "mesh boolean operands too large to co-refine: {tris_a} + {tris_b} triangles \
91                 exceeds the {MAX_INPUT_TRIANGLES} budget"
92            ),
93        });
94    }
95
96    // Step 1: BVH broad-phase
97    let bvh_a = build_triangle_bvh(mesh_a);
98    let bvh_b = build_triangle_bvh(mesh_b);
99    let pairs = find_intersecting_pairs(mesh_a, &bvh_b, tolerance);
100    log::debug!("mesh_boolean {op:?}: {} intersecting pairs", pairs.len());
101    if pairs.len() > MAX_INTERSECTING_PAIRS {
102        return Err(OperationsError::InvalidInput {
103            reason: format!(
104                "mesh boolean co-refinement too large: {} intersecting triangle pairs \
105                 exceeds the {MAX_INTERSECTING_PAIRS} budget (operands {tris_a} + {tris_b} triangles)",
106                pairs.len()
107            ),
108        });
109    }
110
111    // Step 2: Triangle-triangle intersection segments
112    let segments = compute_all_intersections(mesh_a, mesh_b, &pairs, tolerance);
113    log::debug!("mesh_boolean {op:?}: step2 {} segments", segments.len());
114
115    // Step 3: Conforming re-triangulation of both meshes
116    let split_a = split_mesh_conforming(mesh_a, &segments, true, tolerance);
117    let split_b = split_mesh_conforming(mesh_b, &segments, false, tolerance);
118
119    // Step 4: Classify sub-triangles
120    let classify_a = classify_split_triangles(&split_a, mesh_b, &bvh_b, tolerance);
121    let classify_b = classify_split_triangles(&split_b, mesh_a, &bvh_a, tolerance);
122
123    // Step 5: Assemble result
124    let mesh = assemble_result(&split_a, &split_b, &classify_a, &classify_b, op);
125    log::debug!(
126        "mesh_boolean {op:?}: assembled {} tris",
127        mesh.indices.len() / 3
128    );
129
130    if mesh.positions.is_empty() {
131        return Err(OperationsError::EmptyResult {
132            reason: "mesh boolean produced no output vertices".into(),
133        });
134    }
135
136    let (boundary_edge_count, non_manifold_edge_count) = welded_health(&mesh, SELF_CHECK_GRID);
137    Ok(MeshBooleanResult {
138        mesh,
139        boundary_edge_count,
140        non_manifold_edge_count,
141    })
142}
143
144/// Count boundary and non-manifold edges after welding vertices to a grid.
145fn welded_health(mesh: &TriangleMesh, grid: f64) -> (usize, usize) {
146    type Q = (i64, i64, i64);
147    let s = 1.0 / grid;
148    #[allow(clippy::cast_possible_truncation)]
149    let q = |p: Point3| -> Q {
150        (
151            (p.x() * s).round() as i64,
152            (p.y() * s).round() as i64,
153            (p.z() * s).round() as i64,
154        )
155    };
156    let mut occ: DetHashMap<(Q, Q), u32> = DetHashMap::default();
157    for tri in mesh.indices.chunks_exact(3) {
158        let a = q(mesh.positions[tri[0] as usize]);
159        let b = q(mesh.positions[tri[1] as usize]);
160        let c = q(mesh.positions[tri[2] as usize]);
161        if a == b || b == c || a == c {
162            continue;
163        }
164        for (p, r) in [(a, b), (b, c), (c, a)] {
165            let key = if p <= r { (p, r) } else { (r, p) };
166            *occ.entry(key).or_default() += 1;
167        }
168    }
169    let bnd = occ.values().filter(|&&c| c == 1).count();
170    let nm = occ.values().filter(|&&c| c > 2).count();
171    (bnd, nm)
172}
173
174/// Build a BVH over a mesh's triangles.
175fn build_triangle_bvh(mesh: &TriangleMesh) -> Bvh {
176    let tri_count = mesh.indices.len() / 3;
177    let mut entries = Vec::with_capacity(tri_count);
178    for i in 0..tri_count {
179        let (v0, v1, v2) = get_triangle(mesh, i);
180        entries.push((i, Aabb3::from_points([v0, v1, v2])));
181    }
182    Bvh::build(&entries)
183}
184
185/// Find all potentially intersecting triangle pairs between mesh A and mesh B.
186fn find_intersecting_pairs(
187    mesh_a: &TriangleMesh,
188    bvh_b: &Bvh,
189    tolerance: f64,
190) -> Vec<(usize, usize)> {
191    let tri_count_a = mesh_a.indices.len() / 3;
192    let mut pairs = Vec::new();
193
194    for i in 0..tri_count_a {
195        let (v0, v1, v2) = get_triangle(mesh_a, i);
196        let aabb_a = Aabb3::from_points([v0, v1, v2]).expanded(tolerance);
197        let candidates = bvh_b.query_overlap(&aabb_a);
198        for j in candidates {
199            pairs.push((i, j));
200        }
201    }
202
203    pairs
204}
205
206/// An intersection segment between a triangle of mesh A and one of mesh B.
207///
208/// `apply_a` / `apply_b` say which mesh's split must honor the segment as a
209/// triangulation constraint. Transversal intersections apply to both;
210/// coplanar-contact co-refinement segments apply to one side only (the
211/// segment already runs along an existing edge of the other mesh).
212#[derive(Debug, Clone)]
213struct IsectSegment {
214    p0: Point3,
215    p1: Point3,
216    tri_a: usize,
217    tri_b: usize,
218    apply_a: bool,
219    apply_b: bool,
220}
221
222/// Compute all triangle-triangle intersection segments for the candidate pairs.
223fn compute_all_intersections(
224    mesh_a: &TriangleMesh,
225    mesh_b: &TriangleMesh,
226    pairs: &[(usize, usize)],
227    tolerance: f64,
228) -> Vec<IsectSegment> {
229    let mut result = Vec::new();
230
231    for &(tri_a, tri_b) in pairs {
232        let (a0, a1, a2) = get_triangle(mesh_a, tri_a);
233        let (b0, b1, b2) = get_triangle(mesh_b, tri_b);
234
235        for mut seg in intersect_triangles(a0, a1, a2, b0, b1, b2, tolerance) {
236            seg.tri_a = tri_a;
237            seg.tri_b = tri_b;
238            result.push(seg);
239        }
240    }
241
242    result
243}
244
245/// Compute the intersection segments between two triangles.
246///
247/// Transversal case: Moller interval overlap on the intersection line of the
248/// two planes, using `orient3d` for the side classification. Coplanar case:
249/// mutual 2D edge clipping so each mesh conforms to the other's edges inside
250/// the shared plane. Returns an empty vec if the triangles do not intersect
251/// or only touch at a point.
252#[allow(clippy::similar_names)]
253fn intersect_triangles(
254    a0: Point3,
255    a1: Point3,
256    a2: Point3,
257    b0: Point3,
258    b1: Point3,
259    b2: Point3,
260    tolerance: f64,
261) -> Vec<IsectSegment> {
262    // Classify vertices of B against the plane of A. orient3d scales with
263    // the host triangle's area, so normalize to a distance before comparing
264    // against the linear tolerance — otherwise a large host makes on-plane
265    // vertices read as genuinely off-plane (skipping the grazing imprint) and
266    // a tiny host makes off-plane vertices read as touching.
267    let na_pre = (a1 - a0).cross(a2 - a0);
268    let nb_pre = (b1 - b0).cross(b2 - b0);
269    let na_len = na_pre.length().max(1e-30);
270    let nb_len = nb_pre.length().max(1e-30);
271    let db0 = orient3d(a0, a1, a2, b0) / na_len;
272    let db1 = orient3d(a0, a1, a2, b1) / na_len;
273    let db2 = orient3d(a0, a1, a2, b2) / na_len;
274
275    if all_same_sign(db0, db1, db2, tolerance) {
276        // Grazing contact: B lies on one side but touches A's plane along an
277        // edge (two on-plane vertices). That edge is a region boundary for
278        // the coincident-band classification — without imprinting it, A's
279        // triangles straddle the boundary and the kept/dropped halves of one
280        // quad leave open edges (a stacking lip's bottom annulus grazing the
281        // body wall plane at z=13.3 was the lite fallback's bd=103).
282        return grazing_imprint([a0, a1, a2], [b0, b1, b2], [db0, db1, db2], true, tolerance);
283    }
284
285    // Classify vertices of A against the plane of B (distance-normalized,
286    // as above).
287    let da0 = orient3d(b0, b1, b2, a0) / nb_len;
288    let da1 = orient3d(b0, b1, b2, a1) / nb_len;
289    let da2 = orient3d(b0, b1, b2, a2) / nb_len;
290
291    if all_same_sign(da0, da1, da2, tolerance) {
292        // Mirror grazing case: A touches B's plane along an edge.
293        return grazing_imprint(
294            [b0, b1, b2],
295            [a0, a1, a2],
296            [da0, da1, da2],
297            false,
298            tolerance,
299        );
300    }
301
302    let na = (a1 - a0).cross(a2 - a0);
303    let nb = (b1 - b0).cross(b2 - b0);
304    let line_dir = na.cross(nb);
305
306    // Coplanar check: when |na × nb| ≈ 0, the triangles lie in the same plane.
307    let line_len_sq = line_dir.dot(line_dir);
308    let na_len_sq = na.dot(na);
309    let nb_len_sq = nb.dot(nb);
310    // sin²(angle) threshold: tolerance² for angular comparison
311    if line_len_sq < (tolerance * tolerance) * na_len_sq.max(nb_len_sq) {
312        return coplanar_corefine_segments([a0, a1, a2], [b0, b1, b2], tolerance);
313    }
314
315    // Project onto the axis with the largest component for numerical stability.
316    let ax = line_dir.x().abs();
317    let ay = line_dir.y().abs();
318    let az = line_dir.z().abs();
319
320    let project = |p: Point3| -> f64 {
321        if ax >= ay && ax >= az {
322            p.x()
323        } else if ay >= az {
324            p.y()
325        } else {
326            p.z()
327        }
328    };
329
330    let Some((ta_min, ta_max)) = triangle_interval(a0, a1, a2, da0, da1, da2, &project) else {
331        return Vec::new();
332    };
333    let Some((tb_min, tb_max)) = triangle_interval(b0, b1, b2, db0, db1, db2, &project) else {
334        return Vec::new();
335    };
336
337    let t_lo = ta_min.max(tb_min);
338    let t_hi = ta_max.min(tb_max);
339
340    if t_hi - t_lo < tolerance {
341        return Vec::new();
342    }
343
344    let tri_data = TriPlaneData {
345        v: [a0, a1, a2],
346        d: [da0, da1, da2],
347    };
348    let p0 = point_on_intersection_line(&tri_data, t_lo, &project);
349    let p1 = point_on_intersection_line(&tri_data, t_hi, &project);
350
351    vec![IsectSegment {
352        p0,
353        p1,
354        tri_a: 0,
355        tri_b: 0,
356        apply_a: true,
357        apply_b: true,
358    }]
359}
360
361/// Imprint a grazing contact: `touching`'s edge that lies in `host`'s plane
362/// (both endpoint orientations within tolerance) is clipped to `host`'s
363/// triangle and emitted as a MUTUAL constraint — the host splits its
364/// interior along it, and the touching mesh splits its own on-plane edge at
365/// the same canonical points, so the coincident-band classification boundary
366/// falls on welded triangle edges instead of cutting through interiors.
367fn grazing_imprint(
368    host: [Point3; 3],
369    touching: [Point3; 3],
370    touching_orients: [f64; 3],
371    host_is_a: bool,
372    tolerance: f64,
373) -> Vec<IsectSegment> {
374    // `touching_orients` arrive distance-normalized from the caller.
375    let n = (host[1] - host[0]).cross(host[2] - host[0]);
376    if n.length() < 1e-30 {
377        return Vec::new();
378    }
379    let on_plane: Vec<usize> = (0..3)
380        .filter(|&i| touching_orients[i].abs() <= tolerance)
381        .collect();
382    if on_plane.len() != 2 {
383        return Vec::new();
384    }
385    let p0 = touching[on_plane[0]];
386    let p1 = touching[on_plane[1]];
387
388    // Project into the host plane's dominant axes and clip to the host
389    // triangle (the same frame `coplanar_corefine_segments` uses).
390    let nax = n.x().abs();
391    let nay = n.y().abs();
392    let naz = n.z().abs();
393    let to_2d = |p: Point3| -> Point2 {
394        if naz >= nax && naz >= nay {
395            Point2::new(p.x(), p.y())
396        } else if nay >= nax {
397            Point2::new(p.x(), p.z())
398        } else {
399            Point2::new(p.y(), p.z())
400        }
401    };
402    let host2d = [to_2d(host[0]), to_2d(host[1]), to_2d(host[2])];
403    let Some((t0, t1)) = clip_segment_to_triangle_2d(to_2d(p0), to_2d(p1), &host2d, tolerance)
404    else {
405        return Vec::new();
406    };
407    let e = p1 - p0;
408    if (t1 - t0) * e.length() < tolerance * 2.0 {
409        return Vec::new();
410    }
411    // Mutual: the host splits its interior along the segment, and the
412    // touching mesh splits its own on-plane edge at the same canonical clip
413    // points (via the splitter's global on-edge point map) — one-sided
414    // imprinting leaves fresh T-junctions between the host's new vertices
415    // and the touching edge's own subdivision.
416    let _ = host_is_a;
417    vec![IsectSegment {
418        p0: lerp_point(p0, p1, t0),
419        p1: lerp_point(p0, p1, t1),
420        tri_a: 0,
421        tri_b: 0,
422        apply_a: true,
423        apply_b: true,
424    }]
425}
426
427/// Co-refinement segments for a coplanar triangle pair.
428///
429/// Each edge of B clipped to the interior of A becomes a constraint for A's
430/// re-triangulation (and vice versa), so both meshes end up conforming to
431/// each other's edges inside the shared plane. The clip endpoints are the
432/// mutual edge-edge crossing points, which lie on triangle edges of BOTH
433/// meshes; the splitter's edge-point propagation carries them onto the
434/// neighbors sharing those edges.
435fn coplanar_corefine_segments(
436    a3d: [Point3; 3],
437    b3d: [Point3; 3],
438    tolerance: f64,
439) -> Vec<IsectSegment> {
440    let na = (a3d[1] - a3d[0]).cross(a3d[2] - a3d[0]);
441    let nax = na.x().abs();
442    let nay = na.y().abs();
443    let naz = na.z().abs();
444
445    let to_2d = |p: Point3| -> Point2 {
446        if naz >= nax && naz >= nay {
447            Point2::new(p.x(), p.y())
448        } else if nay >= nax {
449            Point2::new(p.x(), p.z())
450        } else {
451            Point2::new(p.y(), p.z())
452        }
453    };
454
455    let a2d = [to_2d(a3d[0]), to_2d(a3d[1]), to_2d(a3d[2])];
456    let b2d = [to_2d(b3d[0]), to_2d(b3d[1]), to_2d(b3d[2])];
457
458    let mut out = Vec::new();
459    // B's edges clipped to A constrain mesh A's triangle.
460    clip_edges_into(&b2d, &b3d, &a2d, tolerance, true, &mut out);
461    // A's edges clipped to B constrain mesh B's triangle.
462    clip_edges_into(&a2d, &a3d, &b2d, tolerance, false, &mut out);
463    out
464}
465
466/// Clip each edge of `src` against the triangle `clip` (2D), emitting the
467/// interior portions as constraint segments for one mesh side.
468fn clip_edges_into(
469    src2d: &[Point2; 3],
470    src3d: &[Point3; 3],
471    clip2d: &[Point2; 3],
472    tolerance: f64,
473    for_mesh_a: bool,
474    out: &mut Vec<IsectSegment>,
475) {
476    for (i, j) in [(0usize, 1usize), (1, 2), (2, 0)] {
477        let Some((t0, t1)) = clip_segment_to_triangle_2d(src2d[i], src2d[j], clip2d, tolerance)
478        else {
479            continue;
480        };
481        let e3d = src3d[j] - src3d[i];
482        let len = e3d.length();
483        if (t1 - t0) * len < tolerance * 2.0 {
484            continue;
485        }
486        let p0 = lerp_point(src3d[i], src3d[j], t0);
487        let p1 = lerp_point(src3d[i], src3d[j], t1);
488        out.push(IsectSegment {
489            p0,
490            p1,
491            tri_a: 0,
492            tri_b: 0,
493            apply_a: for_mesh_a,
494            apply_b: !for_mesh_a,
495        });
496    }
497}
498
499/// Clip the parametric segment `e0 + t*(e1-e0)`, t in [0,1], against a 2D
500/// triangle (any winding). Returns the surviving parameter interval, or
501/// `None` when the segment misses the triangle.
502fn clip_segment_to_triangle_2d(
503    e0: Point2,
504    e1: Point2,
505    tri: &[Point2; 3],
506    tolerance: f64,
507) -> Option<(f64, f64)> {
508    // Orient the triangle CCW so the inward side of each edge is cross >= 0.
509    let signed2 = (tri[1].x() - tri[0].x()) * (tri[2].y() - tri[0].y())
510        - (tri[1].y() - tri[0].y()) * (tri[2].x() - tri[0].x());
511    if signed2.abs() < 1e-30 {
512        return None;
513    }
514    let (o0, o1, o2) = if signed2 > 0.0 {
515        (tri[0], tri[1], tri[2])
516    } else {
517        (tri[0], tri[2], tri[1])
518    };
519
520    let mut t_lo = 0.0_f64;
521    let mut t_hi = 1.0_f64;
522    for (p, q) in [(o0, o1), (o1, o2), (o2, o0)] {
523        let ex = q.x() - p.x();
524        let ey = q.y() - p.y();
525        let elen = ex.hypot(ey);
526        if elen < 1e-30 {
527            return None;
528        }
529        // Signed distance of the segment endpoints from this clip edge
530        // (positive = inside for a CCW triangle), in true distance units.
531        let d0 = (ex * (e0.y() - p.y()) - ey * (e0.x() - p.x())) / elen;
532        let d1 = (ex * (e1.y() - p.y()) - ey * (e1.x() - p.x())) / elen;
533        let eps = tolerance;
534        if d0 < -eps && d1 < -eps {
535            return None;
536        }
537        if d0 >= -eps && d1 >= -eps {
538            continue; // fully inside this half-plane
539        }
540        // Crossing: split at d == 0.
541        let t = d0 / (d0 - d1);
542        if d0 < -eps {
543            t_lo = t_lo.max(t);
544        } else {
545            t_hi = t_hi.min(t);
546        }
547    }
548    if t_hi <= t_lo {
549        return None;
550    }
551    Some((t_lo, t_hi))
552}
553
554/// Check if three signed distances are all on the same side (all positive or all negative).
555fn all_same_sign(d0: f64, d1: f64, d2: f64, tolerance: f64) -> bool {
556    let pos = d0 > tolerance || d1 > tolerance || d2 > tolerance;
557    let neg = d0 < -tolerance || d1 < -tolerance || d2 < -tolerance;
558    // All non-negative or all non-positive (accounting for tolerance).
559    (d0 >= -tolerance && d1 >= -tolerance && d2 >= -tolerance && pos && !neg)
560        || (d0 <= tolerance && d1 <= tolerance && d2 <= tolerance && neg && !pos)
561}
562
563/// Compute the parameter interval of a triangle on the intersection line.
564///
565/// The three distances `d0, d1, d2` are the signed distances of each vertex
566/// from the other triangle's plane. The `project` function maps a 3D point
567/// to a scalar on the dominant axis of the intersection line.
568fn triangle_interval(
569    v0: Point3,
570    v1: Point3,
571    v2: Point3,
572    d0: f64,
573    d1: f64,
574    d2: f64,
575    project: &dyn Fn(Point3) -> f64,
576) -> Option<(f64, f64)> {
577    let p0 = project(v0);
578    let p1 = project(v1);
579    let p2 = project(v2);
580
581    // Find the lone vertex (the one on the opposite side from the other two).
582    // Compute the two intersection points where the triangle crosses the plane.
583    let (t0, t1) = if (d0 > 0.0) != (d1 > 0.0) && (d0 > 0.0) != (d2 > 0.0) {
584        // v0 is alone
585        let ta = interp_param(p0, p1, d0, d1);
586        let tb = interp_param(p0, p2, d0, d2);
587        (ta, tb)
588    } else if (d1 > 0.0) != (d0 > 0.0) && (d1 > 0.0) != (d2 > 0.0) {
589        // v1 is alone
590        let ta = interp_param(p1, p0, d1, d0);
591        let tb = interp_param(p1, p2, d1, d2);
592        (ta, tb)
593    } else if (d2 > 0.0) != (d0 > 0.0) && (d2 > 0.0) != (d1 > 0.0) {
594        // v2 is alone
595        let ta = interp_param(p2, p0, d2, d0);
596        let tb = interp_param(p2, p1, d2, d1);
597        (ta, tb)
598    } else {
599        // Degenerate: one or more vertices are on the plane.
600        // Find the two vertices that straddle or lie on the plane.
601        let mut ts = Vec::new();
602        if d0.abs() < 1e-15 {
603            ts.push(p0);
604        }
605        if d1.abs() < 1e-15 {
606            ts.push(p1);
607        }
608        if d2.abs() < 1e-15 {
609            ts.push(p2);
610        }
611        // Also check edges that cross the plane.
612        if d0 * d1 < 0.0 {
613            ts.push(interp_param(p0, p1, d0, d1));
614        }
615        if d1 * d2 < 0.0 {
616            ts.push(interp_param(p1, p2, d1, d2));
617        }
618        if d0 * d2 < 0.0 {
619            ts.push(interp_param(p0, p2, d0, d2));
620        }
621
622        if ts.len() < 2 {
623            return None;
624        }
625
626        let mut lo = ts[0];
627        let mut hi = ts[0];
628        for &t in &ts[1..] {
629            if t < lo {
630                lo = t;
631            }
632            if t > hi {
633                hi = t;
634            }
635        }
636        (lo, hi)
637    };
638
639    let (lo, hi) = if t0 <= t1 { (t0, t1) } else { (t1, t0) };
640    Some((lo, hi))
641}
642
643/// Interpolate to find the parameter on the intersection line where an edge
644/// crosses the plane.
645fn interp_param(p_a: f64, p_b: f64, d_a: f64, d_b: f64) -> f64 {
646    let denom = d_a - d_b;
647    if denom.abs() < 1e-30 {
648        0.5 * (p_a + p_b)
649    } else {
650        (p_b - p_a).mul_add(d_a / denom, p_a)
651    }
652}
653
654/// Triangle vertices and their signed distances from a plane.
655struct TriPlaneData {
656    v: [Point3; 3],
657    d: [f64; 3],
658}
659
660/// Reconstruct a 3D point on the intersection line given a parameter value
661/// along the dominant axis.
662///
663/// Finds the two edges of the triangle that cross the other triangle's plane
664/// and interpolates to find the 3D point whose projection equals `t_target`.
665fn point_on_intersection_line(
666    tri: &TriPlaneData,
667    t_target: f64,
668    project: &dyn Fn(Point3) -> f64,
669) -> Point3 {
670    let [v0, v1, v2] = tri.v;
671    let [d0, d1, d2] = tri.d;
672    // Collect the 3D points where triangle edges cross the plane.
673    let mut crossing_points: Vec<Point3> = Vec::with_capacity(2);
674    let mut crossing_params: Vec<f64> = Vec::with_capacity(2);
675
676    let edges = [(v0, v1, d0, d1), (v1, v2, d1, d2), (v0, v2, d0, d2)];
677
678    for &(va, vb, da, db) in &edges {
679        if da.abs() < 1e-15 && db.abs() < 1e-15 {
680            // Both on the plane: add both endpoints.
681            crossing_points.push(va);
682            crossing_params.push(project(va));
683            crossing_points.push(vb);
684            crossing_params.push(project(vb));
685        } else if da.abs() < 1e-15 {
686            crossing_points.push(va);
687            crossing_params.push(project(va));
688        } else if db.abs() < 1e-15 {
689            crossing_points.push(vb);
690            crossing_params.push(project(vb));
691        } else if da * db < 0.0 {
692            let t = da / (da - db);
693            let p = lerp_point(va, vb, t);
694            crossing_points.push(p);
695            crossing_params.push(project(p));
696        }
697    }
698
699    if crossing_points.len() < 2 {
700        // Fallback: return first crossing point or centroid.
701        return crossing_points
702            .first()
703            .copied()
704            .unwrap_or_else(|| triangle_centroid(v0, v1, v2));
705    }
706
707    // Interpolate between the two crossing points to hit t_target.
708    let t0 = crossing_params[0];
709    let t1 = crossing_params[1];
710    let denom = t1 - t0;
711    if denom.abs() < 1e-30 {
712        crossing_points[0]
713    } else {
714        let s = (t_target - t0) / denom;
715        lerp_point(crossing_points[0], crossing_points[1], s)
716    }
717}
718
719/// Linear interpolation between two points.
720fn lerp_point(a: Point3, b: Point3, t: f64) -> Point3 {
721    Point3::new(
722        (b.x() - a.x()).mul_add(t, a.x()),
723        (b.y() - a.y()).mul_add(t, a.y()),
724        (b.z() - a.z()).mul_add(t, a.z()),
725    )
726}
727
728/// Centroid of a triangle.
729fn triangle_centroid(v0: Point3, v1: Point3, v2: Point3) -> Point3 {
730    Point3::new(
731        (v0.x() + v1.x() + v2.x()) / 3.0,
732        (v0.y() + v1.y() + v2.y()) / 3.0,
733        (v0.z() + v1.z() + v2.z()) / 3.0,
734    )
735}
736
737/// A triangle mesh that has been split by intersection segments.
738#[derive(Debug, Clone)]
739struct SplitMesh {
740    positions: Vec<Point3>,
741    normals: Vec<Vec3>,
742    triangles: Vec<[u32; 3]>,
743}
744
745/// Where an insertion point sits relative to its host triangle.
746enum PointSite {
747    Corner,
748    Edge(usize),
749    Interior,
750}
751
752/// Per-host-triangle split input, produced in phase 1 of the splitter.
753#[derive(Default)]
754struct HostSplit {
755    /// Canonical (welded) insertion points.
756    pts: Vec<Point3>,
757    /// Constraint chains as index pairs into `pts`.
758    constraints: Vec<(usize, usize)>,
759}
760
761/// Quantized undirected edge key for the cross-triangle edge-point map.
762type EdgeKey = ((i64, i64, i64), (i64, i64, i64));
763
764/// The float-to-int cast saturates (Rust `as` semantics), so coordinates
765/// beyond ±i64::MAX/S ≈ ±9.2e9 units collapse onto the same key instead of
766/// wrapping; models are expected to stay far inside that bound.
767fn edge_key(a: Point3, b: Point3) -> EdgeKey {
768    const S: f64 = 1.0e9;
769    #[allow(clippy::cast_possible_truncation)]
770    let q = |p: Point3| -> (i64, i64, i64) {
771        (
772            (p.x() * S).round() as i64,
773            (p.y() * S).round() as i64,
774            (p.z() * S).round() as i64,
775        )
776    };
777    let (qa, qb) = (q(a), q(b));
778    if qa <= qb { (qa, qb) } else { (qb, qa) }
779}
780
781/// Split a mesh's triangles along intersection segments, producing a
782/// triangulation that CONFORMS to the segments: every segment appears as a
783/// chain of sub-triangle edges, and points landing on a triangle edge are
784/// propagated to the neighbor sharing that edge (no T-junctions).
785#[allow(clippy::too_many_lines)]
786fn split_mesh_conforming(
787    mesh: &TriangleMesh,
788    segments: &[IsectSegment],
789    is_mesh_a: bool,
790    tolerance: f64,
791) -> SplitMesh {
792    let tri_count = mesh.indices.len() / 3;
793
794    // Group constraint segments by host triangle index.
795    let mut host_segments: DetHashMap<usize, Vec<(Point3, Point3)>> = DetHashMap::default();
796    for seg in segments {
797        let applies = if is_mesh_a { seg.apply_a } else { seg.apply_b };
798        if !applies {
799            continue;
800        }
801        let host = if is_mesh_a { seg.tri_a } else { seg.tri_b };
802        host_segments
803            .entry(host)
804            .or_default()
805            .push((seg.p0, seg.p1));
806    }
807
808    // Phase 1: per registered host, weld points, split segments at mutual
809    // crossings and collinear interior points, classify each point against
810    // the host triangle, and feed on-edge points into the global edge map.
811    let mut host_data: DetHashMap<usize, HostSplit> = DetHashMap::default();
812    let mut edge_points: DetHashMap<EdgeKey, Vec<Point3>> = DetHashMap::default();
813
814    let mut host_indices: Vec<usize> = host_segments.keys().copied().collect();
815    host_indices.sort_unstable();
816
817    for &host in &host_indices {
818        let segs = &host_segments[&host];
819        let (v0, v1, v2) = get_triangle(mesh, host);
820
821        let mut pts: Vec<Point3> = Vec::new();
822        let canon = |p: Point3, pts: &mut Vec<Point3>| -> usize {
823            let tol_sq = tolerance * tolerance;
824            for (i, q) in pts.iter().enumerate() {
825                if dist_sq(*q, p) < tol_sq {
826                    return i;
827                }
828            }
829            pts.push(p);
830            pts.len() - 1
831        };
832
833        // Canonicalize segment endpoints.
834        let mut seg_idx: Vec<(usize, usize)> = Vec::with_capacity(segs.len());
835        for &(p0, p1) in segs {
836            let i0 = canon(p0, &mut pts);
837            let i1 = canon(p1, &mut pts);
838            if i0 != i1 {
839                seg_idx.push((i0, i1));
840            }
841        }
842
843        // Split segments at mutual transversal crossings. Each round resolves
844        // one crossing, so the cap scales with the possible crossing count
845        // instead of a fixed budget that dense hosts could silently exhaust.
846        let max_rounds = 16 + seg_idx.len() * seg_idx.len();
847        let mut changed = true;
848        let mut rounds = 0;
849        while changed && rounds < max_rounds {
850            changed = false;
851            rounds += 1;
852            'outer: for si in 0..seg_idx.len() {
853                for sj in (si + 1)..seg_idx.len() {
854                    let (a0, a1) = seg_idx[si];
855                    let (b0, b1) = seg_idx[sj];
856                    if a0 == b0 || a0 == b1 || a1 == b0 || a1 == b1 {
857                        continue;
858                    }
859                    if let Some(x) =
860                        transversal_crossing(pts[a0], pts[a1], pts[b0], pts[b1], tolerance)
861                    {
862                        let xi = canon(x, &mut pts);
863                        seg_idx[si] = (a0, xi);
864                        seg_idx.push((xi, a1));
865                        seg_idx[sj] = (b0, xi);
866                        seg_idx.push((xi, b1));
867                        changed = true;
868                        break 'outer;
869                    }
870                }
871            }
872        }
873        if changed {
874            log::warn!(
875                "mesh boolean: crossing resolution on host triangle {host} exhausted \
876                 {max_rounds} rounds; unresolved crossings may force a non-conforming fan split"
877            );
878        }
879
880        // Split segments at collinear interior points (chained seams from
881        // adjacent opposing triangles share endpoints mid-segment).
882        let mut chains: Vec<(usize, usize)> = Vec::with_capacity(seg_idx.len());
883        for &(i0, i1) in &seg_idx {
884            let a = pts[i0];
885            let b = pts[i1];
886            let ab = b - a;
887            let len_sq = ab.dot(ab);
888            if len_sq < tolerance * tolerance {
889                continue;
890            }
891            let mut on_seg: Vec<(f64, usize)> = Vec::new();
892            for (k, &p) in pts.iter().enumerate() {
893                if k == i0 || k == i1 {
894                    continue;
895                }
896                let t = (p - a).dot(ab) / len_sq;
897                let margin = tolerance / len_sq.sqrt();
898                if t <= margin || t >= 1.0 - margin {
899                    continue;
900                }
901                let foot = lerp_point(a, b, t);
902                if dist_sq(p, foot) < tolerance * tolerance {
903                    on_seg.push((t, k));
904                }
905            }
906            on_seg.sort_by(|x, y| x.0.total_cmp(&y.0));
907            let mut prev = i0;
908            for &(_, k) in &on_seg {
909                if prev != k {
910                    chains.push((prev, k));
911                }
912                prev = k;
913            }
914            if prev != i1 {
915                chains.push((prev, i1));
916            }
917        }
918
919        // Classify points; feed on-edge points to the global map.
920        let corners = [v0, v1, v2];
921        for &p in &pts {
922            match classify_point_site(p, v0, v1, v2, tolerance) {
923                PointSite::Corner | PointSite::Interior => {}
924                PointSite::Edge(e) => {
925                    let (ea, eb) = (corners[e], corners[(e + 1) % 3]);
926                    let entry = edge_points.entry(edge_key(ea, eb)).or_default();
927                    let tol_sq = tolerance * tolerance;
928                    if !entry.iter().any(|q| dist_sq(*q, p) < tol_sq) {
929                        entry.push(p);
930                    }
931                }
932            }
933        }
934
935        host_data.insert(
936            host,
937            HostSplit {
938                pts,
939                constraints: chains,
940            },
941        );
942    }
943
944    // Phase 2: re-triangulate every triangle that has constraints or that
945    // received points on its edges from a neighbor's split.
946    let mut positions = mesh.positions.clone();
947    let mut normals = mesh.normals.clone();
948    let mut triangles: Vec<[u32; 3]> = Vec::with_capacity(tri_count * 2);
949
950    for i in 0..tri_count {
951        let i0 = mesh.indices[i * 3] as usize;
952        let i1 = mesh.indices[i * 3 + 1] as usize;
953        let i2 = mesh.indices[i * 3 + 2] as usize;
954        let v0 = mesh.positions[i0];
955        let v1 = mesh.positions[i1];
956        let v2 = mesh.positions[i2];
957
958        let host = host_data.get(&i);
959        let corners = [v0, v1, v2];
960        let mut edge_pts: [Vec<Point3>; 3] = [Vec::new(), Vec::new(), Vec::new()];
961        for e in 0..3 {
962            let (ea, eb) = (corners[e], corners[(e + 1) % 3]);
963            if let Some(list) = edge_points.get(&edge_key(ea, eb)) {
964                let dir = eb - ea;
965                let len_sq = dir.dot(dir);
966                if len_sq < 1e-30 {
967                    continue;
968                }
969                let mut with_t: Vec<(f64, Point3)> = list
970                    .iter()
971                    .map(|&p| ((p - ea).dot(dir) / len_sq, p))
972                    .filter(|&(t, _)| t > 0.0 && t < 1.0)
973                    .collect();
974                with_t.sort_by(|x, y| x.0.total_cmp(&y.0));
975                edge_pts[e] = with_t.into_iter().map(|(_, p)| p).collect();
976            }
977        }
978
979        let needs_split =
980            host.is_some_and(|h| !h.pts.is_empty()) || edge_pts.iter().any(|l| !l.is_empty());
981        if !needs_split {
982            #[allow(clippy::cast_possible_truncation)]
983            {
984                triangles.push([i0 as u32, i1 as u32, i2 as u32]);
985            }
986            continue;
987        }
988
989        let sub_tris = retriangulate_conforming(v0, v1, v2, &edge_pts, host, tolerance)
990            .unwrap_or_else(|| legacy_fan_split(v0, v1, v2, &edge_pts, host, tolerance));
991
992        let n0 = mesh.normals[i0];
993        for (sv0, sv1, sv2) in sub_tris {
994            #[allow(clippy::cast_possible_truncation)]
995            let base = positions.len() as u32;
996            positions.push(sv0);
997            positions.push(sv1);
998            positions.push(sv2);
999            normals.push(n0);
1000            normals.push(n0);
1001            normals.push(n0);
1002            triangles.push([base, base + 1, base + 2]);
1003        }
1004    }
1005
1006    SplitMesh {
1007        positions,
1008        normals,
1009        triangles,
1010    }
1011}
1012
1013/// Transversal crossing point of two 3D segments known to be near-coplanar
1014/// (both lie in the host triangle's plane). Returns the crossing point when
1015/// the segments genuinely cross in their interiors.
1016fn transversal_crossing(
1017    a0: Point3,
1018    a1: Point3,
1019    b0: Point3,
1020    b1: Point3,
1021    tolerance: f64,
1022) -> Option<Point3> {
1023    let da = a1 - a0;
1024    let db = b1 - b0;
1025    let n = da.cross(db);
1026    let n_len_sq = n.dot(n);
1027    let la_sq = da.dot(da);
1028    let lb_sq = db.dot(db);
1029    if n_len_sq < 1e-12 * la_sq * lb_sq {
1030        return None; // near-parallel: handled by collinear chaining
1031    }
1032    let r = b0 - a0;
1033    // Solve a0 + t*da = b0 + u*db in the least-squares sense.
1034    let t = r.cross(db).dot(n) / n_len_sq;
1035    let u = r.cross(da).dot(n) / n_len_sq;
1036    let margin_t = tolerance / la_sq.sqrt();
1037    let margin_u = tolerance / lb_sq.sqrt();
1038    if t <= margin_t || t >= 1.0 - margin_t || u <= margin_u || u >= 1.0 - margin_u {
1039        return None;
1040    }
1041    let pa = lerp_point(a0, a1, t);
1042    let pb = lerp_point(b0, b1, u);
1043    if dist_sq(pa, pb) > tolerance * tolerance * 4.0 {
1044        return None; // skew, not actually crossing
1045    }
1046    Some(pa)
1047}
1048
1049/// Classify a point against a host triangle: coincident with a corner, on an
1050/// edge (0: v0-v1, 1: v1-v2, 2: v2-v0), or interior.
1051fn classify_point_site(p: Point3, v0: Point3, v1: Point3, v2: Point3, tolerance: f64) -> PointSite {
1052    let tol_sq = tolerance * tolerance;
1053    if dist_sq(p, v0) < tol_sq || dist_sq(p, v1) < tol_sq || dist_sq(p, v2) < tol_sq {
1054        return PointSite::Corner;
1055    }
1056    if let Some(e) = point_on_edge(p, v0, v1, v2, tolerance) {
1057        return PointSite::Edge(e);
1058    }
1059    PointSite::Interior
1060}
1061
1062/// Conforming re-triangulation of one host triangle via constrained
1063/// Delaunay: corners + edge points + interior points, with the boundary
1064/// chains and the intersection segments as constraints.
1065///
1066/// Returns `None` when the CDT fails (point location or constraint
1067/// recovery); the caller falls back to the legacy fan split.
1068#[allow(clippy::too_many_lines)]
1069fn retriangulate_conforming(
1070    v0: Point3,
1071    v1: Point3,
1072    v2: Point3,
1073    edge_pts: &[Vec<Point3>; 3],
1074    host: Option<&HostSplit>,
1075    tolerance: f64,
1076) -> Option<Vec<(Point3, Point3, Point3)>> {
1077    // Build an orthonormal in-plane frame.
1078    let e01 = v1 - v0;
1079    let ng = e01.cross(v2 - v0);
1080    let ng_len = ng.length();
1081    let e01_len = e01.length();
1082    if ng_len < 1e-30 || e01_len < 1e-30 {
1083        return None;
1084    }
1085    let w = ng * (1.0 / ng_len);
1086    let u = e01 * (1.0 / e01_len);
1087    let vax = w.cross(u);
1088    let to2d = |p: Point3| -> Point2 {
1089        let d = p - v0;
1090        Point2::new(d.dot(u), d.dot(vax))
1091    };
1092
1093    let corners2d = [to2d(v0), to2d(v1), to2d(v2)];
1094    let mut min = corners2d[0];
1095    let mut max = corners2d[0];
1096    for c in &corners2d[1..] {
1097        min = Point2::new(min.x().min(c.x()), min.y().min(c.y()));
1098        max = Point2::new(max.x().max(c.x()), max.y().max(c.y()));
1099    }
1100
1101    let n_pts = host.map_or(0, |h| h.pts.len());
1102    let mut cdt = Cdt::with_capacity(
1103        (min, max),
1104        3 + n_pts + edge_pts.iter().map(Vec::len).sum::<usize>(),
1105    );
1106
1107    // Map from CDT vertex index to 3D position (first writer wins).
1108    let mut back: DetHashMap<usize, Point3> = DetHashMap::default();
1109    let insert = |cdt: &mut Cdt, p: Point3, back: &mut DetHashMap<usize, Point3>| {
1110        let idx = cdt.insert_point(to2d(p)).ok()?;
1111        back.entry(idx).or_insert(p);
1112        Some(idx)
1113    };
1114
1115    let c0 = insert(&mut cdt, v0, &mut back)?;
1116    let c1 = insert(&mut cdt, v1, &mut back)?;
1117    let c2 = insert(&mut cdt, v2, &mut back)?;
1118    let corner_idx = [c0, c1, c2];
1119
1120    // Boundary chains: corner -> sorted edge points -> next corner.
1121    for e in 0..3 {
1122        let mut prev = corner_idx[e];
1123        for &p in &edge_pts[e] {
1124            let idx = insert(&mut cdt, p, &mut back)?;
1125            if idx != prev {
1126                cdt.insert_constraint(prev, idx).ok()?;
1127                prev = idx;
1128            }
1129        }
1130        let last = corner_idx[(e + 1) % 3];
1131        if prev != last {
1132            cdt.insert_constraint(prev, last).ok()?;
1133        }
1134    }
1135
1136    // Interior points and intersection-segment constraints.
1137    if let Some(h) = host {
1138        let mut pt_idx: Vec<usize> = Vec::with_capacity(h.pts.len());
1139        for &p in &h.pts {
1140            pt_idx.push(insert(&mut cdt, p, &mut back)?);
1141        }
1142        for &(i0, i1) in &h.constraints {
1143            let (a, b) = (pt_idx[i0], pt_idx[i1]);
1144            if a != b {
1145                cdt.insert_constraint(a, b).ok()?;
1146            }
1147        }
1148    }
1149
1150    // Extract, orient, and sanity-check the sub-triangles.
1151    let verts2d = cdt.vertices().to_vec();
1152    let mut out: Vec<(Point3, Point3, Point3)> = Vec::new();
1153    let mut area_sum = 0.0_f64;
1154    for (ia, ib, ic) in cdt.triangles() {
1155        let (pa, pb, pc) = (verts2d[ia], verts2d[ib], verts2d[ic]);
1156        let signed2 = (pb.x() - pa.x()) * (pc.y() - pa.y()) - (pb.y() - pa.y()) * (pc.x() - pa.x());
1157        area_sum += signed2.abs() * 0.5;
1158        let (qa, qb, qc) = (
1159            back.get(&ia).copied()?,
1160            back.get(&ib).copied()?,
1161            back.get(&ic).copied()?,
1162        );
1163        // CCW in the (u, vax) frame maps to a 3D normal along +w (the host
1164        // triangle's outward geometric normal); flip CW triangles.
1165        if signed2 >= 0.0 {
1166            out.push((qa, qb, qc));
1167        } else {
1168            out.push((qa, qc, qb));
1169        }
1170    }
1171
1172    // The union of sub-triangles must tile the host triangle exactly.
1173    let host_area = 0.5 * ng_len;
1174    if (area_sum - host_area).abs() > (host_area * 1e-6).max(tolerance) {
1175        return None;
1176    }
1177
1178    Some(out)
1179}
1180
1181/// Legacy point-insertion fan split, used only when the CDT path fails.
1182/// Not conforming (segments are not constrained), but never worse than the
1183/// pre-CDT behavior of this module.
1184fn legacy_fan_split(
1185    v0: Point3,
1186    v1: Point3,
1187    v2: Point3,
1188    edge_pts: &[Vec<Point3>; 3],
1189    host: Option<&HostSplit>,
1190    tolerance: f64,
1191) -> Vec<(Point3, Point3, Point3)> {
1192    let mut points: Vec<Point3> = Vec::new();
1193    for list in edge_pts {
1194        for &p in list {
1195            maybe_add_unique(&mut points, p, tolerance);
1196        }
1197    }
1198    if let Some(h) = host {
1199        for &p in &h.pts {
1200            maybe_add_unique(&mut points, p, tolerance);
1201        }
1202    }
1203    split_triangle_by_points(v0, v1, v2, &points, tolerance)
1204}
1205
1206/// Add a point to a list if no existing point is within tolerance.
1207fn maybe_add_unique(pts: &mut Vec<Point3>, p: Point3, tolerance: f64) {
1208    let tol_sq = tolerance * tolerance;
1209    for existing in pts.iter() {
1210        if dist_sq(*existing, p) < tol_sq {
1211            return;
1212        }
1213    }
1214    pts.push(p);
1215}
1216
1217/// Split a triangle by inserting points, producing sub-triangles.
1218///
1219/// Uses barycentric coordinate classification and simple fan/edge splitting.
1220/// For each inserted point, the triangle containing it is subdivided.
1221fn split_triangle_by_points(
1222    v0: Point3,
1223    v1: Point3,
1224    v2: Point3,
1225    points: &[Point3],
1226    tolerance: f64,
1227) -> Vec<(Point3, Point3, Point3)> {
1228    if points.is_empty() {
1229        return vec![(v0, v1, v2)];
1230    }
1231
1232    // Start with the original triangle and iteratively split.
1233    let mut tris = vec![(v0, v1, v2)];
1234
1235    for &pt in points {
1236        let mut new_tris = Vec::new();
1237        let mut inserted = false;
1238
1239        for (tv0, tv1, tv2) in &tris {
1240            if !inserted && let Some(sub) = try_split_triangle(*tv0, *tv1, *tv2, pt, tolerance) {
1241                new_tris.extend(sub);
1242                inserted = true;
1243                continue;
1244            }
1245            new_tris.push((*tv0, *tv1, *tv2));
1246        }
1247
1248        tris = new_tris;
1249    }
1250
1251    tris
1252}
1253
1254/// Try to split a single triangle by inserting a point.
1255///
1256/// Returns `None` if the point is outside the triangle or coincident with
1257/// a vertex. Returns `Some(sub_triangles)` on success.
1258fn try_split_triangle(
1259    v0: Point3,
1260    v1: Point3,
1261    v2: Point3,
1262    pt: Point3,
1263    tolerance: f64,
1264) -> Option<Vec<(Point3, Point3, Point3)>> {
1265    let tol_sq = tolerance * tolerance;
1266
1267    // Check if the point coincides with a vertex.
1268    if dist_sq(pt, v0) < tol_sq || dist_sq(pt, v1) < tol_sq || dist_sq(pt, v2) < tol_sq {
1269        return None;
1270    }
1271
1272    // Check if the point is on an edge.
1273    if let Some(edge_idx) = point_on_edge(pt, v0, v1, v2, tolerance) {
1274        // Split into 2 triangles along the edge containing the point.
1275        let result = match edge_idx {
1276            0 => vec![(v0, pt, v2), (pt, v1, v2)], // point on edge v0-v1
1277            1 => vec![(v1, pt, v0), (pt, v2, v0)], // point on edge v1-v2
1278            _ => vec![(v2, pt, v1), (pt, v0, v1)], // point on edge v2-v0
1279        };
1280        return Some(result);
1281    }
1282
1283    // Check if the point is inside the triangle using barycentric coordinates.
1284    let bary = barycentric(v0, v1, v2, pt);
1285    if bary.0 < -tolerance || bary.1 < -tolerance || bary.2 < -tolerance {
1286        return None; // Outside the triangle.
1287    }
1288
1289    // Point is inside: split into 3 sub-triangles.
1290    Some(vec![(v0, v1, pt), (v1, v2, pt), (v2, v0, pt)])
1291}
1292
1293/// Check if a point lies on one of the three edges of a triangle.
1294///
1295/// Returns the edge index (0: v0-v1, 1: v1-v2, 2: v2-v0) or `None`.
1296fn point_on_edge(pt: Point3, v0: Point3, v1: Point3, v2: Point3, tolerance: f64) -> Option<usize> {
1297    let edges = [(v0, v1), (v1, v2), (v2, v0)];
1298    for (i, &(ea, eb)) in edges.iter().enumerate() {
1299        let edge = eb - ea;
1300        let len_sq = edge.length_squared();
1301        if len_sq < 1e-30 {
1302            continue;
1303        }
1304        let t = (pt - ea).dot(edge) / len_sq;
1305        if t < -tolerance || t > 1.0 + tolerance {
1306            continue;
1307        }
1308        let closest = Point3::new(
1309            edge.x().mul_add(t, ea.x()),
1310            edge.y().mul_add(t, ea.y()),
1311            edge.z().mul_add(t, ea.z()),
1312        );
1313        if dist_sq(pt, closest) < tolerance * tolerance {
1314            return Some(i);
1315        }
1316    }
1317    None
1318}
1319
1320/// Compute barycentric coordinates of a point in a triangle.
1321fn barycentric(v0: Point3, v1: Point3, v2: Point3, p: Point3) -> (f64, f64, f64) {
1322    let e0 = v1 - v0;
1323    let e1 = v2 - v0;
1324    let ep = p - v0;
1325
1326    let d00 = e0.dot(e0);
1327    let d01 = e0.dot(e1);
1328    let d11 = e1.dot(e1);
1329    let d20 = ep.dot(e0);
1330    let d21 = ep.dot(e1);
1331
1332    let denom = d00.mul_add(d11, -(d01 * d01));
1333    if denom.abs() < 1e-30 {
1334        return (-1.0, -1.0, -1.0); // Degenerate triangle.
1335    }
1336
1337    let inv = 1.0 / denom;
1338    let v = d11.mul_add(d20, -(d01 * d21)) * inv;
1339    let w = d00.mul_add(d21, -(d01 * d20)) * inv;
1340    let u = 1.0 - v - w;
1341
1342    (u, v, w)
1343}
1344
1345/// Squared distance between two points.
1346fn dist_sq(a: Point3, b: Point3) -> f64 {
1347    let d = b - a;
1348    d.dot(d)
1349}
1350
1351/// Classification of a sub-triangle against the other mesh.
1352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1353enum TriState {
1354    /// Strictly inside the other solid.
1355    Inside,
1356    /// Strictly outside the other solid.
1357    Outside,
1358    /// Lying on the other mesh's surface, normals pointing the same way.
1359    OnSame,
1360    /// Lying on the other mesh's surface, normals opposed.
1361    OnOpp,
1362}
1363
1364/// Classify each sub-triangle of a split mesh against the other mesh.
1365///
1366/// A sub-triangle whose centroid lies on the other mesh's surface (within a
1367/// small epsilon, with near-parallel normals) is classified `OnSame`/`OnOpp`
1368/// — the winding number is exactly ½ there and must not be used as an
1369/// inside/outside coin flip. All other centroids use the generalized winding
1370/// number.
1371fn classify_split_triangles(
1372    split: &SplitMesh,
1373    other_mesh: &TriangleMesh,
1374    other_bvh: &Bvh,
1375    tolerance: f64,
1376) -> Vec<TriState> {
1377    // Must not exceed the contact/co-refinement tolerance: faces farther
1378    // apart than `tolerance` are never co-refined, so classifying them
1379    // OnSame/OnOpp would drop them in assembly and open the result.
1380    let eps_on = tolerance.max(1e-9);
1381    split
1382        .triangles
1383        .iter()
1384        .map(|tri| {
1385            let v0 = split.positions[tri[0] as usize];
1386            let v1 = split.positions[tri[1] as usize];
1387            let v2 = split.positions[tri[2] as usize];
1388            let centroid = triangle_centroid(v0, v1, v2);
1389            let host_n = (v1 - v0).cross(v2 - v0);
1390            let host_n_len = host_n.length();
1391
1392            if host_n_len > 1e-30
1393                && let Some((dist, other_n)) =
1394                    closest_surface_normal(centroid, other_mesh, other_bvh, eps_on)
1395                && dist < eps_on
1396            {
1397                let cos = host_n.dot(other_n) / (host_n_len * other_n.length().max(1e-30));
1398                if cos > 0.9 {
1399                    return TriState::OnSame;
1400                }
1401                if cos < -0.9 {
1402                    return TriState::OnOpp;
1403                }
1404            }
1405
1406            let wn = winding_number_at_point(centroid, other_mesh);
1407            if wn.abs() > 0.5 {
1408                TriState::Inside
1409            } else {
1410                TriState::Outside
1411            }
1412        })
1413        .collect()
1414}
1415
1416/// Distance from a point to the nearest triangle of a mesh within `radius`,
1417/// plus that triangle's geometric (winding-derived, outward) normal.
1418fn closest_surface_normal(
1419    p: Point3,
1420    mesh: &TriangleMesh,
1421    bvh: &Bvh,
1422    radius: f64,
1423) -> Option<(f64, Vec3)> {
1424    let query = Aabb3::from_points([p]).expanded(radius);
1425    let candidates = bvh.query_overlap(&query);
1426    let mut best: Option<(f64, Vec3)> = None;
1427    for t in candidates {
1428        let (v0, v1, v2) = get_triangle(mesh, t);
1429        let n = (v1 - v0).cross(v2 - v0);
1430        if n.dot(n) < 1e-30 {
1431            continue;
1432        }
1433        let cp = closest_point_on_triangle(p, v0, v1, v2);
1434        let d = dist_sq(p, cp).sqrt();
1435        if best.is_none_or(|(bd, _)| d < bd) {
1436            best = Some((d, n));
1437        }
1438    }
1439    best
1440}
1441
1442/// Closest point on a triangle to a point (Ericson, Real-Time Collision
1443/// Detection, 5.1.5).
1444#[allow(clippy::many_single_char_names)]
1445fn closest_point_on_triangle(p: Point3, a: Point3, b: Point3, c: Point3) -> Point3 {
1446    let ab = b - a;
1447    let ac = c - a;
1448    let ap = p - a;
1449
1450    let d1 = ab.dot(ap);
1451    let d2 = ac.dot(ap);
1452    if d1 <= 0.0 && d2 <= 0.0 {
1453        return a;
1454    }
1455
1456    let bp = p - b;
1457    let d3 = ab.dot(bp);
1458    let d4 = ac.dot(bp);
1459    if d3 >= 0.0 && d4 <= d3 {
1460        return b;
1461    }
1462
1463    let vc = d1.mul_add(d4, -(d3 * d2));
1464    if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
1465        let t = d1 / (d1 - d3);
1466        return lerp_point(a, b, t);
1467    }
1468
1469    let cp = p - c;
1470    let d5 = ab.dot(cp);
1471    let d6 = ac.dot(cp);
1472    if d6 >= 0.0 && d5 <= d6 {
1473        return c;
1474    }
1475
1476    let vb = d5.mul_add(d2, -(d1 * d6));
1477    if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
1478        let t = d2 / (d2 - d6);
1479        return lerp_point(a, c, t);
1480    }
1481
1482    let va = d3.mul_add(d6, -(d5 * d4));
1483    if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
1484        let t = (d4 - d3) / ((d4 - d3) + (d5 - d6));
1485        return lerp_point(b, c, t);
1486    }
1487
1488    let denom = 1.0 / (va + vb + vc);
1489    let v = vb * denom;
1490    let w = vc * denom;
1491    Point3::new(
1492        ac.x().mul_add(w, ab.x().mul_add(v, a.x())),
1493        ac.y().mul_add(w, ab.y().mul_add(v, a.y())),
1494        ac.z().mul_add(w, ab.z().mul_add(v, a.z())),
1495    )
1496}
1497
1498/// Compute the generalized winding number of a point with respect to a
1499/// triangle mesh.
1500///
1501/// Sums the signed solid angles subtended by each triangle as seen from the
1502/// point, divided by 4pi. For a closed mesh, this returns ~1 for points
1503/// inside and ~0 for points outside.
1504///
1505/// This is a standalone helper that can be reused for other classification tasks.
1506#[must_use]
1507pub(crate) fn winding_number_at_point(point: Point3, mesh: &TriangleMesh) -> f64 {
1508    let tri_count = mesh.indices.len() / 3;
1509    let mut total_solid_angle = 0.0;
1510
1511    for i in 0..tri_count {
1512        let (v0, v1, v2) = get_triangle(mesh, i);
1513
1514        // Vectors from point to triangle vertices.
1515        let a = v0 - point;
1516        let b = v1 - point;
1517        let c = v2 - point;
1518
1519        let la = a.length();
1520        let lb = b.length();
1521        let lc = c.length();
1522
1523        // Skip degenerate triangles or if the point is at a vertex.
1524        if la < 1e-15 || lb < 1e-15 || lc < 1e-15 {
1525            continue;
1526        }
1527
1528        // Van Oosterom & Strackee formula for the signed solid angle of a
1529        // triangle as seen from a point.
1530        let numerator = a.dot(b.cross(c));
1531        let denominator = c
1532            .dot(a)
1533            .mul_add(lb, a.dot(b).mul_add(lc, b.dot(c).mul_add(la, la * lb * lc)));
1534
1535        // atan2 gives the half solid angle; multiply by 2 at the end.
1536        total_solid_angle += 2.0 * numerator.atan2(denominator);
1537    }
1538
1539    total_solid_angle / (4.0 * std::f64::consts::PI)
1540}
1541
1542/// Assemble the result mesh from classified sub-triangles.
1543///
1544/// Selection logic (`OnSame`/`OnOpp` are coincident-surface triangles; mesh
1545/// A owns the single kept copy of any coincident boundary region, mesh B's
1546/// coincident triangles are always dropped):
1547/// - [`BooleanOp::Fuse`]: A outside B or `OnSame`; B strictly outside A
1548/// - [`BooleanOp::Cut`]: A outside B or `OnOpp`; B strictly inside A (flipped)
1549/// - [`BooleanOp::Intersect`]: A inside B or `OnSame`; B strictly inside A
1550fn assemble_result(
1551    split_a: &SplitMesh,
1552    split_b: &SplitMesh,
1553    classify_a: &[TriState],
1554    classify_b: &[TriState],
1555    op: BooleanOp,
1556) -> TriangleMesh {
1557    let mut positions = Vec::new();
1558    let mut normals = Vec::new();
1559    let mut indices = Vec::new();
1560
1561    for (i, tri) in split_a.triangles.iter().enumerate() {
1562        let state = classify_a.get(i).copied().unwrap_or(TriState::Outside);
1563        let keep = match op {
1564            BooleanOp::Fuse => matches!(state, TriState::Outside | TriState::OnSame),
1565            BooleanOp::Cut => matches!(state, TriState::Outside | TriState::OnOpp),
1566            BooleanOp::Intersect => matches!(state, TriState::Inside | TriState::OnSame),
1567        };
1568        if keep {
1569            append_triangle(
1570                &split_a.positions,
1571                &split_a.normals,
1572                tri,
1573                false,
1574                &mut positions,
1575                &mut normals,
1576                &mut indices,
1577            );
1578        }
1579    }
1580
1581    for (i, tri) in split_b.triangles.iter().enumerate() {
1582        let state = classify_b.get(i).copied().unwrap_or(TriState::Outside);
1583        let (keep, flip) = match op {
1584            BooleanOp::Fuse => (state == TriState::Outside, false),
1585            BooleanOp::Cut => (state == TriState::Inside, true),
1586            BooleanOp::Intersect => (state == TriState::Inside, false),
1587        };
1588        if keep {
1589            append_triangle(
1590                &split_b.positions,
1591                &split_b.normals,
1592                tri,
1593                flip,
1594                &mut positions,
1595                &mut normals,
1596                &mut indices,
1597            );
1598        }
1599    }
1600
1601    TriangleMesh {
1602        positions,
1603        normals,
1604        indices,
1605    }
1606}
1607
1608/// Append a triangle to the output mesh, optionally flipping its winding and normal.
1609fn append_triangle(
1610    src_positions: &[Point3],
1611    src_normals: &[Vec3],
1612    tri: &[u32; 3],
1613    flip: bool,
1614    positions: &mut Vec<Point3>,
1615    normals: &mut Vec<Vec3>,
1616    indices: &mut Vec<u32>,
1617) {
1618    #[allow(clippy::cast_possible_truncation)]
1619    let base = positions.len() as u32;
1620
1621    if flip {
1622        for &idx in tri.iter().rev() {
1623            let i = idx as usize;
1624            positions.push(src_positions[i]);
1625            normals.push(-src_normals[i]);
1626        }
1627    } else {
1628        for &idx in tri {
1629            let i = idx as usize;
1630            positions.push(src_positions[i]);
1631            normals.push(src_normals[i]);
1632        }
1633    }
1634
1635    indices.push(base);
1636    indices.push(base + 1);
1637    indices.push(base + 2);
1638}
1639
1640/// Extract the three vertices of a triangle from a mesh.
1641fn get_triangle(mesh: &TriangleMesh, tri_idx: usize) -> (Point3, Point3, Point3) {
1642    let base = tri_idx * 3;
1643    let i0 = mesh.indices[base] as usize;
1644    let i1 = mesh.indices[base + 1] as usize;
1645    let i2 = mesh.indices[base + 2] as usize;
1646    (mesh.positions[i0], mesh.positions[i1], mesh.positions[i2])
1647}
1648
1649#[cfg(test)]
1650mod tests {
1651    #![allow(clippy::unwrap_used, clippy::expect_used)]
1652
1653    use super::*;
1654
1655    /// Oversized operands must be REJECTED, not attempted.
1656    ///
1657    /// On wasm32 an unbounded co-refinement exhausts the 4GB linear-memory cap,
1658    /// and the resulting `handle_alloc_error` → `abort()` traps the instance and
1659    /// strands its borrow flag — every later call fails with "recursive use of
1660    /// an object", with no panic message to explain it. An `Err` is recoverable;
1661    /// that abort is not. (Built as bare index/position vectors so the test does
1662    /// not allocate a real multi-million-triangle mesh.)
1663    #[test]
1664    fn oversized_operands_are_rejected_not_attempted() {
1665        let mut huge = TriangleMesh::default();
1666        huge.positions.push(Point3::new(0.0, 0.0, 0.0));
1667        huge.normals.push(Vec3::new(0.0, 0.0, 1.0));
1668        huge.indices = vec![0; (MAX_INPUT_TRIANGLES + 1) * 3];
1669
1670        let small = tetrahedron_mesh(Point3::new(0.0, 0.0, 0.0), 1.0);
1671        let err = mesh_boolean(&huge, &small, BooleanOp::Cut, 1e-7)
1672            .expect_err("oversized operands must be rejected");
1673        assert!(
1674            format!("{err}").contains("too large"),
1675            "expected a budget rejection, got: {err}"
1676        );
1677    }
1678
1679    /// Create a tetrahedron mesh centered at a point.
1680    fn tetrahedron_mesh(center: Point3, size: f64) -> TriangleMesh {
1681        let s = size;
1682        let v0 = Point3::new(center.x() + s, center.y() + s, center.z() + s);
1683        let v1 = Point3::new(center.x() + s, center.y() - s, center.z() - s);
1684        let v2 = Point3::new(center.x() - s, center.y() + s, center.z() - s);
1685        let v3 = Point3::new(center.x() - s, center.y() - s, center.z() + s);
1686
1687        let positions = [v0, v1, v2, v3];
1688
1689        let faces = [(0u32, 2, 1), (0, 1, 3), (0, 3, 2), (1, 2, 3)];
1690        let mut out_positions = Vec::new();
1691        let mut out_normals = Vec::new();
1692        let mut indices = Vec::new();
1693
1694        for &(i0, i1, i2) in &faces {
1695            let p0 = positions[i0 as usize];
1696            let p1 = positions[i1 as usize];
1697            let p2 = positions[i2 as usize];
1698
1699            let e1 = p1 - p0;
1700            let e2 = p2 - p0;
1701            let n = e1.cross(e2).normalize().unwrap_or(Vec3::new(0.0, 0.0, 1.0));
1702
1703            #[allow(clippy::cast_possible_truncation)]
1704            let base = out_positions.len() as u32;
1705            out_positions.push(p0);
1706            out_positions.push(p1);
1707            out_positions.push(p2);
1708            out_normals.push(n);
1709            out_normals.push(n);
1710            out_normals.push(n);
1711            indices.push(base);
1712            indices.push(base + 1);
1713            indices.push(base + 2);
1714        }
1715
1716        TriangleMesh {
1717            positions: out_positions,
1718            normals: out_normals,
1719            indices,
1720        }
1721    }
1722
1723    /// Create an axis-aligned box mesh centered at a point.
1724    fn box_mesh(center: Point3, half_size: f64) -> TriangleMesh {
1725        box_mesh_half_extents(center, Vec3::new(half_size, half_size, half_size))
1726    }
1727
1728    /// Create an axis-aligned box mesh with per-axis half extents.
1729    fn box_mesh_half_extents(center: Point3, half: Vec3) -> TriangleMesh {
1730        let cx = center.x();
1731        let cy = center.y();
1732        let cz = center.z();
1733        let (sx, sy, sz) = (half.x(), half.y(), half.z());
1734
1735        let verts = [
1736            Point3::new(cx - sx, cy - sy, cz - sz), // 0
1737            Point3::new(cx + sx, cy - sy, cz - sz), // 1
1738            Point3::new(cx + sx, cy + sy, cz - sz), // 2
1739            Point3::new(cx - sx, cy + sy, cz - sz), // 3
1740            Point3::new(cx - sx, cy - sy, cz + sz), // 4
1741            Point3::new(cx + sx, cy - sy, cz + sz), // 5
1742            Point3::new(cx + sx, cy + sy, cz + sz), // 6
1743            Point3::new(cx - sx, cy + sy, cz + sz), // 7
1744        ];
1745
1746        // 12 triangles (2 per face), with outward-facing normals.
1747        let face_tris: [(usize, usize, usize, Vec3); 12] = [
1748            // -Z face
1749            (0, 3, 2, Vec3::new(0.0, 0.0, -1.0)),
1750            (0, 2, 1, Vec3::new(0.0, 0.0, -1.0)),
1751            // +Z face
1752            (4, 5, 6, Vec3::new(0.0, 0.0, 1.0)),
1753            (4, 6, 7, Vec3::new(0.0, 0.0, 1.0)),
1754            // -X face
1755            (0, 4, 7, Vec3::new(-1.0, 0.0, 0.0)),
1756            (0, 7, 3, Vec3::new(-1.0, 0.0, 0.0)),
1757            // +X face
1758            (1, 2, 6, Vec3::new(1.0, 0.0, 0.0)),
1759            (1, 6, 5, Vec3::new(1.0, 0.0, 0.0)),
1760            // -Y face
1761            (0, 1, 5, Vec3::new(0.0, -1.0, 0.0)),
1762            (0, 5, 4, Vec3::new(0.0, -1.0, 0.0)),
1763            // +Y face
1764            (3, 7, 6, Vec3::new(0.0, 1.0, 0.0)),
1765            (3, 6, 2, Vec3::new(0.0, 1.0, 0.0)),
1766        ];
1767
1768        let mut positions = Vec::new();
1769        let mut normals = Vec::new();
1770        let mut indices = Vec::new();
1771
1772        for &(i0, i1, i2, n) in &face_tris {
1773            #[allow(clippy::cast_possible_truncation)]
1774            let base = positions.len() as u32;
1775            positions.push(verts[i0]);
1776            positions.push(verts[i1]);
1777            positions.push(verts[i2]);
1778            normals.push(n);
1779            normals.push(n);
1780            normals.push(n);
1781            indices.push(base);
1782            indices.push(base + 1);
1783            indices.push(base + 2);
1784        }
1785
1786        TriangleMesh {
1787            positions,
1788            normals,
1789            indices,
1790        }
1791    }
1792
1793    /// Signed volume of a triangle mesh via the divergence theorem.
1794    fn mesh_signed_volume(mesh: &TriangleMesh) -> f64 {
1795        let mut vol = 0.0;
1796        for tri in mesh.indices.chunks_exact(3) {
1797            let a = mesh.positions[tri[0] as usize];
1798            let b = mesh.positions[tri[1] as usize];
1799            let c = mesh.positions[tri[2] as usize];
1800            let va = a - Point3::new(0.0, 0.0, 0.0);
1801            let vb = b - Point3::new(0.0, 0.0, 0.0);
1802            let vc = c - Point3::new(0.0, 0.0, 0.0);
1803            vol += va.dot(vb.cross(vc)) / 6.0;
1804        }
1805        vol
1806    }
1807
1808    #[test]
1809    fn mesh_boolean_disjoint_fuse() {
1810        let a = tetrahedron_mesh(Point3::new(0.0, 0.0, 0.0), 1.0);
1811        let b = tetrahedron_mesh(Point3::new(10.0, 0.0, 0.0), 1.0);
1812
1813        let a_tri_count = a.indices.len() / 3;
1814        let b_tri_count = b.indices.len() / 3;
1815
1816        let result = mesh_boolean(&a, &b, BooleanOp::Fuse, 1e-7).unwrap();
1817        let result_tri_count = result.mesh.indices.len() / 3;
1818
1819        // Disjoint fuse should contain all triangles from both meshes.
1820        assert_eq!(
1821            result_tri_count,
1822            a_tri_count + b_tri_count,
1823            "disjoint fuse should combine all triangles: expected {}, got {}",
1824            a_tri_count + b_tri_count,
1825            result_tri_count
1826        );
1827    }
1828
1829    #[test]
1830    fn mesh_boolean_overlapping_intersect() {
1831        let a = box_mesh(Point3::new(0.0, 0.0, 0.0), 1.0);
1832        let b = box_mesh(Point3::new(0.5, 0.5, 0.5), 1.0);
1833
1834        let result = mesh_boolean(&a, &b, BooleanOp::Intersect, 1e-7).unwrap();
1835        let result_tri_count = result.mesh.indices.len() / 3;
1836
1837        // The intersection of two overlapping cubes should produce a non-empty result.
1838        assert!(
1839            result_tri_count > 0,
1840            "intersection of overlapping boxes should have triangles, got {}",
1841            result_tri_count
1842        );
1843
1844        // The intersection is a 1.5^3 cube.
1845        assert_eq!(result.boundary_edge_count, 0, "intersect should be closed");
1846        assert_eq!(
1847            result.non_manifold_edge_count, 0,
1848            "intersect should be manifold"
1849        );
1850        let vol = mesh_signed_volume(&result.mesh);
1851        assert!(
1852            (vol - 1.5_f64.powi(3)).abs() < 1e-9,
1853            "intersection volume should be 3.375, got {vol}"
1854        );
1855    }
1856
1857    #[test]
1858    fn mesh_boolean_overlapping_cut_watertight() {
1859        let a = box_mesh(Point3::new(0.0, 0.0, 0.0), 1.0);
1860        let b = box_mesh(Point3::new(0.5, 0.5, 0.5), 1.0);
1861
1862        let result = mesh_boolean(&a, &b, BooleanOp::Cut, 1e-7).unwrap();
1863        assert_eq!(result.boundary_edge_count, 0, "cut should be closed");
1864        assert_eq!(result.non_manifold_edge_count, 0, "cut should be manifold");
1865        let vol = mesh_signed_volume(&result.mesh);
1866        assert!(
1867            (vol - (8.0 - 1.5_f64.powi(3))).abs() < 1e-9,
1868            "cut volume should be 4.625, got {vol}"
1869        );
1870    }
1871
1872    #[test]
1873    fn mesh_boolean_coincident_wall_cut() {
1874        // B occupies the left half of A, sharing three walls exactly:
1875        // the coincident-contact class that winding-number classification
1876        // alone gets wrong (winding is exactly 1/2 on the shared walls).
1877        let a = box_mesh_half_extents(Point3::new(0.0, 0.0, 0.0), Vec3::new(2.0, 1.0, 1.0));
1878        let b = box_mesh_half_extents(Point3::new(-1.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 1.0));
1879
1880        let result = mesh_boolean(&a, &b, BooleanOp::Cut, 1e-7).unwrap();
1881        assert_eq!(
1882            result.boundary_edge_count, 0,
1883            "coincident-wall cut should be closed"
1884        );
1885        assert_eq!(
1886            result.non_manifold_edge_count, 0,
1887            "coincident-wall cut should be manifold"
1888        );
1889        let vol = mesh_signed_volume(&result.mesh);
1890        assert!(
1891            (vol - 8.0).abs() < 1e-9,
1892            "remaining half should have volume 8, got {vol}"
1893        );
1894    }
1895
1896    #[test]
1897    fn mesh_boolean_coincident_wall_fuse() {
1898        // Two boxes sharing a full wall: fuse must dissolve the shared wall.
1899        let a = box_mesh_half_extents(Point3::new(-1.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 1.0));
1900        let b = box_mesh_half_extents(Point3::new(1.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 1.0));
1901
1902        let result = mesh_boolean(&a, &b, BooleanOp::Fuse, 1e-7).unwrap();
1903        assert_eq!(
1904            result.boundary_edge_count, 0,
1905            "coincident-wall fuse should be closed"
1906        );
1907        assert_eq!(
1908            result.non_manifold_edge_count, 0,
1909            "coincident-wall fuse should be manifold"
1910        );
1911        let vol = mesh_signed_volume(&result.mesh);
1912        assert!(
1913            (vol - 16.0).abs() < 1e-9,
1914            "fused volume should be 16, got {vol}"
1915        );
1916    }
1917
1918    #[test]
1919    fn mesh_boolean_coplanar_top_stack_fuse() {
1920        // A small box sitting exactly on top of a bigger one: the contact
1921        // patch is coincident with opposite normals and must vanish, while
1922        // the big box's top face must conform to the small footprint.
1923        let a = box_mesh_half_extents(Point3::new(0.0, 0.0, 0.0), Vec3::new(2.0, 2.0, 1.0));
1924        let b = box_mesh_half_extents(Point3::new(0.0, 0.0, 1.5), Vec3::new(0.5, 0.5, 0.5));
1925
1926        let result = mesh_boolean(&a, &b, BooleanOp::Fuse, 1e-7).unwrap();
1927        assert_eq!(result.boundary_edge_count, 0, "stack fuse should be closed");
1928        assert_eq!(
1929            result.non_manifold_edge_count, 0,
1930            "stack fuse should be manifold"
1931        );
1932        let vol = mesh_signed_volume(&result.mesh);
1933        assert!(
1934            (vol - 33.0).abs() < 1e-7,
1935            "stacked volume should be 32 + 1 = 33, got {vol}"
1936        );
1937    }
1938
1939    #[test]
1940    fn mesh_boolean_near_disjoint_fuse_keeps_facing_walls() {
1941        // Two boxes separated by a gap wider than the intersection tolerance
1942        // but inside a naive "coincident" window: the facing walls are never
1943        // co-refined, so classifying them OnOpp would drop them and open both
1944        // solids. They must classify Outside and survive the fuse intact.
1945        let gap = 5e-7;
1946        let a = box_mesh_half_extents(Point3::new(-1.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 1.0));
1947        let b = box_mesh_half_extents(Point3::new(1.0 + gap, 0.0, 0.0), Vec3::new(1.0, 1.0, 1.0));
1948
1949        let result = mesh_boolean(&a, &b, BooleanOp::Fuse, 1e-7).unwrap();
1950        assert_eq!(
1951            result.mesh.indices.len() / 3,
1952            24,
1953            "near-disjoint fuse must keep all 24 triangles"
1954        );
1955        assert_eq!(
1956            result.boundary_edge_count, 0,
1957            "near-disjoint fuse should be closed"
1958        );
1959        let vol = mesh_signed_volume(&result.mesh);
1960        assert!(
1961            (vol - 16.0).abs() < 1e-6,
1962            "near-disjoint fused volume should be 16, got {vol}"
1963        );
1964    }
1965
1966    #[test]
1967    fn mesh_boolean_produces_valid_mesh() {
1968        let a = box_mesh(Point3::new(0.0, 0.0, 0.0), 1.0);
1969        let b = box_mesh(Point3::new(0.5, 0.5, 0.5), 1.0);
1970
1971        let result = mesh_boolean(&a, &b, BooleanOp::Fuse, 1e-7).unwrap();
1972
1973        // Verify all indices are valid.
1974        let n_verts = result.mesh.positions.len();
1975        for &idx in &result.mesh.indices {
1976            assert!(
1977                (idx as usize) < n_verts,
1978                "index {} out of bounds (n_verts = {})",
1979                idx,
1980                n_verts
1981            );
1982        }
1983
1984        // Verify indices come in groups of 3.
1985        assert_eq!(
1986            result.mesh.indices.len() % 3,
1987            0,
1988            "indices should be a multiple of 3"
1989        );
1990
1991        // Verify normals match positions count.
1992        assert_eq!(
1993            result.mesh.positions.len(),
1994            result.mesh.normals.len(),
1995            "positions and normals should have the same count"
1996        );
1997    }
1998
1999    #[test]
2000    fn winding_number_inside_box() {
2001        let bx = box_mesh(Point3::new(0.0, 0.0, 0.0), 1.0);
2002
2003        let inside = winding_number_at_point(Point3::new(0.0, 0.0, 0.0), &bx);
2004        assert!(
2005            inside.abs() > 0.5,
2006            "winding number at center of box should be ~1, got {}",
2007            inside
2008        );
2009
2010        let outside = winding_number_at_point(Point3::new(5.0, 5.0, 5.0), &bx);
2011        assert!(
2012            outside.abs() < 0.5,
2013            "winding number outside box should be ~0, got {}",
2014            outside
2015        );
2016    }
2017
2018    #[test]
2019    fn mesh_boolean_disjoint_intersect_is_error() {
2020        let a = tetrahedron_mesh(Point3::new(0.0, 0.0, 0.0), 1.0);
2021        let b = tetrahedron_mesh(Point3::new(10.0, 0.0, 0.0), 1.0);
2022
2023        let result = mesh_boolean(&a, &b, BooleanOp::Intersect, 1e-7);
2024        assert!(
2025            result.is_err(),
2026            "intersection of disjoint meshes should error"
2027        );
2028    }
2029
2030    /// A grazing contact (one triangle's edge lying in the other's plane,
2031    /// both triangles otherwise on one side) must imprint that edge into
2032    /// BOTH meshes: without it the host's triangles straddle the coincident-band
2033    /// classification boundary and the kept/dropped halves of a quad
2034    /// leave open edges (the stacking-lip bottom annulus grazing the body
2035    /// wall plane — the lite fallback's bd=103).
2036    #[test]
2037    fn grazing_edge_contact_emits_mutual_imprint() {
2038        // Host triangle in the z=0 plane.
2039        let a0 = Point3::new(0.0, 0.0, 0.0);
2040        let a1 = Point3::new(10.0, 0.0, 0.0);
2041        let a2 = Point3::new(0.0, 10.0, 0.0);
2042        // Touching triangle: edge (b0,b1) lies in z=0, apex above.
2043        let b0 = Point3::new(1.0, 1.0, 0.0);
2044        let b1 = Point3::new(5.0, 1.0, 0.0);
2045        let b2 = Point3::new(3.0, 1.0, 4.0);
2046
2047        let segs = intersect_triangles(a0, a1, a2, b0, b1, b2, 1e-7);
2048        assert_eq!(segs.len(), 1, "grazing edge must imprint one segment");
2049        let s = &segs[0];
2050        assert!(
2051            s.apply_a && s.apply_b,
2052            "the imprint must constrain both meshes"
2053        );
2054        let len = (s.p1 - s.p0).length();
2055        assert!(
2056            (len - 4.0).abs() < 1e-6,
2057            "imprint must span the touching edge, got {len}"
2058        );
2059        assert!(s.p0.z().abs() < 1e-9 && s.p1.z().abs() < 1e-9);
2060    }
2061}