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