Skip to main content

ifc_geometry/input/body/
mod.rs

1//! What a product's Body representation is made of, without lowering it.
2//!
3//! # Why a description and not a mesh
4//!
5//! Rule checks on structural members and walls ask how a body is modelled:
6//! "is this beam an extrusion of an I-section, how deep is the web, which way
7//! does it run". A mesh has lost every one of those answers. This module
8//! reads them from the representation items directly, in SI units and world
9//! coordinates, and links no geometry kernel.
10//!
11//! # One entry per item, never a merged answer
12//!
13//! A Body representation may hold several items, and a mapped item may map
14//! several more. [`body_description`] reports one [`BodyItem`] per resolved
15//! geometric item, in authored order, rather than refusing or picking one:
16//! a column with a base plate is two extrusions, and both are facts a rule
17//! check may need. [`BodyDescription::sole_item`] is the convenience for the
18//! common single-item case.
19//!
20//! # All or nothing
21//!
22//! If any item cannot be described exactly (an unsupported family, a dangling
23//! reference, a mapping that scales a swept solid), the whole call fails with
24//! a typed error naming the entity. A partial list would let a caller treat a
25//! body as fully checked when part of it was never read.
26//!
27//! # Frames
28//!
29//! Items are placed exactly as lowering places them: the representation
30//! context's `WorldCoordinateSystem`, then the product's placement chain, then
31//! for a mapped item `MappingTarget` and `MappingOrigin`, then the item's own
32//! `Position`. Mapped geometry therefore resolves to the same answer as the
33//! same geometry authored in place.
34
35mod kind;
36mod sweep;
37
38pub use kind::BodyKind;
39
40use ifc_model::{EntityId, Model};
41
42use super::context::product_representation_frame;
43use super::profile::ProfileDescription;
44use super::representation::{select_shape_representation, Representation, RepresentationPurpose};
45use crate::error::{GeometryError, GeometryResult};
46use crate::resource::mapped::MappingWalker;
47use crate::resource::operator::operator_transform;
48use crate::resource::placement::axis_placement_transform;
49use crate::slots::Slots;
50use crate::transform::Transform;
51use crate::units::UnitScale;
52
53/// The Body representation of one product, one entry per geometric item.
54#[derive(Debug, Clone, PartialEq)]
55#[non_exhaustive]
56pub struct BodyDescription {
57    /// The product described.
58    pub product: EntityId,
59    /// The representation selected as the body
60    /// ([`crate::select_shape_representation`]).
61    pub representation: EntityId,
62    /// One entry per geometric item, mapped items resolved, in authored order.
63    pub items: Vec<BodyItem>,
64}
65
66impl BodyDescription {
67    /// The only item, when the body has exactly one.
68    ///
69    /// `None` for an empty body and for a body of several items; a caller
70    /// that needs every item reads [`Self::items`].
71    pub fn sole_item(&self) -> Option<&BodyItem> {
72        match self.items.as_slice() {
73            [item] => Some(item),
74            _ => None,
75        }
76    }
77}
78
79/// One geometric representation item of a body.
80#[derive(Debug, Clone, PartialEq)]
81#[non_exhaustive]
82pub struct BodyItem {
83    /// The geometric item itself, never an `IfcMappedItem`.
84    pub item: EntityId,
85    /// Its concrete IFC type in upper case.
86    pub type_name: String,
87    /// The `IfcMappedItem`s this item was reached through, outermost first.
88    /// Empty when the item is authored directly in the body.
89    pub mapped_by: Vec<EntityId>,
90    /// How the item models its shape.
91    pub kind: BodyKind,
92    /// Profile and path, for the swept-area families
93    /// ([`BodyKind::is_swept_area`]); `None` for every other kind.
94    pub swept: Option<SweptSolid>,
95    /// The frame the item's own coordinates are placed in, in metres: the
96    /// representation context's `WorldCoordinateSystem` and the product
97    /// placement, composed with every `MappingTarget o MappingOrigin` in
98    /// [`Self::mapped_by`] (#185). The identity composition for an item
99    /// authored directly in a body at the origin.
100    ///
101    /// Unlike [`SweptSolid::placement_world`] it is not required to be rigid:
102    /// a mapping may scale or mirror any item kind, and this frame says so.
103    /// A swept solid's `placement_world` is this frame composed with the
104    /// solid's own `Position`.
105    pub item_world: Transform,
106}
107
108impl BodyItem {
109    /// Whether the item is placed mirrored: [`Self::item_world`] reverses
110    /// handedness, so a left-hand part appears as its right-hand twin.
111    ///
112    /// `None` when the frame is degenerate (a zero or non-finite
113    /// determinant), which no valid placement or mapping produces.
114    pub fn is_mirrored(&self) -> Option<bool> {
115        let determinant = self.item_world.determinant();
116        (determinant.is_finite() && determinant != 0.0).then_some(determinant < 0.0)
117    }
118}
119
120/// A swept-area solid: its profile, where it sits, and the path it follows.
121#[derive(Debug, Clone, PartialEq)]
122#[non_exhaustive]
123pub struct SweptSolid {
124    /// `SweptArea`, in metres and radians.
125    pub profile: ProfileDescription,
126    /// `EndSweptArea` of a tapered sweep; `None` otherwise.
127    pub end_profile: Option<ProfileDescription>,
128    /// The solid's `Position` composed into world coordinates, in metres.
129    ///
130    /// The profile lies in this frame's XY plane. Rigid by construction: a
131    /// mapping that scales or mirrors a swept solid is refused, because its
132    /// profile parameters would no longer be the authored ones.
133    pub placement_world: Transform,
134    /// The path the profile follows.
135    pub path: SweepPath,
136}
137
138/// The path of a swept-area solid, in world coordinates.
139#[derive(Debug, Clone, PartialEq)]
140#[non_exhaustive]
141pub enum SweepPath {
142    /// A straight extrusion (plain or tapered).
143    #[non_exhaustive]
144    Extrusion {
145        /// `ExtrudedDirection` in world coordinates, unit length.
146        direction_world: [f64; 3],
147        /// `Depth`, in metres, measured along `direction_world`.
148        depth: f64,
149    },
150    /// A revolution about an axis (plain or tapered).
151    #[non_exhaustive]
152    Revolution {
153        /// The axis origin in world coordinates, in metres.
154        axis_origin_world: [f64; 3],
155        /// The axis direction in world coordinates, unit length.
156        axis_direction_world: [f64; 3],
157        /// `Angle`, in radians.
158        angle: f64,
159    },
160    /// A sweep along a directrix curve, reported by reference.
161    ///
162    /// The curve and its `StartParam`/`EndParam` live in the directrix's own
163    /// parameterisation; reading them is a curve question, not a body one.
164    #[non_exhaustive]
165    Directrix {
166        /// The `Directrix` curve.
167        directrix: EntityId,
168    },
169}
170
171/// Describe the Body representation of `product`.
172///
173/// Returns `Ok(None)` when the product has no body representation (only an
174/// Axis or FootPrint, or none at all): that is an answer, not a failure. A
175/// body whose representation lists no items yields an empty `items`.
176///
177/// Every item is described or the call fails; see the module docs. Kernel-free:
178/// available with `--no-default-features`.
179///
180/// ```no_run
181/// # use ifc_model::{EntityId, Model};
182/// # use ifc_geometry::{body_description, units, BodyKind, SweepPath};
183/// # fn demo(model: &Model, beam: EntityId) {
184/// let scale = units::resolve(model);
185/// let body = body_description(model, &scale, beam).unwrap().expect("a body");
186/// let item = body.sole_item().expect("one item");
187/// if item.kind == BodyKind::Extrusion {
188///     let swept = item.swept.as_ref().unwrap();
189///     if let SweepPath::Extrusion { direction_world, depth, .. } = swept.path {
190///         let _ = (swept.profile.type_name.as_str(), direction_world, depth);
191///     }
192/// }
193/// # }
194/// ```
195pub fn body_description(
196    model: &Model,
197    units: &UnitScale,
198    product: EntityId,
199) -> GeometryResult<Option<BodyDescription>> {
200    let Some(representation) = select_shape_representation(model, product)? else {
201        return Ok(None);
202    };
203    // The same frame lowering places the body's items in.
204    let Some(world) =
205        product_representation_frame(model, units, product, RepresentationPurpose::Body)?
206    else {
207        return Ok(None);
208    };
209
210    let mut walk = Walk {
211        model,
212        units,
213        walker: MappingWalker::new(),
214        mapped_by: Vec::new(),
215        items: Vec::new(),
216    };
217    walk.representation(product, representation, world)?;
218    Ok(Some(BodyDescription {
219        product,
220        representation,
221        items: walk.items,
222    }))
223}
224
225/// State for one body walk: the mapped-item stack and the collected items.
226struct Walk<'m> {
227    model: &'m Model,
228    units: &'m UnitScale,
229    walker: MappingWalker,
230    mapped_by: Vec<EntityId>,
231    items: Vec<BodyItem>,
232}
233
234impl Walk<'_> {
235    /// Describe every item of one `IfcRepresentation` under `frame`.
236    fn representation(
237        &mut self,
238        referrer: EntityId,
239        representation: EntityId,
240        frame: Transform,
241    ) -> GeometryResult<()> {
242        let entity = self
243            .model
244            .get(representation)
245            .ok_or(GeometryError::MissingEntity {
246                referrer,
247                missing: representation,
248            })?;
249        for item in Representation::new(representation, entity).items()? {
250            self.item(representation, item, frame)?;
251        }
252        Ok(())
253    }
254
255    /// Describe one item, resolving a mapped item to what it maps.
256    fn item(&mut self, referrer: EntityId, item: EntityId, frame: Transform) -> GeometryResult<()> {
257        let entity = self.model.get(item).ok_or(GeometryError::MissingEntity {
258            referrer,
259            missing: item,
260        })?;
261        let type_name = entity.type_name.to_ascii_uppercase();
262        if type_name == "IFCMAPPEDITEM" {
263            return self.mapped(item, frame);
264        }
265        let kind = BodyKind::classify(&type_name).ok_or_else(|| {
266            Slots::new(item, entity).unsupported("representation item family is not described")
267        })?;
268        let swept = if kind.is_swept_area() {
269            // The innermost mapping is what scaled the frame, if anything did.
270            let culprit = self.mapped_by.last().copied().unwrap_or(item);
271            Some(sweep::describe(
272                self.model, self.units, item, entity, frame, culprit,
273            )?)
274        } else {
275            None
276        };
277        self.items.push(BodyItem {
278            item,
279            type_name,
280            mapped_by: self.mapped_by.clone(),
281            kind,
282            swept,
283            item_world: frame,
284        });
285        Ok(())
286    }
287
288    /// Resolve an `IfcMappedItem`: `frame o MappingTarget o MappingOrigin`.
289    ///
290    /// The same composition as `lower::mapped`, so a mapped body and the same
291    /// body authored in place describe identically.
292    fn mapped(&mut self, item: EntityId, frame: Transform) -> GeometryResult<()> {
293        self.walker.enter(item)?;
294        let result = self.mapped_inner(item, frame);
295        self.walker.exit();
296        result
297    }
298
299    fn mapped_inner(&mut self, item: EntityId, frame: Transform) -> GeometryResult<()> {
300        let instance = self.walker.resolve(self.model, item)?;
301        let target_entity =
302            self.model
303                .get(instance.mapping_target)
304                .ok_or(GeometryError::MissingEntity {
305                    referrer: item,
306                    missing: instance.mapping_target,
307                })?;
308        // Both frames carry file-unit coordinates; convert exactly once here.
309        let target = operator_transform(self.model, instance.mapping_target, target_entity)?
310            .to_metres(self.units);
311        let origin_entity =
312            self.model
313                .get(instance.mapping_origin)
314                .ok_or(GeometryError::MissingEntity {
315                    referrer: item,
316                    missing: instance.mapping_origin,
317                })?;
318        let origin = axis_placement_transform(self.model, instance.mapping_origin, origin_entity)?
319            .to_metres(self.units);
320        let inner = frame.compose(&target).compose(&origin);
321
322        self.mapped_by.push(item);
323        let result = self.representation(item, instance.mapped_representation, inner);
324        self.mapped_by.pop();
325        result
326    }
327}