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(¤t) != 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, ¤t, decoder, depth, quality, visited)?;
603 }
604 if operand::boolean_operator(¤t) == 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(¤t, 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;