Skip to main content

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}