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