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