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