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 rather than the schema tables: the crate
12//! deliberately has no `ifc-schema` dependency.
13
14use ifc_model::guid::Guid;
15use ifc_model::{Edit, Entity, EntityId, Model, Transaction, Value};
16
17use super::{invalid, SpatialAuthoringResult};
18
19/// Which space-boundary level to stage.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum BoundaryLevel {
22    /// `IfcRelSpaceBoundary`: the boundary alone.
23    Base,
24    /// `IfcRelSpaceBoundary1stLevel`: adds `ParentBoundary`.
25    First,
26    /// `IfcRelSpaceBoundary2ndLevel`: adds `CorrespondingBoundary`.
27    Second,
28}
29
30impl BoundaryLevel {
31    /// The entity type name for this level.
32    fn type_name(self) -> &'static str {
33        match self {
34            Self::Base => "IFCRELSPACEBOUNDARY",
35            Self::First => "IFCRELSPACEBOUNDARY1STLEVEL",
36            Self::Second => "IFCRELSPACEBOUNDARY2NDLEVEL",
37        }
38    }
39
40    /// Total attribute count, including inherited.
41    fn arity(self) -> usize {
42        match self {
43            Self::Base => 9,
44            Self::First => 10,
45            Self::Second => 11,
46        }
47    }
48}
49
50/// Authored fields for a space boundary.
51#[derive(Debug, Clone, Copy)]
52pub struct BoundaryDraft<'a> {
53    /// `IfcRoot.Name`.
54    pub name: Option<&'a str>,
55    /// `IfcRoot.Description`.
56    pub description: Option<&'a str>,
57    /// `RelatingSpace`, an `IfcSpaceBoundarySelect`.
58    pub space: EntityId,
59    /// `RelatedBuildingElement`, an `IfcElement`.
60    pub element: EntityId,
61    /// `ConnectionGeometry`, if the boundary has a shape.
62    pub connection_geometry: Option<EntityId>,
63    /// `PhysicalOrVirtualBoundary`. Constrained by `CorrectPhysOrVirt`.
64    pub physical_or_virtual: &'a str,
65    /// `InternalOrExternalBoundary`.
66    pub internal_or_external: &'a str,
67    /// `ParentBoundary`. First and second levels only.
68    pub parent: Option<EntityId>,
69    /// `CorrespondingBoundary`. Second level only.
70    pub corresponding: Option<EntityId>,
71}
72
73const PHYS_OR_VIRT: &[&str] = &["PHYSICAL", "VIRTUAL", "NOTDEFINED"];
74const INT_OR_EXT: &[&str] = &[
75    "INTERNAL",
76    "EXTERNAL",
77    "EXTERNAL_EARTH",
78    "EXTERNAL_WATER",
79    "EXTERNAL_FIRE",
80    "NOTDEFINED",
81];
82
83/// Resolve the type name of a staged or committed entity.
84fn type_name<'a>(tx: &'a Transaction, model: &'a Model, id: EntityId) -> Option<&'a str> {
85    tx.edits()
86        .iter()
87        .rev()
88        .find_map(|edit| match edit {
89            Edit::Create { id: staged, entity } if *staged == id => Some(entity.type_name.as_ref()),
90            _ => None,
91        })
92        .or_else(|| model.get(id).map(|entity| entity.type_name.as_ref()))
93}
94
95/// Stage an `IfcRelSpaceBoundary` at the requested level.
96///
97/// # Errors
98///
99/// Refuses a malformed GlobalId, an unknown enum token, a `parent`
100/// or `corresponding` reference on a level that does not declare the
101/// slot, and a physical/virtual flag that contradicts the bounding
102/// element (`CorrectPhysOrVirt`).
103pub fn create_space_boundary(
104    tx: &mut Transaction,
105    model: &Model,
106    level: BoundaryLevel,
107    global_id: &str,
108    draft: BoundaryDraft<'_>,
109) -> SpatialAuthoringResult<EntityId> {
110    let entity = level.type_name();
111    if Guid::parse(global_id).is_none() {
112        return Err(invalid(entity, "GlobalId", global_id));
113    }
114    let physical = draft.physical_or_virtual.to_ascii_uppercase();
115    if !PHYS_OR_VIRT.contains(&physical.as_str()) {
116        return Err(invalid(
117            entity,
118            "PhysicalOrVirtualBoundary",
119            draft.physical_or_virtual,
120        ));
121    }
122    let internal = draft.internal_or_external.to_ascii_uppercase();
123    if !INT_OR_EXT.contains(&internal.as_str()) {
124        return Err(invalid(
125            entity,
126            "InternalOrExternalBoundary",
127            draft.internal_or_external,
128        ));
129    }
130
131    // CorrectPhysOrVirt: the flag and the element must agree. A
132    // physical boundary cannot be bounded by a virtual element, and a
133    // virtual one only by a virtual element or an opening. NOTDEFINED
134    // is unconstrained.
135    let bounded_by = type_name(tx, model, draft.element)
136        .map(str::to_ascii_uppercase)
137        .unwrap_or_default();
138    let is_virtual = bounded_by == "IFCVIRTUALELEMENT";
139    let is_opening = bounded_by == "IFCOPENINGELEMENT";
140    let agrees = match physical.as_str() {
141        "PHYSICAL" => !is_virtual,
142        "VIRTUAL" => is_virtual || is_opening,
143        _ => true,
144    };
145    if !agrees {
146        return Err(invalid(
147            entity,
148            "PhysicalOrVirtualBoundary",
149            format!("{physical} does not agree with {bounded_by}"),
150        ));
151    }
152
153    // A slot the level does not declare cannot be filled: writing it
154    // would land past the end of the record.
155    if draft.parent.is_some() && level == BoundaryLevel::Base {
156        return Err(invalid(
157            entity,
158            "ParentBoundary",
159            "not declared at this level",
160        ));
161    }
162    if draft.corresponding.is_some() && level != BoundaryLevel::Second {
163        return Err(invalid(
164            entity,
165            "CorrespondingBoundary",
166            "not declared at this level",
167        ));
168    }
169
170    let mut attributes = vec![Value::Null; level.arity()];
171    attributes[0] = Value::Text(global_id.into());
172    attributes[2] = draft.name.map_or(Value::Null, |t| Value::Text(t.into()));
173    attributes[3] = draft
174        .description
175        .map_or(Value::Null, |t| Value::Text(t.into()));
176    attributes[4] = Value::Ref(draft.space);
177    attributes[5] = Value::Ref(draft.element);
178    attributes[6] = draft.connection_geometry.map_or(Value::Null, Value::Ref);
179    attributes[7] = Value::Enum(physical.into());
180    attributes[8] = Value::Enum(internal.into());
181    if let Some(parent) = draft.parent {
182        attributes[9] = Value::Ref(parent);
183    }
184    if let Some(corresponding) = draft.corresponding {
185        attributes[10] = Value::Ref(corresponding);
186    }
187    Ok(tx.create(Entity::new(entity, attributes)))
188}
189
190const CONNECTION_TYPE: &[&str] = &["ATPATH", "ATSTART", "ATEND", "NOTDEFINED"];
191
192/// Stage an `IfcRelConnectsPathElements`.
193///
194/// Priorities rank which element wins where two path elements meet.
195/// `NormalizedRelatingPriorities` and its related twin bound every
196/// entry to 0..=100; an out-of-range entry is a ranking the schema
197/// cannot express, so it is refused rather than clamped.
198///
199/// # Errors
200///
201/// Refuses a malformed GlobalId, an element connected to itself, an
202/// unknown connection-type token, and a priority outside 0..=100.
203pub fn connect_path_elements(
204    tx: &mut Transaction,
205    global_id: &str,
206    relating: EntityId,
207    related: EntityId,
208    priorities: (&[i64], &[i64]),
209    connection_types: (&str, &str),
210) -> SpatialAuthoringResult<EntityId> {
211    const ENTITY: &str = "IFCRELCONNECTSPATHELEMENTS";
212    if Guid::parse(global_id).is_none() {
213        return Err(invalid(ENTITY, "GlobalId", global_id));
214    }
215    if relating == related {
216        return Err(invalid(ENTITY, "RelatedElement", "is the relating element"));
217    }
218    let (relating_priorities, related_priorities) = priorities;
219    for (values, attribute) in [
220        (relating_priorities, "RelatingPriorities"),
221        (related_priorities, "RelatedPriorities"),
222    ] {
223        if let Some(out) = values.iter().find(|value| !(0..=100).contains(*value)) {
224            return Err(invalid(ENTITY, attribute, out.to_string()));
225        }
226    }
227    let (relating_type, related_type) = connection_types;
228    let relating_token = relating_type.to_ascii_uppercase();
229    let related_token = related_type.to_ascii_uppercase();
230    for (token, attribute) in [
231        (&relating_token, "RelatingConnectionType"),
232        (&related_token, "RelatedConnectionType"),
233    ] {
234        if !CONNECTION_TYPE.contains(&token.as_str()) {
235            return Err(invalid(ENTITY, attribute, token.clone()));
236        }
237    }
238
239    let integers =
240        |values: &[i64]| Value::List(values.iter().copied().map(Value::Integer).collect());
241    let mut attributes = vec![Value::Null; 11];
242    attributes[0] = Value::Text(global_id.into());
243    attributes[5] = Value::Ref(relating);
244    attributes[6] = Value::Ref(related);
245    attributes[7] = integers(relating_priorities);
246    attributes[8] = integers(related_priorities);
247    attributes[9] = Value::Enum(related_token.into());
248    attributes[10] = Value::Enum(relating_token.into());
249    Ok(tx.create(Entity::new(ENTITY, attributes)))
250}