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 degenerate_check;
18mod group_cut;
19mod normals;
20mod plane_eps;
21mod topology_diagnostic;
22mod union;
23
24pub use group_cut::{GroupCut, GroupReject};
25pub use normals::calculate_normals;
26pub(crate) use consolidate::tri_is_needle;
27
28/// Type alias for small triangle collections (typically 1-2 triangles from clipping)
29pub type TriangleVec = SmallVec<[Triangle; 4]>;
30
31/// Plane definition for clipping
32#[derive(Debug, Clone, Copy)]
33pub struct Plane {
34    /// Point on the plane
35    pub point: Point3<f64>,
36    /// Normal vector (must be normalized)
37    pub normal: Vector3<f64>,
38}
39
40impl Plane {
41    /// Create a new plane
42    pub fn new(point: Point3<f64>, normal: Vector3<f64>) -> Self {
43        Self {
44            point,
45            normal: normal.normalize(),
46        }
47    }
48
49    /// Calculate signed distance from point to plane
50    /// Positive = in front, Negative = behind
51    pub fn signed_distance(&self, point: &Point3<f64>) -> f64 {
52        (point - self.point).dot(&self.normal)
53    }
54}
55
56/// Triangle clipping result
57#[derive(Debug, Clone)]
58pub enum ClipResult {
59    /// Triangle is completely in front (keep it)
60    AllFront(Triangle),
61    /// Triangle is completely behind (discard it)
62    AllBehind,
63    /// Triangle intersects plane - returns new triangles (uses SmallVec to avoid heap allocation)
64    Split(TriangleVec),
65}
66
67/// Triangle definition
68#[derive(Debug, Clone)]
69pub struct Triangle {
70    pub v0: Point3<f64>,
71    pub v1: Point3<f64>,
72    pub v2: Point3<f64>,
73}
74
75impl Triangle {
76    /// Create a new triangle
77    #[inline]
78    pub fn new(v0: Point3<f64>, v1: Point3<f64>, v2: Point3<f64>) -> Self {
79        Self { v0, v1, v2 }
80    }
81
82    /// Calculate triangle normal.
83    ///
84    /// **Degenerate triangles get `+Z`, never NaN.** A zero-area (collapsed or
85    /// exactly collinear) triangle has a zero-length cross product, and the
86    /// plain `normalize()` this used to call is `v / |v|` — i.e. `0.0 / 0.0`,
87    /// which is NaN in every component. Those NaNs were written verbatim into
88    /// `Mesh::normals` by `add_triangle_to_mesh` (the only production caller of
89    /// this method, via `ClippingProcessor::clip_mesh`), and they SURVIVED the
90    /// mesh-hygiene pass: `clean_degenerate` / `drop_thin_triangles` rewrites
91    /// only `indices`, so the degenerate triangle's vertices stay in
92    /// `positions` / `normals` as ORPHANS carrying NaN. Six of duplex.ifc's
93    /// material-layer wall slices shipped 81 NaN normal components that way,
94    /// which the `@ifc-lite/provenance` node-hash domain check rightly rejects
95    /// (every NaN bit pattern collapses to one quiet NaN when serialized, so
96    /// accepting them would give distinct payloads the same hash).
97    ///
98    /// `+Z` is this crate's established convention for an undefined normal —
99    /// the same fallback `csg::normals::calculate_normals` and
100    /// `mesh::weld_impl`'s average-normals path already use — so a consumer
101    /// that meets one meets them all. It is stated in the KERNEL's own Z-up
102    /// frame, like every other normal this crate writes, so a viewer that
103    /// converts to Y-up reads it back as `+Y`; that is the conversion doing its
104    /// job, not a second convention. The value is arbitrary but must be a FIXED
105    /// unit vector: a zero normal would just re-create the division by zero in
106    /// any shader or exporter that re-normalizes.
107    ///
108    /// Non-degenerate triangles are unaffected, bit-for-bit: `try_normalize(0.0)`
109    /// returns `Some(v.unscale(|v|))` for every `|v| > 0`, which is exactly what
110    /// `normalize()` computed. The extra `is_finite` check covers the
111    /// astronomically-unlikely underflow case where `|v|` rounds to zero from
112    /// non-zero components (division would yield ±Inf, also out of domain).
113    #[inline]
114    pub fn normal(&self) -> Vector3<f64> {
115        match self.cross_product().try_normalize(0.0) {
116            Some(n) if n.x.is_finite() && n.y.is_finite() && n.z.is_finite() => n,
117            _ => Vector3::new(0.0, 0.0, 1.0),
118        }
119    }
120
121    /// Calculate the cross product of edges, which is twice the area vector.
122    ///
123    /// Returns a `Vector3<f64>` perpendicular to the triangle plane.
124    /// For degenerate/collinear triangles, returns the zero vector.
125    /// Use `is_degenerate()` or `try_normalize()` on the result if you need
126    /// to detect and handle degenerate cases.
127    #[inline]
128    pub fn cross_product(&self) -> Vector3<f64> {
129        let edge1 = self.v1 - self.v0;
130        let edge2 = self.v2 - self.v0;
131        edge1.cross(&edge2)
132    }
133
134    /// Calculate triangle area (half the magnitude of the cross product).
135    #[inline]
136    pub fn area(&self) -> f64 {
137        self.cross_product().norm() * 0.5
138    }
139
140    /// Check if triangle is degenerate (zero area, collinear vertices).
141    ///
142    /// Uses `try_normalize` on the cross product with the specified epsilon.
143    /// Returns `true` if the cross product cannot be normalized (i.e., degenerate).
144    #[inline]
145    pub fn is_degenerate(&self, epsilon: f64) -> bool {
146        self.cross_product().try_normalize(epsilon).is_none()
147    }
148}
149
150/// One recorded invocation of a CSG kernel op (perf-census diagnostics).
151/// `op`: 0=subtract 1=union 2=intersection
152/// 3=clip. `a_tris`/`b_tris` are the operand triangle counts — the arrangement
153/// cost driver — so the census measures the *real* heavy-path workload reaching
154/// the kernel (analytic AABB box clips never get here).
155#[derive(Clone, Copy, Debug)]
156pub struct CsgOpRecord {
157    pub op: u8,
158    pub a_tris: u32,
159    pub b_tris: u32,
160}
161
162// Global (Mutex) so it captures ops on rayon worker threads, not just the caller.
163static CSG_CENSUS: std::sync::Mutex<Vec<CsgOpRecord>> = std::sync::Mutex::new(Vec::new());
164
165/// Clear the CSG op census (call before a measured run).
166pub fn reset_csg_census() {
167    if let Ok(mut g) = CSG_CENSUS.lock() {
168        g.clear();
169    }
170}
171
172/// Drain the CSG op census (call after a measured run).
173pub fn take_csg_census() -> Vec<CsgOpRecord> {
174    CSG_CENSUS
175        .lock()
176        .map(|mut g| std::mem::take(&mut *g))
177        .unwrap_or_default()
178}
179
180#[inline]
181fn record_csg_op(op: u8, a_tris: usize, b_tris: usize) {
182    #[cfg(feature = "opening-perf-trace")]
183    crate::opening_perf_trace::record(|c| {
184        let index = usize::from(op).min(3);
185        c.csg_operations[index] = c.csg_operations[index].saturating_add(1);
186        c.csg_operand_triangles[index] = c.csg_operand_triangles[index]
187            .saturating_add(a_tris as u64).saturating_add(b_tris as u64);
188    });
189    if let Ok(mut g) = CSG_CENSUS.lock() {
190        g.push(CsgOpRecord {
191            op,
192            a_tris: a_tris as u32,
193            b_tris: b_tris as u32,
194        });
195    }
196}
197
198/// CSG Clipping Processor
199pub struct ClippingProcessor {
200    /// Floor for [`Self::clip_mesh`]'s projected classification epsilon (and
201    /// the whole tolerance [`Self::clip_triangle`] still uses). Raw `f64`,
202    /// never rescaled by `unit_scale`, so its unit is the caller's: file units
203    /// on the `processors/boolean` path, METRES on `router/layers`. See
204    /// [`plane_eps`] for the frames, the sizing and the KNOWN LIMITATION.
205    pub epsilon: f64,
206    /// Metres per caller unit, used only by the physical ring-width noise gate.
207    length_unit_scale: f64,
208    /// Boolean / CSG failures recorded since the last `take_failures()`.
209    /// Interior-mutable so the existing `&self` API stays unchanged.
210    failures: RefCell<Vec<BoolFailure>>,
211}
212
213impl ClippingProcessor {
214    /// Create a new clipping processor
215    pub fn new() -> Self {
216        Self {
217            epsilon: 1e-6,
218            length_unit_scale: 1.0,
219            failures: RefCell::new(Vec::new()),
220        }
221    }
222
223    /// Drain and return the failures recorded by this processor since its
224    /// creation (or the last `take_failures` call). The processor's internal
225    /// log is cleared.
226    pub fn take_failures(&self) -> Vec<BoolFailure> {
227        std::mem::take(&mut *self.failures.borrow_mut())
228    }
229
230    /// Number of failures currently buffered (without draining).
231    pub fn failure_count(&self) -> usize {
232        self.failures.borrow().len()
233    }
234
235    /// Whether any failure recorded since index `since` (a prior
236    /// [`failure_count`](Self::failure_count)) was an `OperandTooLarge`
237    /// rejection. HISTORICAL: only the deleted BSP polygon cap ever
238    /// emitted this from the boolean ops — the exact kernel has no operand
239    /// cap, so this is now always `false` on the boolean path. Kept because
240    /// the void router still keys its AABB-fallback decision on it
241    /// (issue #635 / #947), which is conservative and correct either way.
242    pub(crate) fn has_operand_too_large_since(&self, since: usize) -> bool {
243        let failures = self.failures.borrow();
244        let since = since.min(failures.len());
245        failures[since..]
246            .iter()
247            .any(|f| matches!(f.reason, BoolFailureReason::OperandTooLarge { .. }))
248    }
249
250    /// Internal: append a failure record. Public-crate so the boolean
251    /// processor in `processors/boolean.rs` can record fallbacks that
252    /// happen above the kernel layer.
253    pub(crate) fn record_failure(&self, op: BoolOp, reason: BoolFailureReason) {
254        #[cfg(feature = "opening-perf-trace")]
255        crate::opening_perf_trace::record(|c| {
256            let index = match &reason {
257                BoolFailureReason::KernelError(_) => 0,
258                BoolFailureReason::KernelOutputInvalid => 1,
259                BoolFailureReason::OperandTooLarge { .. } => 2,
260                _ => 3,
261            };
262            c.csg_failures[index] = c.csg_failures[index].saturating_add(1);
263        });
264        self.failures.borrow_mut().push(BoolFailure::new(op, reason));
265    }
266
267    /// Clip a triangle against a plane
268    /// Returns triangles that are in front of the plane
269    pub fn clip_triangle(&self, triangle: &Triangle, plane: &Plane) -> ClipResult {
270        plane_eps::clip_triangle_with_epsilon(triangle, plane, self.epsilon)
271    }
272
273    /// Check if two meshes' bounding boxes overlap
274    fn bounds_overlap(host_mesh: &Mesh, opening_mesh: &Mesh) -> bool {
275        let (host_min, host_max) = host_mesh.bounds();
276        let (open_min, open_max) = opening_mesh.bounds();
277
278        // Issue #977: this runs on the *un-inflated* cutter, before
279        // `manifold_kernel::difference` inflates it. A recess whose cut face is
280        // exactly flush with a host face touches the host's AABB right at the
281        // boundary; strict `<`/`>` would classify it as non-overlapping and drop
282        // the cut before inflation ever runs. Use inclusive `<=`/`>=` with a small
283        // *relative* epsilon (scaled to the operands, so it is unit-robust across
284        // mm/m models) to keep flush cutters in play without admitting genuinely
285        // disjoint operands.
286        let span = (host_max.x - host_min.x)
287            .max(host_max.y - host_min.y)
288            .max(host_max.z - host_min.z)
289            .max(open_max.x - open_min.x)
290            .max(open_max.y - open_min.y)
291            .max(open_max.z - open_min.z);
292        let eps = span * 1e-6;
293
294        let overlap_x = open_min.x - eps <= host_max.x && open_max.x + eps >= host_min.x;
295        let overlap_y = open_min.y - eps <= host_max.y && open_max.y + eps >= host_min.y;
296        let overlap_z = open_min.z - eps <= host_max.z && open_max.z + eps >= host_min.z;
297
298        overlap_x && overlap_y && overlap_z
299    }
300
301    /// Subtract opening mesh from host mesh using CSG boolean operations
302    /// on the pure-Rust exact mesh-arrangement kernel.
303    ///
304    /// Returns the same [`GroupCut`] as [`Self::subtract_mesh_many`], a group
305    /// of one. It used to return the host un-cut on every bail, the shape of
306    /// a real cut, and callers guessed which one happened from the triangle
307    /// count and a 0.1 % volume test that read a small real cut as no cut
308    /// (#4692). A rejection leaves the host untouched. A cutter the kernel
309    /// classifies as not reaching the host solid still produces the
310    /// consolidated arrangement output, as it always did; it comes back as
311    /// [`GroupCut::Retessellated`], not as a cut, unless the arrangement did
312    /// not conform and the output has the host's triangle count, which comes
313    /// back as `Rejected(Nonconforming)` (#5362).
314    ///
315    /// Unlike the group path, the single cutter records a [`BoolFailure`]
316    /// (drainable via [`Self::take_failures`]) for an empty cutter
317    /// (`EmptyOperand`), a missed bounds overlap (`NoBoundsOverlap`) and a
318    /// budget trip (`OperandTooLarge`), as well as for `InvalidOutput` and
319    /// `GateRejected`. `EmptyHost` records nothing. `Cut` and `Retessellated`
320    /// pass the same validation and gates. The accept
321    /// path also runs `record_topology_tear` (#3440 step 1): diagnostic only,
322    /// never gates, in every build. `topology_gate_reject` (#3440 step 2) runs
323    /// the same closure predicate but, ONLY when the crate is built with the
324    /// `csg_topology_gate` feature (off by default; no downstream crate turns
325    /// it on), rejects a torn result the same way `KernelOutputInvalid` does.
326    pub fn subtract_mesh(&self, host_mesh: &Mesh, opening_mesh: &Mesh) -> GroupCut {
327        #[cfg(feature = "opening-perf-trace")]
328        crate::opening_perf_trace::record(|c| c.single_subtract_calls = c.single_subtract_calls.saturating_add(1));
329        record_csg_op(0, host_mesh.triangle_count(), opening_mesh.triangle_count());
330        if host_mesh.is_empty() {
331            return GroupCut::Rejected(GroupReject::EmptyHost);
332        }
333        if opening_mesh.is_empty() {
334            self.record_failure(BoolOp::Difference, BoolFailureReason::EmptyOperand);
335            return GroupCut::Rejected(GroupReject::NoOverlap);
336        }
337        if !Self::bounds_overlap(host_mesh, opening_mesh) {
338            self.record_failure(BoolOp::Difference, BoolFailureReason::NoBoundsOverlap);
339            return GroupCut::Rejected(GroupReject::NoOverlap);
340        }
341
342        // Pure-Rust exact mesh-arrangement kernel, with consolidate_coplanar
343        // merging per-face fragments to match Manifold's clean output.
344        //
345        // NB: the kernel output itself is the watertightness bar — the
346        // crack-family fix lives upstream (`promote_cutter_verts_onto_host_faces`'s
347        // exact-plane lift). `consolidate_coplanar` can still re-open a closed
348        // cut along a µm-offset plane pair (each bucket earcuts independently,
349        // breaking the shared boundary chain); a closure-preserving guard here
350        // was tried and REJECTED — on FZK-Haus gable walls the raw kernel
351        // output carries >50:1 needle fragments that consolidation legitimately
352        // merges (the pinned `csg_quality_regression` spike bar). A
353        // seam-preserving consolidation is the remaining follow-up.
354        crate::kernel::budget::begin();
355        let (raw, changed, conforming) =
356            crate::kernel::mesh_bridge::subtract_with_change(host_mesh, opening_mesh);
357        // Deterministic escalation guardrail (#1109): if the exact predicate
358        // cascade escalated past the per-boolean budget, the cut bailed mid-
359        // arrangement. Discard the partial result (its `changed` bit too) and
360        // reject, so the void router's #635 AABB box-cut fallback fires. The
361        // trip point is a pure function of the snapped operands, so server
362        // (native) and client (wasm) degrade the SAME element identically.
363        if crate::kernel::budget::tripped() {
364            self.record_failure(
365                BoolOp::Difference,
366                BoolFailureReason::OperandTooLarge {
367                    polys_a: host_mesh.triangle_count(),
368                    polys_b: opening_mesh.triangle_count(),
369                },
370            );
371            return GroupCut::Rejected(GroupReject::BudgetTripped);
372        }
373        let result = self.consolidate(raw);
374        if !result.is_empty() && !self.validate_mesh(&result) {
375            self.record_failure(BoolOp::Difference, BoolFailureReason::KernelOutputInvalid);
376            return GroupCut::Rejected(GroupReject::InvalidOutput);
377        }
378        if self.accept_gates_reject(BoolOp::Difference, &result) {
379            return GroupCut::Rejected(GroupReject::GateRejected);
380        }
381        self.record_topology_tear(BoolOp::Difference, &result);
382        if changed {
383            #[cfg(feature = "opening-perf-trace")]
384            crate::opening_perf_trace::record(|c| c.single_changed_results = c.single_changed_results.saturating_add(1));
385            GroupCut::Cut(result)
386        } else if !conforming && result.triangle_count() == host_mesh.triangle_count() {
387            // A non-conforming "no change" is not proof the cutter misses the
388            // host (straddling sub-triangles can be misclassified), so it is
389            // not reported as a `Retessellated` miss, which callers read as
390            // disjoint (#5362). A same-count re-tessellation would be
391            // discarded anyway (`mesh_to_keep`), so this only keeps the #635
392            // fallback armed for it, as before #5362.
393            GroupCut::Rejected(GroupReject::Nonconforming)
394        } else {
395            #[cfg(feature = "opening-perf-trace")]
396            crate::opening_perf_trace::record(|c| {
397                c.single_retessellated_results = c.single_retessellated_results.saturating_add(1);
398                c.single_retessellated_input_triangles = c.single_retessellated_input_triangles.saturating_add(host_mesh.triangle_count() as u64);
399                c.single_retessellated_output_triangles = c.single_retessellated_output_triangles.saturating_add(result.triangle_count() as u64);
400            });
401            GroupCut::Retessellated(result)
402        }
403    }
404
405    /// Intersect two meshes using CSG boolean operations on the pure-Rust
406    /// exact kernel.
407    ///
408    /// Returns the intersection of two meshes (the volume where both
409    /// overlap).
410    pub fn intersection_mesh(&self, mesh_a: &Mesh, mesh_b: &Mesh) -> Result<Mesh> {
411        record_csg_op(2, mesh_a.triangle_count(), mesh_b.triangle_count());
412        if mesh_a.is_empty() || mesh_b.is_empty() {
413            return Ok(Mesh::new());
414        }
415
416        // Pure-Rust exact kernel. An empty result is legitimate
417        // (disjoint operands → empty intersection).
418        let result = self.consolidate(crate::kernel::mesh_bridge::intersection(mesh_a, mesh_b));
419        if !result.is_empty() && !self.validate_mesh(&result) {
420            self.record_failure(BoolOp::Intersection, BoolFailureReason::KernelOutputInvalid);
421            return Ok(Mesh::new());
422        }
423        if self.accept_gates_reject(BoolOp::Intersection, &result) {
424            return Ok(Mesh::new());
425        }
426        Ok(result).inspect(|m| self.record_topology_tear(BoolOp::Intersection, m))
427    }
428
429    /// Validate mesh for common issues
430    fn validate_mesh(&self, mesh: &Mesh) -> bool {
431        // Check for NaN/Inf in positions
432        if mesh.positions.iter().any(|v| !v.is_finite()) {
433            return false;
434        }
435        // Check for NaN/Inf in normals
436        if mesh.normals.iter().any(|v| !v.is_finite()) {
437            return false;
438        }
439        // Check for valid triangle indices
440        let vertex_count = mesh.vertex_count();
441        for idx in &mesh.indices {
442            if *idx as usize >= vertex_count {
443                return false;
444            }
445        }
446
447        true
448    }
449
450    /// Clip an entire mesh against a plane.
451    ///
452    /// The classification epsilon is per-axis f32 rounding noise projected
453    /// onto `plane`'s own normal and floored at [`Self::epsilon`]; see
454    /// [`plane_eps`] for why it must scale with coordinate magnitude, why the
455    /// magnitude is tracked per axis rather than maxed over all three, and why
456    /// `near_band_from_extent` is deliberately not reused.
457    pub fn clip_mesh(&self, mesh: &Mesh, plane: &Plane) -> Result<Mesh> {
458        record_csg_op(3, mesh.triangle_count(), 0);
459        let mut result = Mesh::new();
460
461        let eps = plane_eps::PlaneEps::new(mesh, self.epsilon).for_normal(&plane.normal);
462
463        // Process each triangle
464        let vert_count = mesh.positions.len() / 3;
465        for i in (0..mesh.indices.len()).step_by(3) {
466            if i + 2 >= mesh.indices.len() {
467                break;
468            }
469            let i0 = mesh.indices[i] as usize;
470            let i1 = mesh.indices[i + 1] as usize;
471            let i2 = mesh.indices[i + 2] as usize;
472
473            // Bounds check vertex indices
474            if i0 >= vert_count || i1 >= vert_count || i2 >= vert_count {
475                continue;
476            }
477
478            // Get triangle vertices
479            let v0 = Point3::new(
480                mesh.positions[i0 * 3] as f64,
481                mesh.positions[i0 * 3 + 1] as f64,
482                mesh.positions[i0 * 3 + 2] as f64,
483            );
484            let v1 = Point3::new(
485                mesh.positions[i1 * 3] as f64,
486                mesh.positions[i1 * 3 + 1] as f64,
487                mesh.positions[i1 * 3 + 2] as f64,
488            );
489            let v2 = Point3::new(
490                mesh.positions[i2 * 3] as f64,
491                mesh.positions[i2 * 3 + 1] as f64,
492                mesh.positions[i2 * 3 + 2] as f64,
493            );
494
495            let triangle = Triangle::new(v0, v1, v2);
496
497            // Clip triangle
498            match plane_eps::clip_triangle_with_epsilon(&triangle, plane, eps) {
499                ClipResult::AllFront(tri) => {
500                    // Keep original triangle
501                    add_triangle_to_mesh(&mut result, &tri);
502                }
503                ClipResult::AllBehind => {
504                    // Discard triangle
505                }
506                ClipResult::Split(triangles) => {
507                    // Add clipped triangles
508                    for tri in triangles {
509                        add_triangle_to_mesh(&mut result, &tri);
510                    }
511                }
512            }
513        }
514
515        Ok(result)
516    }
517}
518
519impl Default for ClippingProcessor {
520    fn default() -> Self {
521        Self::new()
522    }
523}
524
525/// Add a triangle to a mesh
526fn add_triangle_to_mesh(mesh: &mut Mesh, triangle: &Triangle) {
527    let base_idx = mesh.vertex_count() as u32;
528
529    // Calculate normal
530    let normal = triangle.normal();
531
532    // Add vertices
533    mesh.add_vertex(triangle.v0, normal);
534    mesh.add_vertex(triangle.v1, normal);
535    mesh.add_vertex(triangle.v2, normal);
536
537    // Add triangle
538    mesh.add_triangle(base_idx, base_idx + 1, base_idx + 2);
539}
540
541#[cfg(test)]
542#[path = "csg_tests.rs"]
543mod csg_tests;