ifc_lite_export/
relationships.rs

1// SPDX-License-Identifier: MPL-2.0
2//! Connections IFC keeps in relationship entities rather than on the products.
3//!
4//! [`EntityRow`](crate::EntityRow) describes one product on its own: its class,
5//! identity, name, and the property and quantity sets attached to it. What it
6//! cannot describe is how products relate to each other, because IFC models
7//! those as separate objectified relationships (`IfcRelAggregates`,
8//! `IfcRelContainedInSpatialStructure`, `IfcRelDefinesByType`). Those are not
9//! products, so they never appear in the row stream, and a consumer that wants
10//! the spatial tree or an occurrence's type has nowhere to get it.
11//!
12//! This resolves all three in one scan. A caller that wants only the spatial
13//! half pays for the type half as well, which is one extra match arm over the
14//! same pass and cheaper than a second traversal.
15
16use std::collections::HashMap;
17
18use ifc_lite_core::{keyword_eq, EntityDecoder, EntityScanner};
19
20/// Relationships resolved from one pass over the file.
21#[derive(Debug, Clone, Default)]
22pub struct Relationships {
23    /// Spatial parent to its children, from `IfcRelAggregates` and
24    /// `IfcRelContainedInSpatialStructure` together.
25    ///
26    /// Both are edges in the same tree: aggregation composes the spatial
27    /// structure (project to site to building to storey), containment attaches
28    /// elements to the storey that holds them. A consumer walking a hierarchy
29    /// wants them merged, which is why they are not reported separately.
30    pub spatial_children: HashMap<u32, Vec<u32>>,
31    /// The first `IfcProject`, which is the root of that tree. `None` for a file
32    /// that declares none, which is malformed but occurs.
33    pub project: Option<u32>,
34    /// An occurrence to the type object it is defined by, from
35    /// `IfcRelDefinesByType`.
36    ///
37    /// One type defines many occurrences, so the map is keyed by occurrence: it
38    /// answers "what is this?" for a product in hand, which is the direction a
39    /// consumer asks in.
40    pub type_of: HashMap<u32, u32>,
41}
42
43/// Resolve the relationships in `content`.
44///
45/// Malformed relationships are skipped rather than failing the scan: a file with
46/// one dangling reference still has a usable tree, and the alternative is
47/// returning nothing for a defect the caller cannot act on.
48pub fn relationships(content: &[u8]) -> Relationships {
49    // No entity index, deliberately. Every decode below is `decode_at_with_id`
50    // over the scanner's own spans; only `decode_by_id` consults an index, and
51    // nothing here calls it. Building one costs a full extra scan whose result
52    // is never read: ~57 ms of a 235 ms call on a 327 MB model. Do not add one
53    // back without a `decode_by_id` to justify it.
54    let mut decoder = EntityDecoder::new(content);
55    let mut out = Relationships::default();
56
57    let mut scanner = EntityScanner::new(content);
58    while let Some((id, type_name, start, end)) = scanner.next_entity() {
59        if keyword_eq(type_name, "IFCPROJECT") {
60            out.project = out.project.or(Some(id));
61        } else if keyword_eq(type_name, "IFCRELAGGREGATES") {
62            // IfcRelAggregates: RelatingObject(4), RelatedObjects(5, list)
63            if let Ok(rel) = decoder.decode_at_with_id(id, start, end) {
64                if let Some(parent) = rel.get(4).and_then(|a| a.as_entity_ref()) {
65                    if let Some(list) = rel.get(5).and_then(|a| a.as_list()) {
66                        for c in list {
67                            if let Some(cid) = c.as_entity_ref() {
68                                out.spatial_children.entry(parent).or_default().push(cid);
69                            }
70                        }
71                    }
72                }
73            }
74        } else if keyword_eq(type_name, "IFCRELCONTAINEDINSPATIALSTRUCTURE") {
75            // IfcRelContainedInSpatialStructure: RelatedElements(4, list), RelatingStructure(5)
76            if let Ok(rel) = decoder.decode_at_with_id(id, start, end) {
77                if let Some(parent) = rel.get(5).and_then(|a| a.as_entity_ref()) {
78                    if let Some(list) = rel.get(4).and_then(|a| a.as_list()) {
79                        for c in list {
80                            if let Some(cid) = c.as_entity_ref() {
81                                out.spatial_children.entry(parent).or_default().push(cid);
82                            }
83                        }
84                    }
85                }
86            }
87        } else if keyword_eq(type_name, "IFCRELDEFINESBYTYPE") {
88            // IfcRelDefinesByType: RelatedObjects(4, list), RelatingType(5)
89            if let Ok(rel) = decoder.decode_at_with_id(id, start, end) {
90                if let Some(ty) = rel.get(5).and_then(|a| a.as_entity_ref()) {
91                    if let Some(list) = rel.get(4).and_then(|a| a.as_list()) {
92                        for o in list {
93                            if let Some(oid) = o.as_entity_ref() {
94                                out.type_of.insert(oid, ty);
95                            }
96                        }
97                    }
98                }
99            }
100        }
101    }
102    out
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    /// A project, a storey aggregated under it, a wall contained in the storey,
110    /// and a wall type defining that wall.
111    const FILE: &[u8] = br#"ISO-10303-21;
112HEADER;
113FILE_DESCRIPTION((''),'2;1');
114FILE_NAME('rel.ifc','2026-01-01T00:00:00',(''),(''),'','','');
115FILE_SCHEMA(('IFC4'));
116ENDSEC;
117DATA;
118#1=IFCPROJECT('p',$,'P',$,$,$,$,$,$);
119#2=IFCBUILDINGSTOREY('s',$,'S',$,$,$,$,$,.ELEMENT.,0.);
120#3=IFCWALL('w',$,'W',$,$,$,$,$,.STANDARD.);
121#4=IFCWALLTYPE('t',$,'T',$,$,$,$,$,$,.STANDARD.);
122#10=IFCRELAGGREGATES('a',$,$,$,#1,(#2));
123#11=IFCRELCONTAINEDINSPATIALSTRUCTURE('c',$,$,$,(#3),#2);
124#12=IFCRELDEFINESBYTYPE('d',$,$,$,(#3),#4);
125ENDSEC;
126END-ISO-10303-21;
127"#;
128
129    #[test]
130    fn aggregation_and_containment_land_in_one_tree() {
131        let r = relationships(FILE);
132        assert_eq!(r.project, Some(1));
133        assert_eq!(r.spatial_children.get(&1).map(Vec::as_slice), Some(&[2u32][..]));
134        assert_eq!(r.spatial_children.get(&2).map(Vec::as_slice), Some(&[3u32][..]));
135    }
136
137    #[test]
138    fn an_occurrence_finds_its_type() {
139        let r = relationships(FILE);
140        assert_eq!(r.type_of.get(&3), Some(&4));
141        assert_eq!(
142            r.type_of.get(&4),
143            None,
144            "the map is keyed by occurrence, not by type"
145        );
146    }
147
148    /// A file with no relationships is empty, not a failure. Plenty of real
149    /// files carry only geometry.
150    #[test]
151    fn a_file_without_relationships_yields_nothing() {
152        let bare = br#"ISO-10303-21;
153HEADER;
154FILE_DESCRIPTION((''),'2;1');
155FILE_NAME('b.ifc','2026-01-01T00:00:00',(''),(''),'','','');
156FILE_SCHEMA(('IFC4'));
157ENDSEC;
158DATA;
159#1=IFCWALL('w',$,'W',$,$,$,$,$,.STANDARD.);
160ENDSEC;
161END-ISO-10303-21;
162"#;
163        let r = relationships(bare);
164        assert!(r.spatial_children.is_empty());
165        assert!(r.type_of.is_empty());
166        assert_eq!(r.project, None);
167    }
168
169    /// A dangling reference must not take the rest of the tree with it.
170    #[test]
171    fn a_dangling_relationship_does_not_lose_the_others() {
172        let mut broken = String::from_utf8(FILE.to_vec()).expect("ascii");
173        // Point the containment at a storey that does not exist.
174        broken = broken.replace(
175            "#11=IFCRELCONTAINEDINSPATIALSTRUCTURE('c',$,$,$,(#3),#2);",
176            "#11=IFCRELCONTAINEDINSPATIALSTRUCTURE('c',$,$,$,(#3),#999);",
177        );
178        let r = relationships(broken.as_bytes());
179        assert_eq!(
180            r.spatial_children.get(&1).map(Vec::as_slice),
181            Some(&[2u32][..]),
182            "the aggregation edge is independent of the broken containment"
183        );
184        assert_eq!(r.type_of.get(&3), Some(&4));
185    }
186
187    /// STEP keyword case is not significant and the scanner returns the
188    /// keyword as written; a `match` on uppercase literals used to see none
189    /// of these relationships in a lowercase-keyword file.
190    #[test]
191    fn lowercase_keywords_resolve_the_same_relationships() {
192        // The fixture's attributes are quoted strings, `$` and enums this
193        // function never reads, so lowercasing the whole buffer recases only
194        // what matters.
195        let lower = FILE.to_ascii_lowercase();
196        assert!(lower.windows(20).any(|w| w == b"#10=ifcrelaggregates"), "fixture was not recased");
197        let upper = relationships(FILE);
198        let recased = relationships(&lower);
199        assert_eq!(recased.project, upper.project);
200        assert_eq!(recased.spatial_children, upper.spatial_children);
201        assert_eq!(recased.type_of, upper.type_of);
202    }
203}