Skip to main content

ifc_schema/
bundled.rs

1//! Compiled schema artifacts bundled inside this crate.
2//!
3//! A consumer that needs a schema should not have to source the `.exp` file
4//! itself, decode it as Latin-1, or reparse hundreds of kilobytes of EXPRESS
5//! on every process start.
6//!
7//! The artifacts under `data/` are the *parsed* schema (entities, attributes,
8//! types), not the EXPRESS source text — they never contain normative
9//! buildingSMART/ISO 16739 prose, only the structural facts the EXPRESS
10//! extractor would read, decoded into this crate's own types. The build-time
11//! `generation` feature that produces them requires a user-supplied copy of
12//! the `.exp` file; that file is never vendored into this crate or its
13//! published archive (see `tools/generate.rs`).
14//!
15//! # Bundled versions
16//!
17//! IFC2x3 TC1, IFC4 ADD2 TC1, IFC4X1 FINAL, IFC4X2 FINAL and IFC4X3 ADD2 are
18//! separate artifacts. Version dispatch never substitutes one table for
19//! another; that would turn schema validation into confident nonsense.
20//!
21//! # One feature per release
22//!
23//! Each release is behind its own feature (`ifc2x3`, `ifc4`, `ifc4x1`,
24//! `ifc4x2`, `ifc4x3`), all on by default. Its accessor (`ifc4()`, ...)
25//! exists only with its feature. [`for_version`] exists in every build and
26//! returns [`NotBundled`] for a release whose feature is off, so a
27//! single-release build refuses the others with a typed error: an unknown
28//! header token is `None` from [`SchemaVersion::from_header_token`], a known
29//! release that is not compiled in is `Err(NotBundled)` here.
30
31#[cfg(feature = "artifact")]
32use std::sync::OnceLock;
33
34#[cfg(feature = "artifact")]
35use crate::artifact::decode_schema;
36use crate::registry::Schema;
37use crate::version::SchemaVersion;
38
39/// A recognised IFC release whose table is not compiled into this build.
40///
41/// Returned by [`for_version`] when the release's cargo feature (`ifc2x3`,
42/// `ifc4`, `ifc4x1`, `ifc4x2` or `ifc4x3` on `ifc-schema`, passed through by
43/// `openbim-ifc`) is off. Distinct from an unknown `FILE_SCHEMA` token, which
44/// never becomes a [`SchemaVersion`] at all.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
46#[non_exhaustive]
47pub struct NotBundled {
48    /// The release that was asked for.
49    pub version: SchemaVersion,
50}
51
52impl std::fmt::Display for NotBundled {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        write!(
55            f,
56            "IFC release {} is not compiled into this build (enable the `{}` feature of ifc-schema)",
57            self.version.release_id(),
58            self.version.feature_name()
59        )
60    }
61}
62
63impl std::error::Error for NotBundled {}
64
65/// Declares one release's cached accessor, compiled only with its feature.
66macro_rules! bundled_release {
67    ($feature:literal, $cell:ident, $name:ident, $file:literal, $label:literal, $doc:literal) => {
68        #[cfg(feature = $feature)]
69        static $cell: OnceLock<Schema> = OnceLock::new();
70
71        #[doc = $doc]
72        ///
73        /// Decoded once on first use and cached for the life of the process.
74        #[doc = concat!("Requires the `", $feature, "` feature (on by default).")]
75        #[cfg(feature = $feature)]
76        #[must_use]
77        pub fn $name() -> &'static Schema {
78            $cell.get_or_init(|| {
79                decode_schema(include_bytes!($file)).expect(concat!(
80                    "the bundled ",
81                    $label,
82                    " artifact is produced and verified by this crate's own build"
83                ))
84            })
85        }
86    };
87}
88
89bundled_release!(
90    "ifc2x3",
91    IFC2X3,
92    ifc2x3,
93    "../data/ifc2x3-tc1.bin",
94    "IFC2x3",
95    "The bundled IFC2x3 TC1 schema (653 entities, 327 types).\n\n\
96     Still the most common schema in the wild. Its layouts differ from IFC4 \
97     in ways that silently corrupt a reader that assumes the newer tables: \
98     `IfcWallStandardCase` has 8 attributes here and 9 in IFC4, because IFC4 \
99     inserts `PredefinedType`."
100);
101bundled_release!(
102    "ifc4",
103    IFC4,
104    ifc4,
105    "../data/ifc4-add2-tc1.bin",
106    "IFC4",
107    "The bundled IFC4 ADD2 TC1 schema (776 entities, 397 types).\n\n\
108     Building it costs a `bincode` decode of a committed artifact: the \
109     `IFC4.exp` EXPRESS source is never read at runtime and is not in the \
110     published crate. Custom schema files remain available through \
111     `Schema::from_express` (the `express` feature) or [`Schema::new`]."
112);
113bundled_release!(
114    "ifc4x1",
115    IFC4X1,
116    ifc4x1,
117    "../data/ifc4x1-final.bin",
118    "IFC4X1",
119    "The bundled IFC4X1 FINAL schema (801 entities, 400 types).\n\n\
120     Its own artifact: IFC4X1 adds the alignment entities to IFC4 and is not \
121     an alias for either IFC4 or IFC4X3."
122);
123bundled_release!(
124    "ifc4x2",
125    IFC4X2,
126    ifc4x2,
127    "../data/ifc4x2-final.bin",
128    "IFC4X2",
129    "The bundled IFC4X2 FINAL schema (816 entities, 407 types).\n\n\
130     Its own artifact: IFC4X2 adds bridges to IFC4X1 and is not an alias for \
131     IFC4 or IFC4X3."
132);
133bundled_release!(
134    "ifc4x3",
135    IFC4X3,
136    ifc4x3,
137    "../data/ifc4x3-add2.bin",
138    "IFC4X3",
139    "The bundled IFC4X3 ADD2 schema (876 entities, 436 types).\n\n\
140     Its own artifact. It is never an alias for IFC4: renamed and civil \
141     entities require the declared tables."
142);
143
144/// The bundled schema for `version`.
145///
146/// This is the lookup a reader should use after parsing `FILE_SCHEMA`, so a
147/// release without tables becomes an explicit "cannot check this" rather
148/// than a silent fallback to the wrong tables.
149///
150/// # Errors
151///
152/// [`NotBundled`] when `version`'s release feature is off in this build.
153/// With default features every release is bundled and this never fails.
154pub fn for_version(version: SchemaVersion) -> Result<&'static Schema, NotBundled> {
155    match version {
156        #[cfg(feature = "ifc2x3")]
157        SchemaVersion::Ifc2x3 => Ok(ifc2x3()),
158        #[cfg(feature = "ifc4")]
159        SchemaVersion::Ifc4 => Ok(ifc4()),
160        #[cfg(feature = "ifc4x1")]
161        SchemaVersion::Ifc4x1 => Ok(ifc4x1()),
162        #[cfg(feature = "ifc4x2")]
163        SchemaVersion::Ifc4x2 => Ok(ifc4x2()),
164        #[cfg(feature = "ifc4x3")]
165        SchemaVersion::Ifc4x3 => Ok(ifc4x3()),
166        #[allow(unreachable_patterns)]
167        _ => Err(NotBundled { version }),
168    }
169}
170
171#[cfg(all(
172    test,
173    feature = "ifc2x3",
174    feature = "ifc4",
175    feature = "ifc4x1",
176    feature = "ifc4x2",
177    feature = "ifc4x3"
178))]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn bundled_ifc4_matches_the_normative_entity_and_type_counts() {
184        let schema = ifc4();
185        assert_eq!(schema.entity_count(), 776, "IFC4 ADD2 TC1 entity count");
186        assert_eq!(schema.type_count(), 397, "IFC4 ADD2 TC1 type count");
187    }
188
189    #[test]
190    fn bundled_ifc4x3_matches_the_normative_entity_and_type_counts() {
191        let schema = ifc4x3();
192        assert_eq!(schema.entity_count(), 876, "IFC4X3 ADD2 entity count");
193        assert_eq!(schema.type_count(), 436, "IFC4X3 ADD2 type count");
194        assert!(schema.entity("IfcBuiltElement").is_some());
195        assert!(schema.entity("IfcBuildingElement").is_none());
196        assert!(std::ptr::eq(schema, ifc4x3()), "constructor must cache");
197    }
198
199    #[test]
200    fn bundled_ifc2x3_matches_the_normative_entity_and_type_counts() {
201        let schema = ifc2x3();
202        assert_eq!(schema.entity_count(), 653, "IFC2x3 TC1 entity count");
203        assert_eq!(schema.type_count(), 327, "IFC2x3 TC1 type count");
204    }
205
206    #[test]
207    fn bundled_ifc4_resolves_the_deep_inheritance_chain() {
208        let schema = ifc4();
209        assert!(schema.is_a("IFCWALL", "IfcRoot"), "wall is a root");
210        assert!(schema.is_a("IFCWALL", "IfcProduct"), "wall is a product");
211        assert_eq!(
212            &schema.attribute_names("IFCWALL")[..4],
213            ["GlobalId", "OwnerHistory", "Name", "Description"],
214            "IfcRoot's slots must come first"
215        );
216    }
217
218    /// Every recognised version resolves to its independent bundled table.
219    #[test]
220    fn version_lookup_returns_the_matching_table() {
221        assert_eq!(
222            for_version(SchemaVersion::Ifc4).map(|s| s.entity_count()),
223            Ok(776)
224        );
225        assert_eq!(
226            for_version(SchemaVersion::Ifc2x3).map(|s| s.entity_count()),
227            Ok(653)
228        );
229        assert_eq!(
230            for_version(SchemaVersion::Ifc4x3).map(|s| s.entity_count()),
231            Ok(876),
232            "IFC4X3 must select its own bundled tables"
233        );
234    }
235
236    /// The IFC2x3 and IFC4 bundled schemas must be distinct tables.
237    ///
238    /// Wiring both constructors to the same artifact would pass every count
239    /// test above if the counts happened to be read from the same file, so
240    /// pin a layout that genuinely differs between the versions.
241    #[test]
242    fn the_ifc2x3_and_ifc4_bundles_are_not_the_same_table() {
243        // IFC4 inserts PredefinedType; IFC2x3 stops at Tag.
244        assert_eq!(
245            ifc2x3().attribute_names("IFCWALLSTANDARDCASE"),
246            [
247                "GlobalId",
248                "OwnerHistory",
249                "Name",
250                "Description",
251                "ObjectType",
252                "ObjectPlacement",
253                "Representation",
254                "Tag"
255            ],
256        );
257        assert_eq!(
258            ifc4().attribute_names("IFCWALLSTANDARDCASE"),
259            [
260                "GlobalId",
261                "OwnerHistory",
262                "Name",
263                "Description",
264                "ObjectType",
265                "ObjectPlacement",
266                "Representation",
267                "Tag",
268                "PredefinedType"
269            ],
270        );
271    }
272
273    /// IFC2x3 entities that IFC4 removed must resolve only in IFC2x3.
274    #[test]
275    fn version_specific_entities_resolve_in_their_own_schema() {
276        assert!(
277            !ifc2x3().attributes("IFC2DCOMPOSITECURVE").is_empty(),
278            "Ifc2DCompositeCurve exists in IFC2x3"
279        );
280        assert!(
281            ifc4().type_def("IfcHeatFluxDensityMeasure").is_some(),
282            "IFC4 keeps the derived measure types"
283        );
284    }
285
286    #[test]
287    fn repeated_calls_return_the_same_cached_schema() {
288        let first = ifc4() as *const _;
289        let second = ifc4() as *const _;
290        assert_eq!(first, second, "ifc4() must not reparse on every call");
291        let first = ifc2x3() as *const _;
292        let second = ifc2x3() as *const _;
293        assert_eq!(first, second, "ifc2x3() must not reparse on every call");
294    }
295
296    /// An inline `UNIQUE` in an aggregate must not truncate the slot list.
297    ///
298    /// `IfcTypeProduct.RepresentationMaps` is declared
299    /// `OPTIONAL LIST [1:?] OF UNIQUE IfcRepresentationMap`. A parser that
300    /// treats that `UNIQUE` as the start of a UNIQUE block drops it and `Tag`,
301    /// which shifts every following slot of all 124 entities inheriting from
302    /// `IfcTypeProduct` -- silently, since the values still look plausible.
303    ///
304    /// Expected layouts are IfcOpenShell's, which is an independent
305    /// implementation of the same normative schema.
306    #[test]
307    fn type_product_subtypes_keep_their_full_slot_layout() {
308        let schema = ifc4();
309        assert_eq!(
310            schema.attribute_names("IFCTYPEPRODUCT"),
311            [
312                "GlobalId",
313                "OwnerHistory",
314                "Name",
315                "Description",
316                "ApplicableOccurrence",
317                "HasPropertySets",
318                "RepresentationMaps",
319                "Tag"
320            ],
321        );
322        assert_eq!(
323            schema.attribute_names("IFCWALLTYPE"),
324            [
325                "GlobalId",
326                "OwnerHistory",
327                "Name",
328                "Description",
329                "ApplicableOccurrence",
330                "HasPropertySets",
331                "RepresentationMaps",
332                "Tag",
333                "ElementType",
334                "PredefinedType"
335            ],
336            "ElementType sits at 8, not 6"
337        );
338    }
339
340    /// The same inline-`UNIQUE` hazard exists in IFC2x3.
341    #[test]
342    fn ifc2x3_type_product_keeps_its_full_slot_layout() {
343        assert_eq!(
344            ifc2x3().attribute_names("IFCTYPEPRODUCT"),
345            [
346                "GlobalId",
347                "OwnerHistory",
348                "Name",
349                "Description",
350                "ApplicableOccurrence",
351                "HasPropertySets",
352                "RepresentationMaps",
353                "Tag"
354            ],
355        );
356    }
357
358    /// The other IFC4 declarations carrying an inline `UNIQUE`.
359    #[test]
360    fn every_inline_unique_aggregate_survives_parsing() {
361        let schema = ifc4();
362        assert!(
363            schema
364                .attribute_names("IFCGRID")
365                .contains(&"PredefinedType"),
366            "IfcGrid declares UAxes/VAxes/WAxes with inline UNIQUE"
367        );
368        assert_eq!(schema.attribute_names("IFCPOLYLOOP"), ["Polygon"]);
369        assert_eq!(
370            schema.attribute_names("IFCPROPERTYTABLEVALUE")[7],
371            "CurveInterpolation",
372            "slot 7 after two inherited IfcProperty slots"
373        );
374    }
375
376    /// The generator's expected counts must match the committed artifacts.
377    ///
378    /// The counts in `tools/generate.rs` are the only guard against pointing
379    /// the generator at the wrong `.exp` -- a mistake that yields a
380    /// plausible-looking artifact describing the wrong schema. Nothing else
381    /// checks them, because the generator needs a normative source file that
382    /// CI does not have. Pinning them against the artifacts that shipped
383    /// keeps the guard honest without needing the source.
384    #[test]
385    fn the_generator_guards_match_the_bundled_artifacts() {
386        // Parsed out of the generator's TARGETS table so the two cannot drift.
387        let source = include_str!("../tools/generate.rs");
388        let expected: Vec<(String, usize, usize)> = source
389            .split("Target {")
390            .skip(1)
391            .filter_map(|block| {
392                let field = |key: &str| -> Option<&str> {
393                    let start = block.find(key)? + key.len();
394                    let rest = &block[start..];
395                    let end = rest.find(',')?;
396                    Some(rest[..end].trim())
397                };
398                Some((
399                    field("selector:")?.trim_matches('"').to_owned(),
400                    field("entities:")?.parse().ok()?,
401                    field("types:")?.parse().ok()?,
402                ))
403            })
404            .collect();
405        let bundled = [
406            ("ifc2x3", SchemaVersion::Ifc2x3, ifc2x3()),
407            ("ifc4", SchemaVersion::Ifc4, ifc4()),
408            ("ifc4x1", SchemaVersion::Ifc4x1, ifc4x1()),
409            ("ifc4x2", SchemaVersion::Ifc4x2, ifc4x2()),
410            ("ifc4x3", SchemaVersion::Ifc4x3, ifc4x3()),
411        ];
412        assert_eq!(expected.len(), bundled.len(), "one generator target each");
413        for ((selector, entities, types), (name, version, schema)) in expected.iter().zip(bundled) {
414            assert_eq!(selector, name, "generator target order");
415            assert_eq!(
416                (*entities, *types),
417                (schema.entity_count(), schema.type_count()),
418                "{name} generator guard vs the committed artifact"
419            );
420            assert_eq!(
421                (*entities, *types),
422                (
423                    version.expected_entity_count(),
424                    version.expected_type_count()
425                ),
426                "{name} generator guard vs SchemaVersion"
427            );
428            assert_eq!(schema.version(), Some(version), "{name} declared name");
429        }
430    }
431
432    /// INVERSE and UNIQUE declarations (#111), counted per release. The
433    /// IFC2X3, IFC4 and IFC4X3 figures were counted independently of this
434    /// crate from the normative EXPRESS; a bundle that dropped or duplicated
435    /// a clause fails here.
436    #[test]
437    fn inverse_and_unique_declarations_match_the_normative_counts() {
438        for (schema, inverses, unique) in [
439            (ifc2x3(), 115, 17),
440            (ifc4(), 153, 4),
441            (ifc4x1(), 158, 4),
442            (ifc4x2(), 160, 4),
443            (ifc4x3(), 165, 4),
444        ] {
445            let counted: usize = schema.entities().map(|e| e.inverses.len()).sum();
446            assert_eq!(counted, inverses, "{} INVERSE", schema.name());
447            let counted: usize = schema.entities().map(|e| e.unique_rules.len()).sum();
448            assert_eq!(counted, unique, "{} UNIQUE", schema.name());
449            // Every aggregate attribute carries its levels.
450            for entity in schema.entities() {
451                for attribute in &entity.attributes {
452                    assert_eq!(
453                        attribute.aggregate,
454                        !attribute.aggregation.is_empty(),
455                        "{} {}.{}",
456                        schema.name(),
457                        entity.name,
458                        attribute.name
459                    );
460                }
461            }
462        }
463        let root = ifc4().entity("IfcRoot").unwrap();
464        assert_eq!(root.unique_rules[0].label.as_deref(), Some("UR1"));
465        assert_eq!(root.unique_rules[0].attributes, ["GlobalId"]);
466        let object = ifc4().entity("IfcObjectDefinition").unwrap();
467        let decomposes = object
468            .inverses
469            .iter()
470            .find(|inverse| inverse.name == "Decomposes")
471            .unwrap();
472        assert_eq!(decomposes.entity, "IfcRelAggregates");
473        assert_eq!(decomposes.for_attribute, "RelatedObjects");
474        let set = decomposes.aggregation.as_ref().unwrap();
475        assert_eq!(
476            (set.kind, &set.lower, &set.upper),
477            (
478                crate::AggregateKind::Set,
479                &crate::Bound::Integer(0),
480                &crate::Bound::Integer(1)
481            )
482        );
483    }
484
485    /// Aggregate bounds and nesting survive, and a nested aggregate's
486    /// `type_name` is its innermost element type (#111, #215).
487    #[test]
488    fn aggregate_bounds_and_nesting_are_bundled() {
489        use crate::{AggregateKind, Bound};
490        let coords = ifc4()
491            .attributes("IfcCartesianPointList3D")
492            .into_iter()
493            .find(|attribute| attribute.name == "CoordList")
494            .unwrap()
495            .clone();
496        assert_eq!(coords.type_name, "IfcLengthMeasure");
497        assert_eq!(coords.aggregation.len(), 2);
498        assert_eq!(coords.aggregation[0].kind, AggregateKind::List);
499        assert_eq!(coords.aggregation[0].lower, Bound::Integer(1));
500        assert_eq!(coords.aggregation[0].upper, Bound::Unbounded);
501        assert_eq!(coords.aggregation[1].lower, Bound::Integer(3));
502        assert_eq!(coords.aggregation[1].upper, Bound::Integer(3));
503
504        let point = ifc2x3()
505            .attributes("IfcCartesianPoint")
506            .into_iter()
507            .find(|attribute| attribute.name == "Coordinates")
508            .unwrap()
509            .clone();
510        assert_eq!(point.type_name, "IfcLengthMeasure");
511        assert_eq!(point.aggregation.len(), 1);
512        assert_eq!(
513            (&point.aggregation[0].lower, &point.aggregation[0].upper),
514            (&Bound::Integer(1), &Bound::Integer(3))
515        );
516
517        let polyline = ifc4()
518            .attributes("IfcPolyLoop")
519            .into_iter()
520            .find(|attribute| attribute.name == "Polygon")
521            .unwrap()
522            .clone();
523        assert!(polyline.aggregation[0].unique);
524        assert_eq!(polyline.aggregation[0].lower, Bound::Integer(3));
525    }
526
527    /// IFC4X1 and IFC4X2 are their own tables, not a paste of a neighbour:
528    /// each count differs from IFC4 and IFC4X3, and each carries the
529    /// entities its release introduced and none its successor added.
530    #[test]
531    fn the_intermediate_releases_are_distinct_tables() {
532        for schema in [ifc4x1(), ifc4x2()] {
533            for neighbour in [ifc4(), ifc4x3()] {
534                assert_ne!(schema.entity_count(), neighbour.entity_count());
535            }
536        }
537        assert_ne!(ifc4x1().entity_count(), ifc4x2().entity_count());
538
539        assert!(ifc4().entity("IfcAlignment").is_none());
540        assert!(ifc4x1().entity("IfcAlignment").is_some());
541        assert!(ifc4x1().entity("IfcAlignmentCurve").is_some());
542        assert!(ifc4x1().entity("IfcBridge").is_none());
543        assert!(ifc4x2().entity("IfcBridge").is_some());
544        assert!(ifc4x2().entity("IfcBuiltElement").is_none());
545        assert!(ifc4x3().entity("IfcAlignmentCurve").is_none());
546
547        assert_eq!(
548            for_version(SchemaVersion::Ifc4x1).map(Schema::name),
549            Ok("IFC4X1")
550        );
551        assert_eq!(
552            for_version(SchemaVersion::Ifc4x2).map(Schema::name),
553            Ok("IFC4X2")
554        );
555        assert!(std::ptr::eq(ifc4x1(), ifc4x1()), "constructor must cache");
556    }
557}
558
559/// A build without a release's feature refuses that release with a typed
560/// error, and still serves the releases it does bundle. Run by the gate as
561/// `cargo test -p ifc-schema --no-default-features --features ifc4`.
562#[cfg(all(test, feature = "ifc4", not(feature = "ifc4x3")))]
563mod single_release_tests {
564    use super::*;
565
566    #[test]
567    fn an_unbundled_release_is_refused_with_not_bundled() {
568        assert_eq!(
569            for_version(SchemaVersion::Ifc4).map(Schema::name),
570            Ok("IFC4")
571        );
572        let refused = for_version(SchemaVersion::Ifc4x3).expect_err("not compiled in");
573        assert_eq!(
574            refused,
575            NotBundled {
576                version: SchemaVersion::Ifc4x3
577            }
578        );
579        assert!(!SchemaVersion::Ifc4x3.is_bundled());
580        assert!(SchemaVersion::Ifc4.is_bundled());
581        assert!(
582            refused.to_string().contains("`ifc4x3` feature"),
583            "{refused}"
584        );
585        // A known release without tables is not an unknown token.
586        assert_eq!(
587            SchemaVersion::from_header_token("IFC4X3"),
588            Some(SchemaVersion::Ifc4x3)
589        );
590    }
591}