Skip to main content

ifc_lite_geometry/router/
voids.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//! Void (opening) subtraction: 3D CSG, AABB clipping, and triangle-box intersection.
6
7use super::GeometryRouter;
8use crate::csg::{ClipResult, ClippingProcessor, Plane, Triangle, TriangleVec};
9use crate::{Error, Mesh, Point3, Result, Vector3};
10use ifc_lite_core::{DecodedEntity, EntityDecoder, IfcType};
11use nalgebra::Matrix4;
12use rustc_hash::FxHashMap;
13
14/// Reusable buffers for triangle clipping operations
15///
16/// This struct eliminates per-triangle allocations in clip_triangle_against_box
17/// by reusing Vec buffers across multiple clipping operations.
18struct ClipBuffers {
19    /// Triangles to output (outside the box)
20    result: TriangleVec,
21    /// Triangles remaining to be processed
22    remaining: TriangleVec,
23    /// Next iteration's remaining triangles (swap buffer)
24    next_remaining: TriangleVec,
25}
26
27impl ClipBuffers {
28    /// Create new empty buffers
29    fn new() -> Self {
30        Self {
31            result: TriangleVec::new(),
32            remaining: TriangleVec::new(),
33            next_remaining: TriangleVec::new(),
34        }
35    }
36
37    /// Clear all buffers for reuse
38    #[inline]
39    fn clear(&mut self) {
40        self.result.clear();
41        self.remaining.clear();
42        self.next_remaining.clear();
43    }
44}
45
46impl GeometryRouter {
47    /// Get individual bounding boxes for each representation item in an opening element.
48    /// This handles disconnected geometry (e.g., two separate window openings in one IfcOpeningElement)
49    /// by returning separate bounds for each item instead of one combined bounding box.
50
51    /// Extract extrusion direction and position transform from IfcExtrudedAreaSolid
52    /// Returns (local_direction, position_transform)
53    fn extract_extrusion_direction_from_solid(
54        &self,
55        solid: &DecodedEntity,
56        decoder: &mut EntityDecoder,
57    ) -> Option<(Vector3<f64>, Option<Matrix4<f64>>)> {
58        // Get ExtrudedDirection (attribute 2: IfcDirection)
59        let direction_attr = solid.get(2)?;
60        let direction_entity = decoder.resolve_ref(direction_attr).ok()??;
61        let local_dir = self.parse_direction(&direction_entity).ok()?;
62
63        // Get Position transform (attribute 1: IfcAxis2Placement3D)
64        let position_transform = if let Some(pos_attr) = solid.get(1) {
65            if !pos_attr.is_null() {
66                if let Ok(Some(pos_entity)) = decoder.resolve_ref(pos_attr) {
67                    if pos_entity.ifc_type == IfcType::IfcAxis2Placement3D {
68                        self.parse_axis2_placement_3d(&pos_entity, decoder).ok()
69                    } else {
70                        None
71                    }
72                } else {
73                    None
74                }
75            } else {
76                None
77            }
78        } else {
79            None
80        };
81
82        Some((local_dir, position_transform))
83    }
84
85    /// Recursively extract extrusion direction and position transform from representation item
86    /// Handles IfcExtrudedAreaSolid, IfcBooleanClippingResult, and IfcMappedItem
87    /// Returns (local_direction, position_transform) where direction is in local space
88    fn extract_extrusion_direction_recursive(
89        &self,
90        item: &DecodedEntity,
91        decoder: &mut EntityDecoder,
92    ) -> Option<(Vector3<f64>, Option<Matrix4<f64>>)> {
93        match item.ifc_type {
94            IfcType::IfcExtrudedAreaSolid => {
95                // Direct extraction from ExtrudedDirection (attribute 2) and Position (attribute 1)
96                self.extract_extrusion_direction_from_solid(item, decoder)
97            }
98            IfcType::IfcBooleanClippingResult | IfcType::IfcBooleanResult => {
99                // FirstOperand (attribute 1) contains base geometry
100                let first_attr = item.get(1)?;
101                let first_operand = decoder.resolve_ref(first_attr).ok()??;
102                self.extract_extrusion_direction_recursive(&first_operand, decoder)
103            }
104            IfcType::IfcMappedItem => {
105                // MappingSource (attribute 0) -> MappedRepresentation -> Items
106                let source_attr = item.get(0)?;
107                let source = decoder.resolve_ref(source_attr).ok()??;
108                // RepresentationMap.MappedRepresentation is attribute 1
109                let rep_attr = source.get(1)?;
110                let rep = decoder.resolve_ref(rep_attr).ok()??;
111
112                // MappingTarget (attribute 1) -> instance transform
113                let mapping_transform = if let Some(target_attr) = item.get(1) {
114                    if !target_attr.is_null() {
115                        if let Ok(Some(target)) = decoder.resolve_ref(target_attr) {
116                            self.parse_cartesian_transformation_operator(&target, decoder).ok()
117                        } else {
118                            None
119                        }
120                    } else {
121                        None
122                    }
123                } else {
124                    None
125                };
126
127                // Get first item from representation
128                let items_attr = rep.get(3)?;
129                let items = decoder.resolve_ref_list(items_attr).ok()?;
130                items.first().and_then(|first| {
131                    self.extract_extrusion_direction_recursive(first, decoder).map(|(dir, pos)| {
132                        // Combine MappingTarget transform with position transform
133                        let combined = match (mapping_transform.as_ref(), pos) {
134                            (Some(map), Some(pos)) => Some(map * pos),
135                            (Some(map), None) => Some(map.clone()),
136                            (None, Some(pos)) => Some(pos),
137                            (None, None) => None,
138                        };
139                        (dir, combined)
140                    })
141                })
142            }
143            _ => None,
144        }
145    }
146
147    /// Get opening item bounds with extrusion direction for each representation item
148    /// Returns Vec of (min, max, extrusion_direction) tuples
149    /// Extrusion direction is in world coordinates, normalized
150    /// Returns None for extrusion direction if it cannot be extracted (fallback to bounds-only)
151    fn get_opening_item_bounds_with_direction(
152        &self,
153        element: &DecodedEntity,
154        decoder: &mut EntityDecoder,
155    ) -> Result<Vec<(Point3<f64>, Point3<f64>, Option<Vector3<f64>>)>> {
156        // Get representation (attribute 6 for most building elements)
157        let representation_attr = element.get(6).ok_or_else(|| {
158            Error::geometry("Element has no representation attribute".to_string())
159        })?;
160
161        if representation_attr.is_null() {
162            return Ok(vec![]);
163        }
164
165        let representation = decoder
166            .resolve_ref(representation_attr)?
167            .ok_or_else(|| Error::geometry("Failed to resolve representation".to_string()))?;
168
169        // Get representations list
170        let representations_attr = representation.get(2).ok_or_else(|| {
171            Error::geometry("ProductDefinitionShape missing Representations".to_string())
172        })?;
173
174        let representations = decoder.resolve_ref_list(representations_attr)?;
175
176        // Get placement transform
177        let mut placement_transform = self.get_placement_transform_from_element(element, decoder)
178            .unwrap_or_else(|_| Matrix4::identity());
179        self.scale_transform(&mut placement_transform);
180
181        let mut bounds_list = Vec::new();
182
183        for shape_rep in representations {
184            if shape_rep.ifc_type != IfcType::IfcShapeRepresentation {
185                continue;
186            }
187
188            // Check representation type
189            if let Some(rep_type_attr) = shape_rep.get(2) {
190                if let Some(rep_type) = rep_type_attr.as_string() {
191                    if !matches!(rep_type, "Body" | "SweptSolid" | "Brep" | "CSG" | "Clipping" | "Tessellation") {
192                        continue;
193                    }
194                }
195            }
196
197            // Get items list
198            let items_attr = match shape_rep.get(3) {
199                Some(attr) => attr,
200                None => continue,
201            };
202
203            let items = match decoder.resolve_ref_list(items_attr) {
204                Ok(items) => items,
205                Err(_) => continue,
206            };
207
208            // Process each item separately to get individual bounds
209            for item in items {
210                // Try to extract extrusion direction recursively (handles wrappers)
211                let extrusion_direction = if let Some((local_dir, position_transform)) =
212                    self.extract_extrusion_direction_recursive(&item, decoder)
213                {
214                    // Transform extrusion direction from local to world coordinates
215                    if let Some(pos_transform) = position_transform {
216                        // Extract rotation matrix (3x3 upper-left of 4x4 transform)
217                        let rot_x = Vector3::new(
218                            pos_transform[(0, 0)],
219                            pos_transform[(1, 0)],
220                            pos_transform[(2, 0)],
221                        );
222                        let rot_y = Vector3::new(
223                            pos_transform[(0, 1)],
224                            pos_transform[(1, 1)],
225                            pos_transform[(2, 1)],
226                        );
227                        let rot_z = Vector3::new(
228                            pos_transform[(0, 2)],
229                            pos_transform[(1, 2)],
230                            pos_transform[(2, 2)],
231                        );
232
233                        // Transform local direction to world space
234                        // Use try_normalize to guard against zero-length vectors
235                        let world_dir = (rot_x * local_dir.x
236                            + rot_y * local_dir.y
237                            + rot_z * local_dir.z)
238                            .try_normalize(1e-12)
239                            .ok_or_else(|| Error::geometry("Zero-length direction vector".to_string()))?;
240
241                        // Apply element placement transform
242                        let element_rot_x = Vector3::new(
243                            placement_transform[(0, 0)],
244                            placement_transform[(1, 0)],
245                            placement_transform[(2, 0)],
246                        );
247                        let element_rot_y = Vector3::new(
248                            placement_transform[(0, 1)],
249                            placement_transform[(1, 1)],
250                            placement_transform[(2, 1)],
251                        );
252                        let element_rot_z = Vector3::new(
253                            placement_transform[(0, 2)],
254                            placement_transform[(1, 2)],
255                            placement_transform[(2, 2)],
256                        );
257
258                        let final_dir = (element_rot_x * world_dir.x
259                            + element_rot_y * world_dir.y
260                            + element_rot_z * world_dir.z)
261                            .try_normalize(1e-12)
262                            .ok_or_else(|| Error::geometry("Zero-length direction vector".to_string()))?;
263
264                        Some(final_dir)
265                    } else {
266                        // No position transform, use local direction directly
267                        // Still need to apply element placement
268                        let element_rot_x = Vector3::new(
269                            placement_transform[(0, 0)],
270                            placement_transform[(1, 0)],
271                            placement_transform[(2, 0)],
272                        );
273                        let element_rot_y = Vector3::new(
274                            placement_transform[(0, 1)],
275                            placement_transform[(1, 1)],
276                            placement_transform[(2, 1)],
277                        );
278                        let element_rot_z = Vector3::new(
279                            placement_transform[(0, 2)],
280                            placement_transform[(1, 2)],
281                            placement_transform[(2, 2)],
282                        );
283
284                        let final_dir = (element_rot_x * local_dir.x
285                            + element_rot_y * local_dir.y
286                            + element_rot_z * local_dir.z)
287                            .try_normalize(1e-12)
288                            .ok_or_else(|| Error::geometry("Zero-length direction vector".to_string()))?;
289
290                        Some(final_dir)
291                    }
292                } else {
293                    None
294                };
295
296                // Get mesh bounds (same as original function)
297                let mesh = match self.process_representation_item(&item, decoder) {
298                    Ok(m) if !m.is_empty() => m,
299                    _ => continue,
300                };
301
302                // Get bounds and transform to world coordinates
303                let (mesh_min, mesh_max) = mesh.bounds();
304
305                // Transform corner points to world coordinates
306                let corners = [
307                    Point3::new(mesh_min.x as f64, mesh_min.y as f64, mesh_min.z as f64),
308                    Point3::new(mesh_max.x as f64, mesh_min.y as f64, mesh_min.z as f64),
309                    Point3::new(mesh_min.x as f64, mesh_max.y as f64, mesh_min.z as f64),
310                    Point3::new(mesh_max.x as f64, mesh_max.y as f64, mesh_min.z as f64),
311                    Point3::new(mesh_min.x as f64, mesh_min.y as f64, mesh_max.z as f64),
312                    Point3::new(mesh_max.x as f64, mesh_min.y as f64, mesh_max.z as f64),
313                    Point3::new(mesh_min.x as f64, mesh_max.y as f64, mesh_max.z as f64),
314                    Point3::new(mesh_max.x as f64, mesh_max.y as f64, mesh_max.z as f64),
315                ];
316
317                // Transform all corners and compute new AABB
318                let transformed: Vec<Point3<f64>> = corners.iter()
319                    .map(|p| placement_transform.transform_point(p))
320                    .collect();
321
322                let world_min = Point3::new(
323                    transformed.iter().map(|p| p.x).fold(f64::INFINITY, f64::min),
324                    transformed.iter().map(|p| p.y).fold(f64::INFINITY, f64::min),
325                    transformed.iter().map(|p| p.z).fold(f64::INFINITY, f64::min),
326                );
327                let world_max = Point3::new(
328                    transformed.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max),
329                    transformed.iter().map(|p| p.y).fold(f64::NEG_INFINITY, f64::max),
330                    transformed.iter().map(|p| p.z).fold(f64::NEG_INFINITY, f64::max),
331                );
332
333                // Apply RTC offset to opening bounds so they match wall mesh coordinate system
334                // Wall mesh positions have RTC subtracted during transform_mesh, so opening bounds must match
335                let rtc = self.rtc_offset;
336                let rtc_min = Point3::new(
337                    world_min.x - rtc.0,
338                    world_min.y - rtc.1,
339                    world_min.z - rtc.2,
340                );
341                let rtc_max = Point3::new(
342                    world_max.x - rtc.0,
343                    world_max.y - rtc.1,
344                    world_max.z - rtc.2,
345                );
346
347                bounds_list.push((rtc_min, rtc_max, extrusion_direction));
348            }
349        }
350
351        Ok(bounds_list)
352    }
353
354    /// Process element with void subtraction (openings)
355    /// Process element with voids using optimized plane clipping
356    ///
357    /// This approach is more efficient than full 3D CSG for rectangular openings:
358    /// 1. Get chamfered wall mesh (preserves chamfered corners)
359    /// 2. For each opening, use optimized box cutting with internal face generation
360    /// 3. Apply any clipping operations (roof clips) from original representation
361    #[inline]
362    /// Process an element with void subtraction (openings).
363    ///
364    /// This function handles three distinct cases for cutting openings:
365    ///
366    /// 1. **Floor/Slab openings** (vertical Z-extrusion): Uses CSG with actual mesh geometry
367    ///    because the XY footprint may be rotated relative to the slab orientation.
368    ///
369    /// 2. **Wall openings** (horizontal X/Y-extrusion, axis-aligned): Uses AABB clipping
370    ///    for fast, accurate cutting of rectangular openings.
371    ///
372    /// 3. **Diagonal wall openings**: Uses AABB clipping without internal face generation
373    ///    to avoid rotation artifacts.
374    ///
375    /// **Important**: Internal face generation is disabled for all openings because it causes
376    /// visual artifacts (rotated faces, thin lines extending from models). The opening cutouts
377    /// are still geometrically correct - only the internal "reveal" faces are omitted.
378    pub fn process_element_with_voids(
379        &self,
380        element: &DecodedEntity,
381        decoder: &mut EntityDecoder,
382        void_index: &FxHashMap<u32, Vec<u32>>,
383    ) -> Result<Mesh> {
384        // Check if this element has any openings
385        let opening_ids = match void_index.get(&element.id) {
386            Some(ids) if !ids.is_empty() => ids,
387            _ => {
388                // No openings - just process normally
389                return self.process_element(element, decoder);
390            }
391        };
392
393        // SAFETY: Skip void subtraction for elements with too many openings
394        // This prevents CSG operations from causing panics or excessive processing time
395        // Elements with many openings (like curtain walls) are better handled without CSG
396        const MAX_OPENINGS: usize = 15;
397        if opening_ids.len() > MAX_OPENINGS {
398            // Just return the base mesh without void subtraction
399            return self.process_element(element, decoder);
400        }
401
402        // STEP 1: Get chamfered wall mesh (preserves chamfered corners at intersections)
403        let wall_mesh = match self.process_element(element, decoder) {
404            Ok(m) => m,
405            Err(_) => {
406                return self.process_element(element, decoder);
407            }
408        };
409
410        // OPTIMIZATION: Only extract clipping planes if element actually has them
411        // This skips expensive profile extraction for ~95% of elements
412        use nalgebra::Vector3;
413        let world_clipping_planes: Vec<(Point3<f64>, Vector3<f64>, bool)> =
414            if self.has_clipping_planes(element, decoder) {
415                // Get element's ObjectPlacement transform (for clipping planes)
416                let mut object_placement_transform = match self.get_placement_transform_from_element(element, decoder) {
417                    Ok(t) => t,
418                    Err(_) => Matrix4::identity(),
419                };
420                self.scale_transform(&mut object_placement_transform);
421
422                // Extract clipping planes (for roof clips)
423                let clipping_planes = match self.extract_base_profile_and_clips(element, decoder) {
424                    Ok((_profile, _depth, _axis, _origin, _transform, clips)) => clips,
425                    Err(_) => Vec::new(),
426                };
427
428                // Transform clipping planes to world coordinates
429                clipping_planes
430                    .iter()
431                    .map(|(point, normal, agreement)| {
432                        let world_point = object_placement_transform.transform_point(point);
433                        let rotation = object_placement_transform.fixed_view::<3, 3>(0, 0);
434                        let world_normal = (rotation * normal).normalize();
435                        (world_point, world_normal, *agreement)
436                    })
437                    .collect()
438            } else {
439                Vec::new()
440            };
441
442        // STEP 5: Collect opening info (bounds for rectangular, full mesh for non-rectangular)
443        // For rectangular openings, get individual bounds per representation item to handle
444        // disconnected geometry (e.g., two separate window openings in one IfcOpeningElement)
445        enum OpeningType {
446            /// Rectangular opening with AABB clipping
447            /// Fields: (min_bounds, max_bounds, extrusion_direction, is_diagonal)
448            Rectangular(Point3<f64>, Point3<f64>, Option<Vector3<f64>>, bool),
449            /// Non-rectangular opening (circular, arched, or floor openings with rotated footprint)
450            /// Uses full CSG subtraction with actual mesh geometry
451            NonRectangular(Mesh),
452        }
453
454        let mut openings: Vec<OpeningType> = Vec::new();
455        for &opening_id in opening_ids.iter() {
456            let opening_entity = match decoder.decode_by_id(opening_id) {
457                Ok(e) => e,
458                Err(_) => continue,
459            };
460
461            let opening_mesh = match self.process_element(&opening_entity, decoder) {
462                Ok(m) if !m.is_empty() => m,
463                _ => continue,
464            };
465
466            let vertex_count = opening_mesh.positions.len() / 3;
467
468            if vertex_count > 100 {
469                // Non-rectangular (circular, arched, etc.) - use full CSG
470                openings.push(OpeningType::NonRectangular(opening_mesh));
471            } else {
472                // Rectangular - get individual bounds with extrusion direction for each representation item
473                // This handles disconnected geometry (multiple boxes with gaps between them)
474                let item_bounds_with_dir = self.get_opening_item_bounds_with_direction(&opening_entity, decoder)
475                    .unwrap_or_default();
476
477                if !item_bounds_with_dir.is_empty() {
478                    // Check if this is a floor/slab opening (vertical Z-extrusion)
479                    // Floor openings may have rotated XY footprints that AABB clipping can't handle correctly.
480                    // Example: A rectangular opening in a diagonal slab - the opening's rectangle in XY
481                    // is rotated relative to the world axes, so AABB clipping creates a diamond-shaped cutout.
482                    let is_floor_opening = item_bounds_with_dir.iter().any(|(_, _, dir)| {
483                        dir.map(|d| d.z.abs() > 0.95).unwrap_or(false)
484                    });
485
486                    // For floor openings, use CSG with actual mesh geometry to handle rotated footprints
487                    if is_floor_opening && vertex_count > 0 {
488                        openings.push(OpeningType::NonRectangular(opening_mesh.clone()));
489                    } else {
490                        // Use AABB clipping for wall openings (X/Y extrusion)
491                        // Mark diagonal ones so we skip internal face generation (which causes artifacts)
492                        for (min_pt, max_pt, extrusion_dir) in item_bounds_with_dir {
493                            // Check if extrusion direction is diagonal (not axis-aligned)
494                            let is_diagonal = extrusion_dir.map(|dir| {
495                                const AXIS_THRESHOLD: f64 = 0.95;
496                                let abs_x = dir.x.abs();
497                                let abs_y = dir.y.abs();
498                                let abs_z = dir.z.abs();
499                                // Diagonal if no single component dominates (>95% of magnitude)
500                                !(abs_x > AXIS_THRESHOLD || abs_y > AXIS_THRESHOLD || abs_z > AXIS_THRESHOLD)
501                            }).unwrap_or(false);
502
503                            openings.push(OpeningType::Rectangular(min_pt, max_pt, extrusion_dir, is_diagonal));
504                        }
505                    }
506                } else {
507                    // Fallback to combined mesh bounds when individual bounds unavailable
508                    let (open_min, open_max) = opening_mesh.bounds();
509                    let min_f64 = Point3::new(open_min.x as f64, open_min.y as f64, open_min.z as f64);
510                    let max_f64 = Point3::new(open_max.x as f64, open_max.y as f64, open_max.z as f64);
511
512                    openings.push(OpeningType::Rectangular(min_f64, max_f64, None, false));
513                }
514            }
515        }
516
517        if openings.is_empty() {
518            return self.process_element(element, decoder);
519        }
520
521        // STEP 6: Cut openings using appropriate method
522        use crate::csg::ClippingProcessor;
523        let clipper = ClippingProcessor::new();
524        let mut result = wall_mesh;
525
526        // Get wall bounds for clamping opening faces (from result before cutting)
527        let (wall_min_f32, wall_max_f32) = result.bounds();
528        let wall_min = Point3::new(
529            wall_min_f32.x as f64,
530            wall_min_f32.y as f64,
531            wall_min_f32.z as f64,
532        );
533        let wall_max = Point3::new(
534            wall_max_f32.x as f64,
535            wall_max_f32.y as f64,
536            wall_max_f32.z as f64,
537        );
538
539        // Validate wall mesh ONCE before CSG operations (not per-iteration)
540        // This avoids O(n) validation on every loop iteration
541        let wall_valid = !result.is_empty()
542            && result.positions.iter().all(|&v| v.is_finite())
543            && result.triangle_count() >= 4;
544
545        if !wall_valid {
546            // Wall mesh is invalid, return as-is
547            return Ok(result);
548        }
549
550        // Track CSG operations to prevent excessive complexity
551        let mut csg_operation_count = 0;
552        const MAX_CSG_OPERATIONS: usize = 10; // Limit to prevent runaway CSG
553
554        for opening in openings.iter() {
555            match opening {
556                OpeningType::Rectangular(open_min, open_max, extrusion_dir, is_diagonal) => {
557                    // Use AABB clipping for all rectangular openings
558                    let (final_min, final_max) = if let Some(dir) = extrusion_dir {
559                        // Extend along the actual extrusion direction to penetrate multi-layer walls
560                        self.extend_opening_along_direction(*open_min, *open_max, wall_min, wall_max, *dir)
561                    } else {
562                        // Fallback: use opening bounds as-is (no direction available)
563                        (*open_min, *open_max)
564                    };
565
566                    if *is_diagonal {
567                        // For diagonal openings, use AABB clipping WITHOUT internal faces
568                        // Internal faces for diagonal openings cause rotation artifacts
569                        result = self.cut_rectangular_opening_no_faces(&result, final_min, final_max);
570                    } else {
571                        // For axis-aligned openings, use AABB clipping (no internal faces)
572                        // Internal face generation is disabled for all openings because it causes
573                        // visual artifacts (rotated faces, thin lines). The opening cutout is still
574                        // geometrically correct - only the internal "reveal" faces are omitted.
575                        result = self.cut_rectangular_opening(&result, final_min, final_max, wall_min, wall_max);
576                    }
577                }
578                OpeningType::NonRectangular(opening_mesh) => {
579                    // Safety: limit total CSG operations to prevent crashes on complex geometry
580                    if csg_operation_count >= MAX_CSG_OPERATIONS {
581                        // Skip remaining CSG operations
582                        continue;
583                    }
584
585                    // Validate opening mesh before CSG (only once per opening)
586                    let opening_valid = !opening_mesh.is_empty()
587                        && opening_mesh.positions.iter().all(|&v| v.is_finite())
588                        && opening_mesh.positions.len() >= 9; // At least 3 vertices
589
590                    if !opening_valid {
591                        // Skip invalid opening
592                        continue;
593                    }
594
595                    // Use full CSG subtraction for non-rectangular shapes
596                    // Note: mesh_to_csgrs validates and filters invalid triangles internally
597                    match clipper.subtract_mesh(&result, opening_mesh) {
598                        Ok(csg_result) => {
599                            // Validate result is not degenerate
600                            if !csg_result.is_empty() && csg_result.triangle_count() >= 4 {
601                                result = csg_result;
602                            }
603                            // If result is degenerate, keep previous result
604                        }
605                        Err(_) => {
606                            // Keep original result if CSG fails
607                        }
608                    }
609                    csg_operation_count += 1;
610                }
611            }
612        }
613
614        // STEP 7: Apply clipping planes (roof clips) if any
615        if !world_clipping_planes.is_empty() {
616            use crate::csg::{ClippingProcessor, Plane};
617            let clipper = ClippingProcessor::new();
618
619            for (_clip_idx, (plane_point, plane_normal, agreement)) in world_clipping_planes.iter().enumerate() {
620                let clip_normal = if *agreement {
621                    *plane_normal
622                } else {
623                    -*plane_normal
624                };
625
626                let plane = Plane::new(*plane_point, clip_normal);
627                if let Ok(clipped) = clipper.clip_mesh(&result, &plane) {
628                    if !clipped.is_empty() {
629                        result = clipped;
630                    }
631                }
632            }
633        }
634
635        Ok(result)
636    }
637
638    /// Cut a rectangular opening from a mesh using optimized plane clipping
639    ///
640    /// This is more efficient than full CSG because:
641    /// 1. Only processes triangles that intersect the opening bounds
642    /// Extend opening bounds along extrusion direction to match wall extent
643    ///
644    /// Projects wall corners onto the extrusion axis and extends the opening
645    /// min/max to cover the wall's full extent along that direction.
646    /// This ensures openings penetrate multi-layer walls correctly without
647    /// causing artifacts for angled walls.
648    fn extend_opening_along_direction(
649        &self,
650        open_min: Point3<f64>,
651        open_max: Point3<f64>,
652        wall_min: Point3<f64>,
653        wall_max: Point3<f64>,
654        extrusion_direction: Vector3<f64>,  // World-space, normalized
655    ) -> (Point3<f64>, Point3<f64>) {
656        // Use opening center as reference point for projection
657        let open_center = Point3::new(
658            (open_min.x + open_max.x) * 0.5,
659            (open_min.y + open_max.y) * 0.5,
660            (open_min.z + open_max.z) * 0.5,
661        );
662
663        // Project all 8 corners of the wall box onto the extrusion axis
664        let wall_corners = [
665            Point3::new(wall_min.x, wall_min.y, wall_min.z),
666            Point3::new(wall_max.x, wall_min.y, wall_min.z),
667            Point3::new(wall_min.x, wall_max.y, wall_min.z),
668            Point3::new(wall_max.x, wall_max.y, wall_min.z),
669            Point3::new(wall_min.x, wall_min.y, wall_max.z),
670            Point3::new(wall_max.x, wall_min.y, wall_max.z),
671            Point3::new(wall_min.x, wall_max.y, wall_max.z),
672            Point3::new(wall_max.x, wall_max.y, wall_max.z),
673        ];
674
675        // Find min/max projections of wall corners onto extrusion axis
676        let mut wall_min_proj = f64::INFINITY;
677        let mut wall_max_proj = f64::NEG_INFINITY;
678
679        for corner in &wall_corners {
680            // Project corner onto extrusion axis relative to opening center
681            let proj = (corner - open_center).dot(&extrusion_direction);
682            wall_min_proj = wall_min_proj.min(proj);
683            wall_max_proj = wall_max_proj.max(proj);
684        }
685
686        // Project opening corners onto extrusion axis
687        let open_corners = [
688            Point3::new(open_min.x, open_min.y, open_min.z),
689            Point3::new(open_max.x, open_min.y, open_min.z),
690            Point3::new(open_min.x, open_max.y, open_min.z),
691            Point3::new(open_max.x, open_max.y, open_min.z),
692            Point3::new(open_min.x, open_min.y, open_max.z),
693            Point3::new(open_max.x, open_min.y, open_max.z),
694            Point3::new(open_min.x, open_max.y, open_max.z),
695            Point3::new(open_max.x, open_max.y, open_max.z),
696        ];
697
698        let mut open_min_proj = f64::INFINITY;
699        let mut open_max_proj = f64::NEG_INFINITY;
700
701        for corner in &open_corners {
702            let proj = (corner - open_center).dot(&extrusion_direction);
703            open_min_proj = open_min_proj.min(proj);
704            open_max_proj = open_max_proj.max(proj);
705        }
706
707        // Calculate how much to extend in each direction along the extrusion axis
708        // If wall extends beyond opening, we need to extend the opening
709        let extend_backward = (open_min_proj - wall_min_proj).max(0.0);  // How much wall extends before opening
710        let extend_forward = (wall_max_proj - open_max_proj).max(0.0);   // How much wall extends after opening
711
712        // Extend opening bounds along the extrusion direction
713        let extended_min = open_min - extrusion_direction * extend_backward;
714        let extended_max = open_max + extrusion_direction * extend_forward;
715
716        // Create new AABB that encompasses both original opening and extended points
717        // This ensures we don't shrink the opening in other dimensions
718        let all_points = [
719            open_min, open_max,
720            extended_min, extended_max,
721        ];
722
723        let new_min = Point3::new(
724            all_points.iter().map(|p| p.x).fold(f64::INFINITY, f64::min),
725            all_points.iter().map(|p| p.y).fold(f64::INFINITY, f64::min),
726            all_points.iter().map(|p| p.z).fold(f64::INFINITY, f64::min),
727        );
728        let new_max = Point3::new(
729            all_points.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max),
730            all_points.iter().map(|p| p.y).fold(f64::NEG_INFINITY, f64::max),
731            all_points.iter().map(|p| p.z).fold(f64::NEG_INFINITY, f64::max),
732        );
733
734        (new_min, new_max)
735    }
736
737    /// Cut a rectangular opening from a mesh using AABB clipping.
738    ///
739    /// This method clips triangles against the opening bounding box using axis-aligned
740    /// clipping planes. Internal face generation is disabled because it causes visual
741    /// artifacts (rotated faces, thin lines extending from models).
742    pub(super) fn cut_rectangular_opening(
743        &self,
744        mesh: &Mesh,
745        open_min: Point3<f64>,
746        open_max: Point3<f64>,
747        _wall_min: Point3<f64>,
748        _wall_max: Point3<f64>,
749    ) -> Mesh {
750        // Use the same implementation as cut_rectangular_opening_no_faces
751        // Internal faces are disabled for all openings to avoid artifacts
752        self.cut_rectangular_opening_no_faces(mesh, open_min, open_max)
753    }
754
755    /// Cut a rectangular opening using AABB clipping WITHOUT generating internal faces.
756    /// Used for diagonal openings where internal face generation causes rotation artifacts.
757    fn cut_rectangular_opening_no_faces(
758        &self,
759        mesh: &Mesh,
760        open_min: Point3<f64>,
761        open_max: Point3<f64>,
762    ) -> Mesh {
763        use nalgebra::Vector3;
764
765        const EPSILON: f64 = 1e-6;
766
767        let mut result = Mesh::with_capacity(
768            mesh.positions.len() / 3,
769            mesh.indices.len() / 3,
770        );
771
772        let mut clip_buffers = ClipBuffers::new();
773
774        for chunk in mesh.indices.chunks_exact(3) {
775            let i0 = chunk[0] as usize;
776            let i1 = chunk[1] as usize;
777            let i2 = chunk[2] as usize;
778
779            let v0 = Point3::new(
780                mesh.positions[i0 * 3] as f64,
781                mesh.positions[i0 * 3 + 1] as f64,
782                mesh.positions[i0 * 3 + 2] as f64,
783            );
784            let v1 = Point3::new(
785                mesh.positions[i1 * 3] as f64,
786                mesh.positions[i1 * 3 + 1] as f64,
787                mesh.positions[i1 * 3 + 2] as f64,
788            );
789            let v2 = Point3::new(
790                mesh.positions[i2 * 3] as f64,
791                mesh.positions[i2 * 3 + 1] as f64,
792                mesh.positions[i2 * 3 + 2] as f64,
793            );
794
795            let n0 = if mesh.normals.len() >= mesh.positions.len() {
796                Vector3::new(
797                    mesh.normals[i0 * 3] as f64,
798                    mesh.normals[i0 * 3 + 1] as f64,
799                    mesh.normals[i0 * 3 + 2] as f64,
800                )
801            } else {
802                let edge1 = v1 - v0;
803                let edge2 = v2 - v0;
804                edge1.cross(&edge2).try_normalize(1e-10).unwrap_or(Vector3::new(0.0, 0.0, 1.0))
805            };
806
807            let tri_min_x = v0.x.min(v1.x).min(v2.x);
808            let tri_max_x = v0.x.max(v1.x).max(v2.x);
809            let tri_min_y = v0.y.min(v1.y).min(v2.y);
810            let tri_max_y = v0.y.max(v1.y).max(v2.y);
811            let tri_min_z = v0.z.min(v1.z).min(v2.z);
812            let tri_max_z = v0.z.max(v1.z).max(v2.z);
813
814            // If triangle is completely outside opening, keep it as-is
815            if tri_max_x <= open_min.x - EPSILON || tri_min_x >= open_max.x + EPSILON ||
816               tri_max_y <= open_min.y - EPSILON || tri_min_y >= open_max.y + EPSILON ||
817               tri_max_z <= open_min.z - EPSILON || tri_min_z >= open_max.z + EPSILON {
818                let base = result.vertex_count() as u32;
819                result.add_vertex(v0, n0);
820                result.add_vertex(v1, n0);
821                result.add_vertex(v2, n0);
822                result.add_triangle(base, base + 1, base + 2);
823                continue;
824            }
825
826            // Check if triangle is completely inside opening (remove it)
827            if tri_min_x >= open_min.x + EPSILON && tri_max_x <= open_max.x - EPSILON &&
828               tri_min_y >= open_min.y + EPSILON && tri_max_y <= open_max.y - EPSILON &&
829               tri_min_z >= open_min.z + EPSILON && tri_max_z <= open_max.z - EPSILON {
830                continue;
831            }
832
833            // Triangle may intersect opening - clip it
834            if self.triangle_intersects_box(&v0, &v1, &v2, &open_min, &open_max) {
835                self.clip_triangle_against_box(&mut result, &mut clip_buffers, &v0, &v1, &v2, &n0, &open_min, &open_max);
836            } else {
837                let base = result.vertex_count() as u32;
838                result.add_vertex(v0, n0);
839                result.add_vertex(v1, n0);
840                result.add_vertex(v2, n0);
841                result.add_triangle(base, base + 1, base + 2);
842            }
843        }
844
845        // No internal face generation for diagonal openings
846        result
847    }
848
849
850    /// Test if a triangle intersects an axis-aligned bounding box using Separating Axis Theorem (SAT)
851    /// Returns true if triangle and box intersect, false if they are separated
852    fn triangle_intersects_box(
853        &self,
854        v0: &Point3<f64>,
855        v1: &Point3<f64>,
856        v2: &Point3<f64>,
857        box_min: &Point3<f64>,
858        box_max: &Point3<f64>,
859    ) -> bool {
860        use nalgebra::Vector3;
861
862        // Box center and half-extents
863        let box_center = Point3::new(
864            (box_min.x + box_max.x) * 0.5,
865            (box_min.y + box_max.y) * 0.5,
866            (box_min.z + box_max.z) * 0.5,
867        );
868        let box_half_extents = Vector3::new(
869            (box_max.x - box_min.x) * 0.5,
870            (box_max.y - box_min.y) * 0.5,
871            (box_max.z - box_min.z) * 0.5,
872        );
873
874        // Translate triangle to box-local space
875        let t0 = v0 - box_center;
876        let t1 = v1 - box_center;
877        let t2 = v2 - box_center;
878
879        // Triangle edges
880        let e0 = t1 - t0;
881        let e1 = t2 - t1;
882        let e2 = t0 - t2;
883
884        // Test 1: Box axes (X, Y, Z)
885        // Project triangle onto each axis and check overlap
886        for axis_idx in 0..3 {
887            let axis = match axis_idx {
888                0 => Vector3::new(1.0, 0.0, 0.0),
889                1 => Vector3::new(0.0, 1.0, 0.0),
890                2 => Vector3::new(0.0, 0.0, 1.0),
891                _ => unreachable!(),
892            };
893
894            let p0 = t0.dot(&axis);
895            let p1 = t1.dot(&axis);
896            let p2 = t2.dot(&axis);
897
898            let tri_min = p0.min(p1).min(p2);
899            let tri_max = p0.max(p1).max(p2);
900            let box_extent = box_half_extents[axis_idx];
901
902            if tri_max < -box_extent || tri_min > box_extent {
903                return false; // Separated on this axis
904            }
905        }
906
907        // Test 2: Triangle face normal
908        let triangle_normal = e0.cross(&e2);
909        let triangle_offset = t0.dot(&triangle_normal);
910
911        // Project box onto triangle normal
912        let mut box_projection = 0.0;
913        for i in 0..3 {
914            let axis = match i {
915                0 => Vector3::new(1.0, 0.0, 0.0),
916                1 => Vector3::new(0.0, 1.0, 0.0),
917                2 => Vector3::new(0.0, 0.0, 1.0),
918                _ => unreachable!(),
919            };
920            box_projection += box_half_extents[i] * triangle_normal.dot(&axis).abs();
921        }
922
923        if triangle_offset.abs() > box_projection {
924            return false; // Separated by triangle plane
925        }
926
927        // Test 3: 9 cross-product axes (3 box edges x 3 triangle edges)
928        let box_axes = [
929            Vector3::new(1.0, 0.0, 0.0),
930            Vector3::new(0.0, 1.0, 0.0),
931            Vector3::new(0.0, 0.0, 1.0),
932        ];
933        let tri_edges = [e0, e1, e2];
934
935        for box_axis in &box_axes {
936            for tri_edge in &tri_edges {
937                let axis = box_axis.cross(tri_edge);
938
939                // Skip degenerate axes (parallel edges)
940                if axis.norm_squared() < 1e-10 {
941                    continue;
942                }
943
944                let axis_normalized = axis.normalize();
945
946                // Project triangle onto axis
947                let p0 = t0.dot(&axis_normalized);
948                let p1 = t1.dot(&axis_normalized);
949                let p2 = t2.dot(&axis_normalized);
950                let tri_min = p0.min(p1).min(p2);
951                let tri_max = p0.max(p1).max(p2);
952
953                // Project box onto axis
954                let mut box_projection = 0.0;
955                for i in 0..3 {
956                    let box_axis_vec = box_axes[i];
957                    box_projection += box_half_extents[i] * axis_normalized.dot(&box_axis_vec).abs();
958                }
959
960                if tri_max < -box_projection || tri_min > box_projection {
961                    return false; // Separated on this axis
962                }
963            }
964        }
965
966        // No separating axis found - triangle and box intersect
967        true
968    }
969
970    /// Clip a triangle against an opening box using clip-and-collect algorithm
971    /// Removes the part of the triangle that's inside the box
972    /// Collects "outside" parts directly to result, continues processing "inside" parts
973    ///
974    /// Uses reusable ClipBuffers to avoid per-triangle allocations (6+ Vec allocations
975    /// per intersecting triangle without buffers).
976    fn clip_triangle_against_box(
977        &self,
978        result: &mut Mesh,
979        buffers: &mut ClipBuffers,
980        v0: &Point3<f64>,
981        v1: &Point3<f64>,
982        v2: &Point3<f64>,
983        normal: &Vector3<f64>,
984        open_min: &Point3<f64>,
985        open_max: &Point3<f64>,
986    ) {
987        let clipper = ClippingProcessor::new();
988
989        // Clear buffers for reuse (retains capacity)
990        buffers.clear();
991
992        // Planes with INWARD normals (so "front" = inside box, "behind" = outside box)
993        // We clip to keep geometry OUTSIDE the box (behind these planes)
994        let planes = [
995            // +X inward: inside box where x >= open_min.x
996            Plane::new(Point3::new(open_min.x, 0.0, 0.0), Vector3::new(1.0, 0.0, 0.0)),
997            // -X inward: inside box where x <= open_max.x
998            Plane::new(Point3::new(open_max.x, 0.0, 0.0), Vector3::new(-1.0, 0.0, 0.0)),
999            // +Y inward: inside box where y >= open_min.y
1000            Plane::new(Point3::new(0.0, open_min.y, 0.0), Vector3::new(0.0, 1.0, 0.0)),
1001            // -Y inward: inside box where y <= open_max.y
1002            Plane::new(Point3::new(0.0, open_max.y, 0.0), Vector3::new(0.0, -1.0, 0.0)),
1003            // +Z inward: inside box where z >= open_min.z
1004            Plane::new(Point3::new(0.0, 0.0, open_min.z), Vector3::new(0.0, 0.0, 1.0)),
1005            // -Z inward: inside box where z <= open_max.z
1006            Plane::new(Point3::new(0.0, 0.0, open_max.z), Vector3::new(0.0, 0.0, -1.0)),
1007        ];
1008
1009        // Initialize remaining with the input triangle
1010        buffers.remaining.push(Triangle::new(*v0, *v1, *v2));
1011
1012        // Clip-and-collect: collect "outside" parts, continue processing "inside" parts
1013        for plane in &planes {
1014            buffers.next_remaining.clear();
1015            let flipped_plane = Plane::new(plane.point, -plane.normal);
1016
1017            for tri in &buffers.remaining {
1018                match clipper.clip_triangle(tri, plane) {
1019                    ClipResult::AllFront(_) => {
1020                        // Triangle is completely inside this plane - continue checking
1021                        buffers.next_remaining.push(tri.clone());
1022                    }
1023                    ClipResult::AllBehind => {
1024                        // Triangle is completely outside this plane - it's outside the box
1025                        buffers.result.push(tri.clone());
1026                    }
1027                    ClipResult::Split(inside_tris) => {
1028                        // Triangle was split - inside parts continue, get outside parts
1029                        buffers.next_remaining.extend(inside_tris);
1030
1031                        // Get the outside parts using flipped plane (behind inward = front of outward)
1032                        match clipper.clip_triangle(tri, &flipped_plane) {
1033                            ClipResult::AllFront(outside_tri) => {
1034                                // All outside - add to result
1035                                buffers.result.push(outside_tri);
1036                            }
1037                            ClipResult::Split(outside_tris) => {
1038                                // Split - these are the outside parts
1039                                buffers.result.extend(outside_tris);
1040                            }
1041                            ClipResult::AllBehind => {
1042                                // This shouldn't happen if original was split
1043                                // But handle gracefully - if it happens, inside_tris are all we have
1044                            }
1045                        }
1046                    }
1047                }
1048            }
1049
1050            // Swap buffers instead of reallocating
1051            std::mem::swap(&mut buffers.remaining, &mut buffers.next_remaining);
1052        }
1053
1054        // 'remaining' triangles are inside ALL planes = inside box = discard
1055        // Add collected result_triangles to mesh
1056        for tri in &buffers.result {
1057            let base = result.vertex_count() as u32;
1058            result.add_vertex(tri.v0, *normal);
1059            result.add_vertex(tri.v1, *normal);
1060            result.add_vertex(tri.v2, *normal);
1061            result.add_triangle(base, base + 1, base + 2);
1062        }
1063    }
1064
1065}