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}