Skip to main content

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(&current, decoder, depth, quality, visited)?;
714            }
715            let operator = Self::boolean_operator(&current);
716            if operator == ".DIFFERENCE." || operator == "DIFFERENCE" {
717                if let Some(result) =
718                    self.try_union_polygonal_chain(&current, 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;