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 · 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(¤t, &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(¤t, &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(¢er_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(¤t, &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;