Skip to main content

ifc_lite_processing/
processor.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::MeshData;
10use crate::types::response::{
11    CoordinateInfo, ModelMetadata, ProcessingStats, QuickMetadataBootstrap,
12    QuickMetadataEntitySummary, QuickMetadataSpatialNode,
13};
14use ifc_lite_core::{
15    build_entity_index, AttributeValue, 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
25/// Controls how IfcWindow / IfcDoor openings are exported.
26#[derive(Debug, Clone, Copy, PartialEq, Default, serde::Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum OpeningFilterMode {
29    /// Export all openings and cut their voids in host walls (default behaviour).
30    #[default]
31    Default = 0,
32    /// Skip all IfcWindow / IfcDoor meshes and do not cut any voids.
33    IgnoreAll = 1,
34    /// Skip only opaque (non-glazed) windows and doors; glazed ones are kept.
35    IgnoreOpaque = 2,
36}
37
38impl OpeningFilterMode {
39    /// Stable string suffix for disk-cache keys. Unlike `Debug` formatting,
40    /// this is guaranteed not to change across compiler versions.
41    pub fn cache_key_suffix(&self) -> &'static str {
42        match self {
43            Self::Default => "default",
44            Self::IgnoreAll => "ignore_all",
45            Self::IgnoreOpaque => "ignore_opaque",
46        }
47    }
48}
49
50/// Result of processing an IFC file.
51pub struct ProcessingResult {
52    pub meshes: Vec<MeshData>,
53    /// Declares the coordinate space used by serialized mesh vertices.
54    pub mesh_coordinate_space: Option<String>,
55    /// IfcSite ObjectPlacement as column-major 4x4 matrix (in meters).
56    pub site_transform: Option<Vec<f64>>,
57    /// IfcBuilding ObjectPlacement as column-major 4x4 matrix (in meters).
58    pub building_transform: Option<Vec<f64>>,
59    pub metadata: ModelMetadata,
60    pub stats: ProcessingStats,
61}
62
63/// Controls the tradeoff between first-frame latency and richer upfront metadata.
64#[derive(Debug, Clone, Copy)]
65pub struct StreamingOptions {
66    /// Batch size used for the very first emitted chunk.
67    pub initial_batch_size: usize,
68    /// Batch size used after the first emitted chunk for higher throughput.
69    pub throughput_batch_size: usize,
70    /// Prioritize cheap/high-yield element classes first.
71    pub fast_first_batch: bool,
72    /// Include expensive property parsing on the first-frame path.
73    pub include_properties: bool,
74    /// Include expensive presentation-layer resolution on the first-frame path.
75    pub include_presentation_layers: bool,
76    /// Emit a lightweight spatial bootstrap during the scan phase.
77    pub emit_quick_metadata_bootstrap: bool,
78    /// Retain emitted meshes in the returned ProcessingResult.
79    pub retain_emitted_meshes: bool,
80    /// Tessellation detail level (#976). `Medium` reproduces the historical
81    /// output byte-for-byte; consumer-selectable on the wasm path via
82    /// `setTessellationQuality`, and on the server via the
83    /// `tessellation_quality` query parameter. 2D symbolic extraction
84    /// (`symbolic.rs`) deliberately ignores the level — symbols are
85    /// resolution-independent line work.
86    pub tessellation_quality: TessellationQuality,
87}
88
89impl Default for StreamingOptions {
90    fn default() -> Self {
91        Self {
92            initial_batch_size: 50,
93            throughput_batch_size: 50,
94            fast_first_batch: false,
95            include_properties: true,
96            include_presentation_layers: true,
97            emit_quick_metadata_bootstrap: false,
98            retain_emitted_meshes: true,
99            tessellation_quality: TessellationQuality::default(),
100        }
101    }
102}
103
104const SITE_LOCAL_MESH_COORDINATE_SPACE: &str = "site_local";
105const MODEL_RTC_MESH_COORDINATE_SPACE: &str = "model_rtc";
106const RAW_IFC_MESH_COORDINATE_SPACE: &str = "raw_ifc";
107
108/// Epsilon (metres) below which a placement translation is treated as identity.
109/// Avoids overriding a detected RTC anchor when `IfcSite` sits at the origin
110/// while the geometry itself carries large world coordinates.
111const PLACEMENT_IDENTITY_EPSILON: f64 = 1e-9;
112
113#[inline]
114fn translation_is_nonidentity(t: (f64, f64, f64)) -> bool {
115    t.0.abs() > PLACEMENT_IDENTITY_EPSILON
116        || t.1.abs() > PLACEMENT_IDENTITY_EPSILON
117        || t.2.abs() > PLACEMENT_IDENTITY_EPSILON
118}
119
120/// Apply the inverse of the site placement's 3×3 rotation to in-place `f32`
121/// triplets (positions or normals). Translation is handled separately via the
122/// router's `rtc_offset`; this only rotates vertices into the site-local axis
123/// frame when that frame is non-identity.
124fn apply_inverse_rotation_in_place(values: &mut [f32], column_major_matrix: &[f64]) {
125    if values.len() < 3 || column_major_matrix.len() < 16 {
126        return;
127    }
128
129    let r00 = column_major_matrix[0];
130    let r10 = column_major_matrix[1];
131    let r20 = column_major_matrix[2];
132    let r01 = column_major_matrix[4];
133    let r11 = column_major_matrix[5];
134    let r21 = column_major_matrix[6];
135    let r02 = column_major_matrix[8];
136    let r12 = column_major_matrix[9];
137    let r22 = column_major_matrix[10];
138
139    let is_identity = (r00 - 1.0).abs() < PLACEMENT_IDENTITY_EPSILON
140        && r10.abs() < PLACEMENT_IDENTITY_EPSILON
141        && r20.abs() < PLACEMENT_IDENTITY_EPSILON
142        && r01.abs() < PLACEMENT_IDENTITY_EPSILON
143        && (r11 - 1.0).abs() < PLACEMENT_IDENTITY_EPSILON
144        && r21.abs() < PLACEMENT_IDENTITY_EPSILON
145        && r02.abs() < PLACEMENT_IDENTITY_EPSILON
146        && r12.abs() < PLACEMENT_IDENTITY_EPSILON
147        && (r22 - 1.0).abs() < PLACEMENT_IDENTITY_EPSILON;
148    if is_identity {
149        return;
150    }
151
152    for chunk in values.chunks_exact_mut(3) {
153        let x = chunk[0] as f64;
154        let y = chunk[1] as f64;
155        let z = chunk[2] as f64;
156        chunk[0] = (r00 * x + r10 * y + r20 * z) as f32;
157        chunk[1] = (r01 * x + r11 * y + r21 * z) as f32;
158        chunk[2] = (r02 * x + r12 * y + r22 * z) as f32;
159    }
160}
161
162/// Rotate a mesh into the site-local axis frame. Only runs for the
163/// `site_local` coordinate-space tier; translation alignment happens upstream
164/// via the router's RTC subtraction.
165///
166/// Exposed so the streaming server can apply the same rotation to meshes it
167/// produces outside this crate's parallel loop.
168pub fn convert_mesh_to_site_local(mesh: &mut MeshData, site_transform: Option<&Vec<f64>>) {
169    let Some(site_transform) = site_transform else {
170        return;
171    };
172
173    apply_inverse_rotation_in_place(&mut mesh.positions, site_transform);
174    apply_inverse_rotation_in_place(&mut mesh.normals, site_transform);
175    // Positions are stored RELATIVE to `mesh.origin`, so the world point is
176    // `origin + position`. The site-local inverse rotation acts on the world
177    // point, so the origin must be rotated by the SAME inverse rotation (in f64)
178    // — otherwise the element would be rotated about the wrong centre.
179    apply_inverse_rotation_point_f64(&mut mesh.origin, site_transform);
180}
181
182/// Inverse-rotate a single f64 point in place by `column_major_matrix` (the same
183/// Rᵀ used by `apply_inverse_rotation_in_place`). Used for the per-mesh origin.
184fn apply_inverse_rotation_point_f64(p: &mut [f64; 3], column_major_matrix: &[f64]) {
185    if column_major_matrix.len() < 16 || (p[0] == 0.0 && p[1] == 0.0 && p[2] == 0.0) {
186        return;
187    }
188    let (r00, r10, r20) = (
189        column_major_matrix[0],
190        column_major_matrix[1],
191        column_major_matrix[2],
192    );
193    let (r01, r11, r21) = (
194        column_major_matrix[4],
195        column_major_matrix[5],
196        column_major_matrix[6],
197    );
198    let (r02, r12, r22) = (
199        column_major_matrix[8],
200        column_major_matrix[9],
201        column_major_matrix[10],
202    );
203    let (x, y, z) = (p[0], p[1], p[2]);
204    p[0] = r00 * x + r10 * y + r20 * z;
205    p[1] = r01 * x + r11 * y + r21 * z;
206    p[2] = r02 * x + r12 * y + r22 * z;
207}
208
209/// Job for processing a single entity.
210struct EntityJob {
211    id: u32,
212    ifc_type: IfcType,
213    start: usize,
214    end: usize,
215    product_definition_shape_id: Option<u32>,
216    element_color: [f32; 4],
217    global_id: Option<String>,
218    name: Option<String>,
219    presentation_layer: Option<String>,
220    space_zone_properties: Option<BTreeMap<String, String>>,
221    /// Set for synthetic type-only-geometry jobs (#957): the `IfcRepresentationMap`
222    /// id to render directly (baking its MappingOrigin) instead of walking the
223    /// element's `IfcProductDefinitionShape`. `None` for ordinary product jobs.
224    representation_map_id: Option<u32>,
225}
226
227fn populate_entity_job_metadata(
228    job: &mut EntityJob,
229    geometry_style_index: &FxHashMap<u32, GeometryStyleInfo>,
230    element_material_color: &FxHashMap<u32, [f32; 4]>,
231    layer_by_assigned_representation: &FxHashMap<u32, String>,
232    color_cache_by_product_definition_shape: &mut FxHashMap<u32, Option<[f32; 4]>>,
233    layer_cache_by_product_definition_shape: &mut FxHashMap<u32, Option<String>>,
234    layer_cache_by_representation: &mut FxHashMap<u32, Option<String>>,
235    decoder: &mut EntityDecoder,
236    include_presentation_layers: bool,
237) {
238    if job.global_id.is_some() || job.name.is_some() || job.product_definition_shape_id.is_some() {
239        return;
240    }
241
242    let Ok(entity) = decoder.decode_at(job.start, job.end) else {
243        return;
244    };
245
246    job.global_id = normalize_optional_string(entity.get_string(0));
247    job.name = normalize_optional_string(entity.get_string(2));
248    job.product_definition_shape_id = entity.get_ref(6);
249
250    let Some(product_definition_shape_id) = job.product_definition_shape_id else {
251        return;
252    };
253
254    let resolved_color = color_cache_by_product_definition_shape
255        .entry(product_definition_shape_id)
256        .or_insert_with(|| {
257            resolve_element_color_for_product_definition_shape(
258                product_definition_shape_id,
259                geometry_style_index,
260                decoder,
261            )
262        });
263    if let Some(color) = resolved_color {
264        job.element_color = *color;
265    } else if let Some(color) = element_material_color.get(&job.id) {
266        job.element_color = *color;
267    }
268
269    if include_presentation_layers {
270        let resolved_layer = layer_cache_by_product_definition_shape
271            .entry(product_definition_shape_id)
272            .or_insert_with(|| {
273                resolve_presentation_layer_for_product_definition_shape(
274                    product_definition_shape_id,
275                    layer_by_assigned_representation,
276                    layer_cache_by_representation,
277                    decoder,
278                )
279            });
280        job.presentation_layer = resolved_layer.clone();
281    }
282}
283
284// `GeometryStyleInfo` moved to `crate::style` — it is shared by this
285// orchestrator, the canonical per-element producer (`crate::element`), and
286// (via `from_color`) the browser batch path.
287use crate::style::GeometryStyleInfo;
288
289#[derive(Debug, Clone)]
290struct PropertySetDefinition {
291    name: Option<String>,
292    property_ids: Vec<u32>,
293}
294
295#[derive(Debug, Clone)]
296struct RelDefinesByPropertiesLink {
297    property_set_id: u32,
298    related_object_ids: Vec<u32>,
299}
300
301/// Extract entity references from a list attribute.
302pub(crate) fn get_refs_from_list(entity: &DecodedEntity, index: usize) -> Option<Vec<u32>> {
303    let list = entity.get_list(index)?;
304    let refs: Vec<u32> = list.iter().filter_map(|v| v.as_entity_ref()).collect();
305    if refs.is_empty() {
306        None
307    } else {
308        Some(refs)
309    }
310}
311
312fn normalize_optional_string(raw: Option<&str>) -> Option<String> {
313    let value = raw?.trim();
314    if value.is_empty() || value == "$" {
315        return None;
316    }
317    Some(value.to_string())
318}
319
320fn normalize_ifc_property_name(raw: Option<&str>) -> Option<String> {
321    let name = normalize_optional_string(raw)?;
322    let cleaned = name.trim();
323    if cleaned.is_empty() {
324        return None;
325    }
326
327    Some(cleaned.to_string())
328}
329
330fn is_space_or_zone_type(ifc_type: &IfcType) -> bool {
331    matches!(
332        ifc_type,
333        IfcType::IfcSpace
334            | IfcType::IfcSpaceType
335            | IfcType::IfcZone
336            | IfcType::IfcSpatialZone
337            | IfcType::IfcSpatialZoneType
338    )
339}
340
341fn collect_property_set_definition(property_set: &DecodedEntity) -> Option<PropertySetDefinition> {
342    let property_ids = property_set
343        .get_list(4)
344        .or_else(|| property_set.get_list(2))
345        .map(|items| {
346            items
347                .iter()
348                .filter_map(AttributeValue::as_entity_ref)
349                .collect::<Vec<u32>>()
350        })
351        .unwrap_or_default();
352
353    if property_ids.is_empty() {
354        return None;
355    }
356
357    let name = normalize_optional_string(property_set.get_string(2))
358        .or_else(|| normalize_optional_string(property_set.get_string(0)));
359
360    Some(PropertySetDefinition { name, property_ids })
361}
362
363fn collect_rel_defines_by_properties_link(
364    rel_defines: &DecodedEntity,
365) -> Option<RelDefinesByPropertiesLink> {
366    let property_set_id = rel_defines.get_ref(5).or_else(|| rel_defines.get_ref(3))?;
367    let related_object_ids = rel_defines
368        .get_list(4)
369        .or_else(|| rel_defines.get_list(2))
370        .map(|items| {
371            items
372                .iter()
373                .filter_map(AttributeValue::as_entity_ref)
374                .collect::<Vec<u32>>()
375        })
376        .unwrap_or_default();
377
378    if related_object_ids.is_empty() {
379        return None;
380    }
381
382    Some(RelDefinesByPropertiesLink {
383        property_set_id,
384        related_object_ids,
385    })
386}
387
388fn attribute_list_to_string(values: &[AttributeValue]) -> Option<String> {
389    let tokens = values
390        .iter()
391        .filter_map(attribute_value_to_string)
392        .collect::<Vec<String>>();
393
394    if tokens.is_empty() {
395        return None;
396    }
397
398    Some(tokens.join("; "))
399}
400
401fn attribute_value_to_string(value: &AttributeValue) -> Option<String> {
402    match value {
403        AttributeValue::Null | AttributeValue::Derived => None,
404        AttributeValue::String(text) => normalize_optional_string(Some(text)),
405        AttributeValue::Enum(text) => normalize_optional_string(Some(text.trim_matches('.'))),
406        AttributeValue::Integer(number) => Some(number.to_string()),
407        AttributeValue::Float(number) => Some(number.to_string()),
408        AttributeValue::EntityRef(id) => Some(format!("#{id}")),
409        AttributeValue::List(values) => {
410            if values.len() >= 2 && matches!(values.first(), Some(AttributeValue::String(_))) {
411                return values.get(1).and_then(attribute_value_to_string);
412            }
413
414            attribute_list_to_string(values)
415        }
416    }
417}
418
419fn extract_property_name_and_value(property_entity: &DecodedEntity) -> Option<(String, String)> {
420    let property_name = normalize_ifc_property_name(property_entity.get_string(0))
421        .or_else(|| normalize_ifc_property_name(property_entity.get_string(2)))?;
422
423    let property_type = property_entity.ifc_type.name();
424    let value = match property_type {
425        "IfcPropertySingleValue" => property_entity.get(2).and_then(attribute_value_to_string),
426        "IfcPropertyEnumeratedValue" => property_entity.get(2).and_then(attribute_value_to_string),
427        "IfcPropertyListValue" => property_entity.get(2).and_then(attribute_value_to_string),
428        "IfcPropertyBoundedValue" => {
429            let lower = property_entity.get(2).and_then(attribute_value_to_string);
430            let upper = property_entity.get(3).and_then(attribute_value_to_string);
431            match (lower, upper) {
432                (Some(lo), Some(hi)) => Some(format!("{lo}..{hi}")),
433                (Some(lo), None) => Some(lo),
434                (None, Some(hi)) => Some(hi),
435                (None, None) => None,
436            }
437        }
438        "IfcPropertyReferenceValue" => property_entity.get(2).and_then(attribute_value_to_string),
439        _ => None,
440    }?;
441
442    let normalized_value = value.trim();
443    if normalized_value.is_empty() || normalized_value == "$" {
444        return None;
445    }
446
447    Some((property_name, normalized_value.to_string()))
448}
449
450fn add_space_zone_property(
451    attributes: &mut BTreeMap<String, String>,
452    property_set_name: Option<&str>,
453    property_name: &str,
454    property_value: &str,
455) {
456    if property_name.trim().is_empty() || property_value.trim().is_empty() {
457        return;
458    }
459
460    attributes
461        .entry(property_name.to_string())
462        .or_insert_with(|| property_value.to_string());
463
464    if let Some(pset_name) = normalize_optional_string(property_set_name) {
465        let scoped_name = format!("{}.{}", pset_name, property_name);
466        attributes
467            .entry(scoped_name)
468            .or_insert_with(|| property_value.to_string());
469    }
470}
471
472fn build_space_zone_properties_by_entity(
473    entity_jobs: &[EntityJob],
474    property_values_by_id: &FxHashMap<u32, (String, String)>,
475    property_sets_by_id: &FxHashMap<u32, PropertySetDefinition>,
476    rel_defines_by_properties: &[RelDefinesByPropertiesLink],
477) -> FxHashMap<u32, BTreeMap<String, String>> {
478    let mut target_space_zone_ids = FxHashMap::default();
479    for job in entity_jobs
480        .iter()
481        .filter(|job| is_space_or_zone_type(&job.ifc_type))
482    {
483        target_space_zone_ids.insert(job.id, ());
484    }
485
486    if target_space_zone_ids.is_empty() {
487        return FxHashMap::default();
488    }
489
490    let mut properties_by_entity: FxHashMap<u32, BTreeMap<String, String>> = FxHashMap::default();
491
492    for link in rel_defines_by_properties {
493        let Some(property_set) = property_sets_by_id.get(&link.property_set_id) else {
494            continue;
495        };
496
497        for related_id in &link.related_object_ids {
498            if !target_space_zone_ids.contains_key(related_id) {
499                continue;
500            }
501
502            let attributes = properties_by_entity.entry(*related_id).or_default();
503            for property_id in &property_set.property_ids {
504                let Some((property_name, property_value)) = property_values_by_id.get(property_id)
505                else {
506                    continue;
507                };
508
509                add_space_zone_property(
510                    attributes,
511                    property_set.name.as_deref(),
512                    property_name,
513                    property_value,
514                );
515            }
516        }
517    }
518
519    properties_by_entity
520}
521
522fn assign_space_zone_properties(
523    entity_jobs: &mut [EntityJob],
524    property_values_by_id: &FxHashMap<u32, (String, String)>,
525    property_sets_by_id: &FxHashMap<u32, PropertySetDefinition>,
526    rel_defines_by_properties: &[RelDefinesByPropertiesLink],
527) {
528    let properties_by_entity = build_space_zone_properties_by_entity(
529        entity_jobs,
530        property_values_by_id,
531        property_sets_by_id,
532        rel_defines_by_properties,
533    );
534
535    if properties_by_entity.is_empty() {
536        return;
537    }
538
539    for job in entity_jobs.iter_mut() {
540        if let Some(properties) = properties_by_entity.get(&job.id) {
541            job.space_zone_properties = Some(properties.clone());
542        }
543    }
544}
545
546#[derive(Clone)]
547struct QuickSpatialNodeEntry {
548    express_id: u32,
549    type_name: String,
550    name: String,
551    elevation: Option<f64>,
552    children: Vec<u32>,
553    elements: Vec<u32>,
554    parent: Option<u32>,
555}
556
557/// Case-insensitive spatial-type check that avoids to_ascii_uppercase() allocation.
558#[inline]
559fn is_quick_spatial_type_ci(type_name: &str) -> bool {
560    type_name.eq_ignore_ascii_case("IFCPROJECT")
561        || type_name.eq_ignore_ascii_case("IFCSITE")
562        || type_name.eq_ignore_ascii_case("IFCBUILDING")
563        || type_name.eq_ignore_ascii_case("IFCBUILDINGSTOREY")
564        || type_name.eq_ignore_ascii_case("IFCSPACE")
565        || type_name.eq_ignore_ascii_case("IFCSPATIALZONE")
566        || type_name.eq_ignore_ascii_case("IFCFACILITY")
567        || type_name.eq_ignore_ascii_case("IFCFACILITYPART")
568        || type_name.eq_ignore_ascii_case("IFCBRIDGE")
569        || type_name.eq_ignore_ascii_case("IFCBRIDGEPART")
570        || type_name.eq_ignore_ascii_case("IFCROAD")
571        || type_name.eq_ignore_ascii_case("IFCROADPART")
572        || type_name.eq_ignore_ascii_case("IFCRAILWAY")
573        || type_name.eq_ignore_ascii_case("IFCRAILWAYPART")
574}
575
576fn parse_step_arguments(entity_bytes: &[u8]) -> Vec<&[u8]> {
577    let Some(open_idx) = entity_bytes.iter().position(|byte| *byte == b'(') else {
578        return Vec::new();
579    };
580    let Some(close_idx) = entity_bytes.iter().rposition(|byte| *byte == b')') else {
581        return Vec::new();
582    };
583    if close_idx <= open_idx {
584        return Vec::new();
585    }
586    let args = &entity_bytes[open_idx + 1..close_idx];
587    let mut parts = Vec::new();
588    let mut in_string = false;
589    let mut depth = 0i32;
590    let mut start = 0usize;
591    let bytes = args;
592    let mut index = 0usize;
593    while index < bytes.len() {
594        match bytes[index] {
595            b'\'' => {
596                if in_string && index + 1 < bytes.len() && bytes[index + 1] == b'\'' {
597                    index += 1;
598                } else {
599                    in_string = !in_string;
600                }
601            }
602            b'(' if !in_string => depth += 1,
603            b')' if !in_string => depth -= 1,
604            b',' if !in_string && depth == 0 => {
605                parts.push(args[start..index].trim_ascii());
606                start = index + 1;
607            }
608            _ => {}
609        }
610        index += 1;
611    }
612    if start <= args.len() {
613        parts.push(args[start..].trim_ascii());
614    }
615    parts
616}
617
618fn parse_step_string(token: &[u8]) -> Option<String> {
619    let trimmed = token.trim_ascii();
620    if trimmed.len() < 2 || trimmed[0] != b'\'' || trimmed[trimmed.len() - 1] != b'\'' {
621        return None;
622    }
623    Some(String::from_utf8_lossy(&trimmed[1..trimmed.len() - 1]).replace("''", "'"))
624}
625
626fn parse_step_ref(token: &[u8]) -> Option<u32> {
627    std::str::from_utf8(token.trim_ascii().strip_prefix(b"#")?)
628        .ok()?
629        .parse()
630        .ok()
631}
632
633fn parse_step_ref_list(token: &[u8]) -> Vec<u32> {
634    let trimmed = token.trim_ascii();
635    let inner = trimmed
636        .strip_prefix(b"(")
637        .and_then(|value| value.strip_suffix(b")"))
638        .unwrap_or(trimmed);
639    inner.split(|byte| *byte == b',').filter_map(parse_step_ref).collect()
640}
641
642fn extract_name_from_args(args: &[&[u8]], fallback: &str) -> String {
643    args.get(2)
644        .and_then(|token| parse_step_string(token))
645        .filter(|value| !value.trim().is_empty())
646        .unwrap_or_else(|| fallback.to_string())
647}
648
649fn extract_storey_elevation_from_args(args: &[&[u8]]) -> Option<f64> {
650    for index in [9usize, 8usize] {
651        if let Some(value) = args
652            .get(index)
653            .and_then(|token| std::str::from_utf8(token.trim_ascii()).ok())
654            .and_then(|token| token.parse::<f64>().ok())
655        {
656            return Some(value);
657        }
658    }
659    args.iter()
660        .filter_map(|token| std::str::from_utf8(token.trim_ascii()).ok())
661        .filter_map(|token| token.parse::<f64>().ok())
662        .find(|value| value.abs() < 10_000.0)
663}
664
665fn build_quick_spatial_tree_node(
666    express_id: u32,
667    nodes: &HashMap<u32, QuickSpatialNodeEntry>,
668    element_summaries: &HashMap<u32, QuickMetadataEntitySummary>,
669) -> Result<QuickMetadataSpatialNode, String> {
670    let node = nodes
671        .get(&express_id)
672        .ok_or_else(|| format!("Quick spatial node #{express_id} not found"))?;
673    let mut children = Vec::with_capacity(node.children.len());
674    for child_id in &node.children {
675        children.push(build_quick_spatial_tree_node(
676            *child_id,
677            nodes,
678            element_summaries,
679        )?);
680    }
681    let elements = node
682        .elements
683        .iter()
684        .map(|element_id| {
685            element_summaries
686                .get(element_id)
687                .cloned()
688                .unwrap_or(QuickMetadataEntitySummary {
689                express_id: *element_id,
690                type_name: "IfcProduct".to_string(),
691                name: format!("IfcProduct #{}", element_id),
692                global_id: None,
693                kind: "element".to_string(),
694                has_children: false,
695                element_count: None,
696                elevation: None,
697            })
698        })
699        .collect();
700    Ok(QuickMetadataSpatialNode {
701        summary: QuickMetadataEntitySummary {
702            express_id: node.express_id,
703            type_name: node.type_name.clone(),
704            name: node.name.clone(),
705            global_id: None,
706            kind: "spatial".to_string(),
707            has_children: !node.children.is_empty() || !node.elements.is_empty(),
708            element_count: Some(node.elements.len()),
709            elevation: node.elevation,
710        },
711        children,
712        elements,
713    })
714}
715
716fn geometry_priority_score(ifc_type: &IfcType) -> u8 {
717    match ifc_type {
718        IfcType::IfcWall | IfcType::IfcWallStandardCase => 100,
719        IfcType::IfcSlab => 95,
720        IfcType::IfcColumn => 90,
721        IfcType::IfcBeam => 85,
722        IfcType::IfcRoof => 80,
723        IfcType::IfcStair | IfcType::IfcStairFlight => 75,
724        IfcType::IfcCurtainWall => 70,
725        IfcType::IfcFooting | IfcType::IfcPile => 65,
726        IfcType::IfcDoor | IfcType::IfcWindow => 30,
727        IfcType::IfcFurnishingElement => 10,
728        _ => 50,
729    }
730}
731
732/// Process IFC content with parallel geometry extraction (default opening filter).
733pub fn process_geometry<T>(content: &T) -> ProcessingResult
734where
735    T: AsRef<[u8]> + ?Sized,
736{
737    process_geometry_filtered(content.as_ref(), OpeningFilterMode::Default)
738}
739
740/// Process IFC content with parallel geometry extraction and emit batches as they complete.
741pub fn process_geometry_streaming(
742    content: &[u8],
743    batch_size: usize,
744    on_batch: impl FnMut(&[MeshData], usize, usize),
745) -> ProcessingResult {
746    process_geometry_streaming_with_options(
747        content,
748        StreamingOptions {
749            initial_batch_size: batch_size,
750            throughput_batch_size: batch_size,
751            ..StreamingOptions::default()
752        },
753        on_batch,
754        |_| {},
755    )
756}
757
758/// Process IFC content with parallel geometry extraction and configurable streaming behavior.
759pub fn process_geometry_streaming_with_options(
760    content: &[u8],
761    options: StreamingOptions,
762    on_batch: impl FnMut(&[MeshData], usize, usize),
763    on_color_update: impl FnMut(&[(u32, [f32; 4])]),
764) -> ProcessingResult {
765    process_geometry_streaming_with_options_and_bootstrap(
766        content,
767        options,
768        on_batch,
769        on_color_update,
770        |_| {},
771    )
772}
773
774/// Process IFC content with parallel geometry extraction and emit a quick metadata bootstrap
775/// once the scan phase completes.
776pub fn process_geometry_streaming_with_options_and_bootstrap(
777    content: &[u8],
778    options: StreamingOptions,
779    on_batch: impl FnMut(&[MeshData], usize, usize),
780    on_color_update: impl FnMut(&[(u32, [f32; 4])]),
781    on_quick_metadata_bootstrap: impl FnMut(&QuickMetadataBootstrap),
782) -> ProcessingResult {
783    process_geometry_streaming_filtered_with_options(
784        content,
785        OpeningFilterMode::Default,
786        options,
787        on_batch,
788        on_color_update,
789        on_quick_metadata_bootstrap,
790    )
791}
792
793/// Process IFC content with parallel geometry extraction and a configurable opening filter.
794pub fn process_geometry_filtered<T>(
795    content: &T,
796    opening_filter: OpeningFilterMode,
797) -> ProcessingResult
798where
799    T: AsRef<[u8]> + ?Sized,
800{
801    process_geometry_filtered_with_quality(content, opening_filter, TessellationQuality::default())
802}
803
804/// Like [`process_geometry_filtered`] with a consumer-selected tessellation
805/// detail level (#976) — the server half of the quality knob the wasm path
806/// exposes via `setTessellationQuality`.
807pub fn process_geometry_filtered_with_quality<T>(
808    content: &T,
809    opening_filter: OpeningFilterMode,
810    tessellation_quality: TessellationQuality,
811) -> ProcessingResult
812where
813    T: AsRef<[u8]> + ?Sized,
814{
815    let content = content.as_ref();
816    process_geometry_streaming_filtered_with_options(
817        content,
818        opening_filter,
819        StreamingOptions {
820            initial_batch_size: usize::MAX,
821            throughput_batch_size: usize::MAX,
822            tessellation_quality,
823            ..StreamingOptions::default()
824        },
825        |_, _, _| {},
826        |_| {},
827        |_| {},
828    )
829}
830
831/// Process IFC content with parallel geometry extraction and a configurable streaming batch size.
832pub fn process_geometry_streaming_filtered(
833    content: &[u8],
834    opening_filter: OpeningFilterMode,
835    batch_size: usize,
836    on_batch: impl FnMut(&[MeshData], usize, usize),
837    on_color_update: impl FnMut(&[(u32, [f32; 4])]),
838) -> ProcessingResult {
839    process_geometry_streaming_filtered_with_options(
840        content,
841        opening_filter,
842        StreamingOptions {
843            initial_batch_size: batch_size,
844            throughput_batch_size: batch_size,
845            ..StreamingOptions::default()
846        },
847        on_batch,
848        on_color_update,
849        |_| {},
850    )
851}
852
853/// Process IFC content with parallel geometry extraction and configurable streaming behavior.
854pub fn process_geometry_streaming_filtered_with_options(
855    content: &[u8],
856    opening_filter: OpeningFilterMode,
857    options: StreamingOptions,
858    mut on_batch: impl FnMut(&[MeshData], usize, usize),
859    mut on_color_update: impl FnMut(&[(u32, [f32; 4])]),
860    mut on_quick_metadata_bootstrap: impl FnMut(&QuickMetadataBootstrap),
861) -> ProcessingResult {
862    let total_start = std::time::Instant::now();
863    let parse_start = std::time::Instant::now();
864    let entity_scan_start = std::time::Instant::now();
865
866    tracing::info!(
867        content_size = content.len(),
868        "Starting IFC geometry processing"
869    );
870
871    // Build entity index (fast SIMD-accelerated single pass)
872    let entity_index = Arc::new(build_entity_index(content));
873    let mut decoder = EntityDecoder::with_arc_index(content, entity_index.clone());
874    tracing::debug!("Built entity index");
875
876    // Styled items / indexed colour maps / material chain / voids / fills /
877    // aggregates are span-stashed during the scan and resolved afterwards by
878    // the SHARED resolver (`crate::prepass::resolve_prepass`) — the exact code
879    // the browser prepasses run, so the #858/#913-class resolution drift
880    // cannot recur.
881    let mut prepass_spans = crate::prepass::PrepassSpans::default();
882    let mut project_id: Option<u32> = None;
883    let mut presentation_layer_by_assigned_id: FxHashMap<u32, String> = FxHashMap::default();
884    let mut property_values_by_id: FxHashMap<u32, (String, String)> = FxHashMap::default();
885    let mut property_sets_by_id: FxHashMap<u32, PropertySetDefinition> = FxHashMap::default();
886    let mut rel_defines_by_properties: Vec<RelDefinesByPropertiesLink> = Vec::new();
887
888    // Collect geometry entities
889    let mut scanner = EntityScanner::new(content);
890    let mut entity_jobs: Vec<EntityJob> = Vec::with_capacity(2000);
891    // #957: type-product geometry (IfcXxxType + its RepresentationMaps) and the
892    // set of RepresentationMaps already instantiated by an IfcMappedItem. After
893    // the scan, RepresentationMaps NOT in the referenced set are rendered as
894    // orphan type geometry (buildingSMART annex-E showcase files).
895    let mut type_product_geometry: Vec<(u32, usize, usize, IfcType, Vec<u32>)> = Vec::new();
896    let mut referenced_representation_maps: FxHashSet<u32> = FxHashSet::default();
897    // #957 follow-up: type ids that an IfcRelDefinesByType instantiates (the type
898    // has at least one occurrence). Such a type's geometry is already drawn through
899    // its occurrences — directly or via an IfcMappedItem — so it must NOT also be
900    // rendered as orphan type-only geometry. Real-world exporters (e.g. ArchiCAD
901    // AC20) attach a RepresentationMap to nearly every door/window/furniture type
902    // while the occurrence carries its own body, leaving the type map referenced by
903    // no IfcMappedItem; without this gate every such type double-renders at its
904    // MappingOrigin (duplicate boxes at the wrong position).
905    let mut instantiated_type_ids: FxHashSet<u32> = FxHashSet::default();
906    let quick_metadata_enabled = options.emit_quick_metadata_bootstrap;
907    let mut quick_spatial_nodes =
908        quick_metadata_enabled.then(HashMap::<u32, QuickSpatialNodeEntry>::new);
909    let mut quick_aggregate_links = if quick_metadata_enabled {
910        Vec::<(u32, Vec<u32>)>::new()
911    } else {
912        Vec::new()
913    };
914    let mut quick_containment_links = if quick_metadata_enabled {
915        Vec::<(u32, Vec<u32>)>::new()
916    } else {
917        Vec::new()
918    };
919    // IfcRelReferencedInSpatialStructure is a *secondary* (non-owning) link — a
920    // space referenced from another storey for context. It must NOT establish
921    // primary tree ownership, so it is kept separate from containment links and
922    // only ever contributes elements, never parent/child node ownership (#1075).
923    let mut quick_referenced_links = if quick_metadata_enabled {
924        Vec::<(u32, Vec<u32>)>::new()
925    } else {
926        Vec::new()
927    };
928    let mut quick_element_summaries = if quick_metadata_enabled {
929        HashMap::<u32, QuickMetadataEntitySummary>::new()
930    } else {
931        HashMap::new()
932    };
933    let mut schema_version = "IFC2X3".to_string();
934    let mut total_entities = 0usize;
935    let mut site_entity_pos: Option<(usize, usize)> = None;
936    let mut building_entity_pos: Option<(usize, usize)> = None;
937
938    let defer_style_updates = options.fast_first_batch
939        && opening_filter == OpeningFilterMode::Default
940        && !options.include_presentation_layers;
941
942    while let Some((id, type_name, start, end)) = scanner.next_entity() {
943        total_entities += 1;
944        if let Some(spatial_nodes) = quick_spatial_nodes.as_mut() {
945            // Case-insensitive check without allocating a new uppercase string.
946            if is_quick_spatial_type_ci(type_name) {
947                let args = parse_step_arguments(&content[start..end]);
948                let fallback = format!("{type_name} #{id}");
949                spatial_nodes.entry(id).or_insert(QuickSpatialNodeEntry {
950                    express_id: id,
951                    type_name: type_name.to_string(),
952                    name: extract_name_from_args(&args, &fallback),
953                    elevation: if type_name.eq_ignore_ascii_case("IfcBuildingStorey") {
954                        extract_storey_elevation_from_args(&args)
955                    } else {
956                        None
957                    },
958                    children: Vec::new(),
959                    elements: Vec::new(),
960                    parent: None,
961                });
962            } else if type_name.eq_ignore_ascii_case("IFCRELAGGREGATES") {
963                let args = parse_step_arguments(&content[start..end]);
964                if let Some(parent_id) = args.get(4).and_then(|token| parse_step_ref(token)) {
965                    quick_aggregate_links.push((
966                        parent_id,
967                        args.get(5)
968                            .map(|token| parse_step_ref_list(token))
969                            .unwrap_or_default(),
970                    ));
971                }
972            } else if type_name.eq_ignore_ascii_case("IFCRELCONTAINEDINSPATIALSTRUCTURE") {
973                let args = parse_step_arguments(&content[start..end]);
974                if let Some(parent_id) = args.get(5).and_then(|token| parse_step_ref(token)) {
975                    quick_containment_links.push((
976                        parent_id,
977                        args.get(4)
978                            .map(|token| parse_step_ref_list(token))
979                            .unwrap_or_default(),
980                    ));
981                }
982            } else if type_name.eq_ignore_ascii_case("IFCRELREFERENCEDINSPATIALSTRUCTURE") {
983                let args = parse_step_arguments(&content[start..end]);
984                if let Some(parent_id) = args.get(5).and_then(|token| parse_step_ref(token)) {
985                    quick_referenced_links.push((
986                        parent_id,
987                        args.get(4)
988                            .map(|token| parse_step_ref_list(token))
989                            .unwrap_or_default(),
990                    ));
991                }
992            }
993        }
994
995        if type_name == "IFCINDEXEDCOLOURMAP" {
996            // Span-stashed for the shared post-scan resolver (#663, #858).
997            prepass_spans.indexed_colour_maps.push((id, start, end));
998            continue;
999        }
1000
1001        if type_name == "IFCSTYLEDITEM" {
1002            // Span-stashed; the shared resolver classifies orphan (material
1003            // appearance, #407 — always resolved up front) vs
1004            // geometry-attached (deferred in fast_first_batch mode, #913 §2c).
1005            prepass_spans.styled_items.push((id, start, end));
1006            continue;
1007        } else if type_name == "IFCMATERIALDEFINITIONREPRESENTATION" {
1008            prepass_spans.material_def_reprs.push((id, start, end));
1009            continue;
1010        } else if type_name == "IFCRELASSOCIATESMATERIAL" {
1011            prepass_spans.rel_associates_material.push((id, start, end));
1012            continue;
1013        } else if type_name == "IFCPRESENTATIONLAYERASSIGNMENT" {
1014            if !options.include_presentation_layers {
1015                continue;
1016            }
1017            if let Ok(layer_assignment) = decoder.decode_at(start, end) {
1018                collect_presentation_layer_assignments(
1019                    &mut presentation_layer_by_assigned_id,
1020                    &layer_assignment,
1021                );
1022            }
1023            continue;
1024        } else if type_name == "IFCPROPERTYSET" {
1025            if !options.include_properties {
1026                continue;
1027            }
1028            if let Ok(property_set) = decoder.decode_at(start, end) {
1029                if let Some(definition) = collect_property_set_definition(&property_set) {
1030                    property_sets_by_id.insert(id, definition);
1031                }
1032            }
1033            continue;
1034        } else if type_name == "IFCRELDEFINESBYPROPERTIES" {
1035            if !options.include_properties {
1036                continue;
1037            }
1038            if let Ok(rel_defines) = decoder.decode_at(start, end) {
1039                if let Some(link) = collect_rel_defines_by_properties_link(&rel_defines) {
1040                    rel_defines_by_properties.push(link);
1041                }
1042            }
1043            continue;
1044        } else if type_name.starts_with("IFCPROPERTY") {
1045            if !options.include_properties {
1046                continue;
1047            }
1048            if let Ok(property_entity) = decoder.decode_at(start, end) {
1049                if let Some((name, value)) = extract_property_name_and_value(&property_entity) {
1050                    property_values_by_id.insert(id, (name, value));
1051                }
1052            }
1053            continue;
1054        } else if type_name == "IFCRELVOIDSELEMENT" {
1055            prepass_spans.void_rels.push((id, start, end));
1056        } else if type_name == "IFCRELFILLSELEMENT" {
1057            prepass_spans.fills_rels.push((id, start, end));
1058        } else if type_name == "IFCRELAGGREGATES" {
1059            // Independent of quick-metadata mode: the shared resolver decodes
1060            // these into the parent → children map that pushes voids down to
1061            // aggregated parts when the host has no body of its own
1062            // (IfcWallElementedCase, #845).
1063            prepass_spans.aggregate_rels.push((id, start, end));
1064        } else if type_name == "IFCPROJECT" && project_id.is_none() {
1065            project_id = Some(id);
1066        } else if type_name == "IFCSITE" && site_entity_pos.is_none() {
1067            site_entity_pos = Some((start, end));
1068        } else if type_name == "IFCBUILDING" && building_entity_pos.is_none() {
1069            building_entity_pos = Some((start, end));
1070        }
1071
1072        if ifc_lite_core::has_geometry_by_name(type_name) {
1073            let ifc_type = IfcType::from_str(type_name);
1074            if quick_metadata_enabled {
1075                quick_element_summaries.insert(
1076                    id,
1077                    QuickMetadataEntitySummary {
1078                        express_id: id,
1079                        type_name: type_name.to_string(),
1080                        name: format!("{type_name} #{id}"),
1081                        global_id: None,
1082                        kind: "element".to_string(),
1083                        has_children: false,
1084                        element_count: None,
1085                        elevation: None,
1086                    },
1087                );
1088            }
1089            entity_jobs.push(EntityJob {
1090                id,
1091                ifc_type: ifc_type.clone(),
1092                start,
1093                end,
1094                product_definition_shape_id: None,
1095                element_color: crate::style::default_color_for_type(ifc_type).to_array(),
1096                global_id: None,
1097                name: None,
1098                presentation_layer: None,
1099                space_zone_properties: None,
1100                representation_map_id: None,
1101            });
1102        }
1103        // #957: collect type-product geometry (IfcXxxType carrying its own
1104        // RepresentationMaps) and every IfcMappedItem's MappingSource, so after
1105        // the scan we can render the RepresentationMaps that NO occurrence
1106        // instantiates (orphan library/showcase geometry). The cheap suffix
1107        // pre-filter keeps the is_subtype_of check off the hot path for the
1108        // ~all-non-type majority of entities.
1109        else if type_name == "IFCMAPPEDITEM" {
1110            let args = parse_step_arguments(&content[start..end]);
1111            if let Some(source_id) = args.first().and_then(|token| parse_step_ref(token)) {
1112                referenced_representation_maps.insert(source_id);
1113            }
1114        } else if type_name == "IFCRELDEFINESBYTYPE" {
1115            // IfcRelDefinesByType.RelatingType is the last attribute (index 5);
1116            // record it so its type-only geometry is suppressed (it has occurrences).
1117            let args = parse_step_arguments(&content[start..end]);
1118            if let Some(type_id) = args.get(5).and_then(|token| parse_step_ref(token)) {
1119                instantiated_type_ids.insert(type_id);
1120            }
1121        } else if (type_name.ends_with("TYPE") || type_name.ends_with("STYLE"))
1122            && IfcType::from_str(type_name).is_subtype_of(IfcType::IfcTypeProduct)
1123        {
1124            let args = parse_step_arguments(&content[start..end]);
1125            // IfcTypeProduct.RepresentationMaps is attribute index 6.
1126            let rep_map_ids = args
1127                .get(6)
1128                .map(|token| parse_step_ref_list(token))
1129                .unwrap_or_default();
1130            if !rep_map_ids.is_empty() {
1131                type_product_geometry.push((
1132                    id,
1133                    start,
1134                    end,
1135                    IfcType::from_str(type_name),
1136                    rep_map_ids,
1137                ));
1138            }
1139        }
1140    }
1141
1142    // #957: synthesize render jobs for orphan type-product geometry — a
1143    // RepresentationMap on an IfcXxxType that no IfcMappedItem instantiates.
1144    // Normally-instanced typed products keep their geometry on the occurrence
1145    // (whose IfcMappedItem references the map), so those maps are in
1146    // `referenced_representation_maps` and skipped here — no double render.
1147    // buildingSMART annex-E "tessellated shape with style" files declare the
1148    // geometry only on the type, so without this they render nothing (#957).
1149    for (type_id, start, end, ifc_type, rep_map_ids) in &type_product_geometry {
1150        // The orphan/instanced decision is canonical in
1151        // `element::plan_type_geometry`; the native pipeline suppresses
1152        // instanced types entirely (an export must never duplicate geometry),
1153        // so every planned map here renders as an orphan (class 1).
1154        for (rep_map_id, _class) in crate::element::plan_type_geometry(
1155            rep_map_ids,
1156            &referenced_representation_maps,
1157            instantiated_type_ids.contains(type_id),
1158            crate::element::TypeGeometryMode::SuppressInstanced,
1159        ) {
1160            entity_jobs.push(EntityJob {
1161                id: *type_id,
1162                ifc_type: *ifc_type,
1163                start: *start,
1164                end: *end,
1165                product_definition_shape_id: None,
1166                element_color: crate::style::default_color_for_type(*ifc_type).to_array(),
1167                global_id: None,
1168                name: None,
1169                presentation_layer: None,
1170                space_zone_properties: None,
1171                representation_map_id: Some(rep_map_id),
1172            });
1173        }
1174    }
1175
1176    // ── Shared post-scan resolution (`crate::prepass`) ──
1177    // Styled items (orphan vs attached, defer-aware), IfcIndexedColourMap,
1178    // the #407 material chain join, voids + fills, and the #845 aggregate
1179    // void propagation — the exact code the browser prepasses run.
1180    let resolved = crate::prepass::resolve_prepass(
1181        &prepass_spans,
1182        &mut decoder,
1183        crate::prepass::ResolveOptions {
1184            collect_indexed_colour_full: true,
1185            defer_attached_styles: defer_style_updates,
1186        },
1187    );
1188    let crate::prepass::ResolvedPrepass {
1189        mut geometry_style_index,
1190        indexed_colour_index,
1191        indexed_colour_full,
1192        element_material_colors,
1193        void_index,
1194        filling_by_opening,
1195        deferred_attached_styled_spans: deferred_styled_item_positions,
1196        ..
1197    } = resolved;
1198
1199    let entity_scan_time = entity_scan_start.elapsed();
1200
1201    let lookup_start = std::time::Instant::now();
1202    if options.include_properties {
1203        assign_space_zone_properties(
1204            &mut entity_jobs,
1205            &property_values_by_id,
1206            &property_sets_by_id,
1207            &rel_defines_by_properties,
1208        );
1209    }
1210    if options.fast_first_batch {
1211        entity_jobs.sort_by(|left, right| {
1212            geometry_priority_score(&right.ifc_type).cmp(&geometry_priority_score(&left.ifc_type))
1213        });
1214    }
1215    let lookup_time = lookup_start.elapsed();
1216
1217    let (skipped_entity_ids, filtered_void_index) = apply_opening_filter(
1218        &entity_jobs,
1219        &void_index,
1220        &filling_by_opening,
1221        &geometry_style_index,
1222        &mut decoder,
1223        opening_filter,
1224    );
1225
1226    // Detect schema version
1227    if content
1228        .windows(b"IFC4X3".len())
1229        .any(|window| window == b"IFC4X3")
1230    {
1231        schema_version = "IFC4X3".into();
1232    } else if content
1233        .windows(b"IFC4".len())
1234        .any(|window| window == b"IFC4")
1235    {
1236        schema_version = "IFC4".into();
1237    }
1238
1239    let geometry_entity_count = entity_jobs.len();
1240    tracing::info!(
1241        total_entities = total_entities,
1242        geometry_entities = geometry_entity_count,
1243        voids = void_index.len(),
1244        schema_version = %schema_version,
1245        "Entity scanning complete"
1246    );
1247
1248    if let Some(mut spatial_nodes) = quick_spatial_nodes.take() {
1249        for (parent_id, child_ids) in quick_aggregate_links {
1250            if !spatial_nodes.contains_key(&parent_id) {
1251                continue;
1252            }
1253            for child_id in child_ids {
1254                if !spatial_nodes.contains_key(&child_id) {
1255                    continue;
1256                }
1257                if let Some(parent) = spatial_nodes.get_mut(&parent_id) {
1258                    parent.children.push(child_id);
1259                }
1260                if let Some(child) = spatial_nodes.get_mut(&child_id) {
1261                    child.parent = Some(parent_id);
1262                }
1263            }
1264        }
1265        for (parent_id, element_ids) in quick_containment_links {
1266            if !spatial_nodes.contains_key(&parent_id) {
1267                continue;
1268            }
1269            for child_id in element_ids {
1270                // A spatial element (IfcSpace / IfcSpatialZone) attached to a
1271                // storey via IfcRelContainedInSpatialStructure — what Revit
1272                // Family + Dynamo emits instead of IfcRelAggregates — is a real
1273                // node of the spatial tree, not a contained product. Promote it
1274                // to a child node so it shows in the hierarchy (#1075); anything
1275                // that isn't itself a spatial node stays a contained element.
1276                if spatial_nodes.contains_key(&child_id) {
1277                    // Skip if already placed via IfcRelAggregates (wired just
1278                    // above) to avoid a duplicate child / parent overwrite.
1279                    let already_placed = spatial_nodes
1280                        .get(&child_id)
1281                        .is_some_and(|child| child.parent.is_some());
1282                    if !already_placed {
1283                        if let Some(parent) = spatial_nodes.get_mut(&parent_id) {
1284                            parent.children.push(child_id);
1285                        }
1286                        if let Some(child) = spatial_nodes.get_mut(&child_id) {
1287                            child.parent = Some(parent_id);
1288                        }
1289                    }
1290                } else if let Some(parent) = spatial_nodes.get_mut(&parent_id) {
1291                    parent.elements.push(child_id);
1292                }
1293            }
1294        }
1295        // Referenced-in links are non-owning: they only contribute elements and
1296        // never promote to (or re-parent) a spatial node, so a space referenced
1297        // from a second storey can't steal ownership from its containing storey.
1298        for (parent_id, element_ids) in quick_referenced_links {
1299            if !spatial_nodes.contains_key(&parent_id) {
1300                continue;
1301            }
1302            for child_id in element_ids {
1303                // A child that is itself a spatial node keeps the ownership it
1304                // got from its IfcRelContainedInSpatialStructure/aggregate link.
1305                if spatial_nodes.contains_key(&child_id) {
1306                    continue;
1307                }
1308                if let Some(parent) = spatial_nodes.get_mut(&parent_id) {
1309                    parent.elements.push(child_id);
1310                }
1311            }
1312        }
1313        let mut root_id = spatial_nodes
1314            .values()
1315            .find(|node| node.type_name == "IfcProject")
1316            .map(|node| node.express_id);
1317        if root_id.is_none() {
1318            root_id = spatial_nodes
1319                .values()
1320                .find(|node| node.parent.is_none())
1321                .map(|node| node.express_id);
1322        }
1323        let spatial_tree = root_id
1324            .map(|root| {
1325                build_quick_spatial_tree_node(root, &spatial_nodes, &quick_element_summaries)
1326            })
1327            .transpose()
1328            .unwrap_or(None);
1329        on_quick_metadata_bootstrap(&QuickMetadataBootstrap {
1330            schema_version: schema_version.clone(),
1331            entity_count: total_entities,
1332            spatial_tree,
1333        });
1334    }
1335
1336    // Preprocess complex geometry
1337    let preprocess_start = std::time::Instant::now();
1338    // Resolve BOTH unit scales once via the shared resolver (the scan recorded
1339    // IFCPROJECT's id, so this is an O(1) decode — no more full-file hunts:
1340    // the historic `with_units` + `plane_angle_to_radians` pair each re-walked
1341    // the whole DATA section). Seed the shared decoder so every later consumer
1342    // (opening filter, metadata phase, deferred-style replay) inherits them.
1343    let unit_scales = crate::prepass::resolve_unit_scales(content, project_id, &mut decoder);
1344    decoder.seed_unit_scales(
1345        unit_scales.length_unit_scale,
1346        unit_scales.plane_angle_to_radians,
1347    );
1348    let mut router = GeometryRouter::with_scale(unit_scales.length_unit_scale);
1349    router.set_tessellation_quality(options.tessellation_quality);
1350
1351    // Resolve IfcSite and IfcBuilding placement transforms.
1352    let site_transform: Option<Vec<f64>> = site_entity_pos.and_then(|(start, end)| {
1353        let entity = decoder.decode_at(start, end).ok()?;
1354        let matrix = router
1355            .resolve_scaled_placement(&entity, &mut decoder)
1356            .ok()?;
1357        Some(matrix.to_vec())
1358    });
1359    let building_transform: Option<Vec<f64>> = building_entity_pos.and_then(|(start, end)| {
1360        let entity = decoder.decode_at(start, end).ok()?;
1361        let matrix = router
1362            .resolve_scaled_placement(&entity, &mut decoder)
1363            .ok()?;
1364        Some(matrix.to_vec())
1365    });
1366
1367    let rtc_jobs: Vec<(u32, usize, usize, IfcType)> = entity_jobs
1368        .iter()
1369        .map(|job| (job.id, job.start, job.end, job.ifc_type))
1370        .collect();
1371    let detected_rtc_offset =
1372        router.detect_rtc_offset_with_fallback(&rtc_jobs, &mut decoder, content);
1373
1374    // Three-tier coordinate-space selection:
1375    //   1. `site_local`: IfcSite placement has a non-identity translation.
1376    //      Vertices are expressed relative to the site origin — small floats
1377    //      AND a meaningful, relatable frame (useful for coordination).
1378    //   2. `model_rtc`:  IfcSite is identity (or missing) but geometry still
1379    //      lives at large world coordinates. Subtract a detected anchor so
1380    //      f32 precision is preserved.
1381    //   3. `raw_ifc`:    neither anchor applies; geometry is already small.
1382    let site_rtc = site_transform
1383        .as_ref()
1384        .map(|st| (st[12], st[13], st[14])) // column-major: translation at 12,13,14
1385        .filter(|t| translation_is_nonidentity(*t));
1386    let detected_has_offset = translation_is_nonidentity(detected_rtc_offset);
1387    let (rtc_offset, coord_space) = if let Some(site) = site_rtc {
1388        (site, SITE_LOCAL_MESH_COORDINATE_SPACE)
1389    } else if detected_has_offset {
1390        (detected_rtc_offset, MODEL_RTC_MESH_COORDINATE_SPACE)
1391    } else {
1392        ((0.0, 0.0, 0.0), RAW_IFC_MESH_COORDINATE_SPACE)
1393    };
1394    let has_rtc_offset = coord_space != RAW_IFC_MESH_COORDINATE_SPACE;
1395    router.set_rtc_offset(rtc_offset);
1396    let preprocess_time = preprocess_start.elapsed();
1397
1398    let parse_time = parse_start.elapsed();
1399    tracing::info!(
1400        entity_scan_time_ms = entity_scan_time.as_millis(),
1401        lookup_time_ms = lookup_time.as_millis(),
1402        preprocess_time_ms = preprocess_time.as_millis(),
1403        parse_time_ms = parse_time.as_millis(),
1404        "Parse phase complete, starting geometry extraction"
1405    );
1406
1407    // PARALLEL GEOMETRY PROCESSING
1408    let geometry_start = std::time::Instant::now();
1409    let entity_index_arc = entity_index; // Already Arc from above
1410    let unit_scale = router.unit_scale();
1411    let rtc_offset = router.rtc_offset();
1412    // Resolve the plane-angle scale ONCE on the warm shared decoder, then seed
1413    // every per-element worker decoder below (EntityDecoder::seed_unit_scales).
1414    // Resolved once by the shared `prepass::resolve_unit_scales` above — the
1415    // parallel path builds a fresh (cold-cache) decoder per element, so
1416    // without seeding every arc-bearing element would re-pay an O(file)
1417    // IFCPROJECT scan (≈135 ms each on a 75 MB model where IFCPROJECT sits at
1418    // byte ~68 MB).
1419    let seed_plane_angle_to_radians = unit_scales.plane_angle_to_radians;
1420    let void_index_arc = Arc::new(filtered_void_index);
1421    let skipped_entity_ids = Arc::new(skipped_entity_ids);
1422    // Fold indexed-colour-map colours in where no IFCSTYLEDITEM already claimed
1423    // the geometry (styled items win, matching the browser precedence).
1424    crate::prepass::merge_indexed_colours(&mut geometry_style_index, &indexed_colour_index);
1425    let mut geometry_style_index = Arc::new(geometry_style_index);
1426    let indexed_colour_full = Arc::new(indexed_colour_full);
1427    // #961: decode surface textures (IfcBlobTexture PNG / IfcPixelTexture) and
1428    // their per-triangle UV maps once, keyed by face-set id. `build_texture_index`
1429    // bails out on a cheap substring check for the (vast majority) untextured
1430    // files. Consumed by the type-only render path below.
1431    let texture_index = Arc::new(ifc_lite_geometry::build_texture_index(
1432        content,
1433        &mut decoder,
1434    ));
1435    // Material chain joined by the shared resolver (#407). The single
1436    // opaque-first colour is the general-path element fallback; the full list
1437    // feeds the opening sub-mesh transparent/opaque split (#913 §2.3).
1438    let element_material_color: FxHashMap<u32, [f32; 4]> = element_material_colors
1439        .iter()
1440        .filter_map(|(&id, colors)| crate::style::pick_opaque_first(colors).map(|c| (id, c)))
1441        .collect();
1442    let element_material_colors = Arc::new(element_material_colors);
1443
1444    let total_jobs = entity_jobs.len();
1445    let initial_chunk_size = options.initial_batch_size.max(1);
1446    let throughput_chunk_size = options.throughput_batch_size.max(initial_chunk_size);
1447    let mut color_cache_by_product_definition_shape: FxHashMap<u32, Option<[f32; 4]>> =
1448        FxHashMap::default();
1449    let mut layer_cache_by_product_definition_shape: FxHashMap<u32, Option<String>> =
1450        FxHashMap::default();
1451    let mut layer_cache_by_representation: FxHashMap<u32, Option<String>> = FxHashMap::default();
1452    let mut meshes: Vec<MeshData> = Vec::new();
1453    let mut processed_jobs = 0usize;
1454    let mut total_meshes = 0usize;
1455    let mut total_vertices = 0usize;
1456    let mut total_triangles = 0usize;
1457    let mut chunk_start = 0usize;
1458    let mut current_chunk_size = initial_chunk_size;
1459
1460    let mut deferred_styles_applied = !defer_style_updates;
1461
1462    // CSG-diagnostics sink shared across all per-job routers (drained after
1463    // the loop into ProcessingStats + one tracing summary).
1464    let csg_failure_collector: std::sync::Mutex<FxHashMap<u32, Vec<ifc_lite_geometry::BoolFailure>>> =
1465        std::sync::Mutex::new(FxHashMap::default());
1466
1467    while chunk_start < total_jobs {
1468        let chunk_end = (chunk_start + current_chunk_size).min(total_jobs);
1469        let jobs_chunk = &mut entity_jobs[chunk_start..chunk_end];
1470
1471        // ── Desktop: two-phase parallel metadata population ──
1472        // Phase 1 (parallel): decode entities, extract GlobalId/Name/ProductDefinitionShapeId
1473        // Phase 2 (serial): resolve colors from cache (cheap, cache-hit dominated)
1474        #[cfg(not(target_arch = "wasm32"))]
1475        {
1476            // Phase 1: parallel decode with thread-local EntityDecoder
1477            let entity_index_for_meta = entity_index_arc.clone();
1478            jobs_chunk.par_iter_mut().for_each(|job| {
1479                if job.global_id.is_some()
1480                    || job.name.is_some()
1481                    || job.product_definition_shape_id.is_some()
1482                {
1483                    return;
1484                }
1485                let mut local_decoder =
1486                    EntityDecoder::with_arc_index(content, entity_index_for_meta.clone());
1487                let Ok(entity) = local_decoder.decode_at(job.start, job.end) else {
1488                    return;
1489                };
1490                job.global_id = normalize_optional_string(entity.get_string(0));
1491                job.name = normalize_optional_string(entity.get_string(2));
1492                job.product_definition_shape_id = entity.get_ref(6);
1493            });
1494
1495            // Phase 2: serial color/layer resolution (cache-hit dominated, fast)
1496            for job in jobs_chunk.iter_mut() {
1497                let Some(pds_id) = job.product_definition_shape_id else {
1498                    continue;
1499                };
1500                let resolved_color = color_cache_by_product_definition_shape
1501                    .entry(pds_id)
1502                    .or_insert_with(|| {
1503                        resolve_element_color_for_product_definition_shape(
1504                            pds_id,
1505                            &geometry_style_index,
1506                            &mut decoder,
1507                        )
1508                    });
1509                if let Some(color) = resolved_color {
1510                    job.element_color = *color;
1511                } else if let Some(color) = element_material_color.get(&job.id) {
1512                    // No direct/indexed geometry style — inherit the material
1513                    // appearance (#407).
1514                    job.element_color = *color;
1515                }
1516                if options.include_presentation_layers {
1517                    let resolved_layer = layer_cache_by_product_definition_shape
1518                        .entry(pds_id)
1519                        .or_insert_with(|| {
1520                            resolve_presentation_layer_for_product_definition_shape(
1521                                pds_id,
1522                                &presentation_layer_by_assigned_id,
1523                                &mut layer_cache_by_representation,
1524                                &mut decoder,
1525                            )
1526                        });
1527                    job.presentation_layer = resolved_layer.clone();
1528                }
1529            }
1530        }
1531
1532        // ── WASM: existing serial path (unchanged) ──
1533        #[cfg(target_arch = "wasm32")]
1534        for job in jobs_chunk.iter_mut() {
1535            populate_entity_job_metadata(
1536                job,
1537                &geometry_style_index,
1538                &element_material_color,
1539                &presentation_layer_by_assigned_id,
1540                &mut color_cache_by_product_definition_shape,
1541                &mut layer_cache_by_product_definition_shape,
1542                &mut layer_cache_by_representation,
1543                &mut decoder,
1544                options.include_presentation_layers,
1545            );
1546        }
1547        let site_local_rotation: Option<&Vec<f64>> =
1548            if coord_space == SITE_LOCAL_MESH_COORDINATE_SPACE {
1549                site_transform.as_ref()
1550            } else {
1551                None
1552            };
1553        let chunk_meshes: Vec<MeshData> = jobs_chunk
1554            .par_iter()
1555            .flat_map_iter(|job| {
1556                process_entity_job(
1557                    job,
1558                    content,
1559                    &entity_index_arc,
1560                    unit_scale,
1561                    rtc_offset,
1562                    seed_plane_angle_to_radians,
1563                    options.tessellation_quality,
1564                    void_index_arc.as_ref(),
1565                    skipped_entity_ids.as_ref(),
1566                    geometry_style_index.as_ref(),
1567                    indexed_colour_full.as_ref(),
1568                    element_material_colors.as_ref(),
1569                    texture_index.as_ref(),
1570                    site_local_rotation,
1571                    &csg_failure_collector,
1572                )
1573            })
1574            .collect();
1575
1576        processed_jobs += jobs_chunk.len();
1577        total_vertices += chunk_meshes.iter().map(|m| m.vertex_count()).sum::<usize>();
1578        total_triangles += chunk_meshes
1579            .iter()
1580            .map(|m| m.triangle_count())
1581            .sum::<usize>();
1582
1583        if !chunk_meshes.is_empty() {
1584            total_meshes += chunk_meshes.len();
1585            let emit_mesh_chunk_size = current_chunk_size.max(1);
1586            for emitted_meshes in chunk_meshes.chunks(emit_mesh_chunk_size) {
1587                on_batch(emitted_meshes, processed_jobs, total_jobs);
1588            }
1589            if options.retain_emitted_meshes {
1590                meshes.extend(chunk_meshes);
1591            }
1592
1593            if !deferred_styles_applied {
1594                // Replay saved IFCSTYLEDITEM positions instead of re-scanning
1595                // the entire file.  This eliminates ~0.5-1 s for 1 GB files.
1596                // The replay is the shared resolver's styled-item building
1597                // block, so deferred and up-front resolution cannot drift.
1598                let mut rebuilt_styles = {
1599                    let mut style_decoder =
1600                        EntityDecoder::with_arc_index(content, entity_index_arc.clone());
1601                    crate::prepass::resolve_styled_item_spans(
1602                        &deferred_styled_item_positions,
1603                        &mut style_decoder,
1604                    )
1605                };
1606                crate::prepass::merge_indexed_colours(&mut rebuilt_styles, &indexed_colour_index);
1607                geometry_style_index = Arc::new(rebuilt_styles);
1608                let deferred_color_updates = build_color_updates_for_jobs(
1609                    &entity_jobs[..processed_jobs],
1610                    geometry_style_index.as_ref(),
1611                    content,
1612                    &entity_index_arc,
1613                );
1614                if !deferred_color_updates.is_empty() {
1615                    on_color_update(&deferred_color_updates);
1616                }
1617                deferred_styles_applied = true;
1618            }
1619        }
1620        chunk_start = chunk_end;
1621        current_chunk_size = throughput_chunk_size;
1622    }
1623
1624    let geometry_time = geometry_start.elapsed();
1625    // Surface the aggregated CSG diagnostics — same per-reason breakdown the
1626    // browser console shows on the wasm path.
1627    let csg_failures = csg_failure_collector
1628        .into_inner()
1629        .unwrap_or_else(|poisoned| poisoned.into_inner());
1630    let total_csg_failures: usize = csg_failures.values().map(Vec::len).sum();
1631    let products_with_failures = csg_failures.len();
1632    if total_csg_failures > 0 {
1633        let mut by_reason: HashMap<&'static str, usize> = HashMap::new();
1634        for fails in csg_failures.values() {
1635            for f in fails {
1636                *by_reason.entry(f.reason.label()).or_insert(0) += 1;
1637            }
1638        }
1639        let mut breakdown: Vec<(&'static str, usize)> = by_reason.into_iter().collect();
1640        breakdown.sort_by(|a, b| b.1.cmp(&a.1));
1641        let breakdown = breakdown
1642            .iter()
1643            .map(|(reason, count)| format!("{reason}={count}"))
1644            .collect::<Vec<_>>()
1645            .join(" ");
1646        tracing::warn!(
1647            total_csg_failures,
1648            products_with_failures,
1649            %breakdown,
1650            "CSG failures during geometry extraction (cut dropped, host kept uncut)"
1651        );
1652    }
1653
1654    let total_time = total_start.elapsed();
1655
1656    tracing::info!(
1657        meshes = meshes.len(),
1658        vertices = total_vertices,
1659        triangles = total_triangles,
1660        geometry_time_ms = geometry_time.as_millis(),
1661        total_time_ms = total_time.as_millis(),
1662        "Geometry processing complete"
1663    );
1664
1665    ProcessingResult {
1666        meshes,
1667        mesh_coordinate_space: Some(coord_space.to_string()),
1668        site_transform,
1669        building_transform,
1670        metadata: ModelMetadata {
1671            schema_version,
1672            entity_count: total_entities,
1673            geometry_entity_count,
1674            coordinate_info: CoordinateInfo {
1675                origin_shift: [rtc_offset.0, rtc_offset.1, rtc_offset.2],
1676                is_geo_referenced: has_rtc_offset,
1677            },
1678            length_unit_scale: Some(unit_scale),
1679            georeferencing: crate::extract_georeferencing(content),
1680        },
1681        stats: ProcessingStats {
1682            total_meshes,
1683            total_vertices,
1684            total_triangles,
1685            parse_time_ms: parse_time.as_millis() as u64,
1686            entity_scan_time_ms: entity_scan_time.as_millis() as u64,
1687            lookup_time_ms: lookup_time.as_millis() as u64,
1688            preprocess_time_ms: preprocess_time.as_millis() as u64,
1689            geometry_time_ms: geometry_time.as_millis() as u64,
1690            total_time_ms: total_time.as_millis() as u64,
1691            from_cache: false,
1692            total_csg_failures: total_csg_failures as u64,
1693            products_with_failures: products_with_failures as u64,
1694        },
1695    }
1696}
1697
1698fn process_entity_job(
1699    job: &EntityJob,
1700    content: &[u8],
1701    entity_index_arc: &Arc<EntityIndex>,
1702    unit_scale: f64,
1703    rtc_offset: (f64, f64, f64),
1704    // Pre-resolved scales seeded into this job's decoder so arc tessellation and
1705    // unit conversion never trigger a per-element full-file IFCPROJECT scan.
1706    seed_plane_angle_to_radians: f64,
1707    tessellation_quality: TessellationQuality,
1708    void_index: &FxHashMap<u32, Vec<u32>>,
1709    skipped_entity_ids: &HashSet<u32>,
1710    geometry_style_index: &FxHashMap<u32, GeometryStyleInfo>,
1711    indexed_colour_full: &FxHashMap<u32, crate::style::FullIndexedColourMap>,
1712    element_material_colors: &FxHashMap<u32, Vec<[f32; 4]>>,
1713    // Surface textures + UV maps keyed by face-set id (#961). Empty for
1714    // untextured models.
1715    texture_index: &FxHashMap<u32, ifc_lite_geometry::ResolvedTextureMap>,
1716    // Present only when the selected coordinate space is `site_local`; rotates
1717    // mesh vertices into the site's axis frame.
1718    site_local_rotation: Option<&Vec<f64>>,
1719    // Shared sink for per-job router CSG diagnostics (parity with the wasm
1720    // path's `drain_and_log_csg_diagnostics`).
1721    csg_failure_collector: &std::sync::Mutex<FxHashMap<u32, Vec<ifc_lite_geometry::BoolFailure>>>,
1722) -> Vec<MeshData> {
1723    if skipped_entity_ids.contains(&job.id) {
1724        return Vec::new();
1725    }
1726
1727    let mut local_decoder = EntityDecoder::with_arc_index(content, entity_index_arc.clone());
1728    // Seed the unit-scale caches so curve/arc processing skips the O(file)
1729    // IFCPROJECT scan that each fresh per-element decoder would otherwise repeat.
1730    local_decoder.seed_unit_scales(unit_scale, seed_plane_angle_to_radians);
1731
1732    let entity = match local_decoder.decode_at(job.start, job.end) {
1733        Ok(entity) => entity,
1734        Err(_) => return Vec::new(),
1735    };
1736
1737    let mut local_router = GeometryRouter::with_scale_and_quality(unit_scale, tessellation_quality);
1738    local_router.set_rtc_offset(rtc_offset);
1739    let local_router = local_router;
1740
1741    let metadata = crate::element::ElementMeshMetadata {
1742        global_id: job.global_id.clone(),
1743        name: job.name.clone(),
1744        presentation_layer: job.presentation_layer.clone(),
1745        space_zone_properties: job.space_zone_properties.clone(),
1746    };
1747    // #957: the scan loop plans type geometry with `SuppressInstanced` (see
1748    // `plan_type_geometry`), so a synthetic job's map always renders as an
1749    // orphan — geometry_class 1.
1750    let kind = match job.representation_map_id {
1751        Some(rep_map_id) => crate::element::ElementJobKind::TypeProduct {
1752            rep_maps: vec![(rep_map_id, 1)],
1753        },
1754        None => crate::element::ElementJobKind::Product,
1755    };
1756    let ctx = crate::element::MeshProductionContext {
1757        void_index,
1758        geometry_style_index,
1759        indexed_colour_full,
1760        element_material_colors,
1761        texture_index,
1762        site_local_rotation,
1763    };
1764
1765    let produced = crate::element::produce_element_meshes(
1766        &crate::element::ElementMeshJob {
1767            id: job.id,
1768            ifc_type: job.ifc_type,
1769            entity: &entity,
1770            kind,
1771            element_color: Some(job.element_color),
1772            metadata: Some(&metadata),
1773        },
1774        &ctx,
1775        // Geometry hashing is a viewer diff feature — off on the native path.
1776        &crate::element::MeshProductionOptions::default(),
1777        &mut local_decoder,
1778        &local_router,
1779    );
1780
1781    // Surface this element's CSG diagnostics in the shared collector. The
1782    // wasm path logs them in the browser console; without this the server
1783    // would silently discard every failed opening cut.
1784    if !produced.csg_failures.is_empty() {
1785        if let Ok(mut collector) = csg_failure_collector.lock() {
1786            for (product_id, fails) in produced.csg_failures {
1787                collector.entry(product_id).or_default().extend(fails);
1788            }
1789        }
1790    }
1791
1792    produced.meshes
1793}
1794
1795
1796
1797fn build_color_updates_for_jobs(
1798    jobs: &[EntityJob],
1799    geometry_styles: &FxHashMap<u32, GeometryStyleInfo>,
1800    content: &[u8],
1801    entity_index: &Arc<EntityIndex>,
1802) -> Vec<(u32, [f32; 4])> {
1803    let mut decoder = EntityDecoder::with_arc_index(content, entity_index.clone());
1804    let mut updates: Vec<(u32, [f32; 4])> = Vec::new();
1805
1806    for job in jobs {
1807        // #957: synthetic type-only-geometry jobs resolve their colour from the
1808        // RepresentationMap (a type has no IfcProductDefinitionShape), so the
1809        // product-definition path below never corrects them. Backfill them here
1810        // or a deferred IfcStyledItem (fast_first_batch) leaves the orphan type
1811        // geometry stuck at its fallback colour.
1812        if let Some(rep_map_id) = job.representation_map_id {
1813            if let Some(color) = crate::element::resolve_color_for_representation_map(
1814                rep_map_id,
1815                geometry_styles,
1816                &mut decoder,
1817            ) {
1818                if color != job.element_color {
1819                    updates.push((job.id, color));
1820                }
1821            }
1822            continue;
1823        }
1824        let Ok(entity) = decoder.decode_at(job.start, job.end) else {
1825            continue;
1826        };
1827        let Some(product_definition_shape_id) = entity.get_ref(6) else {
1828            continue;
1829        };
1830        let Some(color) = resolve_element_color_for_product_definition_shape(
1831            product_definition_shape_id,
1832            geometry_styles,
1833            &mut decoder,
1834        ) else {
1835            continue;
1836        };
1837        if color != job.element_color {
1838            updates.push((job.id, color));
1839        }
1840    }
1841
1842    updates
1843}
1844
1845fn collect_presentation_layer_assignments(
1846    layer_by_assigned_representation: &mut FxHashMap<u32, String>,
1847    layer_assignment: &DecodedEntity,
1848) {
1849    let Some(layer_name) = normalize_optional_string(layer_assignment.get_string(0)) else {
1850        return;
1851    };
1852
1853    let Some(assigned_items) = get_refs_from_list(layer_assignment, 2) else {
1854        return;
1855    };
1856
1857    for assigned in assigned_items {
1858        layer_by_assigned_representation
1859            .entry(assigned)
1860            .or_insert_with(|| layer_name.clone());
1861    }
1862}
1863
1864fn resolve_element_color_for_product_definition_shape(
1865    product_definition_shape_id: u32,
1866    geometry_styles: &FxHashMap<u32, GeometryStyleInfo>,
1867    decoder: &mut EntityDecoder,
1868) -> Option<[f32; 4]> {
1869    find_color_in_representation(product_definition_shape_id, geometry_styles, decoder)
1870}
1871
1872fn resolve_presentation_layer_for_product_definition_shape(
1873    product_definition_shape_id: u32,
1874    layer_by_assigned_representation: &FxHashMap<u32, String>,
1875    cache_by_representation: &mut FxHashMap<u32, Option<String>>,
1876    decoder: &mut EntityDecoder,
1877) -> Option<String> {
1878    if let Some(layer_name) = layer_by_assigned_representation.get(&product_definition_shape_id) {
1879        return Some(layer_name.clone());
1880    }
1881
1882    let product_definition_shape = decoder.decode_by_id(product_definition_shape_id).ok()?;
1883    let representation_ids = get_refs_from_list(&product_definition_shape, 2)?;
1884
1885    for representation_id in representation_ids {
1886        if let Some(layer_name) = resolve_presentation_layer_name(
1887            representation_id,
1888            layer_by_assigned_representation,
1889            cache_by_representation,
1890            decoder,
1891            &mut Vec::new(),
1892        ) {
1893            return Some(layer_name);
1894        }
1895    }
1896
1897    None
1898}
1899
1900fn resolve_presentation_layer_name(
1901    representation_id: u32,
1902    layer_by_assigned_representation: &FxHashMap<u32, String>,
1903    cache_by_representation: &mut FxHashMap<u32, Option<String>>,
1904    decoder: &mut EntityDecoder,
1905    traversal_stack: &mut Vec<u32>,
1906) -> Option<String> {
1907    if let Some(cached) = cache_by_representation.get(&representation_id) {
1908        return cached.clone();
1909    }
1910
1911    if traversal_stack.contains(&representation_id) {
1912        return None;
1913    }
1914    traversal_stack.push(representation_id);
1915
1916    if let Some(layer_name) = layer_by_assigned_representation.get(&representation_id) {
1917        let result = Some(layer_name.clone());
1918        cache_by_representation.insert(representation_id, result.clone());
1919        traversal_stack.pop();
1920        return result;
1921    }
1922
1923    let mut resolved: Option<String> = None;
1924
1925    if let Ok(representation) = decoder.decode_by_id(representation_id) {
1926        if let Some(items) = get_refs_from_list(&representation, 3) {
1927            for item_id in items {
1928                if let Some(layer_name) = layer_by_assigned_representation.get(&item_id) {
1929                    resolved = Some(layer_name.clone());
1930                    break;
1931                }
1932
1933                if let Ok(item) = decoder.decode_by_id(item_id) {
1934                    if item.ifc_type == IfcType::IfcMappedItem {
1935                        if let Some(mapping_source_id) = item.get_ref(0) {
1936                            if let Ok(mapping_source) = decoder.decode_by_id(mapping_source_id) {
1937                                if let Some(mapped_representation_id) = mapping_source.get_ref(1) {
1938                                    if let Some(layer_name) = resolve_presentation_layer_name(
1939                                        mapped_representation_id,
1940                                        layer_by_assigned_representation,
1941                                        cache_by_representation,
1942                                        decoder,
1943                                        traversal_stack,
1944                                    ) {
1945                                        resolved = Some(layer_name);
1946                                        break;
1947                                    }
1948                                }
1949                            }
1950                        }
1951                    }
1952                }
1953            }
1954        }
1955    }
1956
1957    traversal_stack.pop();
1958    cache_by_representation.insert(representation_id, resolved.clone());
1959    resolved
1960}
1961
1962/// Find a color in a representation by traversing its items.
1963fn find_color_in_representation(
1964    repr_id: u32,
1965    geometry_styles: &FxHashMap<u32, GeometryStyleInfo>,
1966    decoder: &mut EntityDecoder,
1967) -> Option<[f32; 4]> {
1968    // Decode the IfcProductDefinitionShape
1969    let repr = decoder.decode_by_id(repr_id).ok()?;
1970
1971    // Attribute 2: Representations (list of IfcRepresentation)
1972    let repr_list = get_refs_from_list(&repr, 2)?;
1973
1974    for shape_repr_id in repr_list {
1975        if let Ok(shape_repr) = decoder.decode_by_id(shape_repr_id) {
1976            // Attribute 3: Items (list of IfcRepresentationItem)
1977            if let Some(items) = get_refs_from_list(&shape_repr, 3) {
1978                for item_id in items {
1979                    // Check direct style
1980                    if let Some(style) = geometry_styles.get(&item_id) {
1981                        return Some(style.color);
1982                    }
1983
1984                    // Check mapped items
1985                    if let Ok(item) = decoder.decode_by_id(item_id) {
1986                        if item.ifc_type == IfcType::IfcMappedItem {
1987                            if let Some(source_id) = item.get_ref(0) {
1988                                if let Ok(source) = decoder.decode_by_id(source_id) {
1989                                    if let Some(mapped_repr_id) = source.get_ref(1) {
1990                                        if let Some(color) = find_color_in_shape_representation(
1991                                            mapped_repr_id,
1992                                            geometry_styles,
1993                                            decoder,
1994                                        ) {
1995                                            return Some(color);
1996                                        }
1997                                    }
1998                                }
1999                            }
2000                        }
2001                    }
2002                }
2003            }
2004        }
2005    }
2006
2007    None
2008}
2009
2010/// Find color in a shape representation.
2011fn find_color_in_shape_representation(
2012    repr_id: u32,
2013    geometry_styles: &FxHashMap<u32, GeometryStyleInfo>,
2014    decoder: &mut EntityDecoder,
2015) -> Option<[f32; 4]> {
2016    let repr = decoder.decode_by_id(repr_id).ok()?;
2017    let items = get_refs_from_list(&repr, 3)?;
2018
2019    for item_id in items {
2020        if let Some(style) = geometry_styles.get(&item_id) {
2021            return Some(style.color);
2022        }
2023    }
2024
2025    None
2026}
2027
2028/// Apply the opening filter and return which entity IDs to suppress and a filtered void index.
2029///
2030/// Returns `(skipped_entity_ids, filtered_void_index)` where:
2031/// - `skipped_entity_ids` is the set of IfcWindow/IfcDoor entity IDs to omit from geometry output
2032/// - `filtered_void_index` is the void index with suppressed openings removed from host lists
2033fn apply_opening_filter(
2034    entity_jobs: &[EntityJob],
2035    void_index: &FxHashMap<u32, Vec<u32>>,
2036    filling_by_opening: &FxHashMap<u32, u32>,
2037    geometry_style_index: &FxHashMap<u32, GeometryStyleInfo>,
2038    decoder: &mut EntityDecoder,
2039    mode: OpeningFilterMode,
2040) -> (HashSet<u32>, FxHashMap<u32, Vec<u32>>) {
2041    if mode == OpeningFilterMode::Default {
2042        return (HashSet::default(), void_index.clone());
2043    }
2044
2045    // Collect all IfcWindow / IfcDoor entity jobs.
2046    let filling_jobs: FxHashMap<u32, &EntityJob> = entity_jobs
2047        .iter()
2048        .filter(|job| matches!(job.ifc_type, IfcType::IfcWindow | IfcType::IfcDoor))
2049        .map(|job| (job.id, job))
2050        .collect();
2051
2052    if filling_jobs.is_empty() {
2053        return (HashSet::default(), void_index.clone());
2054    }
2055
2056    let mut skipped_entity_ids: HashSet<u32> = HashSet::default();
2057
2058    // IgnoreAll: suppress every window/door mesh and clear ALL wall voids.
2059    // We always clear the full void_index because IfcRelFillsElement is often absent
2060    // or only partially present, and without it we cannot identify which specific openings
2061    // belong to windows/doors.
2062    if mode == OpeningFilterMode::IgnoreAll {
2063        for (&id, _) in &filling_jobs {
2064            skipped_entity_ids.insert(id);
2065        }
2066        return (skipped_entity_ids, FxHashMap::default());
2067    }
2068
2069    // IgnoreOpaque: suppress only windows/doors that have no transparent sub-parts.
2070    // Mesh suppression uses element color + style traversal (is_opaque_opening).
2071    // Void suppression uses IfcRelFillsElement data when available.
2072    for (&id, job) in &filling_jobs {
2073        if is_opaque_opening(job, geometry_style_index, decoder) {
2074            skipped_entity_ids.insert(id);
2075        }
2076    }
2077
2078    if filling_by_opening.is_empty() {
2079        // No IfcRelFillsElement — can't map voids to specific window/door entities.
2080        return (skipped_entity_ids, void_index.clone());
2081    }
2082
2083    // Build openings_to_suppress from the explicit opening → filling mapping.
2084    let mut openings_to_suppress: HashSet<u32> = HashSet::default();
2085    for (&opening_id, &filling_id) in filling_by_opening {
2086        if skipped_entity_ids.contains(&filling_id) {
2087            openings_to_suppress.insert(opening_id);
2088        }
2089    }
2090
2091    if openings_to_suppress.is_empty() {
2092        return (skipped_entity_ids, void_index.clone());
2093    }
2094
2095    let mut filtered: FxHashMap<u32, Vec<u32>> = FxHashMap::default();
2096    for (&host_id, openings) in void_index {
2097        let remaining: Vec<u32> = openings
2098            .iter()
2099            .copied()
2100            .filter(|oid| !openings_to_suppress.contains(oid))
2101            .collect();
2102        if !remaining.is_empty() {
2103            filtered.insert(host_id, remaining);
2104        }
2105    }
2106
2107    (skipped_entity_ids, filtered)
2108}
2109
2110/// Returns `true` when the entity has no transparent or glass sub-parts,
2111/// meaning it is an opaque window/door that should be suppressed by `IgnoreOpaque`.
2112///
2113/// Any of the following makes it NOT opaque (returns `false`):
2114/// - Entity name contains "glas" (case-insensitive)
2115/// - Resolved element color has any transparency (alpha < 1.0)
2116/// - Any sub-geometry style has alpha < 1.0 or a material/style name containing "glas"
2117fn is_opaque_opening(
2118    job: &EntityJob,
2119    styles: &FxHashMap<u32, GeometryStyleInfo>,
2120    decoder: &mut EntityDecoder,
2121) -> bool {
2122    let Ok(entity) = decoder.decode_at(job.start, job.end) else {
2123        return true;
2124    };
2125
2126    // 1. Entity name contains "glas" → glazed.
2127    if normalize_optional_string(entity.get_string(2))
2128        .as_deref()
2129        .map(|n| n.to_lowercase().contains("glas"))
2130        .unwrap_or(false)
2131    {
2132        return false;
2133    }
2134
2135    // 2. Resolved element color has any transparency → glazed.
2136    //    Covers IfcWindow entities using their default colour ([0.6, 0.8, 1.0, 0.4])
2137    //    and any entity whose explicit surface style resolved to a transparent colour.
2138    if job.element_color[3] < 1.0 {
2139        return false;
2140    }
2141
2142    let Some(product_shape_id) = entity.get_ref(6) else {
2143        return true; // No shape info — treat as opaque
2144    };
2145
2146    let Ok(product_shape) = decoder.decode_by_id(product_shape_id) else {
2147        return true;
2148    };
2149
2150    let Some(repr_ids) = get_refs_from_list(&product_shape, 2) else {
2151        return true;
2152    };
2153
2154    for repr_id in repr_ids {
2155        let Ok(repr) = decoder.decode_by_id(repr_id) else {
2156            continue;
2157        };
2158        let Some(item_ids) = get_refs_from_list(&repr, 3) else {
2159            continue;
2160        };
2161        for item_id in item_ids {
2162            // Direct style on item
2163            if let Some(style) = styles.get(&item_id) {
2164                if has_glass_style(style) {
2165                    return false;
2166                }
2167            }
2168
2169            // Mapped items: IfcMappedItem → IfcRepresentationMap → IfcRepresentation → items
2170            if let Ok(item) = decoder.decode_by_id(item_id) {
2171                if item.ifc_type == IfcType::IfcMappedItem {
2172                    if let Some(source_id) = item.get_ref(0) {
2173                        if let Ok(source) = decoder.decode_by_id(source_id) {
2174                            if let Some(mapped_repr_id) = source.get_ref(1) {
2175                                if let Ok(mapped_repr) = decoder.decode_by_id(mapped_repr_id) {
2176                                    if let Some(mapped_items) = get_refs_from_list(&mapped_repr, 3)
2177                                    {
2178                                        for mapped_item_id in mapped_items {
2179                                            if let Some(style) = styles.get(&mapped_item_id) {
2180                                                if has_glass_style(style) {
2181                                                    return false;
2182                                                }
2183                                            }
2184                                        }
2185                                    }
2186                                }
2187                            }
2188                        }
2189                    }
2190                }
2191            }
2192        }
2193    }
2194
2195    true // No glass found → opaque
2196}
2197
2198/// Returns `true` when a geometry style indicates a glass/transparent material.
2199///
2200/// Triggers on:
2201/// - Any transparency at all (alpha < 1.0)
2202/// - Style/material name containing "glas" (case-insensitive)
2203fn has_glass_style(style: &GeometryStyleInfo) -> bool {
2204    if style.color[3] < 1.0 {
2205        return true;
2206    }
2207    if style
2208        .material_name
2209        .as_deref()
2210        .map(|n| n.to_lowercase().contains("glas"))
2211        .unwrap_or(false)
2212    {
2213        return true;
2214    }
2215    false
2216}
2217
2218
2219// Default IFC-type colors now come from the single canonical table in
2220// `crate::style::default_color_for_type` (issue #913). Do not reintroduce a
2221// per-module table here — see `tests/styling_parity.rs` for the guard.
2222
2223#[cfg(test)]
2224mod tests {
2225    use super::*;
2226
2227    fn map(pairs: &[(u32, &[u32])]) -> FxHashMap<u32, Vec<u32>> {
2228        pairs.iter().map(|(k, v)| (*k, v.to_vec())).collect()
2229    }
2230
2231    // `find_geometry_item_color_follows_mapped_item` lives in
2232    // `crate::element::tests`, next to the resolver it pins.
2233}