Skip to main content

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}