Skip to main content

ifc_systems/zone/
spatial_group.rs

1//! Where an element sits spatially, and where it is merely referenced.
2//!
3//! # Two relationships that are not interchangeable
4//!
5//! ```text
6//! IfcRelContainedInSpatialStructure   4 = RelatedElements  5 = RelatingStructure
7//! IfcRelReferencedInSpatialStructure  4 = RelatedElements  5 = RelatingStructure
8//! ```
9//!
10//! Same slot layout, different cardinality and different meaning:
11//!
12//! - `ContainedInStructure` is `SET [0:1]`: an element has AT MOST ONE
13//!   containing structure. This is its home.
14//! - `ReferencedInStructures` is `SET [0:?]`: an element may be referenced by
15//!   MANY structures. A duct passing through five rooms is referenced by all
16//!   five and contained by none of them.
17//!
18//! Merging the two loses the distinction that makes the second useful. A
19//! caller asking "which storey owns this pump" wants containment; a caller
20//! asking "which rooms does this duct serve" wants references. They are kept
21//! apart.
22
23use std::collections::{BTreeMap, BTreeSet};
24
25use ifc_model::{EntityId, Model, Value};
26
27use crate::error::SystemAnomaly;
28
29fn refs(value: Option<&Value>) -> Vec<EntityId> {
30    match value {
31        Some(Value::List(items)) => items
32            .iter()
33            .filter_map(|v| match v {
34                Value::Ref(id) => Some(*id),
35                _ => None,
36            })
37            .collect(),
38        Some(Value::Ref(id)) => vec![*id],
39        _ => Vec::new(),
40    }
41}
42
43pub(crate) mod slot {
44    /// Both spatial relationships put elements at 4 and the structure at 5.
45    pub const RELATED_ELEMENTS: usize = 4;
46    pub const RELATING_STRUCTURE: usize = 5;
47}
48
49/// Where one element sits in the spatial structure.
50#[derive(Debug, Clone, PartialEq, Eq, Default)]
51pub struct SpatialPlacement {
52    /// The single containing structure, if the file states one.
53    ///
54    /// `Option`, not `Vec`, because the schema says `SET [0:1]`. A file
55    /// stating two is malformed and reports `ContainedTwice`.
56    pub contained_in: Option<EntityId>,
57    /// Structures that reference this element, ascending by id.
58    ///
59    /// Unbounded by the schema, so a `Vec` is the honest shape.
60    pub referenced_in: Vec<EntityId>,
61}
62
63impl SpatialPlacement {
64    /// Every structure this element relates to, containment first.
65    ///
66    /// Convenience for callers that genuinely do not care which mechanism
67    /// stated the link -- but they must opt in, rather than the reader
68    /// flattening the distinction for everyone.
69    pub fn all_structures(&self) -> Vec<EntityId> {
70        let mut out: Vec<_> = self.contained_in.into_iter().collect();
71        out.extend(self.referenced_in.iter().copied());
72        out.dedup();
73        out
74    }
75}
76
77/// Read spatial containment and referencing for every element that has either.
78///
79/// Elements with no spatial relationship are absent from the map rather than
80/// present-and-empty: absence is the file's actual statement.
81pub fn spatial_placements(
82    model: &Model,
83) -> (BTreeMap<EntityId, SpatialPlacement>, Vec<SystemAnomaly>) {
84    let mut out: BTreeMap<EntityId, SpatialPlacement> = BTreeMap::new();
85    let mut anomalies = Vec::new();
86
87    // Containment: at most one per element, so a second is an anomaly.
88    for &relation in model.ids_of_type("IFCRELCONTAINEDINSPATIALSTRUCTURE") {
89        let Some(entity) = model.get(relation) else {
90            continue;
91        };
92        let structure = match entity.attributes.get(slot::RELATING_STRUCTURE) {
93            Some(Value::Ref(id)) => *id,
94            _ => continue,
95        };
96        for element in refs(entity.attributes.get(slot::RELATED_ELEMENTS)) {
97            let slot_entry = out.entry(element).or_default();
98            match slot_entry.contained_in {
99                Some(existing) if existing != structure => {
100                    // IfcElement.ContainedInStructure is SET [0:1]; two
101                    // different homes cannot both be true. Keep the first by
102                    // id order so the result is deterministic, and say so.
103                    anomalies.push(SystemAnomaly::ContainedTwice {
104                        element,
105                        first: existing,
106                        second: structure,
107                    });
108                }
109                Some(_) => {}
110                None => slot_entry.contained_in = Some(structure),
111            }
112        }
113    }
114
115    // Referencing: many per element is legal and expected.
116    let mut references: BTreeMap<EntityId, BTreeSet<EntityId>> = BTreeMap::new();
117    for &relation in model.ids_of_type("IFCRELREFERENCEDINSPATIALSTRUCTURE") {
118        let Some(entity) = model.get(relation) else {
119            continue;
120        };
121        let structure = match entity.attributes.get(slot::RELATING_STRUCTURE) {
122            Some(Value::Ref(id)) => *id,
123            _ => continue,
124        };
125        for element in refs(entity.attributes.get(slot::RELATED_ELEMENTS)) {
126            references.entry(element).or_default().insert(structure);
127        }
128    }
129    for (element, structures) in references {
130        out.entry(element).or_default().referenced_in = structures.into_iter().collect();
131    }
132
133    (out, anomalies)
134}