Skip to main content

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