Skip to main content

ifc_resource/
view.rs

1//! Shared schema-resolved borrowed view primitives.
2//!
3//! # Supported releases
4//!
5//! IFC4 ADD2 TC1 and IFC4X3 ADD2 declare identical attribute layouts for
6//! every entity this crate projects (checked against the normative `.exp`
7//! files); IFC4X3 only adds `IfcQuantityNumber`, which is not one of the six
8//! projected simple-quantity kinds. One code path therefore serves both.
9//!
10//! IFC2X3 is refused with `UnsupportedSchema` on purpose: it declares no
11//! `IfcConstructionResourceType`, no `IfcResourceTime`, and no
12//! `PredefinedType` on `IfcCrewResource` or
13//! `IfcConstructionEquipmentResource`, so there is no normative behavior to
14//! project. A reduced IFC2X3 path needs a design decision first, not a
15//! fallback here.
16
17use std::collections::HashSet;
18
19use ifc_model::{Entity, EntityId, Model, Value};
20use ifc_schema::{ifc4, ifc4x3, Schema, SchemaVersion, TypeKind};
21
22use crate::error::{ResourceError, ResourceResult};
23use crate::{ConstructionResource, ResourceTime};
24
25#[derive(Debug, Clone, Copy)]
26/// Borrowed, schema-resolved entry point for the bounded IFC4 resource
27/// slice: pairs a model with the schema selected for it and exposes
28/// per-entity projection and query methods.
29pub struct ResourceView<'m, 's> {
30    pub(crate) model: &'m Model,
31    pub(crate) schema: &'s Schema,
32}
33
34impl<'m, 's> ResourceView<'m, 's> {
35    /// Builds a view over `model` using an explicit `schema`, failing if
36    /// `schema` is not IFC4 or IFC4X3, or does not match the model's
37    /// declared `FILE_SCHEMA` token.
38    pub fn new(model: &'m Model, schema: &'s Schema) -> ResourceResult<Self> {
39        let Some(version) = schema.version() else {
40            return Err(ResourceError::UnsupportedSchema {
41                token: schema.name().to_owned(),
42            });
43        };
44        if !matches!(version, SchemaVersion::Ifc4 | SchemaVersion::Ifc4x3) {
45            return Err(ResourceError::UnsupportedSchema {
46                token: schema.name().to_owned(),
47            });
48        }
49        match model.header().schema.as_slice() {
50            [] => {}
51            [token] if SchemaVersion::from_header_token(token) == Some(version) => {}
52            [token] => {
53                return Err(ResourceError::UnsupportedSchema {
54                    token: token.clone(),
55                });
56            }
57            tokens => {
58                return Err(ResourceError::AmbiguousSchema {
59                    tokens: tokens.to_vec(),
60                });
61            }
62        }
63        Ok(Self { model, schema })
64    }
65
66    /// The bundled schema this view resolves attributes against.
67    #[must_use]
68    pub fn schema(&self) -> &'s Schema {
69        self.schema
70    }
71
72    /// Projects a concrete `IfcConstructionResource` occurrence by entity
73    /// id.
74    pub fn resource(&self, id: EntityId) -> ResourceResult<ConstructionResource<'m, 's>> {
75        ConstructionResource::from_record(self.record(id, "IfcConstructionResource")?)
76    }
77
78    /// Projects an `IfcResourceTime` by entity id.
79    pub fn resource_time(&self, id: EntityId) -> ResourceResult<ResourceTime<'m, 's>> {
80        Ok(ResourceTime::from_record(
81            self.record(id, "IfcResourceTime")?,
82        ))
83    }
84
85    pub(crate) fn record(
86        &self,
87        id: EntityId,
88        expected: &'static str,
89    ) -> ResourceResult<Record<'m, 's>> {
90        Record::new(self.model, self.schema, id, expected)
91    }
92
93    pub(crate) fn ids_of_ancestor(&self, ancestor: &str) -> Vec<EntityId> {
94        self.model
95            .iter()
96            .filter_map(|(id, entity)| self.schema.is_a(&entity.type_name, ancestor).then_some(id))
97            .collect()
98    }
99}
100
101impl<'m> ResourceView<'m, 'static> {
102    /// Selects the bundled schema matching the model's declared
103    /// `FILE_SCHEMA` token (IFC4 ADD2 TC1 or IFC4X3 ADD2), failing if the
104    /// header names no schema, more than one, or an unsupported one.
105    pub fn for_model(model: &'m Model) -> ResourceResult<Self> {
106        let token = match model.header().schema.as_slice() {
107            [] => return Err(ResourceError::MissingSchema),
108            [token] => token,
109            tokens => {
110                return Err(ResourceError::AmbiguousSchema {
111                    tokens: tokens.to_vec(),
112                });
113            }
114        };
115        let version = SchemaVersion::from_header_token(token).ok_or_else(|| {
116            ResourceError::UnsupportedSchema {
117                token: token.clone(),
118            }
119        })?;
120        let schema = match version {
121            SchemaVersion::Ifc4 => ifc4(),
122            SchemaVersion::Ifc4x3 => ifc4x3(),
123            // IFC2X3 predates these layouts; IFC4X1 and IFC4X2 are bundled
124            // by ifc-schema but not verified here. Refused, never aliased.
125            _ => {
126                return Err(ResourceError::UnsupportedSchema {
127                    token: token.clone(),
128                });
129            }
130            // A release added to `SchemaVersion` later has no reviewed
131            // resource layout: refused rather than read with another's.
132            #[allow(unreachable_patterns)]
133            _ => {
134                return Err(ResourceError::UnsupportedSchema {
135                    token: token.clone(),
136                });
137            }
138        };
139        Self::new(model, schema)
140    }
141}
142
143pub(crate) fn validate_object_assignment(
144    model: &Model,
145    schema: &Schema,
146    relation: Option<EntityId>,
147    related_objects_type: Option<&str>,
148    related_objects: &[EntityId],
149) -> ResourceResult<()> {
150    let Some(category) = related_objects_type else {
151        return Ok(());
152    };
153    let expected = if category.eq_ignore_ascii_case("NOTDEFINED") {
154        return Ok(());
155    } else if category.eq_ignore_ascii_case("PRODUCT") {
156        "IfcProduct"
157    } else if category.eq_ignore_ascii_case("PROCESS") {
158        "IfcProcess"
159    } else if category.eq_ignore_ascii_case("CONTROL") {
160        "IfcControl"
161    } else if category.eq_ignore_ascii_case("RESOURCE") {
162        "IfcResource"
163    } else if category.eq_ignore_ascii_case("ACTOR") {
164        "IfcActor"
165    } else if category.eq_ignore_ascii_case("GROUP") {
166        "IfcGroup"
167    } else if category.eq_ignore_ascii_case("PROJECT") {
168        "IfcProject"
169    } else {
170        return Err(ResourceError::InvalidEnumeration {
171            entity: relation,
172            attribute: "RelatedObjectsType",
173            value: category.to_owned(),
174        });
175    };
176    if related_objects.iter().any(|target| {
177        model
178            .get(*target)
179            .is_none_or(|entity| !schema.is_a(&entity.type_name, expected))
180    }) {
181        return Err(ResourceError::SemanticViolation {
182            entity: relation,
183            rule: "IfcRelAssigns.WR1_IfcCorrectObjectAssignment",
184        });
185    }
186    Ok(())
187}
188
189#[derive(Debug, Clone, Copy)]
190pub(crate) struct Record<'m, 's> {
191    pub(crate) model: &'m Model,
192    pub(crate) schema: &'s Schema,
193    pub(crate) id: EntityId,
194    pub(crate) entity: &'m Entity,
195}
196
197impl<'m, 's> Record<'m, 's> {
198    pub(crate) fn new(
199        model: &'m Model,
200        schema: &'s Schema,
201        id: EntityId,
202        expected: &'static str,
203    ) -> ResourceResult<Self> {
204        let entity = model.get(id).ok_or(ResourceError::EntityNotFound { id })?;
205        if !schema.is_a(&entity.type_name, expected) {
206            return Err(ResourceError::WrongType {
207                id,
208                expected,
209                actual: entity.type_name.to_string(),
210            });
211        }
212        Ok(Self {
213            model,
214            schema,
215            id,
216            entity,
217        })
218    }
219
220    fn slot(&self, attribute: &'static str) -> ResourceResult<usize> {
221        self.schema
222            .attribute_names(&self.entity.type_name)
223            .iter()
224            .position(|name| name.eq_ignore_ascii_case(attribute))
225            .ok_or(ResourceError::MissingAttribute {
226                entity: self.id,
227                attribute,
228            })
229    }
230
231    pub(crate) fn value(&self, attribute: &'static str) -> ResourceResult<&'m Value> {
232        self.entity
233            .attributes
234            .get(self.slot(attribute)?)
235            .ok_or(ResourceError::MissingAttribute {
236                entity: self.id,
237                attribute,
238            })
239    }
240
241    pub(crate) fn required_text(&self, attribute: &'static str) -> ResourceResult<&'m str> {
242        match self.value(attribute)?.unwrap_typed() {
243            Value::Text(value) => Ok(value),
244            _ => Err(self.invalid(attribute, "text")),
245        }
246    }
247
248    pub(crate) fn optional_text_list(
249        &self,
250        attribute: &'static str,
251        minimum: usize,
252    ) -> ResourceResult<Vec<&'m str>> {
253        let values = match self.value(attribute)?.unwrap_typed() {
254            Value::Null | Value::Derived => return Ok(Vec::new()),
255            Value::List(values) => values,
256            _ => return Err(self.invalid(attribute, "aggregate of text or null")),
257        };
258        if values.len() < minimum {
259            return Err(ResourceError::InvalidCardinality {
260                entity: self.id,
261                attribute,
262                minimum,
263                actual: values.len(),
264            });
265        }
266        values
267            .iter()
268            .map(|value| match value.unwrap_typed() {
269                Value::Text(value) => Ok(value.as_ref()),
270                _ => Err(self.invalid(attribute, "aggregate of text or null")),
271            })
272            .collect()
273    }
274
275    pub(crate) fn optional_text(&self, attribute: &'static str) -> ResourceResult<Option<&'m str>> {
276        match self.value(attribute)?.unwrap_typed() {
277            Value::Null | Value::Derived => Ok(None),
278            Value::Text(value) => Ok(Some(value)),
279            _ => Err(self.invalid(attribute, "text or null")),
280        }
281    }
282
283    pub(crate) fn optional_bool(&self, attribute: &'static str) -> ResourceResult<Option<bool>> {
284        match self.value(attribute)?.unwrap_typed() {
285            Value::Null | Value::Derived => Ok(None),
286            Value::Bool(value) => Ok(Some(*value)),
287            _ => Err(self.invalid(attribute, "boolean or null")),
288        }
289    }
290
291    pub(crate) fn optional_positive_number(
292        &self,
293        attribute: &'static str,
294    ) -> ResourceResult<Option<f64>> {
295        let value = match self.value(attribute)?.unwrap_typed() {
296            Value::Null | Value::Derived => return Ok(None),
297            Value::Integer(value) => *value as f64,
298            Value::Real(value) => *value,
299            _ => return Err(self.invalid(attribute, "finite positive number or null")),
300        };
301        if !value.is_finite() || value <= 0.0 {
302            return Err(self.invalid(attribute, "finite positive number or null"));
303        }
304        Ok(Some(value))
305    }
306
307    pub(crate) fn required_non_negative_number(
308        &self,
309        attribute: &'static str,
310    ) -> ResourceResult<f64> {
311        let value = match self.value(attribute)?.unwrap_typed() {
312            Value::Integer(value) => *value as f64,
313            Value::Real(value) => *value,
314            _ => return Err(self.invalid(attribute, "finite non-negative number")),
315        };
316        if !value.is_finite() || value < 0.0 {
317            return Err(self.invalid(attribute, "finite non-negative number"));
318        }
319        Ok(value)
320    }
321
322    pub(crate) fn required_enum(&self, attribute: &'static str) -> ResourceResult<&'m str> {
323        let value = match self.value(attribute)?.unwrap_typed() {
324            Value::Enum(value) => value,
325            _ => return Err(self.invalid(attribute, "declared enumeration")),
326        };
327        if !self.declares_enum_member(attribute, value) {
328            return Err(ResourceError::InvalidEnumeration {
329                entity: Some(self.id),
330                attribute,
331                value: value.to_string(),
332            });
333        }
334        Ok(value)
335    }
336
337    pub(crate) fn optional_enum(&self, attribute: &'static str) -> ResourceResult<Option<&'m str>> {
338        let value = match self.value(attribute)?.unwrap_typed() {
339            Value::Null | Value::Derived => return Ok(None),
340            Value::Enum(value) => value,
341            _ => return Err(self.invalid(attribute, "declared enumeration or null")),
342        };
343        if !self.declares_enum_member(attribute, value) {
344            return Err(ResourceError::InvalidEnumeration {
345                entity: Some(self.id),
346                attribute,
347                value: value.to_string(),
348            });
349        }
350        Ok(Some(value))
351    }
352
353    fn declares_enum_member(&self, attribute: &str, value: &str) -> bool {
354        let declarations = self.schema.attributes(&self.entity.type_name);
355        let Some(declaration) = declarations
356            .iter()
357            .find(|candidate| candidate.name.eq_ignore_ascii_case(attribute))
358        else {
359            return false;
360        };
361        let mut type_name = declaration.type_name.as_str();
362        for _ in 0..16 {
363            let Some(definition) = self.schema.type_def(type_name) else {
364                return false;
365            };
366            match &definition.kind {
367                TypeKind::Enumeration(members) => {
368                    return members
369                        .iter()
370                        .any(|member| member.eq_ignore_ascii_case(value));
371                }
372                TypeKind::Defined(alias) => type_name = alias,
373                TypeKind::Select(_) => return false,
374                _ => return false,
375            }
376        }
377        false
378    }
379
380    pub(crate) fn optional_ref(
381        &self,
382        attribute: &'static str,
383        expected: &'static str,
384    ) -> ResourceResult<Option<EntityId>> {
385        let target = match self.value(attribute)?.unwrap_typed() {
386            Value::Null | Value::Derived => return Ok(None),
387            Value::Ref(target) => *target,
388            _ => return Err(self.invalid(attribute, "entity reference or null")),
389        };
390        self.check_reference(attribute, target, &[expected], expected)?;
391        Ok(Some(target))
392    }
393
394    pub(crate) fn required_ref(
395        &self,
396        attribute: &'static str,
397        expected: &'static str,
398    ) -> ResourceResult<EntityId> {
399        let Value::Ref(target) = self.value(attribute)?.unwrap_typed() else {
400            return Err(self.invalid(attribute, "entity reference"));
401        };
402        self.check_reference(attribute, *target, &[expected], expected)?;
403        Ok(*target)
404    }
405
406    pub(crate) fn optional_ref_select(
407        &self,
408        attribute: &'static str,
409        expected: &'static str,
410        members: &[&str],
411    ) -> ResourceResult<Option<EntityId>> {
412        let target = match self.value(attribute)?.unwrap_typed() {
413            Value::Null | Value::Derived => return Ok(None),
414            Value::Ref(target) => *target,
415            _ => return Err(self.invalid(attribute, "entity reference or null")),
416        };
417        self.check_reference(attribute, target, members, expected)?;
418        Ok(Some(target))
419    }
420
421    pub(crate) fn required_ref_select(
422        &self,
423        attribute: &'static str,
424        expected: &'static str,
425        members: &[&str],
426    ) -> ResourceResult<EntityId> {
427        let Value::Ref(target) = self.value(attribute)?.unwrap_typed() else {
428            return Err(self.invalid(attribute, "entity reference"));
429        };
430        self.check_reference(attribute, *target, members, expected)?;
431        Ok(*target)
432    }
433
434    pub(crate) fn refs(
435        &self,
436        attribute: &'static str,
437        expected: &'static str,
438        minimum: usize,
439        optional: bool,
440        unique: bool,
441    ) -> ResourceResult<Vec<EntityId>> {
442        let values = match self.value(attribute)?.unwrap_typed() {
443            Value::Null | Value::Derived if optional => return Ok(Vec::new()),
444            Value::List(values) => values,
445            _ => return Err(self.invalid(attribute, "aggregate of entity references")),
446        };
447        if values.len() < minimum {
448            return Err(ResourceError::InvalidCardinality {
449                entity: self.id,
450                attribute,
451                minimum,
452                actual: values.len(),
453            });
454        }
455        let mut targets = Vec::with_capacity(values.len());
456        let mut seen = HashSet::with_capacity(values.len());
457        for value in values {
458            let Value::Ref(target) = value.unwrap_typed() else {
459                return Err(self.invalid(attribute, "aggregate of entity references"));
460            };
461            if unique && !seen.insert(*target) {
462                return Err(ResourceError::DuplicateReference {
463                    entity: self.id,
464                    attribute,
465                    target: *target,
466                });
467            }
468            self.check_reference(attribute, *target, &[expected], expected)?;
469            targets.push(*target);
470        }
471        Ok(targets)
472    }
473
474    pub(crate) fn check_reference(
475        &self,
476        attribute: &'static str,
477        target: EntityId,
478        members: &[&str],
479        expected: &'static str,
480    ) -> ResourceResult<()> {
481        let entity = self
482            .model
483            .get(target)
484            .ok_or(ResourceError::DanglingReference {
485                entity: self.id,
486                attribute,
487                target,
488            })?;
489        if !members
490            .iter()
491            .any(|member| self.schema.is_a(&entity.type_name, member))
492        {
493            return Err(ResourceError::WrongReferenceType {
494                entity: self.id,
495                attribute,
496                target,
497                expected,
498                actual: entity.type_name.to_string(),
499            });
500        }
501        Ok(())
502    }
503
504    pub(crate) fn require_object_type_if(
505        &self,
506        condition: bool,
507        rule: &'static str,
508    ) -> ResourceResult<()> {
509        if condition
510            && self
511                .optional_text("ObjectType")?
512                .is_none_or(|value| value.trim().is_empty())
513        {
514            return Err(ResourceError::SemanticViolation {
515                entity: Some(self.id),
516                rule,
517            });
518        }
519        Ok(())
520    }
521
522    fn invalid(&self, attribute: &'static str, expected: &'static str) -> ResourceError {
523        ResourceError::InvalidValue {
524            entity: self.id,
525            attribute,
526            expected,
527        }
528    }
529}
530
531#[cfg(test)]
532mod intermediate_release_tests {
533    use super::*;
534
535    /// IFC4X1 and IFC4X2 have bundled tables but no verified layout here:
536    /// refused with the unsupported-schema error, never read as IFC4/IFC4X3.
537    #[test]
538    fn ifc4x1_and_ifc4x2_are_refused_not_aliased() {
539        for token in ["IFC4X1", "IFC4X2"] {
540            let mut model = Model::new();
541            model.header_mut().schema = vec![token.to_owned()];
542            assert!(
543                matches!(
544                    ResourceView::for_model(&model),
545                    Err(ResourceError::UnsupportedSchema { token: found }) if found == token
546                ),
547                "{token} must be refused"
548            );
549        }
550    }
551}