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}