Skip to main content

ifc_alignment/
view.rs

1//! Pins the authoritative IFC4X3 profile and exposes bounded traversal.
2//!
3//! [`AlignmentView`] is the read-side entry point: it refuses any release
4//! but IFC4X3, and [`AlignmentView::hierarchy`] answers what an
5//! `IfcAlignment` is made of (its layouts, child alignments and referents)
6//! without the caller re-walking `IfcRelNests`/`IfcRelAggregates`.
7//!
8//! ## Internal split
9//!
10//! - `hierarchy.rs`: the public alignment hierarchy.
11//!
12//! `IfcAlignment*` entities were introduced in IFC4X3; IFC2X3 and IFC4 ADD2
13//! TC1 do not declare them at all. Unlike `ifc-resource`/`ifc-structural`,
14//! there is therefore no cross-version dispatch table here -- exactly one
15//! schema profile is authoritative, and any other declared schema is a typed
16//! refusal rather than an approximation.
17
18use std::collections::HashSet;
19
20use ifc_model::{EntityId, Model};
21use ifc_schema::{ifc4x3, Schema, SchemaVersion};
22
23use crate::error::{AlignmentError, AlignmentResult};
24use crate::slot;
25
26mod hierarchy;
27
28pub use hierarchy::AlignmentHierarchy;
29
30/// Where a relationship keeps its two ends, and their schema names.
31#[derive(Debug, Clone, Copy)]
32pub(crate) struct RelationSlots {
33    relating: usize,
34    relating_name: &'static str,
35    related: usize,
36    related_name: &'static str,
37}
38
39impl RelationSlots {
40    /// `IfcRelNests` and `IfcRelAggregates` (`IfcRelDecomposes`).
41    pub(crate) const DECOMPOSES: Self = Self {
42        relating: slot::decomposes::RELATING_OBJECT,
43        relating_name: "RelatingObject",
44        related: slot::decomposes::RELATED_OBJECTS,
45        related_name: "RelatedObjects",
46    };
47    /// `IfcRelPositions`.
48    pub(crate) const POSITIONS: Self = Self {
49        relating: slot::rel_positions::RELATING_POSITIONING_ELEMENT,
50        relating_name: "RelatingPositioningElement",
51        related: slot::rel_positions::RELATED_PRODUCTS,
52        related_name: "RelatedProducts",
53    };
54}
55
56/// A model whose declared schema has been pinned to IFC4X3 ADD2.
57///
58/// Holding the schema alongside the model lets traversal use `is_a` for
59/// subtype-aware queries instead of exact type-name matching.
60#[derive(Debug, Clone, Copy)]
61pub struct AlignmentView<'m> {
62    pub(crate) model: &'m Model,
63    pub(crate) schema: &'static Schema,
64}
65
66impl<'m> AlignmentView<'m> {
67    /// Pin the model's declared schema and confirm it is IFC4X3.
68    pub fn for_model(model: &'m Model) -> AlignmentResult<Self> {
69        let token = match model.header().schema.as_slice() {
70            [] => return Err(AlignmentError::MissingSchema),
71            [token] => token,
72            tokens => {
73                return Err(AlignmentError::AmbiguousSchema {
74                    tokens: tokens.to_vec(),
75                });
76            }
77        };
78        let version = SchemaVersion::from_header_token(token).ok_or_else(|| {
79            AlignmentError::UnsupportedSchema {
80                token: token.clone(),
81            }
82        })?;
83        if version != SchemaVersion::Ifc4x3 {
84            return Err(AlignmentError::UnsupportedSchema {
85                token: token.clone(),
86            });
87        }
88        Ok(Self {
89            model,
90            schema: ifc4x3(),
91        })
92    }
93
94    /// Entity ids whose declared type is `ancestor` or a subtype of it.
95    pub(crate) fn ids_of_ancestor(&self, ancestor: &str) -> Vec<EntityId> {
96        self.model
97            .iter()
98            .filter_map(|(id, entity)| self.schema.is_a(&entity.type_name, ancestor).then_some(id))
99            .collect()
100    }
101
102    /// Directly nested children of `parent` (`IfcRelNests.RelatedObjects` in
103    /// `LIST` order), filtered to entities whose type satisfies `expected`.
104    ///
105    /// Nesting order is load-bearing for alignment: segment order along a
106    /// horizontal/vertical/cant layout is defined by nesting order, not by
107    /// any numeric field on the segment itself.
108    pub(crate) fn nested_children(
109        &self,
110        parent: EntityId,
111        expected: &str,
112    ) -> AlignmentResult<Vec<EntityId>> {
113        let children = self.related_by("IfcRelNests", RelationSlots::DECOMPOSES, parent)?;
114        Ok(self.filter_is_a(children, expected))
115    }
116
117    /// Every object a relationship of type `relation` relates to `relating`,
118    /// in relationship order and then in each `RelatedObjects` order.
119    ///
120    /// Each related reference must resolve: a dangling one is refused, not
121    /// skipped, because skipping it would silently shorten a layout.
122    pub(crate) fn related_by(
123        &self,
124        relation: &str,
125        slots: RelationSlots,
126        relating: EntityId,
127    ) -> AlignmentResult<Vec<EntityId>> {
128        let mut result = Vec::new();
129        for id in self.ids_of_ancestor(relation) {
130            if self.relating_end(id, slots)? == relating {
131                result.extend(self.related_end(id, slots)?);
132            }
133        }
134        Ok(result)
135    }
136
137    /// Every object that relates `related` through a relationship of type
138    /// `relation`: the inverse of [`Self::related_by`].
139    ///
140    /// Only relationships that list `related` are read in full, so a
141    /// malformed relationship elsewhere in the file does not refuse this
142    /// one's question.
143    pub(crate) fn relating_of(
144        &self,
145        relation: &str,
146        slots: RelationSlots,
147        related: EntityId,
148    ) -> AlignmentResult<Vec<EntityId>> {
149        let mut result = Vec::new();
150        for id in self.ids_of_ancestor(relation) {
151            let lists_it = self
152                .model
153                .get(id)
154                .and_then(|entity| entity.attributes.get(slots.related))
155                .and_then(|value| value.as_list())
156                .is_some_and(|values| values.iter().any(|v| v.as_ref_id() == Some(related)));
157            if lists_it {
158                result.push(self.relating_end(id, slots)?);
159            }
160        }
161        Ok(result)
162    }
163
164    /// `ids` restricted to entities whose declared type is `expected` or a
165    /// subtype of it.
166    pub(crate) fn filter_is_a(&self, ids: Vec<EntityId>, expected: &str) -> Vec<EntityId> {
167        ids.into_iter()
168            .filter(|id| {
169                self.model
170                    .get(*id)
171                    .is_some_and(|entity| self.schema.is_a(&entity.type_name, expected))
172            })
173            .collect()
174    }
175
176    /// The relating object of one relationship instance.
177    fn relating_end(&self, relation: EntityId, slots: RelationSlots) -> AlignmentResult<EntityId> {
178        self.model
179            .get(relation)
180            .ok_or(AlignmentError::MissingEntity { entity: relation })?
181            .attributes
182            .get(slots.relating)
183            .and_then(|value| value.as_ref_id())
184            .ok_or(AlignmentError::InvalidAttribute {
185                entity: relation,
186                index: slots.relating,
187                name: slots.relating_name,
188            })
189    }
190
191    /// The related objects of one relationship instance, each resolved.
192    fn related_end(
193        &self,
194        relation: EntityId,
195        slots: RelationSlots,
196    ) -> AlignmentResult<Vec<EntityId>> {
197        let invalid = AlignmentError::InvalidAttribute {
198            entity: relation,
199            index: slots.related,
200            name: slots.related_name,
201        };
202        let values = self
203            .model
204            .get(relation)
205            .ok_or(AlignmentError::MissingEntity { entity: relation })?
206            .attributes
207            .get(slots.related)
208            .and_then(|value| value.as_list())
209            .ok_or_else(|| invalid.clone())?;
210        let mut related = Vec::with_capacity(values.len());
211        for value in values {
212            let child = value.as_ref_id().ok_or_else(|| invalid.clone())?;
213            if self.model.get(child).is_none() {
214                return Err(AlignmentError::DanglingReference {
215                    entity: relation,
216                    attribute: slots.related_name,
217                    target: child,
218                });
219            }
220            related.push(child);
221        }
222        Ok(related)
223    }
224
225    /// The `IfcAlignmentSegment` instances nested under `parent`, together
226    /// with the `IfcAlignmentParameterSegment` each one's `DesignParameters`
227    /// resolves to, in authored order.
228    ///
229    /// `IfcAlignmentSegment` is the geometry-bearing node in the nesting
230    /// tree; `DesignParameters` is a direct forward reference to the typed
231    /// parameter entity (`IfcAlignmentHorizontalSegment`, etc), not itself
232    /// nested. Both indirections are resolved here so callers get the
233    /// concrete parameter entity id directly.
234    pub(crate) fn segment_chain(
235        &self,
236        parent: EntityId,
237        design_parameters_type: &str,
238    ) -> AlignmentResult<Vec<EntityId>> {
239        let segments = self.nested_children(parent, "IfcAlignmentSegment")?;
240        let mut seen = HashSet::with_capacity(segments.len());
241        let mut result = Vec::with_capacity(segments.len());
242        for segment in segments {
243            if !seen.insert(segment) {
244                return Err(AlignmentError::SemanticViolation {
245                    entity: Some(segment),
246                    rule: "IfcRelNests must not list the same segment twice",
247                });
248            }
249            let entity = self
250                .model
251                .get(segment)
252                .ok_or(AlignmentError::MissingEntity { entity: segment })?;
253            // `IfcAlignmentSegment` subtype of `IfcLinearElement` subtype of
254            // `IfcProduct`; `DesignParameters` is its sole own attribute.
255            let design_parameters = entity
256                .attributes
257                .get(slot::segment::DESIGN_PARAMETERS)
258                .and_then(|value| value.as_ref_id())
259                .ok_or(AlignmentError::InvalidAttribute {
260                    entity: segment,
261                    index: slot::segment::DESIGN_PARAMETERS,
262                    name: "DesignParameters",
263                })?;
264            let parameters_entity =
265                self.model
266                    .get(design_parameters)
267                    .ok_or(AlignmentError::DanglingReference {
268                        entity: segment,
269                        attribute: "DesignParameters",
270                        target: design_parameters,
271                    })?;
272            if !self
273                .schema
274                .is_a(&parameters_entity.type_name, design_parameters_type)
275            {
276                return Err(AlignmentError::WrongType {
277                    entity: design_parameters,
278                    expected: "matches the requested design-parameters family",
279                    actual: parameters_entity.type_name.to_string(),
280                });
281            }
282            result.push(design_parameters);
283        }
284        Ok(result)
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use ifc_model::Header;
292
293    fn model_with_schema(tokens: &[&str]) -> Model {
294        let mut model = Model::default();
295        *model.header_mut() = Header {
296            schema: tokens.iter().map(|s| s.to_string()).collect(),
297            ..Header::default()
298        };
299        model
300    }
301
302    #[test]
303    fn refuses_a_missing_schema_declaration() {
304        let model = model_with_schema(&[]);
305        assert!(matches!(
306            AlignmentView::for_model(&model),
307            Err(AlignmentError::MissingSchema)
308        ));
309    }
310
311    #[test]
312    fn refuses_an_ambiguous_schema_declaration() {
313        let model = model_with_schema(&["IFC4X3_ADD2", "IFC4"]);
314        assert!(matches!(
315            AlignmentView::for_model(&model),
316            Err(AlignmentError::AmbiguousSchema { .. })
317        ));
318    }
319
320    #[test]
321    fn refuses_ifc2x3_because_alignment_entities_do_not_exist_there() {
322        let model = model_with_schema(&["IFC2X3"]);
323        assert!(matches!(
324            AlignmentView::for_model(&model),
325            Err(AlignmentError::UnsupportedSchema { token }) if token == "IFC2X3"
326        ));
327    }
328
329    #[test]
330    fn refuses_ifc4_because_alignment_entities_do_not_exist_there() {
331        let model = model_with_schema(&["IFC4"]);
332        assert!(matches!(
333            AlignmentView::for_model(&model),
334            Err(AlignmentError::UnsupportedSchema { token }) if token == "IFC4"
335        ));
336    }
337
338    #[test]
339    fn accepts_ifc4x3_add2() {
340        let model = model_with_schema(&["IFC4X3_ADD2"]);
341        assert!(AlignmentView::for_model(&model).is_ok());
342    }
343
344    #[test]
345    fn accepts_the_bare_ifc4x3_token() {
346        let model = model_with_schema(&["IFC4X3"]);
347        assert!(AlignmentView::for_model(&model).is_ok());
348    }
349
350    #[test]
351    fn refuses_an_unrecognized_token() {
352        let model = model_with_schema(&["IFC5"]);
353        assert!(matches!(
354            AlignmentView::for_model(&model),
355            Err(AlignmentError::UnsupportedSchema { token }) if token == "IFC5"
356        ));
357    }
358}
359
360#[cfg(test)]
361mod intermediate_release_tests {
362    use super::*;
363
364    /// IFC4X1 and IFC4X2 have bundled tables but no verified layout here:
365    /// refused with the unsupported-schema error, never read as IFC4/IFC4X3.
366    #[test]
367    fn ifc4x1_and_ifc4x2_are_refused_not_aliased() {
368        for token in ["IFC4X1", "IFC4X2"] {
369            let mut model = Model::new();
370            model.header_mut().schema = vec![token.to_owned()];
371            assert!(
372                matches!(
373                    AlignmentView::for_model(&model),
374                    Err(AlignmentError::UnsupportedSchema { token: found }) if found == token
375                ),
376                "{token} must be refused"
377            );
378        }
379    }
380}