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::mesh::{SubMesh, SubMeshCollection};
10use crate::{Error, Mesh, Point3, Result, Vector3};
11use ifc_lite_core::{DecodedEntity, EntityDecoder, IfcType};
12use nalgebra::Matrix4;
13use rustc_hash::{FxHashMap, FxHashSet};
14
15/// Epsilon for normalizing direction vectors (guards against zero-length).
16const NORMALIZE_EPSILON: f64 = 1e-12;
17/// Minimum opening volume (m³) below which CSG is skipped to avoid BSP instability.
18/// 0.0001 m³ ≈ 0.1 litre — filters artefacts while allowing small real openings (e.g. sleeves).
19const MIN_OPENING_VOLUME: f64 = 0.0001;
20/// Fraction of pre-CSG triangles the result must retain. CSG outputs with fewer
21/// triangles than `pre_count / CSG_TRIANGLE_RETENTION_DIVISOR` are rejected as
22/// BSP blowups.
23const CSG_TRIANGLE_RETENTION_DIVISOR: usize = 4;
24/// Minimum triangle count for a valid CSG result.
25const MIN_VALID_TRIANGLES: usize = 4;
26/// Maximum wrapper depth when drilling through mapped/boolean items to find an extrusion.
27const MAX_EXTRUSION_EXTRACT_DEPTH: usize = 32;
28
29/// Extract rotation columns from a 4x4 transform matrix.
30fn extract_rotation_columns(m: &Matrix4<f64>) -> (Vector3<f64>, Vector3<f64>, Vector3<f64>) {
31    (
32        Vector3::new(m[(0, 0)], m[(1, 0)], m[(2, 0)]),
33        Vector3::new(m[(0, 1)], m[(1, 1)], m[(2, 1)]),
34        Vector3::new(m[(0, 2)], m[(1, 2)], m[(2, 2)]),
35    )
36}
37
38/// Apply rotation from columns to a direction and normalize.
39fn rotate_and_normalize(
40    rot: &(Vector3<f64>, Vector3<f64>, Vector3<f64>),
41    dir: &Vector3<f64>,
42) -> Result<Vector3<f64>> {
43    (rot.0 * dir.x + rot.1 * dir.y + rot.2 * dir.z)
44        .try_normalize(NORMALIZE_EPSILON)
45        .ok_or_else(|| Error::geometry("Zero-length direction vector".to_string()))
46}
47
48// ---------------------------------------------------------------------------
49// Reveal face generation helpers
50// ---------------------------------------------------------------------------
51
52/// Determine the primary extrusion axis (0=X, 1=Y, 2=Z) from the opening's
53/// extrusion direction, or fall back to the wall's thinnest AABB dimension.
54#[inline]
55fn determine_extrusion_axis(
56    extrusion_dir: Option<&Vector3<f64>>,
57    wall_min: &Point3<f64>,
58    wall_max: &Point3<f64>,
59) -> usize {
60    if let Some(dir) = extrusion_dir {
61        let ax = dir.x.abs();
62        let ay = dir.y.abs();
63        let az = dir.z.abs();
64        if ax >= ay && ax >= az {
65            0
66        } else if ay >= az {
67            1
68        } else {
69            2
70        }
71    } else {
72        let dx = (wall_max.x - wall_min.x).abs();
73        let dy = (wall_max.y - wall_min.y).abs();
74        let dz = (wall_max.z - wall_min.z).abs();
75        if dx <= dy && dx <= dz {
76            0
77        } else if dy <= dz {
78            1
79        } else {
80            2
81        }
82    }
83}
84
85/// Read a coordinate from a `Point3` by axis index (0=X, 1=Y, 2=Z).
86#[inline(always)]
87fn axis_val(p: &Point3<f64>, axis: usize) -> f64 {
88    match axis {
89        0 => p.x,
90        1 => p.y,
91        _ => p.z,
92    }
93}
94
95/// Build a `Point3` given values for three named axes.
96#[inline(always)]
97fn point_from_axes(a: usize, va: f64, b: usize, vb: f64, c: usize, vc: f64) -> Point3<f64> {
98    let mut coords = [0.0_f64; 3];
99    coords[a] = va;
100    coords[b] = vb;
101    coords[c] = vc;
102    Point3::new(coords[0], coords[1], coords[2])
103}
104
105/// Build a unit `Vector3` pointing along the given axis with the given sign.
106#[inline(always)]
107fn vec_along_axis(axis: usize, sign: f64) -> Vector3<f64> {
108    let mut coords = [0.0_f64; 3];
109    coords[axis] = sign;
110    Vector3::new(coords[0], coords[1], coords[2])
111}
112
113/// Add a single reveal quad (2 triangles) to the mesh, auto-correcting winding
114/// order so the face normal matches the desired direction.
115#[inline]
116fn add_reveal_quad(
117    mesh: &mut Mesh,
118    p0: Point3<f64>,
119    p1: Point3<f64>,
120    p2: Point3<f64>,
121    p3: Point3<f64>,
122    desired_normal: Vector3<f64>,
123) {
124    let edge1 = p1 - p0;
125    let edge2 = p2 - p0;
126    let computed = edge1.cross(&edge2);
127
128    let base = mesh.vertex_count() as u32;
129    mesh.add_vertex(p0, desired_normal);
130    mesh.add_vertex(p1, desired_normal);
131    mesh.add_vertex(p2, desired_normal);
132    mesh.add_vertex(p3, desired_normal);
133
134    if computed.dot(&desired_normal) >= 0.0 {
135        mesh.add_triangle(base, base + 1, base + 2);
136        mesh.add_triangle(base, base + 2, base + 3);
137    } else {
138        mesh.add_triangle(base, base + 2, base + 1);
139        mesh.add_triangle(base, base + 3, base + 2);
140    }
141}
142
143/// Generate 4 reveal quads for a rectangular opening.
144///
145/// Reveals are the inner surfaces of the hole cut through the wall.  Each face
146/// spans the wall thickness (along the extrusion direction) and sits at one
147/// edge of the opening (top, bottom, left, right).
148///
149/// A reveal is **skipped** when the opening edge coincides with the wall
150/// boundary (e.g. a door that starts at floor level has no sill reveal).
151fn generate_reveal_quads(
152    mesh: &mut Mesh,
153    open_min: &Point3<f64>,
154    open_max: &Point3<f64>,
155    wall_min: &Point3<f64>,
156    wall_max: &Point3<f64>,
157    extrusion_dir: Option<&Vector3<f64>>,
158) {
159    let ea = determine_extrusion_axis(extrusion_dir, wall_min, wall_max);
160
161    // Reveal depth along the extrusion axis, clamped to the wall-opening
162    // intersection so reveals never extend beyond either surface.
163    let d_min = axis_val(wall_min, ea).max(axis_val(open_min, ea));
164    let d_max = axis_val(wall_max, ea).min(axis_val(open_max, ea));
165    if d_max - d_min < 1e-4 {
166        return; // No wall thickness to reveal
167    }
168
169    // The two cross-axes (the ones that are NOT the extrusion axis).
170    let cross: [usize; 2] = match ea {
171        0 => [1, 2],
172        1 => [0, 2],
173        _ => [0, 1],
174    };
175
176    // Require positive overlap on both cross-axes before emitting any quads.
177    // Guards callers that apply voids per sub-mesh (multi-layer walls) where a
178    // sub-mesh AABB may not overlap the opening at all — without this check,
179    // floating reveal faces would be emitted far from the sub-mesh geometry.
180    for &ax in &cross {
181        let ov_min = axis_val(open_min, ax).max(axis_val(wall_min, ax));
182        let ov_max = axis_val(open_max, ax).min(axis_val(wall_max, ax));
183        if ov_max - ov_min < 1e-4 {
184            return;
185        }
186    }
187
188    for (i, &ca) in cross.iter().enumerate() {
189        let oa = cross[1 - i]; // the *other* cross-axis
190        // Clamp the orthogonal cross-axis extent to the wall so reveals never
191        // overshoot the mesh boundary (e.g. an opening taller than its slab).
192        let o_min = axis_val(open_min, oa).max(axis_val(wall_min, oa));
193        let o_max = axis_val(open_max, oa).min(axis_val(wall_max, oa));
194
195        // --- Face at open_min[ca] — normal points +ca (into opening) ---
196        let face_lo = axis_val(open_min, ca);
197        if face_lo > axis_val(wall_min, ca) + 1e-4 {
198            add_reveal_quad(
199                mesh,
200                point_from_axes(ea, d_min, ca, face_lo, oa, o_min),
201                point_from_axes(ea, d_max, ca, face_lo, oa, o_min),
202                point_from_axes(ea, d_max, ca, face_lo, oa, o_max),
203                point_from_axes(ea, d_min, ca, face_lo, oa, o_max),
204                vec_along_axis(ca, 1.0),
205            );
206        }
207
208        // --- Face at open_max[ca] — normal points −ca (into opening) ---
209        let face_hi = axis_val(open_max, ca);
210        if face_hi < axis_val(wall_max, ca) - 1e-4 {
211            add_reveal_quad(
212                mesh,
213                point_from_axes(ea, d_min, ca, face_hi, oa, o_max),
214                point_from_axes(ea, d_max, ca, face_hi, oa, o_max),
215                point_from_axes(ea, d_max, ca, face_hi, oa, o_min),
216                point_from_axes(ea, d_min, ca, face_hi, oa, o_min),
217                vec_along_axis(ca, -1.0),
218            );
219        }
220    }
221}
222
223/// Whether the representation type is geometry we can process.
224fn is_body_representation(rep_type: &str) -> bool {
225    matches!(
226        rep_type,
227        "Body"
228            | "SweptSolid"
229            | "Brep"
230            | "CSG"
231            | "Clipping"
232            | "Tessellation"
233            | "MappedRepresentation"
234            | "SolidModel"
235            | "SurfaceModel"
236            | "AdvancedSweptSolid"
237            | "AdvancedBrep"
238    )
239}
240
241/// Classification of an opening for void subtraction.
242#[derive(Clone)]
243enum OpeningType {
244    /// Rectangular opening with AABB clipping
245    /// Fields: (min_bounds, max_bounds, extrusion_direction)
246    Rectangular(Point3<f64>, Point3<f64>, Option<Vector3<f64>>),
247    /// Diagonal rectangular opening with mesh geometry for batched rotation clipping
248    /// Fields: (opening_mesh, extrusion_direction)
249    DiagonalRectangular(Mesh, Vector3<f64>),
250    /// Non-rectangular opening (circular, arched, or floor openings with rotated footprint)
251    /// Uses full CSG subtraction with actual mesh geometry
252    NonRectangular(Mesh),
253}
254
255/// Reusable buffers for triangle clipping operations
256///
257/// This struct eliminates per-triangle allocations in clip_triangle_against_box
258/// by reusing Vec buffers across multiple clipping operations.
259struct ClipBuffers {
260    /// Triangles to output (outside the box)
261    result: TriangleVec,
262    /// Triangles remaining to be processed
263    remaining: TriangleVec,
264    /// Next iteration's remaining triangles (swap buffer)
265    next_remaining: TriangleVec,
266}
267
268impl ClipBuffers {
269    /// Create new empty buffers
270    fn new() -> Self {
271        Self {
272            result: TriangleVec::new(),
273            remaining: TriangleVec::new(),
274            next_remaining: TriangleVec::new(),
275        }
276    }
277
278    /// Clear all buffers for reuse
279    #[inline]
280    fn clear(&mut self) {
281        self.result.clear();
282        self.remaining.clear();
283        self.next_remaining.clear();
284    }
285}
286
287/// Pre-computed per-element void subtraction data.
288///
289/// Building this is expensive: `classify_openings` re-runs `process_element`
290/// on each `IfcOpeningElement`, and clipping-plane extraction resolves the
291/// element's representation. Once built, it can be reused across every
292/// sub-mesh of the same element without re-doing any of that work, so the
293/// per-sub-mesh void path in
294/// [`GeometryRouter::process_element_with_submeshes_and_voids`] pays the
295/// classification cost once per element rather than once per sub-mesh.
296pub(super) struct VoidContext {
297    /// All classified openings. The diagonal-opening pass needs the raw list
298    /// (unmerged) so its per-item box rotation stays accurate.
299    openings: Vec<OpeningType>,
300    /// Rectangular openings merged into larger boxes to prevent O(2^N)
301    /// triangle growth when many adjacent openings tile a surface.
302    merged_openings: Vec<OpeningType>,
303    /// Clipping planes (e.g. roof clips) already transformed to world space.
304    world_clipping_planes: Vec<(Point3<f64>, Vector3<f64>, bool)>,
305}
306
307impl VoidContext {
308    fn is_noop(&self) -> bool {
309        self.openings.is_empty() && self.world_clipping_planes.is_empty()
310    }
311}
312
313impl GeometryRouter {
314    /// Get individual bounding boxes for each representation item in an opening element.
315    /// This handles disconnected geometry (e.g., two separate window openings in one IfcOpeningElement)
316    /// by returning separate bounds for each item instead of one combined bounding box.
317
318    /// Extract extrusion direction and position transform from IfcExtrudedAreaSolid
319    /// Returns (local_direction, position_transform)
320    fn extract_extrusion_direction_from_solid(
321        &self,
322        solid: &DecodedEntity,
323        decoder: &mut EntityDecoder,
324    ) -> Option<(Vector3<f64>, Option<Matrix4<f64>>)> {
325        // Get ExtrudedDirection (attribute 2: IfcDirection)
326        let direction_attr = solid.get(2)?;
327        let direction_entity = decoder.resolve_ref(direction_attr).ok()??;
328        let local_dir = self.parse_direction(&direction_entity).ok()?;
329
330        // Get Position transform (attribute 1: IfcAxis2Placement3D)
331        let position_transform = if let Some(pos_attr) = solid.get(1) {
332            if !pos_attr.is_null() {
333                if let Ok(Some(pos_entity)) = decoder.resolve_ref(pos_attr) {
334                    if pos_entity.ifc_type == IfcType::IfcAxis2Placement3D {
335                        self.parse_axis2_placement_3d(&pos_entity, decoder).ok()
336                    } else {
337                        None
338                    }
339                } else {
340                    None
341                }
342            } else {
343                None
344            }
345        } else {
346            None
347        };
348
349        Some((local_dir, position_transform))
350    }
351
352    /// Recursively extract extrusion direction and position transform from representation item
353    /// Handles IfcExtrudedAreaSolid, IfcBooleanClippingResult, and IfcMappedItem
354    /// Returns (local_direction, position_transform) where direction is in local space
355    fn extract_extrusion_direction_recursive(
356        &self,
357        item: &DecodedEntity,
358        decoder: &mut EntityDecoder,
359    ) -> Option<(Vector3<f64>, Option<Matrix4<f64>>)> {
360        let mut current = item.clone();
361        let mut visited = FxHashSet::default();
362        let mut mapping_chain: Option<Matrix4<f64>> = None;
363
364        for _depth in 0..MAX_EXTRUSION_EXTRACT_DEPTH {
365            if !visited.insert(current.id) {
366                return None;
367            }
368
369            match current.ifc_type {
370                IfcType::IfcExtrudedAreaSolid => {
371                    let (dir, position_transform) =
372                        self.extract_extrusion_direction_from_solid(&current, decoder)?;
373                    let combined = match (mapping_chain.as_ref(), position_transform) {
374                        (Some(chain), Some(pos)) => Some(chain * pos),
375                        (Some(chain), None) => Some(chain.clone()),
376                        (None, Some(pos)) => Some(pos),
377                        (None, None) => None,
378                    };
379                    return Some((dir, combined));
380                }
381                IfcType::IfcBooleanClippingResult | IfcType::IfcBooleanResult => {
382                    // FirstOperand (attribute 1) contains base geometry
383                    let first_attr = current.get(1)?;
384                    current = decoder.resolve_ref(first_attr).ok()??;
385                }
386                IfcType::IfcMappedItem => {
387                    // MappingSource (attribute 0) -> MappedRepresentation -> Items
388                    let source_attr = current.get(0)?;
389                    let source = decoder.resolve_ref(source_attr).ok()??;
390                    // RepresentationMap.MappedRepresentation is attribute 1
391                    let rep_attr = source.get(1)?;
392                    let rep = decoder.resolve_ref(rep_attr).ok()??;
393
394                    // MappingTarget (attribute 1) -> instance transform
395                    if let Some(target_attr) = current.get(1) {
396                        if !target_attr.is_null() {
397                            if let Ok(Some(target)) = decoder.resolve_ref(target_attr) {
398                                if let Ok(map) =
399                                    self.parse_cartesian_transformation_operator(&target, decoder)
400                                {
401                                    mapping_chain = Some(match mapping_chain.take() {
402                                        Some(chain) => chain * map,
403                                        None => map,
404                                    });
405                                }
406                            }
407                        }
408                    }
409
410                    // Get first item from representation
411                    let items_attr = rep.get(3)?;
412                    let items = decoder.resolve_ref_list(items_attr).ok()?;
413                    current = items.first()?.clone();
414                }
415                _ => return None,
416            }
417        }
418
419        None
420    }
421
422    /// Get per-item meshes for an opening element, transformed to world coordinates.
423    /// Uses the same `transform_mesh` path as `process_element` to ensure identical
424    /// coordinate handling (ObjectPlacement, unit scaling, conditional RTC offset).
425    pub fn get_opening_item_meshes_world(
426        &self,
427        element: &DecodedEntity,
428        decoder: &mut EntityDecoder,
429    ) -> Result<Vec<Mesh>> {
430        let representation_attr = element.get(6).ok_or_else(|| {
431            Error::geometry("Element has no representation attribute".to_string())
432        })?;
433        if representation_attr.is_null() {
434            return Ok(vec![]);
435        }
436
437        let representation = decoder
438            .resolve_ref(representation_attr)?
439            .ok_or_else(|| Error::geometry("Failed to resolve representation".to_string()))?;
440        let representations_attr = representation.get(2).ok_or_else(|| {
441            Error::geometry("ProductDefinitionShape missing Representations".to_string())
442        })?;
443        let representations = decoder.resolve_ref_list(representations_attr)?;
444
445        // Get the same placement transform that apply_placement uses
446        let mut placement_transform = self
447            .get_placement_transform_from_element(element, decoder)
448            .unwrap_or_else(|_| Matrix4::identity());
449        self.scale_transform(&mut placement_transform);
450
451        let mut item_meshes = Vec::new();
452
453        for shape_rep in representations {
454            if shape_rep.ifc_type != IfcType::IfcShapeRepresentation {
455                continue;
456            }
457            if let Some(rep_type_attr) = shape_rep.get(2) {
458                if let Some(rep_type) = rep_type_attr.as_string() {
459                    if !is_body_representation(rep_type) {
460                        continue;
461                    }
462                }
463            }
464            let items_attr = match shape_rep.get(3) {
465                Some(attr) => attr,
466                None => continue,
467            };
468            let items = match decoder.resolve_ref_list(items_attr) {
469                Ok(items) => items,
470                Err(_) => continue,
471            };
472
473            for item in items {
474                let mut mesh = match self.process_representation_item(&item, decoder) {
475                    Ok(m) if !m.is_empty() => m,
476                    _ => continue,
477                };
478
479                // Use the same transform_mesh as process_element → apply_placement
480                // This handles ObjectPlacement, unit scaling, and conditional RTC
481                self.transform_mesh_world(&mut mesh, &placement_transform);
482
483                item_meshes.push(mesh);
484            }
485        }
486
487        Ok(item_meshes)
488    }
489
490    /// Extrusion direction is in world coordinates, normalized
491    /// Returns None for extrusion direction if it cannot be extracted (fallback to bounds-only)
492    pub fn get_opening_item_bounds_with_direction(
493        &self,
494        element: &DecodedEntity,
495        decoder: &mut EntityDecoder,
496    ) -> Result<Vec<(Point3<f64>, Point3<f64>, Option<Vector3<f64>>)>> {
497        // Get representation (attribute 6 for most building elements)
498        let representation_attr = element.get(6).ok_or_else(|| {
499            Error::geometry("Element has no representation attribute".to_string())
500        })?;
501
502        if representation_attr.is_null() {
503            return Ok(vec![]);
504        }
505
506        let representation = decoder
507            .resolve_ref(representation_attr)?
508            .ok_or_else(|| Error::geometry("Failed to resolve representation".to_string()))?;
509
510        // Get representations list
511        let representations_attr = representation.get(2).ok_or_else(|| {
512            Error::geometry("ProductDefinitionShape missing Representations".to_string())
513        })?;
514
515        let representations = decoder.resolve_ref_list(representations_attr)?;
516
517        // Get placement transform
518        let mut placement_transform = self
519            .get_placement_transform_from_element(element, decoder)
520            .unwrap_or_else(|_| Matrix4::identity());
521        self.scale_transform(&mut placement_transform);
522
523        let mut bounds_list = Vec::new();
524
525        for shape_rep in representations {
526            if shape_rep.ifc_type != IfcType::IfcShapeRepresentation {
527                continue;
528            }
529
530            // Check representation type
531            if let Some(rep_type_attr) = shape_rep.get(2) {
532                if let Some(rep_type) = rep_type_attr.as_string() {
533                    if !is_body_representation(rep_type) {
534                        continue;
535                    }
536                }
537            }
538
539            // Get items list
540            let items_attr = match shape_rep.get(3) {
541                Some(attr) => attr,
542                None => continue,
543            };
544
545            let items = match decoder.resolve_ref_list(items_attr) {
546                Ok(items) => items,
547                Err(_) => continue,
548            };
549
550            // Process each item separately to get individual bounds
551            for item in items {
552                // Try to extract extrusion direction recursively (handles wrappers)
553                let extrusion_direction = if let Some((local_dir, position_transform)) =
554                    self.extract_extrusion_direction_recursive(&item, decoder)
555                {
556                    // Transform extrusion direction from local to world coordinates
557                    if let Some(pos_transform) = position_transform {
558                        let pos_rot = extract_rotation_columns(&pos_transform);
559                        let world_dir = rotate_and_normalize(&pos_rot, &local_dir)?;
560
561                        let element_rot = extract_rotation_columns(&placement_transform);
562                        let final_dir = rotate_and_normalize(&element_rot, &world_dir)?;
563
564                        Some(final_dir)
565                    } else {
566                        let element_rot = extract_rotation_columns(&placement_transform);
567                        let final_dir = rotate_and_normalize(&element_rot, &local_dir)?;
568
569                        Some(final_dir)
570                    }
571                } else {
572                    None
573                };
574
575                // Get mesh bounds (same as original function)
576                let mesh = match self.process_representation_item(&item, decoder) {
577                    Ok(m) if !m.is_empty() => m,
578                    _ => continue,
579                };
580
581                // Get bounds and transform to world coordinates
582                let (mesh_min, mesh_max) = mesh.bounds();
583
584                // Transform corner points to world coordinates
585                let corners = [
586                    Point3::new(mesh_min.x as f64, mesh_min.y as f64, mesh_min.z as f64),
587                    Point3::new(mesh_max.x as f64, mesh_min.y as f64, mesh_min.z as f64),
588                    Point3::new(mesh_min.x as f64, mesh_max.y as f64, mesh_min.z as f64),
589                    Point3::new(mesh_max.x as f64, mesh_max.y as f64, mesh_min.z as f64),
590                    Point3::new(mesh_min.x as f64, mesh_min.y as f64, mesh_max.z as f64),
591                    Point3::new(mesh_max.x as f64, mesh_min.y as f64, mesh_max.z as f64),
592                    Point3::new(mesh_min.x as f64, mesh_max.y as f64, mesh_max.z as f64),
593                    Point3::new(mesh_max.x as f64, mesh_max.y as f64, mesh_max.z as f64),
594                ];
595
596                // Transform all corners and compute new AABB
597                let transformed: Vec<Point3<f64>> = corners
598                    .iter()
599                    .map(|p| placement_transform.transform_point(p))
600                    .collect();
601
602                let world_min = Point3::new(
603                    transformed
604                        .iter()
605                        .map(|p| p.x)
606                        .fold(f64::INFINITY, f64::min),
607                    transformed
608                        .iter()
609                        .map(|p| p.y)
610                        .fold(f64::INFINITY, f64::min),
611                    transformed
612                        .iter()
613                        .map(|p| p.z)
614                        .fold(f64::INFINITY, f64::min),
615                );
616                let world_max = Point3::new(
617                    transformed
618                        .iter()
619                        .map(|p| p.x)
620                        .fold(f64::NEG_INFINITY, f64::max),
621                    transformed
622                        .iter()
623                        .map(|p| p.y)
624                        .fold(f64::NEG_INFINITY, f64::max),
625                    transformed
626                        .iter()
627                        .map(|p| p.z)
628                        .fold(f64::NEG_INFINITY, f64::max),
629                );
630
631                // Apply RTC offset to opening bounds so they match wall mesh coordinate system
632                // Wall mesh positions have RTC subtracted during transform_mesh, so opening bounds must match
633                let rtc = self.rtc_offset;
634                let rtc_min = Point3::new(
635                    world_min.x - rtc.0,
636                    world_min.y - rtc.1,
637                    world_min.z - rtc.2,
638                );
639                let rtc_max = Point3::new(
640                    world_max.x - rtc.0,
641                    world_max.y - rtc.1,
642                    world_max.z - rtc.2,
643                );
644
645                bounds_list.push((rtc_min, rtc_max, extrusion_direction));
646            }
647        }
648
649        Ok(bounds_list)
650    }
651
652    /// Process element with void subtraction (openings)
653    /// Process element with voids using optimized plane clipping
654    ///
655    /// This approach is more efficient than full 3D CSG for rectangular openings:
656    /// 1. Get chamfered wall mesh (preserves chamfered corners)
657    /// 2. For each opening, use optimized box cutting with internal face generation
658    /// 3. Apply any clipping operations (roof clips) from original representation
659    #[inline]
660    /// Process an element with void subtraction (openings).
661    ///
662    /// This function handles three distinct cases for cutting openings:
663    ///
664    /// 1. **Floor/Slab openings** (vertical Z-extrusion): Uses CSG with actual mesh geometry
665    ///    because the XY footprint may be rotated relative to the slab orientation.
666    ///
667    /// 2. **Wall openings** (horizontal X/Y-extrusion, axis-aligned): Uses AABB clipping
668    ///    for fast, accurate cutting of rectangular openings.
669    ///
670    /// 3. **Diagonal wall openings**: Uses AABB clipping without internal face generation
671    ///    to avoid rotation artifacts.
672    ///
673    /// Reveal faces (inner surfaces of the opening holes) are generated as a
674    /// post-clipping step for rectangular and diagonal openings.  For diagonal
675    /// walls the geometry is computed in a rotated axis-aligned frame and
676    /// rotated back, giving correct results for any wall orientation.
677    pub fn process_element_with_voids(
678        &self,
679        element: &DecodedEntity,
680        decoder: &mut EntityDecoder,
681        void_index: &FxHashMap<u32, Vec<u32>>,
682    ) -> Result<Mesh> {
683        let opening_ids = match void_index.get(&element.id) {
684            Some(ids) if !ids.is_empty() => ids,
685            _ => {
686                return self.process_element(element, decoder);
687            }
688        };
689
690        let wall_mesh = match self.process_element(element, decoder) {
691            Ok(m) => m,
692            Err(_) => {
693                return self.process_element(element, decoder);
694            }
695        };
696
697        Ok(self.apply_voids_to_mesh(wall_mesh, element, opening_ids, decoder))
698    }
699
700    /// Apply opening subtraction and clipping planes to an already-built mesh.
701    ///
702    /// Shared entry point used by both the single-mesh path
703    /// ([`process_element_with_voids`]) and the per-sub-mesh path
704    /// ([`process_element_with_submeshes_and_voids`]). The incoming mesh is
705    /// expected to be in the same (world) coordinate space as the element —
706    /// i.e. placement already applied — because opening and clip geometry are
707    /// resolved in world coordinates.
708    ///
709    /// Returns the input mesh unchanged when it is invalid or when no
710    /// openings/clips apply, so callers never lose their input on a
711    /// degenerate opening set.
712    pub(super) fn apply_voids_to_mesh(
713        &self,
714        mesh: Mesh,
715        element: &DecodedEntity,
716        opening_ids: &[u32],
717        decoder: &mut EntityDecoder,
718    ) -> Mesh {
719        let ctx = self.build_void_context(element, opening_ids, decoder);
720        self.apply_void_context(mesh, &ctx)
721    }
722
723    /// Classify openings and extract clipping planes for an element.
724    ///
725    /// This is the expensive half of void subtraction — it decodes every
726    /// `IfcOpeningElement` (running `process_element` on each), classifies
727    /// them as rectangular / diagonal / non-rectangular, merges adjacent
728    /// rectangles, and transforms clipping planes to world space. The
729    /// output is reusable across every sub-mesh of the same element.
730    pub(super) fn build_void_context(
731        &self,
732        element: &DecodedEntity,
733        opening_ids: &[u32],
734        decoder: &mut EntityDecoder,
735    ) -> VoidContext {
736        let world_clipping_planes: Vec<(Point3<f64>, Vector3<f64>, bool)> =
737            if self.has_clipping_planes(element, decoder) {
738                let mut object_placement_transform =
739                    match self.get_placement_transform_from_element(element, decoder) {
740                        Ok(t) => t,
741                        Err(_) => Matrix4::identity(),
742                    };
743                self.scale_transform(&mut object_placement_transform);
744
745                let clipping_planes = match self.extract_base_profile_and_clips(element, decoder) {
746                    Ok((_profile, _depth, _axis, _origin, _transform, clips)) => clips,
747                    Err(_) => Vec::new(),
748                };
749
750                clipping_planes
751                    .iter()
752                    .map(|(point, normal, agreement)| {
753                        let world_point = object_placement_transform.transform_point(point);
754                        let rotation = object_placement_transform.fixed_view::<3, 3>(0, 0);
755                        let world_normal = (rotation * normal).normalize();
756                        (world_point, world_normal, *agreement)
757                    })
758                    .collect()
759            } else {
760                Vec::new()
761            };
762
763        let openings = self.classify_openings(opening_ids, decoder);
764        let merged_openings = Self::merge_rectangular_openings(&openings);
765
766        VoidContext {
767            openings,
768            merged_openings,
769            world_clipping_planes,
770        }
771    }
772
773    /// Apply a pre-built `VoidContext` to a single mesh.
774    ///
775    /// This is the cheap per-mesh half of void subtraction: it re-reads the
776    /// mesh bounds (which differ per sub-mesh), extends rectangular openings
777    /// along their extrusion axis so they fully penetrate the mesh, runs the
778    /// batched rectangular clip, then applies the CSG and clipping-plane
779    /// passes. All the classification work has already been done in
780    /// [`GeometryRouter::build_void_context`].
781    pub(super) fn apply_void_context(&self, mesh: Mesh, ctx: &VoidContext) -> Mesh {
782        if ctx.is_noop() {
783            return mesh;
784        }
785
786        let clipper = ClippingProcessor::new();
787        let mut result = mesh;
788
789        let (wall_min_f32, wall_max_f32) = result.bounds();
790        let wall_min = Point3::new(
791            wall_min_f32.x as f64,
792            wall_min_f32.y as f64,
793            wall_min_f32.z as f64,
794        );
795        let wall_max = Point3::new(
796            wall_max_f32.x as f64,
797            wall_max_f32.y as f64,
798            wall_max_f32.z as f64,
799        );
800
801        let wall_valid = !result.is_empty()
802            && result.positions.iter().all(|&v| v.is_finite())
803            && result.triangle_count() >= 4;
804
805        if !wall_valid {
806            return result;
807        }
808
809        let mut csg_operation_count = 0;
810        const MAX_CSG_OPERATIONS: usize = 10;
811
812        self.apply_diagonal_openings(&mut result, &ctx.openings, &wall_min, &wall_max);
813
814        let mut rect_boxes: Vec<(Point3<f64>, Point3<f64>)> = Vec::new();
815        // Keep extrusion directions alongside boxes for reveal generation.
816        let mut rect_dirs: Vec<Option<Vector3<f64>>> = Vec::new();
817        let mut non_rect_openings: Vec<&OpeningType> = Vec::new();
818
819        for opening in &ctx.merged_openings {
820            match opening {
821                OpeningType::Rectangular(open_min, open_max, extrusion_dir) => {
822                    let (final_min, final_max) = if let Some(dir) = extrusion_dir {
823                        self.extend_opening_along_direction(
824                            *open_min, *open_max, wall_min, wall_max, *dir,
825                        )
826                    } else {
827                        (*open_min, *open_max)
828                    };
829                    rect_boxes.push((final_min, final_max));
830                    rect_dirs.push(*extrusion_dir);
831                }
832                other => {
833                    non_rect_openings.push(other);
834                }
835            }
836        }
837
838        if !rect_boxes.is_empty() {
839            let (new_result, processed) =
840                self.cut_multiple_rectangular_openings(&result, &rect_boxes);
841            result = new_result;
842
843            // Generate reveal faces only for openings that were actually cut.
844            // The triangle cap inside `cut_multiple_rectangular_openings` may
845            // have short-circuited the loop, leaving a suffix of boxes
846            // unprocessed — emitting reveals for them would add floating
847            // interior faces without a matching cutout.
848            for (i, (open_min, open_max)) in rect_boxes.iter().enumerate().take(processed) {
849                generate_reveal_quads(
850                    &mut result,
851                    open_min,
852                    open_max,
853                    &wall_min,
854                    &wall_max,
855                    rect_dirs[i].as_ref(),
856                );
857            }
858        }
859
860        for opening in &non_rect_openings {
861            match *opening {
862                OpeningType::Rectangular(..) | OpeningType::DiagonalRectangular(..) => {}
863                OpeningType::NonRectangular(ref opening_mesh) => {
864                    if csg_operation_count >= MAX_CSG_OPERATIONS {
865                        continue;
866                    }
867
868                    let opening_valid = !opening_mesh.is_empty()
869                        && opening_mesh.positions.iter().all(|&v| v.is_finite())
870                        && opening_mesh.positions.len() >= 9;
871
872                    if !opening_valid {
873                        continue;
874                    }
875
876                    let (result_min, result_max) = result.bounds();
877                    let (open_min_f32, open_max_f32) = opening_mesh.bounds();
878                    let no_overlap = open_max_f32.x < result_min.x
879                        || open_min_f32.x > result_max.x
880                        || open_max_f32.y < result_min.y
881                        || open_min_f32.y > result_max.y
882                        || open_max_f32.z < result_min.z
883                        || open_min_f32.z > result_max.z;
884                    if no_overlap {
885                        continue;
886                    }
887
888                    let open_vol = (open_max_f32.x - open_min_f32.x)
889                        * (open_max_f32.y - open_min_f32.y)
890                        * (open_max_f32.z - open_min_f32.z);
891                    if open_vol < MIN_OPENING_VOLUME as f32 {
892                        continue;
893                    }
894
895                    let tri_before = result.triangle_count();
896                    match clipper.subtract_mesh(&result, opening_mesh) {
897                        Ok(csg_result) => {
898                            let min_tris = (tri_before / CSG_TRIANGLE_RETENTION_DIVISOR)
899                                .max(MIN_VALID_TRIANGLES);
900                            if !csg_result.is_empty() && csg_result.triangle_count() >= min_tris {
901                                result = csg_result;
902                            }
903                        }
904                        Err(_) => {}
905                    }
906                    csg_operation_count += 1;
907                }
908            }
909        }
910
911        if !ctx.world_clipping_planes.is_empty() {
912            for (plane_point, plane_normal, agreement) in &ctx.world_clipping_planes {
913                let clip_normal = if *agreement {
914                    *plane_normal
915                } else {
916                    -*plane_normal
917                };
918
919                let plane = Plane::new(*plane_point, clip_normal);
920                if let Ok(clipped) = clipper.clip_mesh(&result, &plane) {
921                    if !clipped.is_empty() {
922                        result = clipped;
923                    }
924                }
925            }
926        }
927
928        result
929    }
930
931    /// Process an element into per-item sub-meshes with opening subtraction.
932    ///
933    /// Mirrors [`process_element_with_voids`] but preserves each
934    /// `IfcShapeRepresentation` item as its own sub-mesh so that callers can
935    /// look up a direct `IfcStyledItem` color per geometry item (e.g. the
936    /// three extrusion layers of a multi-layer wall). The opening(s) are
937    /// subtracted from each sub-mesh independently so that windows and doors
938    /// cut through every material layer they intersect.
939    ///
940    /// Returns an empty collection when there are no openings (callers should
941    /// fall back to [`process_element_with_submeshes`]) or when every
942    /// sub-mesh is destroyed by void subtraction.
943    pub fn process_element_with_submeshes_and_voids(
944        &self,
945        element: &DecodedEntity,
946        decoder: &mut EntityDecoder,
947        void_index: &FxHashMap<u32, Vec<u32>>,
948    ) -> Result<SubMeshCollection> {
949        // Layered single-solid path: slice the element's base mesh by its
950        // material-layer buildup AFTER subtracting voids. This produces one
951        // sub-mesh per layer keyed by IfcMaterial id, so layers show up as
952        // individual colors even when the underlying geometry is a single
953        // swept solid.
954        if let Some(layered) = self.try_layered_sub_meshes(element, decoder, Some(void_index)) {
955            return Ok(layered);
956        }
957
958        let opening_ids = match void_index.get(&element.id) {
959            Some(ids) if !ids.is_empty() => ids.clone(),
960            _ => return Ok(SubMeshCollection::new()),
961        };
962
963        let sub_meshes = self.process_element_with_submeshes(element, decoder)?;
964        if sub_meshes.is_empty() {
965            return Ok(SubMeshCollection::new());
966        }
967
968        // Classify openings + resolve clipping planes ONCE per element. Doing
969        // this per sub-mesh would re-run `process_element` on every opening
970        // and re-extract clipping planes N times, multiplying the expensive
971        // parsing/CSG setup by the sub-mesh count on the exact elements this
972        // path targets (multi-layer walls with windows).
973        let ctx = self.build_void_context(element, &opening_ids, decoder);
974
975        let mut voided = SubMeshCollection::new();
976        for sub in sub_meshes.sub_meshes {
977            let geometry_id = sub.geometry_id;
978            let voided_mesh = self.apply_void_context(sub.mesh, &ctx);
979            if !voided_mesh.is_empty() {
980                voided.sub_meshes.push(SubMesh::new(geometry_id, voided_mesh));
981            }
982        }
983
984        Ok(voided)
985    }
986
987    fn classify_openings(
988        &self,
989        opening_ids: &[u32],
990        decoder: &mut EntityDecoder,
991    ) -> Vec<OpeningType> {
992        let mut openings: Vec<OpeningType> = Vec::new();
993        for &opening_id in opening_ids.iter() {
994            let opening_entity = match decoder.decode_by_id(opening_id) {
995                Ok(e) => e,
996                Err(_) => continue,
997            };
998
999            let opening_mesh = match self.process_element(&opening_entity, decoder) {
1000                Ok(m) if !m.is_empty() => m,
1001                _ => continue,
1002            };
1003
1004            let vertex_count = opening_mesh.positions.len() / 3;
1005
1006            if vertex_count > 100 {
1007                openings.push(OpeningType::NonRectangular(opening_mesh));
1008            } else {
1009                let item_bounds_with_dir = self
1010                    .get_opening_item_bounds_with_direction(&opening_entity, decoder)
1011                    .unwrap_or_default();
1012
1013                if !item_bounds_with_dir.is_empty() {
1014                    let is_floor_opening = item_bounds_with_dir
1015                        .iter()
1016                        .any(|(_, _, dir)| dir.map(|d| d.z.abs() > 0.95).unwrap_or(false));
1017
1018                    if is_floor_opening && vertex_count > 0 {
1019                        openings.push(OpeningType::NonRectangular(opening_mesh.clone()));
1020                    } else {
1021                        let any_diagonal = item_bounds_with_dir.iter().any(|(_, _, dir)| {
1022                            dir.map(|d| {
1023                                const AXIS_THRESHOLD: f64 = 0.95;
1024                                let abs_x = d.x.abs();
1025                                let abs_y = d.y.abs();
1026                                let abs_z = d.z.abs();
1027                                !(abs_x > AXIS_THRESHOLD
1028                                    || abs_y > AXIS_THRESHOLD
1029                                    || abs_z > AXIS_THRESHOLD)
1030                            })
1031                            .unwrap_or(false)
1032                        });
1033
1034                        if any_diagonal {
1035                            // Only use the diagonal path if we have an actual extrusion direction;
1036                            // without one the rotation would be arbitrary and produce wrong cuts.
1037                            if let Some(dir) = item_bounds_with_dir.iter().find_map(|(_, _, d)| *d)
1038                            {
1039                                let item_meshes = self
1040                                    .get_opening_item_meshes_world(&opening_entity, decoder)
1041                                    .unwrap_or_default();
1042                                if item_meshes.is_empty() {
1043                                    openings.push(OpeningType::DiagonalRectangular(
1044                                        opening_mesh.clone(),
1045                                        dir,
1046                                    ));
1047                                } else {
1048                                    for item_mesh in item_meshes {
1049                                        openings
1050                                            .push(OpeningType::DiagonalRectangular(item_mesh, dir));
1051                                    }
1052                                }
1053                            } else {
1054                                // No direction available — fall back to CSG
1055                                openings.push(OpeningType::NonRectangular(opening_mesh.clone()));
1056                            }
1057                        } else {
1058                            for (min_pt, max_pt, extrusion_dir) in item_bounds_with_dir {
1059                                openings.push(OpeningType::Rectangular(
1060                                    min_pt,
1061                                    max_pt,
1062                                    extrusion_dir,
1063                                ));
1064                            }
1065                        }
1066                    }
1067                } else {
1068                    let (open_min, open_max) = opening_mesh.bounds();
1069                    let min_f64 =
1070                        Point3::new(open_min.x as f64, open_min.y as f64, open_min.z as f64);
1071                    let max_f64 =
1072                        Point3::new(open_max.x as f64, open_max.y as f64, open_max.z as f64);
1073
1074                    openings.push(OpeningType::Rectangular(min_f64, max_f64, None));
1075                }
1076            }
1077        }
1078        openings
1079    }
1080
1081    /// Merge adjacent/overlapping rectangular openings into larger boxes.
1082    /// This prevents exponential triangle growth when many small openings
1083    /// tile a wall surface — each clip creates boundary triangles that get
1084    /// re-split by the next clip, causing O(2^N) growth.
1085    fn merge_rectangular_openings(openings: &[OpeningType]) -> Vec<OpeningType> {
1086        const MERGE_TOLERANCE: f64 = 0.01; // 1cm tolerance for adjacency
1087
1088        // Separate rectangular and non-rectangular openings
1089        let mut rects: Vec<(Point3<f64>, Point3<f64>, Option<Vector3<f64>>)> = Vec::new();
1090        let mut others: Vec<OpeningType> = Vec::new();
1091
1092        for opening in openings {
1093            match opening {
1094                OpeningType::Rectangular(min, max, dir) => {
1095                    rects.push((*min, *max, *dir));
1096                }
1097                other => others.push(other.clone()),
1098            }
1099        }
1100
1101        // Iteratively merge overlapping/adjacent rectangles
1102        let mut merged = true;
1103        while merged {
1104            merged = false;
1105            let mut i = 0;
1106            while i < rects.len() {
1107                let mut j = i + 1;
1108                while j < rects.len() {
1109                    let (a_min, a_max, _) = &rects[i];
1110                    let (b_min, b_max, _) = &rects[j];
1111
1112                    // Check if boxes overlap or are adjacent (within tolerance)
1113                    let overlaps_x = a_min.x <= b_max.x + MERGE_TOLERANCE
1114                        && a_max.x >= b_min.x - MERGE_TOLERANCE;
1115                    let overlaps_y = a_min.y <= b_max.y + MERGE_TOLERANCE
1116                        && a_max.y >= b_min.y - MERGE_TOLERANCE;
1117                    let overlaps_z = a_min.z <= b_max.z + MERGE_TOLERANCE
1118                        && a_max.z >= b_min.z - MERGE_TOLERANCE;
1119
1120                    // Check direction compatibility before merging
1121                    let dirs_compatible = match (&rects[i].2, &rects[j].2) {
1122                        (Some(a), Some(b)) => {
1123                            let dot = a.x * b.x + a.y * b.y + a.z * b.z;
1124                            dot.abs() > 0.99 // Nearly parallel directions
1125                        }
1126                        (None, None) => true,
1127                        _ => false, // One has direction, other doesn't
1128                    };
1129
1130                    if overlaps_x && overlaps_y && overlaps_z && dirs_compatible {
1131                        // Merge into box i
1132                        let dir = rects[i].2;
1133                        rects[i] = (
1134                            Point3::new(
1135                                a_min.x.min(b_min.x),
1136                                a_min.y.min(b_min.y),
1137                                a_min.z.min(b_min.z),
1138                            ),
1139                            Point3::new(
1140                                a_max.x.max(b_max.x),
1141                                a_max.y.max(b_max.y),
1142                                a_max.z.max(b_max.z),
1143                            ),
1144                            dir,
1145                        );
1146                        rects.remove(j);
1147                        merged = true;
1148                    } else {
1149                        j += 1;
1150                    }
1151                }
1152                i += 1;
1153            }
1154        }
1155
1156        // Reconstruct the opening list
1157        let mut result: Vec<OpeningType> = rects
1158            .into_iter()
1159            .map(|(min, max, dir)| OpeningType::Rectangular(min, max, dir))
1160            .collect();
1161        result.extend(others);
1162        result
1163    }
1164
1165    fn apply_diagonal_openings(
1166        &self,
1167        result: &mut Mesh,
1168        openings: &[OpeningType],
1169        wall_min: &Point3<f64>,
1170        wall_max: &Point3<f64>,
1171    ) {
1172        use nalgebra::Rotation3;
1173
1174        let diagonal_openings: Vec<(&Mesh, &Vector3<f64>)> = openings
1175            .iter()
1176            .filter_map(|o| match o {
1177                OpeningType::DiagonalRectangular(mesh, dir) => Some((mesh, dir)),
1178                _ => None,
1179            })
1180            .collect();
1181
1182        if diagonal_openings.is_empty() {
1183            return;
1184        }
1185
1186        // Group openings by extrusion direction so each group gets its own
1187        // rotate-clip-unrotate pass (directions considered equal within a
1188        // small angular tolerance).
1189        const DIR_DOT_THRESHOLD: f64 = 0.9998; // ~1° tolerance
1190        let mut groups: Vec<(Vector3<f64>, Vec<&Mesh>)> = Vec::new();
1191        for (mesh, dir) in &diagonal_openings {
1192            let d = *dir;
1193            if let Some(group) = groups
1194                .iter_mut()
1195                .find(|(g, _)| d.dot(g).abs() > DIR_DOT_THRESHOLD)
1196            {
1197                group.1.push(mesh);
1198            } else {
1199                groups.push((*d, vec![mesh]));
1200            }
1201        }
1202
1203        let wall_corners = [
1204            Point3::new(wall_min.x, wall_min.y, wall_min.z),
1205            Point3::new(wall_max.x, wall_min.y, wall_min.z),
1206            Point3::new(wall_min.x, wall_max.y, wall_min.z),
1207            Point3::new(wall_max.x, wall_max.y, wall_min.z),
1208            Point3::new(wall_min.x, wall_min.y, wall_max.z),
1209            Point3::new(wall_max.x, wall_min.y, wall_max.z),
1210            Point3::new(wall_min.x, wall_max.y, wall_max.z),
1211            Point3::new(wall_max.x, wall_max.y, wall_max.z),
1212        ];
1213
1214        for (extrusion_dir, group_meshes) in &groups {
1215            let target = Vector3::new(1.0, 0.0, 0.0);
1216            let rotation = Rotation3::rotation_between(extrusion_dir, &target)
1217                .unwrap_or(Rotation3::identity());
1218            let inv_rotation = rotation.inverse();
1219
1220            // Rotate positions and normals into the aligned frame
1221            for chunk in result.positions.chunks_exact_mut(3) {
1222                let p = rotation * Point3::new(chunk[0] as f64, chunk[1] as f64, chunk[2] as f64);
1223                chunk[0] = p.x as f32;
1224                chunk[1] = p.y as f32;
1225                chunk[2] = p.z as f32;
1226            }
1227            for chunk in result.normals.chunks_exact_mut(3) {
1228                let n = rotation * Vector3::new(chunk[0] as f64, chunk[1] as f64, chunk[2] as f64);
1229                chunk[0] = n.x as f32;
1230                chunk[1] = n.y as f32;
1231                chunk[2] = n.z as f32;
1232            }
1233
1234            // Compute full rotated wall AABB (needed for reveal depth clamping).
1235            let mut rot_wall_min =
1236                Point3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
1237            let mut rot_wall_max =
1238                Point3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
1239            for wc in &wall_corners {
1240                let rwc = rotation * wc;
1241                rot_wall_min.x = rot_wall_min.x.min(rwc.x);
1242                rot_wall_min.y = rot_wall_min.y.min(rwc.y);
1243                rot_wall_min.z = rot_wall_min.z.min(rwc.z);
1244                rot_wall_max.x = rot_wall_max.x.max(rwc.x);
1245                rot_wall_max.y = rot_wall_max.y.max(rwc.y);
1246                rot_wall_max.z = rot_wall_max.z.max(rwc.z);
1247            }
1248
1249            for opening_mesh in group_meshes {
1250                let mut rot_min = Point3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
1251                let mut rot_max =
1252                    Point3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
1253                for chunk in opening_mesh.positions.chunks_exact(3) {
1254                    let p =
1255                        rotation * Point3::new(chunk[0] as f64, chunk[1] as f64, chunk[2] as f64);
1256                    rot_min.x = rot_min.x.min(p.x);
1257                    rot_min.y = rot_min.y.min(p.y);
1258                    rot_min.z = rot_min.z.min(p.z);
1259                    rot_max.x = rot_max.x.max(p.x);
1260                    rot_max.y = rot_max.y.max(p.y);
1261                    rot_max.z = rot_max.z.max(p.z);
1262                }
1263                rot_min.x = rot_min.x.min(rot_wall_min.x);
1264                rot_max.x = rot_max.x.max(rot_wall_max.x);
1265
1266                *result = self.cut_rectangular_opening_no_faces(result, rot_min, rot_max);
1267
1268                // Generate reveal faces in the rotated (X-aligned) frame.
1269                // They rotate back to world space together with the rest of the mesh.
1270                let x_dir = Vector3::new(1.0, 0.0, 0.0);
1271                generate_reveal_quads(
1272                    result,
1273                    &rot_min,
1274                    &rot_max,
1275                    &rot_wall_min,
1276                    &rot_wall_max,
1277                    Some(&x_dir),
1278                );
1279            }
1280
1281            // Rotate positions and normals back to world frame
1282            for chunk in result.positions.chunks_exact_mut(3) {
1283                let p =
1284                    inv_rotation * Point3::new(chunk[0] as f64, chunk[1] as f64, chunk[2] as f64);
1285                chunk[0] = p.x as f32;
1286                chunk[1] = p.y as f32;
1287                chunk[2] = p.z as f32;
1288            }
1289            for chunk in result.normals.chunks_exact_mut(3) {
1290                let n =
1291                    inv_rotation * Vector3::new(chunk[0] as f64, chunk[1] as f64, chunk[2] as f64);
1292                chunk[0] = n.x as f32;
1293                chunk[1] = n.y as f32;
1294                chunk[2] = n.z as f32;
1295            }
1296        }
1297    }
1298
1299    /// Cut a rectangular opening from a mesh using optimized plane clipping
1300    ///
1301    /// This is more efficient than full CSG because:
1302    /// 1. Only processes triangles that intersect the opening bounds
1303    /// Extend opening bounds along extrusion direction to match wall extent
1304    ///
1305    /// Projects wall corners onto the extrusion axis and extends the opening
1306    /// min/max to cover the wall's full extent along that direction.
1307    /// This ensures openings penetrate multi-layer walls correctly without
1308    /// causing artifacts for angled walls.
1309    fn extend_opening_along_direction(
1310        &self,
1311        open_min: Point3<f64>,
1312        open_max: Point3<f64>,
1313        wall_min: Point3<f64>,
1314        wall_max: Point3<f64>,
1315        extrusion_direction: Vector3<f64>, // World-space, normalized
1316    ) -> (Point3<f64>, Point3<f64>) {
1317        // Use opening center as reference point for projection
1318        let open_center = Point3::new(
1319            (open_min.x + open_max.x) * 0.5,
1320            (open_min.y + open_max.y) * 0.5,
1321            (open_min.z + open_max.z) * 0.5,
1322        );
1323
1324        // Project all 8 corners of the wall box onto the extrusion axis
1325        let wall_corners = [
1326            Point3::new(wall_min.x, wall_min.y, wall_min.z),
1327            Point3::new(wall_max.x, wall_min.y, wall_min.z),
1328            Point3::new(wall_min.x, wall_max.y, wall_min.z),
1329            Point3::new(wall_max.x, wall_max.y, wall_min.z),
1330            Point3::new(wall_min.x, wall_min.y, wall_max.z),
1331            Point3::new(wall_max.x, wall_min.y, wall_max.z),
1332            Point3::new(wall_min.x, wall_max.y, wall_max.z),
1333            Point3::new(wall_max.x, wall_max.y, wall_max.z),
1334        ];
1335
1336        // Find min/max projections of wall corners onto extrusion axis
1337        let mut wall_min_proj = f64::INFINITY;
1338        let mut wall_max_proj = f64::NEG_INFINITY;
1339
1340        for corner in &wall_corners {
1341            // Project corner onto extrusion axis relative to opening center
1342            let proj = (corner - open_center).dot(&extrusion_direction);
1343            wall_min_proj = wall_min_proj.min(proj);
1344            wall_max_proj = wall_max_proj.max(proj);
1345        }
1346
1347        // Project opening corners onto extrusion axis
1348        let open_corners = [
1349            Point3::new(open_min.x, open_min.y, open_min.z),
1350            Point3::new(open_max.x, open_min.y, open_min.z),
1351            Point3::new(open_min.x, open_max.y, open_min.z),
1352            Point3::new(open_max.x, open_max.y, open_min.z),
1353            Point3::new(open_min.x, open_min.y, open_max.z),
1354            Point3::new(open_max.x, open_min.y, open_max.z),
1355            Point3::new(open_min.x, open_max.y, open_max.z),
1356            Point3::new(open_max.x, open_max.y, open_max.z),
1357        ];
1358
1359        let mut open_min_proj = f64::INFINITY;
1360        let mut open_max_proj = f64::NEG_INFINITY;
1361
1362        for corner in &open_corners {
1363            let proj = (corner - open_center).dot(&extrusion_direction);
1364            open_min_proj = open_min_proj.min(proj);
1365            open_max_proj = open_max_proj.max(proj);
1366        }
1367
1368        // Calculate how much to extend in each direction along the extrusion axis
1369        // If wall extends beyond opening, we need to extend the opening
1370        let extend_backward = (open_min_proj - wall_min_proj).max(0.0); // How much wall extends before opening
1371        let extend_forward = (wall_max_proj - open_max_proj).max(0.0); // How much wall extends after opening
1372
1373        // Extend opening bounds along the extrusion direction
1374        let extended_min = open_min - extrusion_direction * extend_backward;
1375        let extended_max = open_max + extrusion_direction * extend_forward;
1376
1377        // Create new AABB that encompasses both original opening and extended points
1378        // This ensures we don't shrink the opening in other dimensions
1379        let all_points = [open_min, open_max, extended_min, extended_max];
1380
1381        let new_min = Point3::new(
1382            all_points.iter().map(|p| p.x).fold(f64::INFINITY, f64::min),
1383            all_points.iter().map(|p| p.y).fold(f64::INFINITY, f64::min),
1384            all_points.iter().map(|p| p.z).fold(f64::INFINITY, f64::min),
1385        );
1386        let new_max = Point3::new(
1387            all_points
1388                .iter()
1389                .map(|p| p.x)
1390                .fold(f64::NEG_INFINITY, f64::max),
1391            all_points
1392                .iter()
1393                .map(|p| p.y)
1394                .fold(f64::NEG_INFINITY, f64::max),
1395            all_points
1396                .iter()
1397                .map(|p| p.z)
1398                .fold(f64::NEG_INFINITY, f64::max),
1399        );
1400
1401        (new_min, new_max)
1402    }
1403
1404    /// Cut a rectangular opening from a mesh using AABB clipping.
1405    ///
1406    /// This method clips triangles against the opening bounding box using axis-aligned
1407    /// clipping planes. Reveal faces are generated separately in the caller after
1408    /// all clipping is complete (see `generate_reveal_quads`).
1409    /// Single-pass multi-box rectangular clipping.
1410    /// Instead of iterating boxes one-by-one (O(2^N) triangle growth from boundary
1411    /// re-splitting), this tests each triangle against ALL boxes simultaneously.
1412    /// A triangle is discarded if it falls completely inside ANY box.
1413    /// A triangle is kept as-is if it doesn't intersect ANY box.
1414    /// Triangles that partially intersect are clipped against the intersecting box.
1415    fn cut_multiple_rectangular_openings(
1416        &self,
1417        mesh: &Mesh,
1418        boxes: &[(Point3<f64>, Point3<f64>)],
1419    ) -> (Mesh, usize) {
1420        let mut current = mesh.clone();
1421
1422        // Process each box, but only clip triangles that actually intersect THIS box.
1423        // The key insight: after clipping against box N, the new boundary triangles
1424        // are at box N's edges. Box N+1 only clips triangles that intersect IT —
1425        // if box N+1 doesn't overlap box N's edges, no re-splitting occurs.
1426        //
1427        // The exponential growth happened because adjacent boxes shared edges,
1428        // causing every boundary triangle from box N to be re-split by box N+1.
1429        // With merged boxes, adjacency is eliminated.
1430        //
1431        // Safety: cap triangle count to prevent OOM from pathological cases.
1432        // When the cap trips, the remaining suffix of boxes is left uncut; the
1433        // processed count is returned so the caller can skip reveal generation
1434        // for openings that didn't actually leave a hole in the mesh.
1435        const MAX_TRIANGLES: usize = 500_000;
1436
1437        let mut processed = 0;
1438        for (open_min, open_max) in boxes.iter() {
1439            if current.indices.len() / 3 > MAX_TRIANGLES {
1440                break;
1441            }
1442            current = self.cut_rectangular_opening(&current, *open_min, *open_max);
1443            processed += 1;
1444        }
1445
1446        (current, processed)
1447    }
1448
1449    pub(super) fn cut_rectangular_opening(
1450        &self,
1451        mesh: &Mesh,
1452        open_min: Point3<f64>,
1453        open_max: Point3<f64>,
1454    ) -> Mesh {
1455        self.cut_rectangular_opening_no_faces(mesh, open_min, open_max)
1456    }
1457
1458    /// Cut a rectangular opening using AABB clipping WITHOUT generating internal faces.
1459    /// Used for diagonal openings where internal face generation causes rotation artifacts.
1460    fn cut_rectangular_opening_no_faces(
1461        &self,
1462        mesh: &Mesh,
1463        open_min: Point3<f64>,
1464        open_max: Point3<f64>,
1465    ) -> Mesh {
1466        use nalgebra::Vector3;
1467
1468        const EPSILON: f64 = 1e-6;
1469
1470        let mut result = Mesh::with_capacity(mesh.positions.len() / 3, mesh.indices.len() / 3);
1471
1472        let mut clip_buffers = ClipBuffers::new();
1473
1474        let num_vertices = mesh.positions.len() / 3;
1475        for chunk in mesh.indices.chunks_exact(3) {
1476            let i0 = chunk[0] as usize;
1477            let i1 = chunk[1] as usize;
1478            let i2 = chunk[2] as usize;
1479
1480            // Bounds check: skip triangles with out-of-range vertex indices
1481            if i0 >= num_vertices || i1 >= num_vertices || i2 >= num_vertices {
1482                continue;
1483            }
1484
1485            let v0 = Point3::new(
1486                mesh.positions[i0 * 3] as f64,
1487                mesh.positions[i0 * 3 + 1] as f64,
1488                mesh.positions[i0 * 3 + 2] as f64,
1489            );
1490            let v1 = Point3::new(
1491                mesh.positions[i1 * 3] as f64,
1492                mesh.positions[i1 * 3 + 1] as f64,
1493                mesh.positions[i1 * 3 + 2] as f64,
1494            );
1495            let v2 = Point3::new(
1496                mesh.positions[i2 * 3] as f64,
1497                mesh.positions[i2 * 3 + 1] as f64,
1498                mesh.positions[i2 * 3 + 2] as f64,
1499            );
1500
1501            let n0 = if mesh.normals.len() >= mesh.positions.len() {
1502                Vector3::new(
1503                    mesh.normals[i0 * 3] as f64,
1504                    mesh.normals[i0 * 3 + 1] as f64,
1505                    mesh.normals[i0 * 3 + 2] as f64,
1506                )
1507            } else {
1508                let edge1 = v1 - v0;
1509                let edge2 = v2 - v0;
1510                edge1
1511                    .cross(&edge2)
1512                    .try_normalize(1e-10)
1513                    .unwrap_or(Vector3::new(0.0, 0.0, 1.0))
1514            };
1515
1516            let tri_min_x = v0.x.min(v1.x).min(v2.x);
1517            let tri_max_x = v0.x.max(v1.x).max(v2.x);
1518            let tri_min_y = v0.y.min(v1.y).min(v2.y);
1519            let tri_max_y = v0.y.max(v1.y).max(v2.y);
1520            let tri_min_z = v0.z.min(v1.z).min(v2.z);
1521            let tri_max_z = v0.z.max(v1.z).max(v2.z);
1522
1523            // If triangle is completely outside opening, keep it as-is
1524            if tri_max_x <= open_min.x - EPSILON
1525                || tri_min_x >= open_max.x + EPSILON
1526                || tri_max_y <= open_min.y - EPSILON
1527                || tri_min_y >= open_max.y + EPSILON
1528                || tri_max_z <= open_min.z - EPSILON
1529                || tri_min_z >= open_max.z + EPSILON
1530            {
1531                let base = result.vertex_count() as u32;
1532                result.add_vertex(v0, n0);
1533                result.add_vertex(v1, n0);
1534                result.add_vertex(v2, n0);
1535                result.add_triangle(base, base + 1, base + 2);
1536                continue;
1537            }
1538
1539            // Check if triangle is completely inside opening (remove it)
1540            if tri_min_x >= open_min.x + EPSILON
1541                && tri_max_x <= open_max.x - EPSILON
1542                && tri_min_y >= open_min.y + EPSILON
1543                && tri_max_y <= open_max.y - EPSILON
1544                && tri_min_z >= open_min.z + EPSILON
1545                && tri_max_z <= open_max.z - EPSILON
1546            {
1547                continue;
1548            }
1549
1550            // Triangle may intersect opening - clip it
1551            if self.triangle_intersects_box(&v0, &v1, &v2, &open_min, &open_max) {
1552                self.clip_triangle_against_box(
1553                    &mut result,
1554                    &mut clip_buffers,
1555                    &v0,
1556                    &v1,
1557                    &v2,
1558                    &n0,
1559                    &open_min,
1560                    &open_max,
1561                );
1562            } else {
1563                let base = result.vertex_count() as u32;
1564                result.add_vertex(v0, n0);
1565                result.add_vertex(v1, n0);
1566                result.add_vertex(v2, n0);
1567                result.add_triangle(base, base + 1, base + 2);
1568            }
1569        }
1570
1571        // Reveal faces are generated by the caller (see generate_reveal_quads)
1572        result
1573    }
1574
1575    /// Test if a triangle intersects an axis-aligned bounding box using Separating Axis Theorem (SAT)
1576    /// Returns true if triangle and box intersect, false if they are separated
1577    fn triangle_intersects_box(
1578        &self,
1579        v0: &Point3<f64>,
1580        v1: &Point3<f64>,
1581        v2: &Point3<f64>,
1582        box_min: &Point3<f64>,
1583        box_max: &Point3<f64>,
1584    ) -> bool {
1585        use nalgebra::Vector3;
1586
1587        // Box center and half-extents
1588        let box_center = Point3::new(
1589            (box_min.x + box_max.x) * 0.5,
1590            (box_min.y + box_max.y) * 0.5,
1591            (box_min.z + box_max.z) * 0.5,
1592        );
1593        let box_half_extents = Vector3::new(
1594            (box_max.x - box_min.x) * 0.5,
1595            (box_max.y - box_min.y) * 0.5,
1596            (box_max.z - box_min.z) * 0.5,
1597        );
1598
1599        // Translate triangle to box-local space
1600        let t0 = v0 - box_center;
1601        let t1 = v1 - box_center;
1602        let t2 = v2 - box_center;
1603
1604        // Triangle edges
1605        let e0 = t1 - t0;
1606        let e1 = t2 - t1;
1607        let e2 = t0 - t2;
1608
1609        // Test 1: Box axes (X, Y, Z)
1610        // Project triangle onto each axis and check overlap
1611        for axis_idx in 0..3 {
1612            let axis = match axis_idx {
1613                0 => Vector3::new(1.0, 0.0, 0.0),
1614                1 => Vector3::new(0.0, 1.0, 0.0),
1615                2 => Vector3::new(0.0, 0.0, 1.0),
1616                _ => unreachable!(),
1617            };
1618
1619            let p0 = t0.dot(&axis);
1620            let p1 = t1.dot(&axis);
1621            let p2 = t2.dot(&axis);
1622
1623            let tri_min = p0.min(p1).min(p2);
1624            let tri_max = p0.max(p1).max(p2);
1625            let box_extent = box_half_extents[axis_idx];
1626
1627            if tri_max < -box_extent || tri_min > box_extent {
1628                return false; // Separated on this axis
1629            }
1630        }
1631
1632        // Test 2: Triangle face normal
1633        let triangle_normal = e0.cross(&e2);
1634        let triangle_offset = t0.dot(&triangle_normal);
1635
1636        // Project box onto triangle normal
1637        let mut box_projection = 0.0;
1638        for i in 0..3 {
1639            let axis = match i {
1640                0 => Vector3::new(1.0, 0.0, 0.0),
1641                1 => Vector3::new(0.0, 1.0, 0.0),
1642                2 => Vector3::new(0.0, 0.0, 1.0),
1643                _ => unreachable!(),
1644            };
1645            box_projection += box_half_extents[i] * triangle_normal.dot(&axis).abs();
1646        }
1647
1648        if triangle_offset.abs() > box_projection {
1649            return false; // Separated by triangle plane
1650        }
1651
1652        // Test 3: 9 cross-product axes (3 box edges x 3 triangle edges)
1653        let box_axes = [
1654            Vector3::new(1.0, 0.0, 0.0),
1655            Vector3::new(0.0, 1.0, 0.0),
1656            Vector3::new(0.0, 0.0, 1.0),
1657        ];
1658        let tri_edges = [e0, e1, e2];
1659
1660        for box_axis in &box_axes {
1661            for tri_edge in &tri_edges {
1662                let axis = box_axis.cross(tri_edge);
1663
1664                // Skip degenerate axes (parallel edges)
1665                if axis.norm_squared() < 1e-10 {
1666                    continue;
1667                }
1668
1669                let axis_normalized = axis.normalize();
1670
1671                // Project triangle onto axis
1672                let p0 = t0.dot(&axis_normalized);
1673                let p1 = t1.dot(&axis_normalized);
1674                let p2 = t2.dot(&axis_normalized);
1675                let tri_min = p0.min(p1).min(p2);
1676                let tri_max = p0.max(p1).max(p2);
1677
1678                // Project box onto axis
1679                let mut box_projection = 0.0;
1680                for i in 0..3 {
1681                    let box_axis_vec = box_axes[i];
1682                    box_projection +=
1683                        box_half_extents[i] * axis_normalized.dot(&box_axis_vec).abs();
1684                }
1685
1686                if tri_max < -box_projection || tri_min > box_projection {
1687                    return false; // Separated on this axis
1688                }
1689            }
1690        }
1691
1692        // No separating axis found - triangle and box intersect
1693        true
1694    }
1695
1696    /// Clip a triangle against an opening box using clip-and-collect algorithm.
1697    /// Removes the part of the triangle that's inside the box.
1698    /// Collects "outside" parts directly to result, continues processing "inside" parts.
1699    ///
1700    /// Uses reusable ClipBuffers to avoid per-triangle allocations (6+ Vec allocations
1701    /// per intersecting triangle without buffers).
1702    ///
1703    /// ## FIX (2026-03-18): Direct back-part computation
1704    ///
1705    /// The previous implementation clipped the original triangle against a **flipped plane**
1706    /// to obtain "outside" parts. When triangle vertices were within epsilon (1e-6) of the
1707    /// clipping plane, `clip_triangle` classified them as "front" for **both** the original
1708    /// and flipped planes — returning `Split` on the original but `AllFront` on the flipped.
1709    /// This added the **entire original triangle** to the result as an "outside" piece while
1710    /// the clipped front parts also continued processing, duplicating geometry.
1711    ///
1712    fn clip_triangle_against_box(
1713        &self,
1714        result: &mut Mesh,
1715        buffers: &mut ClipBuffers,
1716        v0: &Point3<f64>,
1717        v1: &Point3<f64>,
1718        v2: &Point3<f64>,
1719        normal: &Vector3<f64>,
1720        open_min: &Point3<f64>,
1721        open_max: &Point3<f64>,
1722    ) {
1723        let clipper = ClippingProcessor::new();
1724        let epsilon = clipper.epsilon;
1725
1726        // Clear buffers for reuse (retains capacity)
1727        buffers.clear();
1728
1729        // Planes with INWARD normals (so "front" = inside box, "behind" = outside box)
1730        // We clip to keep geometry OUTSIDE the box (behind these planes)
1731        let planes = [
1732            // +X inward: inside box where x >= open_min.x
1733            Plane::new(
1734                Point3::new(open_min.x, 0.0, 0.0),
1735                Vector3::new(1.0, 0.0, 0.0),
1736            ),
1737            // -X inward: inside box where x <= open_max.x
1738            Plane::new(
1739                Point3::new(open_max.x, 0.0, 0.0),
1740                Vector3::new(-1.0, 0.0, 0.0),
1741            ),
1742            // +Y inward: inside box where y >= open_min.y
1743            Plane::new(
1744                Point3::new(0.0, open_min.y, 0.0),
1745                Vector3::new(0.0, 1.0, 0.0),
1746            ),
1747            // -Y inward: inside box where y <= open_max.y
1748            Plane::new(
1749                Point3::new(0.0, open_max.y, 0.0),
1750                Vector3::new(0.0, -1.0, 0.0),
1751            ),
1752            // +Z inward: inside box where z >= open_min.z
1753            Plane::new(
1754                Point3::new(0.0, 0.0, open_min.z),
1755                Vector3::new(0.0, 0.0, 1.0),
1756            ),
1757            // -Z inward: inside box where z <= open_max.z
1758            Plane::new(
1759                Point3::new(0.0, 0.0, open_max.z),
1760                Vector3::new(0.0, 0.0, -1.0),
1761            ),
1762        ];
1763
1764        // Guard: skip if input vertices contain NaN (from degenerate prior clips)
1765        if !v0.x.is_finite()
1766            || !v0.y.is_finite()
1767            || !v0.z.is_finite()
1768            || !v1.x.is_finite()
1769            || !v1.y.is_finite()
1770            || !v1.z.is_finite()
1771            || !v2.x.is_finite()
1772            || !v2.y.is_finite()
1773            || !v2.z.is_finite()
1774        {
1775            // Keep the triangle as-is (don't clip degenerate geometry)
1776            let base = result.vertex_count() as u32;
1777            result.add_vertex(*v0, *normal);
1778            result.add_vertex(*v1, *normal);
1779            result.add_vertex(*v2, *normal);
1780            result.add_triangle(base, base + 1, base + 2);
1781            return;
1782        }
1783        // Initialize remaining with the input triangle
1784        buffers.remaining.push(Triangle::new(*v0, *v1, *v2));
1785
1786        // Clip-and-collect: collect "outside" parts, continue processing "inside" parts
1787        for plane in &planes {
1788            buffers.next_remaining.clear();
1789
1790            for tri in &buffers.remaining {
1791                // Compute signed distances
1792                let d0 = plane.signed_distance(&tri.v0);
1793                let d1 = plane.signed_distance(&tri.v1);
1794                let d2 = plane.signed_distance(&tri.v2);
1795
1796                // Guard: NaN distances from degenerate vertices (from prior interpolation)
1797                if !d0.is_finite() || !d1.is_finite() || !d2.is_finite() {
1798                    buffers.result.push(tri.clone()); // keep as-is
1799                    continue;
1800                }
1801
1802                let f0 = d0 >= -epsilon;
1803                let f1 = d1 >= -epsilon;
1804                let f2 = d2 >= -epsilon;
1805                let front_count = f0 as u8 + f1 as u8 + f2 as u8;
1806
1807                match front_count {
1808                    3 => {
1809                        buffers.next_remaining.push(tri.clone());
1810                    }
1811                    0 => {
1812                        buffers.result.push(tri.clone());
1813                    }
1814                    1 => {
1815                        let (front, back1, back2, d_f, d_b1, d_b2) = if f0 {
1816                            (tri.v0, tri.v1, tri.v2, d0, d1, d2)
1817                        } else if f1 {
1818                            (tri.v1, tri.v2, tri.v0, d1, d2, d0)
1819                        } else {
1820                            (tri.v2, tri.v0, tri.v1, d2, d0, d1)
1821                        };
1822
1823                        let denom1 = d_f - d_b1;
1824                        let denom2 = d_f - d_b2;
1825                        if denom1.abs() < 1e-12 || denom2.abs() < 1e-12 {
1826                            buffers.next_remaining.push(tri.clone());
1827                            continue;
1828                        }
1829                        let t1 = (d_f / denom1).clamp(0.0, 1.0);
1830                        let t2 = (d_f / denom2).clamp(0.0, 1.0);
1831                        let p1 = front + (back1 - front) * t1;
1832                        let p2 = front + (back2 - front) * t2;
1833
1834                        // Validate interpolated points
1835                        if !p1.x.is_finite()
1836                            || !p1.y.is_finite()
1837                            || !p1.z.is_finite()
1838                            || !p2.x.is_finite()
1839                            || !p2.y.is_finite()
1840                            || !p2.z.is_finite()
1841                        {
1842                            buffers.next_remaining.push(tri.clone());
1843                            continue;
1844                        }
1845
1846                        buffers.next_remaining.push(Triangle::new(front, p1, p2));
1847                        buffers.result.push(Triangle::new(p1, back1, back2));
1848                        buffers.result.push(Triangle::new(p1, back2, p2));
1849                    }
1850                    2 => {
1851                        let (front1, front2, back, d_f1, d_f2, d_b) = if !f0 {
1852                            (tri.v1, tri.v2, tri.v0, d1, d2, d0)
1853                        } else if !f1 {
1854                            (tri.v2, tri.v0, tri.v1, d2, d0, d1)
1855                        } else {
1856                            (tri.v0, tri.v1, tri.v2, d0, d1, d2)
1857                        };
1858
1859                        let denom1 = d_f1 - d_b;
1860                        let denom2 = d_f2 - d_b;
1861                        if denom1.abs() < 1e-12 || denom2.abs() < 1e-12 {
1862                            buffers.next_remaining.push(tri.clone());
1863                            continue;
1864                        }
1865                        let t1 = (d_f1 / denom1).clamp(0.0, 1.0);
1866                        let t2 = (d_f2 / denom2).clamp(0.0, 1.0);
1867                        let p1 = front1 + (back - front1) * t1;
1868                        let p2 = front2 + (back - front2) * t2;
1869
1870                        // Validate interpolated points
1871                        if !p1.x.is_finite()
1872                            || !p1.y.is_finite()
1873                            || !p1.z.is_finite()
1874                            || !p2.x.is_finite()
1875                            || !p2.y.is_finite()
1876                            || !p2.z.is_finite()
1877                        {
1878                            buffers.next_remaining.push(tri.clone());
1879                            continue;
1880                        }
1881
1882                        buffers
1883                            .next_remaining
1884                            .push(Triangle::new(front1, front2, p1));
1885                        buffers.next_remaining.push(Triangle::new(front2, p2, p1));
1886                        buffers.result.push(Triangle::new(p1, p2, back));
1887                    }
1888                    _ => {
1889                        // Should be unreachable, but guard against corruption
1890                        buffers.result.push(tri.clone());
1891                    }
1892                }
1893            }
1894
1895            // Swap buffers instead of reallocating
1896            std::mem::swap(&mut buffers.remaining, &mut buffers.next_remaining);
1897        }
1898
1899        // 'remaining' triangles are inside ALL planes = inside box = discard
1900        // Add collected result_triangles to mesh
1901        for tri in &buffers.result {
1902            let base = result.vertex_count() as u32;
1903            result.add_vertex(tri.v0, *normal);
1904            result.add_vertex(tri.v1, *normal);
1905            result.add_vertex(tri.v2, *normal);
1906            result.add_triangle(base, base + 1, base + 2);
1907        }
1908    }
1909}
1910
1911// ---------------------------------------------------------------------------
1912// Tests
1913// ---------------------------------------------------------------------------
1914
1915#[cfg(test)]
1916mod reveal_tests {
1917    use super::*;
1918    use crate::Mesh;
1919
1920    /// Build a simple box mesh (12 triangles) for testing.
1921    #[allow(dead_code)]
1922    fn make_box_mesh(
1923        min: Point3<f64>,
1924        max: Point3<f64>,
1925    ) -> Mesh {
1926        let mut m = Mesh::with_capacity(24, 36);
1927
1928        let corners = [
1929            Point3::new(min.x, min.y, min.z), // 0
1930            Point3::new(max.x, min.y, min.z), // 1
1931            Point3::new(max.x, max.y, min.z), // 2
1932            Point3::new(min.x, max.y, min.z), // 3
1933            Point3::new(min.x, min.y, max.z), // 4
1934            Point3::new(max.x, min.y, max.z), // 5
1935            Point3::new(max.x, max.y, max.z), // 6
1936            Point3::new(min.x, max.y, max.z), // 7
1937        ];
1938
1939        // 6 faces × 4 vertices each with face normals
1940        let faces: [(Vector3<f64>, [usize; 4]); 6] = [
1941            (Vector3::new(0.0, 0.0, -1.0), [0, 2, 1, 3]),  // -Z
1942            (Vector3::new(0.0, 0.0, 1.0), [4, 5, 6, 7]),   // +Z
1943            (Vector3::new(0.0, -1.0, 0.0), [0, 1, 5, 4]),   // -Y
1944            (Vector3::new(0.0, 1.0, 0.0), [2, 3, 7, 6]),    // +Y
1945            (Vector3::new(-1.0, 0.0, 0.0), [0, 4, 7, 3]),   // -X
1946            (Vector3::new(1.0, 0.0, 0.0), [1, 2, 6, 5]),    // +X
1947        ];
1948        for (n, idx) in &faces {
1949            let b = m.vertex_count() as u32;
1950            m.add_vertex(corners[idx[0]], *n);
1951            m.add_vertex(corners[idx[1]], *n);
1952            m.add_vertex(corners[idx[2]], *n);
1953            m.add_vertex(corners[idx[3]], *n);
1954            m.add_triangle(b, b + 1, b + 2);
1955            m.add_triangle(b, b + 2, b + 3);
1956        }
1957        m
1958    }
1959
1960    /// Extract the dominant normal (first triangle's normal) of all reveal
1961    /// triangles (those added after `pre_count` triangles).
1962    fn reveal_normals(mesh: &Mesh, pre_tri_count: usize) -> Vec<Vector3<f64>> {
1963        let mut normals = Vec::new();
1964        let indices = &mesh.indices[pre_tri_count * 3..];
1965        for tri in indices.chunks_exact(3) {
1966            let i = tri[0] as usize;
1967            let nx = mesh.normals[i * 3] as f64;
1968            let ny = mesh.normals[i * 3 + 1] as f64;
1969            let nz = mesh.normals[i * 3 + 2] as f64;
1970            normals.push(Vector3::new(nx, ny, nz));
1971        }
1972        normals
1973    }
1974
1975    #[test]
1976    fn test_reveals_generated_for_axis_aligned_opening() {
1977        // Wall: 10m long (X), 0.3m thick (Y), 3m tall (Z)
1978        let wall_min = Point3::new(0.0, -0.15, 0.0);
1979        let wall_max = Point3::new(10.0, 0.15, 3.0);
1980
1981        // Opening: 2m wide at X=4..6, full Y depth, 1m..2.5m in Z
1982        let open_min = Point3::new(4.0, -0.3, 1.0);
1983        let open_max = Point3::new(6.0, 0.3, 2.5);
1984
1985        let mut mesh = Mesh::new();
1986        let extrusion_dir = Vector3::new(0.0, 1.0, 0.0); // Through the wall
1987
1988        generate_reveal_quads(
1989            &mut mesh,
1990            &open_min,
1991            &open_max,
1992            &wall_min,
1993            &wall_max,
1994            Some(&extrusion_dir),
1995        );
1996
1997        // Should have 4 reveal quads = 8 triangles = 16 vertices
1998        assert_eq!(mesh.triangle_count(), 8, "Expected 4 reveal quads (8 triangles)");
1999        assert_eq!(mesh.vertex_count(), 16, "Expected 16 vertices (4 per quad)");
2000    }
2001
2002    #[test]
2003    fn test_reveal_normals_point_inward() {
2004        let wall_min = Point3::new(0.0, -0.15, 0.0);
2005        let wall_max = Point3::new(10.0, 0.15, 3.0);
2006        let open_min = Point3::new(4.0, -0.3, 1.0);
2007        let open_max = Point3::new(6.0, 0.3, 2.5);
2008
2009        let mut mesh = Mesh::new();
2010        let dir = Vector3::new(0.0, 1.0, 0.0);
2011        generate_reveal_quads(&mut mesh, &open_min, &open_max, &wall_min, &wall_max, Some(&dir));
2012
2013        let normals = reveal_normals(&mesh, 0);
2014
2015        // Opening center in X/Z cross-section is (5.0, 1.75)
2016        // Left face at X=4.0 → normal should have +X component
2017        // Right face at X=6.0 → normal should have −X component
2018        // Bottom at Z=1.0 → normal should have +Z component
2019        // Top at Z=2.5 → normal should have −Z component
2020        let has_pos_x = normals.iter().any(|n| n.x > 0.5);
2021        let has_neg_x = normals.iter().any(|n| n.x < -0.5);
2022        let has_pos_z = normals.iter().any(|n| n.z > 0.5);
2023        let has_neg_z = normals.iter().any(|n| n.z < -0.5);
2024
2025        assert!(has_pos_x, "Should have +X normal (left reveal)");
2026        assert!(has_neg_x, "Should have −X normal (right reveal)");
2027        assert!(has_pos_z, "Should have +Z normal (bottom reveal)");
2028        assert!(has_neg_z, "Should have −Z normal (top reveal)");
2029    }
2030
2031    #[test]
2032    fn test_no_reveals_when_opening_at_wall_boundary() {
2033        // Door-like opening that starts at wall bottom (Z=0) and spans full width
2034        let wall_min = Point3::new(0.0, -0.15, 0.0);
2035        let wall_max = Point3::new(10.0, 0.15, 3.0);
2036        // Opening at Z=0 (floor) to Z=2.1 (door height), X covers full wall
2037        let open_min = Point3::new(0.0, -0.3, 0.0);
2038        let open_max = Point3::new(10.0, 0.3, 2.1);
2039
2040        let mut mesh = Mesh::new();
2041        let dir = Vector3::new(0.0, 1.0, 0.0);
2042        generate_reveal_quads(&mut mesh, &open_min, &open_max, &wall_min, &wall_max, Some(&dir));
2043
2044        // X edges at wall boundary → no left/right reveals
2045        // Z bottom at wall boundary → no bottom reveal
2046        // Only top reveal at Z=2.1 should exist
2047        assert_eq!(mesh.triangle_count(), 2, "Only top reveal expected (1 quad = 2 tris)");
2048    }
2049
2050    #[test]
2051    fn test_reveals_with_extrusion_along_x() {
2052        // Wall oriented along Y, thickness along X
2053        let wall_min = Point3::new(-0.15, 0.0, 0.0);
2054        let wall_max = Point3::new(0.15, 10.0, 3.0);
2055        let open_min = Point3::new(-0.3, 4.0, 1.0);
2056        let open_max = Point3::new(0.3, 6.0, 2.5);
2057
2058        let mut mesh = Mesh::new();
2059        let dir = Vector3::new(1.0, 0.0, 0.0);
2060        generate_reveal_quads(&mut mesh, &open_min, &open_max, &wall_min, &wall_max, Some(&dir));
2061
2062        assert_eq!(mesh.triangle_count(), 8, "4 reveal quads for X-extrusion");
2063    }
2064
2065    #[test]
2066    fn test_reveals_with_extrusion_along_z() {
2067        // Slab-like: thickness along Z (horizontal openings)
2068        let wall_min = Point3::new(0.0, 0.0, -0.15);
2069        let wall_max = Point3::new(10.0, 10.0, 0.15);
2070        let open_min = Point3::new(3.0, 3.0, -0.3);
2071        let open_max = Point3::new(5.0, 5.0, 0.3);
2072
2073        let mut mesh = Mesh::new();
2074        let dir = Vector3::new(0.0, 0.0, 1.0);
2075        generate_reveal_quads(&mut mesh, &open_min, &open_max, &wall_min, &wall_max, Some(&dir));
2076
2077        assert_eq!(mesh.triangle_count(), 8, "4 reveal quads for Z-extrusion");
2078    }
2079
2080    #[test]
2081    fn test_reveals_clamp_to_wall_depth() {
2082        // Wall: 0.3m thick along Y
2083        let wall_min = Point3::new(0.0, 0.0, 0.0);
2084        let wall_max = Point3::new(10.0, 0.3, 3.0);
2085        // Opening extends well beyond wall in Y (simulating extend_opening_along_direction)
2086        let open_min = Point3::new(4.0, -1.0, 1.0);
2087        let open_max = Point3::new(6.0, 1.3, 2.5);
2088
2089        let mut mesh = Mesh::new();
2090        let dir = Vector3::new(0.0, 1.0, 0.0);
2091        generate_reveal_quads(&mut mesh, &open_min, &open_max, &wall_min, &wall_max, Some(&dir));
2092
2093        // Reveal depth should be clamped to wall: Y=0.0..0.3 (not -1.0..1.3)
2094        for chunk in mesh.positions.chunks_exact(3) {
2095            let y = chunk[1] as f64;
2096            assert!(
2097                y >= -1e-3 && y <= 0.3 + 1e-3,
2098                "Reveal vertex Y={y} should be within wall bounds [0.0, 0.3]"
2099            );
2100        }
2101    }
2102
2103    #[test]
2104    fn test_no_reveals_when_no_wall_thickness() {
2105        // Degenerate wall with zero thickness along extrusion
2106        let wall_min = Point3::new(0.0, 0.0, 0.0);
2107        let wall_max = Point3::new(10.0, 0.0, 3.0);
2108        let open_min = Point3::new(4.0, -0.1, 1.0);
2109        let open_max = Point3::new(6.0, 0.1, 2.5);
2110
2111        let mut mesh = Mesh::new();
2112        let dir = Vector3::new(0.0, 1.0, 0.0);
2113        generate_reveal_quads(&mut mesh, &open_min, &open_max, &wall_min, &wall_max, Some(&dir));
2114
2115        assert_eq!(mesh.triangle_count(), 0, "No reveals for zero-thickness wall");
2116    }
2117
2118    #[test]
2119    fn test_no_reveals_when_opening_misses_submesh_on_cross_axis() {
2120        // Sub-mesh slab: Z=[0..0.5]. Opening is at Z=[1.0..2.0] — fully above.
2121        // Extrusion along Y (wall thickness). Without cross-axis overlap
2122        // guards, reveals would be emitted floating above the slab.
2123        let wall_min = Point3::new(0.0, -0.15, 0.0);
2124        let wall_max = Point3::new(10.0, 0.15, 0.5);
2125        let open_min = Point3::new(4.0, -0.3, 1.0);
2126        let open_max = Point3::new(6.0, 0.3, 2.0);
2127
2128        let mut mesh = Mesh::new();
2129        let dir = Vector3::new(0.0, 1.0, 0.0);
2130        generate_reveal_quads(&mut mesh, &open_min, &open_max, &wall_min, &wall_max, Some(&dir));
2131
2132        assert_eq!(
2133            mesh.triangle_count(),
2134            0,
2135            "No reveals when opening lies outside sub-mesh on a cross-axis"
2136        );
2137    }
2138
2139    #[test]
2140    fn test_reveals_clamp_to_wall_on_orthogonal_cross_axis() {
2141        // Sub-mesh Z extent is [0..2.0], but opening spans Z=[1.0..3.0] —
2142        // taller than the sub-mesh. The left/right reveals (cross-axis X)
2143        // must be clamped in Z to the sub-mesh bound, not the opening's.
2144        let wall_min = Point3::new(0.0, -0.15, 0.0);
2145        let wall_max = Point3::new(10.0, 0.15, 2.0);
2146        let open_min = Point3::new(4.0, -0.3, 1.0);
2147        let open_max = Point3::new(6.0, 0.3, 3.0);
2148
2149        let mut mesh = Mesh::new();
2150        let dir = Vector3::new(0.0, 1.0, 0.0);
2151        generate_reveal_quads(&mut mesh, &open_min, &open_max, &wall_min, &wall_max, Some(&dir));
2152
2153        for chunk in mesh.positions.chunks_exact(3) {
2154            let z = chunk[2] as f64;
2155            assert!(
2156                z >= -1e-3 && z <= 2.0 + 1e-3,
2157                "Reveal vertex Z={z} should stay within sub-mesh [0.0, 2.0]"
2158            );
2159        }
2160    }
2161
2162    #[test]
2163    fn test_determine_extrusion_axis() {
2164        let wmin = Point3::new(0.0, 0.0, 0.0);
2165        let wmax = Point3::new(10.0, 0.3, 3.0);
2166
2167        assert_eq!(
2168            determine_extrusion_axis(Some(&Vector3::new(1.0, 0.0, 0.0)), &wmin, &wmax),
2169            0
2170        );
2171        assert_eq!(
2172            determine_extrusion_axis(Some(&Vector3::new(0.0, 1.0, 0.0)), &wmin, &wmax),
2173            1
2174        );
2175        assert_eq!(
2176            determine_extrusion_axis(Some(&Vector3::new(0.0, 0.0, 1.0)), &wmin, &wmax),
2177            2
2178        );
2179        // Diagonal direction — picks dominant axis
2180        assert_eq!(
2181            determine_extrusion_axis(Some(&Vector3::new(0.7, 0.7, 0.0)), &wmin, &wmax),
2182            0 // X and Y tied, X wins via >=
2183        );
2184        // No direction → thinnest wall dim (Y=0.3)
2185        assert_eq!(
2186            determine_extrusion_axis(None, &wmin, &wmax),
2187            1
2188        );
2189    }
2190}