Skip to main content

ifc_lite_geometry/csg/
mod.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! CSG (Constructive Solid Geometry) Operations
6//!
7//! Fast triangle clipping and boolean operations.
8
9use crate::diagnostics::{BoolFailure, BoolFailureReason, BoolOp};
10use crate::error::Result;
11use crate::mesh::Mesh;
12use nalgebra::{Point3, Vector3};
13use smallvec::SmallVec;
14use std::cell::RefCell;
15
16mod consolidate;
17mod normals;
18
19pub use normals::calculate_normals;
20pub(crate) use consolidate::tri_is_needle;
21
22/// Type alias for small triangle collections (typically 1-2 triangles from clipping)
23pub type TriangleVec = SmallVec<[Triangle; 4]>;
24
25/// Plane definition for clipping
26#[derive(Debug, Clone, Copy)]
27pub struct Plane {
28    /// Point on the plane
29    pub point: Point3<f64>,
30    /// Normal vector (must be normalized)
31    pub normal: Vector3<f64>,
32}
33
34impl Plane {
35    /// Create a new plane
36    pub fn new(point: Point3<f64>, normal: Vector3<f64>) -> Self {
37        Self {
38            point,
39            normal: normal.normalize(),
40        }
41    }
42
43    /// Calculate signed distance from point to plane
44    /// Positive = in front, Negative = behind
45    pub fn signed_distance(&self, point: &Point3<f64>) -> f64 {
46        (point - self.point).dot(&self.normal)
47    }
48}
49
50/// Triangle clipping result
51#[derive(Debug, Clone)]
52pub enum ClipResult {
53    /// Triangle is completely in front (keep it)
54    AllFront(Triangle),
55    /// Triangle is completely behind (discard it)
56    AllBehind,
57    /// Triangle intersects plane - returns new triangles (uses SmallVec to avoid heap allocation)
58    Split(TriangleVec),
59}
60
61/// Triangle definition
62#[derive(Debug, Clone)]
63pub struct Triangle {
64    pub v0: Point3<f64>,
65    pub v1: Point3<f64>,
66    pub v2: Point3<f64>,
67}
68
69impl Triangle {
70    /// Create a new triangle
71    #[inline]
72    pub fn new(v0: Point3<f64>, v1: Point3<f64>, v2: Point3<f64>) -> Self {
73        Self { v0, v1, v2 }
74    }
75
76    /// Calculate triangle normal.
77    ///
78    /// **Degenerate triangles get `+Z`, never NaN.** A zero-area (collapsed or
79    /// exactly collinear) triangle has a zero-length cross product, and the
80    /// plain `normalize()` this used to call is `v / |v|` — i.e. `0.0 / 0.0`,
81    /// which is NaN in every component. Those NaNs were written verbatim into
82    /// `Mesh::normals` by `add_triangle_to_mesh` (the only production caller of
83    /// this method, via `ClippingProcessor::clip_mesh`), and they SURVIVED the
84    /// mesh-hygiene pass: `clean_degenerate` / `drop_thin_triangles` rewrites
85    /// only `indices`, so the degenerate triangle's vertices stay in
86    /// `positions` / `normals` as ORPHANS carrying NaN. Six of duplex.ifc's
87    /// material-layer wall slices shipped 81 NaN normal components that way,
88    /// which the `@ifc-lite/provenance` node-hash domain check rightly rejects
89    /// (every NaN bit pattern collapses to one quiet NaN when serialized, so
90    /// accepting them would give distinct payloads the same hash).
91    ///
92    /// `+Z` is this crate's established convention for an undefined normal —
93    /// the same fallback `csg::normals::calculate_normals` and
94    /// `mesh::weld_impl`'s average-normals path already use — so a consumer
95    /// that meets one meets them all. It is stated in the KERNEL's own Z-up
96    /// frame, like every other normal this crate writes, so a viewer that
97    /// converts to Y-up reads it back as `+Y`; that is the conversion doing its
98    /// job, not a second convention. The value is arbitrary but must be a FIXED
99    /// unit vector: a zero normal would just re-create the division by zero in
100    /// any shader or exporter that re-normalizes.
101    ///
102    /// Non-degenerate triangles are unaffected, bit-for-bit: `try_normalize(0.0)`
103    /// returns `Some(v.unscale(|v|))` for every `|v| > 0`, which is exactly what
104    /// `normalize()` computed. The extra `is_finite` check covers the
105    /// astronomically-unlikely underflow case where `|v|` rounds to zero from
106    /// non-zero components (division would yield ±Inf, also out of domain).
107    #[inline]
108    pub fn normal(&self) -> Vector3<f64> {
109        match self.cross_product().try_normalize(0.0) {
110            Some(n) if n.x.is_finite() && n.y.is_finite() && n.z.is_finite() => n,
111            _ => Vector3::new(0.0, 0.0, 1.0),
112        }
113    }
114
115    /// Calculate the cross product of edges, which is twice the area vector.
116    ///
117    /// Returns a `Vector3<f64>` perpendicular to the triangle plane.
118    /// For degenerate/collinear triangles, returns the zero vector.
119    /// Use `is_degenerate()` or `try_normalize()` on the result if you need
120    /// to detect and handle degenerate cases.
121    #[inline]
122    pub fn cross_product(&self) -> Vector3<f64> {
123        let edge1 = self.v1 - self.v0;
124        let edge2 = self.v2 - self.v0;
125        edge1.cross(&edge2)
126    }
127
128    /// Calculate triangle area (half the magnitude of the cross product).
129    #[inline]
130    pub fn area(&self) -> f64 {
131        self.cross_product().norm() * 0.5
132    }
133
134    /// Check if triangle is degenerate (zero area, collinear vertices).
135    ///
136    /// Uses `try_normalize` on the cross product with the specified epsilon.
137    /// Returns `true` if the cross product cannot be normalized (i.e., degenerate).
138    #[inline]
139    pub fn is_degenerate(&self, epsilon: f64) -> bool {
140        self.cross_product().try_normalize(epsilon).is_none()
141    }
142}
143
144/// One recorded invocation of a CSG kernel op (perf-census diagnostics).
145/// `op`: 0=subtract 1=union 2=intersection
146/// 3=clip. `a_tris`/`b_tris` are the operand triangle counts — the arrangement
147/// cost driver — so the census measures the *real* heavy-path workload reaching
148/// the kernel (analytic AABB box clips never get here).
149#[derive(Clone, Copy, Debug)]
150pub struct CsgOpRecord {
151    pub op: u8,
152    pub a_tris: u32,
153    pub b_tris: u32,
154}
155
156// Global (Mutex) so it captures ops on rayon worker threads, not just the caller.
157static CSG_CENSUS: std::sync::Mutex<Vec<CsgOpRecord>> = std::sync::Mutex::new(Vec::new());
158
159/// Clear the CSG op census (call before a measured run).
160pub fn reset_csg_census() {
161    if let Ok(mut g) = CSG_CENSUS.lock() {
162        g.clear();
163    }
164}
165
166/// Drain the CSG op census (call after a measured run).
167pub fn take_csg_census() -> Vec<CsgOpRecord> {
168    CSG_CENSUS
169        .lock()
170        .map(|mut g| std::mem::take(&mut *g))
171        .unwrap_or_default()
172}
173
174#[inline]
175fn record_csg_op(op: u8, a_tris: usize, b_tris: usize) {
176    if let Ok(mut g) = CSG_CENSUS.lock() {
177        g.push(CsgOpRecord {
178            op,
179            a_tris: a_tris as u32,
180            b_tris: b_tris as u32,
181        });
182    }
183}
184
185/// CSG Clipping Processor
186pub struct ClippingProcessor {
187    /// Epsilon for floating point comparisons
188    pub epsilon: f64,
189    /// Boolean / CSG failures recorded since the last `take_failures()`.
190    /// Interior-mutable so the existing `&self` API stays unchanged.
191    failures: RefCell<Vec<BoolFailure>>,
192}
193
194impl ClippingProcessor {
195    /// Create a new clipping processor
196    pub fn new() -> Self {
197        Self {
198            epsilon: 1e-6,
199            failures: RefCell::new(Vec::new()),
200        }
201    }
202
203    /// Drain and return the failures recorded by this processor since its
204    /// creation (or the last `take_failures` call). The processor's internal
205    /// log is cleared.
206    pub fn take_failures(&self) -> Vec<BoolFailure> {
207        std::mem::take(&mut *self.failures.borrow_mut())
208    }
209
210    /// Number of failures currently buffered (without draining).
211    pub fn failure_count(&self) -> usize {
212        self.failures.borrow().len()
213    }
214
215    /// Whether any failure recorded since index `since` (a prior
216    /// [`failure_count`](Self::failure_count)) was an `OperandTooLarge`
217    /// rejection. HISTORICAL: only the deleted BSP polygon cap ever
218    /// emitted this from the boolean ops — the exact kernel has no operand
219    /// cap, so this is now always `false` on the boolean path. Kept because
220    /// the void router still keys its AABB-fallback decision on it
221    /// (issue #635 / #947), which is conservative and correct either way.
222    pub(crate) fn has_operand_too_large_since(&self, since: usize) -> bool {
223        let failures = self.failures.borrow();
224        let since = since.min(failures.len());
225        failures[since..]
226            .iter()
227            .any(|f| matches!(f.reason, BoolFailureReason::OperandTooLarge { .. }))
228    }
229
230    /// Internal: append a failure record. Public-crate so the boolean
231    /// processor in `processors/boolean.rs` can record fallbacks that
232    /// happen above the kernel layer.
233    pub(crate) fn record_failure(&self, op: BoolOp, reason: BoolFailureReason) {
234        self.failures.borrow_mut().push(BoolFailure::new(op, reason));
235    }
236
237    /// Clip a triangle against a plane
238    /// Returns triangles that are in front of the plane
239    pub fn clip_triangle(&self, triangle: &Triangle, plane: &Plane) -> ClipResult {
240        // Calculate signed distances for all vertices
241        let d0 = plane.signed_distance(&triangle.v0);
242        let d1 = plane.signed_distance(&triangle.v1);
243        let d2 = plane.signed_distance(&triangle.v2);
244
245        // Edge intersection parameter, clamped to the segment. Vertices are
246        // classified front/back with an epsilon band (`d >= -epsilon`), so a
247        // "front" vertex can sit slightly behind the plane (d in [-epsilon, 0)).
248        // Feeding that raw distance into `d_front / (d_front - d_back)` yields a
249        // t outside [0, 1] — and when the plane is nearly coincident with a host
250        // face the denominator collapses, extrapolating the cut vertex far off
251        // the edge (issue #1155: a clipped column flew ~97 m). Clamping keeps the
252        // intersection on the edge; the near-zero guard avoids a NaN from a
253        // degenerate (in-plane) edge.
254        let edge_t = |d_front: f64, d_back: f64| -> f64 {
255            let denom = d_front - d_back;
256            if denom.abs() < 1.0e-12 {
257                0.0
258            } else {
259                (d_front / denom).clamp(0.0, 1.0)
260            }
261        };
262
263        // Count vertices in front of plane
264        let mut front_count = 0;
265        if d0 >= -self.epsilon {
266            front_count += 1;
267        }
268        if d1 >= -self.epsilon {
269            front_count += 1;
270        }
271        if d2 >= -self.epsilon {
272            front_count += 1;
273        }
274
275        match front_count {
276            // All vertices behind - discard triangle
277            0 => ClipResult::AllBehind,
278
279            // All vertices in front - keep triangle
280            3 => ClipResult::AllFront(triangle.clone()),
281
282            // One vertex in front - create 1 smaller triangle
283            1 => {
284                let (front, back1, back2) = if d0 >= -self.epsilon {
285                    (triangle.v0, triangle.v1, triangle.v2)
286                } else if d1 >= -self.epsilon {
287                    (triangle.v1, triangle.v2, triangle.v0)
288                } else {
289                    (triangle.v2, triangle.v0, triangle.v1)
290                };
291
292                // Interpolate to find intersection points
293                let d_front = if d0 >= -self.epsilon {
294                    d0
295                } else if d1 >= -self.epsilon {
296                    d1
297                } else {
298                    d2
299                };
300                let d_back1 = if d0 >= -self.epsilon {
301                    d1
302                } else if d1 >= -self.epsilon {
303                    d2
304                } else {
305                    d0
306                };
307                let d_back2 = if d0 >= -self.epsilon {
308                    d2
309                } else if d1 >= -self.epsilon {
310                    d0
311                } else {
312                    d1
313                };
314
315                let t1 = edge_t(d_front, d_back1);
316                let t2 = edge_t(d_front, d_back2);
317
318                let p1 = front + (back1 - front) * t1;
319                let p2 = front + (back2 - front) * t2;
320
321                ClipResult::Split(smallvec::smallvec![Triangle::new(front, p1, p2)])
322            }
323
324            // Two vertices in front - create 2 triangles
325            2 => {
326                let (front1, front2, back) = if d0 < -self.epsilon {
327                    (triangle.v1, triangle.v2, triangle.v0)
328                } else if d1 < -self.epsilon {
329                    (triangle.v2, triangle.v0, triangle.v1)
330                } else {
331                    (triangle.v0, triangle.v1, triangle.v2)
332                };
333
334                // Interpolate to find intersection points
335                let d_back = if d0 < -self.epsilon {
336                    d0
337                } else if d1 < -self.epsilon {
338                    d1
339                } else {
340                    d2
341                };
342                let d_front1 = if d0 < -self.epsilon {
343                    d1
344                } else if d1 < -self.epsilon {
345                    d2
346                } else {
347                    d0
348                };
349                let d_front2 = if d0 < -self.epsilon {
350                    d2
351                } else if d1 < -self.epsilon {
352                    d0
353                } else {
354                    d1
355                };
356
357                let t1 = edge_t(d_front1, d_back);
358                let t2 = edge_t(d_front2, d_back);
359
360                let p1 = front1 + (back - front1) * t1;
361                let p2 = front2 + (back - front2) * t2;
362
363                ClipResult::Split(smallvec::smallvec![
364                    Triangle::new(front1, front2, p1),
365                    Triangle::new(front2, p2, p1),
366                ])
367            }
368
369            _ => unreachable!(),
370        }
371    }
372
373    /// Check if two meshes' bounding boxes overlap
374    fn bounds_overlap(host_mesh: &Mesh, opening_mesh: &Mesh) -> bool {
375        let (host_min, host_max) = host_mesh.bounds();
376        let (open_min, open_max) = opening_mesh.bounds();
377
378        // Issue #977: this runs on the *un-inflated* cutter, before
379        // `manifold_kernel::difference` inflates it. A recess whose cut face is
380        // exactly flush with a host face touches the host's AABB right at the
381        // boundary; strict `<`/`>` would classify it as non-overlapping and drop
382        // the cut before inflation ever runs. Use inclusive `<=`/`>=` with a small
383        // *relative* epsilon (scaled to the operands, so it is unit-robust across
384        // mm/m models) to keep flush cutters in play without admitting genuinely
385        // disjoint operands.
386        let span = (host_max.x - host_min.x)
387            .max(host_max.y - host_min.y)
388            .max(host_max.z - host_min.z)
389            .max(open_max.x - open_min.x)
390            .max(open_max.y - open_min.y)
391            .max(open_max.z - open_min.z);
392        let eps = span * 1e-6;
393
394        let overlap_x = open_min.x - eps <= host_max.x && open_max.x + eps >= host_min.x;
395        let overlap_y = open_min.y - eps <= host_max.y && open_max.y + eps >= host_min.y;
396        let overlap_z = open_min.z - eps <= host_max.z && open_max.z + eps >= host_min.z;
397
398        overlap_x && overlap_y && overlap_z
399    }
400
401    /// Subtract opening mesh from host mesh using CSG boolean operations
402    /// on the pure-Rust exact mesh-arrangement kernel.
403    ///
404    /// On any failure path the host is returned un-cut and a [`BoolFailure`]
405    /// record is appended to the processor's failure log (drainable via
406    /// [`Self::take_failures`]). An empty host returns an empty mesh without
407    /// recording a failure (it's a fast path, not a fallback).
408    pub fn subtract_mesh(&self, host_mesh: &Mesh, opening_mesh: &Mesh) -> Result<Mesh> {
409        record_csg_op(0, host_mesh.triangle_count(), opening_mesh.triangle_count());
410        if host_mesh.is_empty() {
411            return Ok(Mesh::new());
412        }
413        if opening_mesh.is_empty() {
414            self.record_failure(BoolOp::Difference, BoolFailureReason::EmptyOperand);
415            return Ok(host_mesh.clone());
416        }
417        if !Self::bounds_overlap(host_mesh, opening_mesh) {
418            self.record_failure(BoolOp::Difference, BoolFailureReason::NoBoundsOverlap);
419            return Ok(host_mesh.clone());
420        }
421
422        // Pure-Rust exact mesh-arrangement kernel, with consolidate_coplanar
423        // merging per-face fragments to match Manifold's clean output.
424        //
425        // NB: the kernel output itself is the watertightness bar — the
426        // crack-family fix lives upstream (`promote_cutter_verts_onto_host_faces`'s
427        // exact-plane lift). `consolidate_coplanar` can still re-open a closed
428        // cut along a µm-offset plane pair (each bucket earcuts independently,
429        // breaking the shared boundary chain); a closure-preserving guard here
430        // was tried and REJECTED — on FZK-Haus gable walls the raw kernel
431        // output carries >50:1 needle fragments that consolidation legitimately
432        // merges (the pinned `csg_quality_regression` spike bar). A
433        // seam-preserving consolidation is the remaining follow-up.
434        crate::kernel::budget::begin();
435        let raw = crate::kernel::mesh_bridge::subtract(host_mesh, opening_mesh);
436        // Deterministic escalation guardrail (#1109): if the exact predicate
437        // cascade escalated past the per-boolean budget, the cut bailed mid-
438        // arrangement. Discard the partial result and return the host un-cut so
439        // the void router's #635 AABB box-cut fallback fires. The trip point is a
440        // pure function of the snapped operands, so server (native) and client
441        // (wasm) degrade the SAME element identically — parity preserved.
442        if crate::kernel::budget::tripped() {
443            self.record_failure(
444                BoolOp::Difference,
445                BoolFailureReason::OperandTooLarge {
446                    polys_a: host_mesh.triangle_count(),
447                    polys_b: opening_mesh.triangle_count(),
448                },
449            );
450            return Ok(host_mesh.clone());
451        }
452        let result = Self::consolidate_coplanar(raw);
453        if !result.is_empty() && !self.validate_mesh(&result) {
454            self.record_failure(BoolOp::Difference, BoolFailureReason::KernelOutputInvalid);
455            return Ok(host_mesh.clone());
456        }
457        Ok(result)
458    }
459
460    /// Subtract a GROUP of pairwise-disjoint opening cutters from the host in
461    /// ONE conforming arrangement (disjoint-cutter batching).
462    ///
463    /// A REJECTED group (the N-ary arrangement could not fully conform, or no
464    /// cutter overlaps the host) returns the host UN-CUT and records NO
465    /// failure: rejection is the expected, handled outcome — the router's
466    /// per-opening sequential loop (with the full #635 fallback machinery and
467    /// its own diagnostics) immediately takes over for the group's members, so
468    /// a failure record here would be pure noise on elements whose voids end
469    /// up perfectly cut (the issue-582/583 zero-CSG-failure bar). Only a
470    /// genuinely invalid kernel OUTPUT records, exactly like
471    /// [`Self::subtract_mesh`].
472    pub fn subtract_mesh_many(&self, host_mesh: &Mesh, cutters: &[&Mesh]) -> Result<Mesh> {
473        if host_mesh.is_empty() {
474            return Ok(Mesh::new());
475        }
476        let live: Vec<&Mesh> = cutters
477            .iter()
478            .copied()
479            .filter(|c| !c.is_empty() && Self::bounds_overlap(host_mesh, c))
480            .collect();
481        if live.is_empty() {
482            return Ok(host_mesh.clone()); // silent: sequential path takes over
483        }
484        // Cap the cutters packed into ONE conforming arrangement. Void cutters
485        // here are order-free (set difference: host − {all} ≡ host − {chunk₁} −
486        // {chunk₂} − …), and the N-ary arrangement cost is SUPER-LINEAR in the
487        // cutters in a single arrangement. A Revit IfcBuildingElementPart with
488        // ~90 openings cost ~12 s in one arrangement vs ~0.4 s chunked at 16 (30×),
489        // and on wasm that single element alone blew the geometry-stream watchdog —
490        // an 86 MB model that loaded in ~15 s natively STALLED at 40 s in the
491        // browser. Chunking bounds the per-arrangement cost so no single element
492        // can stall the stream. It is solid-equivalent (the batch path's contract
493        // is volume parity + watertightness, not byte-identical tessellation); for
494        // live.len() <= MAX_CUTTERS_PER_ARRANGEMENT it IS the prior single
495        // arrangement. On any chunk's budget trip / unrecovered constraint, reject
496        // the WHOLE group (return host un-cut) so the per-opening sequential path
497        // (own budget + #635 AABB fallback) takes over — identical to before.
498        const MAX_CUTTERS_PER_ARRANGEMENT: usize = 16;
499        let mut result = host_mesh.clone();
500        for chunk in live.chunks(MAX_CUTTERS_PER_ARRANGEMENT) {
501            // Census: record THIS kernel invocation's real operand sizes (the
502            // current host + this chunk's cutters). Chunking runs the kernel once
503            // per chunk, so report K real ops, not one synthetic op carrying the
504            // whole group's cutter total. For live.len() <= cap this is one record
505            // identical to the prior single arrangement.
506            let chunk_tris: usize = chunk.iter().map(|c| c.triangle_count()).sum();
507            record_csg_op(0, result.triangle_count(), chunk_tris);
508            crate::kernel::budget::begin();
509            let raw = crate::kernel::mesh_bridge::subtract_many(&result, chunk);
510            if crate::kernel::budget::tripped() {
511                // Escalation budget exceeded (#1109): reject the group silently so
512                // the per-opening sequential path takes over (deterministic).
513                return Ok(host_mesh.clone());
514            }
515            let Some(raw) = raw else {
516                // Unrecovered constraint in this chunk's arrangement — reject the
517                // group so the sequential per-opening path takes over.
518                return Ok(host_mesh.clone());
519            };
520            let next = Self::consolidate_coplanar(raw);
521            // Validate each intermediate BEFORE it becomes the next chunk's host:
522            // a non-watertight / invalid intermediate would silently corrupt every
523            // subsequent subtraction. On failure reject the whole group so the
524            // per-opening sequential path takes over — same guard as the
525            // un-chunked path, just applied per chunk.
526            if !next.is_empty() && !self.validate_mesh(&next) {
527                self.record_failure(BoolOp::Difference, BoolFailureReason::KernelOutputInvalid);
528                return Ok(host_mesh.clone());
529            }
530            result = next;
531        }
532        Ok(result)
533    }
534
535    /// Union two meshes together using CSG boolean operations on the
536    /// pure-Rust exact kernel.
537    ///
538    /// Empty operands are handled silently — they have a unique correct answer.
539    pub fn union_mesh(&self, mesh_a: &Mesh, mesh_b: &Mesh) -> Result<Mesh> {
540        record_csg_op(1, mesh_a.triangle_count(), mesh_b.triangle_count());
541        if mesh_a.is_empty() {
542            return Ok(mesh_b.clone());
543        }
544        if mesh_b.is_empty() {
545            return Ok(mesh_a.clone());
546        }
547
548        // Pure-Rust exact kernel. On an empty/invalid kernel result
549        // fall back to a plain merge (overlap not removed) + record the failure,
550        // preserving the legacy never-Err contract.
551        let raw_u = crate::kernel::mesh_bridge::union(mesh_a, mesh_b);
552        let result = Self::consolidate_coplanar(raw_u);
553        if result.is_empty() || !self.validate_mesh(&result) {
554            self.record_failure(BoolOp::Union, BoolFailureReason::KernelOutputInvalid);
555            let mut merged = mesh_a.clone();
556            merged.merge(mesh_b);
557            return Ok(merged);
558        }
559        Ok(result)
560    }
561
562    /// Intersect two meshes using CSG boolean operations on the pure-Rust
563    /// exact kernel.
564    ///
565    /// Returns the intersection of two meshes (the volume where both
566    /// overlap).
567    pub fn intersection_mesh(&self, mesh_a: &Mesh, mesh_b: &Mesh) -> Result<Mesh> {
568        record_csg_op(2, mesh_a.triangle_count(), mesh_b.triangle_count());
569        if mesh_a.is_empty() || mesh_b.is_empty() {
570            return Ok(Mesh::new());
571        }
572
573        // Pure-Rust exact kernel. An empty result is legitimate
574        // (disjoint operands → empty intersection).
575        let result =
576            Self::consolidate_coplanar(crate::kernel::mesh_bridge::intersection(mesh_a, mesh_b));
577        if !result.is_empty() && !self.validate_mesh(&result) {
578            self.record_failure(BoolOp::Intersection, BoolFailureReason::KernelOutputInvalid);
579            return Ok(Mesh::new());
580        }
581        Ok(result)
582    }
583
584    /// Union multiple meshes together
585    ///
586    /// Convenience method that sequentially unions all non-empty meshes.
587    /// Skips empty meshes to avoid unnecessary CSG operations.
588    pub fn union_meshes(&self, meshes: &[Mesh]) -> Result<Mesh> {
589        if meshes.is_empty() {
590            return Ok(Mesh::new());
591        }
592
593        if meshes.len() == 1 {
594            return Ok(meshes[0].clone());
595        }
596
597        // Start with first non-empty mesh
598        let mut result = Mesh::new();
599        let mut found_first = false;
600
601        for mesh in meshes {
602            if mesh.is_empty() {
603                continue;
604            }
605
606            if !found_first {
607                result = mesh.clone();
608                found_first = true;
609                continue;
610            }
611
612            result = self.union_mesh(&result, mesh)?;
613        }
614
615        Ok(result)
616    }
617
618    /// Heuristic: does this look like a botched CSG difference?
619    ///
620    /// Kernel-neutral check used by the boolean processor (e.g. the
621    /// polygonal-bounded half-space clip) to fall back to a robust
622    /// unbounded plane clip when a difference result looks collapsed
623    /// relative to its host. Historically this caught a Linux-specific
624    /// Manifold pathology where a wall body clipped by an
625    /// `IfcPolygonalBoundedHalfSpace` prism collapsed to a near-empty
626    /// result (1 triangle from a 12-triangle host box).
627    ///
628    /// Rules:
629    ///  * An empty result is a legit outcome (cutter contains host) —
630    ///    NOT degenerate.
631    ///  * A closed-volume result needs at least 4 triangles. Anything
632    ///    below that is structurally broken.
633    ///  * For hosts with >= 12 triangles (typical IFC solid input), the
634    ///    output should retain at least 25 % of the host's triangle
635    ///    count when the cutter is partial.
636    pub(crate) fn difference_result_looks_degenerate(host: &Mesh, result: &Mesh) -> bool {
637        let result_tris = result.indices.len() / 3;
638        if result_tris == 0 {
639            return false;
640        }
641        if result_tris < 4 {
642            return true;
643        }
644        let host_tris = host.indices.len() / 3;
645        if host_tris >= 12 && result_tris * 4 < host_tris {
646            return true;
647        }
648
649        // "Wrong piece" check: a difference result MUST be a subset of the
650        // host volume, so the result's bounding box has to sit inside the
651        // host's. When a malformed cutter (typical: IfcFacetedBrep with
652        // inward-pointing face normals) inverts the kernel's
653        // inside/outside test, Manifold returns the CUTTER mesh instead —
654        // which lives partially or wholly outside the host bbox. House.ifc
655        // wall #3448 (a 7 m extrusion clipped by a gable-shaped brep)
656        // rendered as the gable triangle alone before this guard.
657        let (host_min, host_max) = host.bounds();
658        let (res_min, res_max) = result.bounds();
659        // 1 % of the host's edge **per axis** — using a single tolerance
660        // derived from the longest dimension lets thin walls/plates pass
661        // a wrong-piece check on Y/Z that they shouldn't (CodeRabbit
662        // review on PR #861). With per-axis slack, a 5 m × 0.4 m × 7 m
663        // wall gets ±5 cm tolerance on X, ±4 mm on Y, ±7 cm on Z — so a
664        // result that pokes >4 mm past the wall's thickness face is
665        // correctly flagged even though it's well within 1 % of the X
666        // span.
667        let slack = (host_max - host_min).abs() * 0.01;
668        if res_min.x + slack.x < host_min.x
669            || res_min.y + slack.y < host_min.y
670            || res_min.z + slack.z < host_min.z
671            || res_max.x > host_max.x + slack.x
672            || res_max.y > host_max.y + slack.y
673            || res_max.z > host_max.z + slack.z
674        {
675            return true;
676        }
677        false
678    }
679
680    /// Validate mesh for common issues
681    fn validate_mesh(&self, mesh: &Mesh) -> bool {
682        // Check for NaN/Inf in positions
683        if mesh.positions.iter().any(|v| !v.is_finite()) {
684            return false;
685        }
686
687        // Check for NaN/Inf in normals
688        if mesh.normals.iter().any(|v| !v.is_finite()) {
689            return false;
690        }
691
692        // Check for valid triangle indices
693        let vertex_count = mesh.vertex_count();
694        for idx in &mesh.indices {
695            if *idx as usize >= vertex_count {
696                return false;
697            }
698        }
699
700        true
701    }
702
703    /// Clip an entire mesh against a plane
704    pub fn clip_mesh(&self, mesh: &Mesh, plane: &Plane) -> Result<Mesh> {
705        record_csg_op(3, mesh.triangle_count(), 0);
706        let mut result = Mesh::new();
707
708        // Process each triangle
709        let vert_count = mesh.positions.len() / 3;
710        for i in (0..mesh.indices.len()).step_by(3) {
711            if i + 2 >= mesh.indices.len() {
712                break;
713            }
714            let i0 = mesh.indices[i] as usize;
715            let i1 = mesh.indices[i + 1] as usize;
716            let i2 = mesh.indices[i + 2] as usize;
717
718            // Bounds check vertex indices
719            if i0 >= vert_count || i1 >= vert_count || i2 >= vert_count {
720                continue;
721            }
722
723            // Get triangle vertices
724            let v0 = Point3::new(
725                mesh.positions[i0 * 3] as f64,
726                mesh.positions[i0 * 3 + 1] as f64,
727                mesh.positions[i0 * 3 + 2] as f64,
728            );
729            let v1 = Point3::new(
730                mesh.positions[i1 * 3] as f64,
731                mesh.positions[i1 * 3 + 1] as f64,
732                mesh.positions[i1 * 3 + 2] as f64,
733            );
734            let v2 = Point3::new(
735                mesh.positions[i2 * 3] as f64,
736                mesh.positions[i2 * 3 + 1] as f64,
737                mesh.positions[i2 * 3 + 2] as f64,
738            );
739
740            let triangle = Triangle::new(v0, v1, v2);
741
742            // Clip triangle
743            match self.clip_triangle(&triangle, plane) {
744                ClipResult::AllFront(tri) => {
745                    // Keep original triangle
746                    add_triangle_to_mesh(&mut result, &tri);
747                }
748                ClipResult::AllBehind => {
749                    // Discard triangle
750                }
751                ClipResult::Split(triangles) => {
752                    // Add clipped triangles
753                    for tri in triangles {
754                        add_triangle_to_mesh(&mut result, &tri);
755                    }
756                }
757            }
758        }
759
760        Ok(result)
761    }
762}
763
764impl Default for ClippingProcessor {
765    fn default() -> Self {
766        Self::new()
767    }
768}
769
770/// Add a triangle to a mesh
771fn add_triangle_to_mesh(mesh: &mut Mesh, triangle: &Triangle) {
772    let base_idx = mesh.vertex_count() as u32;
773
774    // Calculate normal
775    let normal = triangle.normal();
776
777    // Add vertices
778    mesh.add_vertex(triangle.v0, normal);
779    mesh.add_vertex(triangle.v1, normal);
780    mesh.add_vertex(triangle.v2, normal);
781
782    // Add triangle
783    mesh.add_triangle(base_idx, base_idx + 1, base_idx + 2);
784}
785
786#[cfg(test)]
787#[path = "csg_tests.rs"]
788mod csg_tests;