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