Skip to main content

ifc_lite_processing/
element.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//! Canonical per-element mesh production — THE single decision tree that turns
6//! one IFC product (or type-product RepresentationMap) into renderable meshes.
7//!
8//! Both pipelines run this exact code:
9//! - the native orchestrator (`processor.rs`) calls [`produce_element_meshes`]
10//!   from its rayon loop with a fresh seeded decoder + router per element;
11//! - the browser batch path (`wasm-bindings` `processGeometryBatch`) calls it
12//!   per job with a warm per-batch decoder + router.
13//!
14//! History: the two pipelines used to carry diverging inline copies of this
15//! tree, and fixes had to land twice (#858, #913, #957, #961, #1071). Any
16//! change to mesh-production behaviour belongs HERE, exactly once. The only
17//! sanctioned behavioural fork is [`TypeGeometryMode`] — a product
18//! requirement, not drift: an export must never duplicate type geometry,
19//! while the interactive viewer renders it tagged for its Model/Types switch.
20//!
21//! The converged decision tree (union of the strongest behaviours of both
22//! former copies):
23//!
24//! ```text
25//! representation gate (IfcAlignment exempt)
26//! ├─ TypeProduct job (#957): render each planned RepresentationMap
27//! │    (textures #961, geometry_class tag, styled-item colour)
28//! └─ Product job:
29//!    ├─ has openings → submesh-aware void cut (per-part colours survive)
30//!    ├─ else        → submesh path for ALL types (per-item colours,
31//!    │                per-item error skipping, #858 palette split per item)
32//!    └─ fallback chain when the submesh path produced nothing:
33//!         void-aware single mesh → plain element → element-level #858 split
34//!         → single coloured mesh
35//! ```
36
37use crate::style::{FullIndexedColourMap, GeometryStyleInfo};
38use crate::types::mesh::{MeshData, MeshTextureData};
39use ifc_lite_core::{DecodedEntity, EntityDecoder, IfcType};
40use ifc_lite_geometry::{
41    calculate_normals, BoolFailure, GeometryHasher, GeometryRouter, Mesh, ResolvedTextureMap,
42    SubMeshCollection,
43};
44use rustc_hash::{FxHashMap, FxHashSet};
45use std::collections::BTreeMap;
46
47use crate::processor::{convert_mesh_to_site_local, get_refs_from_list};
48
49/// Element-level metadata stamped on every produced [`MeshData`]. The native
50/// pipeline resolves these during its metadata phase; the browser passes
51/// `None` (its viewer gets metadata from the parser worker instead).
52#[derive(Debug, Clone, Default)]
53pub struct ElementMeshMetadata {
54    pub global_id: Option<String>,
55    pub name: Option<String>,
56    pub presentation_layer: Option<String>,
57    pub space_zone_properties: Option<BTreeMap<String, String>>,
58}
59
60/// What the job renders.
61#[derive(Debug, Clone)]
62pub enum ElementJobKind {
63    /// Ordinary product occurrence — walk its IfcProductDefinitionShape.
64    Product,
65    /// #957 type geometry: render these RepresentationMaps directly (baking
66    /// their MappingOrigin), each pre-tagged with its geometry_class
67    /// (1 = orphan, 2 = instanced). Produce the list with
68    /// [`plan_type_geometry`] — callers must not hand-roll the filter.
69    TypeProduct { rep_maps: Vec<(u32, u8)> },
70}
71
72/// One unit of mesh production.
73pub struct ElementMeshJob<'a> {
74    pub id: u32,
75    pub ifc_type: IfcType,
76    /// The decoded product (or type-product) entity. Callers decode it —
77    /// they own skip-set checks and decode-failure policy.
78    pub entity: &'a DecodedEntity,
79    pub kind: ElementJobKind,
80    /// Caller-resolved element fallback colour (direct style > material
81    /// chain > type default). `None` ⇒ `default_color_for_type`.
82    pub element_color: Option<[f32; 4]>,
83    pub metadata: Option<&'a ElementMeshMetadata>,
84}
85
86/// Read-only shared state for one production run. Every field is a borrow of
87/// `Sync` data, so `&MeshProductionContext` can be captured by a rayon
88/// closure (native) or used serially (wasm).
89pub struct MeshProductionContext<'a> {
90    /// Host element id → opening ids (post void-propagation / opening filter).
91    pub void_index: &'a FxHashMap<u32, Vec<u32>>,
92    /// Geometry item id → resolved style (styled-item index).
93    pub geometry_style_index: &'a FxHashMap<u32, GeometryStyleInfo>,
94    /// Geometry item id → full per-triangle palette (#858).
95    pub indexed_colour_full: &'a FxHashMap<u32, FullIndexedColourMap>,
96    /// Element id → material colour list (#407/#913 transparent/opaque
97    /// alternation). Empty map when the caller has no material chain data.
98    pub element_material_colors: &'a FxHashMap<u32, Vec<[f32; 4]>>,
99    /// Surface textures + UV maps keyed by face-set id (#961).
100    pub texture_index: &'a FxHashMap<u32, ResolvedTextureMap>,
101    /// Site-local rotation (native `site_local` coordinate space only).
102    /// `None` for the browser — its Z-up→Y-up swap happens at the FFI
103    /// boundary, after this function.
104    pub site_local_rotation: Option<&'a Vec<f64>>,
105}
106
107/// RTC-invariant per-element fingerprint configuration (#971/#924).
108#[derive(Debug, Clone, Copy)]
109pub struct GeometryHashConfig {
110    /// Quantization grid in metres.
111    pub tolerance: f64,
112    /// World-reconstruction offset added back to local positions (the batch
113    /// RTC when a shift was applied, else zeros) so the file's RTC choice
114    /// never registers as a geometry change.
115    pub world_rtc: [f64; 3],
116}
117
118#[derive(Debug, Clone, Copy, Default)]
119pub struct MeshProductionOptions {
120    /// `Some` ⇒ compute one fingerprint per element (browser diff feature).
121    /// Type-product jobs are never hashed (diffing type-library shapes is a
122    /// separate feature decision).
123    pub geometry_hash: Option<GeometryHashConfig>,
124}
125
126/// The #957 suppress-vs-tag decision — an explicit product-requirement fork,
127/// not drift. See [`plan_type_geometry`].
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum TypeGeometryMode {
130    /// Native/export: instanced types are suppressed entirely (an export must
131    /// never duplicate geometry); orphan maps emit with geometry_class 1.
132    SuppressInstanced,
133    /// Viewer: instanced types emit too, tagged geometry_class 2, so the
134    /// Model/Types view switch can filter at render time.
135    EmitTagged,
136}
137
138/// The single home of the #957 orphan/instanced RepresentationMap decision.
139///
140/// A map referenced by an `IfcMappedItem` always draws through its occurrence
141/// — emitting it again would double-render at the MappingOrigin (the
142/// AC20/ArchiCAD duplicate-boxes regression), so referenced maps are filtered
143/// in every mode. What remains is classified by whether the type has an
144/// occurrence (`IfcRelDefinesByType`): orphans are class 1 (part of the
145/// model — nothing else renders them), instanced types are class 2 (the
146/// type-library shape) and only emitted in [`TypeGeometryMode::EmitTagged`].
147pub fn plan_type_geometry(
148    rep_map_ids: &[u32],
149    referenced_representation_maps: &FxHashSet<u32>,
150    type_is_instantiated: bool,
151    mode: TypeGeometryMode,
152) -> Vec<(u32, u8)> {
153    if mode == TypeGeometryMode::SuppressInstanced && type_is_instantiated {
154        return Vec::new();
155    }
156    let class: u8 = if type_is_instantiated { 2 } else { 1 };
157    rep_map_ids
158        .iter()
159        .filter(|rm| !referenced_representation_maps.contains(rm))
160        .map(|rm| (*rm, class))
161        .collect()
162}
163
164/// Everything one element produced.
165pub struct ProducedElementMeshes {
166    pub meshes: Vec<MeshData>,
167    /// Per-ELEMENT fingerprint, accumulated across all of the element's
168    /// meshes in the native IFC frame (pre-split, pre-site-rotation).
169    /// `None` when hashing is off, nothing was produced, or the job is a
170    /// TypeProduct.
171    pub geometry_hash: Option<u64>,
172    /// CSG diagnostics recorded while producing THIS element, attributed by
173    /// product id. The router is fully drained on return, so a warm router
174    /// reused across a batch never leaks one element's failures into the
175    /// next. Failures from a superseded strategy (a fallback re-attempting
176    /// the same cuts) are discarded — only the path that produced the
177    /// returned meshes contributes.
178    pub csg_failures: FxHashMap<u32, Vec<BoolFailure>>,
179}
180
181/// THE canonical per-element mesh producer.
182///
183/// Decoder and router are caller-supplied so each pipeline keeps its reuse
184/// policy: the native rayon loop builds a fresh seeded decoder + router per
185/// element; the browser batch path reuses one warm pair per batch. The
186/// decoder MUST have its unit-scale caches seeded
187/// (`EntityDecoder::seed_unit_scales`) — otherwise arc tessellation re-pays
188/// an O(file) IFCPROJECT scan per fresh decoder.
189pub fn produce_element_meshes(
190    job: &ElementMeshJob<'_>,
191    ctx: &MeshProductionContext<'_>,
192    opts: &MeshProductionOptions,
193    decoder: &mut EntityDecoder,
194    router: &GeometryRouter,
195) -> ProducedElementMeshes {
196    let mut hasher = match (&job.kind, opts.geometry_hash) {
197        (ElementJobKind::Product, Some(cfg)) => {
198            Some(GeometryHasher::new(cfg.tolerance, cfg.world_rtc))
199        }
200        _ => None,
201    };
202
203    let meshes = produce_inner(job, ctx, decoder, router, &mut hasher);
204
205    // Drain the router's per-element CSG diagnostics on EVERY return path so
206    // a warm (batch-reused) router starts the next element clean.
207    let csg_failures = router.take_csg_failures();
208
209    let geometry_hash = hasher.and_then(|h| if h.is_empty() { None } else { Some(h.finish()) });
210
211    ProducedElementMeshes {
212        meshes,
213        geometry_hash,
214        csg_failures,
215    }
216}
217
218fn produce_inner(
219    job: &ElementMeshJob<'_>,
220    ctx: &MeshProductionContext<'_>,
221    decoder: &mut EntityDecoder,
222    router: &GeometryRouter,
223    hasher: &mut Option<GeometryHasher>,
224) -> Vec<MeshData> {
225    // Representation gate, with the IfcAlignment exception: alignments carry
226    // their geometry on IfcAlignment*Segment children, so a null
227    // Representation attribute does not mean "nothing to render".
228    let has_representation = job.entity.get(6).is_some_and(|a| !a.is_null());
229    if !has_representation && job.ifc_type != IfcType::IfcAlignment {
230        return Vec::new();
231    }
232
233    let element_color = job
234        .element_color
235        .unwrap_or_else(|| crate::style::default_color_for_type(job.ifc_type).to_array());
236
237    if let ElementJobKind::TypeProduct { rep_maps } = &job.kind {
238        return produce_type_geometry(job, rep_maps, element_color, ctx, decoder, router);
239    }
240
241    let has_openings = ctx
242        .void_index
243        .get(&job.id)
244        .is_some_and(|openings| !openings.is_empty());
245
246    if has_openings {
247        // Voided elements: submesh-aware cut FIRST, so per-part colours
248        // survive the void subtraction (a voided window keeps frame/glass
249        // split; a voided multi-layer wall keeps its layer colours).
250        if let Ok(sub_meshes) =
251            router.process_element_with_submeshes_and_voids(job.entity, decoder, ctx.void_index)
252        {
253            if !sub_meshes.is_empty() {
254                let out = emit_sub_meshes(job, sub_meshes, element_color, ctx, decoder, hasher);
255                if !out.is_empty() {
256                    return out;
257                }
258            }
259        }
260    } else {
261        // Submesh path for ALL types: per-geometry-item colours (window glass
262        // transparency, multi-material doors) and per-item error skipping —
263        // one unsupported representation item no longer blanks the whole
264        // element (`process_element` aborts with `?`). #858 palette split
265        // happens per item inside `emit_sub_meshes`.
266        if let Ok(sub_meshes) = router.process_element_with_submeshes(job.entity, decoder) {
267            if !sub_meshes.is_empty() {
268                let out = emit_sub_meshes(job, sub_meshes, element_color, ctx, decoder, hasher);
269                if !out.is_empty() {
270                    return out;
271                }
272            }
273        }
274    }
275
276    // Fallback chain. A superseding strategy is about to re-process this
277    // element's representation and re-attempt the same (deterministic)
278    // cuts/booleans; discard the abandoned attempt's diagnostics so
279    // re-failures aren't double-counted. (The voids→plain-element
280    // mini-fallback below intentionally keeps its records: a failed/emptying
281    // cut that leaves the host uncut IS the diagnostic.)
282    let _ = router.take_csg_failures();
283
284    let mut mesh_candidate = router
285        .process_element_with_voids(job.entity, decoder, ctx.void_index)
286        .ok();
287    let needs_fallback = match mesh_candidate.as_ref() {
288        Some(mesh) => mesh.is_empty(),
289        None => true,
290    };
291    if needs_fallback {
292        mesh_candidate = router.process_element(job.entity, decoder).ok();
293    }
294
295    let Some(mut mesh) = mesh_candidate else {
296        return Vec::new();
297    };
298    if mesh.is_empty() {
299        return Vec::new();
300    }
301
302    // Multi-colour IfcIndexedColourMap → one mesh per palette group (#858),
303    // resolved by walking the element's representation for the colour-mapped
304    // face set. Only applies while the produced triangle count still matches
305    // the face set's CoordIndex (no CSG/void retopology) — the splitter
306    // guards this; otherwise the single dominant-coloured mesh below wins.
307    if !ctx.indexed_colour_full.is_empty() {
308        if let Some(full) =
309            find_indexed_colour_for_element(job.entity, ctx.indexed_colour_full, decoder)
310        {
311            let geometry_id = full.geometry_id;
312            if let Some(groups) = crate::style::split_mesh_by_indexed_colour(&mesh, full) {
313                if let Some(h) = hasher.as_mut() {
314                    h.add_mesh_with_origin(&mesh.positions, &mesh.indices, mesh.origin);
315                }
316                let mut out: Vec<MeshData> = Vec::with_capacity(groups.len());
317                for (color, mut part) in groups {
318                    if part.normals.len() != part.positions.len() {
319                        calculate_normals(&mut part);
320                    }
321                    out.push(build_mesh_data(
322                        job,
323                        part,
324                        color.to_array(),
325                        None,
326                        Some(geometry_id),
327                        0,
328                        ctx,
329                    ));
330                }
331                if !out.is_empty() {
332                    return out;
333                }
334            }
335        }
336    }
337
338    if mesh.normals.len() != mesh.positions.len() {
339        calculate_normals(&mut mesh);
340    }
341    if let Some(h) = hasher.as_mut() {
342        h.add_mesh_with_origin(&mesh.positions, &mesh.indices, mesh.origin);
343    }
344    vec![build_mesh_data(job, mesh, element_color, None, None, 0, ctx)]
345}
346
347/// Emit a sub-mesh collection: per-item colour resolution through the
348/// canonical `resolve_submesh_color` precedence (#913 §4.2), material-name
349/// inference for window/door parts, and the #858 per-item palette split.
350fn emit_sub_meshes(
351    job: &ElementMeshJob<'_>,
352    sub_meshes: SubMeshCollection,
353    element_color: [f32; 4],
354    ctx: &MeshProductionContext<'_>,
355    decoder: &mut EntityDecoder,
356    hasher: &mut Option<GeometryHasher>,
357) -> Vec<MeshData> {
358    let mut out: Vec<MeshData> = Vec::with_capacity(sub_meshes.len());
359    // Material colours for this element, used when a sub-mesh has no direct
360    // style — alternated so frame (opaque) and glazing (transparent) split
361    // across the window's parts (#913 §2.3).
362    let material_colors = ctx.element_material_colors.get(&job.id);
363    let mut mat_color_idx = 0usize;
364
365    for sub in sub_meshes.sub_meshes {
366        let mut sub_mesh = sub.mesh;
367        if sub_mesh.is_empty() {
368            continue;
369        }
370        if sub_mesh.normals.len() != sub_mesh.positions.len() {
371            calculate_normals(&mut sub_mesh);
372        }
373
374        let style = ctx.geometry_style_index.get(&sub.geometry_id);
375        // Direct style wins; else chase IfcMappedItem so mapped sub-geometry
376        // inherits its underlying style (#913 §2.7).
377        let direct_color = style.map(|s| s.color).or_else(|| {
378            find_geometry_item_color(sub.geometry_id, ctx.geometry_style_index, decoder)
379        });
380        let color = crate::style::resolve_submesh_color(
381            direct_color,
382            material_colors.map(|v| v.as_slice()),
383            &mut mat_color_idx,
384            element_color,
385        );
386        let material_name = style
387            .and_then(|s| s.material_name.as_ref())
388            .map(ToString::to_string)
389            .or_else(|| infer_opening_subpart_material_name(&job.ifc_type, color, sub.geometry_id));
390
391        if let Some(h) = hasher.as_mut() {
392            h.add_mesh_with_origin(&sub_mesh.positions, &sub_mesh.indices, sub_mesh.origin);
393        }
394
395        // #858: a face set with a per-triangle colour map splits into one
396        // mesh per palette group (guards inside the splitter: triangle count
397        // must still match, ≥2 distinct colours). Palette colours supersede
398        // the resolved style colour for the split parts.
399        if let Some(full) = ctx.indexed_colour_full.get(&sub.geometry_id) {
400            if let Some(groups) = crate::style::split_mesh_by_indexed_colour(&sub_mesh, full) {
401                for (rgba, mut part) in groups {
402                    if part.normals.len() != part.positions.len() {
403                        calculate_normals(&mut part);
404                    }
405                    out.push(build_mesh_data(
406                        job,
407                        part,
408                        rgba.to_array(),
409                        None,
410                        Some(sub.geometry_id),
411                        0,
412                        ctx,
413                    ));
414                }
415                continue;
416            }
417        }
418
419        out.push(build_mesh_data(
420            job,
421            sub_mesh,
422            color,
423            material_name,
424            Some(sub.geometry_id),
425            0,
426            ctx,
427        ));
428    }
429    out
430}
431
432/// Render a type-product's planned RepresentationMaps (#957), texture-aware
433/// (#961), each mesh tagged with its planned geometry_class.
434fn produce_type_geometry(
435    job: &ElementMeshJob<'_>,
436    rep_maps: &[(u32, u8)],
437    element_color: [f32; 4],
438    ctx: &MeshProductionContext<'_>,
439    decoder: &mut EntityDecoder,
440    router: &GeometryRouter,
441) -> Vec<MeshData> {
442    let mut out: Vec<MeshData> = Vec::new();
443    for &(rep_map_id, geometry_class) in rep_maps {
444        let Ok(rep_map) = decoder.decode_by_id(rep_map_id) else {
445            continue;
446        };
447        // One part per output mesh: each textured face set carries its own
448        // UVs + decoded image; untextured items merge into one part (#961).
449        let Ok(parts) =
450            router.process_representation_map_with_texture(&rep_map, decoder, ctx.texture_index)
451        else {
452            continue;
453        };
454        if parts.is_empty() {
455            continue;
456        }
457
458        let color =
459            resolve_color_for_representation_map(rep_map_id, ctx.geometry_style_index, decoder)
460                .unwrap_or(element_color);
461
462        for (mut mesh, uvs, texture) in parts {
463            if mesh.is_empty() {
464                continue;
465            }
466            if mesh.normals.len() != mesh.positions.len() {
467                calculate_normals(&mut mesh);
468            }
469            let mut mesh_data =
470                build_mesh_data(job, mesh, color, None, None, geometry_class, ctx);
471            if let Some(tex) = texture {
472                mesh_data = mesh_data.with_texture(
473                    uvs,
474                    MeshTextureData {
475                        rgba: tex.rgba,
476                        width: tex.width,
477                        height: tex.height,
478                        repeat_s: tex.repeat_s,
479                        repeat_t: tex.repeat_t,
480                    },
481                );
482            }
483            out.push(mesh_data);
484        }
485    }
486    out
487}
488
489/// Whether the f32-collapse degenerate-triangle backstop is disabled.
490///
491/// On by default. Set `IFC_LITE_DISABLE_DEGENERATE_BACKSTOP=1` to keep the raw
492/// (possibly fan-corrupted) triangles — an escape hatch for debugging the
493/// heuristic or measuring exactly what it removes. Read once and cached.
494fn degenerate_backstop_disabled() -> bool {
495    static DISABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
496    *DISABLED.get_or_init(|| std::env::var("IFC_LITE_DISABLE_DEGENERATE_BACKSTOP").is_ok())
497}
498
499/// Construct the final [`MeshData`]: metadata stamp, style metadata,
500/// geometry-class tag, and the optional site-local rotation. ALWAYS the last
501/// step — geometry hashing happens before this (native IFC frame).
502fn build_mesh_data(
503    job: &ElementMeshJob<'_>,
504    mut mesh: Mesh,
505    color: [f32; 4],
506    material_name: Option<String>,
507    geometry_item_id: Option<u32>,
508    geometry_class: u8,
509    ctx: &MeshProductionContext<'_>,
510) -> MeshData {
511    // Backstop for f32 vertex-storage collapse: at building-scale world
512    // coordinates an f32 mantissa can't separate sub-15µm-apart vertices, so
513    // triangles collapse into zero-area / long-thin "fan" slivers that visibly
514    // span large georeferenced models. Drop the unambiguously-degenerate ones
515    // here — the single funnel for every element MeshData. With local-frame
516    // precision on, the mesh is stored relative to `origin` (small coords) so
517    // collapse is PREVENTED upstream and this drops nothing; it stays as the
518    // defence-in-depth safety net for any element still too large for its frame.
519    if !degenerate_backstop_disabled() {
520        mesh.drop_degenerate_triangles();
521    }
522    let mesh_origin = mesh.origin;
523    let mut mesh_data = MeshData::new(
524        job.id,
525        job.ifc_type.name().to_string(),
526        mesh.positions,
527        mesh.normals,
528        mesh.indices,
529        color,
530    )
531    .with_origin(mesh_origin);
532    if let Some(meta) = job.metadata {
533        mesh_data = mesh_data
534            .with_element_metadata(
535                meta.global_id.clone(),
536                meta.name.clone(),
537                meta.presentation_layer.clone(),
538            )
539            .with_properties(meta.space_zone_properties.clone());
540    }
541    if material_name.is_some() || geometry_item_id.is_some() {
542        mesh_data = mesh_data.with_style_metadata(material_name, geometry_item_id);
543    }
544    if geometry_class != 0 {
545        mesh_data = mesh_data.with_geometry_class(geometry_class);
546    }
547    convert_mesh_to_site_local(&mut mesh_data, ctx.site_local_rotation);
548    mesh_data
549}
550
551/// Resolve a geometry item's authored colour: direct style on the item, else
552/// chase `IfcMappedItem → IfcRepresentationMap → MappedRepresentation.Items`
553/// recursively (#913 §2.7 — mapped sub-geometry inherits its underlying
554/// item's style).
555pub(crate) fn find_geometry_item_color(
556    geometry_id: u32,
557    geometry_styles: &FxHashMap<u32, GeometryStyleInfo>,
558    decoder: &mut EntityDecoder,
559) -> Option<[f32; 4]> {
560    // Direct style on this exact geometry item wins.
561    if let Some(style) = geometry_styles.get(&geometry_id) {
562        return Some(style.color);
563    }
564
565    // Otherwise, if it's a mapped item, chase the mapping to the underlying
566    // geometry and resolve there (recursing handles nested mapped items).
567    let geom = decoder.decode_by_id(geometry_id).ok()?;
568    if geom.ifc_type != IfcType::IfcMappedItem {
569        return None;
570    }
571    // IfcMappedItem.MappingSource (attr 0) → IfcRepresentationMap.
572    let mapping_source_id = geom.get_ref(0)?;
573    // IfcRepresentationMap.MappedRepresentation (attr 1) → IfcShapeRepresentation.
574    let representation_map = decoder.decode_by_id(mapping_source_id).ok()?;
575    let mapped_representation_id = representation_map.get_ref(1)?;
576    let mapped_representation = decoder.decode_by_id(mapped_representation_id).ok()?;
577    // IfcShapeRepresentation.Items (attr 3).
578    let items = get_refs_from_list(&mapped_representation, 3)?;
579    for underlying in items {
580        if let Some(color) = find_geometry_item_color(underlying, geometry_styles, decoder) {
581            return Some(color);
582        }
583    }
584    None
585}
586
587/// Resolve the authored colour for a type's `IfcRepresentationMap` (#957) by
588/// looking up its mapped geometry items in the styled-item index — the same
589/// index that colours ordinary products. `None` ⇒ caller falls back to the
590/// type's default colour.
591pub(crate) fn resolve_color_for_representation_map(
592    rep_map_id: u32,
593    geometry_style_index: &FxHashMap<u32, GeometryStyleInfo>,
594    decoder: &mut EntityDecoder,
595) -> Option<[f32; 4]> {
596    let rep_map = decoder.decode_by_id(rep_map_id).ok()?;
597    // IfcRepresentationMap.MappedRepresentation = attr 1.
598    let mapped_rep_id = rep_map.get_ref(1)?;
599    let mapped_rep = decoder.decode_by_id(mapped_rep_id).ok()?;
600    // IfcShapeRepresentation.Items = attr 3.
601    let item_ids = get_refs_from_list(&mapped_rep, 3)?;
602    for item_id in item_ids {
603        if let Some(style) = geometry_style_index.get(&item_id) {
604            return Some(style.color);
605        }
606        if let Some(color) = find_geometry_item_color(item_id, geometry_style_index, decoder) {
607            return Some(color);
608        }
609    }
610    None
611}
612
613/// Find the first representation item of `entity` that carries a full
614/// `IfcIndexedColourMap` (#858). Drives the element-level palette split on
615/// the single-mesh fallback path.
616pub(crate) fn find_indexed_colour_for_element<'a>(
617    entity: &DecodedEntity,
618    indexed_colour_full: &'a FxHashMap<u32, FullIndexedColourMap>,
619    decoder: &mut EntityDecoder,
620) -> Option<&'a FullIndexedColourMap> {
621    let pds_id = entity.get_ref(6)?;
622    let pds = decoder.decode_by_id(pds_id).ok()?;
623    let repr_ids = get_refs_from_list(&pds, 2)?;
624    for repr_id in repr_ids {
625        if let Ok(repr) = decoder.decode_by_id(repr_id) {
626            if let Some(items) = get_refs_from_list(&repr, 3) {
627                for item_id in items {
628                    if let Some(full) = indexed_colour_full.get(&item_id) {
629                        return Some(full);
630                    }
631                }
632            }
633        }
634    }
635    None
636}
637
638fn is_opening_with_subparts(ifc_type: &IfcType) -> bool {
639    matches!(ifc_type, IfcType::IfcWindow | IfcType::IfcDoor)
640}
641
642/// Synthesize a material name for window/door sub-parts that carry no
643/// authored style: transparency is a practical proxy for glazing in many BIM
644/// exports.
645pub(crate) fn infer_opening_subpart_material_name(
646    ifc_type: &IfcType,
647    color: [f32; 4],
648    geometry_id: u32,
649) -> Option<String> {
650    if !is_opening_with_subparts(ifc_type) {
651        return None;
652    }
653
654    let prefix = match ifc_type {
655        IfcType::IfcDoor => "Door",
656        _ => "Window",
657    };
658
659    if color[3] <= 0.65 {
660        return Some(format!("{}_Glass", prefix));
661    }
662
663    Some(format!("{}_Frame_{}", prefix, geometry_id))
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669
670    fn refs(ids: &[u32]) -> FxHashSet<u32> {
671        ids.iter().copied().collect()
672    }
673
674    #[test]
675    fn plan_type_geometry_orphan_type_emits_unreferenced_maps_as_class_1() {
676        for mode in [TypeGeometryMode::SuppressInstanced, TypeGeometryMode::EmitTagged] {
677            let planned = plan_type_geometry(&[10, 11, 12], &refs(&[11]), false, mode);
678            assert_eq!(
679                planned,
680                vec![(10, 1), (12, 1)],
681                "orphan type: unreferenced maps render as class 1 in {mode:?}",
682            );
683        }
684    }
685
686    #[test]
687    fn plan_type_geometry_instantiated_type_suppressed_for_export_tagged_for_viewer() {
688        let suppress = plan_type_geometry(
689            &[10, 11],
690            &refs(&[]),
691            true,
692            TypeGeometryMode::SuppressInstanced,
693        );
694        assert!(
695            suppress.is_empty(),
696            "an export must never duplicate an instanced type's geometry"
697        );
698
699        let tagged =
700            plan_type_geometry(&[10, 11], &refs(&[]), true, TypeGeometryMode::EmitTagged);
701        assert_eq!(
702            tagged,
703            vec![(10, 2), (11, 2)],
704            "the viewer renders instanced type maps tagged class 2 for the Types view"
705        );
706    }
707
708    #[test]
709    fn plan_type_geometry_referenced_maps_never_emit() {
710        let planned = plan_type_geometry(
711            &[10],
712            &refs(&[10]),
713            false,
714            TypeGeometryMode::EmitTagged,
715        );
716        assert!(
717            planned.is_empty(),
718            "a map an IfcMappedItem instantiates draws through its occurrence"
719        );
720    }
721
722    #[test]
723    fn find_geometry_item_color_follows_mapped_item() {
724        // #100 IfcMappedItem → #101 IfcRepresentationMap → #103
725        // IfcShapeRepresentation whose Items = (#110). The style lives on the
726        // underlying item #110, not on the mapped item, so a flat lookup of
727        // #100 misses it — the resolver must chase the mapping (#913 §2.7).
728        const IFC: &str = r#"ISO-10303-21;
729HEADER;
730FILE_DESCRIPTION((''),'2;1');
731FILE_NAME('m.ifc','2026-06-04T00:00:00',(''),(''),'','','');
732FILE_SCHEMA(('IFC4'));
733ENDSEC;
734DATA;
735#2=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.0E-5,$,$);
736#100=IFCMAPPEDITEM(#101,#105);
737#101=IFCREPRESENTATIONMAP(#102,#103);
738#102=IFCAXIS2PLACEMENT3D(#104,$,$);
739#103=IFCSHAPEREPRESENTATION(#2,'Body','MappedRepresentation',(#110));
740#104=IFCCARTESIANPOINT((0.,0.,0.));
741#105=IFCCARTESIANTRANSFORMATIONOPERATOR3D($,$,#104,$,$);
742ENDSEC;
743END-ISO-10303-21;
744"#;
745        let blue = [0.1, 0.2, 0.9, 1.0];
746        let mut styles: FxHashMap<u32, GeometryStyleInfo> = FxHashMap::default();
747        styles.insert(110, GeometryStyleInfo::from_color(blue));
748
749        let mut decoder = EntityDecoder::new(IFC);
750
751        // Mapped item, no direct style → inherits the underlying item's colour.
752        assert_eq!(find_geometry_item_color(100, &styles, &mut decoder), Some(blue));
753        // A direct style still wins.
754        assert_eq!(find_geometry_item_color(110, &styles, &mut decoder), Some(blue));
755        // A non-mapped, unstyled item (the representation map itself) → None.
756        assert_eq!(find_geometry_item_color(101, &styles, &mut decoder), None);
757    }
758
759    #[test]
760    fn infer_opening_material_names_glass_vs_frame() {
761        let glass =
762            infer_opening_subpart_material_name(&IfcType::IfcWindow, [0.7, 0.9, 0.5, 0.3], 42);
763        assert_eq!(glass.as_deref(), Some("Window_Glass"));
764
765        let frame =
766            infer_opening_subpart_material_name(&IfcType::IfcDoor, [0.5, 0.5, 0.5, 1.0], 7);
767        assert_eq!(frame.as_deref(), Some("Door_Frame_7"));
768
769        let none = infer_opening_subpart_material_name(&IfcType::IfcWall, [1.0; 4], 1);
770        assert!(none.is_none(), "only windows/doors get inferred part names");
771    }
772}