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::{calculate_normals, 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}
176
177/// Job for processing a single entity.
178struct EntityJob {
179    id: u32,
180    ifc_type: IfcType,
181    start: usize,
182    end: usize,
183    product_definition_shape_id: Option<u32>,
184    element_color: [f32; 4],
185    global_id: Option<String>,
186    name: Option<String>,
187    presentation_layer: Option<String>,
188    space_zone_properties: Option<BTreeMap<String, String>>,
189    /// Set for synthetic type-only-geometry jobs (#957): the `IfcRepresentationMap`
190    /// id to render directly (baking its MappingOrigin) instead of walking the
191    /// element's `IfcProductDefinitionShape`. `None` for ordinary product jobs.
192    representation_map_id: Option<u32>,
193}
194
195fn populate_entity_job_metadata(
196    job: &mut EntityJob,
197    geometry_style_index: &FxHashMap<u32, GeometryStyleInfo>,
198    element_material_color: &FxHashMap<u32, [f32; 4]>,
199    layer_by_assigned_representation: &FxHashMap<u32, String>,
200    color_cache_by_product_definition_shape: &mut FxHashMap<u32, Option<[f32; 4]>>,
201    layer_cache_by_product_definition_shape: &mut FxHashMap<u32, Option<String>>,
202    layer_cache_by_representation: &mut FxHashMap<u32, Option<String>>,
203    decoder: &mut EntityDecoder,
204    include_presentation_layers: bool,
205) {
206    if job.global_id.is_some() || job.name.is_some() || job.product_definition_shape_id.is_some() {
207        return;
208    }
209
210    let Ok(entity) = decoder.decode_at(job.start, job.end) else {
211        return;
212    };
213
214    job.global_id = normalize_optional_string(entity.get_string(0));
215    job.name = normalize_optional_string(entity.get_string(2));
216    job.product_definition_shape_id = entity.get_ref(6);
217
218    let Some(product_definition_shape_id) = job.product_definition_shape_id else {
219        return;
220    };
221
222    let resolved_color = color_cache_by_product_definition_shape
223        .entry(product_definition_shape_id)
224        .or_insert_with(|| {
225            resolve_element_color_for_product_definition_shape(
226                product_definition_shape_id,
227                geometry_style_index,
228                decoder,
229            )
230        });
231    if let Some(color) = resolved_color {
232        job.element_color = *color;
233    } else if let Some(color) = element_material_color.get(&job.id) {
234        job.element_color = *color;
235    }
236
237    if include_presentation_layers {
238        let resolved_layer = layer_cache_by_product_definition_shape
239            .entry(product_definition_shape_id)
240            .or_insert_with(|| {
241                resolve_presentation_layer_for_product_definition_shape(
242                    product_definition_shape_id,
243                    layer_by_assigned_representation,
244                    layer_cache_by_representation,
245                    decoder,
246                )
247            });
248        job.presentation_layer = resolved_layer.clone();
249    }
250}
251
252#[derive(Debug, Clone)]
253struct GeometryStyleInfo {
254    /// Apparent colour for rendering: IfcSurfaceStyleRendering.DiffuseColour
255    /// when authored, otherwise the SurfaceColour. Matches what most IFC
256    /// viewers display.
257    color: [f32; 4],
258    /// SurfaceColour, populated only when the file authored a distinct
259    /// DiffuseColour. Read by the WASM bridge's parallel extractor so the
260    /// GLB exporter can offer "Shading" as a colour source; the
261    /// processing-crate `MeshData` doesn't propagate it (server pipeline
262    /// has no GLB consumer yet), so the field is intentionally read-only
263    /// here.
264    #[allow(dead_code)]
265    shading_color: Option<[f32; 4]>,
266    material_name: Option<String>,
267}
268
269#[derive(Debug, Clone)]
270struct PropertySetDefinition {
271    name: Option<String>,
272    property_ids: Vec<u32>,
273}
274
275#[derive(Debug, Clone)]
276struct RelDefinesByPropertiesLink {
277    property_set_id: u32,
278    related_object_ids: Vec<u32>,
279}
280
281/// Extract entity references from a list attribute.
282fn get_refs_from_list(entity: &DecodedEntity, index: usize) -> Option<Vec<u32>> {
283    let list = entity.get_list(index)?;
284    let refs: Vec<u32> = list.iter().filter_map(|v| v.as_entity_ref()).collect();
285    if refs.is_empty() {
286        None
287    } else {
288        Some(refs)
289    }
290}
291
292fn normalize_optional_string(raw: Option<&str>) -> Option<String> {
293    let value = raw?.trim();
294    if value.is_empty() || value == "$" {
295        return None;
296    }
297    Some(value.to_string())
298}
299
300fn normalize_ifc_property_name(raw: Option<&str>) -> Option<String> {
301    let name = normalize_optional_string(raw)?;
302    let cleaned = name.trim();
303    if cleaned.is_empty() {
304        return None;
305    }
306
307    Some(cleaned.to_string())
308}
309
310fn is_space_or_zone_type(ifc_type: &IfcType) -> bool {
311    matches!(
312        ifc_type,
313        IfcType::IfcSpace
314            | IfcType::IfcSpaceType
315            | IfcType::IfcZone
316            | IfcType::IfcSpatialZone
317            | IfcType::IfcSpatialZoneType
318    )
319}
320
321fn collect_property_set_definition(property_set: &DecodedEntity) -> Option<PropertySetDefinition> {
322    let property_ids = property_set
323        .get_list(4)
324        .or_else(|| property_set.get_list(2))
325        .map(|items| {
326            items
327                .iter()
328                .filter_map(AttributeValue::as_entity_ref)
329                .collect::<Vec<u32>>()
330        })
331        .unwrap_or_default();
332
333    if property_ids.is_empty() {
334        return None;
335    }
336
337    let name = normalize_optional_string(property_set.get_string(2))
338        .or_else(|| normalize_optional_string(property_set.get_string(0)));
339
340    Some(PropertySetDefinition { name, property_ids })
341}
342
343fn collect_rel_defines_by_properties_link(
344    rel_defines: &DecodedEntity,
345) -> Option<RelDefinesByPropertiesLink> {
346    let property_set_id = rel_defines.get_ref(5).or_else(|| rel_defines.get_ref(3))?;
347    let related_object_ids = rel_defines
348        .get_list(4)
349        .or_else(|| rel_defines.get_list(2))
350        .map(|items| {
351            items
352                .iter()
353                .filter_map(AttributeValue::as_entity_ref)
354                .collect::<Vec<u32>>()
355        })
356        .unwrap_or_default();
357
358    if related_object_ids.is_empty() {
359        return None;
360    }
361
362    Some(RelDefinesByPropertiesLink {
363        property_set_id,
364        related_object_ids,
365    })
366}
367
368fn attribute_list_to_string(values: &[AttributeValue]) -> Option<String> {
369    let tokens = values
370        .iter()
371        .filter_map(attribute_value_to_string)
372        .collect::<Vec<String>>();
373
374    if tokens.is_empty() {
375        return None;
376    }
377
378    Some(tokens.join("; "))
379}
380
381fn attribute_value_to_string(value: &AttributeValue) -> Option<String> {
382    match value {
383        AttributeValue::Null | AttributeValue::Derived => None,
384        AttributeValue::String(text) => normalize_optional_string(Some(text)),
385        AttributeValue::Enum(text) => normalize_optional_string(Some(text.trim_matches('.'))),
386        AttributeValue::Integer(number) => Some(number.to_string()),
387        AttributeValue::Float(number) => Some(number.to_string()),
388        AttributeValue::EntityRef(id) => Some(format!("#{id}")),
389        AttributeValue::List(values) => {
390            if values.len() >= 2 && matches!(values.first(), Some(AttributeValue::String(_))) {
391                return values.get(1).and_then(attribute_value_to_string);
392            }
393
394            attribute_list_to_string(values)
395        }
396    }
397}
398
399fn extract_property_name_and_value(property_entity: &DecodedEntity) -> Option<(String, String)> {
400    let property_name = normalize_ifc_property_name(property_entity.get_string(0))
401        .or_else(|| normalize_ifc_property_name(property_entity.get_string(2)))?;
402
403    let property_type = property_entity.ifc_type.name();
404    let value = match property_type {
405        "IfcPropertySingleValue" => property_entity.get(2).and_then(attribute_value_to_string),
406        "IfcPropertyEnumeratedValue" => property_entity.get(2).and_then(attribute_value_to_string),
407        "IfcPropertyListValue" => property_entity.get(2).and_then(attribute_value_to_string),
408        "IfcPropertyBoundedValue" => {
409            let lower = property_entity.get(2).and_then(attribute_value_to_string);
410            let upper = property_entity.get(3).and_then(attribute_value_to_string);
411            match (lower, upper) {
412                (Some(lo), Some(hi)) => Some(format!("{lo}..{hi}")),
413                (Some(lo), None) => Some(lo),
414                (None, Some(hi)) => Some(hi),
415                (None, None) => None,
416            }
417        }
418        "IfcPropertyReferenceValue" => property_entity.get(2).and_then(attribute_value_to_string),
419        _ => None,
420    }?;
421
422    let normalized_value = value.trim();
423    if normalized_value.is_empty() || normalized_value == "$" {
424        return None;
425    }
426
427    Some((property_name, normalized_value.to_string()))
428}
429
430fn add_space_zone_property(
431    attributes: &mut BTreeMap<String, String>,
432    property_set_name: Option<&str>,
433    property_name: &str,
434    property_value: &str,
435) {
436    if property_name.trim().is_empty() || property_value.trim().is_empty() {
437        return;
438    }
439
440    attributes
441        .entry(property_name.to_string())
442        .or_insert_with(|| property_value.to_string());
443
444    if let Some(pset_name) = normalize_optional_string(property_set_name) {
445        let scoped_name = format!("{}.{}", pset_name, property_name);
446        attributes
447            .entry(scoped_name)
448            .or_insert_with(|| property_value.to_string());
449    }
450}
451
452fn build_space_zone_properties_by_entity(
453    entity_jobs: &[EntityJob],
454    property_values_by_id: &FxHashMap<u32, (String, String)>,
455    property_sets_by_id: &FxHashMap<u32, PropertySetDefinition>,
456    rel_defines_by_properties: &[RelDefinesByPropertiesLink],
457) -> FxHashMap<u32, BTreeMap<String, String>> {
458    let mut target_space_zone_ids = FxHashMap::default();
459    for job in entity_jobs
460        .iter()
461        .filter(|job| is_space_or_zone_type(&job.ifc_type))
462    {
463        target_space_zone_ids.insert(job.id, ());
464    }
465
466    if target_space_zone_ids.is_empty() {
467        return FxHashMap::default();
468    }
469
470    let mut properties_by_entity: FxHashMap<u32, BTreeMap<String, String>> = FxHashMap::default();
471
472    for link in rel_defines_by_properties {
473        let Some(property_set) = property_sets_by_id.get(&link.property_set_id) else {
474            continue;
475        };
476
477        for related_id in &link.related_object_ids {
478            if !target_space_zone_ids.contains_key(related_id) {
479                continue;
480            }
481
482            let attributes = properties_by_entity.entry(*related_id).or_default();
483            for property_id in &property_set.property_ids {
484                let Some((property_name, property_value)) = property_values_by_id.get(property_id)
485                else {
486                    continue;
487                };
488
489                add_space_zone_property(
490                    attributes,
491                    property_set.name.as_deref(),
492                    property_name,
493                    property_value,
494                );
495            }
496        }
497    }
498
499    properties_by_entity
500}
501
502fn assign_space_zone_properties(
503    entity_jobs: &mut [EntityJob],
504    property_values_by_id: &FxHashMap<u32, (String, String)>,
505    property_sets_by_id: &FxHashMap<u32, PropertySetDefinition>,
506    rel_defines_by_properties: &[RelDefinesByPropertiesLink],
507) {
508    let properties_by_entity = build_space_zone_properties_by_entity(
509        entity_jobs,
510        property_values_by_id,
511        property_sets_by_id,
512        rel_defines_by_properties,
513    );
514
515    if properties_by_entity.is_empty() {
516        return;
517    }
518
519    for job in entity_jobs.iter_mut() {
520        if let Some(properties) = properties_by_entity.get(&job.id) {
521            job.space_zone_properties = Some(properties.clone());
522        }
523    }
524}
525
526#[derive(Clone)]
527struct QuickSpatialNodeEntry {
528    express_id: u32,
529    type_name: String,
530    name: String,
531    elevation: Option<f64>,
532    children: Vec<u32>,
533    elements: Vec<u32>,
534    parent: Option<u32>,
535}
536
537/// Case-insensitive spatial-type check that avoids to_ascii_uppercase() allocation.
538#[inline]
539fn is_quick_spatial_type_ci(type_name: &str) -> bool {
540    type_name.eq_ignore_ascii_case("IFCPROJECT")
541        || type_name.eq_ignore_ascii_case("IFCSITE")
542        || type_name.eq_ignore_ascii_case("IFCBUILDING")
543        || type_name.eq_ignore_ascii_case("IFCBUILDINGSTOREY")
544        || type_name.eq_ignore_ascii_case("IFCSPACE")
545        || type_name.eq_ignore_ascii_case("IFCFACILITY")
546        || type_name.eq_ignore_ascii_case("IFCFACILITYPART")
547        || type_name.eq_ignore_ascii_case("IFCBRIDGE")
548        || type_name.eq_ignore_ascii_case("IFCBRIDGEPART")
549        || type_name.eq_ignore_ascii_case("IFCROAD")
550        || type_name.eq_ignore_ascii_case("IFCROADPART")
551        || type_name.eq_ignore_ascii_case("IFCRAILWAY")
552        || type_name.eq_ignore_ascii_case("IFCRAILWAYPART")
553}
554
555fn parse_step_arguments(entity_bytes: &[u8]) -> Vec<&[u8]> {
556    let Some(open_idx) = entity_bytes.iter().position(|byte| *byte == b'(') else {
557        return Vec::new();
558    };
559    let Some(close_idx) = entity_bytes.iter().rposition(|byte| *byte == b')') else {
560        return Vec::new();
561    };
562    if close_idx <= open_idx {
563        return Vec::new();
564    }
565    let args = &entity_bytes[open_idx + 1..close_idx];
566    let mut parts = Vec::new();
567    let mut in_string = false;
568    let mut depth = 0i32;
569    let mut start = 0usize;
570    let bytes = args;
571    let mut index = 0usize;
572    while index < bytes.len() {
573        match bytes[index] {
574            b'\'' => {
575                if in_string && index + 1 < bytes.len() && bytes[index + 1] == b'\'' {
576                    index += 1;
577                } else {
578                    in_string = !in_string;
579                }
580            }
581            b'(' if !in_string => depth += 1,
582            b')' if !in_string => depth -= 1,
583            b',' if !in_string && depth == 0 => {
584                parts.push(args[start..index].trim_ascii());
585                start = index + 1;
586            }
587            _ => {}
588        }
589        index += 1;
590    }
591    if start <= args.len() {
592        parts.push(args[start..].trim_ascii());
593    }
594    parts
595}
596
597fn parse_step_string(token: &[u8]) -> Option<String> {
598    let trimmed = token.trim_ascii();
599    if trimmed.len() < 2 || trimmed[0] != b'\'' || trimmed[trimmed.len() - 1] != b'\'' {
600        return None;
601    }
602    Some(String::from_utf8_lossy(&trimmed[1..trimmed.len() - 1]).replace("''", "'"))
603}
604
605fn parse_step_ref(token: &[u8]) -> Option<u32> {
606    std::str::from_utf8(token.trim_ascii().strip_prefix(b"#")?)
607        .ok()?
608        .parse()
609        .ok()
610}
611
612fn parse_step_ref_list(token: &[u8]) -> Vec<u32> {
613    let trimmed = token.trim_ascii();
614    let inner = trimmed
615        .strip_prefix(b"(")
616        .and_then(|value| value.strip_suffix(b")"))
617        .unwrap_or(trimmed);
618    inner.split(|byte| *byte == b',').filter_map(parse_step_ref).collect()
619}
620
621fn extract_name_from_args(args: &[&[u8]], fallback: &str) -> String {
622    args.get(2)
623        .and_then(|token| parse_step_string(token))
624        .filter(|value| !value.trim().is_empty())
625        .unwrap_or_else(|| fallback.to_string())
626}
627
628fn extract_storey_elevation_from_args(args: &[&[u8]]) -> Option<f64> {
629    for index in [9usize, 8usize] {
630        if let Some(value) = args
631            .get(index)
632            .and_then(|token| std::str::from_utf8(token.trim_ascii()).ok())
633            .and_then(|token| token.parse::<f64>().ok())
634        {
635            return Some(value);
636        }
637    }
638    args.iter()
639        .filter_map(|token| std::str::from_utf8(token.trim_ascii()).ok())
640        .filter_map(|token| token.parse::<f64>().ok())
641        .find(|value| value.abs() < 10_000.0)
642}
643
644fn build_quick_spatial_tree_node(
645    express_id: u32,
646    nodes: &HashMap<u32, QuickSpatialNodeEntry>,
647    element_summaries: &HashMap<u32, QuickMetadataEntitySummary>,
648) -> Result<QuickMetadataSpatialNode, String> {
649    let node = nodes
650        .get(&express_id)
651        .ok_or_else(|| format!("Quick spatial node #{express_id} not found"))?;
652    let mut children = Vec::with_capacity(node.children.len());
653    for child_id in &node.children {
654        children.push(build_quick_spatial_tree_node(
655            *child_id,
656            nodes,
657            element_summaries,
658        )?);
659    }
660    let elements = node
661        .elements
662        .iter()
663        .map(|element_id| {
664            element_summaries
665                .get(element_id)
666                .cloned()
667                .unwrap_or(QuickMetadataEntitySummary {
668                express_id: *element_id,
669                type_name: "IfcProduct".to_string(),
670                name: format!("IfcProduct #{}", element_id),
671                global_id: None,
672                kind: "element".to_string(),
673                has_children: false,
674                element_count: None,
675                elevation: None,
676            })
677        })
678        .collect();
679    Ok(QuickMetadataSpatialNode {
680        summary: QuickMetadataEntitySummary {
681            express_id: node.express_id,
682            type_name: node.type_name.clone(),
683            name: node.name.clone(),
684            global_id: None,
685            kind: "spatial".to_string(),
686            has_children: !node.children.is_empty() || !node.elements.is_empty(),
687            element_count: Some(node.elements.len()),
688            elevation: node.elevation,
689        },
690        children,
691        elements,
692    })
693}
694
695fn geometry_priority_score(ifc_type: &IfcType) -> u8 {
696    match ifc_type {
697        IfcType::IfcWall | IfcType::IfcWallStandardCase => 100,
698        IfcType::IfcSlab => 95,
699        IfcType::IfcColumn => 90,
700        IfcType::IfcBeam => 85,
701        IfcType::IfcRoof => 80,
702        IfcType::IfcStair | IfcType::IfcStairFlight => 75,
703        IfcType::IfcCurtainWall => 70,
704        IfcType::IfcFooting | IfcType::IfcPile => 65,
705        IfcType::IfcDoor | IfcType::IfcWindow => 30,
706        IfcType::IfcFurnishingElement => 10,
707        _ => 50,
708    }
709}
710
711/// Process IFC content with parallel geometry extraction (default opening filter).
712pub fn process_geometry<T>(content: &T) -> ProcessingResult
713where
714    T: AsRef<[u8]> + ?Sized,
715{
716    process_geometry_filtered(content.as_ref(), OpeningFilterMode::Default)
717}
718
719/// Process IFC content with parallel geometry extraction and emit batches as they complete.
720pub fn process_geometry_streaming(
721    content: &[u8],
722    batch_size: usize,
723    on_batch: impl FnMut(&[MeshData], usize, usize),
724) -> ProcessingResult {
725    process_geometry_streaming_with_options(
726        content,
727        StreamingOptions {
728            initial_batch_size: batch_size,
729            throughput_batch_size: batch_size,
730            ..StreamingOptions::default()
731        },
732        on_batch,
733        |_| {},
734    )
735}
736
737/// Process IFC content with parallel geometry extraction and configurable streaming behavior.
738pub fn process_geometry_streaming_with_options(
739    content: &[u8],
740    options: StreamingOptions,
741    on_batch: impl FnMut(&[MeshData], usize, usize),
742    on_color_update: impl FnMut(&[(u32, [f32; 4])]),
743) -> ProcessingResult {
744    process_geometry_streaming_with_options_and_bootstrap(
745        content,
746        options,
747        on_batch,
748        on_color_update,
749        |_| {},
750    )
751}
752
753/// Process IFC content with parallel geometry extraction and emit a quick metadata bootstrap
754/// once the scan phase completes.
755pub fn process_geometry_streaming_with_options_and_bootstrap(
756    content: &[u8],
757    options: StreamingOptions,
758    on_batch: impl FnMut(&[MeshData], usize, usize),
759    on_color_update: impl FnMut(&[(u32, [f32; 4])]),
760    on_quick_metadata_bootstrap: impl FnMut(&QuickMetadataBootstrap),
761) -> ProcessingResult {
762    process_geometry_streaming_filtered_with_options(
763        content,
764        OpeningFilterMode::Default,
765        options,
766        on_batch,
767        on_color_update,
768        on_quick_metadata_bootstrap,
769    )
770}
771
772/// Process IFC content with parallel geometry extraction and a configurable opening filter.
773pub fn process_geometry_filtered<T>(
774    content: &T,
775    opening_filter: OpeningFilterMode,
776) -> ProcessingResult
777where
778    T: AsRef<[u8]> + ?Sized,
779{
780    process_geometry_filtered_with_quality(content, opening_filter, TessellationQuality::default())
781}
782
783/// Like [`process_geometry_filtered`] with a consumer-selected tessellation
784/// detail level (#976) — the server half of the quality knob the wasm path
785/// exposes via `setTessellationQuality`.
786pub fn process_geometry_filtered_with_quality<T>(
787    content: &T,
788    opening_filter: OpeningFilterMode,
789    tessellation_quality: TessellationQuality,
790) -> ProcessingResult
791where
792    T: AsRef<[u8]> + ?Sized,
793{
794    let content = content.as_ref();
795    process_geometry_streaming_filtered_with_options(
796        content,
797        opening_filter,
798        StreamingOptions {
799            initial_batch_size: usize::MAX,
800            throughput_batch_size: usize::MAX,
801            tessellation_quality,
802            ..StreamingOptions::default()
803        },
804        |_, _, _| {},
805        |_| {},
806        |_| {},
807    )
808}
809
810/// Process IFC content with parallel geometry extraction and a configurable streaming batch size.
811pub fn process_geometry_streaming_filtered(
812    content: &[u8],
813    opening_filter: OpeningFilterMode,
814    batch_size: usize,
815    on_batch: impl FnMut(&[MeshData], usize, usize),
816    on_color_update: impl FnMut(&[(u32, [f32; 4])]),
817) -> ProcessingResult {
818    process_geometry_streaming_filtered_with_options(
819        content,
820        opening_filter,
821        StreamingOptions {
822            initial_batch_size: batch_size,
823            throughput_batch_size: batch_size,
824            ..StreamingOptions::default()
825        },
826        on_batch,
827        on_color_update,
828        |_| {},
829    )
830}
831
832/// Process IFC content with parallel geometry extraction and configurable streaming behavior.
833pub fn process_geometry_streaming_filtered_with_options(
834    content: &[u8],
835    opening_filter: OpeningFilterMode,
836    options: StreamingOptions,
837    mut on_batch: impl FnMut(&[MeshData], usize, usize),
838    mut on_color_update: impl FnMut(&[(u32, [f32; 4])]),
839    mut on_quick_metadata_bootstrap: impl FnMut(&QuickMetadataBootstrap),
840) -> ProcessingResult {
841    let total_start = std::time::Instant::now();
842    let parse_start = std::time::Instant::now();
843    let entity_scan_start = std::time::Instant::now();
844
845    tracing::info!(
846        content_size = content.len(),
847        "Starting IFC geometry processing"
848    );
849
850    // Build entity index (fast SIMD-accelerated single pass)
851    let entity_index = Arc::new(build_entity_index(content));
852    let mut decoder = EntityDecoder::with_arc_index(content, entity_index.clone());
853    tracing::debug!("Built entity index");
854
855    let mut geometry_style_index: FxHashMap<u32, GeometryStyleInfo> = FxHashMap::default();
856    // IfcIndexedColourMap data, keyed by target geometry id (issue #913).
857    // Collected eagerly regardless of `defer_style_updates`. The dominant
858    // colour is merged into `geometry_style_index` (styled items win); the full
859    // per-triangle map drives sub-mesh splitting at emission (#858).
860    let mut indexed_colour_index: FxHashMap<u32, [f32; 4]> = FxHashMap::default();
861    let mut indexed_colour_full: FxHashMap<u32, crate::style::FullIndexedColourMap> =
862        FxHashMap::default();
863    // Material-chain colour inputs (issue #407): orphan IfcStyledItem colours,
864    // material → styled representations, and element → material associations.
865    // Joined into `element_material_color` after the scan.
866    let mut orphan_styled_items: FxHashMap<u32, [f32; 4]> = FxHashMap::default();
867    let mut material_def_reprs: FxHashMap<u32, Vec<u32>> = FxHashMap::default();
868    let mut element_to_material: FxHashMap<u32, u32> = FxHashMap::default();
869    let mut presentation_layer_by_assigned_id: FxHashMap<u32, String> = FxHashMap::default();
870    let mut property_values_by_id: FxHashMap<u32, (String, String)> = FxHashMap::default();
871    let mut property_sets_by_id: FxHashMap<u32, PropertySetDefinition> = FxHashMap::default();
872    let mut rel_defines_by_properties: Vec<RelDefinesByPropertiesLink> = Vec::new();
873
874    // Collect geometry entities and build void index
875    let mut scanner = EntityScanner::new(content);
876    let mut void_index: FxHashMap<u32, Vec<u32>> = FxHashMap::default();
877    let mut filling_by_opening: FxHashMap<u32, u32> = FxHashMap::default();
878    // Parent → aggregated children, used to propagate void cuts from a
879    // host with no body (e.g. IFC4 IfcWallElementedCase) to the parts
880    // that actually carry the geometry. See `propagate_voids_to_parts`
881    // below.
882    let mut aggregate_children: FxHashMap<u32, Vec<u32>> = FxHashMap::default();
883    let mut entity_jobs: Vec<EntityJob> = Vec::with_capacity(2000);
884    // #957: type-product geometry (IfcXxxType + its RepresentationMaps) and the
885    // set of RepresentationMaps already instantiated by an IfcMappedItem. After
886    // the scan, RepresentationMaps NOT in the referenced set are rendered as
887    // orphan type geometry (buildingSMART annex-E showcase files).
888    let mut type_product_geometry: Vec<(u32, usize, usize, IfcType, Vec<u32>)> = Vec::new();
889    let mut referenced_representation_maps: FxHashSet<u32> = FxHashSet::default();
890    // #957 follow-up: type ids that an IfcRelDefinesByType instantiates (the type
891    // has at least one occurrence). Such a type's geometry is already drawn through
892    // its occurrences — directly or via an IfcMappedItem — so it must NOT also be
893    // rendered as orphan type-only geometry. Real-world exporters (e.g. ArchiCAD
894    // AC20) attach a RepresentationMap to nearly every door/window/furniture type
895    // while the occurrence carries its own body, leaving the type map referenced by
896    // no IfcMappedItem; without this gate every such type double-renders at its
897    // MappingOrigin (duplicate boxes at the wrong position).
898    let mut instantiated_type_ids: FxHashSet<u32> = FxHashSet::default();
899    let quick_metadata_enabled = options.emit_quick_metadata_bootstrap;
900    let mut quick_spatial_nodes =
901        quick_metadata_enabled.then(HashMap::<u32, QuickSpatialNodeEntry>::new);
902    let mut quick_aggregate_links = if quick_metadata_enabled {
903        Vec::<(u32, Vec<u32>)>::new()
904    } else {
905        Vec::new()
906    };
907    let mut quick_containment_links = if quick_metadata_enabled {
908        Vec::<(u32, Vec<u32>)>::new()
909    } else {
910        Vec::new()
911    };
912    let mut quick_element_summaries = if quick_metadata_enabled {
913        HashMap::<u32, QuickMetadataEntitySummary>::new()
914    } else {
915        HashMap::new()
916    };
917    let mut schema_version = "IFC2X3".to_string();
918    let mut total_entities = 0usize;
919    let mut site_entity_pos: Option<(usize, usize)> = None;
920    let mut building_entity_pos: Option<(usize, usize)> = None;
921
922    let defer_style_updates = options.fast_first_batch
923        && opening_filter == OpeningFilterMode::Default
924        && !options.include_presentation_layers;
925    let mut deferred_styled_item_positions: Vec<(usize, usize)> = Vec::new();
926
927    while let Some((id, type_name, start, end)) = scanner.next_entity() {
928        total_entities += 1;
929        if let Some(spatial_nodes) = quick_spatial_nodes.as_mut() {
930            // Case-insensitive check without allocating a new uppercase string.
931            if is_quick_spatial_type_ci(type_name) {
932                let args = parse_step_arguments(&content[start..end]);
933                let fallback = format!("{type_name} #{id}");
934                spatial_nodes.entry(id).or_insert(QuickSpatialNodeEntry {
935                    express_id: id,
936                    type_name: type_name.to_string(),
937                    name: extract_name_from_args(&args, &fallback),
938                    elevation: if type_name.eq_ignore_ascii_case("IfcBuildingStorey") {
939                        extract_storey_elevation_from_args(&args)
940                    } else {
941                        None
942                    },
943                    children: Vec::new(),
944                    elements: Vec::new(),
945                    parent: None,
946                });
947            } else if type_name.eq_ignore_ascii_case("IFCRELAGGREGATES") {
948                let args = parse_step_arguments(&content[start..end]);
949                if let Some(parent_id) = args.get(4).and_then(|token| parse_step_ref(token)) {
950                    quick_aggregate_links.push((
951                        parent_id,
952                        args.get(5)
953                            .map(|token| parse_step_ref_list(token))
954                            .unwrap_or_default(),
955                    ));
956                }
957            } else if type_name.eq_ignore_ascii_case("IFCRELCONTAINEDINSPATIALSTRUCTURE")
958                || type_name.eq_ignore_ascii_case("IFCRELREFERENCEDINSPATIALSTRUCTURE")
959            {
960                let args = parse_step_arguments(&content[start..end]);
961                if let Some(parent_id) = args.get(5).and_then(|token| parse_step_ref(token)) {
962                    quick_containment_links.push((
963                        parent_id,
964                        args.get(4)
965                            .map(|token| parse_step_ref_list(token))
966                            .unwrap_or_default(),
967                    ));
968                }
969            }
970        }
971
972        if type_name == "IFCINDEXEDCOLOURMAP" {
973            // Collect authored tessellation colours so the backend matches the
974            // browser on CATIA-style exports that have no IFCSTYLEDITEM (#663,
975            // #858).
976            if let Ok(icm) = decoder.decode_at(start, end) {
977                if let Some(full) =
978                    crate::style::resolve_indexed_colour_map_full(&icm, &mut decoder)
979                {
980                    let geometry_id = full.geometry_id;
981                    indexed_colour_index
982                        .entry(geometry_id)
983                        .or_insert(full.dominant().to_array());
984                    indexed_colour_full.entry(geometry_id).or_insert(full);
985                }
986            }
987            continue;
988        }
989
990        if type_name == "IFCSTYLEDITEM" {
991            if defer_style_updates {
992                // Only *geometry-attached* styled items are deferred (rebuilt
993                // from saved byte positions after the first batch). Orphan
994                // styled items (null Item) are material appearances (#407) that
995                // feed the material chain — resolved once, up front, before the
996                // deferred rebuild — so they must be collected now or
997                // material-only-styled elements render as the default gray even
998                // after the deferred pass (#913 §2c). The classifying decode is
999                // the cost of telling the two apart.
1000                if let Ok(styled_item) = decoder.decode_at(start, end) {
1001                    if styled_item.get_ref(0).is_none() {
1002                        if let Some(info) =
1003                            extract_style_info_from_styled_item(&styled_item, &mut decoder)
1004                        {
1005                            orphan_styled_items.insert(id, info.color);
1006                        }
1007                        continue;
1008                    }
1009                }
1010                // Geometry-attached (or undecodable) → defer the rebuild.
1011                deferred_styled_item_positions.push((start, end));
1012                continue;
1013            }
1014            if let Ok(styled_item) = decoder.decode_at(start, end) {
1015                if styled_item.get_ref(0).is_none() {
1016                    // Orphan styled item (null Item) = a material appearance
1017                    // (#407). Collect its colour for the material chain.
1018                    if let Some(info) =
1019                        extract_style_info_from_styled_item(&styled_item, &mut decoder)
1020                    {
1021                        orphan_styled_items.insert(id, info.color);
1022                    }
1023                } else {
1024                    collect_geometry_style_info(
1025                        &mut geometry_style_index,
1026                        &styled_item,
1027                        &mut decoder,
1028                    );
1029                }
1030            }
1031            continue;
1032        } else if type_name == "IFCMATERIALDEFINITIONREPRESENTATION" {
1033            // RepresentedMaterial (attr 3) → Representations (attr 2).
1034            if let Ok(entity) = decoder.decode_at(start, end) {
1035                if let Some(material_id) = entity.get_ref(3) {
1036                    if let Some(reprs) = get_refs_from_list(&entity, 2) {
1037                        material_def_reprs
1038                            .entry(material_id)
1039                            .or_default()
1040                            .extend(reprs);
1041                    }
1042                }
1043            }
1044            continue;
1045        } else if type_name == "IFCRELASSOCIATESMATERIAL" {
1046            // RelatingMaterial (attr 5) ← RelatedObjects (attr 4).
1047            if let Ok(entity) = decoder.decode_at(start, end) {
1048                if let Some(material_select_id) = entity.get_ref(5) {
1049                    if let Some(related) = get_refs_from_list(&entity, 4) {
1050                        for element_id in related {
1051                            element_to_material.insert(element_id, material_select_id);
1052                        }
1053                    }
1054                }
1055            }
1056            continue;
1057        } else if type_name == "IFCPRESENTATIONLAYERASSIGNMENT" {
1058            if !options.include_presentation_layers {
1059                continue;
1060            }
1061            if let Ok(layer_assignment) = decoder.decode_at(start, end) {
1062                collect_presentation_layer_assignments(
1063                    &mut presentation_layer_by_assigned_id,
1064                    &layer_assignment,
1065                );
1066            }
1067            continue;
1068        } else if type_name == "IFCPROPERTYSET" {
1069            if !options.include_properties {
1070                continue;
1071            }
1072            if let Ok(property_set) = decoder.decode_at(start, end) {
1073                if let Some(definition) = collect_property_set_definition(&property_set) {
1074                    property_sets_by_id.insert(id, definition);
1075                }
1076            }
1077            continue;
1078        } else if type_name == "IFCRELDEFINESBYPROPERTIES" {
1079            if !options.include_properties {
1080                continue;
1081            }
1082            if let Ok(rel_defines) = decoder.decode_at(start, end) {
1083                if let Some(link) = collect_rel_defines_by_properties_link(&rel_defines) {
1084                    rel_defines_by_properties.push(link);
1085                }
1086            }
1087            continue;
1088        } else if type_name.starts_with("IFCPROPERTY") {
1089            if !options.include_properties {
1090                continue;
1091            }
1092            if let Ok(property_entity) = decoder.decode_at(start, end) {
1093                if let Some((name, value)) = extract_property_name_and_value(&property_entity) {
1094                    property_values_by_id.insert(id, (name, value));
1095                }
1096            }
1097            continue;
1098        } else if type_name == "IFCRELVOIDSELEMENT" {
1099            if let Ok(entity) = decoder.decode_at(start, end) {
1100                if let (Some(host), Some(opening)) = (entity.get_ref(4), entity.get_ref(5)) {
1101                    void_index.entry(host).or_default().push(opening);
1102                }
1103            }
1104        } else if type_name == "IFCRELFILLSELEMENT" {
1105            if let Ok(entity) = decoder.decode_at(start, end) {
1106                // attr 4 = RelatingOpeningElement, attr 5 = RelatedBuildingElement (window/door)
1107                if let (Some(opening_id), Some(filling_id)) = (entity.get_ref(4), entity.get_ref(5))
1108                {
1109                    filling_by_opening.insert(opening_id, filling_id);
1110                }
1111            }
1112        } else if type_name == "IFCRELAGGREGATES" {
1113            // Independent of quick-metadata mode: keep a parent → children
1114            // index so we can push voids down to aggregated parts when the
1115            // host element has no body of its own (IfcWallElementedCase).
1116            let args = parse_step_arguments(&content[start..end]);
1117            if let Some(parent_id) = args.get(4).and_then(|token| parse_step_ref(token)) {
1118                let kids = args
1119                    .get(5)
1120                    .map(|token| parse_step_ref_list(token))
1121                    .unwrap_or_default();
1122                if !kids.is_empty() {
1123                    aggregate_children
1124                        .entry(parent_id)
1125                        .or_default()
1126                        .extend(kids);
1127                }
1128            }
1129        } else if type_name == "IFCSITE" && site_entity_pos.is_none() {
1130            site_entity_pos = Some((start, end));
1131        } else if type_name == "IFCBUILDING" && building_entity_pos.is_none() {
1132            building_entity_pos = Some((start, end));
1133        }
1134
1135        if ifc_lite_core::has_geometry_by_name(type_name) {
1136            let ifc_type = IfcType::from_str(type_name);
1137            if quick_metadata_enabled {
1138                quick_element_summaries.insert(
1139                    id,
1140                    QuickMetadataEntitySummary {
1141                        express_id: id,
1142                        type_name: type_name.to_string(),
1143                        name: format!("{type_name} #{id}"),
1144                        global_id: None,
1145                        kind: "element".to_string(),
1146                        has_children: false,
1147                        element_count: None,
1148                        elevation: None,
1149                    },
1150                );
1151            }
1152            entity_jobs.push(EntityJob {
1153                id,
1154                ifc_type: ifc_type.clone(),
1155                start,
1156                end,
1157                product_definition_shape_id: None,
1158                element_color: crate::style::default_color_for_type(ifc_type).to_array(),
1159                global_id: None,
1160                name: None,
1161                presentation_layer: None,
1162                space_zone_properties: None,
1163                representation_map_id: None,
1164            });
1165        }
1166        // #957: collect type-product geometry (IfcXxxType carrying its own
1167        // RepresentationMaps) and every IfcMappedItem's MappingSource, so after
1168        // the scan we can render the RepresentationMaps that NO occurrence
1169        // instantiates (orphan library/showcase geometry). The cheap suffix
1170        // pre-filter keeps the is_subtype_of check off the hot path for the
1171        // ~all-non-type majority of entities.
1172        else if type_name == "IFCMAPPEDITEM" {
1173            let args = parse_step_arguments(&content[start..end]);
1174            if let Some(source_id) = args.first().and_then(|token| parse_step_ref(token)) {
1175                referenced_representation_maps.insert(source_id);
1176            }
1177        } else if type_name == "IFCRELDEFINESBYTYPE" {
1178            // IfcRelDefinesByType.RelatingType is the last attribute (index 5);
1179            // record it so its type-only geometry is suppressed (it has occurrences).
1180            let args = parse_step_arguments(&content[start..end]);
1181            if let Some(type_id) = args.get(5).and_then(|token| parse_step_ref(token)) {
1182                instantiated_type_ids.insert(type_id);
1183            }
1184        } else if (type_name.ends_with("TYPE") || type_name.ends_with("STYLE"))
1185            && IfcType::from_str(type_name).is_subtype_of(IfcType::IfcTypeProduct)
1186        {
1187            let args = parse_step_arguments(&content[start..end]);
1188            // IfcTypeProduct.RepresentationMaps is attribute index 6.
1189            let rep_map_ids = args
1190                .get(6)
1191                .map(|token| parse_step_ref_list(token))
1192                .unwrap_or_default();
1193            if !rep_map_ids.is_empty() {
1194                type_product_geometry.push((
1195                    id,
1196                    start,
1197                    end,
1198                    IfcType::from_str(type_name),
1199                    rep_map_ids,
1200                ));
1201            }
1202        }
1203    }
1204
1205    // #957: synthesize render jobs for orphan type-product geometry — a
1206    // RepresentationMap on an IfcXxxType that no IfcMappedItem instantiates.
1207    // Normally-instanced typed products keep their geometry on the occurrence
1208    // (whose IfcMappedItem references the map), so those maps are in
1209    // `referenced_representation_maps` and skipped here — no double render.
1210    // buildingSMART annex-E "tessellated shape with style" files declare the
1211    // geometry only on the type, so without this they render nothing (#957).
1212    for (type_id, start, end, ifc_type, rep_map_ids) in &type_product_geometry {
1213        // A type with occurrences (IfcRelDefinesByType) already renders through
1214        // them; only genuinely orphan types (no occurrence) render their own map.
1215        if instantiated_type_ids.contains(type_id) {
1216            continue;
1217        }
1218        for &rep_map_id in rep_map_ids {
1219            if referenced_representation_maps.contains(&rep_map_id) {
1220                continue;
1221            }
1222            entity_jobs.push(EntityJob {
1223                id: *type_id,
1224                ifc_type: ifc_type.clone(),
1225                start: *start,
1226                end: *end,
1227                product_definition_shape_id: None,
1228                element_color: crate::style::default_color_for_type(*ifc_type).to_array(),
1229                global_id: None,
1230                name: None,
1231                presentation_layer: None,
1232                space_zone_properties: None,
1233                representation_map_id: Some(rep_map_id),
1234            });
1235        }
1236    }
1237
1238    // IfcWallElementedCase + friends: when an opening voids a host that
1239    // aggregates parts (drywall panels, studs, tracks) and has no body
1240    // representation of its own, the opening must propagate to every
1241    // aggregated descendant so the part meshes get the cut. Without this
1242    // propagation the cut silently no-ops (the host has nothing to clip)
1243    // and panels/studs cover what should be the window/door hole.
1244    ifc_lite_geometry::propagate_voids_via_aggregates(&mut void_index, &aggregate_children);
1245
1246    let entity_scan_time = entity_scan_start.elapsed();
1247
1248    let lookup_start = std::time::Instant::now();
1249    if options.include_properties {
1250        assign_space_zone_properties(
1251            &mut entity_jobs,
1252            &property_values_by_id,
1253            &property_sets_by_id,
1254            &rel_defines_by_properties,
1255        );
1256    }
1257    if options.fast_first_batch {
1258        entity_jobs.sort_by(|left, right| {
1259            geometry_priority_score(&right.ifc_type).cmp(&geometry_priority_score(&left.ifc_type))
1260        });
1261    }
1262    let lookup_time = lookup_start.elapsed();
1263
1264    let (skipped_entity_ids, filtered_void_index) = apply_opening_filter(
1265        &entity_jobs,
1266        &void_index,
1267        &filling_by_opening,
1268        &geometry_style_index,
1269        &mut decoder,
1270        opening_filter,
1271    );
1272
1273    // Detect schema version
1274    if content
1275        .windows(b"IFC4X3".len())
1276        .any(|window| window == b"IFC4X3")
1277    {
1278        schema_version = "IFC4X3".into();
1279    } else if content
1280        .windows(b"IFC4".len())
1281        .any(|window| window == b"IFC4")
1282    {
1283        schema_version = "IFC4".into();
1284    }
1285
1286    let geometry_entity_count = entity_jobs.len();
1287    tracing::info!(
1288        total_entities = total_entities,
1289        geometry_entities = geometry_entity_count,
1290        voids = void_index.len(),
1291        schema_version = %schema_version,
1292        "Entity scanning complete"
1293    );
1294
1295    if let Some(mut spatial_nodes) = quick_spatial_nodes.take() {
1296        for (parent_id, child_ids) in quick_aggregate_links {
1297            if !spatial_nodes.contains_key(&parent_id) {
1298                continue;
1299            }
1300            for child_id in child_ids {
1301                if !spatial_nodes.contains_key(&child_id) {
1302                    continue;
1303                }
1304                if let Some(parent) = spatial_nodes.get_mut(&parent_id) {
1305                    parent.children.push(child_id);
1306                }
1307                if let Some(child) = spatial_nodes.get_mut(&child_id) {
1308                    child.parent = Some(parent_id);
1309                }
1310            }
1311        }
1312        for (parent_id, element_ids) in quick_containment_links {
1313            if let Some(parent) = spatial_nodes.get_mut(&parent_id) {
1314                parent.elements.extend(element_ids);
1315            }
1316        }
1317        let mut root_id = spatial_nodes
1318            .values()
1319            .find(|node| node.type_name == "IfcProject")
1320            .map(|node| node.express_id);
1321        if root_id.is_none() {
1322            root_id = spatial_nodes
1323                .values()
1324                .find(|node| node.parent.is_none())
1325                .map(|node| node.express_id);
1326        }
1327        let spatial_tree = root_id
1328            .map(|root| {
1329                build_quick_spatial_tree_node(root, &spatial_nodes, &quick_element_summaries)
1330            })
1331            .transpose()
1332            .unwrap_or(None);
1333        on_quick_metadata_bootstrap(&QuickMetadataBootstrap {
1334            schema_version: schema_version.clone(),
1335            entity_count: total_entities,
1336            spatial_tree,
1337        });
1338    }
1339
1340    // Preprocess complex geometry
1341    let preprocess_start = std::time::Instant::now();
1342    let mut router = GeometryRouter::with_units(content, &mut decoder);
1343    router.set_tessellation_quality(options.tessellation_quality);
1344
1345    // Resolve IfcSite and IfcBuilding placement transforms.
1346    let site_transform: Option<Vec<f64>> = site_entity_pos.and_then(|(start, end)| {
1347        let entity = decoder.decode_at(start, end).ok()?;
1348        let matrix = router
1349            .resolve_scaled_placement(&entity, &mut decoder)
1350            .ok()?;
1351        Some(matrix.to_vec())
1352    });
1353    let building_transform: Option<Vec<f64>> = building_entity_pos.and_then(|(start, end)| {
1354        let entity = decoder.decode_at(start, end).ok()?;
1355        let matrix = router
1356            .resolve_scaled_placement(&entity, &mut decoder)
1357            .ok()?;
1358        Some(matrix.to_vec())
1359    });
1360
1361    let rtc_jobs: Vec<(u32, usize, usize, IfcType)> = entity_jobs
1362        .iter()
1363        .map(|job| (job.id, job.start, job.end, job.ifc_type))
1364        .collect();
1365    let detected_rtc_offset =
1366        router.detect_rtc_offset_with_fallback(&rtc_jobs, &mut decoder, content);
1367
1368    // Three-tier coordinate-space selection:
1369    //   1. `site_local`: IfcSite placement has a non-identity translation.
1370    //      Vertices are expressed relative to the site origin — small floats
1371    //      AND a meaningful, relatable frame (useful for coordination).
1372    //   2. `model_rtc`:  IfcSite is identity (or missing) but geometry still
1373    //      lives at large world coordinates. Subtract a detected anchor so
1374    //      f32 precision is preserved.
1375    //   3. `raw_ifc`:    neither anchor applies; geometry is already small.
1376    let site_rtc = site_transform
1377        .as_ref()
1378        .map(|st| (st[12], st[13], st[14])) // column-major: translation at 12,13,14
1379        .filter(|t| translation_is_nonidentity(*t));
1380    let detected_has_offset = translation_is_nonidentity(detected_rtc_offset);
1381    let (rtc_offset, coord_space) = if let Some(site) = site_rtc {
1382        (site, SITE_LOCAL_MESH_COORDINATE_SPACE)
1383    } else if detected_has_offset {
1384        (detected_rtc_offset, MODEL_RTC_MESH_COORDINATE_SPACE)
1385    } else {
1386        ((0.0, 0.0, 0.0), RAW_IFC_MESH_COORDINATE_SPACE)
1387    };
1388    let has_rtc_offset = coord_space != RAW_IFC_MESH_COORDINATE_SPACE;
1389    router.set_rtc_offset(rtc_offset);
1390    let preprocess_time = preprocess_start.elapsed();
1391
1392    let parse_time = parse_start.elapsed();
1393    tracing::info!(
1394        entity_scan_time_ms = entity_scan_time.as_millis(),
1395        lookup_time_ms = lookup_time.as_millis(),
1396        preprocess_time_ms = preprocess_time.as_millis(),
1397        parse_time_ms = parse_time.as_millis(),
1398        "Parse phase complete, starting geometry extraction"
1399    );
1400
1401    // PARALLEL GEOMETRY PROCESSING
1402    let geometry_start = std::time::Instant::now();
1403    let entity_index_arc = entity_index; // Already Arc from above
1404    let unit_scale = router.unit_scale();
1405    let rtc_offset = router.rtc_offset();
1406    let void_index_arc = Arc::new(filtered_void_index);
1407    let skipped_entity_ids = Arc::new(skipped_entity_ids);
1408    // Fold indexed-colour-map colours in where no IFCSTYLEDITEM already claimed
1409    // the geometry (styled items win, matching the browser precedence).
1410    merge_indexed_colours(&mut geometry_style_index, &indexed_colour_index);
1411    let mut geometry_style_index = Arc::new(geometry_style_index);
1412    let indexed_colour_full = Arc::new(indexed_colour_full);
1413    // #961: decode surface textures (IfcBlobTexture PNG / IfcPixelTexture) and
1414    // their per-triangle UV maps once, keyed by face-set id. `build_texture_index`
1415    // bails out on a cheap substring check for the (vast majority) untextured
1416    // files. Consumed by the type-only render path below.
1417    let texture_index = Arc::new(ifc_lite_geometry::build_texture_index(
1418        content,
1419        &mut decoder,
1420    ));
1421    // Join the material chain into colours per element (#407). The single
1422    // opaque-first colour is the general-path element fallback; the full list
1423    // feeds the opening sub-mesh transparent/opaque split (#913 §2.3).
1424    let element_material_colors = crate::style::build_element_material_colors(
1425        &material_def_reprs,
1426        &orphan_styled_items,
1427        &element_to_material,
1428        &mut decoder,
1429    );
1430    let element_material_color: FxHashMap<u32, [f32; 4]> = element_material_colors
1431        .iter()
1432        .filter_map(|(&id, colors)| crate::style::pick_opaque_first(colors).map(|c| (id, c)))
1433        .collect();
1434    let element_material_colors = Arc::new(element_material_colors);
1435
1436    let total_jobs = entity_jobs.len();
1437    let initial_chunk_size = options.initial_batch_size.max(1);
1438    let throughput_chunk_size = options.throughput_batch_size.max(initial_chunk_size);
1439    let mut color_cache_by_product_definition_shape: FxHashMap<u32, Option<[f32; 4]>> =
1440        FxHashMap::default();
1441    let mut layer_cache_by_product_definition_shape: FxHashMap<u32, Option<String>> =
1442        FxHashMap::default();
1443    let mut layer_cache_by_representation: FxHashMap<u32, Option<String>> = FxHashMap::default();
1444    let mut meshes: Vec<MeshData> = Vec::new();
1445    let mut processed_jobs = 0usize;
1446    let mut total_meshes = 0usize;
1447    let mut total_vertices = 0usize;
1448    let mut total_triangles = 0usize;
1449    let mut chunk_start = 0usize;
1450    let mut current_chunk_size = initial_chunk_size;
1451
1452    let mut deferred_styles_applied = !defer_style_updates;
1453
1454    // CSG-diagnostics sink shared across all per-job routers (drained after
1455    // the loop into ProcessingStats + one tracing summary).
1456    let csg_failure_collector: std::sync::Mutex<FxHashMap<u32, Vec<ifc_lite_geometry::BoolFailure>>> =
1457        std::sync::Mutex::new(FxHashMap::default());
1458
1459    while chunk_start < total_jobs {
1460        let chunk_end = (chunk_start + current_chunk_size).min(total_jobs);
1461        let jobs_chunk = &mut entity_jobs[chunk_start..chunk_end];
1462
1463        // ── Desktop: two-phase parallel metadata population ──
1464        // Phase 1 (parallel): decode entities, extract GlobalId/Name/ProductDefinitionShapeId
1465        // Phase 2 (serial): resolve colors from cache (cheap, cache-hit dominated)
1466        #[cfg(not(target_arch = "wasm32"))]
1467        {
1468            // Phase 1: parallel decode with thread-local EntityDecoder
1469            let entity_index_for_meta = entity_index_arc.clone();
1470            jobs_chunk.par_iter_mut().for_each(|job| {
1471                if job.global_id.is_some()
1472                    || job.name.is_some()
1473                    || job.product_definition_shape_id.is_some()
1474                {
1475                    return;
1476                }
1477                let mut local_decoder =
1478                    EntityDecoder::with_arc_index(content, entity_index_for_meta.clone());
1479                let Ok(entity) = local_decoder.decode_at(job.start, job.end) else {
1480                    return;
1481                };
1482                job.global_id = normalize_optional_string(entity.get_string(0));
1483                job.name = normalize_optional_string(entity.get_string(2));
1484                job.product_definition_shape_id = entity.get_ref(6);
1485            });
1486
1487            // Phase 2: serial color/layer resolution (cache-hit dominated, fast)
1488            for job in jobs_chunk.iter_mut() {
1489                let Some(pds_id) = job.product_definition_shape_id else {
1490                    continue;
1491                };
1492                let resolved_color = color_cache_by_product_definition_shape
1493                    .entry(pds_id)
1494                    .or_insert_with(|| {
1495                        resolve_element_color_for_product_definition_shape(
1496                            pds_id,
1497                            &geometry_style_index,
1498                            &mut decoder,
1499                        )
1500                    });
1501                if let Some(color) = resolved_color {
1502                    job.element_color = *color;
1503                } else if let Some(color) = element_material_color.get(&job.id) {
1504                    // No direct/indexed geometry style — inherit the material
1505                    // appearance (#407).
1506                    job.element_color = *color;
1507                }
1508                if options.include_presentation_layers {
1509                    let resolved_layer = layer_cache_by_product_definition_shape
1510                        .entry(pds_id)
1511                        .or_insert_with(|| {
1512                            resolve_presentation_layer_for_product_definition_shape(
1513                                pds_id,
1514                                &presentation_layer_by_assigned_id,
1515                                &mut layer_cache_by_representation,
1516                                &mut decoder,
1517                            )
1518                        });
1519                    job.presentation_layer = resolved_layer.clone();
1520                }
1521            }
1522        }
1523
1524        // ── WASM: existing serial path (unchanged) ──
1525        #[cfg(target_arch = "wasm32")]
1526        for job in jobs_chunk.iter_mut() {
1527            populate_entity_job_metadata(
1528                job,
1529                &geometry_style_index,
1530                &element_material_color,
1531                &presentation_layer_by_assigned_id,
1532                &mut color_cache_by_product_definition_shape,
1533                &mut layer_cache_by_product_definition_shape,
1534                &mut layer_cache_by_representation,
1535                &mut decoder,
1536                options.include_presentation_layers,
1537            );
1538        }
1539        let site_local_rotation: Option<&Vec<f64>> =
1540            if coord_space == SITE_LOCAL_MESH_COORDINATE_SPACE {
1541                site_transform.as_ref()
1542            } else {
1543                None
1544            };
1545        let chunk_meshes: Vec<MeshData> = jobs_chunk
1546            .par_iter()
1547            .flat_map_iter(|job| {
1548                process_entity_job(
1549                    job,
1550                    content,
1551                    &entity_index_arc,
1552                    unit_scale,
1553                    rtc_offset,
1554                    options.tessellation_quality,
1555                    void_index_arc.as_ref(),
1556                    skipped_entity_ids.as_ref(),
1557                    geometry_style_index.as_ref(),
1558                    indexed_colour_full.as_ref(),
1559                    element_material_colors.as_ref(),
1560                    texture_index.as_ref(),
1561                    site_local_rotation,
1562                    &csg_failure_collector,
1563                )
1564            })
1565            .collect();
1566
1567        processed_jobs += jobs_chunk.len();
1568        total_vertices += chunk_meshes.iter().map(|m| m.vertex_count()).sum::<usize>();
1569        total_triangles += chunk_meshes
1570            .iter()
1571            .map(|m| m.triangle_count())
1572            .sum::<usize>();
1573
1574        if !chunk_meshes.is_empty() {
1575            total_meshes += chunk_meshes.len();
1576            let emit_mesh_chunk_size = current_chunk_size.max(1);
1577            for emitted_meshes in chunk_meshes.chunks(emit_mesh_chunk_size) {
1578                on_batch(emitted_meshes, processed_jobs, total_jobs);
1579            }
1580            if options.retain_emitted_meshes {
1581                meshes.extend(chunk_meshes);
1582            }
1583
1584            if !deferred_styles_applied {
1585                // Replay saved IFCSTYLEDITEM positions instead of re-scanning
1586                // the entire file.  This eliminates ~0.5-1 s for 1 GB files.
1587                let mut rebuilt_styles: FxHashMap<u32, GeometryStyleInfo> = FxHashMap::default();
1588                {
1589                    let mut style_decoder =
1590                        EntityDecoder::with_arc_index(content, entity_index_arc.clone());
1591                    for &(start, end) in &deferred_styled_item_positions {
1592                        if let Ok(styled_item) = style_decoder.decode_at(start, end) {
1593                            collect_geometry_style_info(
1594                                &mut rebuilt_styles,
1595                                &styled_item,
1596                                &mut style_decoder,
1597                            );
1598                        }
1599                    }
1600                }
1601                merge_indexed_colours(&mut rebuilt_styles, &indexed_colour_index);
1602                geometry_style_index = Arc::new(rebuilt_styles);
1603                let deferred_color_updates = build_color_updates_for_jobs(
1604                    &entity_jobs[..processed_jobs],
1605                    geometry_style_index.as_ref(),
1606                    content,
1607                    &entity_index_arc,
1608                );
1609                if !deferred_color_updates.is_empty() {
1610                    on_color_update(&deferred_color_updates);
1611                }
1612                deferred_styles_applied = true;
1613            }
1614        }
1615        chunk_start = chunk_end;
1616        current_chunk_size = throughput_chunk_size;
1617    }
1618
1619    let geometry_time = geometry_start.elapsed();
1620    // Surface the aggregated CSG diagnostics — same per-reason breakdown the
1621    // browser console shows on the wasm path.
1622    let csg_failures = csg_failure_collector
1623        .into_inner()
1624        .unwrap_or_else(|poisoned| poisoned.into_inner());
1625    let total_csg_failures: usize = csg_failures.values().map(Vec::len).sum();
1626    let products_with_failures = csg_failures.len();
1627    if total_csg_failures > 0 {
1628        let mut by_reason: HashMap<&'static str, usize> = HashMap::new();
1629        for fails in csg_failures.values() {
1630            for f in fails {
1631                *by_reason.entry(f.reason.label()).or_insert(0) += 1;
1632            }
1633        }
1634        let mut breakdown: Vec<(&'static str, usize)> = by_reason.into_iter().collect();
1635        breakdown.sort_by(|a, b| b.1.cmp(&a.1));
1636        let breakdown = breakdown
1637            .iter()
1638            .map(|(reason, count)| format!("{reason}={count}"))
1639            .collect::<Vec<_>>()
1640            .join(" ");
1641        tracing::warn!(
1642            total_csg_failures,
1643            products_with_failures,
1644            %breakdown,
1645            "CSG failures during geometry extraction (cut dropped, host kept uncut)"
1646        );
1647    }
1648
1649    let total_time = total_start.elapsed();
1650
1651    tracing::info!(
1652        meshes = meshes.len(),
1653        vertices = total_vertices,
1654        triangles = total_triangles,
1655        geometry_time_ms = geometry_time.as_millis(),
1656        total_time_ms = total_time.as_millis(),
1657        "Geometry processing complete"
1658    );
1659
1660    ProcessingResult {
1661        meshes,
1662        mesh_coordinate_space: Some(coord_space.to_string()),
1663        site_transform,
1664        building_transform,
1665        metadata: ModelMetadata {
1666            schema_version,
1667            entity_count: total_entities,
1668            geometry_entity_count,
1669            coordinate_info: CoordinateInfo {
1670                origin_shift: [rtc_offset.0, rtc_offset.1, rtc_offset.2],
1671                is_geo_referenced: has_rtc_offset,
1672            },
1673            length_unit_scale: Some(unit_scale),
1674            georeferencing: crate::extract_georeferencing(content),
1675        },
1676        stats: ProcessingStats {
1677            total_meshes,
1678            total_vertices,
1679            total_triangles,
1680            parse_time_ms: parse_time.as_millis() as u64,
1681            entity_scan_time_ms: entity_scan_time.as_millis() as u64,
1682            lookup_time_ms: lookup_time.as_millis() as u64,
1683            preprocess_time_ms: preprocess_time.as_millis() as u64,
1684            geometry_time_ms: geometry_time.as_millis() as u64,
1685            total_time_ms: total_time.as_millis() as u64,
1686            from_cache: false,
1687            total_csg_failures: total_csg_failures as u64,
1688            products_with_failures: products_with_failures as u64,
1689        },
1690    }
1691}
1692
1693fn process_entity_job(
1694    job: &EntityJob,
1695    content: &[u8],
1696    entity_index_arc: &Arc<EntityIndex>,
1697    unit_scale: f64,
1698    rtc_offset: (f64, f64, f64),
1699    tessellation_quality: TessellationQuality,
1700    void_index: &FxHashMap<u32, Vec<u32>>,
1701    skipped_entity_ids: &HashSet<u32>,
1702    geometry_style_index: &FxHashMap<u32, GeometryStyleInfo>,
1703    indexed_colour_full: &FxHashMap<u32, crate::style::FullIndexedColourMap>,
1704    element_material_colors: &FxHashMap<u32, Vec<[f32; 4]>>,
1705    // Surface textures + UV maps keyed by face-set id (#961). Empty for
1706    // untextured models.
1707    texture_index: &FxHashMap<u32, ifc_lite_geometry::ResolvedTextureMap>,
1708    // Present only when the selected coordinate space is `site_local`; rotates
1709    // mesh vertices into the site's axis frame.
1710    site_local_rotation: Option<&Vec<f64>>,
1711    // Shared sink for per-job router CSG diagnostics (parity with the wasm
1712    // path's `drain_and_log_csg_diagnostics`).
1713    csg_failure_collector: &std::sync::Mutex<FxHashMap<u32, Vec<ifc_lite_geometry::BoolFailure>>>,
1714) -> Vec<MeshData> {
1715    if skipped_entity_ids.contains(&job.id) {
1716        return Vec::new();
1717    }
1718
1719    let mut local_decoder = EntityDecoder::with_arc_index(content, entity_index_arc.clone());
1720
1721    let entity = match local_decoder.decode_at(job.start, job.end) {
1722        Ok(entity) => entity,
1723        Err(_) => return Vec::new(),
1724    };
1725
1726    let has_representation = entity.get(6).is_some_and(|a| !a.is_null());
1727    if !has_representation {
1728        return Vec::new();
1729    }
1730
1731    let mut local_router = GeometryRouter::with_scale_and_quality(unit_scale, tessellation_quality);
1732    local_router.set_rtc_offset(rtc_offset);
1733    let local_router = local_router;
1734    let result = (|| -> Vec<MeshData> {
1735    let global_id = job.global_id.clone();
1736    let name = job.name.clone();
1737    let presentation_layer = job.presentation_layer.clone();
1738    let space_zone_properties = job.space_zone_properties.clone();
1739    let element_color = job.element_color;
1740
1741    // #957: synthetic type-only-geometry job — render the orphan
1742    // RepresentationMap directly (baking its MappingOrigin) instead of walking
1743    // the product's IfcProductDefinitionShape (a type has none).
1744    if let Some(rep_map_id) = job.representation_map_id {
1745        return process_type_representation_map_job(
1746            job,
1747            rep_map_id,
1748            &local_router,
1749            &mut local_decoder,
1750            geometry_style_index,
1751            texture_index,
1752            element_color,
1753            global_id,
1754            name,
1755            presentation_layer,
1756            site_local_rotation,
1757        );
1758    }
1759
1760    let has_openings = void_index.get(&job.id).is_some_and(|v| !v.is_empty());
1761
1762    // Shared per-sub emission: per-item colour resolution through the
1763    // canonical `resolve_submesh_color` precedence (#913 §4.2), identical to
1764    // the wasm `processGeometryBatch` path so browser and backend can't
1765    // drift on sub-mesh colouring.
1766    let mut emit_sub_meshes = |sub_meshes: ifc_lite_geometry::SubMeshCollection,
1767                               local_decoder: &mut EntityDecoder|
1768     -> Vec<MeshData> {
1769        let mut out: Vec<MeshData> = Vec::with_capacity(sub_meshes.len());
1770        // Material colours for this element, used when a sub-mesh has no
1771        // direct style — alternated so frame (opaque) and glazing
1772        // (transparent) split across the window's parts (#913 §2.3).
1773        let material_colors = element_material_colors.get(&job.id);
1774        let mut mat_color_idx = 0usize;
1775
1776        for sub in sub_meshes.sub_meshes {
1777            let mut sub_mesh = sub.mesh;
1778            if sub_mesh.is_empty() {
1779                continue;
1780            }
1781
1782            if sub_mesh.normals.is_empty() {
1783                calculate_normals(&mut sub_mesh);
1784            }
1785
1786            let style = geometry_style_index.get(&sub.geometry_id);
1787            // Direct style wins; else chase IfcMappedItem so mapped
1788            // sub-geometry inherits its underlying style (#913 §2.7).
1789            let direct_color = style.map(|s| s.color).or_else(|| {
1790                find_geometry_item_color(sub.geometry_id, geometry_style_index, local_decoder)
1791            });
1792            let color = crate::style::resolve_submesh_color(
1793                direct_color,
1794                material_colors.map(|v| v.as_slice()),
1795                &mut mat_color_idx,
1796                element_color,
1797            );
1798            let material_name = style
1799                .and_then(|s| s.material_name.as_ref())
1800                .map(ToString::to_string);
1801            let material_name = material_name.or_else(|| {
1802                infer_opening_subpart_material_name(&job.ifc_type, color, sub.geometry_id)
1803            });
1804
1805            let mut mesh_data = MeshData::new(
1806                job.id,
1807                job.ifc_type.name().to_string(),
1808                sub_mesh.positions,
1809                sub_mesh.normals,
1810                sub_mesh.indices,
1811                color,
1812            )
1813            .with_element_metadata(global_id.clone(), name.clone(), presentation_layer.clone())
1814            .with_properties(space_zone_properties.clone())
1815            .with_style_metadata(material_name, Some(sub.geometry_id));
1816            convert_mesh_to_site_local(&mut mesh_data, site_local_rotation);
1817            out.push(mesh_data);
1818        }
1819        out
1820    };
1821
1822    if has_openings {
1823        // Voided elements FIRST — branch order matches the wasm path, so a
1824        // voided window is CUT rather than rendered uncut-as-subparts.
1825        // Prefer the submesh-aware cut (per-part colours survive the void
1826        // subtraction); the single-mesh cut below stays as the fallback.
1827        if let Ok(sub_meshes) = local_router.process_element_with_submeshes_and_voids(
1828            &entity,
1829            &mut local_decoder,
1830            void_index,
1831        ) {
1832            if !sub_meshes.is_empty() {
1833                let out = emit_sub_meshes(sub_meshes, &mut local_decoder);
1834                if !out.is_empty() {
1835                    return out;
1836                }
1837            }
1838        }
1839    } else {
1840        // #858: an IfcIndexedColourMap colours faces of a single face set —
1841        // those elements keep the single-mesh + palette-split path below.
1842        let has_indexed_colour = !indexed_colour_full.is_empty()
1843            && find_indexed_colour_for_element(&entity, indexed_colour_full, &mut local_decoder)
1844                .is_some();
1845        if !has_indexed_colour {
1846            // Submesh path for ALL types (parity with `processGeometryBatch`):
1847            // per-item colours AND per-item error skipping — one unsupported
1848            // representation item no longer makes the whole element invisible
1849            // in server/parquet output while it renders partially in the
1850            // browser.
1851            if let Ok(sub_meshes) =
1852                local_router.process_element_with_submeshes(&entity, &mut local_decoder)
1853            {
1854                if !sub_meshes.is_empty() {
1855                    let out = emit_sub_meshes(sub_meshes, &mut local_decoder);
1856                    if !out.is_empty() {
1857                        return out;
1858                    }
1859                }
1860            }
1861        }
1862    }
1863
1864    // A superseding strategy is about to re-process this element's
1865    // representation and re-attempt the same (deterministic) cuts/booleans.
1866    // Discard the abandoned attempt's diagnostics so only the path that
1867    // actually produced the returned meshes contributes to
1868    // total_csg_failures / products_with_failures — otherwise re-failures
1869    // are double-counted. (`take_csg_failures` is the drain == clear; the
1870    // voids→plain-element mini-fallback below intentionally keeps its
1871    // records: a failed/emptying cut that leaves the host uncut IS the
1872    // diagnostic.)
1873    let _ = local_router.take_csg_failures();
1874
1875    let mut mesh_candidate = local_router
1876        .process_element_with_voids(&entity, &mut local_decoder, void_index)
1877        .ok();
1878    let needs_fallback = match mesh_candidate.as_ref() {
1879        Some(mesh) => mesh.is_empty(),
1880        None => true,
1881    };
1882    if needs_fallback {
1883        mesh_candidate = local_router
1884            .process_element(&entity, &mut local_decoder)
1885            .ok();
1886    }
1887
1888    if let Some(mut mesh) = mesh_candidate {
1889        if !mesh.is_empty() {
1890            // Multi-colour IfcIndexedColourMap → one sub-mesh per palette group
1891            // (#858). Only applies when the produced triangle count still
1892            // matches the face set's CoordIndex (no CSG/void retopology);
1893            // otherwise we keep the single dominant-coloured mesh below.
1894            if !indexed_colour_full.is_empty() {
1895                if let Some(full) = find_indexed_colour_for_element(
1896                    &entity,
1897                    indexed_colour_full,
1898                    &mut local_decoder,
1899                ) {
1900                    let geometry_id = full.geometry_id;
1901                    if let Some(groups) = crate::style::split_mesh_by_indexed_colour(&mesh, full) {
1902                        let mut out: Vec<MeshData> = Vec::with_capacity(groups.len());
1903                        for (color, mut part) in groups {
1904                            if part.normals.is_empty() {
1905                                calculate_normals(&mut part);
1906                            }
1907                            let mut mesh_data = MeshData::new(
1908                                job.id,
1909                                job.ifc_type.name().to_string(),
1910                                part.positions,
1911                                part.normals,
1912                                part.indices,
1913                                color.to_array(),
1914                            )
1915                            .with_element_metadata(
1916                                global_id.clone(),
1917                                name.clone(),
1918                                presentation_layer.clone(),
1919                            )
1920                            .with_properties(space_zone_properties.clone())
1921                            .with_style_metadata(None, Some(geometry_id));
1922                            convert_mesh_to_site_local(&mut mesh_data, site_local_rotation);
1923                            out.push(mesh_data);
1924                        }
1925                        if !out.is_empty() {
1926                            return out;
1927                        }
1928                    }
1929                }
1930            }
1931
1932            if mesh.normals.is_empty() {
1933                calculate_normals(&mut mesh);
1934            }
1935
1936            let mut mesh_data = MeshData::new(
1937                job.id,
1938                job.ifc_type.name().to_string(),
1939                mesh.positions,
1940                mesh.normals,
1941                mesh.indices,
1942                element_color,
1943            )
1944            .with_element_metadata(global_id, name, presentation_layer)
1945            .with_properties(space_zone_properties);
1946            convert_mesh_to_site_local(&mut mesh_data, site_local_rotation);
1947            return vec![mesh_data];
1948        }
1949    }
1950
1951    Vec::new()
1952    })();
1953
1954    // Drain the per-job router's CSG diagnostics into the shared collector
1955    // BEFORE the router drops. The wasm path surfaces these in the browser
1956    // console (`drain_and_log_csg_diagnostics`); without this drain the
1957    // server silently discarded every failed opening cut, and the
1958    // thread-local pending mapped-boolean buffer accumulated across
1959    // requests on the long-lived rayon pool threads.
1960    let failures = local_router.take_csg_failures();
1961    if !failures.is_empty() {
1962        if let Ok(mut collector) = csg_failure_collector.lock() {
1963            for (product_id, fails) in failures {
1964                collector.entry(product_id).or_default().extend(fails);
1965            }
1966        }
1967    }
1968
1969    result
1970}
1971
1972/// Render an orphan type-product `IfcRepresentationMap` (issue #957).
1973///
1974/// Tessellates the map's `MappedRepresentation` (baking its MappingOrigin) and
1975/// builds a single [`MeshData`] keyed on the type's express id. The colour is
1976/// resolved from the mapped geometry's `IfcStyledItem` chain when present (the
1977/// blob/image/pixel-texture annex-E fixtures author a white `IfcSurfaceStyle`),
1978/// otherwise the type's default colour. Texture fidelity is layered on
1979/// separately; this path makes the geometry visible.
1980#[allow(clippy::too_many_arguments)]
1981fn process_type_representation_map_job(
1982    job: &EntityJob,
1983    rep_map_id: u32,
1984    router: &GeometryRouter,
1985    decoder: &mut EntityDecoder,
1986    geometry_style_index: &FxHashMap<u32, GeometryStyleInfo>,
1987    texture_index: &FxHashMap<u32, ifc_lite_geometry::ResolvedTextureMap>,
1988    element_color: [f32; 4],
1989    global_id: Option<String>,
1990    name: Option<String>,
1991    presentation_layer: Option<String>,
1992    site_local_rotation: Option<&Vec<f64>>,
1993) -> Vec<MeshData> {
1994    let Ok(rep_map) = decoder.decode_by_id(rep_map_id) else {
1995        return Vec::new();
1996    };
1997    // Texture-aware build (#961): one part per output mesh — each textured face
1998    // set carries its own UVs + decoded image; untextured items merge into one
1999    // part with no texture.
2000    let Ok(parts) =
2001        router.process_representation_map_with_texture(&rep_map, decoder, texture_index)
2002    else {
2003        return Vec::new();
2004    };
2005    if parts.is_empty() {
2006        return Vec::new();
2007    }
2008
2009    let color = resolve_color_for_representation_map(rep_map_id, geometry_style_index, decoder)
2010        .unwrap_or(element_color);
2011
2012    let mut out: Vec<MeshData> = Vec::with_capacity(parts.len());
2013    for (mut mesh, uvs, texture) in parts {
2014        if mesh.is_empty() {
2015            continue;
2016        }
2017        if mesh.normals.is_empty() {
2018            calculate_normals(&mut mesh);
2019        }
2020        let mut mesh_data = MeshData::new(
2021            job.id,
2022            job.ifc_type.name().to_string(),
2023            mesh.positions,
2024            mesh.normals,
2025            mesh.indices,
2026            color,
2027        )
2028        .with_element_metadata(global_id.clone(), name.clone(), presentation_layer.clone());
2029
2030        // Attach the decoded texture + UVs (#961). `convert_mesh_to_site_local`
2031        // rotates positions/normals only; UVs are 2D and pass through unchanged.
2032        if let Some(tex) = texture {
2033            mesh_data = mesh_data.with_texture(
2034                uvs,
2035                crate::types::mesh::MeshTextureData {
2036                    rgba: tex.rgba,
2037                    width: tex.width,
2038                    height: tex.height,
2039                    repeat_s: tex.repeat_s,
2040                    repeat_t: tex.repeat_t,
2041                },
2042            );
2043        }
2044
2045        convert_mesh_to_site_local(&mut mesh_data, site_local_rotation);
2046        out.push(mesh_data);
2047    }
2048    out
2049}
2050
2051/// Resolve the authored colour for a type's `IfcRepresentationMap` (issue #957)
2052/// by looking up its mapped geometry items in the styled-item index — the same
2053/// index that colours ordinary products. Returns `None` when no item carries a
2054/// style (caller falls back to the type's default colour).
2055fn resolve_color_for_representation_map(
2056    rep_map_id: u32,
2057    geometry_style_index: &FxHashMap<u32, GeometryStyleInfo>,
2058    decoder: &mut EntityDecoder,
2059) -> Option<[f32; 4]> {
2060    let rep_map = decoder.decode_by_id(rep_map_id).ok()?;
2061    // IfcRepresentationMap.MappedRepresentation = attr 1.
2062    let mapped_rep_id = rep_map.get_ref(1)?;
2063    let mapped_rep = decoder.decode_by_id(mapped_rep_id).ok()?;
2064    // IfcShapeRepresentation.Items = attr 3.
2065    let item_ids = get_refs_from_list(&mapped_rep, 3)?;
2066    for item_id in item_ids {
2067        if let Some(style) = geometry_style_index.get(&item_id) {
2068            return Some(style.color);
2069        }
2070        if let Some(color) = find_geometry_item_color(item_id, geometry_style_index, decoder) {
2071            return Some(color);
2072        }
2073    }
2074    None
2075}
2076
2077/// Find the first representation item of `entity` that carries a full
2078/// `IfcIndexedColourMap` (issue #858). Used to drive per-triangle sub-mesh
2079/// splitting in the single-mesh emission path.
2080fn find_indexed_colour_for_element<'a>(
2081    entity: &DecodedEntity,
2082    indexed_colour_full: &'a FxHashMap<u32, crate::style::FullIndexedColourMap>,
2083    decoder: &mut EntityDecoder,
2084) -> Option<&'a crate::style::FullIndexedColourMap> {
2085    let pds_id = entity.get_ref(6)?;
2086    let pds = decoder.decode_by_id(pds_id).ok()?;
2087    let repr_ids = get_refs_from_list(&pds, 2)?;
2088    for repr_id in repr_ids {
2089        if let Ok(repr) = decoder.decode_by_id(repr_id) {
2090            if let Some(items) = get_refs_from_list(&repr, 3) {
2091                for item_id in items {
2092                    if let Some(full) = indexed_colour_full.get(&item_id) {
2093                        return Some(full);
2094                    }
2095                }
2096            }
2097        }
2098    }
2099    None
2100}
2101
2102/// Fold `IfcIndexedColourMap` colours into the style index, keyed by target
2103/// geometry id. `or_insert` preserves IFCSTYLEDITEM precedence: a geometry that
2104/// already has a direct style keeps it; the indexed colour only fills the gaps.
2105fn merge_indexed_colours(
2106    geometry_styles: &mut FxHashMap<u32, GeometryStyleInfo>,
2107    indexed_colours: &FxHashMap<u32, [f32; 4]>,
2108) {
2109    for (&geometry_id, &color) in indexed_colours {
2110        geometry_styles
2111            .entry(geometry_id)
2112            .or_insert_with(|| GeometryStyleInfo {
2113                color,
2114                shading_color: None,
2115                material_name: None,
2116            });
2117    }
2118}
2119
2120fn collect_geometry_style_info(
2121    geometry_styles: &mut FxHashMap<u32, GeometryStyleInfo>,
2122    styled_item: &DecodedEntity,
2123    decoder: &mut EntityDecoder,
2124) {
2125    let Some(geometry_id) = styled_item.get_ref(0) else {
2126        return;
2127    };
2128
2129    if geometry_styles.contains_key(&geometry_id) {
2130        return;
2131    }
2132
2133    if let Some(style_info) = extract_style_info_from_styled_item(styled_item, decoder) {
2134        geometry_styles.insert(geometry_id, style_info);
2135    }
2136}
2137
2138fn build_color_updates_for_jobs(
2139    jobs: &[EntityJob],
2140    geometry_styles: &FxHashMap<u32, GeometryStyleInfo>,
2141    content: &[u8],
2142    entity_index: &Arc<EntityIndex>,
2143) -> Vec<(u32, [f32; 4])> {
2144    let mut decoder = EntityDecoder::with_arc_index(content, entity_index.clone());
2145    let mut updates: Vec<(u32, [f32; 4])> = Vec::new();
2146
2147    for job in jobs {
2148        // #957: synthetic type-only-geometry jobs resolve their colour from the
2149        // RepresentationMap (a type has no IfcProductDefinitionShape), so the
2150        // product-definition path below never corrects them. Backfill them here
2151        // or a deferred IfcStyledItem (fast_first_batch) leaves the orphan type
2152        // geometry stuck at its fallback colour.
2153        if let Some(rep_map_id) = job.representation_map_id {
2154            if let Some(color) =
2155                resolve_color_for_representation_map(rep_map_id, geometry_styles, &mut decoder)
2156            {
2157                if color != job.element_color {
2158                    updates.push((job.id, color));
2159                }
2160            }
2161            continue;
2162        }
2163        let Ok(entity) = decoder.decode_at(job.start, job.end) else {
2164            continue;
2165        };
2166        let Some(product_definition_shape_id) = entity.get_ref(6) else {
2167            continue;
2168        };
2169        let Some(color) = resolve_element_color_for_product_definition_shape(
2170            product_definition_shape_id,
2171            geometry_styles,
2172            &mut decoder,
2173        ) else {
2174            continue;
2175        };
2176        if color != job.element_color {
2177            updates.push((job.id, color));
2178        }
2179    }
2180
2181    updates
2182}
2183
2184fn collect_presentation_layer_assignments(
2185    layer_by_assigned_representation: &mut FxHashMap<u32, String>,
2186    layer_assignment: &DecodedEntity,
2187) {
2188    let Some(layer_name) = normalize_optional_string(layer_assignment.get_string(0)) else {
2189        return;
2190    };
2191
2192    let Some(assigned_items) = get_refs_from_list(layer_assignment, 2) else {
2193        return;
2194    };
2195
2196    for assigned in assigned_items {
2197        layer_by_assigned_representation
2198            .entry(assigned)
2199            .or_insert_with(|| layer_name.clone());
2200    }
2201}
2202
2203fn resolve_element_color_for_product_definition_shape(
2204    product_definition_shape_id: u32,
2205    geometry_styles: &FxHashMap<u32, GeometryStyleInfo>,
2206    decoder: &mut EntityDecoder,
2207) -> Option<[f32; 4]> {
2208    find_color_in_representation(product_definition_shape_id, geometry_styles, decoder)
2209}
2210
2211fn resolve_presentation_layer_for_product_definition_shape(
2212    product_definition_shape_id: u32,
2213    layer_by_assigned_representation: &FxHashMap<u32, String>,
2214    cache_by_representation: &mut FxHashMap<u32, Option<String>>,
2215    decoder: &mut EntityDecoder,
2216) -> Option<String> {
2217    if let Some(layer_name) = layer_by_assigned_representation.get(&product_definition_shape_id) {
2218        return Some(layer_name.clone());
2219    }
2220
2221    let product_definition_shape = decoder.decode_by_id(product_definition_shape_id).ok()?;
2222    let representation_ids = get_refs_from_list(&product_definition_shape, 2)?;
2223
2224    for representation_id in representation_ids {
2225        if let Some(layer_name) = resolve_presentation_layer_name(
2226            representation_id,
2227            layer_by_assigned_representation,
2228            cache_by_representation,
2229            decoder,
2230            &mut Vec::new(),
2231        ) {
2232            return Some(layer_name);
2233        }
2234    }
2235
2236    None
2237}
2238
2239fn resolve_presentation_layer_name(
2240    representation_id: u32,
2241    layer_by_assigned_representation: &FxHashMap<u32, String>,
2242    cache_by_representation: &mut FxHashMap<u32, Option<String>>,
2243    decoder: &mut EntityDecoder,
2244    traversal_stack: &mut Vec<u32>,
2245) -> Option<String> {
2246    if let Some(cached) = cache_by_representation.get(&representation_id) {
2247        return cached.clone();
2248    }
2249
2250    if traversal_stack.contains(&representation_id) {
2251        return None;
2252    }
2253    traversal_stack.push(representation_id);
2254
2255    if let Some(layer_name) = layer_by_assigned_representation.get(&representation_id) {
2256        let result = Some(layer_name.clone());
2257        cache_by_representation.insert(representation_id, result.clone());
2258        traversal_stack.pop();
2259        return result;
2260    }
2261
2262    let mut resolved: Option<String> = None;
2263
2264    if let Ok(representation) = decoder.decode_by_id(representation_id) {
2265        if let Some(items) = get_refs_from_list(&representation, 3) {
2266            for item_id in items {
2267                if let Some(layer_name) = layer_by_assigned_representation.get(&item_id) {
2268                    resolved = Some(layer_name.clone());
2269                    break;
2270                }
2271
2272                if let Ok(item) = decoder.decode_by_id(item_id) {
2273                    if item.ifc_type == IfcType::IfcMappedItem {
2274                        if let Some(mapping_source_id) = item.get_ref(0) {
2275                            if let Ok(mapping_source) = decoder.decode_by_id(mapping_source_id) {
2276                                if let Some(mapped_representation_id) = mapping_source.get_ref(1) {
2277                                    if let Some(layer_name) = resolve_presentation_layer_name(
2278                                        mapped_representation_id,
2279                                        layer_by_assigned_representation,
2280                                        cache_by_representation,
2281                                        decoder,
2282                                        traversal_stack,
2283                                    ) {
2284                                        resolved = Some(layer_name);
2285                                        break;
2286                                    }
2287                                }
2288                            }
2289                        }
2290                    }
2291                }
2292            }
2293        }
2294    }
2295
2296    traversal_stack.pop();
2297    cache_by_representation.insert(representation_id, resolved.clone());
2298    resolved
2299}
2300
2301/// Find a color in a representation by traversing its items.
2302fn find_color_in_representation(
2303    repr_id: u32,
2304    geometry_styles: &FxHashMap<u32, GeometryStyleInfo>,
2305    decoder: &mut EntityDecoder,
2306) -> Option<[f32; 4]> {
2307    // Decode the IfcProductDefinitionShape
2308    let repr = decoder.decode_by_id(repr_id).ok()?;
2309
2310    // Attribute 2: Representations (list of IfcRepresentation)
2311    let repr_list = get_refs_from_list(&repr, 2)?;
2312
2313    for shape_repr_id in repr_list {
2314        if let Ok(shape_repr) = decoder.decode_by_id(shape_repr_id) {
2315            // Attribute 3: Items (list of IfcRepresentationItem)
2316            if let Some(items) = get_refs_from_list(&shape_repr, 3) {
2317                for item_id in items {
2318                    // Check direct style
2319                    if let Some(style) = geometry_styles.get(&item_id) {
2320                        return Some(style.color);
2321                    }
2322
2323                    // Check mapped items
2324                    if let Ok(item) = decoder.decode_by_id(item_id) {
2325                        if item.ifc_type == IfcType::IfcMappedItem {
2326                            if let Some(source_id) = item.get_ref(0) {
2327                                if let Ok(source) = decoder.decode_by_id(source_id) {
2328                                    if let Some(mapped_repr_id) = source.get_ref(1) {
2329                                        if let Some(color) = find_color_in_shape_representation(
2330                                            mapped_repr_id,
2331                                            geometry_styles,
2332                                            decoder,
2333                                        ) {
2334                                            return Some(color);
2335                                        }
2336                                    }
2337                                }
2338                            }
2339                        }
2340                    }
2341                }
2342            }
2343        }
2344    }
2345
2346    None
2347}
2348
2349/// Find color in a shape representation.
2350fn find_color_in_shape_representation(
2351    repr_id: u32,
2352    geometry_styles: &FxHashMap<u32, GeometryStyleInfo>,
2353    decoder: &mut EntityDecoder,
2354) -> Option<[f32; 4]> {
2355    let repr = decoder.decode_by_id(repr_id).ok()?;
2356    let items = get_refs_from_list(&repr, 3)?;
2357
2358    for item_id in items {
2359        if let Some(style) = geometry_styles.get(&item_id) {
2360            return Some(style.color);
2361        }
2362    }
2363
2364    None
2365}
2366
2367/// Resolve a single geometry item's colour, following `IfcMappedItem` into its
2368/// mapped representation when the item itself carries no direct style. Mirrors
2369/// the browser's `find_color_for_geometry` so mapped / instanced sub-geometry
2370/// inherits its underlying style in the sub-mesh path too (issue #913 §2.7) —
2371/// the element-level walk (`find_color_in_representation`) already did this, but
2372/// the per-sub-mesh lookup was a flat `geometry_styles.get`.
2373fn find_geometry_item_color(
2374    geometry_id: u32,
2375    geometry_styles: &FxHashMap<u32, GeometryStyleInfo>,
2376    decoder: &mut EntityDecoder,
2377) -> Option<[f32; 4]> {
2378    // Direct style on this exact geometry item wins.
2379    if let Some(style) = geometry_styles.get(&geometry_id) {
2380        return Some(style.color);
2381    }
2382
2383    // Otherwise, if it's a mapped item, chase the mapping to the underlying
2384    // geometry and resolve there (recursing handles nested mapped items).
2385    let geom = decoder.decode_by_id(geometry_id).ok()?;
2386    if geom.ifc_type != IfcType::IfcMappedItem {
2387        return None;
2388    }
2389    // IfcMappedItem.MappingSource (attr 0) → IfcRepresentationMap.
2390    let mapping_source_id = geom.get_ref(0)?;
2391    // IfcRepresentationMap.MappedRepresentation (attr 1) → IfcShapeRepresentation.
2392    let representation_map = decoder.decode_by_id(mapping_source_id).ok()?;
2393    let mapped_representation_id = representation_map.get_ref(1)?;
2394    let mapped_representation = decoder.decode_by_id(mapped_representation_id).ok()?;
2395    // IfcShapeRepresentation.Items (attr 3).
2396    let items = get_refs_from_list(&mapped_representation, 3)?;
2397    for underlying in items {
2398        if let Some(color) = find_geometry_item_color(underlying, geometry_styles, decoder) {
2399            return Some(color);
2400        }
2401    }
2402    None
2403}
2404
2405/// Extract color from an IfcStyledItem by traversing style references.
2406fn extract_style_info_from_styled_item(
2407    styled_item: &DecodedEntity,
2408    decoder: &mut EntityDecoder,
2409) -> Option<GeometryStyleInfo> {
2410    let style_refs = get_refs_from_list(styled_item, 1)?;
2411
2412    for style_id in style_refs {
2413        if let Ok(style) = decoder.decode_by_id(style_id) {
2414            // IfcPresentationStyleAssignment has nested style refs at attr 0.
2415            if let Some(inner_refs) = get_refs_from_list(&style, 0) {
2416                for inner_id in inner_refs {
2417                    if let Some(info) = extract_surface_style_info(inner_id, decoder) {
2418                        return Some(info);
2419                    }
2420                }
2421            }
2422
2423            // Or the style ref points directly to IfcSurfaceStyle.
2424            if let Some(info) = extract_surface_style_info(style_id, decoder) {
2425                return Some(info);
2426            }
2427        }
2428    }
2429
2430    None
2431}
2432
2433/// Extract colour + style name from an `IfcSurfaceStyle`. Colour resolution is
2434/// the canonical [`crate::style::extract_surface_style_colors`], shared with the
2435/// browser pre-pass so the server and viewer can't disagree on
2436/// `SurfaceColour` vs `DiffuseColour` precedence (see that fn for the #859/#871
2437/// semantics — `SurfaceColour` is the apparent colour; a `DiffuseColour`
2438/// `IfcColourRgb` becomes the optional `shading_color`, not the rendered colour).
2439fn extract_surface_style_info(
2440    style_id: u32,
2441    decoder: &mut EntityDecoder,
2442) -> Option<GeometryStyleInfo> {
2443    let style = decoder.decode_by_id(style_id).ok()?;
2444    let material_name = normalize_style_name(style.get_string(0));
2445    let (color, shading_color) = crate::style::extract_surface_style_colors(style_id, decoder)?;
2446    Some(GeometryStyleInfo {
2447        color,
2448        shading_color,
2449        material_name,
2450    })
2451}
2452
2453fn normalize_style_name(raw: Option<&str>) -> Option<String> {
2454    let name = raw?.trim();
2455    if name.is_empty() || name == "$" {
2456        return None;
2457    }
2458
2459    if name.eq_ignore_ascii_case("<unnamed>") || name.eq_ignore_ascii_case("unnamed") {
2460        return None;
2461    }
2462
2463    Some(name.to_string())
2464}
2465
2466/// Apply the opening filter and return which entity IDs to suppress and a filtered void index.
2467///
2468/// Returns `(skipped_entity_ids, filtered_void_index)` where:
2469/// - `skipped_entity_ids` is the set of IfcWindow/IfcDoor entity IDs to omit from geometry output
2470/// - `filtered_void_index` is the void index with suppressed openings removed from host lists
2471fn apply_opening_filter(
2472    entity_jobs: &[EntityJob],
2473    void_index: &FxHashMap<u32, Vec<u32>>,
2474    filling_by_opening: &FxHashMap<u32, u32>,
2475    geometry_style_index: &FxHashMap<u32, GeometryStyleInfo>,
2476    decoder: &mut EntityDecoder,
2477    mode: OpeningFilterMode,
2478) -> (HashSet<u32>, FxHashMap<u32, Vec<u32>>) {
2479    if mode == OpeningFilterMode::Default {
2480        return (HashSet::default(), void_index.clone());
2481    }
2482
2483    // Collect all IfcWindow / IfcDoor entity jobs.
2484    let filling_jobs: FxHashMap<u32, &EntityJob> = entity_jobs
2485        .iter()
2486        .filter(|job| matches!(job.ifc_type, IfcType::IfcWindow | IfcType::IfcDoor))
2487        .map(|job| (job.id, job))
2488        .collect();
2489
2490    if filling_jobs.is_empty() {
2491        return (HashSet::default(), void_index.clone());
2492    }
2493
2494    let mut skipped_entity_ids: HashSet<u32> = HashSet::default();
2495
2496    // IgnoreAll: suppress every window/door mesh and clear ALL wall voids.
2497    // We always clear the full void_index because IfcRelFillsElement is often absent
2498    // or only partially present, and without it we cannot identify which specific openings
2499    // belong to windows/doors.
2500    if mode == OpeningFilterMode::IgnoreAll {
2501        for (&id, _) in &filling_jobs {
2502            skipped_entity_ids.insert(id);
2503        }
2504        return (skipped_entity_ids, FxHashMap::default());
2505    }
2506
2507    // IgnoreOpaque: suppress only windows/doors that have no transparent sub-parts.
2508    // Mesh suppression uses element color + style traversal (is_opaque_opening).
2509    // Void suppression uses IfcRelFillsElement data when available.
2510    for (&id, job) in &filling_jobs {
2511        if is_opaque_opening(job, geometry_style_index, decoder) {
2512            skipped_entity_ids.insert(id);
2513        }
2514    }
2515
2516    if filling_by_opening.is_empty() {
2517        // No IfcRelFillsElement — can't map voids to specific window/door entities.
2518        return (skipped_entity_ids, void_index.clone());
2519    }
2520
2521    // Build openings_to_suppress from the explicit opening → filling mapping.
2522    let mut openings_to_suppress: HashSet<u32> = HashSet::default();
2523    for (&opening_id, &filling_id) in filling_by_opening {
2524        if skipped_entity_ids.contains(&filling_id) {
2525            openings_to_suppress.insert(opening_id);
2526        }
2527    }
2528
2529    if openings_to_suppress.is_empty() {
2530        return (skipped_entity_ids, void_index.clone());
2531    }
2532
2533    let mut filtered: FxHashMap<u32, Vec<u32>> = FxHashMap::default();
2534    for (&host_id, openings) in void_index {
2535        let remaining: Vec<u32> = openings
2536            .iter()
2537            .copied()
2538            .filter(|oid| !openings_to_suppress.contains(oid))
2539            .collect();
2540        if !remaining.is_empty() {
2541            filtered.insert(host_id, remaining);
2542        }
2543    }
2544
2545    (skipped_entity_ids, filtered)
2546}
2547
2548/// Returns `true` when the entity has no transparent or glass sub-parts,
2549/// meaning it is an opaque window/door that should be suppressed by `IgnoreOpaque`.
2550///
2551/// Any of the following makes it NOT opaque (returns `false`):
2552/// - Entity name contains "glas" (case-insensitive)
2553/// - Resolved element color has any transparency (alpha < 1.0)
2554/// - Any sub-geometry style has alpha < 1.0 or a material/style name containing "glas"
2555fn is_opaque_opening(
2556    job: &EntityJob,
2557    styles: &FxHashMap<u32, GeometryStyleInfo>,
2558    decoder: &mut EntityDecoder,
2559) -> bool {
2560    let Ok(entity) = decoder.decode_at(job.start, job.end) else {
2561        return true;
2562    };
2563
2564    // 1. Entity name contains "glas" → glazed.
2565    if normalize_optional_string(entity.get_string(2))
2566        .as_deref()
2567        .map(|n| n.to_lowercase().contains("glas"))
2568        .unwrap_or(false)
2569    {
2570        return false;
2571    }
2572
2573    // 2. Resolved element color has any transparency → glazed.
2574    //    Covers IfcWindow entities using their default colour ([0.6, 0.8, 1.0, 0.4])
2575    //    and any entity whose explicit surface style resolved to a transparent colour.
2576    if job.element_color[3] < 1.0 {
2577        return false;
2578    }
2579
2580    let Some(product_shape_id) = entity.get_ref(6) else {
2581        return true; // No shape info — treat as opaque
2582    };
2583
2584    let Ok(product_shape) = decoder.decode_by_id(product_shape_id) else {
2585        return true;
2586    };
2587
2588    let Some(repr_ids) = get_refs_from_list(&product_shape, 2) else {
2589        return true;
2590    };
2591
2592    for repr_id in repr_ids {
2593        let Ok(repr) = decoder.decode_by_id(repr_id) else {
2594            continue;
2595        };
2596        let Some(item_ids) = get_refs_from_list(&repr, 3) else {
2597            continue;
2598        };
2599        for item_id in item_ids {
2600            // Direct style on item
2601            if let Some(style) = styles.get(&item_id) {
2602                if has_glass_style(style) {
2603                    return false;
2604                }
2605            }
2606
2607            // Mapped items: IfcMappedItem → IfcRepresentationMap → IfcRepresentation → items
2608            if let Ok(item) = decoder.decode_by_id(item_id) {
2609                if item.ifc_type == IfcType::IfcMappedItem {
2610                    if let Some(source_id) = item.get_ref(0) {
2611                        if let Ok(source) = decoder.decode_by_id(source_id) {
2612                            if let Some(mapped_repr_id) = source.get_ref(1) {
2613                                if let Ok(mapped_repr) = decoder.decode_by_id(mapped_repr_id) {
2614                                    if let Some(mapped_items) = get_refs_from_list(&mapped_repr, 3)
2615                                    {
2616                                        for mapped_item_id in mapped_items {
2617                                            if let Some(style) = styles.get(&mapped_item_id) {
2618                                                if has_glass_style(style) {
2619                                                    return false;
2620                                                }
2621                                            }
2622                                        }
2623                                    }
2624                                }
2625                            }
2626                        }
2627                    }
2628                }
2629            }
2630        }
2631    }
2632
2633    true // No glass found → opaque
2634}
2635
2636/// Returns `true` when a geometry style indicates a glass/transparent material.
2637///
2638/// Triggers on:
2639/// - Any transparency at all (alpha < 1.0)
2640/// - Style/material name containing "glas" (case-insensitive)
2641fn has_glass_style(style: &GeometryStyleInfo) -> bool {
2642    if style.color[3] < 1.0 {
2643        return true;
2644    }
2645    if style
2646        .material_name
2647        .as_deref()
2648        .map(|n| n.to_lowercase().contains("glas"))
2649        .unwrap_or(false)
2650    {
2651        return true;
2652    }
2653    false
2654}
2655
2656fn is_opening_with_subparts(ifc_type: &IfcType) -> bool {
2657    matches!(ifc_type, IfcType::IfcWindow | IfcType::IfcDoor)
2658}
2659
2660fn infer_opening_subpart_material_name(
2661    ifc_type: &IfcType,
2662    color: [f32; 4],
2663    geometry_id: u32,
2664) -> Option<String> {
2665    if !is_opening_with_subparts(ifc_type) {
2666        return None;
2667    }
2668
2669    let prefix = match ifc_type {
2670        IfcType::IfcDoor => "Door",
2671        _ => "Window",
2672    };
2673
2674    // Transparency is a practical proxy for glazing in many BIM exports.
2675    if color[3] <= 0.65 {
2676        return Some(format!("{}_Glass", prefix));
2677    }
2678
2679    Some(format!("{}_Frame_{}", prefix, geometry_id))
2680}
2681
2682// Default IFC-type colors now come from the single canonical table in
2683// `crate::style::default_color_for_type` (issue #913). Do not reintroduce a
2684// per-module table here — see `tests/styling_parity.rs` for the guard.
2685
2686#[cfg(test)]
2687mod tests {
2688    use super::*;
2689
2690    fn map(pairs: &[(u32, &[u32])]) -> FxHashMap<u32, Vec<u32>> {
2691        pairs.iter().map(|(k, v)| (*k, v.to_vec())).collect()
2692    }
2693
2694    #[test]
2695    fn find_geometry_item_color_follows_mapped_item() {
2696        // #100 IfcMappedItem → #101 IfcRepresentationMap → #103
2697        // IfcShapeRepresentation whose Items = (#110). The style lives on the
2698        // underlying item #110, not on the mapped item, so a flat lookup of
2699        // #100 misses it — the resolver must chase the mapping (#913 §2.7).
2700        const IFC: &str = r#"ISO-10303-21;
2701HEADER;
2702FILE_DESCRIPTION((''),'2;1');
2703FILE_NAME('m.ifc','2026-06-04T00:00:00',(''),(''),'','','');
2704FILE_SCHEMA(('IFC4'));
2705ENDSEC;
2706DATA;
2707#2=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.0E-5,$,$);
2708#100=IFCMAPPEDITEM(#101,#105);
2709#101=IFCREPRESENTATIONMAP(#102,#103);
2710#102=IFCAXIS2PLACEMENT3D(#104,$,$);
2711#103=IFCSHAPEREPRESENTATION(#2,'Body','MappedRepresentation',(#110));
2712#104=IFCCARTESIANPOINT((0.,0.,0.));
2713#105=IFCCARTESIANTRANSFORMATIONOPERATOR3D($,$,#104,$,$);
2714ENDSEC;
2715END-ISO-10303-21;
2716"#;
2717        let blue = [0.1, 0.2, 0.9, 1.0];
2718        let mut styles: FxHashMap<u32, GeometryStyleInfo> = FxHashMap::default();
2719        styles.insert(
2720            110,
2721            GeometryStyleInfo {
2722                color: blue,
2723                shading_color: None,
2724                material_name: None,
2725            },
2726        );
2727
2728        let mut decoder = EntityDecoder::new(IFC);
2729
2730        // Mapped item, no direct style → inherits the underlying item's colour.
2731        assert_eq!(
2732            find_geometry_item_color(100, &styles, &mut decoder),
2733            Some(blue)
2734        );
2735        // A direct style still wins.
2736        assert_eq!(
2737            find_geometry_item_color(110, &styles, &mut decoder),
2738            Some(blue)
2739        );
2740        // A non-mapped, unstyled item (the representation map itself) → None.
2741        assert_eq!(find_geometry_item_color(101, &styles, &mut decoder), None);
2742    }
2743
2744}