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}