Skip to main content

ifc_spatial/authoring/
boundary.rs

1//! Authoring space boundaries and path connections.
2//!
3//! A space boundary says which element bounds a space and how.
4//! The three levels refine each other: the base form states the
5//! boundary, the 1st level adds a parent, the 2nd level adds the
6//! boundary on the other side of the same element.
7//!
8//! `CorrectPhysOrVirt` ties the physical/virtual flag to the type
9//! of the bounding element, so the flag cannot be set independently
10//! of what it describes. This module reads the type name from the
11//! transaction or model.
12//!
13//! Records are laid out by attribute name from the model's declared
14//! release (#202). IFC2X3 TC1 declares only the base
15//! `IfcRelSpaceBoundary`, with a required `IfcRoot.OwnerHistory`, and an
16//! `IfcInternalOrExternalEnum` without the `EXTERNAL_*` refinements.
17
18use ifc_model::guid::Guid;
19use ifc_model::{Edit, Entity, EntityId, Model, Transaction, Value};
20
21use super::release::stage;
22use super::{invalid, SpatialAuthoringResult};
23
24/// Which space-boundary level to stage.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26#[non_exhaustive]
27pub enum BoundaryLevel {
28    /// `IfcRelSpaceBoundary`: the boundary alone.
29    Base,
30    /// `IfcRelSpaceBoundary1stLevel`: adds `ParentBoundary`.
31    First,
32    /// `IfcRelSpaceBoundary2ndLevel`: adds `CorrespondingBoundary`.
33    Second,
34}
35
36impl BoundaryLevel {
37    /// The entity type name for this level.
38    fn type_name(self) -> &'static str {
39        match self {
40            Self::Base => "IFCRELSPACEBOUNDARY",
41            Self::First => "IFCRELSPACEBOUNDARY1STLEVEL",
42            Self::Second => "IFCRELSPACEBOUNDARY2NDLEVEL",
43        }
44    }
45}
46
47/// Authored fields for a space boundary.
48///
49/// `#[non_exhaustive]`: build it with [`BoundaryDraft::new`] and the
50/// setters.
51#[derive(Debug, Clone, Copy)]
52#[non_exhaustive]
53pub struct BoundaryDraft<'a> {
54    /// `IfcRoot.Name`.
55    pub name: Option<&'a str>,
56    /// `IfcRoot.Description`.
57    pub description: Option<&'a str>,
58    /// `RelatingSpace`, an `IfcSpaceBoundarySelect`.
59    pub space: EntityId,
60    /// `RelatedBuildingElement`, an `IfcElement`.
61    pub element: EntityId,
62    /// `ConnectionGeometry`, if the boundary has a shape.
63    pub connection_geometry: Option<EntityId>,
64    /// `PhysicalOrVirtualBoundary`. Constrained by `CorrectPhysOrVirt`.
65    pub physical_or_virtual: &'a str,
66    /// `InternalOrExternalBoundary`.
67    pub internal_or_external: &'a str,
68    /// `ParentBoundary`. First and second levels only.
69    pub parent: Option<EntityId>,
70    /// `CorrespondingBoundary`. Second level only.
71    pub corresponding: Option<EntityId>,
72}
73
74impl<'a> BoundaryDraft<'a> {
75    /// A boundary of `space` formed by `element`, with the two enumeration
76    /// tokens the schema requires (`PhysicalOrVirtualBoundary`,
77    /// `InternalOrExternalBoundary`); every optional attribute unset.
78    #[must_use]
79    pub fn new(
80        space: EntityId,
81        element: EntityId,
82        physical_or_virtual: &'a str,
83        internal_or_external: &'a str,
84    ) -> Self {
85        Self {
86            name: None,
87            description: None,
88            space,
89            element,
90            connection_geometry: None,
91            physical_or_virtual,
92            internal_or_external,
93            parent: None,
94            corresponding: None,
95        }
96    }
97
98    /// Set `IfcRoot.Name`.
99    #[must_use]
100    pub fn name(mut self, value: &'a str) -> Self {
101        self.name = Some(value);
102        self
103    }
104
105    /// Set `IfcRoot.Description`.
106    #[must_use]
107    pub fn description(mut self, value: &'a str) -> Self {
108        self.description = Some(value);
109        self
110    }
111
112    /// Set `ConnectionGeometry`.
113    #[must_use]
114    pub fn connection_geometry(mut self, value: EntityId) -> Self {
115        self.connection_geometry = Some(value);
116        self
117    }
118
119    /// Set `ParentBoundary` (first and second levels only).
120    #[must_use]
121    pub fn parent(mut self, value: EntityId) -> Self {
122        self.parent = Some(value);
123        self
124    }
125
126    /// Set `CorrespondingBoundary` (second level only).
127    #[must_use]
128    pub fn corresponding(mut self, value: EntityId) -> Self {
129        self.corresponding = Some(value);
130        self
131    }
132}
133
134const PHYS_OR_VIRT: &[&str] = &["PHYSICAL", "VIRTUAL", "NOTDEFINED"];
135const INT_OR_EXT: &[&str] = &[
136    "INTERNAL",
137    "EXTERNAL",
138    "EXTERNAL_EARTH",
139    "EXTERNAL_WATER",
140    "EXTERNAL_FIRE",
141    "NOTDEFINED",
142];
143
144/// Resolve the type name of a staged or committed entity.
145fn type_name<'a>(tx: &'a Transaction, model: &'a Model, id: EntityId) -> Option<&'a str> {
146    tx.edits()
147        .iter()
148        .rev()
149        .find_map(|edit| match edit {
150            Edit::Create { id: staged, entity } if *staged == id => Some(entity.type_name.as_ref()),
151            _ => None,
152        })
153        .or_else(|| model.get(id).map(|entity| entity.type_name.as_ref()))
154}
155
156/// Stage an `IfcRelSpaceBoundary` at the requested level, in the model's
157/// declared release, with `OwnerHistory` unset.
158///
159/// The record is laid out by attribute name from the release's table. IFC4
160/// and IFC4X3 allow the unset `OwnerHistory`; IFC2X3 requires it, so an
161/// IFC2X3 model is refused with
162/// [`AuthoringRequired`](super::SpatialAuthoringError::AuthoringRequired):
163/// use [`create_space_boundary_with_owner_history`] there.
164///
165/// # Errors
166///
167/// Refuses a malformed GlobalId, an unknown enum token, a `parent`
168/// or `corresponding` reference on a level that does not declare the
169/// slot, and a physical/virtual flag that contradicts the bounding
170/// element (`CorrectPhysOrVirt`). Against the release: a header binding no
171/// single known release (`MultipleSchemas`, `UnsupportedSchema`), a level
172/// it does not declare (`EntityNotInSchema`: IFC2X3 has no 1st or 2nd
173/// level), a token it does not declare (`AuthoringValueType`), and the
174/// IFC2X3 `OwnerHistory` (`AuthoringRequired`). Nothing is staged on an
175/// error.
176pub fn create_space_boundary(
177    tx: &mut Transaction,
178    model: &Model,
179    level: BoundaryLevel,
180    global_id: &str,
181    draft: BoundaryDraft<'_>,
182) -> SpatialAuthoringResult<EntityId> {
183    let values = boundary_values(tx, model, level, global_id, draft)?;
184    stage(tx, model, level.type_name(), values, None)
185}
186
187/// [`create_space_boundary`] with a caller-supplied `IfcOwnerHistory`,
188/// which IFC2X3 requires.
189///
190/// # Errors
191///
192/// Those of [`create_space_boundary`] except the IFC2X3 refusal, and an
193/// `owner_history` that is neither in the model nor staged
194/// (`MissingReference`) or not an `IfcOwnerHistory`
195/// (`WrongReferenceType`). Nothing is staged on an error.
196pub fn create_space_boundary_with_owner_history(
197    tx: &mut Transaction,
198    model: &Model,
199    level: BoundaryLevel,
200    global_id: &str,
201    draft: BoundaryDraft<'_>,
202    owner_history: EntityId,
203) -> SpatialAuthoringResult<EntityId> {
204    let values = boundary_values(tx, model, level, global_id, draft)?;
205    stage(tx, model, level.type_name(), values, Some(owner_history))
206}
207
208/// Validate a boundary draft and name its values.
209fn boundary_values(
210    tx: &Transaction,
211    model: &Model,
212    level: BoundaryLevel,
213    global_id: &str,
214    draft: BoundaryDraft<'_>,
215) -> SpatialAuthoringResult<Vec<(&'static str, Value)>> {
216    let entity = level.type_name();
217    if Guid::parse(global_id).is_none() {
218        return Err(invalid(entity, "GlobalId", global_id));
219    }
220    let physical = draft.physical_or_virtual.to_ascii_uppercase();
221    if !PHYS_OR_VIRT.contains(&physical.as_str()) {
222        return Err(invalid(
223            entity,
224            "PhysicalOrVirtualBoundary",
225            draft.physical_or_virtual,
226        ));
227    }
228    let internal = draft.internal_or_external.to_ascii_uppercase();
229    if !INT_OR_EXT.contains(&internal.as_str()) {
230        return Err(invalid(
231            entity,
232            "InternalOrExternalBoundary",
233            draft.internal_or_external,
234        ));
235    }
236
237    // CorrectPhysOrVirt: the flag and the element must agree. A
238    // physical boundary cannot be bounded by a virtual element, and a
239    // virtual one only by a virtual element or an opening. NOTDEFINED
240    // is unconstrained.
241    let bounded_by = type_name(tx, model, draft.element)
242        .map(str::to_ascii_uppercase)
243        .unwrap_or_default();
244    let is_virtual = bounded_by == "IFCVIRTUALELEMENT";
245    let is_opening = bounded_by == "IFCOPENINGELEMENT";
246    let agrees = match physical.as_str() {
247        "PHYSICAL" => !is_virtual,
248        "VIRTUAL" => is_virtual || is_opening,
249        _ => true,
250    };
251    if !agrees {
252        return Err(invalid(
253            entity,
254            "PhysicalOrVirtualBoundary",
255            format!("{physical} does not agree with {bounded_by}"),
256        ));
257    }
258
259    // A slot the level does not declare cannot be filled: writing it
260    // would land past the end of the record.
261    if draft.parent.is_some() && level == BoundaryLevel::Base {
262        return Err(invalid(
263            entity,
264            "ParentBoundary",
265            "not declared at this level",
266        ));
267    }
268    if draft.corresponding.is_some() && level != BoundaryLevel::Second {
269        return Err(invalid(
270            entity,
271            "CorrespondingBoundary",
272            "not declared at this level",
273        ));
274    }
275
276    let text = |value: Option<&str>| value.map_or(Value::Null, |t| Value::Text(t.into()));
277    let reference = |value: Option<EntityId>| value.map_or(Value::Null, Value::Ref);
278    Ok(vec![
279        ("GlobalId", Value::Text(global_id.into())),
280        ("Name", text(draft.name)),
281        ("Description", text(draft.description)),
282        ("RelatingSpace", Value::Ref(draft.space)),
283        ("RelatedBuildingElement", Value::Ref(draft.element)),
284        ("ConnectionGeometry", reference(draft.connection_geometry)),
285        ("PhysicalOrVirtualBoundary", Value::Enum(physical.into())),
286        ("InternalOrExternalBoundary", Value::Enum(internal.into())),
287        ("ParentBoundary", reference(draft.parent)),
288        ("CorrespondingBoundary", reference(draft.corresponding)),
289    ])
290}
291
292const CONNECTION_TYPE: &[&str] = &["ATPATH", "ATSTART", "ATEND", "NOTDEFINED"];
293const PATH: &str = "IFCRELCONNECTSPATHELEMENTS";
294
295/// Stage an `IfcRelConnectsPathElements`.
296///
297/// Priorities rank which element wins where two path elements meet.
298/// `NormalizedRelatingPriorities` and its related twin bound every
299/// entry to 0..=100; an out-of-range entry is a ranking the schema
300/// cannot express, so it is refused rather than clamped.
301///
302/// IFC4 and IFC4X3 only: it writes their layout and leaves
303/// `OwnerHistory` `$`, which IFC2X3 requires. In IFC2X3 use
304/// [`connect_path_elements_with_owner_history`], which binds the model's
305/// declared release.
306///
307/// # Errors
308///
309/// Refuses a malformed GlobalId, an element connected to itself, an
310/// unknown connection-type token, and a priority outside 0..=100.
311pub fn connect_path_elements(
312    tx: &mut Transaction,
313    global_id: &str,
314    relating: EntityId,
315    related: EntityId,
316    priorities: (&[i64], &[i64]),
317    connection_types: (&str, &str),
318) -> SpatialAuthoringResult<EntityId> {
319    let (relating_token, related_token) =
320        check_path(global_id, relating, related, priorities, connection_types)?;
321    let (relating_priorities, related_priorities) = priorities;
322    let mut attributes = vec![Value::Null; 11];
323    attributes[0] = Value::Text(global_id.into());
324    attributes[5] = Value::Ref(relating);
325    attributes[6] = Value::Ref(related);
326    attributes[7] = integers(relating_priorities);
327    attributes[8] = integers(related_priorities);
328    attributes[9] = Value::Enum(related_token.into());
329    attributes[10] = Value::Enum(relating_token.into());
330    Ok(tx.create(Entity::new(PATH, attributes)))
331}
332
333/// [`connect_path_elements`] in the model's declared release, with a
334/// caller-supplied `IfcOwnerHistory`, which IFC2X3 requires.
335///
336/// # Errors
337///
338/// Those of [`connect_path_elements`], and the release and owner-history
339/// refusals of [`create_space_boundary`] and
340/// [`create_space_boundary_with_owner_history`]. Nothing is staged on an
341/// error.
342#[allow(clippy::too_many_arguments)]
343pub fn connect_path_elements_with_owner_history(
344    tx: &mut Transaction,
345    model: &Model,
346    global_id: &str,
347    relating: EntityId,
348    related: EntityId,
349    priorities: (&[i64], &[i64]),
350    connection_types: (&str, &str),
351    owner_history: EntityId,
352) -> SpatialAuthoringResult<EntityId> {
353    let (relating_token, related_token) =
354        check_path(global_id, relating, related, priorities, connection_types)?;
355    let (relating_priorities, related_priorities) = priorities;
356    let values = vec![
357        ("GlobalId", Value::Text(global_id.into())),
358        ("RelatingElement", Value::Ref(relating)),
359        ("RelatedElement", Value::Ref(related)),
360        ("RelatingPriorities", integers(relating_priorities)),
361        ("RelatedPriorities", integers(related_priorities)),
362        ("RelatedConnectionType", Value::Enum(related_token.into())),
363        ("RelatingConnectionType", Value::Enum(relating_token.into())),
364    ];
365    stage(tx, model, PATH, values, Some(owner_history))
366}
367
368fn integers(values: &[i64]) -> Value {
369    Value::List(values.iter().copied().map(Value::Integer).collect())
370}
371
372/// The checks of [`connect_path_elements`]; the upper-cased relating and
373/// related connection-type tokens.
374fn check_path(
375    global_id: &str,
376    relating: EntityId,
377    related: EntityId,
378    priorities: (&[i64], &[i64]),
379    connection_types: (&str, &str),
380) -> SpatialAuthoringResult<(String, String)> {
381    if Guid::parse(global_id).is_none() {
382        return Err(invalid(PATH, "GlobalId", global_id));
383    }
384    if relating == related {
385        return Err(invalid(PATH, "RelatedElement", "is the relating element"));
386    }
387    let (relating_priorities, related_priorities) = priorities;
388    for (values, attribute) in [
389        (relating_priorities, "RelatingPriorities"),
390        (related_priorities, "RelatedPriorities"),
391    ] {
392        if let Some(out) = values.iter().find(|value| !(0..=100).contains(*value)) {
393            return Err(invalid(PATH, attribute, out.to_string()));
394        }
395    }
396    let (relating_type, related_type) = connection_types;
397    let relating_token = relating_type.to_ascii_uppercase();
398    let related_token = related_type.to_ascii_uppercase();
399    for (token, attribute) in [
400        (&relating_token, "RelatingConnectionType"),
401        (&related_token, "RelatedConnectionType"),
402    ] {
403        if !CONNECTION_TYPE.contains(&token.as_str()) {
404            return Err(invalid(PATH, attribute, token.clone()));
405        }
406    }
407    Ok((relating_token, related_token))
408}