ifc_systems/zone/definition.rs
1//! `IfcZone` and what it is allowed to contain.
2//!
3//! # WR1 is a real constraint, not a convention
4//!
5//! `IfcZone` carries a WHERE rule restricting its members to `IfcZone`,
6//! `IfcSpace`, and from IFC4 also `IfcSpatialZone` -- nothing else. A zone
7//! grouping a pump is not a stylistic choice, it is an invalid file.
8//!
9//! The rule is enforced as a REPORTED anomaly rather than a hard error: a
10//! file with one bad member still has a usable zone structure, and refusing
11//! the whole read would lose the valid members too.
12//!
13//! # Zones are systems from IFC4 on
14//!
15//! IFC4 and IFC4X3 have `IfcZone -> IfcSystem -> IfcGroup`, so `systems()`
16//! returns zones there. IFC2X3 has `IfcZone -> IfcGroup`, so it does not.
17//! This module adds what is specific to zones: the member restriction, the
18//! `LongName` (IFC4 onwards), and the spatial elements they cover.
19//!
20//! # Every slot is read by name in the declared release
21//!
22//! ```text
23//! IfcZone IFC2X3 GlobalId OwnerHistory Name Description ObjectType
24//! IFC4 ... ObjectType LongName (IFC4X3 the same)
25//! IfcRelAssignsToGroup all GlobalId OwnerHistory Name Description
26//! RelatedObjects RelatedObjectsType RelatingGroup
27//! ```
28//!
29//! IFC4X3 ADD2 retypes `IfcRelAssigns.RelatedObjectsType` as
30//! `IfcStrippedOptional` but keeps its position, so `RelatingGroup` stays the
31//! seventh attribute. Positions come from the table, not from constants.
32
33use std::collections::{BTreeMap, BTreeSet};
34
35use ifc_model::{EntityId, Model, Value};
36
37use crate::error::{NotInSchema, SchemaGap, SchemaResolutionError, SystemAnomaly};
38use crate::release::{self, Release};
39
40const ZONE: &str = "IFCZONE";
41const ASSIGNS: &str = "IFCRELASSIGNSTOGROUP";
42
43/// The types WR1 permits inside an `IfcZone`.
44///
45/// Checked by schema ancestry, not string equality: a subtype of `IfcSpace`
46/// is still a space, and comparing type names alone would reject it.
47const ZONE_MEMBER_TYPES: [&str; 3] = ["IFCZONE", "IFCSPACE", "IFCSPATIALZONE"];
48
49/// A zone: a grouping of spatial elements.
50#[derive(Debug, Clone, PartialEq, Eq)]
51#[non_exhaustive]
52pub struct Zone {
53 /// Entity id of the `IfcZone` itself.
54 pub id: EntityId,
55 /// `Name`, if the file states one.
56 pub name: Option<String>,
57 /// `LongName`, the descriptive name (IFC4 onwards).
58 pub long_name: Option<String>,
59 /// Members that satisfy WR1, ascending by id.
60 ///
61 /// Members violating WR1 are NOT here: they are reported as anomalies, so
62 /// this list is always a valid zone content set.
63 pub members: Vec<EntityId>,
64}
65
66fn refs(value: Option<&Value>) -> Vec<EntityId> {
67 match value {
68 Some(Value::List(items)) => items
69 .iter()
70 .filter_map(|v| match v {
71 Value::Ref(id) => Some(*id),
72 _ => None,
73 })
74 .collect(),
75 Some(Value::Ref(id)) => vec![*id],
76 _ => Vec::new(),
77 }
78}
79
80/// The text at the `IfcZone` `attribute` of `id`, read by name in
81/// `release`; `None` when unset, not text, or not declared by the release.
82/// A subtype keeps its inherited attributes first, so `IfcZone`'s position
83/// holds for it too.
84fn text(model: &Model, release: Release, id: EntityId, attribute: &str) -> Option<String> {
85 let slot = release.slot(ZONE, attribute)?;
86 match model.get(id)?.attributes.get(slot)? {
87 Value::Text(t) => Some(t.to_string()),
88 _ => None,
89 }
90}
91
92/// Every `IfcZone` in the file, with WR1-valid members resolved.
93///
94/// Zones are found by schema ancestry so that any future subtype is included
95/// automatically, consistent with how systems and ports are discovered.
96///
97/// Reads against the release the model's `FILE_SCHEMA` header declares:
98/// IFC2X3, IFC4 or IFC4X3 (#194), every slot by attribute name. WR1
99/// differs by release: IFC4 and IFC4X3 admit `IfcZone`, `IfcSpace` and
100/// `IfcSpatialZone`; IFC2X3 admits only `IfcZone` and `IfcSpace`, because
101/// it has no `IfcSpatialZone`. Membership is checked by ancestry in the
102/// declared table, so an `IfcSpatialZone` in an IFC2X3 file is reported as
103/// `ZoneMemberNotSpatial`. `long_name` is `None` for every zone under
104/// IFC2X3, whose `IfcZone` has no `LongName` (issue #52). This bulk reader
105/// cannot tell that apart from a file that left it empty, because
106/// `Zone::long_name` predates #52 and stays `Option<String>`. Use
107/// [`long_name_of`] when that distinction matters.
108///
109/// # Errors
110///
111/// [`SchemaResolutionError`] when the model's `FILE_SCHEMA` binds no release
112/// this crate is verified for (see [`crate::schema_of`]): no schema,
113/// several, or any release but IFC2X3, IFC4 and IFC4X3. None is read as
114/// IFC4.
115pub fn zones(model: &Model) -> Result<(Vec<Zone>, Vec<SystemAnomaly>), SchemaResolutionError> {
116 Ok(zones_in(model, release::resolve(model)?))
117}
118
119fn zones_in(model: &Model, release: Release) -> (Vec<Zone>, Vec<SystemAnomaly>) {
120 let mut anomalies = Vec::new();
121
122 let mut zone_ids = BTreeSet::new();
123 for (type_name, _) in model.type_histogram() {
124 if release.is_a(type_name, ZONE) {
125 zone_ids.extend(model.ids_of_type(type_name).iter().copied());
126 }
127 }
128
129 // Members, gathered per zone and filtered by WR1.
130 let mut members: BTreeMap<EntityId, Vec<EntityId>> = BTreeMap::new();
131 // Every bundled release declares both; `zone_slots_resolve_by_name`
132 // pins them per release.
133 let group_slot = release
134 .slot(ASSIGNS, "RelatingGroup")
135 .expect("every bundled release declares IfcRelAssignsToGroup.RelatingGroup");
136 let members_slot = release
137 .slot(ASSIGNS, "RelatedObjects")
138 .expect("every bundled release declares IfcRelAssigns.RelatedObjects");
139 for &relation in model.ids_of_type(ASSIGNS) {
140 let Some(entity) = model.get(relation) else {
141 continue;
142 };
143 let group = match entity.attributes.get(group_slot) {
144 Some(Value::Ref(id)) => *id,
145 _ => continue,
146 };
147 if !zone_ids.contains(&group) {
148 continue;
149 }
150 for member in refs(entity.attributes.get(members_slot)) {
151 let Some(member_entity) = model.get(member) else {
152 anomalies.push(SystemAnomaly::Dangling {
153 relation,
154 missing: member,
155 });
156 continue;
157 };
158 let type_name = member_entity.type_name.to_ascii_uppercase();
159 let permitted = ZONE_MEMBER_TYPES
160 .iter()
161 .any(|allowed| release.is_a(&type_name, allowed));
162 if permitted {
163 members.entry(group).or_default().push(member);
164 } else {
165 // WR1 violation: reported, and excluded from members so a
166 // caller iterating a zone never sees a pump in a room list.
167 anomalies.push(SystemAnomaly::ZoneMemberNotSpatial {
168 relation,
169 zone: group,
170 member,
171 type_name: member_entity.type_name.to_string(),
172 });
173 }
174 }
175 }
176
177 let zones = zone_ids
178 .into_iter()
179 .map(|id| {
180 let mut member_ids = members.remove(&id).unwrap_or_default();
181 member_ids.sort_unstable();
182 member_ids.dedup();
183 Zone {
184 id,
185 name: text(model, release, id, "Name"),
186 // Under IFC2X3 this is unconditionally None: that release's
187 // IfcZone declares no LongName, so no slot is read at all.
188 // See long_name_of for a caller that needs to tell that apart
189 // from an authored-empty LongName.
190 long_name: text(model, release, id, "LongName"),
191 members: member_ids,
192 }
193 })
194 .collect();
195
196 (zones, anomalies)
197}
198
199/// `LongName` of a single `IfcZone`, distinguishing "not authored" from
200/// "this release has no such attribute".
201///
202/// [`zones`] cannot make this distinction: its `Zone::long_name` field is
203/// `Option<String>` and predates #52, so both cases collapse to `None`
204/// there. IFC2X3's `IfcZone` has no `LongName` at all (it is a plain
205/// five-attribute `IfcGroup` subtype), unlike IFC4's and IFC4X3's, which add
206/// `LongName` as the sixth. Reading it under IFC2X3 is therefore not "the
207/// file left it blank" -- there is no slot to have left blank -- and a
208/// caller that needs to tell the two apart should use this accessor instead
209/// of `Zone::long_name`.
210///
211/// # Errors
212///
213/// [`SchemaGap::Schema`] if the model's `FILE_SCHEMA` does not resolve to
214/// IFC2X3, IFC4 or IFC4X3. [`SchemaGap::NotInSchema`] if the resolved
215/// release does not declare `LongName` for `IfcZone` (IFC2X3).
216pub fn long_name_of(model: &Model, zone: EntityId) -> Result<Option<String>, SchemaGap> {
217 let release: Release = release::resolve(model)?;
218 if release.slot(ZONE, "LongName").is_none() {
219 return Err(SchemaGap::NotInSchema(NotInSchema {
220 entity: zone,
221 schema: release.version,
222 }));
223 }
224 Ok(text(model, release, zone, "LongName"))
225}