ifc_lite_geometry/processors/boolean/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//! BooleanClipping processor - CSG operations.
6//!
7//! Handles IfcBooleanResult and IfcBooleanClippingResult for boolean operations
8//! (DIFFERENCE, UNION, INTERSECTION).
9
10use crate::diagnostics::{BoolFailure, BoolFailureReason, BoolOp};
11use crate::{
12 ClippingProcessor, Error, Mesh, Point3, Result, TessellationQuality, Vector3,
13};
14use ifc_lite_core::{DecodedEntity, EntityDecoder, IfcSchema, IfcType};
15use std::cell::RefCell;
16
17use super::brep::FacetedBrepProcessor;
18use super::csg_primitive::{BlockProcessor, CsgSolidProcessor};
19use super::extrusion::ExtrudedAreaSolidProcessor;
20use super::helpers::parse_axis2_placement_3d;
21use super::swept::{RevolvedAreaSolidProcessor, SweptDiskSolidProcessor};
22use super::tessellated::TriangulatedFaceSetProcessor;
23use crate::router::GeometryProcessor;
24
25mod cut_heuristics;
26mod halfspace_cap;
27mod polygonal_prism;
28use cut_heuristics::{
29 cutter_below_skip_ratio, plane_is_coincident_with_host_face, quality_skips_small_cuts,
30};
31use halfspace_cap::cap_half_space_clip;
32
33/// Maximum recursion depth for nested boolean operations.
34/// Prevents stack overflow from deeply nested IfcBooleanResult chains.
35/// In WASM, the stack is limited (~1-8MB), and each recursion level uses
36/// significant stack space for CSG operations.
37const MAX_BOOLEAN_DEPTH: u32 = 10;
38
39/// BooleanResult processor
40/// Handles IfcBooleanResult and IfcBooleanClippingResult - CSG operations
41///
42/// Supports all IFC boolean operations:
43/// - DIFFERENCE: Subtracts second operand from first (wall clipped by roof, openings, etc.)
44/// - Uses efficient plane clipping for IfcHalfSpaceSolid operands
45/// - Uses full 3D CSG for solid-solid operations (e.g., roof/slab clipping)
46/// - UNION: Combines two solids into one
47/// - INTERSECTION: Returns the overlapping volume of two solids
48///
49/// Performance notes:
50/// - HalfSpaceSolid clipping is very fast (simple plane-based triangle clipping)
51/// - Solid-solid CSG only invoked when actually needed (no overhead for simple geometry)
52/// - Graceful fallback to first operand if CSG fails on degenerate meshes
53pub struct BooleanClippingProcessor {
54 schema: IfcSchema,
55 /// Boolean failures recorded by this processor (the silent solid-solid
56 /// skip, the polygonal-bounded half-space fallthrough, unknown operators)
57 /// and drained from any internal `ClippingProcessor` instances. Drainable
58 /// via [`Self::take_failures`].
59 failures: RefCell<Vec<BoolFailure>>,
60 /// Per-build small-cut skip (#1286). When set, a solid-solid DIFFERENCE
61 /// whose cutter is far smaller than its host is dropped (host rendered
62 /// un-cut) even at a full tessellation tier. Scoped to this processor
63 /// instance — injected by the [`crate::router::GeometryRouter`] that
64 /// constructs it — so concurrent native builds never bleed the flag into
65 /// each other (was a process-wide static). `false` ⇒ every cut runs,
66 /// byte-identical to before the optimization.
67 skip_small_cuts: bool,
68}
69
70impl BooleanClippingProcessor {
71 pub fn new() -> Self {
72 Self::with_skip_small_cuts(false)
73 }
74
75 /// Construct with the per-build small-cut skip set (see
76 /// [`Self::skip_small_cuts`]). The router injects the build's value here;
77 /// nested boolean operands reuse the same `self`, and the only cross-
78 /// processor boolean construction site (`CsgSolidProcessor`) forwards its
79 /// own field so a whole CSG tree shares one scoped value.
80 pub fn with_skip_small_cuts(skip_small_cuts: bool) -> Self {
81 Self {
82 schema: IfcSchema::new(),
83 failures: RefCell::new(Vec::new()),
84 skip_small_cuts,
85 }
86 }
87
88 /// Drain the boolean-failure log accumulated since this processor was
89 /// created (or the last `take_failures` call).
90 pub fn take_failures(&self) -> Vec<BoolFailure> {
91 std::mem::take(&mut *self.failures.borrow_mut())
92 }
93
94 fn record_failure(&self, op: BoolOp, reason: BoolFailureReason) {
95 self.failures.borrow_mut().push(BoolFailure::new(op, reason));
96 }
97
98 /// Move every failure from `clipper` into this processor's log. Used
99 /// after a transient `ClippingProcessor` instance is about to drop.
100 fn drain_clipper_failures(&self, clipper: &ClippingProcessor) {
101 let mut log = self.failures.borrow_mut();
102 log.extend(clipper.take_failures());
103 }
104
105 /// If a DIFFERENCE clip emptied a non-empty host **and** the cutter's
106 /// plane is coincident with one of the host's bounding-box faces,
107 /// revert to the host and record the loss. The coincidence test is
108 /// what keeps this from rendering geometry the model explicitly
109 /// removed: a half-space deliberately placed far from the host so it
110 /// engulfs the body (e.g. a demolition-phase cutter) still produces
111 /// the correct empty mesh because no host face touches that plane.
112 /// Only the Revit IFC2x3 "top-trim at exactly the wall top" pattern
113 /// — issue #821 TallBuilding.ifc walls #615, #1297, #2401 and similar
114 /// Revit exports where the spec-correct cut would erase the wall —
115 /// hits the fallback.
116 fn guard_against_full_host_removal(
117 &self,
118 host: Mesh,
119 result: Mesh,
120 plane_point: Point3<f64>,
121 plane_normal: Vector3<f64>,
122 ) -> Mesh {
123 if host.is_empty() || !result.is_empty() {
124 return result;
125 }
126 if !plane_is_coincident_with_host_face(&host, plane_point, plane_normal) {
127 // Spec-correct full removal — respect the author's intent.
128 return result;
129 }
130 self.record_failure(BoolOp::Difference, BoolFailureReason::DifferenceEmptiedHost);
131 host
132 }
133
134 /// Process a solid operand with depth tracking
135 fn process_operand_with_depth(
136 &self,
137 operand: &DecodedEntity,
138 decoder: &mut EntityDecoder,
139 depth: u32,
140 quality: TessellationQuality,
141 ) -> Result<Mesh> {
142 match operand.ifc_type {
143 IfcType::IfcExtrudedAreaSolid => {
144 let processor = ExtrudedAreaSolidProcessor::new(self.schema.clone());
145 processor.process(operand, decoder, &self.schema, quality)
146 }
147 IfcType::IfcFacetedBrep => {
148 let processor = FacetedBrepProcessor::new();
149 processor.process(operand, decoder, &self.schema, quality)
150 }
151 IfcType::IfcTriangulatedFaceSet => {
152 let processor = TriangulatedFaceSetProcessor::new();
153 processor.process(operand, decoder, &self.schema, quality)
154 }
155 IfcType::IfcSweptDiskSolid => {
156 let processor = SweptDiskSolidProcessor::new(self.schema.clone());
157 processor.process(operand, decoder, &self.schema, quality)
158 }
159 IfcType::IfcRevolvedAreaSolid => {
160 let processor = RevolvedAreaSolidProcessor::new(self.schema.clone());
161 processor.process(operand, decoder, &self.schema, quality)
162 }
163 IfcType::IfcBlock => {
164 BlockProcessor::new().process(operand, decoder, &self.schema, quality)
165 }
166 IfcType::IfcCsgSolid => CsgSolidProcessor::with_skip_small_cuts(self.skip_small_cuts)
167 .process(operand, decoder, &self.schema, quality),
168 IfcType::IfcBooleanResult | IfcType::IfcBooleanClippingResult => {
169 // Recursive case with depth tracking
170 self.process_with_depth(operand, decoder, &self.schema, depth + 1, quality)
171 }
172 _ => Ok(Mesh::new()),
173 }
174 }
175
176 /// Parse IfcHalfSpaceSolid to get clipping plane
177 /// Returns (plane_point, plane_normal, agreement_flag)
178 fn parse_half_space_solid(
179 &self,
180 half_space: &DecodedEntity,
181 decoder: &mut EntityDecoder,
182 ) -> Result<(Point3<f64>, Vector3<f64>, bool)> {
183 // IfcHalfSpaceSolid attributes:
184 // 0: BaseSurface (IfcSurface - usually IfcPlane)
185 // 1: AgreementFlag (boolean - true means material is on positive side)
186
187 let surface_attr = half_space
188 .get(0)
189 .ok_or_else(|| Error::geometry("HalfSpaceSolid missing BaseSurface".to_string()))?;
190
191 let surface = decoder
192 .resolve_ref(surface_attr)?
193 .ok_or_else(|| Error::geometry("Failed to resolve BaseSurface".to_string()))?;
194
195 // Get agreement flag - defaults to true
196 let agreement = half_space
197 .get(1)
198 .map(|v| match v {
199 // Parser strips dots, so enum value is "T" or "F", not ".T." or ".F."
200 ifc_lite_core::AttributeValue::Enum(e) => e != "F" && e != ".F.",
201 _ => true,
202 })
203 .unwrap_or(true);
204
205 // Parse IfcPlane
206 if surface.ifc_type != IfcType::IfcPlane {
207 return Err(Error::geometry(format!(
208 "Expected IfcPlane for HalfSpaceSolid, got {}",
209 surface.ifc_type
210 )));
211 }
212
213 // IfcPlane has one attribute: Position (IfcAxis2Placement3D)
214 let position_attr = surface
215 .get(0)
216 .ok_or_else(|| Error::geometry("IfcPlane missing Position".to_string()))?;
217
218 let position = decoder
219 .resolve_ref(position_attr)?
220 .ok_or_else(|| Error::geometry("Failed to resolve Plane position".to_string()))?;
221
222 // Parse IfcAxis2Placement3D to get transformation matrix
223 // The Position defines the plane's coordinate system:
224 // - Location = plane point (in world coordinates)
225 // - Z-axis (Axis) = plane normal (in local coordinates, needs transformation)
226 let position_transform = parse_axis2_placement_3d(&position, decoder)?;
227
228 // Plane point is the Position's Location (translation part of transform)
229 let location = Point3::new(
230 position_transform[(0, 3)],
231 position_transform[(1, 3)],
232 position_transform[(2, 3)],
233 );
234
235 // Plane normal is the Position's Z-axis transformed to world coordinates
236 // Extract Z-axis from transform matrix (third column)
237 let normal = Vector3::new(
238 position_transform[(0, 2)],
239 position_transform[(1, 2)],
240 position_transform[(2, 2)],
241 )
242 .normalize();
243
244 Ok((location, normal, agreement))
245 }
246
247 /// Apply half-space clipping to mesh
248 fn clip_mesh_with_half_space(
249 &self,
250 mesh: &Mesh,
251 plane_point: Point3<f64>,
252 plane_normal: Vector3<f64>,
253 agreement: bool,
254 ) -> Result<Mesh> {
255 use crate::csg::{ClippingProcessor, Plane};
256
257 // For DIFFERENCE operation with HalfSpaceSolid:
258 // - AgreementFlag=.T. means material is on positive side of plane normal
259 // - AgreementFlag=.F. means material is on negative side of plane normal
260 // Since we're SUBTRACTING the half-space, we keep the opposite side:
261 // - If material is on positive side (agreement=true), remove positive side → keep negative side → clip_normal = plane_normal
262 // - If material is on negative side (agreement=false), remove negative side → keep positive side → clip_normal = -plane_normal
263 let clip_normal = if agreement {
264 plane_normal // Material on positive side, remove it, keep negative side
265 } else {
266 -plane_normal // Material on negative side, remove it, keep positive side
267 };
268
269 let plane = Plane::new(plane_point, clip_normal);
270 let processor = ClippingProcessor::new();
271 let mut clipped = processor.clip_mesh(mesh, &plane)?;
272 // The plane clip removes the half-space but leaves the cut cross-section
273 // OPEN (the BSP kernel's polygon cap was deleted with the BSP port in
274 // #1024). Re-close it: a watertight host clipped by a plane leaves an
275 // open boundary lying on that plane, forming the section to cap.
276 cap_half_space_clip(&mut clipped, plane_point, clip_normal);
277 Ok(clipped)
278 }
279
280 /// Walk the left-spine of a chained
281 /// `IfcBooleanClippingResult(.DIFFERENCE., x, polygonalBoundedHalfSpace)`
282 /// pattern (typical for gable walls clipped by a segmented roof) and
283 /// collect every consecutive `IfcPolygonalBoundedHalfSpace` cutter, plus
284 /// the base solid the chain bottoms out on.
285 ///
286 /// Returns `(base_entity, cutters)` with `cutters` ordered innermost-first.
287 /// Consumed by [`Self::try_union_polygonal_chain`], which unions the cutter
288 /// prisms (a true CSG union — overlap-safe, unlike the old mesh-*merge*
289 /// batching) and subtracts once. See that method for why a single unioned
290 /// subtract beats sequential subtraction here (issue #960: seam slivers +
291 /// deep-chain depth-limit drops).
292 fn collect_polygonal_chain(
293 &self,
294 entity: DecodedEntity,
295 decoder: &mut EntityDecoder,
296 ) -> Result<(DecodedEntity, Vec<DecodedEntity>)> {
297 let mut chain: Vec<DecodedEntity> = Vec::new();
298 let mut current = entity;
299 // Guard against self-referential / cyclic FirstOperand chains in
300 // malformed input (e.g. `#10=IFCBOOLEANCLIPPINGRESULT(.DIFFERENCE.,#10,
301 // #20)`), which would otherwise walk `current = first` forever and grow
302 // `chain` without bound (hang + OOM in the wasm geometry worker, where
303 // panic=abort takes down the whole instance). A visited-id set breaks on
304 // the first repeat WITHOUT capping legitimate deep-but-finite chains —
305 // this walk was made iterative in #960 precisely to bypass
306 // MAX_BOOLEAN_DEPTH for those, so a low depth cap would regress them.
307 let mut visited: std::collections::HashSet<u32> = std::collections::HashSet::new();
308 loop {
309 if !visited.insert(current.id) {
310 break;
311 }
312 if !matches!(
313 current.ifc_type,
314 IfcType::IfcBooleanResult | IfcType::IfcBooleanClippingResult
315 ) {
316 break;
317 }
318 // Operator must be DIFFERENCE.
319 let op = current
320 .get(0)
321 .and_then(|v| match v {
322 ifc_lite_core::AttributeValue::Enum(e) => Some(e.as_str().to_string()),
323 _ => None,
324 })
325 .unwrap_or_else(|| ".DIFFERENCE.".to_string());
326 if op != ".DIFFERENCE." && op != "DIFFERENCE" {
327 break;
328 }
329 let Some(second_attr) = current.get(2) else { break };
330 let Ok(Some(second)) = decoder.resolve_ref(second_attr) else { break };
331 if second.ifc_type != IfcType::IfcPolygonalBoundedHalfSpace {
332 break;
333 }
334 chain.push(second);
335 let Some(first_attr) = current.get(1) else { break };
336 let Ok(Some(first)) = decoder.resolve_ref(first_attr) else { break };
337 current = first;
338 }
339 // Reverse so chain[0] is the innermost (first-applied) clip.
340 chain.reverse();
341 Ok((current, chain))
342 }
343
344 /// Resolve a left-deep chain of
345 /// `IfcBooleanClippingResult(.DIFFERENCE., x, IfcPolygonalBoundedHalfSpace)`
346 /// clips by unioning every cutter prism into one solid and subtracting it
347 /// from the base in a single operation. See the call site in
348 /// [`Self::process_with_depth`] for the full rationale (issue #960: seam
349 /// slivers + deep-chain depth-limit drops).
350 ///
351 /// Returns `Ok(None)` — defer to the standard sequential path — when the
352 /// chain has fewer than two PBHS cutters, when a cutter prism fails to
353 /// build, or when batching can't be proven safe (a full-cross-section
354 /// cutter that needs the per-cutter unbounded-plane fallback, or a CSG
355 /// union that silently under-removes).
356 ///
357 /// Relies on a *watertight* CSG union of the cutter prisms (built by
358 /// [`Self::build_cutter_union`]). No longer manifold-gated — the chain walk
359 /// and cutter build are kernel-agnostic and must compile into the pure-Rust
360 /// wasm — but it still DEFERS (returns `Ok(None)`) when no available kernel
361 /// can produce that watertight union, so a non-manifold mesh-merge is never
362 /// fed into the subtract.
363 fn try_union_polygonal_chain(
364 &self,
365 entity: &DecodedEntity,
366 decoder: &mut EntityDecoder,
367 depth: u32,
368 quality: TessellationQuality,
369 ) -> Result<Option<Mesh>> {
370 let (base_entity, cutters) = self.collect_polygonal_chain(entity.clone(), decoder)?;
371 if cutters.len() < 2 {
372 return Ok(None);
373 }
374
375 // Process the base solid (the innermost first-operand). The chain is
376 // walked iteratively above, so a 12-cutter chain reaches here at the
377 // SAME `depth` as a 2-cutter one — the recursion-depth limit can't drop
378 // it.
379 let base_mesh = self.process_operand_with_depth(&base_entity, decoder, depth, quality)?;
380 if base_mesh.is_empty() {
381 return Ok(Some(base_mesh));
382 }
383
384 // Build each cutter prism (bounds-clamped to the base).
385 let mut prisms: Vec<Mesh> = Vec::with_capacity(cutters.len());
386 for cutter in &cutters {
387 let (plane_point, plane_normal, agreement) =
388 self.parse_half_space_solid(cutter, decoder)?;
389 match self.build_polygonal_bounded_half_space_mesh(
390 cutter,
391 decoder,
392 &base_mesh,
393 plane_point,
394 plane_normal,
395 agreement,
396 ) {
397 Ok(prism) if !prism.is_empty() => prisms.push(prism),
398 // A cutter we can't build a prism for would be silently dropped
399 // here; defer to the sequential path, which records the loss as
400 // `PolygonalBoundedHalfSpaceFallback`.
401 _ => return Ok(None),
402 }
403 }
404
405 let clipper = ClippingProcessor::new();
406
407 // Per-cutter trial subtracts serve two roles:
408 // * reject the chain if any single cutter is degenerate (a full-
409 // cross-section coincident-face clip whose bounded subtract is
410 // fragile — duplex.ifc "Party Wall" #4287/#4399, which the
411 // sequential path rescues via its bounded→unbounded fallback), and
412 // * record the intersection of every single-cutter result's bounds.
413 // The true answer (base minus the union of ALL cutters) is a subset
414 // of each single-cutter result, so its bounds can't exceed that
415 // intersection. If the unioned subtract below pokes outside it, the
416 // CSG union silently under-removed (manifold does this for near-
417 // coincident/duplicate cutters) and must not be trusted.
418 let mut tight_min = Point3::new(f32::NEG_INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY);
419 let mut tight_max = Point3::new(f32::INFINITY, f32::INFINITY, f32::INFINITY);
420 for prism in &prisms {
421 let trial = match clipper.subtract_mesh(&base_mesh, prism) {
422 Ok(m) if !m.is_empty() => m,
423 // Empty or errored single cut — the sequential path's per-cutter
424 // fallback handles it better than a batched union would.
425 _ => {
426 let _ = clipper.take_failures();
427 return Ok(None);
428 }
429 };
430 if ClippingProcessor::difference_result_looks_degenerate(&base_mesh, &trial) {
431 let _ = clipper.take_failures();
432 return Ok(None);
433 }
434 let (tmn, tmx) = trial.bounds();
435 tight_min = Point3::new(
436 tight_min.x.max(tmn.x),
437 tight_min.y.max(tmn.y),
438 tight_min.z.max(tmn.z),
439 );
440 tight_max = Point3::new(
441 tight_max.x.min(tmx.x),
442 tight_max.y.min(tmx.y),
443 tight_max.z.min(tmx.z),
444 );
445 }
446 let _ = clipper.take_failures();
447
448 // Every cutter is a clean partial cut: union them into ONE watertight
449 // solid (a true CSG union, so abutting roof segments share no internal
450 // seam) and subtract once. This eliminates both the zero-thickness seam
451 // fins that sequential subtraction leaves behind AND the deep-chain
452 // MAX_BOOLEAN_DEPTH drops. `build_cutter_union` returns `None` when no
453 // available kernel can union the prisms into a watertight solid; we
454 // defer (like every other guard here) rather than feed a broken,
455 // non-manifold union into the subtract — which the CSG kernel can't
456 // classify, silently returning the host UNCHANGED (issue #960 wall
457 // #2152: the gable-end wall rendered at full 7000 mm extrusion height).
458 let combined = match self.build_cutter_union(&clipper, &prisms) {
459 Some(m) if !m.is_empty() => m,
460 _ => {
461 // Unlike the trial-subtract probes above (whose failures the
462 // sequential path re-encounters and re-logs), the union
463 // attempt is unique to this path — preserve its kernel
464 // failures and record the deferral, since the sequential
465 // fallback can leave seam fins the batched subtract avoids.
466 self.drain_clipper_failures(&clipper);
467 self.record_failure(BoolOp::Union, BoolFailureReason::CutterUnionUnavailable);
468 return Ok(None);
469 }
470 };
471 let result = clipper.subtract_mesh(&base_mesh, &combined);
472 self.drain_clipper_failures(&clipper);
473 let clipped = match result {
474 Ok(m)
475 if !m.is_empty()
476 && !ClippingProcessor::difference_result_looks_degenerate(&base_mesh, &m) =>
477 {
478 m
479 }
480 // Kernel error or a degenerate union result — fall back to the
481 // sequential per-cutter path.
482 _ => return Ok(None),
483 };
484
485 // Reject a silently under-removing union: the result must fit inside the
486 // intersection of the single-cutter result bounds (tolerance scaled to
487 // the host size). If it pokes outside, the union dropped a cut — defer
488 // to sequential. (duplex.ifc: a near-coincident cutter pair unions to
489 // less than either alone.)
490 let (rmn, rmx) = clipped.bounds();
491 let diag = (tight_max.x - tight_min.x)
492 .hypot(tight_max.y - tight_min.y)
493 .hypot(tight_max.z - tight_min.z);
494 let tol = (diag * 1e-3).max(1e-4);
495 let under_removed = rmx.x > tight_max.x + tol
496 || rmx.y > tight_max.y + tol
497 || rmx.z > tight_max.z + tol
498 || rmn.x < tight_min.x - tol
499 || rmn.y < tight_min.y - tol
500 || rmn.z < tight_min.z - tol;
501 if under_removed {
502 return Ok(None);
503 }
504 Ok(Some(clipped))
505 }
506
507 /// Union the chained-clip cutter prisms into ONE watertight solid.
508 ///
509 /// The segmented-roof cutters are prisms that ABUT along shared, exactly-
510 /// coplanar faces (adjacent roof facets meeting at a hip/ridge/valley).
511 /// Unioning them into a single watertight cutter is what lets the chain be
512 /// subtracted ONCE (no seam fins, no deep-chain depth drops — issue #960).
513 ///
514 /// Returns `None` when no available kernel can produce a watertight union;
515 /// the caller then defers to the sequential per-cutter path. We never feed a
516 /// non-manifold mesh-merge into the subtract: the CSG kernel cannot classify
517 /// a non-watertight cutter and silently returns the host UNCHANGED, leaving
518 /// the gable-end wall at full extrusion height.
519 fn build_cutter_union(&self, clipper: &ClippingProcessor, prisms: &[Mesh]) -> Option<Mesh> {
520 if prisms.is_empty() {
521 return None;
522 }
523 if prisms.len() == 1 {
524 return Some(prisms[0].clone());
525 }
526
527 // Primary path: the pure-Rust kernel's N-ary union — ONE conforming
528 // arrangement of all cutter prisms over a shared interner, so coplanar
529 // seams shared by 3+ roof segments (and exactly-duplicated cutter prisms)
530 // dissolve without the tearing that left-deep pairwise accumulation
531 // produces. This makes the segmented-roof clip (#960) watertight on EVERY
532 // build. Exact + platform-deterministic.
533 {
534 let refs: Vec<&Mesh> = prisms.iter().collect();
535 let u = ClippingProcessor::consolidate_coplanar(
536 crate::kernel::mesh_bridge::union_many(&refs),
537 );
538 if !u.is_empty() {
539 return Some(u);
540 }
541 }
542
543 // Fallback: the kernel's sequential multi-mesh union. Returns
544 // `None` on empty/error so the caller defers to the per-cutter path.
545 match clipper.union_meshes(prisms) {
546 Ok(m) if !m.is_empty() => Some(m),
547 _ => None,
548 }
549 }
550
551 /// Internal processing with depth tracking to prevent stack overflow
552 fn process_with_depth(
553 &self,
554 entity: &DecodedEntity,
555 decoder: &mut EntityDecoder,
556 _schema: &IfcSchema,
557 depth: u32,
558 quality: TessellationQuality,
559 ) -> Result<Mesh> {
560 // Depth limit to prevent stack overflow from deeply nested boolean chains
561 if depth > MAX_BOOLEAN_DEPTH {
562 return Err(Error::geometry(format!(
563 "Boolean nesting depth {} exceeds limit {}",
564 depth, MAX_BOOLEAN_DEPTH
565 )));
566 }
567
568 // IfcBooleanResult attributes:
569 // 0: Operator (.DIFFERENCE., .UNION., .INTERSECTION.)
570 // 1: FirstOperand (base geometry)
571 // 2: SecondOperand (clipping geometry)
572
573 // Get operator
574 let operator = entity
575 .get(0)
576 .and_then(|v| match v {
577 ifc_lite_core::AttributeValue::Enum(e) => Some(e.as_str()),
578 _ => None,
579 })
580 .unwrap_or(".DIFFERENCE.");
581
582 // A left-deep chain of `IfcBooleanClippingResult(.DIFFERENCE., x,
583 // IfcPolygonalBoundedHalfSpace)` clips — the canonical "gable wall
584 // trimmed by a segmented roof" pattern — is resolved by unioning all
585 // cutter prisms into one solid and subtracting it once, rather than
586 // applying each cutter sequentially. Two reasons (issue #960,
587 // House.ifc):
588 //
589 // 1. **No seam slivers.** Sequentially subtracting two prisms that
590 // abut along a shared edge (adjacent roof segments meeting at a
591 // hip/valley) leaves the host material exactly on the seam as a
592 // zero-thickness, full-height fin — rendered double-sided, it is a
593 // visible wall sliver poking through the roof. A real CSG *union*
594 // dissolves the shared face, so the single subtract leaves nothing
595 // behind. (This is NOT the old mesh-*merge* batching that produced
596 // non-manifold cutters — `union_meshes` runs a true CSG union,
597 // which handles overlapping/duplicate cutters correctly.)
598 // 2. **No depth-limit drops.** The chain is walked iteratively, so a
599 // wall clipped by 12+ roof planes no longer blows MAX_BOOLEAN_DEPTH
600 // and vanishes (House.ifc walls #4148/#2797/#5904).
601 //
602 // `try_union_polygonal_chain` returns `None` (fall through to the
603 // sequential path below) whenever batching isn't provably safe, so the
604 // per-cutter bounded→unbounded fallback still rescues full-cross-section
605 // clips (duplex.ifc "Party Wall"). Verified mm-identical to IfcOpenShell
606 // on all five reported House.ifc walls.
607 //
608 // The *correctness* of the single subtract hinges on a WATERTIGHT union
609 // of the cutter prisms, which `build_cutter_union` computes with the
610 // exact kernel's N-ary `union_many`. When it can't produce a watertight
611 // union, `try_union_polygonal_chain` returns `None` and we fall through
612 // to the sequential path — so this is never worse than pre-#960 (the
613 // seam-sliver / deep-chain drop only fully resolves once that union is
614 // watertight; 841_house_stack_overflow.ifc).
615 if operator == ".DIFFERENCE." || operator == "DIFFERENCE" {
616 if let Some(result) = self.try_union_polygonal_chain(entity, decoder, depth, quality)? {
617 return Ok(result);
618 }
619 }
620
621 // NOTE: a previous version had a "fast path for chained polygonal-
622 // bounded half-space clips" here that mesh-merged every cutter in
623 // the chain into one combined mesh and ran a single BSP CSG op.
624 // That batching is incorrect when chained cutter polygons OVERLAP
625 // or DUPLICATE — the mesh-merge of two closed solids occupying
626 // the same volume is non-manifold by construction, and BSP CSG on
627 // a non-manifold cutter produces sliver artefacts (issue #583
628 // AC20-Institute-Var-2 Wand-010, which has 4 chained cutters
629 // including an exact duplicate at x=[17,25]).
630 //
631 // The reference implementations both handle this differently:
632 // - web-ifc: strictly sequential. One CSG per IfcBooleanResult
633 // node, recursing first-operand bottom-up.
634 // - ifcopenshell: batches via OCCT's topological CSG (handles
635 // overlap natively) up to 8 operands, then falls
636 // back to sequential past that.
637 //
638 // We can't do OCCT-style topological CSG in our mesh-CSG
639 // kernel, so we follow web-ifc: SEQUENTIAL through the
640 // standard recursive path below. The per-step cutter is always a
641 // single closed manifold prism, so the non-manifold-cutter root
642 // cause is structurally eliminated.
643 //
644 // Performance: N CSG ops instead of 1 for chains of length N, but
645 // each op runs on a SMALL single-cutter mesh (one polygon prism =
646 // ~10-20 tris) rather than the combined N-cutter mesh, so wall-
647 // clock cost is comparable. CSG cost scales with operand polygon
648 // count, not operation count.
649 //
650 // See docs/research/csg-clipping-fidelity.md for the full
651 // side-by-side comparison with the reference implementations.
652
653 // Get first operand (base geometry)
654 let first_operand_attr = entity
655 .get(1)
656 .ok_or_else(|| Error::geometry("BooleanResult missing FirstOperand".to_string()))?;
657
658 let first_operand = decoder
659 .resolve_ref(first_operand_attr)?
660 .ok_or_else(|| Error::geometry("Failed to resolve FirstOperand".to_string()))?;
661
662 // Process first operand to get base mesh
663 let mesh = self.process_operand_with_depth(&first_operand, decoder, depth, quality)?;
664
665 if mesh.is_empty() {
666 return Ok(mesh);
667 }
668
669 // Get second operand
670 let second_operand_attr = entity
671 .get(2)
672 .ok_or_else(|| Error::geometry("BooleanResult missing SecondOperand".to_string()))?;
673
674 let second_operand = decoder
675 .resolve_ref(second_operand_attr)?
676 .ok_or_else(|| Error::geometry("Failed to resolve SecondOperand".to_string()))?;
677
678 // Handle DIFFERENCE operation
679 // Note: Parser may strip dots from enum values, so check both forms
680 if operator == ".DIFFERENCE." || operator == "DIFFERENCE" {
681 // Check if second operand is a half-space solid (simple or polygonally bounded)
682 if second_operand.ifc_type == IfcType::IfcHalfSpaceSolid {
683 // Simple half-space: use plane clipping
684 let (plane_point, plane_normal, agreement) =
685 self.parse_half_space_solid(&second_operand, decoder)?;
686 let clipped =
687 self.clip_mesh_with_half_space(&mesh, plane_point, plane_normal, agreement)?;
688 return Ok(self.guard_against_full_host_removal(
689 mesh,
690 clipped,
691 plane_point,
692 plane_normal,
693 ));
694 }
695
696 if second_operand.ifc_type == IfcType::IfcPolygonalBoundedHalfSpace {
697 let (plane_point, plane_normal, agreement) =
698 self.parse_half_space_solid(&second_operand, decoder)?;
699 if let Ok(bound_mesh) = self.build_polygonal_bounded_half_space_mesh(
700 &second_operand,
701 decoder,
702 &mesh,
703 plane_point,
704 plane_normal,
705 agreement,
706 ) {
707 let clipper = ClippingProcessor::new();
708 let subtract_result = clipper.subtract_mesh(&mesh, &bound_mesh);
709 self.drain_clipper_failures(&clipper);
710 if let Ok(clipped) = subtract_result {
711 // The bounded-prism subtract is fragile on coincident
712 // faces: when the clip polygon spans the full host
713 // cross-section, the prism's in-plane side walls land
714 // exactly on the host's side faces and the CSG kernel
715 // can collapse the host to a near-empty sliver
716 // (duplex.ifc "Party Wall" segments #4287/#4399 —
717 // 12-tri box → 2-tri quad on the deleted legacy BSP
718 // kernel). When the result looks degenerate
719 // we fall through to the robust unbounded plane clip
720 // below: a strict superset of the bounded cut that is
721 // exactly correct whenever the polygon already covers
722 // the host's projected cross-section.
723 if !ClippingProcessor::difference_result_looks_degenerate(&mesh, &clipped) {
724 return Ok(self.guard_against_full_host_removal(
725 mesh,
726 clipped,
727 plane_point,
728 plane_normal,
729 ));
730 }
731 }
732 }
733
734 // Bounded prism subtract failed (or its build did). The
735 // unbounded plane clip *is* applied, but it's a strict
736 // superset of the bounded cut — the polygonal boundary is
737 // silently dropped. Flag so callers can surface the loss.
738 self.record_failure(
739 BoolOp::Difference,
740 BoolFailureReason::PolygonalBoundedHalfSpaceFallback,
741 );
742 let clipped =
743 self.clip_mesh_with_half_space(&mesh, plane_point, plane_normal, agreement)?;
744 return Ok(self.guard_against_full_host_removal(
745 mesh,
746 clipped,
747 plane_point,
748 plane_normal,
749 ));
750 }
751
752 // Solid-solid difference on the exact kernel (no operand-size
753 // cap). The old unconditional `SolidSolidDifferenceSkipped`
754 // short-circuit here meant every CSG primitive cut (issue #780
755 // bath, any `IfcCsgSolid` with a solid cutter) silently rendered
756 // as the uncut host even when the operands were trivially small.
757 let second_mesh =
758 self.process_operand_with_depth(&second_operand, decoder, depth, quality)?;
759 if second_mesh.is_empty() {
760 self.record_failure(BoolOp::Difference, BoolFailureReason::EmptyOperand);
761 return Ok(mesh);
762 }
763 // Small-cut skip: a cutter far smaller than its host (a steel
764 // cope/notch, a small detail recess) costs a full exact subtract —
765 // the dominant load-time cost on boolean-heavy steel — for a
766 // barely-visible change. Dropping it renders the host un-cut and
767 // recovers Manifold-class load times. Enabled either by a preview
768 // tessellation tier (Lowest/Low) OR by the per-build `skip_small_cuts`
769 // field, which the viewer turns on WITHOUT dropping to a preview tier
770 // so curves stay full-density while the tiny cuts are skipped (#1286).
771 // The field is scoped to this processor (injected by the router), so
772 // concurrent native builds never bleed it into one another. With
773 // neither set (the default), EVERY cut runs — byte-identical to
774 // before this optimization, on any tier.
775 if (quality_skips_small_cuts(quality) || self.skip_small_cuts)
776 && cutter_below_skip_ratio(&mesh, &second_mesh)
777 {
778 return Ok(mesh);
779 }
780 let clipper = ClippingProcessor::new();
781 let result = clipper.subtract_mesh(&mesh, &second_mesh);
782 self.drain_clipper_failures(&clipper);
783 return result;
784 }
785
786 // Handle UNION operation — a real CSG union (overlap removed) on the
787 // pure-Rust exact kernel.
788 if operator == ".UNION." || operator == "UNION" {
789 let second_mesh = self.process_operand_with_depth(&second_operand, decoder, depth, quality)?;
790 if second_mesh.is_empty() {
791 self.record_failure(BoolOp::Union, BoolFailureReason::EmptyOperand);
792 return Ok(mesh);
793 }
794 let clipper = ClippingProcessor::new();
795 let result = clipper.union_mesh(&mesh, &second_mesh);
796 self.drain_clipper_failures(&clipper);
797 return result;
798 }
799
800 // Handle INTERSECTION operation — a real intersection volume on the
801 // pure-Rust exact kernel.
802 if operator == ".INTERSECTION." || operator == "INTERSECTION" {
803 let second_mesh =
804 self.process_operand_with_depth(&second_operand, decoder, depth, quality)?;
805 if second_mesh.is_empty() {
806 self.record_failure(BoolOp::Intersection, BoolFailureReason::EmptyOperand);
807 return Ok(Mesh::new());
808 }
809 let clipper = ClippingProcessor::new();
810 let result = clipper.intersection_mesh(&mesh, &second_mesh);
811 self.drain_clipper_failures(&clipper);
812 return result;
813 }
814
815 self.record_failure(
816 BoolOp::Unknown,
817 BoolFailureReason::UnknownBooleanOperator(operator.to_string()),
818 );
819 Ok(mesh)
820 }
821}
822
823impl GeometryProcessor for BooleanClippingProcessor {
824 fn process(
825 &self,
826 entity: &DecodedEntity,
827 decoder: &mut EntityDecoder,
828 schema: &IfcSchema,
829 quality: TessellationQuality,
830 ) -> Result<Mesh> {
831 self.process_with_depth(entity, decoder, schema, 0, quality)
832 }
833
834 fn supported_types(&self) -> Vec<IfcType> {
835 vec![IfcType::IfcBooleanResult, IfcType::IfcBooleanClippingResult]
836 }
837}
838
839impl Default for BooleanClippingProcessor {
840 fn default() -> Self {
841 Self::new()
842 }
843}
844
845#[cfg(test)]
846mod halfspace_cap_tests;
847
848#[cfg(test)]
849mod chain_cycle_tests;