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