ifc_lite_geometry/processors/boolean.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::{Error, Mesh, Point3, Result, Vector3};
11use ifc_lite_core::{DecodedEntity, EntityDecoder, IfcSchema, IfcType};
12
13use crate::router::GeometryProcessor;
14use super::helpers::parse_axis2_placement_3d;
15use super::extrusion::ExtrudedAreaSolidProcessor;
16use super::tessellated::TriangulatedFaceSetProcessor;
17use super::brep::FacetedBrepProcessor;
18use super::swept::{SweptDiskSolidProcessor, RevolvedAreaSolidProcessor};
19
20/// Maximum recursion depth for nested boolean operations.
21/// Prevents stack overflow from deeply nested IfcBooleanResult chains.
22/// In WASM, the stack is limited (~1-8MB), and each recursion level uses
23/// significant stack space for CSG operations.
24const MAX_BOOLEAN_DEPTH: u32 = 20;
25
26/// BooleanResult processor
27/// Handles IfcBooleanResult and IfcBooleanClippingResult - CSG operations
28///
29/// Supports all IFC boolean operations:
30/// - DIFFERENCE: Subtracts second operand from first (wall clipped by roof, openings, etc.)
31/// - Uses efficient plane clipping for IfcHalfSpaceSolid operands
32/// - Uses full 3D CSG for solid-solid operations (e.g., roof/slab clipping)
33/// - UNION: Combines two solids into one
34/// - INTERSECTION: Returns the overlapping volume of two solids
35///
36/// Performance notes:
37/// - HalfSpaceSolid clipping is very fast (simple plane-based triangle clipping)
38/// - Solid-solid CSG only invoked when actually needed (no overhead for simple geometry)
39/// - Graceful fallback to first operand if CSG fails on degenerate meshes
40pub struct BooleanClippingProcessor {
41 schema: IfcSchema,
42}
43
44impl BooleanClippingProcessor {
45 pub fn new() -> Self {
46 Self {
47 schema: IfcSchema::new(),
48 }
49 }
50
51 /// Process a solid operand with depth tracking
52 fn process_operand_with_depth(
53 &self,
54 operand: &DecodedEntity,
55 decoder: &mut EntityDecoder,
56 depth: u32,
57 ) -> Result<Mesh> {
58 match operand.ifc_type {
59 IfcType::IfcExtrudedAreaSolid => {
60 let processor = ExtrudedAreaSolidProcessor::new(self.schema.clone());
61 processor.process(operand, decoder, &self.schema)
62 }
63 IfcType::IfcFacetedBrep => {
64 let processor = FacetedBrepProcessor::new();
65 processor.process(operand, decoder, &self.schema)
66 }
67 IfcType::IfcTriangulatedFaceSet => {
68 let processor = TriangulatedFaceSetProcessor::new();
69 processor.process(operand, decoder, &self.schema)
70 }
71 IfcType::IfcSweptDiskSolid => {
72 let processor = SweptDiskSolidProcessor::new(self.schema.clone());
73 processor.process(operand, decoder, &self.schema)
74 }
75 IfcType::IfcRevolvedAreaSolid => {
76 let processor = RevolvedAreaSolidProcessor::new(self.schema.clone());
77 processor.process(operand, decoder, &self.schema)
78 }
79 IfcType::IfcBooleanResult | IfcType::IfcBooleanClippingResult => {
80 // Recursive case with depth tracking
81 self.process_with_depth(operand, decoder, &self.schema, depth + 1)
82 }
83 _ => Ok(Mesh::new()),
84 }
85 }
86
87 /// Parse IfcHalfSpaceSolid to get clipping plane
88 /// Returns (plane_point, plane_normal, agreement_flag)
89 fn parse_half_space_solid(
90 &self,
91 half_space: &DecodedEntity,
92 decoder: &mut EntityDecoder,
93 ) -> Result<(Point3<f64>, Vector3<f64>, bool)> {
94 // IfcHalfSpaceSolid attributes:
95 // 0: BaseSurface (IfcSurface - usually IfcPlane)
96 // 1: AgreementFlag (boolean - true means material is on positive side)
97
98 let surface_attr = half_space
99 .get(0)
100 .ok_or_else(|| Error::geometry("HalfSpaceSolid missing BaseSurface".to_string()))?;
101
102 let surface = decoder
103 .resolve_ref(surface_attr)?
104 .ok_or_else(|| Error::geometry("Failed to resolve BaseSurface".to_string()))?;
105
106 // Get agreement flag - defaults to true
107 let agreement = half_space
108 .get(1)
109 .map(|v| match v {
110 // Parser strips dots, so enum value is "T" or "F", not ".T." or ".F."
111 ifc_lite_core::AttributeValue::Enum(e) => e != "F" && e != ".F.",
112 _ => true,
113 })
114 .unwrap_or(true);
115
116 // Parse IfcPlane
117 if surface.ifc_type != IfcType::IfcPlane {
118 return Err(Error::geometry(format!(
119 "Expected IfcPlane for HalfSpaceSolid, got {}",
120 surface.ifc_type
121 )));
122 }
123
124 // IfcPlane has one attribute: Position (IfcAxis2Placement3D)
125 let position_attr = surface
126 .get(0)
127 .ok_or_else(|| Error::geometry("IfcPlane missing Position".to_string()))?;
128
129 let position = decoder
130 .resolve_ref(position_attr)?
131 .ok_or_else(|| Error::geometry("Failed to resolve Plane position".to_string()))?;
132
133 // Parse IfcAxis2Placement3D to get transformation matrix
134 // The Position defines the plane's coordinate system:
135 // - Location = plane point (in world coordinates)
136 // - Z-axis (Axis) = plane normal (in local coordinates, needs transformation)
137 let position_transform = parse_axis2_placement_3d(&position, decoder)?;
138
139 // Plane point is the Position's Location (translation part of transform)
140 let location = Point3::new(
141 position_transform[(0, 3)],
142 position_transform[(1, 3)],
143 position_transform[(2, 3)],
144 );
145
146 // Plane normal is the Position's Z-axis transformed to world coordinates
147 // Extract Z-axis from transform matrix (third column)
148 let normal = Vector3::new(
149 position_transform[(0, 2)],
150 position_transform[(1, 2)],
151 position_transform[(2, 2)],
152 ).normalize();
153
154 Ok((location, normal, agreement))
155 }
156
157 /// Apply half-space clipping to mesh
158 fn clip_mesh_with_half_space(
159 &self,
160 mesh: &Mesh,
161 plane_point: Point3<f64>,
162 plane_normal: Vector3<f64>,
163 agreement: bool,
164 ) -> Result<Mesh> {
165 use crate::csg::{ClippingProcessor, Plane};
166
167 // For DIFFERENCE operation with HalfSpaceSolid:
168 // - AgreementFlag=.T. means material is on positive side of plane normal
169 // - AgreementFlag=.F. means material is on negative side of plane normal
170 // Since we're SUBTRACTING the half-space, we keep the opposite side:
171 // - If material is on positive side (agreement=true), remove positive side → keep negative side → clip_normal = plane_normal
172 // - If material is on negative side (agreement=false), remove negative side → keep positive side → clip_normal = -plane_normal
173 let clip_normal = if agreement {
174 plane_normal // Material on positive side, remove it, keep negative side
175 } else {
176 -plane_normal // Material on negative side, remove it, keep positive side
177 };
178
179 let plane = Plane::new(plane_point, clip_normal);
180 let processor = ClippingProcessor::new();
181 processor.clip_mesh(mesh, &plane)
182 }
183
184 /// Internal processing with depth tracking to prevent stack overflow
185 fn process_with_depth(
186 &self,
187 entity: &DecodedEntity,
188 decoder: &mut EntityDecoder,
189 _schema: &IfcSchema,
190 depth: u32,
191 ) -> Result<Mesh> {
192 // Depth limit to prevent stack overflow from deeply nested boolean chains
193 if depth > MAX_BOOLEAN_DEPTH {
194 return Err(Error::geometry(format!(
195 "Boolean nesting depth {} exceeds limit {}",
196 depth, MAX_BOOLEAN_DEPTH
197 )));
198 }
199
200 // IfcBooleanResult attributes:
201 // 0: Operator (.DIFFERENCE., .UNION., .INTERSECTION.)
202 // 1: FirstOperand (base geometry)
203 // 2: SecondOperand (clipping geometry)
204
205 // Get operator
206 let operator = entity
207 .get(0)
208 .and_then(|v| match v {
209 ifc_lite_core::AttributeValue::Enum(e) => Some(e.as_str()),
210 _ => None,
211 })
212 .unwrap_or(".DIFFERENCE.");
213
214 // Get first operand (base geometry)
215 let first_operand_attr = entity
216 .get(1)
217 .ok_or_else(|| Error::geometry("BooleanResult missing FirstOperand".to_string()))?;
218
219 let first_operand = decoder
220 .resolve_ref(first_operand_attr)?
221 .ok_or_else(|| Error::geometry("Failed to resolve FirstOperand".to_string()))?;
222
223 // Process first operand to get base mesh
224 let mesh = self.process_operand_with_depth(&first_operand, decoder, depth)?;
225
226 if mesh.is_empty() {
227 return Ok(mesh);
228 }
229
230 // Get second operand
231 let second_operand_attr = entity
232 .get(2)
233 .ok_or_else(|| Error::geometry("BooleanResult missing SecondOperand".to_string()))?;
234
235 let second_operand = decoder
236 .resolve_ref(second_operand_attr)?
237 .ok_or_else(|| Error::geometry("Failed to resolve SecondOperand".to_string()))?;
238
239 // Handle DIFFERENCE operation
240 // Note: Parser may strip dots from enum values, so check both forms
241 if operator == ".DIFFERENCE." || operator == "DIFFERENCE" {
242 // Check if second operand is a half-space solid (simple or polygonally bounded)
243 if second_operand.ifc_type == IfcType::IfcHalfSpaceSolid {
244 // Simple half-space: use plane clipping
245 let (plane_point, plane_normal, agreement) =
246 self.parse_half_space_solid(&second_operand, decoder)?;
247 return self.clip_mesh_with_half_space(&mesh, plane_point, plane_normal, agreement);
248 }
249
250 // For PolygonalBoundedHalfSpace, use simple plane clipping (same as IfcHalfSpaceSolid)
251 // The polygon boundary defines the region but for wall-roof clipping, the plane is sufficient
252 if second_operand.ifc_type == IfcType::IfcPolygonalBoundedHalfSpace {
253 let (plane_point, plane_normal, agreement) =
254 self.parse_half_space_solid(&second_operand, decoder)?;
255 return self.clip_mesh_with_half_space(&mesh, plane_point, plane_normal, agreement);
256 }
257
258 // Solid-solid difference: return base geometry (first operand).
259 //
260 // The csgrs BSP tree can infinite-recurse on arbitrary solid combinations,
261 // causing unrecoverable stack overflow in WASM. Unlike half-space clipping
262 // (handled above), solid-solid CSG cannot be safely bounded.
263 //
264 // Opening subtraction (windows/doors from walls) is handled separately by
265 // the router via subtract_mesh, which works on controlled geometry. Here we
266 // only encounter IfcBooleanResult chains from CAD exports (Tekla, Revit)
267 // where the visual difference from skipping the boolean is negligible.
268 return Ok(mesh);
269 }
270
271 // Handle UNION operation
272 if operator == ".UNION." || operator == "UNION" {
273 // Merge both meshes (combines geometry without CSG intersection removal)
274 let second_mesh = self.process_operand_with_depth(&second_operand, decoder, depth)?;
275 if !second_mesh.is_empty() {
276 let mut merged = mesh;
277 merged.merge(&second_mesh);
278 return Ok(merged);
279 }
280 return Ok(mesh);
281 }
282
283 // Handle INTERSECTION operation
284 if operator == ".INTERSECTION." || operator == "INTERSECTION" {
285 // Return empty mesh - we can't safely compute the intersection due to
286 // csgrs BSP recursion, and returning the first operand would over-approximate
287 return Ok(Mesh::new());
288 }
289
290 // Unknown operator - return first operand
291 #[cfg(debug_assertions)]
292 eprintln!("[WARN] Unknown CSG operator {}, returning first operand", operator);
293 Ok(mesh)
294 }
295}
296
297impl GeometryProcessor for BooleanClippingProcessor {
298 fn process(
299 &self,
300 entity: &DecodedEntity,
301 decoder: &mut EntityDecoder,
302 schema: &IfcSchema,
303 ) -> Result<Mesh> {
304 self.process_with_depth(entity, decoder, schema, 0)
305 }
306
307 fn supported_types(&self) -> Vec<IfcType> {
308 vec![IfcType::IfcBooleanResult, IfcType::IfcBooleanClippingResult]
309 }
310}
311
312impl Default for BooleanClippingProcessor {
313 fn default() -> Self {
314 Self::new()
315 }
316}