Skip to main content

ifc_lite_processing/processor/
mod.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//! IFC processing service with parallel geometry extraction.
6//!
7//! Originally contributed by Mathias Søndergaard (Sonderwoods/Linkajou).
8
9use crate::types::mesh::{InstanceRecord, MeshData, RawInstanceOccurrence};
10use crate::types::response::{
11    CoordinateInfo, ModelMetadata, ProcessingStats, QuickMetadataBootstrap,
12    QuickMetadataEntitySummary,
13};
14use ifc_lite_core::{
15    DecodedEntity, EntityDecoder,
16    EntityIndex, EntityScanner, IfcType,
17};
18use ifc_lite_geometry::TessellationQuality;
19use ifc_lite_geometry::GeometryRouter;
20use rayon::prelude::*;
21use rustc_hash::{FxHashMap, FxHashSet};
22use std::collections::{BTreeMap, HashMap, HashSet};
23use std::sync::Arc;
24
25mod color_layer;
26mod instancing;
27mod jobs;
28mod opening_filter;
29mod properties;
30mod quick_metadata;
31mod site_local;
32
33pub use site_local::convert_mesh_to_site_local;
34
35use jobs::{build_color_updates_for_jobs, process_entity_job};
36
37use color_layer::{
38    collect_presentation_layer_assignments, resolve_element_color_for_product_definition_shape,
39    resolve_presentation_layer_for_product_definition_shape,
40};
41use opening_filter::apply_opening_filter;
42use properties::resolve_space_zone_properties_lazy;
43use quick_metadata::{
44    build_quick_spatial_tree_node, extract_name_from_args, extract_storey_elevation_from_args,
45    is_quick_spatial_type_ci, parse_step_arguments, parse_step_ref, parse_step_ref_list,
46    QuickSpatialNodeEntry,
47};
48use site_local::{
49    translation_is_nonidentity, MODEL_RTC_MESH_COORDINATE_SPACE, RAW_IFC_MESH_COORDINATE_SPACE,
50    SITE_LOCAL_MESH_COORDINATE_SPACE,
51};
52
53/// Wall-clock timer for diagnostic `ProcessingStats`. On wasm32
54/// `std::time::Instant::now()` traps ("time not implemented on this platform"),
55/// so the non-streaming `process_geometry*` entry points (used by the in-browser
56/// Rust exporters via `ifc-lite-export`) would panic. Timing is purely diagnostic,
57/// so on wasm32 this is a zero-duration no-op; on native it IS `std::time::Instant`
58/// (identical behaviour, no overhead).
59#[cfg(not(target_arch = "wasm32"))]
60use std::time::Instant as Clock;
61
62#[cfg(target_arch = "wasm32")]
63#[derive(Clone, Copy)]
64struct Clock;
65
66#[cfg(target_arch = "wasm32")]
67impl Clock {
68    #[inline]
69    fn now() -> Self {
70        Clock
71    }
72    #[inline]
73    fn elapsed(&self) -> std::time::Duration {
74        std::time::Duration::ZERO
75    }
76}
77
78/// Controls how IfcWindow / IfcDoor openings are exported.
79#[derive(Debug, Clone, Copy, PartialEq, Default, serde::Deserialize)]
80#[serde(rename_all = "snake_case")]
81pub enum OpeningFilterMode {
82    /// Export all openings and cut their voids in host walls (default behaviour).
83    #[default]
84    Default = 0,
85    /// Skip all IfcWindow / IfcDoor meshes and do not cut any voids.
86    IgnoreAll = 1,
87    /// Skip only opaque (non-glazed) windows and doors; glazed ones are kept.
88    IgnoreOpaque = 2,
89}
90
91impl OpeningFilterMode {
92    /// Stable string suffix for disk-cache keys. Unlike `Debug` formatting,
93    /// this is guaranteed not to change across compiler versions.
94    pub fn cache_key_suffix(&self) -> &'static str {
95        match self {
96            Self::Default => "default",
97            Self::IgnoreAll => "ignore_all",
98            Self::IgnoreOpaque => "ignore_opaque",
99        }
100    }
101}
102
103/// Result of processing an IFC file.
104pub struct ProcessingResult {
105    pub meshes: Vec<MeshData>,
106    /// #1623 Phase 2 don't-bake output: per-occurrence instance records emitted when
107    /// `StreamingOptions.enable_instancing` is set. Each non-template occurrence of a
108    /// repeated single-solid `IfcRepresentationMap` skipped its full materialize; the
109    /// template MeshData stays in `meshes` (keyed by `InstanceRecord.template_express_id`)
110    /// and each occurrence here places it by a template-relative transform. Always
111    /// empty when instancing is off, so exporters/determinism see the flat output.
112    pub instances: Vec<InstanceRecord>,
113    /// Declares the coordinate space used by serialized mesh vertices.
114    pub mesh_coordinate_space: Option<String>,
115    /// IfcSite ObjectPlacement as column-major 4x4 matrix (in meters).
116    pub site_transform: Option<Vec<f64>>,
117    /// IfcBuilding ObjectPlacement as column-major 4x4 matrix (in meters).
118    pub building_transform: Option<Vec<f64>>,
119    pub metadata: ModelMetadata,
120    pub stats: ProcessingStats,
121}
122
123/// Controls the tradeoff between first-frame latency and richer upfront metadata.
124// Not `Copy`: `entity_index` holds an `Arc`. Every construction either moves the
125// struct into a process call once or `..Default::default()`s it, so `Clone` suffices.
126#[derive(Debug, Clone)]
127pub struct StreamingOptions {
128    /// Batch size used for the very first emitted chunk.
129    pub initial_batch_size: usize,
130    /// Batch size used after the first emitted chunk for higher throughput.
131    pub throughput_batch_size: usize,
132    /// Prioritize cheap/high-yield element classes first.
133    pub fast_first_batch: bool,
134    /// Include expensive property parsing on the first-frame path.
135    pub include_properties: bool,
136    /// Include expensive presentation-layer resolution on the first-frame path.
137    pub include_presentation_layers: bool,
138    /// Emit a lightweight spatial bootstrap during the scan phase.
139    pub emit_quick_metadata_bootstrap: bool,
140    /// Retain emitted meshes in the returned ProcessingResult.
141    pub retain_emitted_meshes: bool,
142    /// Tessellation detail level (#976). `Medium` reproduces the historical
143    /// output byte-for-byte; consumer-selectable on the wasm path via
144    /// `setTessellationQuality`, and on the server via the
145    /// `tessellation_quality` query parameter. 2D symbolic extraction
146    /// (`symbolic.rs`) deliberately ignores the level — symbols are
147    /// resolution-independent line work.
148    pub tessellation_quality: TessellationQuality,
149    /// Pre-built entity index to reuse instead of scanning `content` again. A caller
150    /// that runs both the geometry pass and a second pass over the same bytes (e.g. a
151    /// server emitting GLB *and* extracting properties) can `build_entity_index`
152    /// once and inject it here, skipping the duplicate SIMD scan. `None` builds it
153    /// internally, exactly as before. The index MUST come from
154    /// `build_entity_index(content)` for the *same* `content`, or decoding will read
155    /// the wrong byte ranges.
156    pub entity_index: Option<Arc<EntityIndex>>,
157    /// Cooperative cancellation: when the flag flips true, the streaming core
158    /// stops between job chunks (no further meshing or batch emission). The
159    /// returned `ProcessingResult` is then PARTIAL - the caller set the flag,
160    /// so it must not present the result as a completed parse. Used by the
161    /// server to stop burning a core when an SSE client disconnects.
162    pub cancel: Option<Arc<std::sync::atomic::AtomicBool>>,
163    /// #1623 Phase 2 "don't-bake" instancing. When `true` (AND
164    /// `retain_emitted_meshes`), the geometry phase meshes each repeated single-solid
165    /// `IfcRepresentationMap` ONCE (a template occurrence) and emits every OTHER
166    /// occurrence as a lightweight `InstanceRecord` (placement + colour + id) instead
167    /// of materializing a full world-space mesh per occurrence — killing the
168    /// per-occurrence 43M-vertex bake on mapped-item-heavy models. `false` (default)
169    /// reproduces the historical materialized output byte-for-byte, so determinism /
170    /// parity / every exporter are unaffected (none arm this).
171    pub enable_instancing: bool,
172}
173
174impl Default for StreamingOptions {
175    fn default() -> Self {
176        Self {
177            initial_batch_size: 50,
178            throughput_batch_size: 50,
179            fast_first_batch: false,
180            include_properties: true,
181            include_presentation_layers: true,
182            emit_quick_metadata_bootstrap: false,
183            retain_emitted_meshes: true,
184            tessellation_quality: TessellationQuality::default(),
185            entity_index: None,
186            cancel: None,
187            enable_instancing: false,
188        }
189    }
190}
191
192/// Job for processing a single entity.
193pub(super) struct EntityJob {
194    pub(super) id: u32,
195    pub(super) ifc_type: IfcType,
196    pub(super) start: usize,
197    pub(super) end: usize,
198    pub(super) product_definition_shape_id: Option<u32>,
199    pub(super) element_color: [f32; 4],
200    pub(super) global_id: Option<String>,
201    pub(super) name: Option<String>,
202    pub(super) presentation_layer: Option<String>,
203    pub(super) space_zone_properties: Option<BTreeMap<String, String>>,
204    /// Set for synthetic type-only-geometry jobs (#957): the `IfcRepresentationMap`
205    /// id to render directly (baking its MappingOrigin) instead of walking the
206    /// element's `IfcProductDefinitionShape`. `None` for ordinary product jobs.
207    pub(super) representation_map_id: Option<u32>,
208}
209
210// Only invoked on the wasm32 serial path; dead on the native build.
211#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
212// Threads the full metadata-resolution context; splitting it would not improve clarity.
213#[allow(clippy::too_many_arguments)]
214fn populate_entity_job_metadata(
215    job: &mut EntityJob,
216    geometry_style_index: &FxHashMap<u32, GeometryStyleInfo>,
217    element_material_color: &FxHashMap<u32, [f32; 4]>,
218    layer_by_assigned_representation: &FxHashMap<u32, String>,
219    color_cache_by_product_definition_shape: &mut FxHashMap<u32, Option<[f32; 4]>>,
220    layer_cache_by_product_definition_shape: &mut FxHashMap<u32, Option<String>>,
221    layer_cache_by_representation: &mut FxHashMap<u32, Option<String>>,
222    decoder: &mut EntityDecoder,
223    include_presentation_layers: bool,
224) {
225    if job.global_id.is_some() || job.name.is_some() || job.product_definition_shape_id.is_some() {
226        return;
227    }
228
229    let Ok(entity) = decoder.decode_at(job.start, job.end) else {
230        return;
231    };
232
233    job.global_id = normalize_optional_string(entity.get_string(0));
234    job.name = normalize_optional_string(entity.get_string(2));
235    job.product_definition_shape_id = entity.get_ref(6);
236
237    let Some(product_definition_shape_id) = job.product_definition_shape_id else {
238        return;
239    };
240
241    let resolved_color = color_cache_by_product_definition_shape
242        .entry(product_definition_shape_id)
243        .or_insert_with(|| {
244            resolve_element_color_for_product_definition_shape(
245                product_definition_shape_id,
246                geometry_style_index,
247                decoder,
248            )
249        });
250    if let Some(color) = resolved_color {
251        job.element_color = *color;
252    } else if let Some(color) = element_material_color.get(&job.id) {
253        job.element_color = *color;
254    }
255
256    if include_presentation_layers {
257        let resolved_layer = layer_cache_by_product_definition_shape
258            .entry(product_definition_shape_id)
259            .or_insert_with(|| {
260                resolve_presentation_layer_for_product_definition_shape(
261                    product_definition_shape_id,
262                    layer_by_assigned_representation,
263                    layer_cache_by_representation,
264                    decoder,
265                )
266            });
267        job.presentation_layer = resolved_layer.clone();
268    }
269}
270
271// `GeometryStyleInfo` moved to `crate::style` — it is shared by this
272// orchestrator, the canonical per-element producer (`crate::element`), and
273// (via `from_color`) the browser batch path.
274use crate::style::GeometryStyleInfo;
275
276/// Extract entity references from a list attribute.
277pub(crate) fn get_refs_from_list(entity: &DecodedEntity, index: usize) -> Option<Vec<u32>> {
278    let list = entity.get_list(index)?;
279    let refs: Vec<u32> = list.iter().filter_map(|v| v.as_entity_ref()).collect();
280    if refs.is_empty() {
281        None
282    } else {
283        Some(refs)
284    }
285}
286
287pub(super) fn normalize_optional_string(raw: Option<&str>) -> Option<String> {
288    let value = raw?.trim();
289    if value.is_empty() || value == "$" {
290        return None;
291    }
292    Some(value.to_string())
293}
294
295fn geometry_priority_score(ifc_type: &IfcType) -> u8 {
296    match ifc_type {
297        IfcType::IfcWall | IfcType::IfcWallStandardCase => 100,
298        IfcType::IfcSlab => 95,
299        IfcType::IfcColumn => 90,
300        IfcType::IfcBeam => 85,
301        IfcType::IfcRoof => 80,
302        IfcType::IfcStair | IfcType::IfcStairFlight => 75,
303        IfcType::IfcCurtainWall => 70,
304        IfcType::IfcFooting | IfcType::IfcPile => 65,
305        IfcType::IfcDoor | IfcType::IfcWindow => 30,
306        IfcType::IfcFurnishingElement => 10,
307        _ => 50,
308    }
309}
310
311/// Process IFC content with parallel geometry extraction (default opening filter).
312pub fn process_geometry<T>(content: &T) -> ProcessingResult
313where
314    T: AsRef<[u8]> + ?Sized,
315{
316    process_geometry_filtered(content.as_ref(), OpeningFilterMode::Default)
317}
318
319/// Like [`process_geometry`] but reuses a pre-built entity index instead of scanning
320/// `content` for one. For a caller that also runs a second pass over the same bytes
321/// (e.g. pairing GLB export with attribute extraction): build the index once with
322/// `ifc_lite_core::build_entity_index` and share it across both, skipping the
323/// duplicate scan. `index` MUST be built from the same `content`. Output is identical
324/// to `process_geometry(content)`.
325pub fn process_geometry_with_index<T>(content: &T, index: Arc<EntityIndex>) -> ProcessingResult
326where
327    T: AsRef<[u8]> + ?Sized,
328{
329    process_geometry_streaming_filtered_with_options(
330        content.as_ref(),
331        OpeningFilterMode::Default,
332        StreamingOptions {
333            initial_batch_size: usize::MAX,
334            throughput_batch_size: usize::MAX,
335            entity_index: Some(index),
336            ..StreamingOptions::default()
337        },
338        |_, _, _| {},
339        |_| {},
340        |_| {},
341    )
342}
343
344/// Process IFC content with parallel geometry extraction and emit batches as they complete.
345pub fn process_geometry_streaming(
346    content: &[u8],
347    batch_size: usize,
348    on_batch: impl FnMut(&[MeshData], usize, usize),
349) -> ProcessingResult {
350    process_geometry_streaming_with_options(
351        content,
352        StreamingOptions {
353            initial_batch_size: batch_size,
354            throughput_batch_size: batch_size,
355            ..StreamingOptions::default()
356        },
357        on_batch,
358        |_| {},
359    )
360}
361
362/// Process IFC content with parallel geometry extraction and configurable streaming behavior.
363pub fn process_geometry_streaming_with_options(
364    content: &[u8],
365    options: StreamingOptions,
366    on_batch: impl FnMut(&[MeshData], usize, usize),
367    on_color_update: impl FnMut(&[(u32, [f32; 4])]),
368) -> ProcessingResult {
369    process_geometry_streaming_with_options_and_bootstrap(
370        content,
371        options,
372        on_batch,
373        on_color_update,
374        |_| {},
375    )
376}
377
378/// Process IFC content with parallel geometry extraction and emit a quick metadata bootstrap
379/// once the scan phase completes.
380pub fn process_geometry_streaming_with_options_and_bootstrap(
381    content: &[u8],
382    options: StreamingOptions,
383    on_batch: impl FnMut(&[MeshData], usize, usize),
384    on_color_update: impl FnMut(&[(u32, [f32; 4])]),
385    on_quick_metadata_bootstrap: impl FnMut(&QuickMetadataBootstrap),
386) -> ProcessingResult {
387    process_geometry_streaming_filtered_with_options(
388        content,
389        OpeningFilterMode::Default,
390        options,
391        on_batch,
392        on_color_update,
393        on_quick_metadata_bootstrap,
394    )
395}
396
397/// Process IFC content with parallel geometry extraction and a configurable opening filter.
398pub fn process_geometry_filtered<T>(
399    content: &T,
400    opening_filter: OpeningFilterMode,
401) -> ProcessingResult
402where
403    T: AsRef<[u8]> + ?Sized,
404{
405    process_geometry_filtered_with_quality(content, opening_filter, TessellationQuality::default())
406}
407
408/// Like [`process_geometry_filtered`] with a consumer-selected tessellation
409/// detail level (#976) — the server half of the quality knob the wasm path
410/// exposes via `setTessellationQuality`.
411pub fn process_geometry_filtered_with_quality<T>(
412    content: &T,
413    opening_filter: OpeningFilterMode,
414    tessellation_quality: TessellationQuality,
415) -> ProcessingResult
416where
417    T: AsRef<[u8]> + ?Sized,
418{
419    let content = content.as_ref();
420    process_geometry_streaming_filtered_with_options(
421        content,
422        opening_filter,
423        StreamingOptions {
424            initial_batch_size: usize::MAX,
425            throughput_batch_size: usize::MAX,
426            tessellation_quality,
427            ..StreamingOptions::default()
428        },
429        |_, _, _| {},
430        |_| {},
431        |_| {},
432    )
433}
434
435/// Process IFC content with parallel geometry extraction and a configurable streaming batch size.
436pub fn process_geometry_streaming_filtered(
437    content: &[u8],
438    opening_filter: OpeningFilterMode,
439    batch_size: usize,
440    on_batch: impl FnMut(&[MeshData], usize, usize),
441    on_color_update: impl FnMut(&[(u32, [f32; 4])]),
442) -> ProcessingResult {
443    process_geometry_streaming_filtered_with_options(
444        content,
445        opening_filter,
446        StreamingOptions {
447            initial_batch_size: batch_size,
448            throughput_batch_size: batch_size,
449            ..StreamingOptions::default()
450        },
451        on_batch,
452        on_color_update,
453        |_| {},
454    )
455}
456
457/// Process IFC content with parallel geometry extraction and configurable streaming behavior.
458pub fn process_geometry_streaming_filtered_with_options(
459    content: &[u8],
460    opening_filter: OpeningFilterMode,
461    options: StreamingOptions,
462    mut on_batch: impl FnMut(&[MeshData], usize, usize),
463    mut on_color_update: impl FnMut(&[(u32, [f32; 4])]),
464    mut on_quick_metadata_bootstrap: impl FnMut(&QuickMetadataBootstrap),
465) -> ProcessingResult {
466    let total_start = Clock::now();
467    let parse_start = Clock::now();
468    let entity_scan_start = Clock::now();
469
470    // Span taxonomy for the pipeline phases. Each phase span mirrors an
471    // existing ProcessingStats timer window (instrumentation only, no
472    // restructuring); `phase_ms` / count fields are recorded post-hoc from the
473    // measurements the pipeline already takes. Field names reuse the
474    // GeometryDiagnostics vocabulary (total_csg_failures, backstop_count, ...)
475    // so events and the wasm PipelineDiagnostics channel share one vocabulary.
476    let pipeline_span = tracing::info_span!(
477        "geometry_pipeline",
478        byte_size = content.len(),
479        element_count = tracing::field::Empty,
480        total_ms = tracing::field::Empty,
481    );
482    let _pipeline_guard = pipeline_span.clone().entered();
483
484    tracing::info!(
485        content_size = content.len(),
486        "Starting IFC geometry processing"
487    );
488
489    let scan_span = tracing::info_span!(
490        "scan_prepass",
491        total_entities = tracing::field::Empty,
492        geometry_entities = tracing::field::Empty,
493        phase_ms = tracing::field::Empty,
494    );
495    let scan_guard = scan_span.clone().entered();
496
497    // The entity index (expressId -> byte span) is built INLINE in the scan loop
498    // below rather than in a separate `build_entity_index` pass, so the file is
499    // walked once instead of twice. A caller that injected an index reuses it and
500    // skips the inline build. `decode_at` during the scan needs no index (it parses
501    // local bytes), so the scan-phase decoder starts index-less; the completed
502    // index is installed before the first ref-resolving call (`resolve_prepass`).
503    let provided_index = options.entity_index.clone();
504    let building_index = provided_index.is_none();
505    let mut inline_index: EntityIndex = if building_index {
506        FxHashMap::with_capacity_and_hasher(content.len() / 50, Default::default())
507    } else {
508        FxHashMap::default()
509    };
510    let mut decoder = match &provided_index {
511        Some(idx) => EntityDecoder::with_arc_index(content, idx.clone()),
512        None => EntityDecoder::new(content),
513    };
514    tracing::debug!("Entity index will be built inline during the scan");
515
516    // Styled items / indexed colour maps / material chain / voids / fills /
517    // aggregates are span-stashed during the scan and resolved afterwards by
518    // the SHARED resolver (`crate::prepass::resolve_prepass`) — the exact code
519    // the browser prepasses run, so the #858/#913-class resolution drift
520    // cannot recur.
521    let mut prepass_spans = crate::prepass::PrepassSpans::default();
522    let mut project_id: Option<u32> = None;
523    let mut presentation_layer_by_assigned_id: FxHashMap<u32, String> = FxHashMap::default();
524    // Space/zone property resolution is demand-driven (see the lookup phase and
525    // `resolve_space_zone_properties_lazy`). The scan only stashes the
526    // IfcRelDefinesByProperties spans; property sets and property atoms are
527    // decoded later, and only for the handful a space/zone actually references —
528    // skipping the eager decode of ~25% of a model's entities that was the
529    // dominant single-threaded cold-load cost on parse-bound models.
530    let mut rel_defines_spans: Vec<(usize, usize)> = Vec::new();
531
532    // Collect geometry entities
533    let mut scanner = EntityScanner::new(content);
534    let mut entity_jobs: Vec<EntityJob> = Vec::with_capacity(2000);
535    // #957: type-product geometry (IfcXxxType + its RepresentationMaps) and the
536    // set of RepresentationMaps already instantiated by an IfcMappedItem. After
537    // the scan, RepresentationMaps NOT in the referenced set are rendered as
538    // orphan type geometry (buildingSMART annex-E showcase files).
539    let mut type_product_geometry: Vec<(u32, usize, usize, IfcType, Vec<u32>)> = Vec::new();
540    let mut referenced_representation_maps: FxHashSet<u32> = FxHashSet::default();
541    // #1623 Phase 2 don't-bake plan (built only when `enable_instancing`):
542    // `IfcRepresentationMap` id ⇒ (occurrence count, min IfcMappedItem express id).
543    // The min-id occurrence is the deterministic template that materializes; the
544    // rest instance against it. Filtered to count >= 2 after the scan.
545    let mut mapped_item_plan: FxHashMap<u32, (u32, u32)> = FxHashMap::default();
546    // #957 follow-up: type ids that an IfcRelDefinesByType instantiates (the type
547    // has at least one occurrence). Such a type's geometry is already drawn through
548    // its occurrences — directly or via an IfcMappedItem — so it must NOT also be
549    // rendered as orphan type-only geometry. Real-world exporters (e.g. ArchiCAD
550    // AC20) attach a RepresentationMap to nearly every door/window/furniture type
551    // while the occurrence carries its own body, leaving the type map referenced by
552    // no IfcMappedItem; without this gate every such type double-renders at its
553    // MappingOrigin (duplicate boxes at the wrong position).
554    let mut instantiated_type_ids: FxHashSet<u32> = FxHashSet::default();
555    let quick_metadata_enabled = options.emit_quick_metadata_bootstrap;
556    let mut quick_spatial_nodes =
557        quick_metadata_enabled.then(HashMap::<u32, QuickSpatialNodeEntry>::new);
558    let mut quick_aggregate_links = if quick_metadata_enabled {
559        Vec::<(u32, Vec<u32>)>::new()
560    } else {
561        Vec::new()
562    };
563    let mut quick_containment_links = if quick_metadata_enabled {
564        Vec::<(u32, Vec<u32>)>::new()
565    } else {
566        Vec::new()
567    };
568    // IfcRelReferencedInSpatialStructure is a *secondary* (non-owning) link — a
569    // space referenced from another storey for context. It must NOT establish
570    // primary tree ownership, so it is kept separate from containment links and
571    // only ever contributes elements, never parent/child node ownership (#1075).
572    let mut quick_referenced_links = if quick_metadata_enabled {
573        Vec::<(u32, Vec<u32>)>::new()
574    } else {
575        Vec::new()
576    };
577    let mut quick_element_summaries = if quick_metadata_enabled {
578        HashMap::<u32, QuickMetadataEntitySummary>::new()
579    } else {
580        HashMap::new()
581    };
582    let mut schema_version = "IFC2X3".to_string();
583    let mut total_entities = 0usize;
584    let mut site_entity_pos: Option<(usize, usize)> = None;
585    let mut building_entity_pos: Option<(usize, usize)> = None;
586
587    let defer_style_updates = options.fast_first_batch
588        && opening_filter == OpeningFilterMode::Default
589        && !options.include_presentation_layers;
590
591    while let Some((id, type_name, start, end)) = scanner.next_entity() {
592        total_entities += 1;
593        if building_index {
594            inline_index.insert(id, (start, end));
595        }
596        if let Some(spatial_nodes) = quick_spatial_nodes.as_mut() {
597            // Case-insensitive check without allocating a new uppercase string.
598            if is_quick_spatial_type_ci(type_name) {
599                let args = parse_step_arguments(&content[start..end]);
600                let fallback = format!("{type_name} #{id}");
601                spatial_nodes.entry(id).or_insert(QuickSpatialNodeEntry {
602                    express_id: id,
603                    type_name: type_name.to_string(),
604                    name: extract_name_from_args(&args, &fallback),
605                    elevation: if type_name.eq_ignore_ascii_case("IfcBuildingStorey") {
606                        extract_storey_elevation_from_args(&args)
607                    } else {
608                        None
609                    },
610                    children: Vec::new(),
611                    elements: Vec::new(),
612                    parent: None,
613                });
614            } else if type_name.eq_ignore_ascii_case("IFCRELAGGREGATES") {
615                let args = parse_step_arguments(&content[start..end]);
616                if let Some(parent_id) = args.get(4).and_then(|token| parse_step_ref(token)) {
617                    quick_aggregate_links.push((
618                        parent_id,
619                        args.get(5)
620                            .map(|token| parse_step_ref_list(token))
621                            .unwrap_or_default(),
622                    ));
623                }
624            } else if type_name.eq_ignore_ascii_case("IFCRELCONTAINEDINSPATIALSTRUCTURE") {
625                let args = parse_step_arguments(&content[start..end]);
626                if let Some(parent_id) = args.get(5).and_then(|token| parse_step_ref(token)) {
627                    quick_containment_links.push((
628                        parent_id,
629                        args.get(4)
630                            .map(|token| parse_step_ref_list(token))
631                            .unwrap_or_default(),
632                    ));
633                }
634            } else if type_name.eq_ignore_ascii_case("IFCRELREFERENCEDINSPATIALSTRUCTURE") {
635                let args = parse_step_arguments(&content[start..end]);
636                if let Some(parent_id) = args.get(5).and_then(|token| parse_step_ref(token)) {
637                    quick_referenced_links.push((
638                        parent_id,
639                        args.get(4)
640                            .map(|token| parse_step_ref_list(token))
641                            .unwrap_or_default(),
642                    ));
643                }
644            }
645        }
646
647        if type_name == "IFCINDEXEDCOLOURMAP" {
648            // Span-stashed for the shared post-scan resolver (#663, #858).
649            prepass_spans.indexed_colour_maps.push((id, start, end));
650            continue;
651        }
652
653        if type_name == "IFCSTYLEDITEM" {
654            // Span-stashed; the shared resolver classifies orphan (material
655            // appearance, #407 — always resolved up front) vs
656            // geometry-attached (deferred in fast_first_batch mode, #913 §2c).
657            prepass_spans.styled_items.push((id, start, end));
658            continue;
659        } else if type_name == "IFCMATERIALDEFINITIONREPRESENTATION" {
660            prepass_spans.material_def_reprs.push((id, start, end));
661            continue;
662        } else if type_name == "IFCRELASSOCIATESMATERIAL" {
663            prepass_spans.rel_associates_material.push((id, start, end));
664            continue;
665        } else if type_name == "IFCPRESENTATIONLAYERASSIGNMENT" {
666            if !options.include_presentation_layers {
667                continue;
668            }
669            if let Ok(layer_assignment) = decoder.decode_at(start, end) {
670                collect_presentation_layer_assignments(
671                    &mut presentation_layer_by_assigned_id,
672                    &layer_assignment,
673                );
674            }
675            continue;
676        } else if type_name == "IFCPROPERTYSET" {
677            // Decoded lazily in the lookup phase, and only when referenced by a
678            // space/zone (its sole consumer). The inline index holds the span.
679            continue;
680        } else if type_name == "IFCRELDEFINESBYPROPERTIES" {
681            if options.include_properties {
682                rel_defines_spans.push((start, end));
683            }
684            continue;
685        } else if type_name.starts_with("IFCPROPERTY") {
686            // Individual property values are resolved lazily by id in the lookup
687            // phase (only those a referenced space/zone property set lists).
688            continue;
689        } else if type_name == "IFCRELVOIDSELEMENT" {
690            prepass_spans.void_rels.push((id, start, end));
691        } else if type_name == "IFCRELFILLSELEMENT" {
692            prepass_spans.fills_rels.push((id, start, end));
693        } else if type_name == "IFCRELAGGREGATES" {
694            // Independent of quick-metadata mode: the shared resolver decodes
695            // these into the parent → children map that pushes voids down to
696            // aggregated parts when the host has no body of its own
697            // (IfcWallElementedCase, #845).
698            prepass_spans.aggregate_rels.push((id, start, end));
699        } else if type_name == "IFCPROJECT" && project_id.is_none() {
700            project_id = Some(id);
701        } else if type_name == "IFCSITE" && site_entity_pos.is_none() {
702            site_entity_pos = Some((start, end));
703        } else if type_name == "IFCBUILDING" && building_entity_pos.is_none() {
704            building_entity_pos = Some((start, end));
705        }
706
707        if ifc_lite_core::has_geometry_by_name(type_name) {
708            // Legacy-aware so a remapped entity (IfcProxy, IfcSolidStratum, …)
709            // labels its node with the real base type, not "Unknown", and matches
710            // the attribute pass's row type (#1496).
711            let ifc_type = ifc_lite_core::legacy_aware_ifc_type(type_name);
712            if quick_metadata_enabled {
713                quick_element_summaries.insert(
714                    id,
715                    QuickMetadataEntitySummary {
716                        express_id: id,
717                        type_name: type_name.to_string(),
718                        name: format!("{type_name} #{id}"),
719                        global_id: None,
720                        kind: "element".to_string(),
721                        has_children: false,
722                        element_count: None,
723                        elevation: None,
724                    },
725                );
726            }
727            entity_jobs.push(EntityJob {
728                id,
729                ifc_type,
730                start,
731                end,
732                product_definition_shape_id: None,
733                element_color: crate::style::default_color_for_type(ifc_type).to_array(),
734                global_id: None,
735                name: None,
736                presentation_layer: None,
737                space_zone_properties: None,
738                representation_map_id: None,
739            });
740        }
741        // #957: collect type-product geometry (IfcXxxType carrying its own
742        // RepresentationMaps) and every IfcMappedItem's MappingSource, so after
743        // the scan we can render the RepresentationMaps that NO occurrence
744        // instantiates (orphan library/showcase geometry). The cheap suffix
745        // pre-filter keeps the is_subtype_of check off the hot path for the
746        // ~all-non-type majority of entities.
747        else if type_name == "IFCMAPPEDITEM" {
748            let args = parse_step_arguments(&content[start..end]);
749            if let Some(source_id) = args.first().and_then(|token| parse_step_ref(token)) {
750                referenced_representation_maps.insert(source_id);
751                // #1623 Phase 2: tally occurrences per source + track the min-id
752                // (deterministic template) occurrence. `id` is this IfcMappedItem's
753                // express id (the router's `item.id` at mesh time).
754                if options.enable_instancing {
755                    mapped_item_plan
756                        .entry(source_id)
757                        .and_modify(|(count, template)| {
758                            *count += 1;
759                            if id < *template {
760                                *template = id;
761                            }
762                        })
763                        .or_insert((1, id));
764                }
765            }
766        } else if type_name == "IFCRELDEFINESBYTYPE" {
767            // IfcRelDefinesByType.RelatingType is the last attribute (index 5);
768            // record it so its type-only geometry is suppressed (it has occurrences).
769            let args = parse_step_arguments(&content[start..end]);
770            if let Some(type_id) = args.get(5).and_then(|token| parse_step_ref(token)) {
771                instantiated_type_ids.insert(type_id);
772            }
773        } else if (type_name.ends_with("TYPE") || type_name.ends_with("STYLE"))
774            && IfcType::from_str(type_name).is_subtype_of(IfcType::IfcTypeProduct)
775        {
776            let args = parse_step_arguments(&content[start..end]);
777            // IfcTypeProduct.RepresentationMaps is attribute index 6.
778            let rep_map_ids = args
779                .get(6)
780                .map(|token| parse_step_ref_list(token))
781                .unwrap_or_default();
782            if !rep_map_ids.is_empty() {
783                type_product_geometry.push((
784                    id,
785                    start,
786                    end,
787                    IfcType::from_str(type_name),
788                    rep_map_ids,
789                ));
790            }
791        }
792    }
793
794    // #957: synthesize render jobs for orphan type-product geometry — a
795    // RepresentationMap on an IfcXxxType that no IfcMappedItem instantiates.
796    // Normally-instanced typed products keep their geometry on the occurrence
797    // (whose IfcMappedItem references the map), so those maps are in
798    // `referenced_representation_maps` and skipped here — no double render.
799    // buildingSMART annex-E "tessellated shape with style" files declare the
800    // geometry only on the type, so without this they render nothing (#957).
801    for (type_id, start, end, ifc_type, rep_map_ids) in &type_product_geometry {
802        // The orphan/instanced decision is canonical in
803        // `element::plan_type_geometry`; the native pipeline suppresses
804        // instanced types entirely (an export must never duplicate geometry),
805        // so every planned map here renders as an orphan (class 1).
806        for (rep_map_id, _class) in crate::element::plan_type_geometry(
807            rep_map_ids,
808            &referenced_representation_maps,
809            instantiated_type_ids.contains(type_id),
810            crate::element::TypeGeometryMode::SuppressInstanced,
811        ) {
812            entity_jobs.push(EntityJob {
813                id: *type_id,
814                ifc_type: *ifc_type,
815                start: *start,
816                end: *end,
817                product_definition_shape_id: None,
818                element_color: crate::style::default_color_for_type(*ifc_type).to_array(),
819                global_id: None,
820                name: None,
821                presentation_layer: None,
822                space_zone_properties: None,
823                representation_map_id: Some(rep_map_id),
824            });
825        }
826    }
827
828    // The inline index is now complete — identical to `build_entity_index` over
829    // the same scanner. Install it into the decoder so `resolve_prepass` and the
830    // downstream phases resolve refs against it, and expose it (as before) to the
831    // geometry workers further down.
832    let entity_index: Arc<EntityIndex> = match provided_index {
833        Some(idx) => idx,
834        None => {
835            let arc = Arc::new(inline_index);
836            decoder.set_entity_index(arc.clone());
837            arc
838        }
839    };
840
841    // ── Shared post-scan resolution (`crate::prepass`) ──
842    // Styled items (orphan vs attached, defer-aware), IfcIndexedColourMap,
843    // the #407 material chain join, voids + fills, and the #845 aggregate
844    // void propagation — the exact code the browser prepasses run.
845    let resolved = crate::prepass::resolve_prepass(
846        &prepass_spans,
847        &mut decoder,
848        crate::prepass::ResolveOptions {
849            collect_indexed_colour_full: true,
850            defer_attached_styles: defer_style_updates,
851        },
852    );
853    let crate::prepass::ResolvedPrepass {
854        mut geometry_style_index,
855        indexed_colour_index,
856        indexed_colour_full,
857        element_material_colors,
858        void_index,
859        filling_by_opening,
860        deferred_attached_styled_spans: deferred_styled_item_positions,
861        ..
862    } = resolved;
863
864    let entity_scan_time = entity_scan_start.elapsed();
865    scan_span.record("total_entities", total_entities as u64);
866    scan_span.record("geometry_entities", entity_jobs.len() as u64);
867    scan_span.record("phase_ms", entity_scan_time.as_millis() as u64);
868    drop(scan_guard);
869
870    let lookup_start = Clock::now();
871    let lookup_span = tracing::debug_span!("lookup", phase_ms = tracing::field::Empty).entered();
872    if options.include_properties {
873        resolve_space_zone_properties_lazy(&mut entity_jobs, &mut decoder, &rel_defines_spans);
874    }
875    if options.fast_first_batch {
876        entity_jobs.sort_by(|left, right| {
877            geometry_priority_score(&right.ifc_type).cmp(&geometry_priority_score(&left.ifc_type))
878        });
879    }
880    let lookup_time = lookup_start.elapsed();
881    lookup_span.record("phase_ms", lookup_time.as_millis() as u64);
882    drop(lookup_span);
883
884    let (skipped_entity_ids, filtered_void_index) = apply_opening_filter(
885        &entity_jobs,
886        &void_index,
887        &filling_by_opening,
888        &geometry_style_index,
889        &mut decoder,
890        opening_filter,
891    );
892
893    // Detect schema version. SIMD substring search (memmem) instead of the naive
894    // per-position `windows().any()`, which walked the WHOLE file — twice for an
895    // IFC2X3 file where both matches fail. Same predicate, byte-identical result.
896    if memchr::memmem::find(content, b"IFC4X3").is_some() {
897        schema_version = "IFC4X3".into();
898    } else if memchr::memmem::find(content, b"IFC4").is_some() {
899        schema_version = "IFC4".into();
900    }
901
902    let geometry_entity_count = entity_jobs.len();
903    tracing::info!(
904        total_entities = total_entities,
905        geometry_entities = geometry_entity_count,
906        voids = void_index.len(),
907        schema_version = %schema_version,
908        "Entity scanning complete"
909    );
910
911    if let Some(mut spatial_nodes) = quick_spatial_nodes.take() {
912        for (parent_id, child_ids) in quick_aggregate_links {
913            if !spatial_nodes.contains_key(&parent_id) {
914                continue;
915            }
916            for child_id in child_ids {
917                if !spatial_nodes.contains_key(&child_id) {
918                    continue;
919                }
920                if let Some(parent) = spatial_nodes.get_mut(&parent_id) {
921                    parent.children.push(child_id);
922                }
923                if let Some(child) = spatial_nodes.get_mut(&child_id) {
924                    child.parent = Some(parent_id);
925                }
926            }
927        }
928        for (parent_id, element_ids) in quick_containment_links {
929            if !spatial_nodes.contains_key(&parent_id) {
930                continue;
931            }
932            for child_id in element_ids {
933                // A spatial element (IfcSpace / IfcSpatialZone) attached to a
934                // storey via IfcRelContainedInSpatialStructure — what Revit
935                // Family + Dynamo emits instead of IfcRelAggregates — is a real
936                // node of the spatial tree, not a contained product. Promote it
937                // to a child node so it shows in the hierarchy (#1075); anything
938                // that isn't itself a spatial node stays a contained element.
939                if spatial_nodes.contains_key(&child_id) {
940                    // Skip if already placed via IfcRelAggregates (wired just
941                    // above) to avoid a duplicate child / parent overwrite.
942                    let already_placed = spatial_nodes
943                        .get(&child_id)
944                        .is_some_and(|child| child.parent.is_some());
945                    if !already_placed {
946                        if let Some(parent) = spatial_nodes.get_mut(&parent_id) {
947                            parent.children.push(child_id);
948                        }
949                        if let Some(child) = spatial_nodes.get_mut(&child_id) {
950                            child.parent = Some(parent_id);
951                        }
952                    }
953                } else if let Some(parent) = spatial_nodes.get_mut(&parent_id) {
954                    parent.elements.push(child_id);
955                }
956            }
957        }
958        // Referenced-in links are non-owning: they only contribute elements and
959        // never promote to (or re-parent) a spatial node, so a space referenced
960        // from a second storey can't steal ownership from its containing storey.
961        for (parent_id, element_ids) in quick_referenced_links {
962            if !spatial_nodes.contains_key(&parent_id) {
963                continue;
964            }
965            for child_id in element_ids {
966                // A child that is itself a spatial node keeps the ownership it
967                // got from its IfcRelContainedInSpatialStructure/aggregate link.
968                if spatial_nodes.contains_key(&child_id) {
969                    continue;
970                }
971                if let Some(parent) = spatial_nodes.get_mut(&parent_id) {
972                    parent.elements.push(child_id);
973                }
974            }
975        }
976        let mut root_id = spatial_nodes
977            .values()
978            .find(|node| node.type_name == "IfcProject")
979            .map(|node| node.express_id);
980        if root_id.is_none() {
981            root_id = spatial_nodes
982                .values()
983                .find(|node| node.parent.is_none())
984                .map(|node| node.express_id);
985        }
986        let spatial_tree = root_id
987            .map(|root| {
988                build_quick_spatial_tree_node(root, &spatial_nodes, &quick_element_summaries)
989            })
990            .transpose()
991            .unwrap_or(None);
992        on_quick_metadata_bootstrap(&QuickMetadataBootstrap {
993            schema_version: schema_version.clone(),
994            entity_count: total_entities,
995            spatial_tree,
996        });
997    }
998
999    // Preprocess complex geometry
1000    let preprocess_start = Clock::now();
1001    let preprocess_span =
1002        tracing::debug_span!("preprocess", phase_ms = tracing::field::Empty).entered();
1003    // Resolve BOTH unit scales once via the shared resolver (the scan recorded
1004    // IFCPROJECT's id, so this is an O(1) decode — no more full-file hunts:
1005    // the historic `with_units` + `plane_angle_to_radians` pair each re-walked
1006    // the whole DATA section). Seed the shared decoder so every later consumer
1007    // (opening filter, metadata phase, deferred-style replay) inherits them.
1008    let unit_scales = tracing::debug_span!("unit_scale")
1009        .in_scope(|| crate::prepass::resolve_unit_scales(content, project_id, &mut decoder));
1010    tracing::debug!(
1011        length_unit_scale = unit_scales.length_unit_scale,
1012        plane_angle_to_radians = unit_scales.plane_angle_to_radians,
1013        "Resolved unit scales"
1014    );
1015    decoder.seed_unit_scales(
1016        unit_scales.length_unit_scale,
1017        unit_scales.plane_angle_to_radians,
1018    );
1019    let mut router = GeometryRouter::with_scale(unit_scales.length_unit_scale);
1020    router.set_tessellation_quality(options.tessellation_quality);
1021    // Build the #563 material-layer index from the IfcRelAssociatesMaterial spans
1022    // the main scan already stashed (like the wasm pre-pass), not a redundant
1023    // `from_content` re-walk of the whole file. Byte-identical; saves 6-15% load.
1024    let material_spans = &prepass_spans.rel_associates_material;
1025    router.set_material_layer_index(Arc::new(
1026        ifc_lite_geometry::MaterialLayerIndex::from_spans(material_spans, &mut decoder),
1027    ));
1028
1029    // Resolve IfcSite and IfcBuilding placement transforms.
1030    let site_transform: Option<Vec<f64>> = site_entity_pos.and_then(|(start, end)| {
1031        let entity = decoder.decode_at(start, end).ok()?;
1032        let matrix = router
1033            .resolve_scaled_placement(&entity, &mut decoder)
1034            .ok()?;
1035        Some(matrix.to_vec())
1036    });
1037    let building_transform: Option<Vec<f64>> = building_entity_pos.and_then(|(start, end)| {
1038        let entity = decoder.decode_at(start, end).ok()?;
1039        let matrix = router
1040            .resolve_scaled_placement(&entity, &mut decoder)
1041            .ok()?;
1042        Some(matrix.to_vec())
1043    });
1044
1045    let rtc_jobs: Vec<(u32, usize, usize, IfcType)> = entity_jobs
1046        .iter()
1047        .map(|job| (job.id, job.start, job.end, job.ifc_type))
1048        .collect();
1049    let detected_rtc_offset =
1050        router.detect_rtc_offset_with_fallback(&rtc_jobs, &mut decoder, content);
1051
1052    // Three-tier coordinate-space selection:
1053    //   1. `site_local`: IfcSite placement has a non-identity translation.
1054    //      Vertices are expressed relative to the site origin — small floats
1055    //      AND a meaningful, relatable frame (useful for coordination).
1056    //   2. `model_rtc`:  IfcSite is identity (or missing) but geometry still
1057    //      lives at large world coordinates. Subtract a detected anchor so
1058    //      f32 precision is preserved.
1059    //   3. `raw_ifc`:    neither anchor applies; geometry is already small.
1060    let site_rtc = site_transform
1061        .as_ref()
1062        .map(|st| (st[12], st[13], st[14])) // column-major: translation at 12,13,14
1063        .filter(|t| translation_is_nonidentity(*t));
1064    let detected_has_offset = translation_is_nonidentity(detected_rtc_offset);
1065    let (rtc_offset, coord_space) = if let Some(site) = site_rtc {
1066        (site, SITE_LOCAL_MESH_COORDINATE_SPACE)
1067    } else if detected_has_offset {
1068        (detected_rtc_offset, MODEL_RTC_MESH_COORDINATE_SPACE)
1069    } else {
1070        ((0.0, 0.0, 0.0), RAW_IFC_MESH_COORDINATE_SPACE)
1071    };
1072    let has_rtc_offset = coord_space != RAW_IFC_MESH_COORDINATE_SPACE;
1073    router.set_rtc_offset(rtc_offset);
1074    let preprocess_time = preprocess_start.elapsed();
1075    preprocess_span.record("phase_ms", preprocess_time.as_millis() as u64);
1076    drop(preprocess_span);
1077
1078    let parse_time = parse_start.elapsed();
1079    tracing::info!(
1080        entity_scan_time_ms = entity_scan_time.as_millis(),
1081        lookup_time_ms = lookup_time.as_millis(),
1082        preprocess_time_ms = preprocess_time.as_millis(),
1083        parse_time_ms = parse_time.as_millis(),
1084        "Parse phase complete, starting geometry extraction"
1085    );
1086
1087    // PARALLEL GEOMETRY PROCESSING
1088    let geometry_start = Clock::now();
1089    let entity_index_arc = entity_index; // Already Arc from above
1090    let unit_scale = router.unit_scale();
1091    let rtc_offset = router.rtc_offset();
1092    // Resolve the plane-angle scale ONCE on the warm shared decoder, then seed
1093    // every per-element worker decoder below (EntityDecoder::seed_unit_scales).
1094    // Resolved once by the shared `prepass::resolve_unit_scales` above — the
1095    // parallel path builds a fresh (cold-cache) decoder per element, so
1096    // without seeding every arc-bearing element would re-pay an O(file)
1097    // IFCPROJECT scan (≈135 ms each on a 75 MB model where IFCPROJECT sits at
1098    // byte ~68 MB).
1099    let seed_plane_angle_to_radians = unit_scales.plane_angle_to_radians;
1100    let void_index_arc = Arc::new(filtered_void_index);
1101    let skipped_entity_ids = Arc::new(skipped_entity_ids);
1102    // Fold indexed-colour-map colours in where no IFCSTYLEDITEM already claimed
1103    // the geometry (styled items win, matching the browser precedence).
1104    crate::prepass::merge_indexed_colours(&mut geometry_style_index, &indexed_colour_index);
1105    let mut geometry_style_index = Arc::new(geometry_style_index);
1106    let indexed_colour_full = Arc::new(indexed_colour_full);
1107    // #961: decode surface textures (IfcBlobTexture PNG / IfcPixelTexture) and
1108    // their per-triangle UV maps once, keyed by face-set id. `build_texture_index`
1109    // bails out on a cheap substring check for the (vast majority) untextured
1110    // files. Consumed by the type-only render path below.
1111    let texture_index = Arc::new(ifc_lite_geometry::build_texture_index(
1112        content,
1113        &mut decoder,
1114    ));
1115    // Material chain joined by the shared resolver (#407). The single
1116    // opaque-first colour is the general-path element fallback; the full list
1117    // feeds the opening sub-mesh transparent/opaque split (#913 §2.3).
1118    let element_material_color: FxHashMap<u32, [f32; 4]> = element_material_colors
1119        .iter()
1120        .filter_map(|(&id, colors)| crate::style::pick_opaque_first(colors).map(|c| (id, c)))
1121        .collect();
1122    let element_material_colors = Arc::new(element_material_colors);
1123
1124    let total_jobs = entity_jobs.len();
1125    let initial_chunk_size = options.initial_batch_size.max(1);
1126    let throughput_chunk_size = options.throughput_batch_size.max(initial_chunk_size);
1127    let mut color_cache_by_product_definition_shape: FxHashMap<u32, Option<[f32; 4]>> =
1128        FxHashMap::default();
1129    let mut layer_cache_by_product_definition_shape: FxHashMap<u32, Option<String>> =
1130        FxHashMap::default();
1131    let mut layer_cache_by_representation: FxHashMap<u32, Option<String>> = FxHashMap::default();
1132    let mut meshes: Vec<MeshData> = Vec::new();
1133    let mut processed_jobs = 0usize;
1134    let mut total_meshes = 0usize;
1135    let mut total_vertices = 0usize;
1136    let mut total_triangles = 0usize;
1137    let mut chunk_start = 0usize;
1138    let mut current_chunk_size = initial_chunk_size;
1139
1140    let mut deferred_styles_applied = !defer_style_updates;
1141
1142    // CSG-diagnostics sink shared across all per-job routers (drained after
1143    // the loop into ProcessingStats + one tracing summary).
1144    let csg_failure_collector: std::sync::Mutex<FxHashMap<u32, Vec<ifc_lite_geometry::BoolFailure>>> =
1145        std::sync::Mutex::new(FxHashMap::default());
1146    // Opening-classification + per-host opening diagnostics sinks, drained from
1147    // each fresh per-job router and merged here, so the native pass can build the
1148    // SAME `GeometryDiagnostics` the WASM batch path produces. Drained from the
1149    // local router (not inside `produce_element_meshes`) because the WASM batch path
1150    // shares that function and drains classification/host from its own warm router
1151    // at batch end — draining there would empty it.
1152    let classification_collector: std::sync::Mutex<ifc_lite_geometry::ClassificationStats> =
1153        std::sync::Mutex::new(ifc_lite_geometry::ClassificationStats::default());
1154    let host_diag_collector: std::sync::Mutex<FxHashMap<u32, ifc_lite_geometry::HostOpeningDiagnostic>> =
1155        std::sync::Mutex::new(FxHashMap::default());
1156    // rect_fast engagement is now drained from each per-job router too (request-
1157    // local), so this pass's `rectFast` is isolated from any concurrent geometry
1158    // pass instead of reading process-global counters.
1159    let rect_fast_collector: std::sync::Mutex<ifc_lite_geometry::RectFastStats> =
1160        std::sync::Mutex::new(ifc_lite_geometry::RectFastStats::default());
1161    // Degenerate-backstop drop tally, summed from each element's
1162    // `ProducedElementMeshes::degenerate_triangles_dropped` (request-local,
1163    // like the other sinks). Non-zero means the f32-collapse safety net in
1164    // `element::build_mesh_data` engaged for this model.
1165    let backstop_collector = std::sync::atomic::AtomicU64::new(0);
1166
1167    // Shared content-dedup cache for the whole model: every per-job router (built
1168    // fresh per element below) dedups against it, so byte-identical geometry the
1169    // exporter failed to share via IfcMappedItem (Tekla parts) is meshed once
1170    // across the rayon pool instead of once per element. The lock is held only for
1171    // a hash get/insert; meshing runs outside it.
1172    let item_dedup_cache = GeometryRouter::new_dedup_cache();
1173
1174    // Shared IfcMappedItem source cache for the whole model (#1623): every per-job
1175    // router (built fresh per element below) meshes each RepresentationMap source
1176    // once against it, instead of once per owning element — the per-router RefCell
1177    // cache only dedups within a single element's own mapped items. The lock is
1178    // held only for a source-mesh get/insert; the meshing runs outside it.
1179    let mapped_item_cache = GeometryRouter::new_mapped_item_cache();
1180
1181    // #1623 Phase 2 don't-bake plan (Some only when enabled): filter to repeated
1182    // sources (count >= 2) — singletons materialize normally — and share it with
1183    // every per-job router via `enable_output_instancing`. Non-template occurrences
1184    // of these sources skip the per-occurrence materialize and emit an
1185    // `InstanceRecord` at finalize. Requires `retain_emitted_meshes` (the template
1186    // MeshData must survive in `meshes` for the finalize to place instances onto it).
1187    //
1188    // NOT armed for the `site_local` coordinate tier (IfcSite has a non-identity
1189    // placement). There, `build_mesh_data` drops the template's `instance_meta`
1190    // (site-local meshes are pre-transformed into the site frame via
1191    // `convert_mesh_to_site_local`, so a world-placement instance transform no
1192    // longer composes) — exactly why the renderer's own instancing (#1238) does not
1193    // instance site-local models either. Leaving the plan armed would strand every
1194    // occurrence in single-threaded finalize orphan-recovery: a perf REGRESSION on a
1195    // translated site (re-bake serially, worse than plain flat) and MISPLACED
1196    // geometry on a rotated site (orphan flats baked in the world frame while
1197    // siblings sit in the site-local frame). Route the whole model to flat instead —
1198    // correct, and no slower than today. (Extending instancing to site-local needs
1199    // the renderer to instance in the site frame too; tracked as a follow-up.)
1200    let instancing_plan: Option<ifc_lite_geometry::MappedInstancePlan> = (options.enable_instancing
1201        && options.retain_emitted_meshes
1202        && coord_space != SITE_LOCAL_MESH_COORDINATE_SPACE)
1203        .then(|| {
1204            Arc::new(
1205                mapped_item_plan
1206                    .into_iter()
1207                    .filter(|(_, (count, _))| *count >= 2)
1208                    .collect::<FxHashMap<u32, (u32, u32)>>(),
1209            )
1210        });
1211    // #858 don't-bake exclusion: geometry ids carrying a MULTI-COLOUR
1212    // IfcIndexedColourMap. A mapped source whose single solid is one of these must NOT
1213    // don't-bake — the flat path splits it into one mesh per palette group (element.rs
1214    // `emit_sub_meshes`), but an instance placeholder resolves ONE colour, collapsing
1215    // the palette. Built only when the plan is armed AND there are indexed-colour maps;
1216    // armed on every per-job router so the guard routes those occurrences to flat
1217    // (byte-identical to instancing-off). `indexed_colour_full` is keyed by the same
1218    // face-set id the router resolves as the source's single solid, so the ids line up
1219    // 1:1.
1220    //
1221    // #1807: keep only MULTI-COLOUR maps. A UNIFORM (single-colour) map never splits —
1222    // `split_mesh_by_indexed_colour` returns None below 2 distinct entries
1223    // (style/indexed_colour.rs) — so it collapses to one `dominant()`-coloured mesh an
1224    // instance carries losslessly. Excluding uniform sources bought nothing and
1225    // disabled don't-bake wholesale on models that store colour as per-triangle
1226    // IfcIndexedColourMap on shared face sets (metering stations, CATIA exports). An
1227    // all-uniform model collects nothing → leave the guard unarmed (None) rather than
1228    // pay a per-element lookup against an empty set on every router.
1229    let indexed_colour_split_ids: Option<Arc<FxHashSet<u32>>> = (instancing_plan.is_some()
1230        && !indexed_colour_full.is_empty())
1231    .then(|| {
1232        let ids: FxHashSet<u32> = indexed_colour_full
1233            .iter()
1234            .filter(|(_, m)| m.has_multiple_colours())
1235            .map(|(&id, _)| id)
1236            .collect();
1237        (!ids.is_empty()).then(|| Arc::new(ids))
1238    })
1239    .flatten();
1240    // Collect the don't-bake occurrences across all chunks/threads; resolved into
1241    // `InstanceRecord`s against the retained template meshes after the geometry phase.
1242    let raw_instance_collector: std::sync::Mutex<Vec<RawInstanceOccurrence>> =
1243        std::sync::Mutex::new(Vec::new());
1244
1245    // Per-part point-cache instrumentation (feeds `ProcessingStats` and, through
1246    // it, `PipelineDiagnostics`). `hits`/`misses` count CartesianPoints served
1247    // by `EntityDecoder::get_polyloop_coords_cached` across every faceted part;
1248    // a non-zero `hits` proves the per-worker cache hoist below memoized points
1249    // ACROSS elements. `faceted_brep_ns` is summed only under the `observability`
1250    // feature (native), since `std::time::Instant` traps on wasm32. All three are
1251    // request-local atomics, like `backstop_collector`.
1252    let point_cache_hits_collector = std::sync::atomic::AtomicU64::new(0);
1253    let point_cache_misses_collector = std::sync::atomic::AtomicU64::new(0);
1254    let faceted_brep_ns_collector = std::sync::atomic::AtomicU64::new(0);
1255
1256    let worker_point_caches = jobs::new_worker_point_caches();
1257    // Sized identically to `worker_point_caches` (one slot per rayon worker,
1258    // indexed by thread index). Memoizes each worker's resolved placement world
1259    // transforms across the whole model — see `new_worker_placement_caches`.
1260    let worker_placement_caches = jobs::new_worker_placement_caches();
1261
1262    let geometry_span = tracing::info_span!(
1263        "geometry",
1264        element_count = total_jobs,
1265        mesh_count = tracing::field::Empty,
1266        triangle_count = tracing::field::Empty,
1267        backstop_count = tracing::field::Empty,
1268        total_csg_failures = tracing::field::Empty,
1269        phase_ms = tracing::field::Empty,
1270    );
1271    let geometry_guard = geometry_span.clone().entered();
1272
1273    while chunk_start < total_jobs {
1274        // Cooperative cancellation between chunks: the caller flipped the flag
1275        // (e.g. its client disconnected), so stop meshing and emitting. The
1276        // result below is partial by contract; see StreamingOptions::cancel.
1277        if options
1278            .cancel
1279            .as_ref()
1280            .is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed))
1281        {
1282            break;
1283        }
1284        let chunk_end = (chunk_start + current_chunk_size).min(total_jobs);
1285        let jobs_chunk = &mut entity_jobs[chunk_start..chunk_end];
1286
1287        // ── Desktop: two-phase parallel metadata population ──
1288        // Phase 1 (parallel): decode entities, extract GlobalId/Name/ProductDefinitionShapeId
1289        // Phase 2 (serial): resolve colors from cache (cheap, cache-hit dominated)
1290        #[cfg(not(target_arch = "wasm32"))]
1291        {
1292            // Phase 1: parallel decode with thread-local EntityDecoder
1293            let entity_index_for_meta = entity_index_arc.clone();
1294            jobs_chunk.par_iter_mut().for_each(|job| {
1295                if job.global_id.is_some()
1296                    || job.name.is_some()
1297                    || job.product_definition_shape_id.is_some()
1298                {
1299                    return;
1300                }
1301                let mut local_decoder =
1302                    EntityDecoder::with_arc_index(content, entity_index_for_meta.clone());
1303                let Ok(entity) = local_decoder.decode_at(job.start, job.end) else {
1304                    return;
1305                };
1306                job.global_id = normalize_optional_string(entity.get_string(0));
1307                job.name = normalize_optional_string(entity.get_string(2));
1308                job.product_definition_shape_id = entity.get_ref(6);
1309            });
1310
1311            // Phase 2: serial color/layer resolution (cache-hit dominated, fast)
1312            for job in jobs_chunk.iter_mut() {
1313                let Some(pds_id) = job.product_definition_shape_id else {
1314                    continue;
1315                };
1316                let resolved_color = color_cache_by_product_definition_shape
1317                    .entry(pds_id)
1318                    .or_insert_with(|| {
1319                        resolve_element_color_for_product_definition_shape(
1320                            pds_id,
1321                            &geometry_style_index,
1322                            &mut decoder,
1323                        )
1324                    });
1325                if let Some(color) = resolved_color {
1326                    job.element_color = *color;
1327                } else if let Some(color) = element_material_color.get(&job.id) {
1328                    // No direct/indexed geometry style — inherit the material
1329                    // appearance (#407).
1330                    job.element_color = *color;
1331                }
1332                if options.include_presentation_layers {
1333                    let resolved_layer = layer_cache_by_product_definition_shape
1334                        .entry(pds_id)
1335                        .or_insert_with(|| {
1336                            resolve_presentation_layer_for_product_definition_shape(
1337                                pds_id,
1338                                &presentation_layer_by_assigned_id,
1339                                &mut layer_cache_by_representation,
1340                                &mut decoder,
1341                            )
1342                        });
1343                    job.presentation_layer = resolved_layer.clone();
1344                }
1345            }
1346        }
1347
1348        // ── WASM: existing serial path (unchanged) ──
1349        #[cfg(target_arch = "wasm32")]
1350        for job in jobs_chunk.iter_mut() {
1351            populate_entity_job_metadata(
1352                job,
1353                &geometry_style_index,
1354                &element_material_color,
1355                &presentation_layer_by_assigned_id,
1356                &mut color_cache_by_product_definition_shape,
1357                &mut layer_cache_by_product_definition_shape,
1358                &mut layer_cache_by_representation,
1359                &mut decoder,
1360                options.include_presentation_layers,
1361            );
1362        }
1363        let site_local_rotation: Option<&Vec<f64>> =
1364            if coord_space == SITE_LOCAL_MESH_COORDINATE_SPACE {
1365                site_transform.as_ref()
1366            } else {
1367                None
1368            };
1369        // Each job borrows its worker's persistent point cache by thread index, so a
1370        // shared point list is parsed once per worker for the whole model, not per
1371        // chunk/part. `.map`/`.flatten_iter()` keeps the former `flat_map_iter` order;
1372        // the cache is pure memoization, so meshes are byte-identical.
1373        let chunk_meshes: Vec<MeshData> = jobs_chunk
1374            .par_iter()
1375            .map(|job| {
1376                let widx = rayon::current_thread_index().unwrap_or(0) % worker_point_caches.len();
1377                // `try_lock`, not `lock`: faceted-brep triangulation nests a rayon
1378                // `par_iter`, so a worker blocked at that nested join can work-steal
1379                // another element job onto its OWN thread index and re-enter here.
1380                // `lock()` on the non-reentrant `Mutex` this thread already holds
1381                // would self-deadlock (regression from the persistent per-worker
1382                // cache). Each slot is a thread's own index, so `try_lock` only ever
1383                // fails on that re-entrant steal; that rare job uses a throwaway
1384                // cache instead. Output is byte-identical: the cache is pure
1385                // memoization of deterministic coordinates, so a miss just re-decodes.
1386                let mut fallback_cache = FxHashMap::default();
1387                let mut slot_guard = worker_point_caches[widx].try_lock().ok();
1388                let worker_point_cache: &mut FxHashMap<u32, (f64, f64, f64)> =
1389                    match slot_guard.as_deref_mut() {
1390                        Some(cache) => cache,
1391                        None => &mut fallback_cache,
1392                    };
1393                // Placement-transform slot: the SAME `try_lock` (not `lock`)
1394                // discipline as the point cache above — a faceted-brep nested
1395                // `par_iter` can work-steal another job onto this worker's own
1396                // thread index and re-enter here, and `lock()` on the
1397                // non-reentrant `Mutex` this thread already holds would
1398                // self-deadlock (#1587). On that rare re-entrant steal we fall
1399                // back to a throwaway cache; output stays byte-identical since
1400                // the cache is pure memoization of a deterministic composition.
1401                let mut fallback_placement_cache = FxHashMap::default();
1402                let mut placement_slot_guard = worker_placement_caches[widx].try_lock().ok();
1403                let worker_placement_cache: &mut FxHashMap<u32, [f64; 16]> =
1404                    match placement_slot_guard.as_deref_mut() {
1405                        Some(cache) => cache,
1406                        None => &mut fallback_placement_cache,
1407                    };
1408                process_entity_job(
1409                    job,
1410                    content,
1411                    &entity_index_arc,
1412                    unit_scale,
1413                    rtc_offset,
1414                    seed_plane_angle_to_radians,
1415                    options.tessellation_quality,
1416                    void_index_arc.as_ref(),
1417                    skipped_entity_ids.as_ref(),
1418                    geometry_style_index.as_ref(),
1419                    indexed_colour_full.as_ref(),
1420                    element_material_colors.as_ref(),
1421                    texture_index.as_ref(),
1422                    site_local_rotation,
1423                    &csg_failure_collector,
1424                    &classification_collector,
1425                    &host_diag_collector,
1426                    &rect_fast_collector,
1427                    &backstop_collector,
1428                    &item_dedup_cache,
1429                    &mapped_item_cache,
1430                    instancing_plan.as_ref(),
1431                    indexed_colour_split_ids.as_ref(),
1432                    &raw_instance_collector,
1433                    worker_point_cache,
1434                    worker_placement_cache,
1435                    &point_cache_hits_collector,
1436                    &point_cache_misses_collector,
1437                    &faceted_brep_ns_collector,
1438                )
1439            })
1440            .flatten_iter()
1441            .collect();
1442
1443        processed_jobs += jobs_chunk.len();
1444        total_vertices += chunk_meshes.iter().map(|m| m.vertex_count()).sum::<usize>();
1445        total_triangles += chunk_meshes
1446            .iter()
1447            .map(|m| m.triangle_count())
1448            .sum::<usize>();
1449
1450        if !chunk_meshes.is_empty() {
1451            total_meshes += chunk_meshes.len();
1452            let emit_mesh_chunk_size = current_chunk_size.max(1);
1453            for emitted_meshes in chunk_meshes.chunks(emit_mesh_chunk_size) {
1454                on_batch(emitted_meshes, processed_jobs, total_jobs);
1455            }
1456            if options.retain_emitted_meshes {
1457                meshes.extend(chunk_meshes);
1458            }
1459
1460            if !deferred_styles_applied {
1461                // Replay saved IFCSTYLEDITEM positions instead of re-scanning
1462                // the entire file.  This eliminates ~0.5-1 s for 1 GB files.
1463                // The replay is the shared resolver's styled-item building
1464                // block, so deferred and up-front resolution cannot drift.
1465                let mut rebuilt_styles = {
1466                    let mut style_decoder =
1467                        EntityDecoder::with_arc_index(content, entity_index_arc.clone());
1468                    crate::prepass::resolve_styled_item_spans(
1469                        &deferred_styled_item_positions,
1470                        &mut style_decoder,
1471                    )
1472                };
1473                crate::prepass::merge_indexed_colours(&mut rebuilt_styles, &indexed_colour_index);
1474                geometry_style_index = Arc::new(rebuilt_styles);
1475                let deferred_color_updates = build_color_updates_for_jobs(
1476                    &entity_jobs[..processed_jobs],
1477                    geometry_style_index.as_ref(),
1478                    content,
1479                    &entity_index_arc,
1480                );
1481                if !deferred_color_updates.is_empty() {
1482                    on_color_update(&deferred_color_updates);
1483                }
1484                deferred_styles_applied = true;
1485            }
1486        }
1487        chunk_start = chunk_end;
1488        current_chunk_size = throughput_chunk_size;
1489    }
1490
1491    let geometry_time = geometry_start.elapsed();
1492    // Surface the aggregated CSG diagnostics — same per-reason breakdown the
1493    // browser console shows on the wasm path.
1494    let csg_failures = csg_failure_collector
1495        .into_inner()
1496        .unwrap_or_else(|poisoned| poisoned.into_inner());
1497    let total_csg_failures: usize = csg_failures.values().map(Vec::len).sum();
1498    let products_with_failures = csg_failures.len();
1499    let backstop_dropped = backstop_collector.into_inner();
1500    let point_cache_hits = point_cache_hits_collector.into_inner();
1501    let point_cache_misses = point_cache_misses_collector.into_inner();
1502    let faceted_brep_time_ms = faceted_brep_ns_collector.into_inner() / 1_000_000;
1503    geometry_span.record("mesh_count", total_meshes as u64);
1504    geometry_span.record("triangle_count", total_triangles as u64);
1505    geometry_span.record("backstop_count", backstop_dropped);
1506    geometry_span.record("total_csg_failures", total_csg_failures as u64);
1507    geometry_span.record("phase_ms", geometry_time.as_millis() as u64);
1508    drop(geometry_guard);
1509    if total_csg_failures > 0 {
1510        let mut by_reason: HashMap<&'static str, usize> = HashMap::new();
1511        for fails in csg_failures.values() {
1512            for f in fails {
1513                *by_reason.entry(f.reason.label()).or_insert(0) += 1;
1514            }
1515        }
1516        let mut breakdown: Vec<(&'static str, usize)> = by_reason.into_iter().collect();
1517        breakdown.sort_by(|a, b| b.1.cmp(&a.1));
1518        let breakdown = breakdown
1519            .iter()
1520            .map(|(reason, count)| format!("{reason}={count}"))
1521            .collect::<Vec<_>>()
1522            .join(" ");
1523        tracing::warn!(
1524            total_csg_failures,
1525            products_with_failures,
1526            %breakdown,
1527            "CSG failures during geometry extraction (cut dropped, host kept uncut)"
1528        );
1529    }
1530
1531    // Build the full GeometryDiagnostics contract from the drained sinks — the
1532    // SAME shape the wasm batch path surfaces, so a native consumer and a browser
1533    // consumer see identical diagnostics. `None` when nothing diagnostic-worthy
1534    // happened (mirrors the wasm `is_empty` skip).
1535    //
1536    // Every sink — `classification`, `host_diags`, `csg_failures` AND `rect_fast` —
1537    // is request-local: each was drained from this pass's own per-job routers and
1538    // merged here, so concurrent in-process geometry passes never cross-contaminate.
1539    let geometry_diagnostics = tracing::debug_span!("collate_diagnostics").in_scope(|| {
1540        // Matches the wasm path's WORST_HOSTS_LIMIT (top-N per-host detail cap).
1541        const WORST_HOSTS_LIMIT: usize = 16;
1542        let classification = classification_collector
1543            .into_inner()
1544            .unwrap_or_else(|poisoned| poisoned.into_inner());
1545        let host_diags = host_diag_collector
1546            .into_inner()
1547            .unwrap_or_else(|poisoned| poisoned.into_inner());
1548        let rect_fast = rect_fast_collector
1549            .into_inner()
1550            .unwrap_or_else(|poisoned| poisoned.into_inner());
1551        let diag = ifc_lite_geometry::aggregate_diagnostics(
1552            classification,
1553            &csg_failures,
1554            &host_diags,
1555            rect_fast,
1556            WORST_HOSTS_LIMIT,
1557        );
1558        (!diag.is_empty()).then_some(diag)
1559    });
1560
1561    // #1623 Phase 2: resolve the don't-bake occurrences into InstanceRecords against
1562    // the retained template meshes (min-id occurrence per source). Empty on the flat
1563    // path (no armed plan ⇒ no occurrences collected). `meshes` is only appended to
1564    // (orphan recovery), so the flat output stays byte-identical.
1565    let instances = instancing::finalize_instances(
1566        raw_instance_collector
1567            .into_inner()
1568            .unwrap_or_else(|poisoned| poisoned.into_inner()),
1569        &mut meshes,
1570        &mapped_item_cache,
1571        [rtc_offset.0, rtc_offset.1, rtc_offset.2],
1572    );
1573
1574    let total_time = total_start.elapsed();
1575    pipeline_span.record("element_count", total_jobs as u64);
1576    pipeline_span.record("total_ms", total_time.as_millis() as u64);
1577
1578    tracing::info!(
1579        meshes = meshes.len(),
1580        instances = instances.len(),
1581        vertices = total_vertices,
1582        triangles = total_triangles,
1583        backstop_count = backstop_dropped,
1584        geometry_time_ms = geometry_time.as_millis(),
1585        total_time_ms = total_time.as_millis(),
1586        "Geometry processing complete"
1587    );
1588
1589    ProcessingResult {
1590        meshes,
1591        instances,
1592        mesh_coordinate_space: Some(coord_space.to_string()),
1593        site_transform,
1594        building_transform,
1595        metadata: ModelMetadata {
1596            schema_version,
1597            entity_count: total_entities,
1598            geometry_entity_count,
1599            coordinate_info: CoordinateInfo {
1600                origin_shift: [rtc_offset.0, rtc_offset.1, rtc_offset.2],
1601                is_geo_referenced: has_rtc_offset,
1602            },
1603            length_unit_scale: Some(unit_scale),
1604            georeferencing: crate::extract_georeferencing(content),
1605        },
1606        stats: ProcessingStats {
1607            total_meshes,
1608            total_vertices,
1609            total_triangles,
1610            parse_time_ms: parse_time.as_millis() as u64,
1611            entity_scan_time_ms: entity_scan_time.as_millis() as u64,
1612            lookup_time_ms: lookup_time.as_millis() as u64,
1613            preprocess_time_ms: preprocess_time.as_millis() as u64,
1614            geometry_time_ms: geometry_time.as_millis() as u64,
1615            total_time_ms: total_time.as_millis() as u64,
1616            from_cache: false,
1617            total_csg_failures: total_csg_failures as u64,
1618            products_with_failures: products_with_failures as u64,
1619            degenerate_triangles_dropped: backstop_dropped,
1620            point_cache_hits,
1621            point_cache_misses,
1622            faceted_brep_time_ms,
1623            geometry_diagnostics,
1624        },
1625    }
1626}
1627
1628// Default IFC-type colors now come from the single canonical table in
1629// `crate::style::default_color_for_type` (issue #913). Do not reintroduce a
1630// per-module table here — see `tests/styling_parity.rs` for the guard.
1631//
1632// `find_geometry_item_color_follows_mapped_item` lives in `crate::element::tests`,
1633// next to the resolver it pins.