ifc_lite_core/
schema_helpers.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Hand-maintained schema helpers built on top of the auto-generated
6//! `IfcType` enum.
7//!
8//! These helpers used to live appended to `generated/schema.rs` despite that
9//! file's "DO NOT EDIT" header. Moving them here keeps them safe from a
10//! re-run of `@ifc-lite/codegen` and lets us derive answers from the EXPRESS
11//! inheritance graph instead of maintaining a leaf-level allow-list that has
12//! to be amended every time a new IFC4X3 subtype shows up (see PR #585 for
13//! `IfcSolarDevice`, which inherits from `IfcEnergyConversionDevice` and was
14//! therefore already covered conceptually by the old whitelist's parent
15//! entry, but missed in practice because the whitelist was only checked by
16//! string match).
17//!
18//! Co-authored with Geronimo <gerald.stampfel+geronimo@gmail.com> (PR #585).
19//!
20//! `has_geometry_by_name`, `is_representationless_spatial_container_by_name`
21//! and `is_simple_geometry_type` are all on the hot path during scene
22//! construction, where the same ~50–100 distinct type names are queried
23//! millions of times per file. An immutable, schema-derived table memoises
24//! all generated names and bounded exporter aliases without taking a read-lock for each entity.
25//! Unknown names use the same predicates without entering a growing cache.
26
27use std::sync::OnceLock;
28
29use rustc_hash::FxHashMap;
30
31use crate::generated::IfcType;
32
33/// Normalise to uppercase ASCII without allocating when the input is already
34/// uppercase (the common case — STEP type tokens are emitted uppercase).
35///
36/// `pub(crate)` so record-level parsing and keyword resolution share one exact
37/// normalisation rule.
38pub(crate) fn normalise_uppercase(type_name: &str) -> std::borrow::Cow<'_, str> {
39    if type_name.bytes().any(|b| b.is_ascii_lowercase()) {
40        std::borrow::Cow::Owned(type_name.to_ascii_uppercase())
41    } else {
42        std::borrow::Cow::Borrowed(type_name)
43    }
44}
45
46#[derive(Clone, Copy)]
47struct TypeClassification {
48    has_geometry: bool,
49    representationless_spatial: bool,
50    simple_geometry: bool,
51}
52
53/// Build only from the finite schema catalog: file-supplied unknown names
54/// cannot retain memory here. Compute via the canonical predicates, including
55/// legacy overrides for names that also occur in the modern schema.
56fn classifications() -> &'static FxHashMap<&'static str, TypeClassification> {
57    static TABLE: OnceLock<FxHashMap<&'static str, TypeClassification>> = OnceLock::new();
58    TABLE.get_or_init(|| {
59        crate::generated::IFC_TYPES
60            .iter()
61            .map(IfcType::as_str)
62            .chain(crate::EXPORTER_STRATUM_ALIASES.iter().copied())
63            .map(|name| {
64                (
65                    name,
66                    TypeClassification {
67                        has_geometry: compute_has_geometry(name),
68                        representationless_spatial: compute_is_representationless_spatial_container(
69                            name,
70                        ),
71                        simple_geometry: compute_is_simple(name),
72                    },
73                )
74            })
75            .collect()
76    })
77}
78
79/// Check if a type name (UPPERCASE STEP string) represents an `IfcProduct`
80/// subtype that can bear geometry (has `ObjectPlacement` + `Representation`).
81///
82/// Implementation:
83/// 1. Modern names go through `IfcType::from_str` and are accepted iff they
84///    inherit from `IfcProduct`, with a small block-list for abstract spatial
85///    containers (`IfcBuildingStorey`, `IfcFacility`, `IfcFacilityPart`,
86///    `IfcSpatialElement`, `IfcSpatialStructureElement`) that don't carry
87///    geometry directly. `IfcSpace`, `IfcSite`, `IfcSpatialZone` and
88///    `IfcBuilding` (and any concrete subtype of those) are intentionally
89///    kept — they have boundary representations the renderer consumes. See
90///    [`is_non_geometric_spatial`] for how that exempt set is maintained.
91/// 2. The only non-EXPRESS compatibility spellings are the documented exporter
92///    stratum aliases.
93/// 3. Reinforcement variants not covered above fall back to a substring
94///    match (`REINFORCING…` / `REINFORCED…`).
95pub fn has_geometry_by_name(type_name: &str) -> bool {
96    let upper = normalise_uppercase(type_name);
97    classifications().get(upper.as_ref()).map_or_else(
98        || compute_has_geometry(upper.as_ref()),
99        |class| class.has_geometry,
100    )
101}
102
103/// Return the geometry-bearing and representationless-spatial predicates together.
104/// The tuple matches [`has_geometry_by_name`] and
105/// [`is_representationless_spatial_container_by_name`], respectively, while
106/// sharing one lookup of the immutable schema classification (#3987).
107pub fn geometry_flags_by_name(type_name: &str) -> (bool, bool) {
108    let upper = normalise_uppercase(type_name);
109    classifications().get(upper.as_ref()).map_or_else(
110        || {
111            let geometry = compute_has_geometry(upper.as_ref());
112            // These predicates are disjoint, including legacy and unknown names.
113            (
114                geometry,
115                !geometry && compute_is_representationless_spatial_container(upper.as_ref()),
116            )
117        },
118        |class| (class.has_geometry, class.representationless_spatial),
119    )
120}
121
122fn compute_has_geometry(upper: &str) -> bool {
123    if crate::is_exporter_stratum_alias(upper) {
124        return true;
125    }
126
127    let t = IfcType::from_str(upper);
128    if matches!(t, IfcType::Unknown(_)) {
129        // Reinforcement bars/meshes/elements are common in IFC2x3 files. Match
130        // a tighter prefix than `contains("REINFORC")` to avoid catching
131        // unrelated tokens with the substring.
132        return upper.starts_with("IFCREINFORCING") || upper.starts_with("IFCREINFORCED");
133    }
134
135    if !t.is_subtype_of(IfcType::IfcProduct) {
136        return false;
137    }
138
139    !is_non_geometric_spatial(t)
140}
141
142/// Subtypes of `IfcProduct` that exist solely as spatial containers and
143/// aren't rendered directly. `IfcSpace`/`IfcSite`/`IfcSpatialZone`/`IfcBuilding`
144/// and their concrete subtypes are deliberately exempt — their boundary
145/// representations are consumed by the renderer when present.
146///
147/// The exempt set grows as exporters are found that attach a body to a
148/// container. `IfcSpatialZone` was unblocked for Revit Family geometry authored
149/// via Dynamo (issue #1075); `IfcBuilding` for terrain/DGM exports that hang an
150/// `IfcShellBasedSurfaceModel` straight off the building (issue #1910). In both
151/// cases the class was blocked, so the entity never became a geometry job and
152/// the model rendered nothing at all. **The gate only *permits* meshing; a
153/// container with no representation still produces nothing**, so exempting a
154/// class costs one abandoned job per instance and is the safe direction.
155/// `IfcBuildingStorey` and the `IfcFacility`/`IfcFacilityPart` families stay
156/// blocked only because no exporter has been observed giving them a body; the
157/// same one-line exemption applies if one is.
158///
159/// We block by inheritance, not by exact match, so IFC4X3 facility
160/// subclasses like `IfcBridge`/`IfcRoad`/`IfcRailway`/`IfcMarineFacility`
161/// (under `IfcFacility`), their `*Part` variants (under `IfcFacilityPart`),
162/// and any future concrete spatial container all collapse to the same answer
163/// without the whitelist needing to enumerate them.
164fn is_non_geometric_spatial(t: IfcType) -> bool {
165    if t.is_subtype_of(IfcType::IfcSpace)
166        || t.is_subtype_of(IfcType::IfcSite)
167        || t.is_subtype_of(IfcType::IfcSpatialZone)
168        || t.is_subtype_of(IfcType::IfcBuilding)
169    {
170        return false;
171    }
172    t.is_subtype_of(IfcType::IfcSpatialElement)
173}
174
175/// Whether `type_name` is one of the spatial-container types that
176/// [`has_geometry_by_name`] still blocks by name (`IfcBuildingStorey`,
177/// `IfcFacility`, `IfcFacilityPart`, `IfcSpatialElement`,
178/// `IfcSpatialStructureElement`, and their subtypes) — i.e. `IfcProduct`
179/// subtypes that `is_non_geometric_spatial` treats as never carrying
180/// geometry directly. `IfcBuilding` (along with `IfcSpace`, `IfcSite` and
181/// `IfcSpatialZone`) is handled class-wide by `has_geometry_by_name` instead
182/// — see [`is_non_geometric_spatial`] — so it is no longer part of this
183/// instance-level exception.
184///
185/// In the overwhelming majority of real files that assumption holds for the
186/// still-blocked types: these entities are pure hierarchy nodes with a null
187/// `Representation`. Issue #1910 was discovered against a DGM/terrain export
188/// that attached an `IfcShellBasedSurfaceModel` directly to `IfcBuilding`
189/// with no `IfcBuildingElement` children at all; that concrete case is now
190/// covered by `IfcBuilding`'s class-wide exemption above, but the same
191/// exporter shape could in principle target `IfcBuildingStorey` or another
192/// still-blocked container. `has_geometry_by_name` alone can't distinguish
193/// "this type never has a body" from "this specific instance happens not
194/// to", so callers that need to catch that exceptional case combine this
195/// predicate with an instance-level check of whether the entity's
196/// `Representation` attribute (index 6 on any `IfcProduct`) is actually
197/// non-null before scheduling it for meshing
198/// (`crate::parser::nth_attribute_is_present`). See
199/// `rust/processing/src/processor/mod.rs` and
200/// `rust/wasm-bindings/src/api/gpu_meshes/prepass.rs`.
201pub fn is_representationless_spatial_container_by_name(type_name: &str) -> bool {
202    let upper = normalise_uppercase(type_name);
203    classifications().get(upper.as_ref()).map_or_else(
204        || compute_is_representationless_spatial_container(upper.as_ref()),
205        |class| class.representationless_spatial,
206    )
207}
208
209fn compute_is_representationless_spatial_container(upper: &str) -> bool {
210    if crate::is_exporter_stratum_alias(upper) {
211        return false;
212    }
213    let t = IfcType::from_str(upper);
214    if matches!(t, IfcType::Unknown(_)) || !t.is_subtype_of(IfcType::IfcProduct) {
215        return false;
216    }
217    is_non_geometric_spatial(t)
218}
219
220/// Check if an IFC entity class is "simple" geometry (processed first for
221/// fast first frame). Driven off the EXPRESS inheritance graph rather than
222/// a leaf-level blacklist, so new IFC4X3 subtypes (e.g. `IfcSolarDevice`
223/// under `IfcEnergyConversionDevice`) are categorised correctly without
224/// code changes — see PR #585.
225///
226/// Returns `true` for "simple" elements (load first), `false` for
227/// "secondary/complex" (openings, doors, windows, furniture, MEP/distribution
228/// elements, spaces, sites, annotations, virtual/proxy entities).
229pub fn is_simple_geometry_type(type_name: &str) -> bool {
230    let upper = normalise_uppercase(type_name);
231    classifications().get(upper.as_ref()).map_or_else(
232        || compute_is_simple(upper.as_ref()),
233        |class| class.simple_geometry,
234    )
235}
236
237/// Resolve a STEP keyword to its schema-local `IfcType`. Supported keywords
238/// retain their exact generated variants. The three non-EXPRESS exporter
239/// stratum aliases remain owned `Unknown` values and are handled explicitly by
240/// `has_geometry_by_name`. Any pass that *classifies* or *labels* an entity
241/// must use this helper rather than reimplementing keyword normalization
242/// (#4203).
243pub fn ifc_type_from_keyword(type_name: &str) -> IfcType {
244    let upper = normalise_uppercase(type_name);
245    IfcType::from_str(upper.as_ref())
246}
247
248/// The `IfcTypeProduct` subtype a STEP keyword names, or `None` when it is
249/// not one.
250///
251/// The single predicate behind every type-geometry candidate gate (#957/#962):
252/// the native processor, the streaming and sharded browser pre-passes, the
253/// styling pre-pass, and the attribute export's pass 3. They MUST agree — a
254/// keyword one admits and another drops is either geometry with no attribute
255/// row or an attribute row with no geometry (#1518).
256///
257/// Keeps the cheap `ends_with` pre-filter that kept the resolve and the
258/// `is_subtype_of` walk off the hot path for the non-type majority, and
259/// resolves through the generated schema universe. Exact variants are
260/// preserved for every supported schema keyword, while the three non-EXPRESS
261/// exporter stratum aliases remain owned `Unknown` values and are handled by
262/// the explicit compatibility predicates (#4203).
263///
264/// `type_name` is the raw STEP keyword as the scanner read it, in whatever case the file wrote it.
265pub fn type_product_ifc_type(type_name: &str) -> Option<IfcType> {
266    if !crate::parser::keyword_ends_with(type_name, "TYPE")
267        && !crate::parser::keyword_ends_with(type_name, "STYLE")
268    {
269        return None;
270    }
271    let ty = ifc_type_from_keyword(type_name);
272    ty.is_subtype_of(IfcType::IfcTypeProduct).then_some(ty)
273}
274
275/// The schema-resolved type for an entity, recovered from its RAW STEP RECORD.
276///
277/// For callers that hold a `DecodedEntity` and its source bytes but no keyword.
278/// `DecodedEntity.ifc_type` comes from a bare [`IfcType::from_str`]. Supported
279/// supported schema names arrive as exact variants. A genuinely unknown name,
280/// including an exporter stratum alias, remains `IfcType::Unknown`, which owns
281/// its normalized keyword alongside its CRC32 ID. The raw record is only the
282/// fallback when a decoder has not retained a recoverable keyword.
283///
284/// `decoded` is returned unchanged when it is already a known type, so the
285/// scan is paid only by entities that need it, and when the record is
286/// malformed enough that no keyword can be read.
287///
288/// Exists because the wasm mesh batch had exactly this shape and got it wrong:
289/// unknown keywords reached the browser labelled `"Unknown"` even when the
290/// raw record could identify them. The raw-record fallback keeps both paths
291/// aligned (#3179).
292pub fn ifc_type_from_record(decoded: IfcType, record: &[u8]) -> IfcType {
293    // #4203: supported schema names have exact enum variants. The raw record
294    // is only needed when the decoder preserved an owned unknown value.
295    if !matches!(decoded, IfcType::Unknown(_)) {
296        return decoded;
297    }
298    match crate::fast_parse::extract_entity_type_name(record) {
299        Some(name) => ifc_type_from_keyword(name),
300        None => decoded,
301    }
302}
303
304fn compute_is_simple(upper: &str) -> bool {
305    let t = ifc_type_from_keyword(upper);
306
307    // Anything not in the modern schema defaults to "simple" priority,
308    // matching the original blacklist's "anything else is simple" behaviour.
309    if matches!(t, IfcType::Unknown(_)) {
310        return true;
311    }
312
313    let is_secondary = t.is_subtype_of(IfcType::IfcOpeningElement)
314        || t.is_subtype_of(IfcType::IfcWindow)
315        || t.is_subtype_of(IfcType::IfcDoor)
316        || t.is_subtype_of(IfcType::IfcFurnishingElement)
317        // Covers IfcEnergyConversionDevice + IfcSolarDevice + every Flow*
318        // and every MEP terminal — all inherit from IfcDistributionElement.
319        || t.is_subtype_of(IfcType::IfcDistributionElement)
320        || matches!(
321            t,
322            // Spatial elements that have geometry but aren't structural.
323            IfcType::IfcSpace
324                | IfcType::IfcSpatialZone
325                | IfcType::IfcSite
326                // Annotations / virtual / proxy.
327                | IfcType::IfcAnnotation
328                | IfcType::IfcVirtualElement
329                | IfcType::IfcBuildingElementProxy
330                // IFC2X3-only keywords the registry parents outside the arms
331                // above (#5180). `IfcProxy` sits on IfcProduct, not on
332                // IfcBuildingElementProxy; `IfcEquipmentElement` sits on
333                // IfcElement although its IFC4 successors are all
334                // IfcDistributionElement subtypes. The retired legacy table
335                // routed them through those two types, so both were deferred.
336                | IfcType::IfcProxy
337                | IfcType::IfcEquipmentElement
338        );
339
340    !is_secondary
341}
342
343#[cfg(test)]
344#[path = "schema_helpers_tests.rs"]
345mod tests;