Skip to main content

ifc_spatial/relation/
boundary.rs

1//! `IfcRelSpaceBoundary`: which element bounds a space, and how.
2//!
3//! A space knows its volume; it does not know which wall encloses it. That
4//! adjacency lives entirely in `IfcRelSpaceBoundary` entities, and it is what
5//! thermal analysis, area take-off and daylight models consume. Without it a
6//! room is a shape with no relationship to the fabric around it.
7//!
8//! # The subtype trap
9//!
10//! ```text
11//! IfcRelSpaceBoundary            4 = RelatingSpace  5 = RelatedBuildingElement
12//!                                6 = ConnectionGeometry (OPTIONAL)
13//!   IfcRelSpaceBoundary1stLevel    + 9 = ParentBoundary
14//!     IfcRelSpaceBoundary2ndLevel    + 10 = CorrespondingBoundary
15//! ```
16//!
17//! `Model::ids_of_type` matches the EXACT type name. Every real second-level
18//! BEM export writes `IfcRelSpaceBoundary2ndLevel`, so a lookup of the
19//! supertype alone finds nothing and the crate reports a building with no
20//! boundaries -- an empty answer, not an error. All three concrete types are
21//! therefore queried explicitly.
22//!
23//! # What is reported and what is not
24//!
25//! `PhysicalOrVirtualBoundary` and `InternalOrExternalBoundary` are read as
26//! stated. The schema's `CorrectPhysOrVirt` rule ties the first to whether
27//! the related element is an `IfcVirtualElement`, but enforcing it belongs to
28//! `ifc-validate`: this crate reports what the file says and never rejects it
29//! (see AGENTS.md). A disagreement is surfaced through
30//! [`SpaceBoundary::physical_matches_element`] so a caller can decide.
31//!
32//! The same holds for `ConnectionGeometry`: a reference that names nothing,
33//! or names something that is not a connection geometry, is returned by
34//! [`SpaceBoundary::connection_geometry`] as a
35//! [`ConnectionGeometryAnomaly`] carrying the offending target, never
36//! dropped and never passed off as a shape.
37
38use ifc_model::{EntityId, Model, Value};
39
40use super::link::refs_in_slot;
41use super::slots::SPACE_BOUNDARY_TYPES;
42
43/// Attribute positions beyond the two ends.
44mod slot {
45    /// `IfcRelSpaceBoundary.ConnectionGeometry`, `OPTIONAL` in IFC2x3 TC1,
46    /// IFC4 ADD2 TC1 and IFC4X3 ADD2 alike.
47    pub const CONNECTION_GEOMETRY: usize = 6;
48    /// `IfcRelSpaceBoundary.PhysicalOrVirtualBoundary`.
49    pub const PHYSICAL_OR_VIRTUAL: usize = 7;
50    /// `IfcRelSpaceBoundary.InternalOrExternalBoundary`.
51    pub const INTERNAL_OR_EXTERNAL: usize = 8;
52    /// `IfcRelSpaceBoundary1stLevel.ParentBoundary`.
53    pub const PARENT_BOUNDARY: usize = 9;
54    /// `IfcRelSpaceBoundary2ndLevel.CorrespondingBoundary`.
55    pub const CORRESPONDING_BOUNDARY: usize = 10;
56}
57
58/// Whether a boundary is real fabric or an analytical divider.
59///
60/// `NotDefined` is a distinct member rather than an `Option` because the
61/// schema states it explicitly: a file saying "not defined" is making a
62/// claim, and collapsing it into "absent" loses that.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum BoundaryPhysicality {
65    /// Real fabric: a wall, slab or roof.
66    Physical,
67    /// An analytical divider with no element, e.g. across an opening.
68    Virtual,
69    /// Stated as undetermined by the file.
70    NotDefined,
71    /// The slot held something that is not one of the three enum members.
72    Unrecognized,
73}
74
75/// Whether the boundary faces conditioned space or outside.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum BoundaryExposure {
78    /// Faces another internal space.
79    Internal,
80    /// Faces outside.
81    External,
82    /// Faces earth, water, or another external variant IFC4 names separately.
83    ExternalVariant,
84    /// Stated as undetermined by the file.
85    NotDefined,
86    /// The slot held something that is not a recognised member.
87    Unrecognized,
88}
89
90/// Every concrete `IfcConnectionGeometry` subtype across the three releases.
91///
92/// `IfcConnectionGeometry` is `ABSTRACT`, so a well-formed slot names one of
93/// these. The list is the union over IFC2x3 TC1 (which alone has
94/// `IfcConnectionPortGeometry`) and IFC4 / IFC4X3 (which add
95/// `IfcConnectionVolumeGeometry`); this crate does not know which release a
96/// file declares, and rejecting a subtype for the wrong release is
97/// `ifc-validate`'s job. Asserted against all three bundled schemas in
98/// `tests/slot_layout.rs`.
99const CONNECTION_GEOMETRY_TYPES: [&str; 6] = [
100    "IFCCONNECTIONCURVEGEOMETRY",
101    "IFCCONNECTIONPOINTECCENTRICITY",
102    "IFCCONNECTIONPOINTGEOMETRY",
103    "IFCCONNECTIONPORTGEOMETRY",
104    "IFCCONNECTIONSURFACEGEOMETRY",
105    "IFCCONNECTIONVOLUMEGEOMETRY",
106];
107
108/// A `ConnectionGeometry` slot the file fills with something unusable.
109///
110/// Returned by [`SpaceBoundary::connection_geometry`] instead of the shape.
111/// Each variant names the boundary and, where there is one, the offending
112/// target, so a caller can point at the record rather than silently losing a
113/// surface from its coverage.
114#[derive(Debug, Clone, PartialEq, Eq)]
115#[non_exhaustive]
116pub enum ConnectionGeometryAnomaly {
117    /// The slot names an entity the model does not contain.
118    Dangling {
119        /// The space boundary holding the reference.
120        boundary: EntityId,
121        /// The missing target.
122        target: EntityId,
123    },
124    /// The slot names an entity that is not a concrete
125    /// `IfcConnectionGeometry` subtype.
126    WrongKind {
127        /// The space boundary holding the reference.
128        boundary: EntityId,
129        /// The target that was found.
130        target: EntityId,
131        /// The target's STEP type, upper-cased.
132        type_name: String,
133    },
134    /// The slot holds a value that is not a single entity reference, such as
135    /// a list or a literal.
136    NotAReference {
137        /// The space boundary holding the value.
138        boundary: EntityId,
139    },
140    /// The boundary itself is not in the model it was asked about, so its
141    /// slot cannot be read. Only reachable with a model other than the one
142    /// the boundary was read from.
143    MissingBoundary {
144        /// The boundary that was looked up.
145        boundary: EntityId,
146    },
147}
148
149/// One space boundary as the file states it.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct SpaceBoundary {
152    /// The relationship entity itself.
153    pub id: EntityId,
154    /// Concrete STEP type, e.g. `IFCRELSPACEBOUNDARY2NDLEVEL`.
155    pub type_name: String,
156    /// `RelatingSpace`: the space being bounded.
157    pub space: Option<EntityId>,
158    /// `RelatedBuildingElement`: the element forming the boundary.
159    pub element: Option<EntityId>,
160    /// `PhysicalOrVirtualBoundary`.
161    pub physicality: BoundaryPhysicality,
162    /// `InternalOrExternalBoundary`.
163    pub exposure: BoundaryExposure,
164    /// `ParentBoundary`, on 1st-level boundaries and above.
165    ///
166    /// An inner boundary -- a window within a wall boundary -- names the
167    /// wall's boundary here. `None` on a plain `IfcRelSpaceBoundary`, which
168    /// has no such slot at all.
169    pub parent: Option<EntityId>,
170    /// `CorrespondingBoundary`, on 2nd-level boundaries only.
171    ///
172    /// The boundary on the OTHER side of the same fabric. This pairing is
173    /// what makes second-level boundaries usable for heat transfer: each
174    /// side's area is known and the two are linked.
175    pub corresponding: Option<EntityId>,
176}
177
178impl SpaceBoundary {
179    /// Does `physicality` agree with the related element's type?
180    ///
181    /// The schema's `CorrectPhysOrVirt` rule: a `Physical` boundary must not
182    /// name an `IfcVirtualElement`, and a `Virtual` one must name either an
183    /// `IfcVirtualElement` or an `IfcOpeningElement`. `NotDefined` is always
184    /// admissible.
185    ///
186    /// Returns `None` when the element is absent or unresolvable, since
187    /// agreement is then unknowable rather than false.
188    #[must_use]
189    pub fn physical_matches_element(&self, model: &Model) -> Option<bool> {
190        let element = self.element?;
191        let entity = model.get(element)?;
192        let name = entity.type_name.to_ascii_uppercase();
193        let is_virtual = name == "IFCVIRTUALELEMENT";
194        let is_opening = name == "IFCOPENINGELEMENT";
195        Some(match self.physicality {
196            BoundaryPhysicality::Physical => !is_virtual,
197            BoundaryPhysicality::Virtual => is_virtual || is_opening,
198            BoundaryPhysicality::NotDefined => true,
199            BoundaryPhysicality::Unrecognized => false,
200        })
201    }
202
203    /// `ConnectionGeometry`: the boundary's shape, if the file gives one.
204    ///
205    /// The coordinates are in the object placement of the relating space
206    /// ([`space`](Self::space)): IFC4 ADD2 TC1 states the connection
207    /// geometry is "given within the local placement of each space", and
208    /// that `SurfaceOnRelatingElement` / `CurveOnRelatingElement` are in the
209    /// space's local coordinate system while the `...OnRelatedElement`
210    /// counterparts, rarely exported, are in the element's. Placing the
211    /// shape in the model therefore needs the space's placement, not the
212    /// element's.
213    ///
214    /// The slot is `OPTIONAL` in IFC2x3, IFC4 and IFC4X3 and sits at the same
215    /// position on `IfcRelSpaceBoundary1stLevel` and `2ndLevel`. The target
216    /// is normally an `IfcConnectionSurfaceGeometry` (3D) or
217    /// `IfcConnectionCurveGeometry` (2D); any concrete
218    /// `IfcConnectionGeometry` subtype is returned, since which ones a
219    /// boundary may use is a validation question.
220    ///
221    /// `model` must be the model the boundary was read from: the slot is
222    /// read from it on each call rather than stored on the struct.
223    ///
224    /// # Errors
225    ///
226    /// Returns a [`ConnectionGeometryAnomaly`] when the slot names a missing
227    /// entity or a non-connection-geometry entity, holds something other
228    /// than one reference, or when the boundary is not in `model`. An absent
229    /// (`$`) or missing slot is `Ok(None)`: the boundary is then stated
230    /// logically, without a shape.
231    pub fn connection_geometry(
232        &self,
233        model: &Model,
234    ) -> Result<Option<EntityId>, ConnectionGeometryAnomaly> {
235        let boundary = self.id;
236        let entity = model
237            .get(boundary)
238            .ok_or(ConnectionGeometryAnomaly::MissingBoundary { boundary })?;
239        let target = match entity.attribute(slot::CONNECTION_GEOMETRY) {
240            None | Some(Value::Null) => return Ok(None),
241            Some(Value::Ref(target)) => *target,
242            Some(_) => return Err(ConnectionGeometryAnomaly::NotAReference { boundary }),
243        };
244        let resolved = model
245            .get(target)
246            .ok_or(ConnectionGeometryAnomaly::Dangling { boundary, target })?;
247        let type_name = resolved.type_name.to_ascii_uppercase();
248        if CONNECTION_GEOMETRY_TYPES.contains(&type_name.as_str()) {
249            Ok(Some(target))
250        } else {
251            Err(ConnectionGeometryAnomaly::WrongKind {
252                boundary,
253                target,
254                type_name,
255            })
256        }
257    }
258}
259
260/// Read the enumeration in a slot, or `NotDefined` when absent.
261fn physicality(model: &Model, id: EntityId, slot: usize) -> BoundaryPhysicality {
262    match enum_text(model, id, slot).as_deref() {
263        None => BoundaryPhysicality::NotDefined,
264        Some("PHYSICAL") => BoundaryPhysicality::Physical,
265        Some("VIRTUAL") => BoundaryPhysicality::Virtual,
266        Some("NOTDEFINED") => BoundaryPhysicality::NotDefined,
267        Some(_) => BoundaryPhysicality::Unrecognized,
268    }
269}
270
271/// Read the exposure enumeration in a slot.
272///
273/// IFC4 adds `EXTERNAL_EARTH`, `EXTERNAL_WATER` and `EXTERNAL_FIRE` to the
274/// original pair. They are grouped as `ExternalVariant` rather than folded
275/// into `External`: a caller computing ground-contact losses needs them
276/// apart, and silently merging would make that impossible.
277fn exposure(model: &Model, id: EntityId, slot: usize) -> BoundaryExposure {
278    match enum_text(model, id, slot).as_deref() {
279        None => BoundaryExposure::NotDefined,
280        Some("INTERNAL") => BoundaryExposure::Internal,
281        Some("EXTERNAL") => BoundaryExposure::External,
282        Some("EXTERNAL_EARTH" | "EXTERNAL_WATER" | "EXTERNAL_FIRE") => {
283            BoundaryExposure::ExternalVariant
284        }
285        Some("NOTDEFINED") => BoundaryExposure::NotDefined,
286        Some(_) => BoundaryExposure::Unrecognized,
287    }
288}
289
290/// The enumeration text in a slot, upper-cased, or `None` if absent/null.
291fn enum_text(model: &Model, id: EntityId, slot: usize) -> Option<String> {
292    let entity = model.get(id)?;
293    match entity.attribute(slot)? {
294        Value::Enum(text) => Some(text.to_ascii_uppercase()),
295        _ => None,
296    }
297}
298
299/// Every space boundary in the model, across all three concrete types.
300#[must_use]
301pub fn all(model: &Model) -> Vec<SpaceBoundary> {
302    let mut out = Vec::new();
303    for slots in SPACE_BOUNDARY_TYPES {
304        for id in model.ids_of_type(slots.type_name) {
305            let Some(entity) = model.get(*id) else {
306                continue;
307            };
308            out.push(SpaceBoundary {
309                id: *id,
310                type_name: entity.type_name.to_ascii_uppercase(),
311                space: refs_in_slot(model, *id, slots.relating).into_iter().next(),
312                element: refs_in_slot(model, *id, slots.related).into_iter().next(),
313                physicality: physicality(model, *id, slot::PHYSICAL_OR_VIRTUAL),
314                exposure: exposure(model, *id, slot::INTERNAL_OR_EXTERNAL),
315                parent: refs_in_slot(model, *id, slot::PARENT_BOUNDARY)
316                    .into_iter()
317                    .next(),
318                corresponding: refs_in_slot(model, *id, slot::CORRESPONDING_BOUNDARY)
319                    .into_iter()
320                    .next(),
321            });
322        }
323    }
324    out
325}
326
327/// The boundaries of one space.
328#[must_use]
329pub fn of_space(model: &Model, space: EntityId) -> Vec<SpaceBoundary> {
330    all(model)
331        .into_iter()
332        .filter(|boundary| boundary.space == Some(space))
333        .collect()
334}
335
336/// The boundaries naming one element.
337///
338/// A wall between two rooms is named by two boundaries, one per space.
339#[must_use]
340pub fn of_element(model: &Model, element: EntityId) -> Vec<SpaceBoundary> {
341    all(model)
342        .into_iter()
343        .filter(|boundary| boundary.element == Some(element))
344        .collect()
345}