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