ifc_systems/system/group.rs
1//! `IfcSystem` and group semantics.
2//!
3//! # The slot trap
4//!
5//! Membership and service use different attribute layouts, and neither is
6//! guessable from the other:
7//!
8//! ```text
9//! IfcRelAssignsToGroup 4 = RelatedObjects 6 = RelatingGroup
10//! IfcRelServicesBuildings 4 = RelatingSystem 5 = RelatedBuildings
11//! ```
12//!
13//! `IfcRelAssignsToGroup` also carries `RelatedObjectsType` at slot 5, so the
14//! group is at 6 and NOT at 5 where every other `IfcRel*` in this crate puts
15//! its relating end. Reading slot 5 yields an enumeration, not a reference,
16//! and a membership silently vanishes.
17//!
18//! `IfcRelServicesBuildings` and the IFC4X3 `ServicesFacilities` view are
19//! read in `services.rs` and joined onto each [`System`].
20//!
21//! # Distribution-system attributes are read by name
22//!
23//! ```text
24//! IfcDistributionSystem IFC4, IFC4X3 ... ObjectType LongName PredefinedType
25//! ```
26//!
27//! `LongName` and `PredefinedType` (`IfcDistributionSystemEnum`) are
28//! resolved by attribute name in the declared release's table, so an
29//! `IfcDistributionCircuit` (which adds nothing) reads them from its
30//! inherited positions. IFC2X3 declares no `IfcDistributionSystem`, so there
31//! the fields are `None` for every system, as they are for any system that is
32//! not an `IfcDistributionSystem`.
33
34use ifc_model::{EntityId, Model, Value};
35
36use crate::error::{SchemaResolutionError, SystemAnomaly};
37use crate::release::{self, Release};
38use crate::system::services;
39
40/// Attribute slots, named so a misread is a compile error rather than a
41/// silently empty result.
42pub(crate) mod slot {
43 /// `IfcRelAssignsToGroup.RelatedObjects`.
44 pub const ASSIGNS_RELATED: usize = 4;
45 /// `IfcRelAssignsToGroup.RelatingGroup` -- 6, not 5.
46 pub const ASSIGNS_GROUP: usize = 6;
47 /// `IfcRoot.Name`.
48 pub const NAME: usize = 2;
49}
50
51/// A system as the file states it, with its members resolved.
52#[derive(Debug, Clone, PartialEq, Eq)]
53#[non_exhaustive]
54pub struct System {
55 /// The `IfcSystem` (or subtype) entity.
56 pub id: EntityId,
57 /// Declared type, e.g. `IFCDISTRIBUTIONSYSTEM`.
58 pub type_name: String,
59 /// `Name`, when present.
60 pub name: Option<String>,
61 /// `IfcDistributionSystem.LongName`, when present (IFC4, IFC4X3).
62 ///
63 /// `None` when the file leaves it empty, and for every system that is
64 /// not an `IfcDistributionSystem` or subtype (a plain `IfcSystem`, a zone,
65 /// any IFC2X3 system), because only that type is read here.
66 pub long_name: Option<String>,
67 /// `IfcDistributionSystem.PredefinedType`: the `IfcDistributionSystemEnum`
68 /// token as the file states it, without dots (e.g. `HEATING`).
69 ///
70 /// `None` under the same conditions as [`System::long_name`].
71 pub predefined_type: Option<String>,
72 /// Members, in file order.
73 ///
74 /// Order is preserved because IFC states no ordering and re-sorting would
75 /// invent one; a caller comparing two exports needs the file's own order.
76 pub members: Vec<EntityId>,
77 /// `ServicesBuildings`: the spatial structures this system serves through
78 /// its `IfcRelServicesBuildings`, in file order (#230).
79 ///
80 /// The inverse is `SET [0:1]`, so only the lowest-id relationship is read;
81 /// a second is reported as [`SystemAnomaly::ServicesBuildingsTwice`].
82 /// Targets missing from the file ([`SystemAnomaly::Dangling`]) or of a
83 /// type the release does not admit ([`SystemAnomaly::ServicedNotSpatial`])
84 /// are reported and left out.
85 pub serviced_buildings: Vec<EntityId>,
86 /// `ServicesFacilities` (IFC4X3 only): the spatial elements whose
87 /// `IfcRelReferencedInSpatialStructure` lists this system, ascending by
88 /// id (#230).
89 ///
90 /// Always empty under IFC2X3 and IFC4, whose `RelatedElements` is
91 /// `IfcProduct` and cannot hold a system.
92 pub serviced_facilities: Vec<EntityId>,
93}
94
95fn text(model: &Model, id: EntityId, slot: usize) -> Option<String> {
96 match model.get(id)?.attributes.get(slot)? {
97 Value::Text(t) => Some(t.to_string()),
98 _ => None,
99 }
100}
101
102const DISTRIBUTION_SYSTEM: &str = "IFCDISTRIBUTIONSYSTEM";
103
104/// `attribute` of `IfcDistributionSystem` on `id`, read by name in `release`.
105///
106/// `None` when `type_name` is not an `IfcDistributionSystem` (or subtype)
107/// under the release, or when the release does not declare the attribute. A
108/// subtype keeps its inherited attributes first, so the supertype's position
109/// holds for it.
110fn distribution_attribute<'m>(
111 model: &'m Model,
112 release: Release,
113 id: EntityId,
114 type_name: &str,
115 attribute: &str,
116) -> Option<&'m Value> {
117 if !release.is_a(type_name, DISTRIBUTION_SYSTEM) {
118 return None;
119 }
120 let slot = release.slot(DISTRIBUTION_SYSTEM, attribute)?;
121 model.get(id)?.attributes.get(slot)
122}
123
124/// `LongName` (an `IfcLabel`); anything but text is not a label.
125fn long_name(model: &Model, release: Release, id: EntityId, type_name: &str) -> Option<String> {
126 match distribution_attribute(model, release, id, type_name, "LongName")? {
127 Value::Text(t) => Some(t.to_string()),
128 _ => None,
129 }
130}
131
132/// `PredefinedType` (an `IfcDistributionSystemEnum`); anything but an
133/// enumeration token is not one.
134fn predefined_type(
135 model: &Model,
136 release: Release,
137 id: EntityId,
138 type_name: &str,
139) -> Option<String> {
140 match distribution_attribute(model, release, id, type_name, "PredefinedType")? {
141 Value::Enum(token) => Some(token.to_string()),
142 _ => None,
143 }
144}
145
146fn refs(value: Option<&Value>) -> Vec<EntityId> {
147 match value {
148 Some(Value::List(items)) => items
149 .iter()
150 .filter_map(|v| match v {
151 Value::Ref(id) => Some(*id),
152 _ => None,
153 })
154 .collect(),
155 Some(Value::Ref(id)) => vec![*id],
156 _ => Vec::new(),
157 }
158}
159
160/// Every system in the file, with members resolved and anomalies reported.
161///
162/// Systems are found by type, not by walking memberships: a file may declare
163/// a system that nothing is assigned to yet, and dropping it would understate
164/// the model. Subtypes are included, so `IfcDistributionSystem` and
165/// `IfcBuildingSystem` are both found in IFC4, and `IfcElectricalCircuit` is
166/// found in IFC2X3 -- but `IfcZone` is NOT, because it subtypes `IfcGroup`
167/// rather than `IfcSystem` in IFC2X3 (issue #52).
168///
169/// Reads against the release the model's `FILE_SCHEMA` header declares:
170/// IFC2X3, IFC4 or IFC4X3.
171///
172/// # Errors
173///
174/// [`SchemaResolutionError`] when the model's `FILE_SCHEMA` binds no release
175/// this crate is verified for (see [`crate::schema_of`]). Nothing is read
176/// against a release the file did not declare.
177pub fn systems(model: &Model) -> Result<(Vec<System>, Vec<SystemAnomaly>), SchemaResolutionError> {
178 let release = release::resolve(model)?;
179 let mut anomalies = Vec::new();
180
181 // Membership is stated by the relationship, not the system, so index the
182 // relationships once instead of rescanning per system.
183 let mut members: std::collections::BTreeMap<EntityId, Vec<EntityId>> =
184 std::collections::BTreeMap::new();
185
186 for &relation in model.ids_of_type("IFCRELASSIGNSTOGROUP") {
187 let Some(entity) = model.get(relation) else {
188 continue;
189 };
190 let group = match entity.attributes.get(slot::ASSIGNS_GROUP) {
191 Some(Value::Ref(id)) => *id,
192 _ => continue,
193 };
194 let Some(group_entity) = model.get(group) else {
195 anomalies.push(SystemAnomaly::Dangling {
196 relation,
197 missing: group,
198 });
199 continue;
200 };
201 // The relationship is shared with every group kind; only systems are
202 // this crate's concern, and the rest are reported rather than dropped.
203 if !release.is_a(&group_entity.type_name.to_ascii_uppercase(), "IFCSYSTEM") {
204 anomalies.push(SystemAnomaly::NotASystem {
205 relation,
206 group,
207 // Upper-cased: a STEP file writes IFCINVENTORY while an
208 // in-memory model may carry IfcInventory, and a caller
209 // matching on this string must not have to know which.
210 type_name: group_entity.type_name.to_ascii_uppercase(),
211 });
212 continue;
213 }
214 for member in refs(entity.attributes.get(slot::ASSIGNS_RELATED)) {
215 if model.get(member).is_none() {
216 anomalies.push(SystemAnomaly::Dangling {
217 relation,
218 missing: member,
219 });
220 continue;
221 }
222 members.entry(group).or_default().push(member);
223 }
224 }
225
226 let mut buildings = services::serviced_buildings(model, release, &mut anomalies);
227 let mut facilities = services::serviced_facilities(model, release, &mut anomalies);
228
229 // `ids_of_type` is an EXACT index: asking it for IFCSYSTEM misses every
230 // IfcDistributionSystem in the file, which is the common case. Systems are
231 // therefore selected by schema ancestry over the file's own type keys.
232 let mut systems = Vec::new();
233 for id in system_ids(model, release) {
234 let Some(entity) = model.get(id) else {
235 continue;
236 };
237 // Upper-cased for the same reason as `NotASystem::type_name`.
238 let type_name = entity.type_name.to_ascii_uppercase();
239 systems.push(System {
240 id,
241 name: text(model, id, slot::NAME),
242 long_name: long_name(model, release, id, &type_name),
243 predefined_type: predefined_type(model, release, id, &type_name),
244 type_name,
245 members: members.remove(&id).unwrap_or_default(),
246 serviced_buildings: buildings.remove(&id).unwrap_or_default(),
247 serviced_facilities: facilities
248 .remove(&id)
249 .map(|set| set.into_iter().collect())
250 .unwrap_or_default(),
251 });
252 }
253 Ok((systems, anomalies))
254}
255
256/// Ids of every entity whose declared type is `IfcSystem` or a subtype,
257/// under `release`.
258///
259/// Deliberately not `Model::ids_of_type`, which indexes the exact type name
260/// only: a file whose systems are all `IfcDistributionSystem` would return
261/// nothing and the crate would report a model with no systems at all.
262fn system_ids(model: &Model, release: Release) -> Vec<EntityId> {
263 let mut out = Vec::new();
264 for (type_name, _) in model.type_histogram() {
265 if release.is_a(type_name, "IFCSYSTEM") {
266 out.extend_from_slice(model.ids_of_type(type_name));
267 }
268 }
269 out.sort_unstable();
270 out
271}