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;