Skip to main content

ifc_geometry/constraint/
local.rs

1//! `IfcLocalPlacement`: the nesting placement chain.
2//!
3//! # Semantics
4//!
5//! An `IfcLocalPlacement` has two attributes: `PlacementRelTo` (the parent
6//! placement, optional) and `RelativePlacement` (an `IfcAxis2Placement3D` or
7//! 2D giving the offset from that parent).
8//!
9//! **If `PlacementRelTo` is absent, the placement is absolute** in the project
10//! coordinate system. That is the recursion's base case.
11//!
12//! # Two traps, both from the spec
13//!
14//! 1. **Cycles happen.** The IFC specification says outright that "rules to
15//!    prevent cyclic relative placements have to be introduced on the
16//!    application level" -- meaning the schema does not forbid them and real
17//!    exporters have produced them. Naive recursion overflows the stack on a
18//!    file that merely looks valid.
19//!
20//! 2. **Chains are walked per element.** A model with 100k elements walks
21//!    100k chains that share their upper links. Resolving without a cache is
22//!    quadratic in the depth; hence [`PlacementResolver`].
23
24use crate::error::{GeometryError, GeometryResult};
25use crate::resource::placement::axis_placement_transform;
26use crate::slots::Slots;
27use crate::transform::Transform;
28use ifc_model::{EntityId, Model};
29use std::collections::HashMap;
30
31/// `IfcLocalPlacement` attribute slots.
32///
33/// From IFC4 ADD2 TC1: `IfcLocalPlacement` has no inherited explicit
34/// attributes (its supertype `IfcObjectPlacement` declares only the inverse
35/// `PlacesObject`), so these indices are its own.
36pub(crate) mod slot {
37    /// `PlacementRelTo`: the parent placement, optional.
38    pub const PLACEMENT_REL_TO: usize = 0;
39    /// `RelativePlacement`: offset from the parent.
40    pub const RELATIVE_PLACEMENT: usize = 1;
41}
42
43/// A borrowed view of an `IfcLocalPlacement`.
44#[derive(Debug, Clone, Copy)]
45pub struct LocalPlacement<'m> {
46    slots: Slots<'m>,
47}
48
49impl<'m> LocalPlacement<'m> {
50    /// Wrap an entity assumed to be an `IfcLocalPlacement`.
51    pub fn new(id: EntityId, entity: &'m ifc_model::Entity) -> Self {
52        Self {
53            slots: Slots::new(id, entity),
54        }
55    }
56
57    /// The entity id.
58    pub fn id(&self) -> EntityId {
59        self.slots.id()
60    }
61
62    /// The parent placement, if any.
63    ///
64    /// `None` means this placement is absolute in project coordinates.
65    pub fn parent(&self) -> Option<EntityId> {
66        self.slots.opt_ref(slot::PLACEMENT_REL_TO)
67    }
68
69    /// The `IfcAxis2Placement` giving the offset from the parent.
70    pub fn relative_placement(&self) -> GeometryResult<EntityId> {
71        self.slots
72            .req_ref(slot::RELATIVE_PLACEMENT, "RelativePlacement")
73    }
74
75    /// This placement's own offset, not including its parents.
76    pub fn local_transform(&self, model: &'m Model) -> GeometryResult<Transform> {
77        let placement_id = self.relative_placement()?;
78        let entity = self.slots.resolve(model, placement_id)?;
79        axis_placement_transform(model, placement_id, entity)
80    }
81}
82
83/// How deep a placement chain may go before we call it malformed.
84///
85/// Real hierarchies are site > building > storey > element > opening, so
86/// single digits. 64 leaves enormous headroom while still terminating on a
87/// corrupt file quickly.
88const MAX_CHAIN_DEPTH: usize = 64;
89
90/// Resolves placement chains to world transforms, with memoization.
91///
92/// # Why a resolver rather than a free function
93///
94/// Placement chains share their upper links: every element in a storey walks
95/// the same storey-building-site tail. Caching per placement turns repeated
96/// work into a lookup, which matters because this runs once per element in the
97/// file.
98#[derive(Debug, Default)]
99pub struct PlacementResolver {
100    cache: HashMap<EntityId, Transform>,
101}
102
103impl PlacementResolver {
104    /// A resolver with an empty cache.
105    pub fn new() -> Self {
106        Self::default()
107    }
108
109    /// How many placements are memoized.
110    pub fn cached(&self) -> usize {
111        self.cache.len()
112    }
113
114    /// Resolve a placement to its world transform.
115    ///
116    /// Walks `PlacementRelTo` to the root, then composes downward. Detects
117    /// cycles rather than overflowing the stack, and reports the entity where
118    /// the cycle closes so the file can be repaired.
119    pub fn world_transform(
120        &mut self,
121        model: &Model,
122        placement: EntityId,
123    ) -> GeometryResult<Transform> {
124        if let Some(cached) = self.cache.get(&placement) {
125            return Ok(*cached);
126        }
127
128        // Walk up to the root, remembering the path. Iterative rather than
129        // recursive so a deep chain cannot blow the stack.
130        let mut chain = Vec::new();
131        let mut visited = Vec::new();
132        let mut current = Some(placement);
133
134        while let Some(id) = current {
135            if visited.contains(&id) {
136                return Err(GeometryError::CyclicChain {
137                    entity: id,
138                    kind: "placement",
139                });
140            }
141            if chain.len() >= MAX_CHAIN_DEPTH {
142                return Err(GeometryError::ChainTooDeep {
143                    entity: id,
144                    kind: "placement",
145                    limit: MAX_CHAIN_DEPTH,
146                });
147            }
148            visited.push(id);
149
150            // A cached ancestor ends the walk: everything above it is known.
151            if self.cache.contains_key(&id) {
152                break;
153            }
154
155            let entity = model.get(id).ok_or(GeometryError::MissingEntity {
156                referrer: placement,
157                missing: id,
158            })?;
159
160            match entity.type_name.as_ref() {
161                "IFCLOCALPLACEMENT" => {
162                    let view = LocalPlacement::new(id, entity);
163                    chain.push(id);
164                    current = view.parent();
165                }
166                // IfcGridPlacement resolves through grid axes rather than a
167                // parent chain; treated as a root here and handled by the
168                // grid module. Returning Unsupported keeps the failure
169                // honest rather than silently placing the element at origin.
170                "IFCGRIDPLACEMENT" => {
171                    return Err(GeometryError::Unsupported {
172                        entity: id,
173                        type_name: entity.type_name.to_string(),
174                        detail: "grid placement resolution",
175                    });
176                }
177                other => {
178                    return Err(GeometryError::WrongEntityType {
179                        entity: id,
180                        actual: other.to_string(),
181                        expected: "IfcLocalPlacement",
182                    });
183                }
184            }
185        }
186
187        // Seed with the cached ancestor's transform if the walk stopped there.
188        let mut world = current
189            .and_then(|id| self.cache.get(&id).copied())
190            .unwrap_or_else(Transform::identity);
191
192        // Compose from the root downward.
193        for id in chain.iter().rev() {
194            let entity = model.get(*id).ok_or(GeometryError::MissingEntity {
195                referrer: placement,
196                missing: *id,
197            })?;
198            let local = LocalPlacement::new(*id, entity).local_transform(model)?;
199            world = world.compose(&local);
200            self.cache.insert(*id, world);
201        }
202
203        Ok(world)
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use ifc_model::{Entity, Value};
211
212    /// Build `#id = IFCAXIS2PLACEMENT3D(#point, $, $)` at the given offset.
213    fn placement_at(model: &mut Model, id: u64, point_id: u64, xyz: [f64; 3]) {
214        model.insert(
215            EntityId(point_id),
216            Entity::new(
217                "IFCCARTESIANPOINT",
218                vec![Value::List(
219                    xyz.iter().map(|v| Value::Real(*v)).collect::<Vec<_>>(),
220                )],
221            ),
222        );
223        model.insert(
224            EntityId(id),
225            Entity::new(
226                "IFCAXIS2PLACEMENT3D",
227                vec![Value::Ref(EntityId(point_id)), Value::Null, Value::Null],
228            ),
229        );
230    }
231
232    /// `#id = IFCLOCALPLACEMENT(parent, axis_placement)`
233    fn local(model: &mut Model, id: u64, parent: Option<u64>, axis: u64) {
234        model.insert(
235            EntityId(id),
236            Entity::new(
237                "IFCLOCALPLACEMENT",
238                vec![
239                    parent.map_or(Value::Null, |p| Value::Ref(EntityId(p))),
240                    Value::Ref(EntityId(axis)),
241                ],
242            ),
243        );
244    }
245
246    /// site(0,0,0) > storey(0,0,3) > wall(1,0,0) puts the wall at (1,0,3).
247    fn three_level_model() -> Model {
248        let mut model = Model::new();
249        placement_at(&mut model, 10, 11, [0.0, 0.0, 0.0]);
250        placement_at(&mut model, 20, 21, [0.0, 0.0, 3.0]);
251        placement_at(&mut model, 30, 31, [1.0, 0.0, 0.0]);
252        local(&mut model, 1, None, 10);
253        local(&mut model, 2, Some(1), 20);
254        local(&mut model, 3, Some(2), 30);
255        model
256    }
257
258    #[test]
259    fn absent_parent_means_world_coordinates() {
260        let model = three_level_model();
261        let mut resolver = PlacementResolver::new();
262        let t = resolver.world_transform(&model, EntityId(1)).unwrap();
263        assert!(t.is_identity(1e-12));
264    }
265
266    #[test]
267    fn chain_composes_from_root_downward() {
268        let model = three_level_model();
269        let mut resolver = PlacementResolver::new();
270        let t = resolver.world_transform(&model, EntityId(3)).unwrap();
271        assert_eq!(t.origin, [1.0, 0.0, 3.0], "storey height must accumulate");
272    }
273
274    /// The spec pushes cycle prevention to the application, so files contain
275    /// them. Detect, do not overflow.
276    #[test]
277    fn cyclic_chains_are_detected_not_stack_overflowed() {
278        let mut model = Model::new();
279        placement_at(&mut model, 10, 11, [0.0, 0.0, 0.0]);
280        local(&mut model, 1, Some(2), 10);
281        local(&mut model, 2, Some(1), 10);
282
283        let mut resolver = PlacementResolver::new();
284        let err = resolver.world_transform(&model, EntityId(1)).unwrap_err();
285        assert!(
286            matches!(err, GeometryError::CyclicChain { .. }),
287            "expected a cycle error, got {err}"
288        );
289    }
290
291    /// A self-referencing placement is the degenerate cycle.
292    #[test]
293    fn self_reference_is_a_cycle() {
294        let mut model = Model::new();
295        placement_at(&mut model, 10, 11, [0.0, 0.0, 0.0]);
296        local(&mut model, 1, Some(1), 10);
297
298        let mut resolver = PlacementResolver::new();
299        assert!(matches!(
300            resolver.world_transform(&model, EntityId(1)).unwrap_err(),
301            GeometryError::CyclicChain { .. }
302        ));
303    }
304
305    #[test]
306    fn shared_ancestors_are_resolved_once() {
307        let model = three_level_model();
308        let mut resolver = PlacementResolver::new();
309        resolver.world_transform(&model, EntityId(3)).unwrap();
310        let after_first = resolver.cached();
311
312        // A sibling under the same storey must reuse the cached tail.
313        resolver.world_transform(&model, EntityId(2)).unwrap();
314        assert_eq!(
315            resolver.cached(),
316            after_first,
317            "resolving an already-cached ancestor must not recompute"
318        );
319    }
320
321    #[test]
322    fn dangling_parent_reference_is_reported() {
323        let mut model = Model::new();
324        placement_at(&mut model, 10, 11, [0.0, 0.0, 0.0]);
325        local(&mut model, 1, Some(999), 10);
326
327        let mut resolver = PlacementResolver::new();
328        assert!(matches!(
329            resolver.world_transform(&model, EntityId(1)).unwrap_err(),
330            GeometryError::MissingEntity { .. }
331        ));
332    }
333
334    /// Grid placement is valid IFC we do not resolve yet; it must say so
335    /// rather than silently placing the element at the origin.
336    #[test]
337    fn grid_placement_reports_unsupported_rather_than_defaulting_to_origin() {
338        let mut model = Model::new();
339        model.insert(
340            EntityId(1),
341            Entity::new("IFCGRIDPLACEMENT", vec![Value::Null, Value::Null]),
342        );
343        let mut resolver = PlacementResolver::new();
344        let err = resolver.world_transform(&model, EntityId(1)).unwrap_err();
345        assert!(err.is_unsupported(), "got {err}");
346    }
347}