Skip to main content

ifc_geometry/resource/
mapped.rs

1//! `IfcMappedItem` and `IfcRepresentationMap`: geometry reuse by reference.
2//!
3//! # What it means
4//!
5//! A mapped item is IFC's block/instance mechanism. One geometry definition
6//! (the [`RepresentationMap`]) is authored once, and every place it appears is
7//! an [`MappedItem`] that references it plus a transform. A file with 400
8//! identical windows stores one window and 400 small records.
9//!
10//! ```text
11//!   IfcMappedItem ---- MappingSource ---> IfcRepresentationMap
12//!         |                                      |
13//!         |                                MappingOrigin (IfcAxis2Placement)
14//!         +---- MappingTarget --->               |
15//!               IfcCartesianTransformationOperator
16//!                                          MappedRepresentation
17//!                                                |
18//!                                                v
19//!                                      the actual geometry
20//! ```
21//!
22//! # The trap: mapped items nest
23//!
24//! The spec is explicit that "an IfcMappedItem can reuse other mapped items
25//! (ako nested blocks)": the `MappedRepresentation` may itself contain mapped
26//! items. A naive resolver that assumes one level silently drops geometry, and
27//! one that recurses without a guard hangs on the cyclic files that real
28//! exporters have produced. The fixture corpus contains
29//! `nested_mapped_item_cycle.ifc` for exactly this reason.
30//!
31//! # The full transform
32//!
33//! Placing a mapped item is **two** transforms composed, not one:
34//!
35//! 1. `MappingOrigin` on the representation map -- where the source geometry
36//!    sits in its own definition space;
37//! 2. `MappingTarget` on the mapped item -- where that space lands in the
38//!    consuming representation.
39//!
40//! Applying only the target is a common bug, and it is invisible whenever
41//! `MappingOrigin` happens to be the identity, which it usually is.
42
43use crate::error::{GeometryError, GeometryResult};
44use crate::slots::Slots;
45use ifc_model::{Entity, EntityId, Model};
46
47/// `IfcMappedItem` attribute slots.
48///
49/// `IfcMappedItem` inherits from `IfcRepresentationItem`, which declares no
50/// explicit attributes, so these indices are its own.
51pub(crate) mod item_slot {
52    /// `MappingSource`: the `IfcRepresentationMap` being instanced.
53    pub const MAPPING_SOURCE: usize = 0;
54    /// `MappingTarget`: an `IfcCartesianTransformationOperator`.
55    pub const MAPPING_TARGET: usize = 1;
56}
57
58/// `IfcRepresentationMap` attribute slots.
59pub(crate) mod map_slot {
60    /// `MappingOrigin`: an `IfcAxis2Placement` for the source geometry.
61    pub const MAPPING_ORIGIN: usize = 0;
62    /// `MappedRepresentation`: the `IfcRepresentation` being reused.
63    pub const MAPPED_REPRESENTATION: usize = 1;
64}
65
66/// How deep mapped items may nest before the file is called malformed.
67///
68/// Legitimate nesting is shallow (a window in a wall assembly in a facade
69/// module). 32 leaves ample room while terminating quickly on a cycle.
70const MAX_NESTING_DEPTH: usize = 32;
71
72/// A borrowed view of an `IfcMappedItem`.
73#[derive(Debug, Clone, Copy)]
74pub struct MappedItem<'m> {
75    slots: Slots<'m>,
76}
77
78impl<'m> MappedItem<'m> {
79    /// Wrap an entity assumed to be an `IfcMappedItem`.
80    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
81        Self {
82            slots: Slots::new(id, entity),
83        }
84    }
85
86    /// The entity id.
87    pub fn id(&self) -> EntityId {
88        self.slots.id()
89    }
90
91    /// The `IfcRepresentationMap` this item instances.
92    pub fn mapping_source(&self) -> GeometryResult<EntityId> {
93        self.slots
94            .req_ref(item_slot::MAPPING_SOURCE, "MappingSource")
95    }
96
97    /// The `IfcCartesianTransformationOperator` placing the instance.
98    pub fn mapping_target(&self) -> GeometryResult<EntityId> {
99        self.slots
100            .req_ref(item_slot::MAPPING_TARGET, "MappingTarget")
101    }
102}
103
104/// A borrowed view of an `IfcRepresentationMap`.
105#[derive(Debug, Clone, Copy)]
106pub struct RepresentationMap<'m> {
107    slots: Slots<'m>,
108}
109
110impl<'m> RepresentationMap<'m> {
111    /// Wrap an entity assumed to be an `IfcRepresentationMap`.
112    pub fn new(id: EntityId, entity: &'m Entity) -> Self {
113        Self {
114            slots: Slots::new(id, entity),
115        }
116    }
117
118    /// The entity id.
119    pub fn id(&self) -> EntityId {
120        self.slots.id()
121    }
122
123    /// The `IfcAxis2Placement` locating the source geometry.
124    pub fn mapping_origin(&self) -> GeometryResult<EntityId> {
125        self.slots
126            .req_ref(map_slot::MAPPING_ORIGIN, "MappingOrigin")
127    }
128
129    /// The `IfcRepresentation` being reused.
130    pub fn mapped_representation(&self) -> GeometryResult<EntityId> {
131        self.slots
132            .req_ref(map_slot::MAPPED_REPRESENTATION, "MappedRepresentation")
133    }
134}
135
136/// Walks mapped-item nesting, detecting cycles and excessive depth.
137///
138/// Separate from the views because the views are stateless borrows while a
139/// safe walk needs to remember where it has been.
140#[derive(Debug, Default)]
141pub struct MappingWalker {
142    visited: Vec<EntityId>,
143}
144
145impl MappingWalker {
146    /// A walker with no history.
147    pub fn new() -> Self {
148        Self::default()
149    }
150
151    /// Current nesting depth.
152    pub fn depth(&self) -> usize {
153        self.visited.len()
154    }
155
156    /// Enter a mapped item, or fail if that would revisit or go too deep.
157    ///
158    /// Call [`Self::exit`] when done with the item, so sibling instances of
159    /// the same map do not look like a cycle. A map legitimately appears many
160    /// times; what is illegal is a map containing *itself*.
161    pub fn enter(&mut self, item: EntityId) -> GeometryResult<()> {
162        if self.visited.contains(&item) {
163            return Err(GeometryError::CyclicChain {
164                entity: item,
165                kind: "mapped item",
166            });
167        }
168        if self.visited.len() >= MAX_NESTING_DEPTH {
169            return Err(GeometryError::ChainTooDeep {
170                entity: item,
171                kind: "mapped item",
172                limit: MAX_NESTING_DEPTH,
173            });
174        }
175        self.visited.push(item);
176        Ok(())
177    }
178
179    /// Leave the most recently entered item.
180    pub fn exit(&mut self) {
181        self.visited.pop();
182    }
183
184    /// Resolve the source map and target operator of a mapped item.
185    ///
186    /// Returns both ids so a caller can compose `MappingOrigin` with
187    /// `MappingTarget`. Composing only the target is the bug this signature
188    /// is shaped to prevent.
189    pub fn resolve(&mut self, model: &Model, item_id: EntityId) -> GeometryResult<MappedInstance> {
190        let entity = model.get(item_id).ok_or(GeometryError::MissingEntity {
191            referrer: item_id,
192            missing: item_id,
193        })?;
194        let item = MappedItem::new(item_id, entity);
195
196        let source_id = item.mapping_source()?;
197        let target_id = item.mapping_target()?;
198
199        let source = model.get(source_id).ok_or(GeometryError::MissingEntity {
200            referrer: item_id,
201            missing: source_id,
202        })?;
203        let map = RepresentationMap::new(source_id, source);
204
205        Ok(MappedInstance {
206            item: item_id,
207            mapping_origin: map.mapping_origin()?,
208            mapped_representation: map.mapped_representation()?,
209            mapping_target: target_id,
210        })
211    }
212}
213
214/// One resolved mapped item: everything needed to place the reused geometry.
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216#[non_exhaustive]
217pub struct MappedInstance {
218    /// The `IfcMappedItem` this came from.
219    pub item: EntityId,
220    /// `IfcAxis2Placement` locating the source geometry in its own space.
221    pub mapping_origin: EntityId,
222    /// The `IfcRepresentation` holding the reused geometry.
223    pub mapped_representation: EntityId,
224    /// `IfcCartesianTransformationOperator` placing the instance.
225    pub mapping_target: EntityId,
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use ifc_model::Value;
232
233    fn model_with_mapping() -> Model {
234        let mut model = Model::new();
235        // #14 = IFCREPRESENTATIONMAP(#2, #13)
236        model.insert(
237            EntityId(14),
238            Entity::new(
239                "IFCREPRESENTATIONMAP",
240                vec![Value::Ref(EntityId(2)), Value::Ref(EntityId(13))],
241            ),
242        );
243        // #19 = IFCMAPPEDITEM(#14, #18)
244        model.insert(
245            EntityId(19),
246            Entity::new(
247                "IFCMAPPEDITEM",
248                vec![Value::Ref(EntityId(14)), Value::Ref(EntityId(18))],
249            ),
250        );
251        model
252    }
253
254    #[test]
255    fn reads_both_halves_of_the_mapping() {
256        let model = model_with_mapping();
257        let mut walker = MappingWalker::new();
258        let resolved = walker.resolve(&model, EntityId(19)).unwrap();
259
260        assert_eq!(resolved.mapping_origin, EntityId(2));
261        assert_eq!(resolved.mapped_representation, EntityId(13));
262        assert_eq!(resolved.mapping_target, EntityId(18));
263    }
264
265    /// A map used many times is normal; that must not look like a cycle.
266    #[test]
267    fn the_same_map_may_be_instanced_repeatedly() {
268        let mut walker = MappingWalker::new();
269        for _ in 0..100 {
270            walker
271                .enter(EntityId(19))
272                .expect("sibling instances are legal");
273            walker.exit();
274        }
275        assert_eq!(walker.depth(), 0);
276    }
277
278    /// A map containing itself is not legal, and must not hang.
279    #[test]
280    fn revisiting_an_item_while_inside_it_is_a_cycle() {
281        let mut walker = MappingWalker::new();
282        walker.enter(EntityId(19)).unwrap();
283        let err = walker.enter(EntityId(19)).unwrap_err();
284        assert!(
285            matches!(err, GeometryError::CyclicChain { .. }),
286            "got {err}"
287        );
288    }
289
290    #[test]
291    fn nesting_is_bounded() {
292        let mut walker = MappingWalker::new();
293        for i in 0..MAX_NESTING_DEPTH {
294            walker.enter(EntityId(i as u64)).unwrap();
295        }
296        let err = walker.enter(EntityId(9999)).unwrap_err();
297        assert!(
298            matches!(err, GeometryError::ChainTooDeep { .. }),
299            "got {err}"
300        );
301    }
302
303    #[test]
304    fn dangling_mapping_source_is_reported() {
305        let mut model = Model::new();
306        model.insert(
307            EntityId(19),
308            Entity::new(
309                "IFCMAPPEDITEM",
310                vec![Value::Ref(EntityId(999)), Value::Ref(EntityId(18))],
311            ),
312        );
313        let mut walker = MappingWalker::new();
314        assert!(matches!(
315            walker.resolve(&model, EntityId(19)).unwrap_err(),
316            GeometryError::MissingEntity { .. }
317        ));
318    }
319}