Skip to main content

ifc_spatial/tree/
kind.rs

1//! Where an entity sits in the spatial hierarchy.
2//!
3//! # From the declared release, not from the name
4//!
5//! A spatial container is an `IfcSpatialElement` (IFC4 ADD2 TC1, IFC4X3
6//! ADD2) or an `IfcSpatialStructureElement` (IFC2X3 TC1, which has no
7//! `IfcSpatialElement`), plus the `IfcProject` at the root. The set differs
8//! by release: IFC4 adds `IfcSpatialZone` and `IfcExternalSpatialElement`,
9//! IFC4X3 adds `IfcFacility` with `IfcBridge`, `IfcRoad`, `IfcRailway` and
10//! `IfcMarineFacility`, and the `IfcFacilityPart` subtypes. None of those
11//! names shares a pattern, so membership is the release's own subtype test
12//! (`Schema::is_a`) against the bundled table of the release the file
13//! declares.
14
15use ifc_model::Model;
16use ifc_schema::{for_version, Schema, SchemaVersion};
17
18/// The spatial role of an entity, as far as containment is concerned.
19///
20/// The five named kinds are those exact entities. Every other spatial
21/// element of the release is [`OtherContainer`](Self::OtherContainer).
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
23#[non_exhaustive]
24pub enum SpatialKind {
25    /// `IfcProject` -- the root. A conformant file has exactly one.
26    Project,
27    /// `IfcSite`.
28    Site,
29    /// `IfcBuilding`.
30    Building,
31    /// `IfcBuildingStorey`.
32    Storey,
33    /// `IfcSpace`.
34    Space,
35    /// Any other spatial element of the release: `IfcSpatialZone` and
36    /// `IfcExternalSpatialElement` (IFC4, IFC4X3), and the IFC4X3
37    /// facilities (`IfcFacility`, `IfcBridge`, `IfcRoad`, `IfcRailway`,
38    /// `IfcMarineFacility`) and facility parts (`IfcBridgePart`,
39    /// `IfcRoadPart`, `IfcRailwayPart`, `IfcMarinePart`,
40    /// `IfcFacilityPartCommon`).
41    OtherContainer,
42    /// Not a spatial container: a wall, a door, a slab, or any entity the
43    /// release does not declare as a spatial element.
44    Element,
45}
46
47impl SpatialKind {
48    /// Classify a STEP type name without a release: a container when any
49    /// bundled release (IFC2X3, IFC4, IFC4X3) declares it a spatial
50    /// element.
51    ///
52    /// No bundled release declares as a spatial element a name another
53    /// declares as something else (`tests/classification.rs` asserts it),
54    /// so this never contradicts [`classify_in`](Self::classify_in) for a
55    /// name the release declares. Prefer `classify_in` when the release is
56    /// known; [`SpatialTree`](crate::SpatialTree) does.
57    #[must_use]
58    pub fn classify(type_name: &str) -> Self {
59        Classifier::any_release().classify(type_name)
60    }
61
62    /// Classify a STEP type name against one release's schema table.
63    ///
64    /// A release whose feature this build leaves out has no table, so every
65    /// name classifies as [`Element`](Self::Element); check
66    /// `ifc_schema::for_version` first when the build is single-release.
67    #[must_use]
68    pub fn classify_in(type_name: &str, release: SchemaVersion) -> Self {
69        Classifier::for_release(release).classify(type_name)
70    }
71
72    /// Whether entities of this kind can contain others.
73    #[must_use]
74    pub const fn is_container(self) -> bool {
75        !matches!(self, Self::Element)
76    }
77}
78
79/// Releases the spatial classification is verified against.
80const VERIFIED: [SchemaVersion; 3] = [
81    SchemaVersion::Ifc2x3,
82    SchemaVersion::Ifc4,
83    SchemaVersion::Ifc4x3,
84];
85
86/// The table(s) a classification is answered from.
87pub(crate) struct Classifier {
88    /// The bound release, if the file declares exactly one bundled release.
89    release: Option<SchemaVersion>,
90    tables: Vec<&'static Schema>,
91}
92
93impl Classifier {
94    /// The release `model` declares, when it names exactly one release this
95    /// classifier is verified for; otherwise every verified release, with
96    /// none bound.
97    ///
98    /// IFC4X1 and IFC4X2 are bundled by `ifc-schema` but not verified here,
99    /// so they bind nothing ([`Self::bound_release`] is `None`) rather than
100    /// being read as IFC4 or IFC4X3. A verified release whose table this
101    /// build leaves out (its release feature is off, #306) binds nothing the
102    /// same way: its empty table would classify every entity as an element.
103    pub(crate) fn for_model(model: &Model) -> Self {
104        match model.header().schema.as_slice() {
105            [token] => match SchemaVersion::from_header_token(token) {
106                Some(release) if VERIFIED.contains(&release) && for_version(release).is_ok() => {
107                    Self::for_release(release)
108                }
109                _ => Self::any_release(),
110            },
111            _ => Self::any_release(),
112        }
113    }
114
115    fn for_release(release: SchemaVersion) -> Self {
116        Self {
117            release: Some(release),
118            tables: for_version(release).into_iter().collect(),
119        }
120    }
121
122    fn any_release() -> Self {
123        let tables = VERIFIED
124            .into_iter()
125            .filter_map(|release| for_version(release).ok())
126            .collect();
127        Self {
128            release: None,
129            tables,
130        }
131    }
132
133    /// The release bound, or `None` when every bundled release is asked.
134    pub(crate) fn bound_release(&self) -> Option<SchemaVersion> {
135        self.release
136    }
137
138    pub(crate) fn classify(&self, type_name: &str) -> SpatialKind {
139        let upper = type_name.to_ascii_uppercase();
140        if !self.tables.iter().any(|table| is_spatial(table, &upper)) {
141            return SpatialKind::Element;
142        }
143        match upper.as_str() {
144            "IFCPROJECT" => SpatialKind::Project,
145            "IFCSITE" => SpatialKind::Site,
146            "IFCBUILDING" => SpatialKind::Building,
147            "IFCBUILDINGSTOREY" => SpatialKind::Storey,
148            "IFCSPACE" => SpatialKind::Space,
149            _ => SpatialKind::OtherContainer,
150        }
151    }
152}
153
154/// Whether `table` declares `upper` as the project or a spatial element.
155fn is_spatial(table: &Schema, upper: &str) -> bool {
156    // IfcProject is an IfcContext (IFC4, IFC4X3) or an IfcObject (IFC2X3),
157    // not a spatial element, but it is the root every tree hangs from.
158    if upper == "IFCPROJECT" {
159        return table.is_a(upper, "IFCPROJECT");
160    }
161    // IFC2X3 has no IfcSpatialElement; its spatial root is
162    // IfcSpatialStructureElement. Asking for an undeclared ancestor
163    // answers false, so the IFC4 root is tried first and the IFC2X3 one
164    // only matters where it is the root.
165    table.is_a(upper, "IFCSPATIALELEMENT") || table.is_a(upper, "IFCSPATIALSTRUCTUREELEMENT")
166}
167
168#[cfg(test)]
169mod intermediate_release_tests {
170    use super::*;
171
172    /// An IFC4X1 or IFC4X2 header binds no release: the classifier answers
173    /// from the verified tables with nothing bound, instead of claiming the
174    /// file was read as IFC4 or IFC4X3.
175    #[test]
176    fn ifc4x1_and_ifc4x2_bind_no_release() {
177        for token in ["IFC4X1", "IFC4X2"] {
178            let mut model = Model::new();
179            model.header_mut().schema = vec![token.to_owned()];
180            assert_eq!(
181                Classifier::for_model(&model).bound_release(),
182                None,
183                "{token}"
184            );
185        }
186        let mut model = Model::new();
187        model.header_mut().schema = vec!["IFC4X3".to_owned()];
188        assert_eq!(
189            Classifier::for_model(&model).bound_release(),
190            Some(SchemaVersion::Ifc4x3)
191        );
192    }
193}