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;