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 (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(&center_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}