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
18use ifc_model::{EntityId, Model, Value};
19
20use crate::error::{SchemaResolutionError, SystemAnomaly};
21use crate::release::{self, Release};
22
23/// Attribute slots, named so a misread is a compile error rather than a
24/// silently empty result.
25pub(crate) mod slot {
26 /// `IfcRelAssignsToGroup.RelatedObjects`.
27 pub const ASSIGNS_RELATED: usize = 4;
28 /// `IfcRelAssignsToGroup.RelatingGroup` -- 6, not 5.
29 pub const ASSIGNS_GROUP: usize = 6;
30 /// `IfcRoot.Name`.
31 pub const NAME: usize = 2;
32}
33
34/// A system as the file states it, with its members resolved.
35#[derive(Debug, Clone, PartialEq, Eq)]
36#[non_exhaustive]
37pub struct System {
38 /// The `IfcSystem` (or subtype) entity.
39 pub id: EntityId,
40 /// Declared type, e.g. `IFCDISTRIBUTIONSYSTEM`.
41 pub type_name: String,
42 /// `Name`, when present.
43 pub name: Option<String>,
44 /// Members, in file order.
45 ///
46 /// Order is preserved because IFC states no ordering and re-sorting would
47 /// invent one; a caller comparing two exports needs the file's own order.
48 pub members: Vec<EntityId>,
49}
50
51fn text(model: &Model, id: EntityId, slot: usize) -> Option<String> {
52 match model.get(id)?.attributes.get(slot)? {
53 Value::Text(t) => Some(t.to_string()),
54 _ => None,
55 }
56}
57
58fn refs(value: Option<&Value>) -> Vec<EntityId> {
59 match value {
60 Some(Value::List(items)) => items
61 .iter()
62 .filter_map(|v| match v {
63 Value::Ref(id) => Some(*id),
64 _ => None,
65 })
66 .collect(),
67 Some(Value::Ref(id)) => vec![*id],
68 _ => Vec::new(),
69 }
70}
71
72/// Every system in the file, with members resolved and anomalies reported.
73///
74/// Systems are found by type, not by walking memberships: a file may declare
75/// a system that nothing is assigned to yet, and dropping it would understate
76/// the model. Subtypes are included, so `IfcDistributionSystem` and
77/// `IfcBuildingSystem` are both found in IFC4, and `IfcElectricalCircuit` is
78/// found in IFC2X3 -- but `IfcZone` is NOT, because it subtypes `IfcGroup`
79/// rather than `IfcSystem` in IFC2X3 (issue #52).
80///
81/// Reads against the release the model's `FILE_SCHEMA` header declares:
82/// IFC2X3, IFC4 or IFC4X3.
83///
84/// # Errors
85///
86/// [`SchemaResolutionError`] when the model's `FILE_SCHEMA` binds no release
87/// this crate is verified for (see [`crate::schema_of`]). Nothing is read
88/// against a release the file did not declare.
89pub fn systems(model: &Model) -> Result<(Vec<System>, Vec<SystemAnomaly>), SchemaResolutionError> {
90 let release = release::resolve(model)?;
91 let mut anomalies = Vec::new();
92
93 // Membership is stated by the relationship, not the system, so index the
94 // relationships once instead of rescanning per system.
95 let mut members: std::collections::BTreeMap<EntityId, Vec<EntityId>> =
96 std::collections::BTreeMap::new();
97
98 for &relation in model.ids_of_type("IFCRELASSIGNSTOGROUP") {
99 let Some(entity) = model.get(relation) else {
100 continue;
101 };
102 let group = match entity.attributes.get(slot::ASSIGNS_GROUP) {
103 Some(Value::Ref(id)) => *id,
104 _ => continue,
105 };
106 let Some(group_entity) = model.get(group) else {
107 anomalies.push(SystemAnomaly::Dangling {
108 relation,
109 missing: group,
110 });
111 continue;
112 };
113 // The relationship is shared with every group kind; only systems are
114 // this crate's concern, and the rest are reported rather than dropped.
115 if !release.is_a(&group_entity.type_name.to_ascii_uppercase(), "IFCSYSTEM") {
116 anomalies.push(SystemAnomaly::NotASystem {
117 relation,
118 group,
119 // Upper-cased: a STEP file writes IFCINVENTORY while an
120 // in-memory model may carry IfcInventory, and a caller
121 // matching on this string must not have to know which.
122 type_name: group_entity.type_name.to_ascii_uppercase(),
123 });
124 continue;
125 }
126 for member in refs(entity.attributes.get(slot::ASSIGNS_RELATED)) {
127 if model.get(member).is_none() {
128 anomalies.push(SystemAnomaly::Dangling {
129 relation,
130 missing: member,
131 });
132 continue;
133 }
134 members.entry(group).or_default().push(member);
135 }
136 }
137
138 // `ids_of_type` is an EXACT index: asking it for IFCSYSTEM misses every
139 // IfcDistributionSystem in the file, which is the common case. Systems are
140 // therefore selected by schema ancestry over the file's own type keys.
141 let mut systems = Vec::new();
142 for id in system_ids(model, release) {
143 let Some(entity) = model.get(id) else {
144 continue;
145 };
146 systems.push(System {
147 id,
148 // Upper-cased for the same reason as `NotASystem::type_name`.
149 type_name: entity.type_name.to_ascii_uppercase(),
150 name: text(model, id, slot::NAME),
151 members: members.remove(&id).unwrap_or_default(),
152 });
153 }
154 Ok((systems, anomalies))
155}
156
157/// Ids of every entity whose declared type is `IfcSystem` or a subtype,
158/// under `release`.
159///
160/// Deliberately not `Model::ids_of_type`, which indexes the exact type name
161/// only: a file whose systems are all `IfcDistributionSystem` would return
162/// nothing and the crate would report a model with no systems at all.
163fn system_ids(model: &Model, release: Release) -> Vec<EntityId> {
164 let mut out = Vec::new();
165 for (type_name, _) in model.type_histogram() {
166 if release.is_a(type_name, "IFCSYSTEM") {
167 out.extend_from_slice(model.ids_of_type(type_name));
168 }
169 }
170 out.sort_unstable();
171 out
172}