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::{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/// Epsilon for normalizing direction vectors (guards against zero-length).
15const NORMALIZE_EPSILON: f64 = 1e-12;
16/// Minimum opening volume (m³) below which CSG is skipped to avoid BSP instability.
17/// 0.0001 m³ ≈ 0.1 litre — filters artefacts while allowing small real openings (e.g. sleeves).
18const MIN_OPENING_VOLUME: f64 = 0.0001;
19/// Fraction of pre-CSG triangles the result must retain. CSG outputs with fewer
20/// triangles than `pre_count / CSG_TRIANGLE_RETENTION_DIVISOR` are rejected as
21/// BSP blowups.
22const CSG_TRIANGLE_RETENTION_DIVISOR: usize = 4;
23/// Minimum triangle count for a valid CSG result.
24const MIN_VALID_TRIANGLES: usize = 4;
25
26/// Extract rotation columns from a 4x4 transform matrix.
27fn extract_rotation_columns(m: &Matrix4<f64>) -> (Vector3<f64>, Vector3<f64>, Vector3<f64>) {
28    (
29        Vector3::new(m[(0, 0)], m[(1, 0)], m[(2, 0)]),
30        Vector3::new(m[(0, 1)], m[(1, 1)], m[(2, 1)]),
31        Vector3::new(m[(0, 2)], m[(1, 2)], m[(2, 2)]),
32    )
33}
34
35/// Apply rotation from columns to a direction and normalize.
36fn rotate_and_normalize(
37    rot: &(Vector3<f64>, Vector3<f64>, Vector3<f64>),
38    dir: &Vector3<f64>,
39) -> Result<Vector3<f64>> {
40    (rot.0 * dir.x + rot.1 * dir.y + rot.2 * dir.z)
41        .try_normalize(NORMALIZE_EPSILON)
42        .ok_or_else(|| Error::geometry("Zero-length direction vector".to_string()))
43}
44
45/// Whether the representation type is geometry we can process.
46fn is_body_representation(rep_type: &str) -> bool {
47    matches!(
48        rep_type,
49        "Body" | "SweptSolid" | "Brep" | "CSG" | "Clipping" | "Tessellation"
50            | "MappedRepresentation" | "SolidModel" | "SurfaceModel"
51            | "AdvancedSweptSolid" | "AdvancedBrep"
52    )
53}
54
55/// Classification of an opening for void subtraction.
56enum OpeningType {
57    /// Rectangular opening with AABB clipping
58    /// Fields: (min_bounds, max_bounds, extrusion_direction)
59    Rectangular(Point3<f64>, Point3<f64>, Option<Vector3<f64>>),
60    /// Diagonal rectangular opening with mesh geometry for batched rotation clipping
61    /// Fields: (opening_mesh, extrusion_direction)
62    DiagonalRectangular(Mesh, Vector3<f64>),
63    /// Non-rectangular opening (circular, arched, or floor openings with rotated footprint)
64    /// Uses full CSG subtraction with actual mesh geometry
65    NonRectangular(Mesh),
66}
67
68/// Reusable buffers for triangle clipping operations
69///
70/// This struct eliminates per-triangle allocations in clip_triangle_against_box
71/// by reusing Vec buffers across multiple clipping operations.
72struct ClipBuffers {
73    /// Triangles to output (outside the box)
74    result: TriangleVec,
75    /// Triangles remaining to be processed
76    remaining: TriangleVec,
77    /// Next iteration's remaining triangles (swap buffer)
78    next_remaining: TriangleVec,
79}
80
81impl ClipBuffers {
82    /// Create new empty buffers
83    fn new() -> Self {
84        Self {
85            result: TriangleVec::new(),
86            remaining: TriangleVec::new(),
87            next_remaining: TriangleVec::new(),
88        }
89    }
90
91    /// Clear all buffers for reuse
92    #[inline]
93    fn clear(&mut self) {
94        self.result.clear();
95        self.remaining.clear();
96        self.next_remaining.clear();
97    }
98}
99
100impl GeometryRouter {
101    /// Get individual bounding boxes for each representation item in an opening element.
102    /// This handles disconnected geometry (e.g., two separate window openings in one IfcOpeningElement)
103    /// by returning separate bounds for each item instead of one combined bounding box.
104
105    /// Extract extrusion direction and position transform from IfcExtrudedAreaSolid
106    /// Returns (local_direction, position_transform)
107    fn extract_extrusion_direction_from_solid(
108        &self,
109        solid: &DecodedEntity,
110        decoder: &mut EntityDecoder,
111    ) -> Option<(Vector3<f64>, Option<Matrix4<f64>>)> {
112        // Get ExtrudedDirection (attribute 2: IfcDirection)
113        let direction_attr = solid.get(2)?;
114        let direction_entity = decoder.resolve_ref(direction_attr).ok()??;
115        let local_dir = self.parse_direction(&direction_entity).ok()?;
116
117        // Get Position transform (attribute 1: IfcAxis2Placement3D)
118        let position_transform = if let Some(pos_attr) = solid.get(1) {
119            if !pos_attr.is_null() {
120                if let Ok(Some(pos_entity)) = decoder.resolve_ref(pos_attr) {
121                    if pos_entity.ifc_type == IfcType::IfcAxis2Placement3D {
122                        self.parse_axis2_placement_3d(&pos_entity, decoder).ok()
123                    } else {
124                        None
125                    }
126                } else {
127                    None
128                }
129            } else {
130                None
131            }
132        } else {
133            None
134        };
135
136        Some((local_dir, position_transform))
137    }
138
139    /// Recursively extract extrusion direction and position transform from representation item
140    /// Handles IfcExtrudedAreaSolid, IfcBooleanClippingResult, and IfcMappedItem
141    /// Returns (local_direction, position_transform) where direction is in local space
142    fn extract_extrusion_direction_recursive(
143        &self,
144        item: &DecodedEntity,
145        decoder: &mut EntityDecoder,
146    ) -> Option<(Vector3<f64>, Option<Matrix4<f64>>)> {
147        match item.ifc_type {
148            IfcType::IfcExtrudedAreaSolid => {
149                // Direct extraction from ExtrudedDirection (attribute 2) and Position (attribute 1)
150                self.extract_extrusion_direction_from_solid(item, decoder)
151            }
152            IfcType::IfcBooleanClippingResult | IfcType::IfcBooleanResult => {
153                // FirstOperand (attribute 1) contains base geometry
154                let first_attr = item.get(1)?;
155                let first_operand = decoder.resolve_ref(first_attr).ok()??;
156                self.extract_extrusion_direction_recursive(&first_operand, decoder)
157            }
158            IfcType::IfcMappedItem => {
159                // MappingSource (attribute 0) -> MappedRepresentation -> Items
160                let source_attr = item.get(0)?;
161                let source = decoder.resolve_ref(source_attr).ok()??;
162                // RepresentationMap.MappedRepresentation is attribute 1
163                let rep_attr = source.get(1)?;
164                let rep = decoder.resolve_ref(rep_attr).ok()??;
165
166                // MappingTarget (attribute 1) -> instance transform
167                let mapping_transform = if let Some(target_attr) = item.get(1) {
168                    if !target_attr.is_null() {
169                        if let Ok(Some(target)) = decoder.resolve_ref(target_attr) {
170                            self.parse_cartesian_transformation_operator(&target, decoder).ok()
171                        } else {
172                            None
173                        }
174                    } else {
175                        None
176                    }
177                } else {
178                    None
179                };
180
181                // Get first item from representation
182                let items_attr = rep.get(3)?;
183                let items = decoder.resolve_ref_list(items_attr).ok()?;
184                items.first().and_then(|first| {
185                    self.extract_extrusion_direction_recursive(first, decoder).map(|(dir, pos)| {
186                        // Combine MappingTarget transform with position transform
187                        let combined = match (mapping_transform.as_ref(), pos) {
188                            (Some(map), Some(pos)) => Some(map * pos),
189                            (Some(map), None) => Some(map.clone()),
190                            (None, Some(pos)) => Some(pos),
191                            (None, None) => None,
192                        };
193                        (dir, combined)
194                    })
195                })
196            }
197            _ => None,
198        }
199    }
200
201    /// Get per-item meshes for an opening element, transformed to world coordinates.
202    /// Uses the same `transform_mesh` path as `process_element` to ensure identical
203    /// coordinate handling (ObjectPlacement, unit scaling, conditional RTC offset).
204    pub fn get_opening_item_meshes_world(
205        &self,
206        element: &DecodedEntity,
207        decoder: &mut EntityDecoder,
208    ) -> Result<Vec<Mesh>> {
209        let representation_attr = element.get(6).ok_or_else(|| {
210            Error::geometry("Element has no representation attribute".to_string())
211        })?;
212        if representation_attr.is_null() {
213            return Ok(vec![]);
214        }
215
216        let representation = decoder.resolve_ref(representation_attr)?
217            .ok_or_else(|| Error::geometry("Failed to resolve representation".to_string()))?;
218        let representations_attr = representation.get(2).ok_or_else(|| {
219            Error::geometry("ProductDefinitionShape missing Representations".to_string())
220        })?;
221        let representations = decoder.resolve_ref_list(representations_attr)?;
222
223        // Get the same placement transform that apply_placement uses
224        let mut placement_transform = self.get_placement_transform_from_element(element, decoder)
225            .unwrap_or_else(|_| Matrix4::identity());
226        self.scale_transform(&mut placement_transform);
227
228        let mut item_meshes = Vec::new();
229
230        for shape_rep in representations {
231            if shape_rep.ifc_type != IfcType::IfcShapeRepresentation {
232                continue;
233            }
234            if let Some(rep_type_attr) = shape_rep.get(2) {
235                if let Some(rep_type) = rep_type_attr.as_string() {
236                    if !is_body_representation(rep_type) {
237                        continue;
238                    }
239                }
240            }
241            let items_attr = match shape_rep.get(3) {
242                Some(attr) => attr,
243                None => continue,
244            };
245            let items = match decoder.resolve_ref_list(items_attr) {
246                Ok(items) => items,
247                Err(_) => continue,
248            };
249
250            for item in items {
251                let mut mesh = match self.process_representation_item(&item, decoder) {
252                    Ok(m) if !m.is_empty() => m,
253                    _ => continue,
254                };
255
256                // Use the same transform_mesh as process_element → apply_placement
257                // This handles ObjectPlacement, unit scaling, and conditional RTC
258                self.transform_mesh(&mut mesh, &placement_transform);
259
260                item_meshes.push(mesh);
261            }
262        }
263
264        Ok(item_meshes)
265    }
266
267    /// Extrusion direction is in world coordinates, normalized
268    /// Returns None for extrusion direction if it cannot be extracted (fallback to bounds-only)
269    pub fn get_opening_item_bounds_with_direction(
270        &self,
271        element: &DecodedEntity,
272        decoder: &mut EntityDecoder,
273    ) -> Result<Vec<(Point3<f64>, Point3<f64>, Option<Vector3<f64>>)>> {
274        // Get representation (attribute 6 for most building elements)
275        let representation_attr = element.get(6).ok_or_else(|| {
276            Error::geometry("Element has no representation attribute".to_string())
277        })?;
278
279        if representation_attr.is_null() {
280            return Ok(vec![]);
281        }
282
283        let representation = decoder
284            .resolve_ref(representation_attr)?
285            .ok_or_else(|| Error::geometry("Failed to resolve representation".to_string()))?;
286
287        // Get representations list
288        let representations_attr = representation.get(2).ok_or_else(|| {
289            Error::geometry("ProductDefinitionShape missing Representations".to_string())
290        })?;
291
292        let representations = decoder.resolve_ref_list(representations_attr)?;
293
294        // Get placement transform
295        let mut placement_transform = self.get_placement_transform_from_element(element, decoder)
296            .unwrap_or_else(|_| Matrix4::identity());
297        self.scale_transform(&mut placement_transform);
298
299        let mut bounds_list = Vec::new();
300
301        for shape_rep in representations {
302            if shape_rep.ifc_type != IfcType::IfcShapeRepresentation {
303                continue;
304            }
305
306            // Check representation type
307            if let Some(rep_type_attr) = shape_rep.get(2) {
308                if let Some(rep_type) = rep_type_attr.as_string() {
309                    if !is_body_representation(rep_type) {
310                        continue;
311                    }
312                }
313            }
314
315            // Get items list
316            let items_attr = match shape_rep.get(3) {
317                Some(attr) => attr,
318                None => continue,
319            };
320
321            let items = match decoder.resolve_ref_list(items_attr) {
322                Ok(items) => items,
323                Err(_) => continue,
324            };
325
326            // Process each item separately to get individual bounds
327            for item in items {
328                // Try to extract extrusion direction recursively (handles wrappers)
329                let extrusion_direction = if let Some((local_dir, position_transform)) =
330                    self.extract_extrusion_direction_recursive(&item, decoder)
331                {
332                    // Transform extrusion direction from local to world coordinates
333                    if let Some(pos_transform) = position_transform {
334                        let pos_rot = extract_rotation_columns(&pos_transform);
335                        let world_dir = rotate_and_normalize(&pos_rot, &local_dir)?;
336
337                        let element_rot = extract_rotation_columns(&placement_transform);
338                        let final_dir = rotate_and_normalize(&element_rot, &world_dir)?;
339
340                        Some(final_dir)
341                    } else {
342                        let element_rot = extract_rotation_columns(&placement_transform);
343                        let final_dir = rotate_and_normalize(&element_rot, &local_dir)?;
344
345                        Some(final_dir)
346                    }
347                } else {
348                    None
349                };
350
351                // Get mesh bounds (same as original function)
352                let mesh = match self.process_representation_item(&item, decoder) {
353                    Ok(m) if !m.is_empty() => m,
354                    _ => continue,
355                };
356
357                // Get bounds and transform to world coordinates
358                let (mesh_min, mesh_max) = mesh.bounds();
359
360                // Transform corner points to world coordinates
361                let corners = [
362                    Point3::new(mesh_min.x as f64, mesh_min.y as f64, mesh_min.z as f64),
363                    Point3::new(mesh_max.x as f64, mesh_min.y as f64, mesh_min.z as f64),
364                    Point3::new(mesh_min.x as f64, mesh_max.y as f64, mesh_min.z as f64),
365                    Point3::new(mesh_max.x as f64, mesh_max.y as f64, mesh_min.z as f64),
366                    Point3::new(mesh_min.x as f64, mesh_min.y as f64, mesh_max.z as f64),
367                    Point3::new(mesh_max.x as f64, mesh_min.y as f64, mesh_max.z as f64),
368                    Point3::new(mesh_min.x as f64, mesh_max.y as f64, mesh_max.z as f64),
369                    Point3::new(mesh_max.x as f64, mesh_max.y as f64, mesh_max.z as f64),
370                ];
371
372                // Transform all corners and compute new AABB
373                let transformed: Vec<Point3<f64>> = corners.iter()
374                    .map(|p| placement_transform.transform_point(p))
375                    .collect();
376
377                let world_min = Point3::new(
378                    transformed.iter().map(|p| p.x).fold(f64::INFINITY, f64::min),
379                    transformed.iter().map(|p| p.y).fold(f64::INFINITY, f64::min),
380                    transformed.iter().map(|p| p.z).fold(f64::INFINITY, f64::min),
381                );
382                let world_max = Point3::new(
383                    transformed.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max),
384                    transformed.iter().map(|p| p.y).fold(f64::NEG_INFINITY, f64::max),
385                    transformed.iter().map(|p| p.z).fold(f64::NEG_INFINITY, f64::max),
386                );
387
388                // Apply RTC offset to opening bounds so they match wall mesh coordinate system
389                // Wall mesh positions have RTC subtracted during transform_mesh, so opening bounds must match
390                let rtc = self.rtc_offset;
391                let rtc_min = Point3::new(
392                    world_min.x - rtc.0,
393                    world_min.y - rtc.1,
394                    world_min.z - rtc.2,
395                );
396                let rtc_max = Point3::new(
397                    world_max.x - rtc.0,
398                    world_max.y - rtc.1,
399                    world_max.z - rtc.2,
400                );
401
402                bounds_list.push((rtc_min, rtc_max, extrusion_direction));
403            }
404        }
405
406        Ok(bounds_list)
407    }
408
409    /// Process element with void subtraction (openings)
410    /// Process element with voids using optimized plane clipping
411    ///
412    /// This approach is more efficient than full 3D CSG for rectangular openings:
413    /// 1. Get chamfered wall mesh (preserves chamfered corners)
414    /// 2. For each opening, use optimized box cutting with internal face generation
415    /// 3. Apply any clipping operations (roof clips) from original representation
416    #[inline]
417    /// Process an element with void subtraction (openings).
418    ///
419    /// This function handles three distinct cases for cutting openings:
420    ///
421    /// 1. **Floor/Slab openings** (vertical Z-extrusion): Uses CSG with actual mesh geometry
422    ///    because the XY footprint may be rotated relative to the slab orientation.
423    ///
424    /// 2. **Wall openings** (horizontal X/Y-extrusion, axis-aligned): Uses AABB clipping
425    ///    for fast, accurate cutting of rectangular openings.
426    ///
427    /// 3. **Diagonal wall openings**: Uses AABB clipping without internal face generation
428    ///    to avoid rotation artifacts.
429    ///
430    /// **Important**: Internal face generation is disabled for all openings because it causes
431    /// visual artifacts (rotated faces, thin lines extending from models). The opening cutouts
432    /// are still geometrically correct - only the internal "reveal" faces are omitted.
433    pub fn process_element_with_voids(
434        &self,
435        element: &DecodedEntity,
436        decoder: &mut EntityDecoder,
437        void_index: &FxHashMap<u32, Vec<u32>>,
438    ) -> Result<Mesh> {
439        // Check if this element has any openings
440        let opening_ids = match void_index.get(&element.id) {
441            Some(ids) if !ids.is_empty() => ids,
442            _ => {
443                // No openings - just process normally
444                return self.process_element(element, decoder);
445            }
446        };
447
448        // No blanket opening-count gate here. Rectangular and diagonal openings use
449        // fast AABB clipping with no per-element limit. NonRectangular (CSG) openings
450        // are independently capped by MAX_CSG_OPERATIONS below, so elements with many
451        // complex openings degrade gracefully rather than skipping void subtraction
452        // entirely.
453
454        // STEP 1: Get chamfered wall mesh (preserves chamfered corners at intersections)
455        let wall_mesh = match self.process_element(element, decoder) {
456            Ok(m) => m,
457            Err(_) => {
458                return self.process_element(element, decoder);
459            }
460        };
461
462        // OPTIMIZATION: Only extract clipping planes if element actually has them
463        // This skips expensive profile extraction for ~95% of elements
464        use nalgebra::Vector3;
465        let world_clipping_planes: Vec<(Point3<f64>, Vector3<f64>, bool)> =
466            if self.has_clipping_planes(element, decoder) {
467                // Get element's ObjectPlacement transform (for clipping planes)
468                let mut object_placement_transform = match self.get_placement_transform_from_element(element, decoder) {
469                    Ok(t) => t,
470                    Err(_) => Matrix4::identity(),
471                };
472                self.scale_transform(&mut object_placement_transform);
473
474                // Extract clipping planes (for roof clips)
475                let clipping_planes = match self.extract_base_profile_and_clips(element, decoder) {
476                    Ok((_profile, _depth, _axis, _origin, _transform, clips)) => clips,
477                    Err(_) => Vec::new(),
478                };
479
480                // Transform clipping planes to world coordinates
481                clipping_planes
482                    .iter()
483                    .map(|(point, normal, agreement)| {
484                        let world_point = object_placement_transform.transform_point(point);
485                        let rotation = object_placement_transform.fixed_view::<3, 3>(0, 0);
486                        let world_normal = (rotation * normal).normalize();
487                        (world_point, world_normal, *agreement)
488                    })
489                    .collect()
490            } else {
491                Vec::new()
492            };
493
494        let openings = self.classify_openings(opening_ids, decoder);
495
496        if openings.is_empty() {
497            return self.process_element(element, decoder);
498        }
499
500        // STEP 6: Cut openings using appropriate method
501        use crate::csg::ClippingProcessor;
502        let clipper = ClippingProcessor::new();
503        let mut result = wall_mesh;
504
505        // Get wall bounds for clamping opening faces (from result before cutting)
506        let (wall_min_f32, wall_max_f32) = result.bounds();
507        let wall_min = Point3::new(
508            wall_min_f32.x as f64,
509            wall_min_f32.y as f64,
510            wall_min_f32.z as f64,
511        );
512        let wall_max = Point3::new(
513            wall_max_f32.x as f64,
514            wall_max_f32.y as f64,
515            wall_max_f32.z as f64,
516        );
517
518        // Validate wall mesh ONCE before CSG operations (not per-iteration)
519        // This avoids O(n) validation on every loop iteration
520        let wall_valid = !result.is_empty()
521            && result.positions.iter().all(|&v| v.is_finite())
522            && result.triangle_count() >= 4;
523
524        if !wall_valid {
525            // Wall mesh is invalid, return as-is
526            return Ok(result);
527        }
528
529        // Track CSG operations to prevent excessive complexity
530        let mut csg_operation_count = 0;
531        const MAX_CSG_OPERATIONS: usize = 10; // Limit to prevent runaway CSG
532
533        self.apply_diagonal_openings(&mut result, &openings, &wall_min, &wall_max);
534
535        for opening in openings.iter() {
536            match opening {
537                OpeningType::Rectangular(open_min, open_max, extrusion_dir) => {
538                    // Use AABB clipping for axis-aligned rectangular openings
539                    let (final_min, final_max) = if let Some(dir) = extrusion_dir {
540                        // Extend along the actual extrusion direction to penetrate multi-layer walls
541                        self.extend_opening_along_direction(*open_min, *open_max, wall_min, wall_max, *dir)
542                    } else {
543                        // Fallback: use opening bounds as-is (no direction available)
544                        (*open_min, *open_max)
545                    };
546                    result = self.cut_rectangular_opening(&result, final_min, final_max);
547                }
548                OpeningType::DiagonalRectangular(_opening_mesh, _extrusion_dir) => {
549                    // Already handled in the batched block above
550                }
551                OpeningType::NonRectangular(opening_mesh) => {
552                    // Safety: limit total CSG operations to prevent crashes on complex geometry
553                    if csg_operation_count >= MAX_CSG_OPERATIONS {
554                        // Skip remaining CSG operations
555                        continue;
556                    }
557
558                    // Validate opening mesh before CSG (only once per opening)
559                    let opening_valid = !opening_mesh.is_empty()
560                        && opening_mesh.positions.iter().all(|&v| v.is_finite())
561                        && opening_mesh.positions.len() >= 9; // At least 3 vertices
562
563                    if !opening_valid {
564                        // Skip invalid opening
565                        continue;
566                    }
567
568                    // Guard CSG against tiny / non-intersecting openings.
569                    //
570                    // Some IfcOpeningElements have vertical (0,0,1) extrusion even in walls
571                    // (e.g. 17 mm connection points). The `is_floor_opening` heuristic
572                    // misclassifies these, forcing them into the CSG path.
573                    // The csgrs BSP tree then destroys the wall mesh because tiny operands
574                    // trigger numerical instability in the BSP split/merge.
575                    //
576                    // Three guards:
577                    // 1. Bounds overlap — skip if opening AABB doesn't touch wall AABB
578                    // 2. Volume threshold — skip openings < 0.1 litre (modelling artefacts)
579                    // 3. Result validation — reject CSG output that loses > 75 % of triangles
580                    let (result_min, result_max) = result.bounds();
581                    let (open_min_f32, open_max_f32) = opening_mesh.bounds();
582                    let no_overlap =
583                        open_max_f32.x < result_min.x || open_min_f32.x > result_max.x ||
584                        open_max_f32.y < result_min.y || open_min_f32.y > result_max.y ||
585                        open_max_f32.z < result_min.z || open_min_f32.z > result_max.z;
586                    if no_overlap {
587                        continue;
588                    }
589
590                    // Guard against CSG on very small openings that can destabilize BSP trees.
591                    let open_vol = (open_max_f32.x - open_min_f32.x)
592                        * (open_max_f32.y - open_min_f32.y)
593                        * (open_max_f32.z - open_min_f32.z);
594                    if open_vol < MIN_OPENING_VOLUME as f32 {
595                        continue;
596                    }
597
598                    // Use full CSG subtraction for non-rectangular shapes
599                    // Note: mesh_to_csgrs validates and filters invalid triangles internally
600                    let tri_before = result.triangle_count();
601                    match clipper.subtract_mesh(&result, opening_mesh) {
602                        Ok(csg_result) => {
603                            // Validate result is not degenerate — must retain a reasonable
604                            // fraction of the pre-CSG triangles to catch BSP blowups
605                            let min_tris = (tri_before / CSG_TRIANGLE_RETENTION_DIVISOR).max(MIN_VALID_TRIANGLES);
606                            if !csg_result.is_empty() && csg_result.triangle_count() >= min_tris {
607                                result = csg_result;
608                            }
609                            // If result is degenerate, keep previous result
610                        }
611                        Err(_) => {
612                            // Keep original result if CSG fails
613                        }
614                    }
615                    csg_operation_count += 1;
616                }
617            }
618        }
619
620        // STEP 7: Apply clipping planes (roof clips) if any
621        if !world_clipping_planes.is_empty() {
622            use crate::csg::{ClippingProcessor, Plane};
623            let clipper = ClippingProcessor::new();
624
625            for (_clip_idx, (plane_point, plane_normal, agreement)) in world_clipping_planes.iter().enumerate() {
626                let clip_normal = if *agreement {
627                    *plane_normal
628                } else {
629                    -*plane_normal
630                };
631
632                let plane = Plane::new(*plane_point, clip_normal);
633                if let Ok(clipped) = clipper.clip_mesh(&result, &plane) {
634                    if !clipped.is_empty() {
635                        result = clipped;
636                    }
637                }
638            }
639        }
640
641        Ok(result)
642    }
643
644    fn classify_openings(
645        &self,
646        opening_ids: &[u32],
647        decoder: &mut EntityDecoder,
648    ) -> Vec<OpeningType> {
649        let mut openings: Vec<OpeningType> = Vec::new();
650        for &opening_id in opening_ids.iter() {
651            let opening_entity = match decoder.decode_by_id(opening_id) {
652                Ok(e) => e,
653                Err(_) => continue,
654            };
655
656            let opening_mesh = match self.process_element(&opening_entity, decoder) {
657                Ok(m) if !m.is_empty() => m,
658                _ => continue,
659            };
660
661            let vertex_count = opening_mesh.positions.len() / 3;
662
663            if vertex_count > 100 {
664                openings.push(OpeningType::NonRectangular(opening_mesh));
665            } else {
666                let item_bounds_with_dir = self.get_opening_item_bounds_with_direction(&opening_entity, decoder)
667                    .unwrap_or_default();
668
669                if !item_bounds_with_dir.is_empty() {
670                    let is_floor_opening = item_bounds_with_dir.iter().any(|(_, _, dir)| {
671                        dir.map(|d| d.z.abs() > 0.95).unwrap_or(false)
672                    });
673
674                    if is_floor_opening && vertex_count > 0 {
675                        openings.push(OpeningType::NonRectangular(opening_mesh.clone()));
676                    } else {
677                        let any_diagonal = item_bounds_with_dir.iter().any(|(_, _, dir)| {
678                            dir.map(|d| {
679                                const AXIS_THRESHOLD: f64 = 0.95;
680                                let abs_x = d.x.abs();
681                                let abs_y = d.y.abs();
682                                let abs_z = d.z.abs();
683                                !(abs_x > AXIS_THRESHOLD || abs_y > AXIS_THRESHOLD || abs_z > AXIS_THRESHOLD)
684                            }).unwrap_or(false)
685                        });
686
687                        if any_diagonal {
688                            // Only use the diagonal path if we have an actual extrusion direction;
689                            // without one the rotation would be arbitrary and produce wrong cuts.
690                            if let Some(dir) = item_bounds_with_dir.iter().find_map(|(_, _, d)| *d) {
691                                let item_meshes = self.get_opening_item_meshes_world(&opening_entity, decoder)
692                                    .unwrap_or_default();
693                                if item_meshes.is_empty() {
694                                    openings.push(OpeningType::DiagonalRectangular(opening_mesh.clone(), dir));
695                                } else {
696                                    for item_mesh in item_meshes {
697                                        openings.push(OpeningType::DiagonalRectangular(item_mesh, dir));
698                                    }
699                                }
700                            } else {
701                                // No direction available — fall back to CSG
702                                openings.push(OpeningType::NonRectangular(opening_mesh.clone()));
703                            }
704                        } else {
705                            for (min_pt, max_pt, extrusion_dir) in item_bounds_with_dir {
706                                openings.push(OpeningType::Rectangular(min_pt, max_pt, extrusion_dir));
707                            }
708                        }
709                    }
710                } else {
711                    let (open_min, open_max) = opening_mesh.bounds();
712                    let min_f64 = Point3::new(open_min.x as f64, open_min.y as f64, open_min.z as f64);
713                    let max_f64 = Point3::new(open_max.x as f64, open_max.y as f64, open_max.z as f64);
714
715                    openings.push(OpeningType::Rectangular(min_f64, max_f64, None));
716                }
717            }
718        }
719        openings
720    }
721
722    fn apply_diagonal_openings(
723        &self,
724        result: &mut Mesh,
725        openings: &[OpeningType],
726        wall_min: &Point3<f64>,
727        wall_max: &Point3<f64>,
728    ) {
729        use nalgebra::Rotation3;
730
731        let diagonal_openings: Vec<(&Mesh, &Vector3<f64>)> = openings.iter()
732            .filter_map(|o| match o {
733                OpeningType::DiagonalRectangular(mesh, dir) => Some((mesh, dir)),
734                _ => None,
735            })
736            .collect();
737
738        if diagonal_openings.is_empty() {
739            return;
740        }
741
742        // Group openings by extrusion direction so each group gets its own
743        // rotate-clip-unrotate pass (directions considered equal within a
744        // small angular tolerance).
745        const DIR_DOT_THRESHOLD: f64 = 0.9998; // ~1° tolerance
746        let mut groups: Vec<(Vector3<f64>, Vec<&Mesh>)> = Vec::new();
747        for (mesh, dir) in &diagonal_openings {
748            let d = *dir;
749            if let Some(group) = groups.iter_mut().find(|(g, _)| d.dot(g).abs() > DIR_DOT_THRESHOLD) {
750                group.1.push(mesh);
751            } else {
752                groups.push((*d, vec![mesh]));
753            }
754        }
755
756        let wall_corners = [
757            Point3::new(wall_min.x, wall_min.y, wall_min.z),
758            Point3::new(wall_max.x, wall_min.y, wall_min.z),
759            Point3::new(wall_min.x, wall_max.y, wall_min.z),
760            Point3::new(wall_max.x, wall_max.y, wall_min.z),
761            Point3::new(wall_min.x, wall_min.y, wall_max.z),
762            Point3::new(wall_max.x, wall_min.y, wall_max.z),
763            Point3::new(wall_min.x, wall_max.y, wall_max.z),
764            Point3::new(wall_max.x, wall_max.y, wall_max.z),
765        ];
766
767        for (extrusion_dir, group_meshes) in &groups {
768            let target = Vector3::new(1.0, 0.0, 0.0);
769            let rotation = Rotation3::rotation_between(extrusion_dir, &target)
770                .unwrap_or(Rotation3::identity());
771            let inv_rotation = rotation.inverse();
772
773            // Rotate positions and normals into the aligned frame
774            for chunk in result.positions.chunks_exact_mut(3) {
775                let p = rotation * Point3::new(chunk[0] as f64, chunk[1] as f64, chunk[2] as f64);
776                chunk[0] = p.x as f32;
777                chunk[1] = p.y as f32;
778                chunk[2] = p.z as f32;
779            }
780            for chunk in result.normals.chunks_exact_mut(3) {
781                let n = rotation * Vector3::new(chunk[0] as f64, chunk[1] as f64, chunk[2] as f64);
782                chunk[0] = n.x as f32;
783                chunk[1] = n.y as f32;
784                chunk[2] = n.z as f32;
785            }
786
787            let mut wall_x_min = f64::INFINITY;
788            let mut wall_x_max = f64::NEG_INFINITY;
789            for wc in &wall_corners {
790                let rwc = rotation * wc;
791                wall_x_min = wall_x_min.min(rwc.x);
792                wall_x_max = wall_x_max.max(rwc.x);
793            }
794
795            for opening_mesh in group_meshes {
796                let mut rot_min = Point3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
797                let mut rot_max = Point3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
798                for chunk in opening_mesh.positions.chunks_exact(3) {
799                    let p = rotation * Point3::new(chunk[0] as f64, chunk[1] as f64, chunk[2] as f64);
800                    rot_min.x = rot_min.x.min(p.x);
801                    rot_min.y = rot_min.y.min(p.y);
802                    rot_min.z = rot_min.z.min(p.z);
803                    rot_max.x = rot_max.x.max(p.x);
804                    rot_max.y = rot_max.y.max(p.y);
805                    rot_max.z = rot_max.z.max(p.z);
806                }
807                rot_min.x = rot_min.x.min(wall_x_min);
808                rot_max.x = rot_max.x.max(wall_x_max);
809
810                *result = self.cut_rectangular_opening_no_faces(result, rot_min, rot_max);
811            }
812
813            // Rotate positions and normals back to world frame
814            for chunk in result.positions.chunks_exact_mut(3) {
815                let p = inv_rotation * Point3::new(chunk[0] as f64, chunk[1] as f64, chunk[2] as f64);
816                chunk[0] = p.x as f32;
817                chunk[1] = p.y as f32;
818                chunk[2] = p.z as f32;
819            }
820            for chunk in result.normals.chunks_exact_mut(3) {
821                let n = inv_rotation * Vector3::new(chunk[0] as f64, chunk[1] as f64, chunk[2] as f64);
822                chunk[0] = n.x as f32;
823                chunk[1] = n.y as f32;
824                chunk[2] = n.z as f32;
825            }
826        }
827    }
828
829    /// Cut a rectangular opening from a mesh using optimized plane clipping
830    ///
831    /// This is more efficient than full CSG because:
832    /// 1. Only processes triangles that intersect the opening bounds
833    /// Extend opening bounds along extrusion direction to match wall extent
834    ///
835    /// Projects wall corners onto the extrusion axis and extends the opening
836    /// min/max to cover the wall's full extent along that direction.
837    /// This ensures openings penetrate multi-layer walls correctly without
838    /// causing artifacts for angled walls.
839    fn extend_opening_along_direction(
840        &self,
841        open_min: Point3<f64>,
842        open_max: Point3<f64>,
843        wall_min: Point3<f64>,
844        wall_max: Point3<f64>,
845        extrusion_direction: Vector3<f64>,  // World-space, normalized
846    ) -> (Point3<f64>, Point3<f64>) {
847        // Use opening center as reference point for projection
848        let open_center = Point3::new(
849            (open_min.x + open_max.x) * 0.5,
850            (open_min.y + open_max.y) * 0.5,
851            (open_min.z + open_max.z) * 0.5,
852        );
853
854        // Project all 8 corners of the wall box onto the extrusion axis
855        let wall_corners = [
856            Point3::new(wall_min.x, wall_min.y, wall_min.z),
857            Point3::new(wall_max.x, wall_min.y, wall_min.z),
858            Point3::new(wall_min.x, wall_max.y, wall_min.z),
859            Point3::new(wall_max.x, wall_max.y, wall_min.z),
860            Point3::new(wall_min.x, wall_min.y, wall_max.z),
861            Point3::new(wall_max.x, wall_min.y, wall_max.z),
862            Point3::new(wall_min.x, wall_max.y, wall_max.z),
863            Point3::new(wall_max.x, wall_max.y, wall_max.z),
864        ];
865
866        // Find min/max projections of wall corners onto extrusion axis
867        let mut wall_min_proj = f64::INFINITY;
868        let mut wall_max_proj = f64::NEG_INFINITY;
869
870        for corner in &wall_corners {
871            // Project corner onto extrusion axis relative to opening center
872            let proj = (corner - open_center).dot(&extrusion_direction);
873            wall_min_proj = wall_min_proj.min(proj);
874            wall_max_proj = wall_max_proj.max(proj);
875        }
876
877        // Project opening corners onto extrusion axis
878        let open_corners = [
879            Point3::new(open_min.x, open_min.y, open_min.z),
880            Point3::new(open_max.x, open_min.y, open_min.z),
881            Point3::new(open_min.x, open_max.y, open_min.z),
882            Point3::new(open_max.x, open_max.y, open_min.z),
883            Point3::new(open_min.x, open_min.y, open_max.z),
884            Point3::new(open_max.x, open_min.y, open_max.z),
885            Point3::new(open_min.x, open_max.y, open_max.z),
886            Point3::new(open_max.x, open_max.y, open_max.z),
887        ];
888
889        let mut open_min_proj = f64::INFINITY;
890        let mut open_max_proj = f64::NEG_INFINITY;
891
892        for corner in &open_corners {
893            let proj = (corner - open_center).dot(&extrusion_direction);
894            open_min_proj = open_min_proj.min(proj);
895            open_max_proj = open_max_proj.max(proj);
896        }
897
898        // Calculate how much to extend in each direction along the extrusion axis
899        // If wall extends beyond opening, we need to extend the opening
900        let extend_backward = (open_min_proj - wall_min_proj).max(0.0);  // How much wall extends before opening
901        let extend_forward = (wall_max_proj - open_max_proj).max(0.0);   // How much wall extends after opening
902
903        // Extend opening bounds along the extrusion direction
904        let extended_min = open_min - extrusion_direction * extend_backward;
905        let extended_max = open_max + extrusion_direction * extend_forward;
906
907        // Create new AABB that encompasses both original opening and extended points
908        // This ensures we don't shrink the opening in other dimensions
909        let all_points = [
910            open_min, open_max,
911            extended_min, extended_max,
912        ];
913
914        let new_min = Point3::new(
915            all_points.iter().map(|p| p.x).fold(f64::INFINITY, f64::min),
916            all_points.iter().map(|p| p.y).fold(f64::INFINITY, f64::min),
917            all_points.iter().map(|p| p.z).fold(f64::INFINITY, f64::min),
918        );
919        let new_max = Point3::new(
920            all_points.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max),
921            all_points.iter().map(|p| p.y).fold(f64::NEG_INFINITY, f64::max),
922            all_points.iter().map(|p| p.z).fold(f64::NEG_INFINITY, f64::max),
923        );
924
925        (new_min, new_max)
926    }
927
928    /// Cut a rectangular opening from a mesh using AABB clipping.
929    ///
930    /// This method clips triangles against the opening bounding box using axis-aligned
931    /// clipping planes. Internal face generation is disabled because it causes visual
932    /// artifacts (rotated faces, thin lines extending from models).
933    pub(super) fn cut_rectangular_opening(
934        &self,
935        mesh: &Mesh,
936        open_min: Point3<f64>,
937        open_max: Point3<f64>,
938    ) -> Mesh {
939        self.cut_rectangular_opening_no_faces(mesh, open_min, open_max)
940    }
941
942    /// Cut a rectangular opening using AABB clipping WITHOUT generating internal faces.
943    /// Used for diagonal openings where internal face generation causes rotation artifacts.
944    fn cut_rectangular_opening_no_faces(
945        &self,
946        mesh: &Mesh,
947        open_min: Point3<f64>,
948        open_max: Point3<f64>,
949    ) -> Mesh {
950        use nalgebra::Vector3;
951
952        const EPSILON: f64 = 1e-6;
953
954        let mut result = Mesh::with_capacity(
955            mesh.positions.len() / 3,
956            mesh.indices.len() / 3,
957        );
958
959        let mut clip_buffers = ClipBuffers::new();
960
961        for chunk in mesh.indices.chunks_exact(3) {
962            let i0 = chunk[0] as usize;
963            let i1 = chunk[1] as usize;
964            let i2 = chunk[2] as usize;
965
966            let v0 = Point3::new(
967                mesh.positions[i0 * 3] as f64,
968                mesh.positions[i0 * 3 + 1] as f64,
969                mesh.positions[i0 * 3 + 2] as f64,
970            );
971            let v1 = Point3::new(
972                mesh.positions[i1 * 3] as f64,
973                mesh.positions[i1 * 3 + 1] as f64,
974                mesh.positions[i1 * 3 + 2] as f64,
975            );
976            let v2 = Point3::new(
977                mesh.positions[i2 * 3] as f64,
978                mesh.positions[i2 * 3 + 1] as f64,
979                mesh.positions[i2 * 3 + 2] as f64,
980            );
981
982            let n0 = if mesh.normals.len() >= mesh.positions.len() {
983                Vector3::new(
984                    mesh.normals[i0 * 3] as f64,
985                    mesh.normals[i0 * 3 + 1] as f64,
986                    mesh.normals[i0 * 3 + 2] as f64,
987                )
988            } else {
989                let edge1 = v1 - v0;
990                let edge2 = v2 - v0;
991                edge1.cross(&edge2).try_normalize(1e-10).unwrap_or(Vector3::new(0.0, 0.0, 1.0))
992            };
993
994            let tri_min_x = v0.x.min(v1.x).min(v2.x);
995            let tri_max_x = v0.x.max(v1.x).max(v2.x);
996            let tri_min_y = v0.y.min(v1.y).min(v2.y);
997            let tri_max_y = v0.y.max(v1.y).max(v2.y);
998            let tri_min_z = v0.z.min(v1.z).min(v2.z);
999            let tri_max_z = v0.z.max(v1.z).max(v2.z);
1000
1001            // If triangle is completely outside opening, keep it as-is
1002            if tri_max_x <= open_min.x - EPSILON || tri_min_x >= open_max.x + EPSILON ||
1003               tri_max_y <= open_min.y - EPSILON || tri_min_y >= open_max.y + EPSILON ||
1004               tri_max_z <= open_min.z - EPSILON || tri_min_z >= open_max.z + EPSILON {
1005                let base = result.vertex_count() as u32;
1006                result.add_vertex(v0, n0);
1007                result.add_vertex(v1, n0);
1008                result.add_vertex(v2, n0);
1009                result.add_triangle(base, base + 1, base + 2);
1010                continue;
1011            }
1012
1013            // Check if triangle is completely inside opening (remove it)
1014            if tri_min_x >= open_min.x + EPSILON && tri_max_x <= open_max.x - EPSILON &&
1015               tri_min_y >= open_min.y + EPSILON && tri_max_y <= open_max.y - EPSILON &&
1016               tri_min_z >= open_min.z + EPSILON && tri_max_z <= open_max.z - EPSILON {
1017                continue;
1018            }
1019
1020            // Triangle may intersect opening - clip it
1021            if self.triangle_intersects_box(&v0, &v1, &v2, &open_min, &open_max) {
1022                self.clip_triangle_against_box(&mut result, &mut clip_buffers, &v0, &v1, &v2, &n0, &open_min, &open_max);
1023            } else {
1024                let base = result.vertex_count() as u32;
1025                result.add_vertex(v0, n0);
1026                result.add_vertex(v1, n0);
1027                result.add_vertex(v2, n0);
1028                result.add_triangle(base, base + 1, base + 2);
1029            }
1030        }
1031
1032        // No internal face generation for diagonal openings
1033        result
1034    }
1035
1036
1037    /// Test if a triangle intersects an axis-aligned bounding box using Separating Axis Theorem (SAT)
1038    /// Returns true if triangle and box intersect, false if they are separated
1039    fn triangle_intersects_box(
1040        &self,
1041        v0: &Point3<f64>,
1042        v1: &Point3<f64>,
1043        v2: &Point3<f64>,
1044        box_min: &Point3<f64>,
1045        box_max: &Point3<f64>,
1046    ) -> bool {
1047        use nalgebra::Vector3;
1048
1049        // Box center and half-extents
1050        let box_center = Point3::new(
1051            (box_min.x + box_max.x) * 0.5,
1052            (box_min.y + box_max.y) * 0.5,
1053            (box_min.z + box_max.z) * 0.5,
1054        );
1055        let box_half_extents = Vector3::new(
1056            (box_max.x - box_min.x) * 0.5,
1057            (box_max.y - box_min.y) * 0.5,
1058            (box_max.z - box_min.z) * 0.5,
1059        );
1060
1061        // Translate triangle to box-local space
1062        let t0 = v0 - box_center;
1063        let t1 = v1 - box_center;
1064        let t2 = v2 - box_center;
1065
1066        // Triangle edges
1067        let e0 = t1 - t0;
1068        let e1 = t2 - t1;
1069        let e2 = t0 - t2;
1070
1071        // Test 1: Box axes (X, Y, Z)
1072        // Project triangle onto each axis and check overlap
1073        for axis_idx in 0..3 {
1074            let axis = match axis_idx {
1075                0 => Vector3::new(1.0, 0.0, 0.0),
1076                1 => Vector3::new(0.0, 1.0, 0.0),
1077                2 => Vector3::new(0.0, 0.0, 1.0),
1078                _ => unreachable!(),
1079            };
1080
1081            let p0 = t0.dot(&axis);
1082            let p1 = t1.dot(&axis);
1083            let p2 = t2.dot(&axis);
1084
1085            let tri_min = p0.min(p1).min(p2);
1086            let tri_max = p0.max(p1).max(p2);
1087            let box_extent = box_half_extents[axis_idx];
1088
1089            if tri_max < -box_extent || tri_min > box_extent {
1090                return false; // Separated on this axis
1091            }
1092        }
1093
1094        // Test 2: Triangle face normal
1095        let triangle_normal = e0.cross(&e2);
1096        let triangle_offset = t0.dot(&triangle_normal);
1097
1098        // Project box onto triangle normal
1099        let mut box_projection = 0.0;
1100        for i in 0..3 {
1101            let axis = match i {
1102                0 => Vector3::new(1.0, 0.0, 0.0),
1103                1 => Vector3::new(0.0, 1.0, 0.0),
1104                2 => Vector3::new(0.0, 0.0, 1.0),
1105                _ => unreachable!(),
1106            };
1107            box_projection += box_half_extents[i] * triangle_normal.dot(&axis).abs();
1108        }
1109
1110        if triangle_offset.abs() > box_projection {
1111            return false; // Separated by triangle plane
1112        }
1113
1114        // Test 3: 9 cross-product axes (3 box edges x 3 triangle edges)
1115        let box_axes = [
1116            Vector3::new(1.0, 0.0, 0.0),
1117            Vector3::new(0.0, 1.0, 0.0),
1118            Vector3::new(0.0, 0.0, 1.0),
1119        ];
1120        let tri_edges = [e0, e1, e2];
1121
1122        for box_axis in &box_axes {
1123            for tri_edge in &tri_edges {
1124                let axis = box_axis.cross(tri_edge);
1125
1126                // Skip degenerate axes (parallel edges)
1127                if axis.norm_squared() < 1e-10 {
1128                    continue;
1129                }
1130
1131                let axis_normalized = axis.normalize();
1132
1133                // Project triangle onto axis
1134                let p0 = t0.dot(&axis_normalized);
1135                let p1 = t1.dot(&axis_normalized);
1136                let p2 = t2.dot(&axis_normalized);
1137                let tri_min = p0.min(p1).min(p2);
1138                let tri_max = p0.max(p1).max(p2);
1139
1140                // Project box onto axis
1141                let mut box_projection = 0.0;
1142                for i in 0..3 {
1143                    let box_axis_vec = box_axes[i];
1144                    box_projection += box_half_extents[i] * axis_normalized.dot(&box_axis_vec).abs();
1145                }
1146
1147                if tri_max < -box_projection || tri_min > box_projection {
1148                    return false; // Separated on this axis
1149                }
1150            }
1151        }
1152
1153        // No separating axis found - triangle and box intersect
1154        true
1155    }
1156
1157    /// Clip a triangle against an opening box using clip-and-collect algorithm.
1158    /// Removes the part of the triangle that's inside the box.
1159    /// Collects "outside" parts directly to result, continues processing "inside" parts.
1160    ///
1161    /// Uses reusable ClipBuffers to avoid per-triangle allocations (6+ Vec allocations
1162    /// per intersecting triangle without buffers).
1163    ///
1164    /// ## FIX (2026-03-18): Direct back-part computation
1165    ///
1166    /// The previous implementation clipped the original triangle against a **flipped plane**
1167    /// to obtain "outside" parts. When triangle vertices were within epsilon (1e-6) of the
1168    /// clipping plane, `clip_triangle` classified them as "front" for **both** the original
1169    /// and flipped planes — returning `Split` on the original but `AllFront` on the flipped.
1170    /// This added the **entire original triangle** to the result as an "outside" piece while
1171    /// the clipped front parts also continued processing, duplicating geometry.
1172    ///
1173    fn clip_triangle_against_box(
1174        &self,
1175        result: &mut Mesh,
1176        buffers: &mut ClipBuffers,
1177        v0: &Point3<f64>,
1178        v1: &Point3<f64>,
1179        v2: &Point3<f64>,
1180        normal: &Vector3<f64>,
1181        open_min: &Point3<f64>,
1182        open_max: &Point3<f64>,
1183    ) {
1184        let clipper = ClippingProcessor::new();
1185        let epsilon = clipper.epsilon;
1186
1187        // Clear buffers for reuse (retains capacity)
1188        buffers.clear();
1189
1190        // Planes with INWARD normals (so "front" = inside box, "behind" = outside box)
1191        // We clip to keep geometry OUTSIDE the box (behind these planes)
1192        let planes = [
1193            // +X inward: inside box where x >= open_min.x
1194            Plane::new(Point3::new(open_min.x, 0.0, 0.0), Vector3::new(1.0, 0.0, 0.0)),
1195            // -X inward: inside box where x <= open_max.x
1196            Plane::new(Point3::new(open_max.x, 0.0, 0.0), Vector3::new(-1.0, 0.0, 0.0)),
1197            // +Y inward: inside box where y >= open_min.y
1198            Plane::new(Point3::new(0.0, open_min.y, 0.0), Vector3::new(0.0, 1.0, 0.0)),
1199            // -Y inward: inside box where y <= open_max.y
1200            Plane::new(Point3::new(0.0, open_max.y, 0.0), Vector3::new(0.0, -1.0, 0.0)),
1201            // +Z inward: inside box where z >= open_min.z
1202            Plane::new(Point3::new(0.0, 0.0, open_min.z), Vector3::new(0.0, 0.0, 1.0)),
1203            // -Z inward: inside box where z <= open_max.z
1204            Plane::new(Point3::new(0.0, 0.0, open_max.z), Vector3::new(0.0, 0.0, -1.0)),
1205        ];
1206
1207        // Initialize remaining with the input triangle
1208        buffers.remaining.push(Triangle::new(*v0, *v1, *v2));
1209
1210        // Clip-and-collect: collect "outside" parts, continue processing "inside" parts
1211        for plane in &planes {
1212            buffers.next_remaining.clear();
1213
1214            for tri in &buffers.remaining {
1215                // Compute signed distances
1216                let d0 = plane.signed_distance(&tri.v0);
1217                let d1 = plane.signed_distance(&tri.v1);
1218                let d2 = plane.signed_distance(&tri.v2);
1219
1220                let f0 = d0 >= -epsilon;
1221                let f1 = d1 >= -epsilon;
1222                let f2 = d2 >= -epsilon;
1223                let front_count = f0 as u8 + f1 as u8 + f2 as u8;
1224
1225                match front_count {
1226                    3 => {
1227                        // All front (inside this plane boundary) - continue
1228                        buffers.next_remaining.push(tri.clone());
1229                    }
1230                    0 => {
1231                        // All behind (outside this plane boundary) - keep
1232                        buffers.result.push(tri.clone());
1233                    }
1234                    1 => {
1235                        // One vertex in front - front part is 1 triangle, back part is a quad (2 tris)
1236                        let (front, back1, back2, d_f, d_b1, d_b2) = if f0 {
1237                            (tri.v0, tri.v1, tri.v2, d0, d1, d2)
1238                        } else if f1 {
1239                            (tri.v1, tri.v2, tri.v0, d1, d2, d0)
1240                        } else {
1241                            (tri.v2, tri.v0, tri.v1, d2, d0, d1)
1242                        };
1243
1244                        let denom1 = d_f - d_b1;
1245                        let denom2 = d_f - d_b2;
1246                        if denom1.abs() < 1e-12 || denom2.abs() < 1e-12 {
1247                            // Near-degenerate split — keep triangle as-is
1248                            buffers.next_remaining.push(tri.clone());
1249                            continue;
1250                        }
1251                        let t1 = (d_f / denom1).clamp(0.0, 1.0);
1252                        let t2 = (d_f / denom2).clamp(0.0, 1.0);
1253                        let p1 = front + (back1 - front) * t1;
1254                        let p2 = front + (back2 - front) * t2;
1255
1256                        // Front (inside): Triangle(front, p1, p2) - continues
1257                        buffers.next_remaining.push(Triangle::new(front, p1, p2));
1258
1259                        // Back (outside): quad (p1, back1, back2, p2) as 2 triangles - result
1260                        buffers.result.push(Triangle::new(p1, back1, back2));
1261                        buffers.result.push(Triangle::new(p1, back2, p2));
1262                    }
1263                    2 => {
1264                        // Two vertices in front - front part is quad (2 tris), back part is 1 triangle
1265                        let (front1, front2, back, d_f1, d_f2, d_b) = if !f0 {
1266                            (tri.v1, tri.v2, tri.v0, d1, d2, d0)
1267                        } else if !f1 {
1268                            (tri.v2, tri.v0, tri.v1, d2, d0, d1)
1269                        } else {
1270                            (tri.v0, tri.v1, tri.v2, d0, d1, d2)
1271                        };
1272
1273                        let denom1 = d_f1 - d_b;
1274                        let denom2 = d_f2 - d_b;
1275                        if denom1.abs() < 1e-12 || denom2.abs() < 1e-12 {
1276                            // Near-degenerate split — keep triangle as-is
1277                            buffers.next_remaining.push(tri.clone());
1278                            continue;
1279                        }
1280                        let t1 = (d_f1 / denom1).clamp(0.0, 1.0);
1281                        let t2 = (d_f2 / denom2).clamp(0.0, 1.0);
1282                        let p1 = front1 + (back - front1) * t1;
1283                        let p2 = front2 + (back - front2) * t2;
1284
1285                        // Front (inside): quad (front1, front2, p2, p1) as 2 tris - continues
1286                        buffers.next_remaining.push(Triangle::new(front1, front2, p1));
1287                        buffers.next_remaining.push(Triangle::new(front2, p2, p1));
1288
1289                        // Back (outside): Triangle(p1, p2, back) - result
1290                        buffers.result.push(Triangle::new(p1, p2, back));
1291                    }
1292                    _ => unreachable!(),
1293                }
1294            }
1295
1296            // Swap buffers instead of reallocating
1297            std::mem::swap(&mut buffers.remaining, &mut buffers.next_remaining);
1298        }
1299
1300        // 'remaining' triangles are inside ALL planes = inside box = discard
1301        // Add collected result_triangles to mesh
1302        for tri in &buffers.result {
1303            let base = result.vertex_count() as u32;
1304            result.add_vertex(tri.v0, *normal);
1305            result.add_vertex(tri.v1, *normal);
1306            result.add_vertex(tri.v2, *normal);
1307            result.add_triangle(base, base + 1, base + 2);
1308        }
1309    }
1310
1311}