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