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