Skip to main content

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}