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::{calculate_normals, ClippingProcessor, Error, Mesh, Point2, Point3, Profile2D, 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    fn parse_polygonal_boundary_2d(
185        &self,
186        boundary: &DecodedEntity,
187        decoder: &mut EntityDecoder,
188    ) -> Result<Vec<Point2<f64>>> {
189        if boundary.ifc_type != IfcType::IfcPolyline {
190            return Err(Error::geometry(format!(
191                "Expected IfcPolyline for PolygonalBoundary, got {}",
192                boundary.ifc_type
193            )));
194        }
195
196        let points_attr = boundary
197            .get(0)
198            .ok_or_else(|| Error::geometry("IfcPolyline missing Points".to_string()))?;
199        let points = decoder.resolve_ref_list(points_attr)?;
200
201        let mut contour = Vec::with_capacity(points.len());
202        for point in points {
203            if point.ifc_type != IfcType::IfcCartesianPoint {
204                return Err(Error::geometry(format!(
205                    "Expected IfcCartesianPoint in PolygonalBoundary, got {}",
206                    point.ifc_type
207                )));
208            }
209
210            let coords_attr = point
211                .get(0)
212                .ok_or_else(|| Error::geometry("IfcCartesianPoint missing coordinates".to_string()))?;
213            let coords = coords_attr
214                .as_list()
215                .ok_or_else(|| Error::geometry("Expected point coordinate list".to_string()))?;
216
217            let x = coords.first().and_then(|v| v.as_float()).unwrap_or(0.0);
218            let y = coords.get(1).and_then(|v| v.as_float()).unwrap_or(0.0);
219            contour.push(Point2::new(x, y));
220        }
221
222        if contour.len() > 1 {
223            let first = contour[0];
224            let last = contour[contour.len() - 1];
225            if (first.x - last.x).abs() < 1e-9 && (first.y - last.y).abs() < 1e-9 {
226                contour.pop();
227            }
228        }
229
230        if contour.len() < 3 {
231            return Err(Error::geometry(
232                "PolygonalBoundary must contain at least 3 distinct points".to_string(),
233            ));
234        }
235
236        Ok(contour)
237    }
238
239    fn polygon_normal(points: &[Point3<f64>]) -> Vector3<f64> {
240        let mut normal = Vector3::new(0.0, 0.0, 0.0);
241        for i in 0..points.len() {
242            let current = points[i];
243            let next = points[(i + 1) % points.len()];
244            normal.x += (current.y - next.y) * (current.z + next.z);
245            normal.y += (current.z - next.z) * (current.x + next.x);
246            normal.z += (current.x - next.x) * (current.y + next.y);
247        }
248
249        normal
250            .try_normalize(1e-12)
251            .unwrap_or_else(|| Vector3::new(0.0, 0.0, 1.0))
252    }
253
254    fn build_prism_mesh(
255        &self,
256        contour_2d: &[Point2<f64>],
257        origin: Point3<f64>,
258        x_axis: Vector3<f64>,
259        y_axis: Vector3<f64>,
260        extrusion_dir: Vector3<f64>,
261        depth: f64,
262    ) -> Result<Mesh> {
263        let profile = Profile2D::new(contour_2d.to_vec());
264        let triangulation = profile.triangulate()?;
265
266        let contour_world: Vec<Point3<f64>> = contour_2d
267            .iter()
268            .map(|p| origin + x_axis * p.x + y_axis * p.y)
269            .collect();
270        let tri_world: Vec<Point3<f64>> = triangulation
271            .points
272            .iter()
273            .map(|p| origin + x_axis * p.x + y_axis * p.y)
274            .collect();
275        let top_world: Vec<Point3<f64>> = tri_world
276            .iter()
277            .map(|p| *p + extrusion_dir * depth)
278            .collect();
279
280        let mut mesh = Mesh::with_capacity(
281            triangulation.points.len() * 2 + contour_world.len() * 4,
282            triangulation.indices.len() * 2 + contour_world.len() * 6,
283        );
284        let zero = Vector3::new(0.0, 0.0, 0.0);
285
286        let push_triangle = |mesh: &mut Mesh, a: Point3<f64>, b: Point3<f64>, c: Point3<f64>| {
287            let base = mesh.vertex_count() as u32;
288            mesh.add_vertex(a, zero);
289            mesh.add_vertex(b, zero);
290            mesh.add_vertex(c, zero);
291            mesh.indices.extend_from_slice(&[base, base + 1, base + 2]);
292        };
293
294        for indices in triangulation.indices.chunks_exact(3) {
295            let i0 = indices[0];
296            let i1 = indices[1];
297            let i2 = indices[2];
298
299            // Base cap faces away from the extruded volume.
300            push_triangle(&mut mesh, tri_world[i2], tri_world[i1], tri_world[i0]);
301            // Top cap faces in the extrusion direction.
302            push_triangle(&mut mesh, top_world[i0], top_world[i1], top_world[i2]);
303        }
304
305        let contour_top: Vec<Point3<f64>> = contour_world
306            .iter()
307            .map(|p| *p + extrusion_dir * depth)
308            .collect();
309
310        for i in 0..contour_world.len() {
311            let next = (i + 1) % contour_world.len();
312            let b0 = contour_world[i];
313            let b1 = contour_world[next];
314            let t0 = contour_top[i];
315            let t1 = contour_top[next];
316
317            push_triangle(&mut mesh, b0, b1, t1);
318            push_triangle(&mut mesh, b0, t1, t0);
319        }
320
321        calculate_normals(&mut mesh);
322        Ok(mesh)
323    }
324
325    fn build_polygonal_bounded_half_space_mesh(
326        &self,
327        half_space: &DecodedEntity,
328        decoder: &mut EntityDecoder,
329        host_mesh: &Mesh,
330        plane_normal: Vector3<f64>,
331        agreement: bool,
332    ) -> Result<Mesh> {
333        let position_attr = half_space
334            .get(2)
335            .ok_or_else(|| Error::geometry("PolygonalBoundedHalfSpace missing Position".to_string()))?;
336        let position = decoder
337            .resolve_ref(position_attr)?
338            .ok_or_else(|| Error::geometry("Failed to resolve bounded half-space Position".to_string()))?;
339        let transform = parse_axis2_placement_3d(&position, decoder)?;
340
341        let boundary_attr = half_space
342            .get(3)
343            .ok_or_else(|| Error::geometry("PolygonalBoundedHalfSpace missing PolygonalBoundary".to_string()))?;
344        let boundary = decoder
345            .resolve_ref(boundary_attr)?
346            .ok_or_else(|| Error::geometry("Failed to resolve PolygonalBoundary".to_string()))?;
347
348        let mut contour_2d = self.parse_polygonal_boundary_2d(&boundary, decoder)?;
349
350        let origin = Point3::new(transform[(0, 3)], transform[(1, 3)], transform[(2, 3)]);
351        let x_axis = Vector3::new(transform[(0, 0)], transform[(1, 0)], transform[(2, 0)]).normalize();
352        let y_axis = Vector3::new(transform[(0, 1)], transform[(1, 1)], transform[(2, 1)]).normalize();
353
354        let mut contour_world: Vec<Point3<f64>> = contour_2d
355            .iter()
356            .map(|p| origin + x_axis * p.x + y_axis * p.y)
357            .collect();
358
359        // Extrude along the side of the plane that is removed by the boolean difference.
360        let extrusion_dir = if agreement {
361            -plane_normal
362        } else {
363            plane_normal
364        }
365        .normalize();
366
367        if Self::polygon_normal(&contour_world).dot(&extrusion_dir) < 0.0 {
368            contour_2d.reverse();
369            contour_world.reverse();
370        }
371
372        let (host_min, host_max) = host_mesh.bounds();
373        let host_corners = [
374            Point3::new(host_min.x as f64, host_min.y as f64, host_min.z as f64),
375            Point3::new(host_max.x as f64, host_min.y as f64, host_min.z as f64),
376            Point3::new(host_min.x as f64, host_max.y as f64, host_min.z as f64),
377            Point3::new(host_max.x as f64, host_max.y as f64, host_min.z as f64),
378            Point3::new(host_min.x as f64, host_min.y as f64, host_max.z as f64),
379            Point3::new(host_max.x as f64, host_min.y as f64, host_max.z as f64),
380            Point3::new(host_min.x as f64, host_max.y as f64, host_max.z as f64),
381            Point3::new(host_max.x as f64, host_max.y as f64, host_max.z as f64),
382        ];
383        let host_diag = ((host_max.x - host_min.x) as f64).hypot((host_max.y - host_min.y) as f64)
384            .hypot((host_max.z - host_min.z) as f64);
385        let max_projection = host_corners
386            .iter()
387            .map(|corner| (corner - origin).dot(&extrusion_dir))
388            .fold(0.0_f64, f64::max);
389        let depth = max_projection.max(host_diag) + 1.0;
390
391        self.build_prism_mesh(&contour_2d, origin, x_axis, y_axis, extrusion_dir, depth)
392    }
393
394    /// Internal processing with depth tracking to prevent stack overflow
395    fn process_with_depth(
396        &self,
397        entity: &DecodedEntity,
398        decoder: &mut EntityDecoder,
399        _schema: &IfcSchema,
400        depth: u32,
401    ) -> Result<Mesh> {
402        // Depth limit to prevent stack overflow from deeply nested boolean chains
403        if depth > MAX_BOOLEAN_DEPTH {
404            return Err(Error::geometry(format!(
405                "Boolean nesting depth {} exceeds limit {}",
406                depth, MAX_BOOLEAN_DEPTH
407            )));
408        }
409
410        // IfcBooleanResult attributes:
411        // 0: Operator (.DIFFERENCE., .UNION., .INTERSECTION.)
412        // 1: FirstOperand (base geometry)
413        // 2: SecondOperand (clipping geometry)
414
415        // Get operator
416        let operator = entity
417            .get(0)
418            .and_then(|v| match v {
419                ifc_lite_core::AttributeValue::Enum(e) => Some(e.as_str()),
420                _ => None,
421            })
422            .unwrap_or(".DIFFERENCE.");
423
424        // Get first operand (base geometry)
425        let first_operand_attr = entity
426            .get(1)
427            .ok_or_else(|| Error::geometry("BooleanResult missing FirstOperand".to_string()))?;
428
429        let first_operand = decoder
430            .resolve_ref(first_operand_attr)?
431            .ok_or_else(|| Error::geometry("Failed to resolve FirstOperand".to_string()))?;
432
433        // Process first operand to get base mesh
434        let mesh = self.process_operand_with_depth(&first_operand, decoder, depth)?;
435
436        if mesh.is_empty() {
437            return Ok(mesh);
438        }
439
440        // Get second operand
441        let second_operand_attr = entity
442            .get(2)
443            .ok_or_else(|| Error::geometry("BooleanResult missing SecondOperand".to_string()))?;
444
445        let second_operand = decoder
446            .resolve_ref(second_operand_attr)?
447            .ok_or_else(|| Error::geometry("Failed to resolve SecondOperand".to_string()))?;
448
449        // Handle DIFFERENCE operation
450        // Note: Parser may strip dots from enum values, so check both forms
451        if operator == ".DIFFERENCE." || operator == "DIFFERENCE" {
452            // Check if second operand is a half-space solid (simple or polygonally bounded)
453            if second_operand.ifc_type == IfcType::IfcHalfSpaceSolid {
454                // Simple half-space: use plane clipping
455                let (plane_point, plane_normal, agreement) =
456                    self.parse_half_space_solid(&second_operand, decoder)?;
457                return self.clip_mesh_with_half_space(&mesh, plane_point, plane_normal, agreement);
458            }
459
460            if second_operand.ifc_type == IfcType::IfcPolygonalBoundedHalfSpace {
461                let (plane_point, plane_normal, agreement) =
462                    self.parse_half_space_solid(&second_operand, decoder)?;
463                if let Ok(bound_mesh) = self.build_polygonal_bounded_half_space_mesh(
464                    &second_operand,
465                    decoder,
466                    &mesh,
467                    plane_normal,
468                    agreement,
469                ) {
470                    let clipper = ClippingProcessor::new();
471                    if let Ok(clipped) = clipper.subtract_mesh(&mesh, &bound_mesh) {
472                        return Ok(clipped);
473                    }
474                }
475
476                return self.clip_mesh_with_half_space(&mesh, plane_point, plane_normal, agreement);
477            }
478
479            // Solid-solid difference: return base geometry (first operand).
480            //
481            // The csgrs BSP tree can infinite-recurse on arbitrary solid combinations,
482            // causing unrecoverable stack overflow in WASM. Unlike half-space clipping
483            // (handled above), solid-solid CSG cannot be safely bounded.
484            //
485            // Opening subtraction (windows/doors from walls) is handled separately by
486            // the router via subtract_mesh, which works on controlled geometry. Here we
487            // only encounter IfcBooleanResult chains from CAD exports (Tekla, Revit)
488            // where the visual difference from skipping the boolean is negligible.
489            return Ok(mesh);
490        }
491
492        // Handle UNION operation
493        if operator == ".UNION." || operator == "UNION" {
494            // Merge both meshes (combines geometry without CSG intersection removal)
495            let second_mesh = self.process_operand_with_depth(&second_operand, decoder, depth)?;
496            if !second_mesh.is_empty() {
497                let mut merged = mesh;
498                merged.merge(&second_mesh);
499                return Ok(merged);
500            }
501            return Ok(mesh);
502        }
503
504        // Handle INTERSECTION operation
505        if operator == ".INTERSECTION." || operator == "INTERSECTION" {
506            // Return empty mesh - we can't safely compute the intersection due to
507            // csgrs BSP recursion, and returning the first operand would over-approximate
508            return Ok(Mesh::new());
509        }
510
511        // Unknown operator - return first operand
512        #[cfg(debug_assertions)]
513        eprintln!("[WARN] Unknown CSG operator {}, returning first operand", operator);
514        Ok(mesh)
515    }
516}
517
518impl GeometryProcessor for BooleanClippingProcessor {
519    fn process(
520        &self,
521        entity: &DecodedEntity,
522        decoder: &mut EntityDecoder,
523        schema: &IfcSchema,
524    ) -> Result<Mesh> {
525        self.process_with_depth(entity, decoder, schema, 0)
526    }
527
528    fn supported_types(&self) -> Vec<IfcType> {
529        vec![IfcType::IfcBooleanResult, IfcType::IfcBooleanClippingResult]
530    }
531}
532
533impl Default for BooleanClippingProcessor {
534    fn default() -> Self {
535        Self::new()
536    }
537}