Skip to main content

ifc_resource/author/
contact.rs

1//! Authoring roles and addresses.
2//!
3//! Both addresses carry a "say something" rule: `IfcPostalAddress.WR1`
4//! and `IfcTelecomAddress.MinimumDataProvided` each require at least
5//! one contact field. An address with every slot unset parses and
6//! reaches nobody, so it is refused rather than written.
7//!
8//! None of these are `IfcRoot` subtypes: they have no GlobalId and are
9//! referenced from the person, organisation or actor that owns them.
10
11use ifc_model::{EntityId, Value};
12
13use crate::author::editor::{build_entity, text, validate_enum, ResourceEditor};
14use crate::error::{ResourceError, ResourceResult};
15
16/// Draft for one `IfcActorRole`.
17#[derive(Debug, Clone, Copy)]
18pub struct ActorRoleDraft<'a> {
19    /// `Role`, an `IfcRoleEnum` token.
20    pub role: &'a str,
21    /// `UserDefinedRole`. Required when `role` is `USERDEFINED`.
22    pub user_defined_role: Option<&'a str>,
23    /// `Description`.
24    pub description: Option<&'a str>,
25}
26
27/// Draft for one `IfcPostalAddress`.
28#[derive(Debug, Clone, Copy, Default)]
29pub struct PostalAddressDraft<'a> {
30    /// `Purpose`, an `IfcAddressTypeEnum` token.
31    pub purpose: Option<&'a str>,
32    /// `Description`.
33    pub description: Option<&'a str>,
34    /// `UserDefinedPurpose`. Required when `purpose` is `USERDEFINED`.
35    pub user_defined_purpose: Option<&'a str>,
36    /// `InternalLocation`: a room or desk within the building.
37    pub internal_location: Option<&'a str>,
38    /// `PostalBox`.
39    pub postal_box: Option<&'a str>,
40    /// `Town`.
41    pub town: Option<&'a str>,
42    /// `Region`.
43    pub region: Option<&'a str>,
44    /// `PostalCode`.
45    pub postal_code: Option<&'a str>,
46    /// `Country`.
47    pub country: Option<&'a str>,
48}
49
50/// Draft for one `IfcTelecomAddress`.
51#[derive(Debug, Clone, Copy, Default)]
52pub struct TelecomAddressDraft<'a> {
53    /// `Purpose`, an `IfcAddressTypeEnum` token.
54    pub purpose: Option<&'a str>,
55    /// `Description`.
56    pub description: Option<&'a str>,
57    /// `UserDefinedPurpose`. Required when `purpose` is `USERDEFINED`.
58    pub user_defined_purpose: Option<&'a str>,
59    /// `PagerNumber`.
60    pub pager_number: Option<&'a str>,
61    /// `WWWHomePageURL`.
62    pub www_home_page_url: Option<&'a str>,
63}
64
65/// The `LIST [1:?]` attributes of an `IfcTelecomAddress`.
66///
67/// Each is optional as a whole but bounded `[1:?]` when present, so an
68/// empty slice is written as absent rather than as an empty list.
69#[derive(Debug, Clone, Copy, Default)]
70pub struct TelecomLists<'a> {
71    /// `TelephoneNumbers`.
72    pub telephone: &'a [&'a str],
73    /// `FacsimileNumbers`.
74    pub facsimile: &'a [&'a str],
75    /// `ElectronicMailAddresses`.
76    pub email: &'a [&'a str],
77    /// `MessagingIDs`.
78    pub messaging: &'a [&'a str],
79}
80
81/// A `LIST [1:?]` written as absent when empty.
82fn optional_list(values: &[&str]) -> Option<Value> {
83    if values.is_empty() {
84        return None;
85    }
86    Some(Value::List(
87        values.iter().map(|v| Value::Text((*v).into())).collect(),
88    ))
89}
90
91impl ResourceEditor<'_> {
92    /// Stage an `IfcActorRole`.
93    ///
94    /// # Errors
95    ///
96    /// Refuses a token outside `IfcRoleEnum` and `USERDEFINED`
97    /// without a `user_defined_role` (WR1).
98    pub fn create_actor_role(&mut self, draft: ActorRoleDraft<'_>) -> ResourceResult<EntityId> {
99        const ENTITY: &str = "IfcActorRole";
100        validate_enum(self.schema, ENTITY, "Role", draft.role)?;
101        if draft.role.eq_ignore_ascii_case("USERDEFINED")
102            && draft
103                .user_defined_role
104                .is_none_or(|text| text.trim().is_empty())
105        {
106            return Err(ResourceError::SemanticViolation {
107                entity: None,
108                rule: "WR1",
109            });
110        }
111        let entity = build_entity(
112            self.schema,
113            ENTITY,
114            &[
115                ("Role", Some(Value::Enum(draft.role.into()))),
116                ("UserDefinedRole", draft.user_defined_role.map(text)),
117                ("Description", draft.description.map(text)),
118            ],
119        )?;
120        self.commit_create(entity)
121    }
122
123    /// Stage an `IfcPostalAddress`.
124    ///
125    /// `address_lines` is `LIST [1:?]` when present.
126    ///
127    /// # Errors
128    ///
129    /// Refuses a `purpose` outside `IfcAddressTypeEnum`, `USERDEFINED`
130    /// without a `user_defined_purpose`, and an address with no
131    /// locating field at all (WR1).
132    pub fn create_postal_address(
133        &mut self,
134        draft: PostalAddressDraft<'_>,
135        address_lines: &[&str],
136    ) -> ResourceResult<EntityId> {
137        const ENTITY: &str = "IfcPostalAddress";
138        self.validate_address_purpose(ENTITY, draft.purpose, draft.user_defined_purpose)?;
139        // WR1: an address that locates nothing reaches nobody.
140        let locates = !address_lines.is_empty()
141            || draft.internal_location.is_some()
142            || draft.postal_box.is_some()
143            || draft.postal_code.is_some()
144            || draft.town.is_some()
145            || draft.region.is_some()
146            || draft.country.is_some();
147        if !locates {
148            return Err(ResourceError::SemanticViolation {
149                entity: None,
150                rule: "WR1",
151            });
152        }
153        let entity = build_entity(
154            self.schema,
155            ENTITY,
156            &[
157                ("Purpose", draft.purpose.map(|p| Value::Enum(p.into()))),
158                ("Description", draft.description.map(text)),
159                ("UserDefinedPurpose", draft.user_defined_purpose.map(text)),
160                ("InternalLocation", draft.internal_location.map(text)),
161                ("AddressLines", optional_list(address_lines)),
162                ("PostalBox", draft.postal_box.map(text)),
163                ("Town", draft.town.map(text)),
164                ("Region", draft.region.map(text)),
165                ("PostalCode", draft.postal_code.map(text)),
166                ("Country", draft.country.map(text)),
167            ],
168        )?;
169        self.commit_create(entity)
170    }
171
172    /// Stage an `IfcTelecomAddress`.
173    ///
174    /// # Errors
175    ///
176    /// Refuses a `purpose` outside `IfcAddressTypeEnum`, `USERDEFINED`
177    /// without a `user_defined_purpose`, and an address carrying no
178    /// contact route at all (MinimumDataProvided).
179    pub fn create_telecom_address(
180        &mut self,
181        draft: TelecomAddressDraft<'_>,
182        lists: TelecomLists<'_>,
183    ) -> ResourceResult<EntityId> {
184        const ENTITY: &str = "IfcTelecomAddress";
185        self.validate_address_purpose(ENTITY, draft.purpose, draft.user_defined_purpose)?;
186        // MinimumDataProvided: at least one way to reach someone.
187        let reachable = !lists.telephone.is_empty()
188            || !lists.facsimile.is_empty()
189            || !lists.email.is_empty()
190            || !lists.messaging.is_empty()
191            || draft.pager_number.is_some()
192            || draft.www_home_page_url.is_some();
193        if !reachable {
194            return Err(ResourceError::SemanticViolation {
195                entity: None,
196                rule: "MinimumDataProvided",
197            });
198        }
199        let entity = build_entity(
200            self.schema,
201            ENTITY,
202            &[
203                ("Purpose", draft.purpose.map(|p| Value::Enum(p.into()))),
204                ("Description", draft.description.map(text)),
205                ("UserDefinedPurpose", draft.user_defined_purpose.map(text)),
206                ("TelephoneNumbers", optional_list(lists.telephone)),
207                ("FacsimileNumbers", optional_list(lists.facsimile)),
208                ("PagerNumber", draft.pager_number.map(text)),
209                ("ElectronicMailAddresses", optional_list(lists.email)),
210                ("WWWHomePageURL", draft.www_home_page_url.map(text)),
211                ("MessagingIDs", optional_list(lists.messaging)),
212            ],
213        )?;
214        self.commit_create(entity)
215    }
216
217    /// Both addresses share `Purpose` and its USERDEFINED rule.
218    fn validate_address_purpose(
219        &self,
220        entity: &'static str,
221        purpose: Option<&str>,
222        user_defined: Option<&str>,
223    ) -> ResourceResult<()> {
224        let Some(token) = purpose else {
225            return Ok(());
226        };
227        validate_enum(self.schema, entity, "Purpose", token)?;
228        if token.eq_ignore_ascii_case("USERDEFINED")
229            && user_defined.is_none_or(|text| text.trim().is_empty())
230        {
231            return Err(ResourceError::SemanticViolation {
232                entity: None,
233                rule: "USERDEFINED_REQUIRES_USER_DEFINED_PURPOSE",
234            });
235        }
236        Ok(())
237    }
238
239    /// Stage an `IfcOrganizationRelationship`.
240    ///
241    /// # Errors
242    ///
243    /// Refuses a reference that is not an `IfcOrganization`, an empty
244    /// related set (`SET [1:?]`), and an organisation related to itself.
245    pub fn create_organization_relationship(
246        &mut self,
247        name: Option<&str>,
248        description: Option<&str>,
249        relating: EntityId,
250        related: &[EntityId],
251    ) -> ResourceResult<EntityId> {
252        const ENTITY: &str = "IfcOrganizationRelationship";
253        self.check_reference(
254            relating,
255            "RelatingOrganization",
256            "IfcOrganization",
257            relating,
258        )?;
259        if related.is_empty() {
260            return Err(ResourceError::InvalidDraft {
261                entity_type: ENTITY,
262                attribute: "RelatedOrganizations",
263                expected: "at least one organisation, per SET [1:?]",
264            });
265        }
266        for organization in related {
267            self.check_reference(
268                *organization,
269                "RelatedOrganizations",
270                "IfcOrganization",
271                *organization,
272            )?;
273            // A relationship from an organisation to itself states
274            // nothing and makes the graph cyclic at depth one.
275            if *organization == relating {
276                return Err(ResourceError::SemanticViolation {
277                    entity: None,
278                    rule: "RELATING_ORGANIZATION_IS_NOT_RELATED",
279                });
280            }
281        }
282        let entity = build_entity(
283            self.schema,
284            ENTITY,
285            &[
286                ("Name", name.map(text)),
287                ("Description", description.map(text)),
288                ("RelatingOrganization", Some(Value::Ref(relating))),
289                (
290                    "RelatedOrganizations",
291                    Some(Value::List(
292                        related.iter().copied().map(Value::Ref).collect(),
293                    )),
294                ),
295            ],
296        )?;
297        self.commit_create(entity)
298    }
299}