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;