ifc_lite_geometry/router/voids/probe.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//! IFC parametric decode + cutter-mesh extraction from opening elements.
6
7use super::geom::*;
8use super::{GeometryRouter, RectParam, MAX_EXTRUSION_EXTRACT_DEPTH};
9use crate::profile::Profile2D;
10use crate::profiles::ProfileProcessor;
11use crate::router::is_body_representation;
12use crate::{Error, Mesh, Point3, Result, Vector3};
13use ifc_lite_core::{DecodedEntity, EntityDecoder, IfcType};
14use nalgebra::{Matrix3, Matrix4};
15use rustc_hash::FxHashSet;
16
17/// A host or opening solid recovered as a single IfcExtrudedAreaSolid swept along
18/// its profile normal (local ±Z), for the 2D opening-subtraction fast path
19/// ([`super::bool2d_path`]). `m` maps profile-local coordinates (profile in the
20/// z=0 plane, swept to `dir_sign·depth`) to NATIVE world (pre unit-scale / RTC),
21/// composed as `placement · mapped-chain · solid-Position`.
22pub(super) struct ExtrudedSolidInfo {
23 /// Full 2D profile (outer + any holes) in the solid's profile plane.
24 pub profile: Profile2D,
25 /// Extrusion depth (> 0).
26 pub depth: f64,
27 /// +1 for a +Z sweep, -1 for a -Z sweep (the only two eligible cases).
28 pub dir_sign: f64,
29 /// Profile-local → native-world transform.
30 pub m: Matrix4<f64>,
31}
32
33impl GeometryRouter {
34 // Get individual bounding boxes for each representation item in an opening element.
35 // This handles disconnected geometry (e.g., two separate window openings in one IfcOpeningElement)
36 // by returning separate bounds for each item instead of one combined bounding box.
37
38 /// Extract extrusion direction and position transform from IfcExtrudedAreaSolid
39 /// Returns (local_direction, position_transform)
40 fn extract_extrusion_direction_from_solid(
41 &self,
42 solid: &DecodedEntity,
43 decoder: &mut EntityDecoder,
44 ) -> Option<(Vector3<f64>, Option<Matrix4<f64>>)> {
45 // Get ExtrudedDirection (attribute 2: IfcDirection)
46 let direction_attr = solid.get(2)?;
47 let direction_entity = decoder.resolve_ref(direction_attr).ok()??;
48 let local_dir = self.parse_direction(&direction_entity).ok()?;
49
50 // Get Position transform (attribute 1: IfcAxis2Placement3D)
51 let position_transform = if let Some(pos_attr) = solid.get(1) {
52 if !pos_attr.is_null() {
53 if let Ok(Some(pos_entity)) = decoder.resolve_ref(pos_attr) {
54 if pos_entity.ifc_type == IfcType::IfcAxis2Placement3D {
55 self.parse_axis2_placement_3d(&pos_entity, decoder).ok()
56 } else {
57 None
58 }
59 } else {
60 None
61 }
62 } else {
63 None
64 }
65 } else {
66 None
67 };
68
69 Some((local_dir, position_transform))
70 }
71
72 /// Recursively extract extrusion direction and position transform from representation item
73 /// Handles IfcExtrudedAreaSolid, IfcBooleanClippingResult, and IfcMappedItem
74 /// Returns (local_direction, position_transform) where direction is in local space
75 fn extract_extrusion_direction_recursive(
76 &self,
77 item: &DecodedEntity,
78 decoder: &mut EntityDecoder,
79 ) -> Option<(Vector3<f64>, Option<Matrix4<f64>>)> {
80 let mut current = item.clone();
81 let mut visited = FxHashSet::default();
82 let mut mapping_chain: Option<Matrix4<f64>> = None;
83
84 for _depth in 0..MAX_EXTRUSION_EXTRACT_DEPTH {
85 if !visited.insert(current.id) {
86 return None;
87 }
88
89 match current.ifc_type {
90 IfcType::IfcExtrudedAreaSolid => {
91 let (dir, position_transform) =
92 self.extract_extrusion_direction_from_solid(¤t, decoder)?;
93 let combined = match (mapping_chain.as_ref(), position_transform) {
94 (Some(chain), Some(pos)) => Some(chain * pos),
95 (Some(chain), None) => Some(*chain),
96 (None, Some(pos)) => Some(pos),
97 (None, None) => None,
98 };
99 return Some((dir, combined));
100 }
101 IfcType::IfcBooleanClippingResult | IfcType::IfcBooleanResult => {
102 // FirstOperand (attribute 1) contains base geometry
103 let first_attr = current.get(1)?;
104 current = decoder.resolve_ref(first_attr).ok()??;
105 }
106 IfcType::IfcMappedItem => {
107 // MappingSource (attribute 0) -> MappedRepresentation -> Items
108 let source_attr = current.get(0)?;
109 let source = decoder.resolve_ref(source_attr).ok()??;
110 // RepresentationMap.MappedRepresentation is attribute 1
111 let rep_attr = source.get(1)?;
112 let rep = decoder.resolve_ref(rep_attr).ok()??;
113
114 // MappingTarget (attribute 1) -> instance transform
115 if let Some(target_attr) = current.get(1) {
116 if !target_attr.is_null() {
117 if let Ok(Some(target)) = decoder.resolve_ref(target_attr) {
118 if let Ok(map) =
119 self.parse_cartesian_transformation_operator(&target, decoder)
120 {
121 mapping_chain = Some(match mapping_chain.take() {
122 Some(chain) => chain * map,
123 None => map,
124 });
125 }
126 }
127 }
128 }
129
130 // Get first item from representation
131 let items_attr = rep.get(3)?;
132 let items = decoder.resolve_ref_list(items_attr).ok()?;
133 current = items.first()?.clone();
134 }
135 _ => return None,
136 }
137 }
138
139 None
140 }
141
142 /// Read a rectangular swept area as `(x_dim, y_dim, off_x, off_y, cos, sin)` in the
143 /// profile plane. Handles `IfcRectangleProfileDef` (XDim/YDim + 2D Position rotation)
144 /// AND an `IfcArbitraryClosedProfileDef` whose outer curve is an axis-aligned 4-point
145 /// rectangle polyline (the common Tekla/structural authoring of a rectangular wall).
146 /// `None` for any non-rectangular profile → the caller defers to the exact kernel.
147 fn read_rect_profile_2d(
148 &self,
149 profile: &DecodedEntity,
150 decoder: &mut EntityDecoder,
151 ) -> Option<(f64, f64, f64, f64, f64, f64)> {
152 match profile.ifc_type {
153 IfcType::IfcRectangleProfileDef => {
154 let x_dim = profile.get_float(3)?;
155 let y_dim = profile.get_float(4)?;
156 // Position (attr 2 = IfcAxis2Placement2D): in-plane rotation + offset.
157 let (mut cos_t, mut sin_t, mut off_x, mut off_y) = (1.0, 0.0, 0.0, 0.0);
158 if let Some(pos_attr) = profile.get(2) {
159 if !pos_attr.is_null() {
160 if let Ok(Some(pos)) = decoder.resolve_ref(pos_attr) {
161 if let Some(loc_attr) = pos.get(0) {
162 if let Ok(Some(loc)) = decoder.resolve_ref(loc_attr) {
163 if let Some(c) = loc.get(0).and_then(|x| x.as_list()) {
164 off_x = c.first().and_then(|x| x.as_float()).unwrap_or(0.0);
165 off_y = c.get(1).and_then(|x| x.as_float()).unwrap_or(0.0);
166 }
167 }
168 }
169 if let Some(rd_attr) = pos.get(1) {
170 if !rd_attr.is_null() {
171 if let Ok(Some(rd)) = decoder.resolve_ref(rd_attr) {
172 if let Some(c) = rd.get(0).and_then(|x| x.as_list()) {
173 let dx =
174 c.first().and_then(|x| x.as_float()).unwrap_or(1.0);
175 let dy =
176 c.get(1).and_then(|x| x.as_float()).unwrap_or(0.0);
177 let n = (dx * dx + dy * dy).sqrt();
178 if n > 1e-12 {
179 cos_t = dx / n;
180 sin_t = dy / n;
181 }
182 }
183 }
184 }
185 }
186 }
187 }
188 }
189 Some((x_dim, y_dim, off_x, off_y, cos_t, sin_t))
190 }
191 IfcType::IfcArbitraryClosedProfileDef => {
192 // OuterCurve (attr 2) must be an axis-aligned rectangle polyline.
193 let curve = decoder.resolve_ref(profile.get(2)?).ok()??;
194 if curve.ifc_type != IfcType::IfcPolyline {
195 return None;
196 }
197 let pts = decoder.resolve_ref_list(curve.get(0)?).ok()?;
198 let mut coords: Vec<(f64, f64)> = Vec::with_capacity(pts.len());
199 for p in &pts {
200 let c = p.get(0).and_then(|x| x.as_list())?;
201 coords.push((c.first()?.as_float()?, c.get(1)?.as_float()?));
202 }
203 // Drop a repeated closing vertex.
204 if coords.len() >= 2 {
205 let (f, l) = (coords[0], coords[coords.len() - 1]);
206 if (f.0 - l.0).abs() < 1e-9 && (f.1 - l.1).abs() < 1e-9 {
207 coords.pop();
208 }
209 }
210 if coords.len() != 4 {
211 return None;
212 }
213 // General 4-point RECTANGLE — axis-aligned OR rotated in-plane. Compute the
214 // oriented box from its edges and fold the in-plane rotation into the frame
215 // (`cos_t`/`sin_t`). Tekla / IFC2X3 routinely author rotated-rectangle
216 // openings this way, so the old axis-aligned-only check rejected ~90% of
217 // them. Axis-aligned is just the cos_t=1, sin_t=0 special case.
218 let p = &coords;
219 let edge = |i: usize| (p[(i + 1) % 4].0 - p[i].0, p[(i + 1) % 4].1 - p[i].1);
220 let len = |e: (f64, f64)| (e.0 * e.0 + e.1 * e.1).sqrt();
221 let e0 = edge(0);
222 let e1 = edge(1);
223 let e2 = edge(2);
224 let (xd, yd) = (len(e0), len(e1));
225 if xd <= 1e-9 || yd <= 1e-9 {
226 return None;
227 }
228 // Rectangle: adjacent edges perpendicular AND opposite edges equal length.
229 let dot = (e0.0 * e1.0 + e0.1 * e1.1) / (xd * yd);
230 if dot.abs() > 0.01 || (len(e2) - xd).abs() > xd * 0.01 + 1e-6 {
231 return None;
232 }
233 // Local X' = first-edge direction; centre = polygon centroid.
234 let (cos_t, sin_t) = (e0.0 / xd, e0.1 / xd);
235 let cx = (p[0].0 + p[1].0 + p[2].0 + p[3].0) * 0.25;
236 let cy = (p[0].1 + p[1].1 + p[2].1 + p[3].1) * 0.25;
237 Some((xd, yd, cx, cy, cos_t, sin_t))
238 }
239 _ => None,
240 }
241 }
242
243 /// Items of the element's body shape representation(s), selected EXACTLY as
244 /// the main mesh path (`process_element` /
245 /// `process_element_with_submeshes_impl`): the effective representation type
246 /// (`RepresentationType`, falling back to the `RepresentationIdentifier`
247 /// when blank — CATIA #1661) filtered by [`is_body_representation`], with a
248 /// `MappedRepresentation` skipped when the element also carries direct body
249 /// geometry (the mesh path's de-dup, so the fast path reads the same solids
250 /// the renderer draws). Items from EVERY qualifying representation are
251 /// collected — the mesh path merges them all — so a probe that requires a
252 /// single item correctly DEFERS when the rendered body spans more than one
253 /// representation instead of silently cutting only the first. The old raw
254 /// `RepresentationType`-only match could latch onto an earlier auxiliary
255 /// `SweptSolid`/`SolidModel` (or miss a CATIA blank-type/`Body`-identifier
256 /// rep the renderer meshes), driving the cut off a DIFFERENT solid than the
257 /// one rendered.
258 fn body_representation_items(
259 &self,
260 element: &DecodedEntity,
261 decoder: &mut EntityDecoder,
262 ) -> Option<Vec<DecodedEntity>> {
263 let rep = decoder.resolve_ref(element.get(6)?).ok()??;
264 if rep.ifc_type != IfcType::IfcProductDefinitionShape {
265 return None;
266 }
267 let reps = decoder.resolve_ref_list(rep.get(2)?).ok()?;
268 // Mirror the mesh path's direct-vs-mapped de-dup: a MappedRepresentation
269 // is skipped only when the element ALSO carries direct body geometry.
270 let has_direct_geometry = reps.iter().any(|sr| {
271 sr.ifc_type == IfcType::IfcShapeRepresentation
272 && crate::router::effective_rep_type(sr)
273 .map(crate::router::is_direct_body_representation)
274 .unwrap_or(false)
275 });
276 let mut items = Vec::new();
277 for sr in reps {
278 if sr.ifc_type != IfcType::IfcShapeRepresentation {
279 continue;
280 }
281 let Some(rt) = crate::router::effective_rep_type(&sr) else {
282 continue;
283 };
284 if rt == "MappedRepresentation" && has_direct_geometry {
285 continue;
286 }
287 if !crate::router::is_body_representation(rt) {
288 continue;
289 }
290 let Some(items_attr) = sr.get(3) else {
291 continue;
292 };
293 if let Ok(rep_items) = decoder.resolve_ref_list(items_attr) {
294 items.extend(rep_items);
295 }
296 }
297 if items.is_empty() {
298 None
299 } else {
300 Some(items)
301 }
302 }
303
304 /// Whether an `IfcMappedItem`'s `RepresentationMap.MappingOrigin` (attr 0) is
305 /// a geometric no-op for the fast-path solid recovery.
306 ///
307 /// The `IfcMappedItem` MESH path drops `MappingOrigin` entirely and applies
308 /// ONLY the `MappingTarget` operator (see
309 /// [`GeometryRouter::process_mapped_item_cached`] and
310 /// `profile_extractor::extract_mapped_item_profiles`, whose composed
311 /// transform is `elem_transform · mapping_target`). To stay bit-for-bit
312 /// consistent with that rendered geometry — the very geometry the exact void
313 /// kernel also cuts — this fast path must drop it too. A non-identity origin
314 /// would shift the recovered solid off the rendered mesh, so we only proceed
315 /// when the origin provably has no effect; otherwise the caller defers the
316 /// whole opening/host to the exact kernel. Returns `true` when the origin is
317 /// absent / null / an identity `IfcAxis2Placement3D`, `false` when it is
318 /// non-identity, a 2D placement, or cannot be confirmed identity.
319 fn mapping_origin_is_identity(
320 &self,
321 source: &DecodedEntity,
322 decoder: &mut EntityDecoder,
323 ) -> bool {
324 let Some(origin_attr) = source.get(0) else {
325 return true; // no MappingOrigin attribute -> no effect
326 };
327 if origin_attr.is_null() {
328 return true;
329 }
330 let origin = match decoder.resolve_ref(origin_attr) {
331 Ok(Some(e)) => e,
332 _ => return false, // present but unresolvable -> defer
333 };
334 // Only a 3D identity placement is a provable no-op for the 3D solid
335 // recovery; a 2D placement (or anything else) -> defer.
336 if origin.ifc_type != IfcType::IfcAxis2Placement3D {
337 return false;
338 }
339 match self.parse_axis2_placement_3d(&origin, decoder) {
340 Ok(m) => {
341 let id = Matrix4::<f64>::identity();
342 m.iter().zip(id.iter()).all(|(a, b)| (a - b).abs() < 1e-9)
343 }
344 Err(_) => false,
345 }
346 }
347
348 /// One representation item → its EXACT oriented box, unwrapping IfcBooleanClippingResult
349 /// / IfcMappedItem to the IfcExtrudedAreaSolid. `None` unless it is a rectangular prism.
350 /// Frame + extents from the parametrics (× unit_scale, − rtc_offset to match the mesh).
351 fn rect_param_from_item(
352 &self,
353 item: DecodedEntity,
354 placement: &Matrix4<f64>,
355 decoder: &mut EntityDecoder,
356 ) -> Option<RectParam> {
357 let mut current = item;
358 let mut chain = Matrix4::<f64>::identity();
359 let mut visited = FxHashSet::default();
360 let solid = loop {
361 if !visited.insert(current.id) || visited.len() > MAX_EXTRUSION_EXTRACT_DEPTH {
362 return None;
363 }
364 match current.ifc_type {
365 IfcType::IfcExtrudedAreaSolid => break current,
366 IfcType::IfcBooleanClippingResult | IfcType::IfcBooleanResult => {
367 current = decoder.resolve_ref(current.get(1)?).ok()??;
368 }
369 IfcType::IfcMappedItem => {
370 let source = decoder.resolve_ref(current.get(0)?).ok()??;
371 let mapped_rep = decoder.resolve_ref(source.get(1)?).ok()??;
372 // The mesh path drops MappingOrigin (applies only
373 // MappingTarget); a non-identity origin would shift the
374 // recovered box off the rendered solid, so defer.
375 if !self.mapping_origin_is_identity(&source, decoder) {
376 return None;
377 }
378 if let Some(t) = current.get(1) {
379 if !t.is_null() {
380 if let Ok(Some(te)) = decoder.resolve_ref(t) {
381 if let Ok(m) =
382 self.parse_cartesian_transformation_operator(&te, decoder)
383 {
384 chain *= m;
385 }
386 }
387 }
388 }
389 current =
390 decoder.resolve_ref_list(mapped_rep.get(3)?).ok()?.into_iter().next()?;
391 }
392 _ => return None,
393 }
394 };
395
396 let profile = decoder.resolve_ref(solid.get(0)?).ok()??;
397 let (x_dim, y_dim, off_x, off_y, cos_t, sin_t) =
398 self.read_rect_profile_2d(&profile, decoder)?;
399 let depth = solid.get_float(3)?;
400 if !(x_dim.is_finite()
401 && y_dim.is_finite()
402 && depth.is_finite()
403 && x_dim > 0.0
404 && y_dim > 0.0
405 && depth > 0.0)
406 {
407 return None;
408 }
409 let solid_pos = match solid.get(1) {
410 Some(a) if !a.is_null() => {
411 let e = decoder.resolve_ref(a).ok()??;
412 self.parse_axis2_placement_3d(&e, decoder).ok()?
413 }
414 _ => Matrix4::identity(),
415 };
416 let dir_local = {
417 let e = decoder.resolve_ref(solid.get(2)?).ok()??;
418 self.parse_direction(&e).ok()?
419 };
420
421 let u = Vector3::new(cos_t, sin_t, 0.0);
422 let v = Vector3::new(-sin_t, cos_t, 0.0);
423 let w = dir_local.try_normalize(1e-12)?;
424 let m = placement * chain * solid_pos;
425 let rot = m.fixed_view::<3, 3>(0, 0).into_owned();
426 let uu = (rot * u).try_normalize(1e-9)?;
427 let vv = (rot * v).try_normalize(1e-9)?;
428 let ww = (rot * w).try_normalize(1e-9)?;
429 let center_local = Point3::new(off_x, off_y, 0.0) + w * (depth * 0.5);
430 let center_native = m.transform_point(¢er_local);
431 let s = self.unit_scale;
432 let (rx, ry, rz) = self.rtc_offset;
433 Some(RectParam {
434 r: Matrix3::from_columns(&[uu, vv, ww]),
435 center: Point3::new(
436 center_native.x * s - rx,
437 center_native.y * s - ry,
438 center_native.z * s - rz,
439 ),
440 half: [x_dim * 0.5 * s, y_dim * 0.5 * s, depth * 0.5 * s],
441 })
442 }
443
444 /// EXACT boxes for a body that is a UNION OF RECTANGULAR PRISMS (the common Tekla
445 /// multi-solid opening): one box per representation item, or `None` if any item is not a
446 /// rectangular extrusion. The cellular `rect_fast` cut subtracts the N boxes natively.
447 pub fn parametric_rect_probe_all(
448 &self,
449 element: &DecodedEntity,
450 decoder: &mut EntityDecoder,
451 ) -> Option<Vec<RectParam>> {
452 let placement = self
453 .get_placement_transform_from_element(element, decoder)
454 .ok()?;
455 let items = self.body_representation_items(element, decoder)?;
456 if items.is_empty() {
457 return None;
458 }
459 let mut boxes = Vec::with_capacity(items.len());
460 for item in items {
461 boxes.push(self.rect_param_from_item(item, &placement, decoder)?);
462 }
463 Some(boxes)
464 }
465
466 /// PHASE-0 CENSUS (read-only): the EXACT oriented rectangular box of an extruded
467 /// element, read from the IFC parametrics (IfcRectangleProfileDef XDim/YDim/Depth +
468 /// composed placement axes), NOT inferred from the f32 mesh. Returns `None` unless the
469 /// element's body is a single clean IfcRectangleProfileDef extrusion (after unwrapping
470 /// IfcBooleanClippingResult / IfcMappedItem). This is the parametric frame + extents the
471 /// failed oriented attempt should have used instead of `infer_opening_frame` + mesh-AABB.
472 pub fn parametric_rect_probe(
473 &self,
474 element: &DecodedEntity,
475 decoder: &mut EntityDecoder,
476 ) -> Option<RectParam> {
477 let placement = self
478 .get_placement_transform_from_element(element, decoder)
479 .ok()?;
480 // A clean rectangular extrusion is exactly ONE Body item. A multi-solid
481 // body (the probe would otherwise read only the first) must defer so the
482 // exact kernel cuts all of it. Sharing `body_representation_items` +
483 // `rect_param_from_item` with `parametric_rect_probe_all` keeps the host
484 // frame and the cutter frames on ONE derivation - they cannot drift into
485 // a silent miscut (they feed the same shared-frame cellular cut).
486 let items = self.body_representation_items(element, decoder)?;
487 if items.len() != 1 {
488 return None;
489 }
490 self.rect_param_from_item(items.into_iter().next()?, &placement, decoder)
491 }
492
493 /// Get per-item meshes for an opening element, transformed to world coordinates.
494 /// Uses the same `transform_mesh` path as `process_element` to ensure identical
495 /// coordinate handling (ObjectPlacement, unit scaling, conditional RTC offset).
496 pub fn get_opening_item_meshes_world(
497 &self,
498 element: &DecodedEntity,
499 decoder: &mut EntityDecoder,
500 ) -> Result<Vec<Mesh>> {
501 let representation_attr = element.get(6).ok_or_else(|| {
502 Error::geometry("Element has no representation attribute".to_string())
503 })?;
504 if representation_attr.is_null() {
505 return Ok(vec![]);
506 }
507
508 let representation = decoder
509 .resolve_ref(representation_attr)?
510 .ok_or_else(|| Error::geometry("Failed to resolve representation".to_string()))?;
511 let representations_attr = representation.get(2).ok_or_else(|| {
512 Error::geometry("ProductDefinitionShape missing Representations".to_string())
513 })?;
514 let representations = decoder.resolve_ref_list(representations_attr)?;
515
516 // Get the same placement transform that apply_placement uses
517 let mut placement_transform = self
518 .get_placement_transform_from_element(element, decoder)
519 .unwrap_or_else(|_| Matrix4::identity());
520 self.scale_transform(&mut placement_transform);
521
522 let mut item_meshes = Vec::new();
523
524 for shape_rep in representations {
525 if shape_rep.ifc_type != IfcType::IfcShapeRepresentation {
526 continue;
527 }
528 if let Some(rep_type) = crate::router::effective_rep_type(&shape_rep) {
529 if !is_body_representation(rep_type) {
530 continue;
531 }
532 }
533 let items_attr = match shape_rep.get(3) {
534 Some(attr) => attr,
535 None => continue,
536 };
537 let items = match decoder.resolve_ref_list(items_attr) {
538 Ok(items) => items,
539 Err(_) => continue,
540 };
541
542 for item in items {
543 let mut mesh = match self.process_representation_item(&item, decoder) {
544 Ok(m) if !m.is_empty() => m,
545 _ => continue,
546 };
547
548 // Keep the host in absolute world/RTC coordinates here: the void cut
549 // (`apply_void_context`) matches it against world-coordinate opening
550 // cutters, so relativizing the host now would silently break every
551 // cut. The per-element local-origin relativization is applied to the
552 // CSG OUTPUT instead (shared host+cutter frame).
553 self.transform_mesh_world_framed(&mut mesh, &placement_transform, false);
554
555 item_meshes.push(mesh);
556 }
557 }
558
559 Ok(item_meshes)
560 }
561
562 /// Extrusion direction is in world coordinates, normalized
563 /// Returns None for extrusion direction if it cannot be extracted (fallback to bounds-only)
564 pub fn get_opening_item_bounds_with_direction(
565 &self,
566 element: &DecodedEntity,
567 decoder: &mut EntityDecoder,
568 ) -> Result<Vec<(Point3<f64>, Point3<f64>, Option<Vector3<f64>>)>> {
569 // Get representation (attribute 6 for most building elements)
570 let representation_attr = element.get(6).ok_or_else(|| {
571 Error::geometry("Element has no representation attribute".to_string())
572 })?;
573
574 if representation_attr.is_null() {
575 return Ok(vec![]);
576 }
577
578 let representation = decoder
579 .resolve_ref(representation_attr)?
580 .ok_or_else(|| Error::geometry("Failed to resolve representation".to_string()))?;
581
582 // Get representations list
583 let representations_attr = representation.get(2).ok_or_else(|| {
584 Error::geometry("ProductDefinitionShape missing Representations".to_string())
585 })?;
586
587 let representations = decoder.resolve_ref_list(representations_attr)?;
588
589 // Get placement transform
590 let mut placement_transform = self
591 .get_placement_transform_from_element(element, decoder)
592 .unwrap_or_else(|_| Matrix4::identity());
593 self.scale_transform(&mut placement_transform);
594
595 let mut bounds_list = Vec::new();
596
597 for shape_rep in representations {
598 if shape_rep.ifc_type != IfcType::IfcShapeRepresentation {
599 continue;
600 }
601
602 // Check representation type
603 if let Some(rep_type) = crate::router::effective_rep_type(&shape_rep) {
604 if !is_body_representation(rep_type) {
605 continue;
606 }
607 }
608
609 // Get items list
610 let items_attr = match shape_rep.get(3) {
611 Some(attr) => attr,
612 None => continue,
613 };
614
615 let items = match decoder.resolve_ref_list(items_attr) {
616 Ok(items) => items,
617 Err(_) => continue,
618 };
619
620 // Process each item separately to get individual bounds
621 for item in items {
622 // Try to extract extrusion direction recursively (handles wrappers)
623 let extrusion_direction = if let Some((local_dir, position_transform)) =
624 self.extract_extrusion_direction_recursive(&item, decoder)
625 {
626 // A zero-length IFCDIRECTION drops only THIS item's direction
627 // (coarser bounds), not `?`-abort every sibling item's bounds.
628 let element_rot = extract_rotation_columns(&placement_transform);
629 if let Some(pos_transform) = position_transform {
630 let pos_rot = extract_rotation_columns(&pos_transform);
631 rotate_and_normalize(&pos_rot, &local_dir)
632 .ok()
633 .and_then(|world_dir| {
634 rotate_and_normalize(&element_rot, &world_dir).ok()
635 })
636 } else {
637 rotate_and_normalize(&element_rot, &local_dir).ok()
638 }
639 } else {
640 None
641 };
642
643 // Get mesh bounds (same as original function)
644 let mesh = match self.process_representation_item(&item, decoder) {
645 Ok(m) if !m.is_empty() => m,
646 _ => continue,
647 };
648
649 // Get bounds and transform to world coordinates
650 let (mesh_min, mesh_max) = mesh.bounds();
651
652 // Transform corner points to world coordinates
653 let corners = [
654 Point3::new(mesh_min.x as f64, mesh_min.y as f64, mesh_min.z as f64),
655 Point3::new(mesh_max.x as f64, mesh_min.y as f64, mesh_min.z as f64),
656 Point3::new(mesh_min.x as f64, mesh_max.y as f64, mesh_min.z as f64),
657 Point3::new(mesh_max.x as f64, mesh_max.y as f64, mesh_min.z as f64),
658 Point3::new(mesh_min.x as f64, mesh_min.y as f64, mesh_max.z as f64),
659 Point3::new(mesh_max.x as f64, mesh_min.y as f64, mesh_max.z as f64),
660 Point3::new(mesh_min.x as f64, mesh_max.y as f64, mesh_max.z as f64),
661 Point3::new(mesh_max.x as f64, mesh_max.y as f64, mesh_max.z as f64),
662 ];
663
664 // Transform all corners and compute new AABB
665 let transformed: Vec<Point3<f64>> = corners
666 .iter()
667 .map(|p| placement_transform.transform_point(p))
668 .collect();
669
670 let world_min = Point3::new(
671 transformed
672 .iter()
673 .map(|p| p.x)
674 .fold(f64::INFINITY, f64::min),
675 transformed
676 .iter()
677 .map(|p| p.y)
678 .fold(f64::INFINITY, f64::min),
679 transformed
680 .iter()
681 .map(|p| p.z)
682 .fold(f64::INFINITY, f64::min),
683 );
684 let world_max = Point3::new(
685 transformed
686 .iter()
687 .map(|p| p.x)
688 .fold(f64::NEG_INFINITY, f64::max),
689 transformed
690 .iter()
691 .map(|p| p.y)
692 .fold(f64::NEG_INFINITY, f64::max),
693 transformed
694 .iter()
695 .map(|p| p.z)
696 .fold(f64::NEG_INFINITY, f64::max),
697 );
698
699 // Apply RTC offset to opening bounds so they match wall mesh coordinate system
700 // Wall mesh positions have RTC subtracted during transform_mesh, so opening bounds must match
701 let rtc = self.rtc_offset;
702 let rtc_min = Point3::new(
703 world_min.x - rtc.0,
704 world_min.y - rtc.1,
705 world_min.z - rtc.2,
706 );
707 let rtc_max = Point3::new(
708 world_max.x - rtc.0,
709 world_max.y - rtc.1,
710 world_max.z - rtc.2,
711 );
712
713 bounds_list.push((rtc_min, rtc_max, extrusion_direction));
714 }
715 }
716
717 Ok(bounds_list)
718 }
719
720 /// Unwrap `item` — through `IfcMappedItem` (accumulating the mapping
721 /// transform), but NOT through `IfcBooleanClippingResult`/`IfcBooleanResult`
722 /// (a clipped host is ineligible for the 2D re-extrude) — to a single
723 /// `IfcExtrudedAreaSolid` swept along its profile normal (local ±Z), and
724 /// recover its full 2D profile + depth + composed profile-local→native-world
725 /// transform. Returns `None` for any non-extrusion, clipped, obliquely-swept,
726 /// or degenerate solid, so callers fall back to the exact kernel.
727 pub(super) fn extruded_solid_from_item(
728 &self,
729 item: DecodedEntity,
730 placement: &Matrix4<f64>,
731 decoder: &mut EntityDecoder,
732 ) -> Option<ExtrudedSolidInfo> {
733 let mut current = item;
734 let mut chain = Matrix4::<f64>::identity();
735 let mut visited = FxHashSet::default();
736 let solid = loop {
737 if !visited.insert(current.id) || visited.len() > MAX_EXTRUSION_EXTRACT_DEPTH {
738 return None;
739 }
740 match current.ifc_type {
741 IfcType::IfcExtrudedAreaSolid => break current,
742 IfcType::IfcMappedItem => {
743 let source = decoder.resolve_ref(current.get(0)?).ok()??;
744 let mapped_rep = decoder.resolve_ref(source.get(1)?).ok()??;
745 // The mesh path drops MappingOrigin (applies only
746 // MappingTarget); a non-identity origin would shift the
747 // re-extruded footprint off the rendered solid, so defer the
748 // whole opening to the exact kernel.
749 if !self.mapping_origin_is_identity(&source, decoder) {
750 return None;
751 }
752 // A non-null MappingTarget MUST resolve + parse to a valid
753 // transform: silently dropping it would misplace the
754 // re-extruded footprint, so any failure defers the whole
755 // opening to the exact kernel rather than continuing with an
756 // identity transform.
757 if let Some(t) = current.get(1) {
758 if !t.is_null() {
759 let te = decoder.resolve_ref(t).ok()??;
760 let mm =
761 self.parse_cartesian_transformation_operator(&te, decoder).ok()?;
762 chain *= mm;
763 }
764 }
765 // Require EXACTLY ONE mapped representation item. A multi-item
766 // mapped opening would otherwise be reduced to its first
767 // solid, dropping the rest from BOTH the 2D footprint and the
768 // residual exact cut; defer the whole opening instead.
769 let mut items = decoder.resolve_ref_list(mapped_rep.get(3)?).ok()?.into_iter();
770 let first = items.next()?;
771 if items.next().is_some() {
772 return None;
773 }
774 current = first;
775 }
776 // Boolean clipping / anything else: ineligible for the 2D path.
777 _ => return None,
778 }
779 };
780
781 let profile_entity = decoder.resolve_ref(solid.get(0)?).ok()??;
782 let profile = ProfileProcessor::new(self.schema.clone())
783 .process(&profile_entity, decoder, self.tessellation_quality)
784 .ok()?;
785 if profile.outer.len() < 3 {
786 return None;
787 }
788 let depth = solid.get_float(3)?;
789 if !depth.is_finite() || depth <= 0.0 {
790 return None;
791 }
792 let dir_local = {
793 let e = decoder.resolve_ref(solid.get(2)?).ok()??;
794 self.parse_direction(&e).ok()?
795 }
796 .try_normalize(1e-12)?;
797 // The 2D re-extrude is only valid when the sweep is along the profile
798 // normal (local ±Z). An oblique / sheared extrusion shifts the footprint
799 // with depth, so the through-cut projection would be wrong — defer.
800 if dir_local.x.abs() > 1e-6 || dir_local.y.abs() > 1e-6 {
801 return None;
802 }
803 let dir_sign = if dir_local.z >= 0.0 { 1.0 } else { -1.0 };
804 let solid_pos = match solid.get(1) {
805 Some(a) if !a.is_null() => {
806 let e = decoder.resolve_ref(a).ok()??;
807 self.parse_axis2_placement_3d(&e, decoder).ok()?
808 }
809 _ => Matrix4::identity(),
810 };
811 Some(ExtrudedSolidInfo {
812 profile,
813 depth,
814 dir_sign,
815 m: placement * chain * solid_pos,
816 })
817 }
818
819 /// The host element's body as a SINGLE eligible extruded solid (exactly one
820 /// Body item, an `IfcExtrudedAreaSolid` after unwrapping mapped items). A
821 /// multi-item body or a clipped host returns `None`.
822 pub(super) fn host_extruded_solid(
823 &self,
824 element: &DecodedEntity,
825 decoder: &mut EntityDecoder,
826 ) -> Option<ExtrudedSolidInfo> {
827 let placement = self
828 .get_placement_transform_from_element(element, decoder)
829 .ok()?;
830 let items = self.body_representation_items(element, decoder)?;
831 if items.len() != 1 {
832 return None;
833 }
834 self.extruded_solid_from_item(items.into_iter().next()?, &placement, decoder)
835 }
836
837 /// Every Body item of an opening element as an eligible extruded solid (an
838 /// opening may be a union of extruded prisms — Tekla multi-solid). `None` if
839 /// the body is empty or ANY item is not a clean extruded solid.
840 pub(super) fn opening_extruded_solids(
841 &self,
842 element: &DecodedEntity,
843 decoder: &mut EntityDecoder,
844 ) -> Option<Vec<ExtrudedSolidInfo>> {
845 let placement = self
846 .get_placement_transform_from_element(element, decoder)
847 .ok()?;
848 let items = self.body_representation_items(element, decoder)?;
849 if items.is_empty() {
850 return None;
851 }
852 let mut out = Vec::with_capacity(items.len());
853 for it in items {
854 out.push(self.extruded_solid_from_item(it, &placement, decoder)?);
855 }
856 Some(out)
857 }
858}