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, RawInstanceOccurrence};
39use ifc_lite_core::{DecodedEntity, EntityDecoder, IfcType};
40use ifc_lite_geometry::{
41    calculate_normals, compose_instance_world_row_major, orient_mesh_outward, BoolFailure,
42    GeometryHasher, GeometryRouter, Mesh, ResolvedTextureMap, 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    /// #1623 Phase 2 don't-bake output: this element's occurrences of a repeated
168    /// `IfcRepresentationMap` that skipped the per-occurrence materialize. Empty
169    /// unless the router was armed with an instancing plan
170    /// (`GeometryRouter::enable_output_instancing`); the streaming finalize resolves
171    /// each into a [`crate::InstanceRecord`] against the shared template MeshData.
172    pub instance_occurrences: Vec<RawInstanceOccurrence>,
173    /// Per-ELEMENT fingerprint, accumulated across all of the element's
174    /// meshes in the native IFC frame (pre-split, pre-site-rotation).
175    /// `None` when hashing is off, nothing was produced, or the job is a
176    /// TypeProduct.
177    pub geometry_hash: Option<u64>,
178    /// CSG diagnostics recorded while producing THIS element, attributed by
179    /// product id. The router is fully drained on return, so a warm router
180    /// reused across a batch never leaks one element's failures into the
181    /// next. Failures from a superseded strategy (a fallback re-attempting
182    /// the same cuts) are discarded — only the path that produced the
183    /// returned meshes contributes.
184    pub csg_failures: FxHashMap<u32, Vec<BoolFailure>>,
185    /// Triangles dropped by the f32-collapse degenerate-triangle backstop
186    /// (`drop_degenerate_triangles` in `build_mesh_data`) across ALL of this
187    /// element's meshes. Zero when the backstop is disabled or nothing was
188    /// degenerate. Request-local (scoped per `produce_element_meshes` call)
189    /// so concurrent passes never cross-contaminate.
190    pub degenerate_triangles_dropped: u64,
191}
192
193/// THE canonical per-element mesh producer.
194///
195/// Decoder and router are caller-supplied so each pipeline keeps its reuse
196/// policy: the native rayon loop builds a fresh seeded decoder + router per
197/// element; the browser batch path reuses one warm pair per batch. The
198/// decoder MUST have its unit-scale caches seeded
199/// (`EntityDecoder::seed_unit_scales`) — otherwise arc tessellation re-pays
200/// an O(file) IFCPROJECT scan per fresh decoder.
201pub fn produce_element_meshes(
202    job: &ElementMeshJob<'_>,
203    ctx: &MeshProductionContext<'_>,
204    opts: &MeshProductionOptions,
205    decoder: &mut EntityDecoder,
206    router: &GeometryRouter,
207) -> ProducedElementMeshes {
208    // Open a per-element CSG escalation scope (#1109). Every boolean this element
209    // issues (one per opening, plus clip cuts) accumulates into ONE deterministic
210    // budget, so a boolean-heavy element (a slab cut by 24+ openings, a Tekla
211    // member with stacked half-space clips) degrades as a UNIT — its remaining
212    // cuts bail to the #635 AABB fallback — instead of grinding the geometry
213    // stream past the 95% watchdog. The per-boolean cap alone could not see this
214    // distributed cost. Unbounded under the server/offline-export profile.
215    ifc_lite_geometry::kernel::budget::begin_element();
216
217    // Open this element's degenerate-backstop scope (same begin/drain shape as
218    // the kernel budget above): `build_mesh_data` adds to the thread-local as
219    // it drops collapsed triangles, and we drain it into the result below.
220    // Thread-local is correct on both pipelines: the native rayon loop runs
221    // one element entirely on one worker thread, and the wasm batch loop is
222    // serial.
223    DEGENERATE_DROPPED.with(|c| c.set(0));
224
225    let mut hasher = match (&job.kind, opts.geometry_hash) {
226        (ElementJobKind::Product, Some(cfg)) => {
227            Some(GeometryHasher::new(cfg.tolerance, cfg.world_rtc))
228        }
229        _ => None,
230    };
231
232    let (meshes, instance_occurrences) = produce_inner(job, ctx, decoder, router, &mut hasher);
233
234    // Drain the router's per-element CSG diagnostics on EVERY return path so
235    // a warm (batch-reused) router starts the next element clean.
236    let csg_failures = router.take_csg_failures();
237
238    let geometry_hash = hasher.and_then(|h| if h.is_empty() { None } else { Some(h.finish()) });
239
240    let degenerate_triangles_dropped = DEGENERATE_DROPPED.with(|c| c.get());
241
242    ProducedElementMeshes {
243        meshes,
244        instance_occurrences,
245        geometry_hash,
246        csg_failures,
247        degenerate_triangles_dropped,
248    }
249}
250
251thread_local! {
252    /// Per-element degenerate-backstop drop tally. Reset at the top of
253    /// `produce_element_meshes`, incremented by `build_mesh_data`, drained
254    /// into [`ProducedElementMeshes::degenerate_triangles_dropped`].
255    static DEGENERATE_DROPPED: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
256}
257
258fn produce_inner(
259    job: &ElementMeshJob<'_>,
260    ctx: &MeshProductionContext<'_>,
261    decoder: &mut EntityDecoder,
262    router: &GeometryRouter,
263    hasher: &mut Option<GeometryHasher>,
264) -> (Vec<MeshData>, Vec<RawInstanceOccurrence>) {
265    // Representation gate, with the IfcAlignment exception: alignments carry
266    // their geometry on IfcAlignment*Segment children, so a null
267    // Representation attribute does not mean "nothing to render".
268    let has_representation = job.entity.get(6).is_some_and(|a| !a.is_null());
269    if !has_representation && job.ifc_type != IfcType::IfcAlignment {
270        return (Vec::new(), Vec::new());
271    }
272
273    let element_color = job
274        .element_color
275        .unwrap_or_else(|| crate::style::default_color_for_type(job.ifc_type).to_array());
276
277    if let ElementJobKind::TypeProduct { rep_maps } = &job.kind {
278        // Type-product geometry (orphan/instanced RepresentationMaps) never rides the
279        // don't-bake path — it is view-mode-gated by geometry_class, not instanced.
280        return (
281            produce_type_geometry(job, rep_maps, element_color, ctx, decoder, router),
282            Vec::new(),
283        );
284    }
285
286    let has_openings = ctx
287        .void_index
288        .get(&job.id)
289        .is_some_and(|openings| !openings.is_empty());
290
291    // Material-layer wall: tag its per-layer slices GEOM_CLASS_LAYER_SLICE so the
292    // 2D/section cut can split the cut into per-layer fills (one sub-mesh = one
293    // layer = one colour). Since #1311 the slices are OPEN bands whose union is
294    // the wall's watertight outer skin (no coincident interface caps), and the
295    // renderer draws them DOUBLE-SIDED like all other IFC geometry — IFC winding
296    // is not reliably outward, so the previous backface-culling of these slices
297    // dropped inward-wound faces and made the wall read hollow. The tag no longer
298    // drives any culling; it is purely the per-layer-fill marker.
299    let layer_class = if router.is_material_layer_sliceable(job.id) {
300        GEOM_CLASS_LAYER_SLICE
301    } else {
302        0
303    };
304
305    if has_openings {
306        // Voided elements: submesh-aware cut FIRST, so per-part colours
307        // survive the void subtraction (a voided window keeps frame/glass
308        // split; a voided multi-layer wall keeps its layer colours).
309        if let Ok(sub_meshes) =
310            router.process_element_with_submeshes_and_voids(job.entity, decoder, ctx.void_index)
311        {
312            if !sub_meshes.is_empty() {
313                let (out, occ) =
314                    emit_sub_meshes(job, sub_meshes, element_color, ctx, decoder, hasher, layer_class);
315                if !out.is_empty() || !occ.is_empty() {
316                    return (out, occ);
317                }
318            }
319        }
320    } else {
321        // Submesh path for ALL types: per-geometry-item colours (window glass
322        // transparency, multi-material doors) and per-item error skipping —
323        // one unsupported representation item no longer blanks the whole
324        // element (`process_element` aborts with `?`). #858 palette split
325        // happens per item inside `emit_sub_meshes`.
326        if let Ok(sub_meshes) =
327            router.process_element_with_submeshes_textured(job.entity, decoder, ctx.texture_index)
328        {
329            if !sub_meshes.is_empty() {
330                let (out, occ) =
331                    emit_sub_meshes(job, sub_meshes, element_color, ctx, decoder, hasher, layer_class);
332                // #1623 Phase 2: a pure don't-bake occurrence produces NO flat mesh
333                // (only instance placeholders); treat that as success so the fallback
334                // chain below does not re-materialize the element flat.
335                if !out.is_empty() || !occ.is_empty() {
336                    return (out, occ);
337                }
338            }
339        }
340    }
341
342    // Fallback chain. A superseding strategy is about to re-process this
343    // element's representation and re-attempt the same (deterministic)
344    // cuts/booleans; discard the abandoned attempt's diagnostics so
345    // re-failures aren't double-counted. (The voids→plain-element
346    // mini-fallback below intentionally keeps its records: a failed/emptying
347    // cut that leaves the host uncut IS the diagnostic.)
348    let _ = router.take_csg_failures();
349
350    let mut mesh_candidate = router
351        .process_element_with_voids(job.entity, decoder, ctx.void_index)
352        .ok();
353    let needs_fallback = match mesh_candidate.as_ref() {
354        // An empty void-cut result normally means the cut FAILED and emptied
355        // the host, so we re-render it un-cut. But when a containing void
356        // genuinely CONSUMED the host (`host_consumed_by_void`), the empty
357        // result is correct — keep it, or the un-cut host re-appears as a
358        // spurious solid.
359        Some(mesh) => mesh.is_empty() && !router.host_consumed_by_void(job.id),
360        None => true,
361    };
362    if needs_fallback {
363        mesh_candidate = router.process_element(job.entity, decoder).ok();
364    }
365
366    let Some(mut mesh) = mesh_candidate else {
367        return (Vec::new(), Vec::new());
368    };
369    if mesh.is_empty() {
370        return (Vec::new(), Vec::new());
371    }
372
373    // Make the assembled body consistently outward-wound. A faceted brep (IFC
374    // face loops are not reliably outward) or a merged multi-item body (extrusion
375    // unioned with a boolean cut) can carry MIXED winding that corrupts signed
376    // volume and the smooth normals computed below. No-op for already-consistent
377    // bodies (every extrusion), so their index buffer + normals are untouched; a
378    // flip invalidates any baked normals, so recompute them.
379    if orient_mesh_outward(&mut mesh) {
380        calculate_normals(&mut mesh);
381    }
382
383    // Multi-colour IfcIndexedColourMap → one mesh per palette group (#858),
384    // resolved by walking the element's representation for the colour-mapped
385    // face set. Only applies while the produced triangle count still matches
386    // the face set's CoordIndex (no CSG/void retopology) — the splitter
387    // guards this; otherwise the single dominant-coloured mesh below wins.
388    if !ctx.indexed_colour_full.is_empty() {
389        if let Some(full) =
390            find_indexed_colour_for_element(job.entity, ctx.indexed_colour_full, decoder)
391        {
392            let geometry_id = full.geometry_id;
393            if let Some(groups) = crate::style::split_mesh_by_indexed_colour(&mesh, full) {
394                if let Some(h) = hasher.as_mut() {
395                    h.add_mesh_with_origin(&mesh.positions, &mesh.indices, mesh.origin);
396                }
397                let mut out: Vec<MeshData> = Vec::with_capacity(groups.len());
398                for (color, mut part) in groups {
399                    if part.normals.len() != part.positions.len() {
400                        calculate_normals(&mut part);
401                    }
402                    out.push(build_mesh_data(
403                        job,
404                        part,
405                        color.to_array(),
406                        None,
407                        Some(geometry_id),
408                        0,
409                        ctx,
410                        None,
411                    ));
412                }
413                if !out.is_empty() {
414                    return (out, Vec::new());
415                }
416            }
417        }
418    }
419
420    if mesh.normals.len() != mesh.positions.len() {
421        calculate_normals(&mut mesh);
422    }
423    if let Some(h) = hasher.as_mut() {
424        h.add_mesh_with_origin(&mesh.positions, &mesh.indices, mesh.origin);
425    }
426    (
427        vec![build_mesh_data(job, mesh, element_color, None, None, 0, ctx, None)],
428        Vec::new(),
429    )
430}
431
432/// Emit a sub-mesh collection: per-item colour resolution through the
433/// canonical `resolve_submesh_color` precedence (#913 §4.2), material-name
434/// inference for window/door parts, and the #858 per-item palette split.
435fn emit_sub_meshes(
436    job: &ElementMeshJob<'_>,
437    sub_meshes: SubMeshCollection,
438    element_color: [f32; 4],
439    ctx: &MeshProductionContext<'_>,
440    decoder: &mut EntityDecoder,
441    hasher: &mut Option<GeometryHasher>,
442    // geometry_class stamped on every emitted sub-mesh. 0 for normal occurrence
443    // geometry; GEOM_CLASS_LAYER_SLICE (3) when these are the per-layer slices of
444    // a material-layer wall — a section-only detail the 3D renderer skips (the
445    // wall renders as one solid) but the 2D/section cut consumes.
446    slice_class: u8,
447) -> (Vec<MeshData>, Vec<RawInstanceOccurrence>) {
448    let mut out: Vec<MeshData> = Vec::with_capacity(sub_meshes.len());
449    let mut occurrences: Vec<RawInstanceOccurrence> = Vec::new();
450    // Material colours for this element, used when a sub-mesh has no direct
451    // style — alternated so frame (opaque) and glazing (transparent) split
452    // across the window's parts (#913 §2.3).
453    let material_colors = ctx.element_material_colors.get(&job.id);
454    let mut mat_color_idx = 0usize;
455
456    for sub in sub_meshes.sub_meshes {
457        let mut sub_mesh = sub.mesh;
458        if sub_mesh.is_empty() {
459            // #1623 Phase 2 don't-bake: an EMPTY sub-mesh carrying instanceable
460            // InstanceMeta is a non-template occurrence of a shared template. Convert
461            // it to a RawInstanceOccurrence (resolving its colour EXACTLY as a
462            // materialized sub-mesh would, keyed on the same nested-solid geometry_id)
463            // instead of dropping it. `transform` was folded into `im.transform` by
464            // `apply_submesh_placement`; we compose the full pre-RTC world transform
465            // here and let the streaming finalize derive the template-relative mat4.
466            if let Some(im) = sub_mesh.instance_meta.as_ref().filter(|im| im.instanceable) {
467                let style = ctx.geometry_style_index.get(&sub.geometry_id);
468                let direct_color = style.map(|s| s.color).or_else(|| {
469                    find_geometry_item_color(sub.geometry_id, ctx.geometry_style_index, decoder)
470                });
471                let color = crate::style::resolve_submesh_color(
472                    direct_color,
473                    material_colors.map(|v| v.as_slice()),
474                    &mut mat_color_idx,
475                    element_color,
476                );
477                occurrences.push(RawInstanceOccurrence {
478                    express_id: job.id,
479                    ifc_type: job.ifc_type.name().to_string(),
480                    global_id: job.metadata.and_then(|m| m.global_id.clone()),
481                    name: job.metadata.and_then(|m| m.name.clone()),
482                    presentation_layer: job.metadata.and_then(|m| m.presentation_layer.clone()),
483                    color,
484                    rep_identity: im.rep_identity,
485                    world_transform: compose_instance_world_row_major(im),
486                });
487            }
488            continue;
489        }
490        // Consistently outward-wind each sub-body (see the single-mesh path); a
491        // flip invalidates baked normals, so recompute on flip or when absent.
492        if orient_mesh_outward(&mut sub_mesh) || sub_mesh.normals.len() != sub_mesh.positions.len() {
493            calculate_normals(&mut sub_mesh);
494        }
495
496        let style = ctx.geometry_style_index.get(&sub.geometry_id);
497        // Direct style wins; else chase IfcMappedItem so mapped sub-geometry
498        // inherits its underlying style (#913 §2.7).
499        let direct_color = style.map(|s| s.color).or_else(|| {
500            find_geometry_item_color(sub.geometry_id, ctx.geometry_style_index, decoder)
501        });
502        let color = crate::style::resolve_submesh_color(
503            direct_color,
504            material_colors.map(|v| v.as_slice()),
505            &mut mat_color_idx,
506            element_color,
507        );
508        let material_name = style
509            .and_then(|s| s.material_name.as_ref())
510            .map(ToString::to_string)
511            .or_else(|| infer_opening_subpart_material_name(&job.ifc_type, color, sub.geometry_id));
512
513        if let Some(h) = hasher.as_mut() {
514            h.add_mesh_with_origin(&sub_mesh.positions, &sub_mesh.indices, sub_mesh.origin);
515        }
516
517        // Textured face set (#1781): thread the per-vertex UVs through the
518        // weld (kept 1:1 with positions, seams stay split) and attach the
519        // texture, mirroring the type-geometry path (#961). The length guard
520        // drops the texture instead of sampling garbage if any upstream step
521        // rebuilt vertices without maintaining the UV channel.
522        if let (Some(uvs), Some(texture)) = (sub.uvs, sub.texture.as_ref()) {
523            if uvs.len() / 2 == sub_mesh.positions.len() / 3 {
524                let mut mesh_data = build_mesh_data(
525                    job,
526                    sub_mesh,
527                    color,
528                    material_name,
529                    Some(sub.geometry_id),
530                    slice_class,
531                    ctx,
532                    Some(uvs),
533                );
534                mesh_data.texture = Some(MeshTextureData::from_attachment(texture));
535                out.push(mesh_data);
536                continue;
537            }
538        }
539
540        // #858: a face set with a per-triangle colour map splits into one
541        // mesh per palette group (guards inside the splitter: triangle count
542        // must still match, ≥2 distinct colours). Palette colours supersede
543        // the resolved style colour for the split parts.
544        if let Some(full) = ctx.indexed_colour_full.get(&sub.geometry_id) {
545            if let Some(groups) = crate::style::split_mesh_by_indexed_colour(&sub_mesh, full) {
546                for (rgba, mut part) in groups {
547                    if part.normals.len() != part.positions.len() {
548                        calculate_normals(&mut part);
549                    }
550                    out.push(build_mesh_data(
551                        job,
552                        part,
553                        rgba.to_array(),
554                        None,
555                        Some(sub.geometry_id),
556                        slice_class,
557                        ctx,
558                        None,
559                    ));
560                }
561                continue;
562            }
563        }
564
565        out.push(build_mesh_data(
566            job,
567            sub_mesh,
568            color,
569            material_name,
570            Some(sub.geometry_id),
571            slice_class,
572            ctx,
573            None,
574        ));
575    }
576    (out, occurrences)
577}
578
579/// geometry_class for the per-layer slices of a material-layer wall. The wall's
580/// slices have verified outward winding, so the 3D renderer draws THIS class
581/// BACKFACE-CULLED — the build-up shows on the faces/edges but the interior
582/// coincident caps never rasterise, so the thin stacked solids don't z-fight
583/// into a hollow shell. The 2D/section cut consumes the same class (never
584/// culled) for its per-layer fills.
585pub const GEOM_CLASS_LAYER_SLICE: u8 = 3;
586
587/// Render a type-product's planned RepresentationMaps (#957), texture-aware
588/// (#961), each mesh tagged with its planned geometry_class.
589fn produce_type_geometry(
590    job: &ElementMeshJob<'_>,
591    rep_maps: &[(u32, u8)],
592    element_color: [f32; 4],
593    ctx: &MeshProductionContext<'_>,
594    decoder: &mut EntityDecoder,
595    router: &GeometryRouter,
596) -> Vec<MeshData> {
597    let mut out: Vec<MeshData> = Vec::new();
598    for &(rep_map_id, geometry_class) in rep_maps {
599        let Ok(rep_map) = decoder.decode_by_id(rep_map_id) else {
600            continue;
601        };
602        // One part per output mesh: each textured face set carries its own
603        // UVs + decoded image; untextured items merge into one part (#961).
604        let Ok(parts) =
605            router.process_representation_map_with_texture(&rep_map, decoder, ctx.texture_index)
606        else {
607            continue;
608        };
609        if parts.is_empty() {
610            continue;
611        }
612
613        let color =
614            resolve_color_for_representation_map(rep_map_id, ctx.geometry_style_index, decoder)
615                .unwrap_or(element_color);
616
617        for (mut mesh, uvs, texture) in parts {
618            if mesh.is_empty() {
619                continue;
620            }
621            if mesh.normals.len() != mesh.positions.len() {
622                calculate_normals(&mut mesh);
623            }
624            // Thread the per-vertex UVs through `build_mesh_data` so the source
625            // weld remaps them WITH the deduped positions (and keeps texture
626            // seams split). Only textured parts carry UVs; untextured parts pass
627            // `None` and get the full position+normal weld.
628            let part_uvs = if texture.is_some() { Some(uvs) } else { None };
629            let mut mesh_data =
630                build_mesh_data(job, mesh, color, None, None, geometry_class, ctx, part_uvs);
631            if let Some(tex) = texture {
632                // UVs were already welded onto `mesh_data`; attach only the
633                // texture (decoded image or #1781 external reference) here.
634                mesh_data.texture = Some(MeshTextureData::from_attachment(&tex));
635            }
636            out.push(mesh_data);
637        }
638    }
639    out
640}
641
642/// Whether the f32-collapse degenerate-triangle backstop is disabled.
643///
644/// On by default. Set `IFC_LITE_DISABLE_DEGENERATE_BACKSTOP=1` to keep the raw
645/// (possibly fan-corrupted) triangles — an escape hatch for debugging the
646/// heuristic or measuring exactly what it removes. Read once and cached.
647fn degenerate_backstop_disabled() -> bool {
648    static DISABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
649    *DISABLED.get_or_init(|| std::env::var("IFC_LITE_DISABLE_DEGENERATE_BACKSTOP").is_ok())
650}
651
652/// Construct the final [`MeshData`]: metadata stamp, style metadata,
653/// geometry-class tag, and the optional site-local rotation. ALWAYS the last
654/// step — geometry hashing happens before this (native IFC frame).
655#[allow(clippy::too_many_arguments)] // distinct per-mesh funnel inputs
656fn build_mesh_data(
657    job: &ElementMeshJob<'_>,
658    mut mesh: Mesh,
659    color: [f32; 4],
660    material_name: Option<String>,
661    geometry_item_id: Option<u32>,
662    geometry_class: u8,
663    ctx: &MeshProductionContext<'_>,
664    // Per-vertex texture coordinates (2 per vertex, 1:1 with `mesh.positions`),
665    // present only for textured type geometry (#961). Threaded through the weld
666    // so the UVs are remapped WITH the deduped positions and stay aligned; a UV
667    // difference also keeps a texture seam's coincident corners split.
668    uvs: Option<Vec<f32>>,
669) -> MeshData {
670    // Backstop for f32 vertex-storage collapse: at building-scale world
671    // coordinates an f32 mantissa can't separate sub-15µm-apart vertices, so
672    // triangles collapse into zero-area / long-thin "fan" slivers that visibly
673    // span large georeferenced models. Drop the unambiguously-degenerate ones
674    // here — the single funnel for every element MeshData. With local-frame
675    // precision on, the mesh is stored relative to `origin` (small coords) so
676    // collapse is PREVENTED upstream and this drops nothing; it stays as the
677    // defence-in-depth safety net for any element still too large for its frame.
678    if !degenerate_backstop_disabled() {
679        let indices_before = mesh.indices.len();
680        mesh.drop_degenerate_triangles();
681        let dropped = ((indices_before - mesh.indices.len()) / 3) as u64;
682        if dropped > 0 {
683            // Diagnostic tally only — the drop itself is unchanged. Drained
684            // per element by `produce_element_meshes` (see DEGENERATE_DROPPED).
685            DEGENERATE_DROPPED.with(|c| c.set(c.get() + dropped));
686        }
687    }
688    // Source vertex weld (see `mesh_weld::weld_indexed`): the faceted-brep
689    // mesher emits per-`IfcFace` geometry duplicating every shared corner once
690    // per incident face (~3-6x). Collapse coincident vertices (identical f32
691    // position + quantized normal + quantized UV) at this single per-element
692    // funnel — the normal/UV keys keep creases and texture seams split (flat
693    // shading, no torn textures), and UVs are remapped WITH the positions.
694    // `None` = nothing merged (already-welded swept solids): keep originals, no
695    // realloc; triangles, winding, and AABB unchanged either way.
696    let welded_uvs = match ifc_lite_geometry::mesh_weld::weld_indexed(
697        &mesh.positions,
698        &mesh.normals,
699        uvs.as_deref(),
700        &mesh.indices,
701    ) {
702        Some((wp, wn, wuv, wi)) => {
703            mesh.positions = wp;
704            mesh.normals = wn;
705            mesh.indices = wi;
706            wuv
707        }
708        None => uvs,
709    };
710    let mesh_origin = mesh.origin;
711    // Instancing: capture before the fields are moved into MeshData. A site-local
712    // rotation (below) re-transforms positions/origin and would invalidate the
713    // captured transform, so drop instancing when one is active (rare; conservative).
714    let instance = if ctx.site_local_rotation.is_none() {
715        mesh.instance_meta.take()
716    } else {
717        None
718    };
719    // Local bounds/placement transform (issue #1474): same caveat as instancing
720    // above — a site-local rotation re-transforms positions and would invalidate
721    // the captured placement, so drop both when one is active.
722    let (local_bounds, local_to_world) = if ctx.site_local_rotation.is_none() {
723        (mesh.local_bounds, mesh.local_to_world)
724    } else {
725        (None, None)
726    };
727    let mut mesh_data = MeshData::new(
728        job.id,
729        job.ifc_type.name().to_string(),
730        mesh.positions,
731        mesh.normals,
732        mesh.indices,
733        color,
734    )
735    .with_origin(mesh_origin)
736    .with_instance(instance)
737    .with_local_bounds(local_bounds)
738    .with_local_to_world(local_to_world);
739    if let Some(meta) = job.metadata {
740        mesh_data = mesh_data
741            .with_element_metadata(
742                meta.global_id.clone(),
743                meta.name.clone(),
744                meta.presentation_layer.clone(),
745            )
746            .with_properties(meta.space_zone_properties.clone());
747    }
748    if material_name.is_some() || geometry_item_id.is_some() {
749        mesh_data = mesh_data.with_style_metadata(material_name, geometry_item_id);
750    }
751    if geometry_class != 0 {
752        mesh_data = mesh_data.with_geometry_class(geometry_class);
753    }
754    // Attach the welded UVs (kept 1:1 with the welded positions by the weld).
755    // The texture IMAGE is attached by the caller; here we only carry the
756    // per-vertex coordinates through the funnel so they can't desync.
757    mesh_data.uvs = welded_uvs;
758    convert_mesh_to_site_local(&mut mesh_data, ctx.site_local_rotation);
759    mesh_data
760}
761
762/// Resolve a geometry item's authored colour: direct style on the item, else
763/// chase `IfcMappedItem → IfcRepresentationMap → MappedRepresentation.Items`
764/// recursively (#913 §2.7 — mapped sub-geometry inherits its underlying
765/// item's style).
766pub(crate) fn find_geometry_item_color(
767    geometry_id: u32,
768    geometry_styles: &FxHashMap<u32, GeometryStyleInfo>,
769    decoder: &mut EntityDecoder,
770) -> Option<[f32; 4]> {
771    // Direct style on this exact geometry item wins.
772    if let Some(style) = geometry_styles.get(&geometry_id) {
773        return Some(style.color);
774    }
775
776    // Otherwise, if it's a mapped item, chase the mapping to the underlying
777    // geometry and resolve there (recursing handles nested mapped items).
778    let geom = decoder.decode_by_id(geometry_id).ok()?;
779    if geom.ifc_type != IfcType::IfcMappedItem {
780        return None;
781    }
782    // IfcMappedItem.MappingSource (attr 0) → IfcRepresentationMap.
783    let mapping_source_id = geom.get_ref(0)?;
784    // IfcRepresentationMap.MappedRepresentation (attr 1) → IfcShapeRepresentation.
785    let representation_map = decoder.decode_by_id(mapping_source_id).ok()?;
786    let mapped_representation_id = representation_map.get_ref(1)?;
787    let mapped_representation = decoder.decode_by_id(mapped_representation_id).ok()?;
788    // IfcShapeRepresentation.Items (attr 3).
789    let items = get_refs_from_list(&mapped_representation, 3)?;
790    for underlying in items {
791        if let Some(color) = find_geometry_item_color(underlying, geometry_styles, decoder) {
792            return Some(color);
793        }
794    }
795    None
796}
797
798/// Resolve the authored colour for a type's `IfcRepresentationMap` (#957) by
799/// looking up its mapped geometry items in the styled-item index — the same
800/// index that colours ordinary products. `None` ⇒ caller falls back to the
801/// type's default colour.
802pub(crate) fn resolve_color_for_representation_map(
803    rep_map_id: u32,
804    geometry_style_index: &FxHashMap<u32, GeometryStyleInfo>,
805    decoder: &mut EntityDecoder,
806) -> Option<[f32; 4]> {
807    let rep_map = decoder.decode_by_id(rep_map_id).ok()?;
808    // IfcRepresentationMap.MappedRepresentation = attr 1.
809    let mapped_rep_id = rep_map.get_ref(1)?;
810    let mapped_rep = decoder.decode_by_id(mapped_rep_id).ok()?;
811    // IfcShapeRepresentation.Items = attr 3.
812    let item_ids = get_refs_from_list(&mapped_rep, 3)?;
813    for item_id in item_ids {
814        if let Some(style) = geometry_style_index.get(&item_id) {
815            return Some(style.color);
816        }
817        if let Some(color) = find_geometry_item_color(item_id, geometry_style_index, decoder) {
818            return Some(color);
819        }
820    }
821    None
822}
823
824/// Find the first representation item of `entity` that carries a full
825/// `IfcIndexedColourMap` (#858). Drives the element-level palette split on
826/// the single-mesh fallback path.
827pub(crate) fn find_indexed_colour_for_element<'a>(
828    entity: &DecodedEntity,
829    indexed_colour_full: &'a FxHashMap<u32, FullIndexedColourMap>,
830    decoder: &mut EntityDecoder,
831) -> Option<&'a FullIndexedColourMap> {
832    let pds_id = entity.get_ref(6)?;
833    let pds = decoder.decode_by_id(pds_id).ok()?;
834    let repr_ids = get_refs_from_list(&pds, 2)?;
835    for repr_id in repr_ids {
836        if let Ok(repr) = decoder.decode_by_id(repr_id) {
837            if let Some(items) = get_refs_from_list(&repr, 3) {
838                for item_id in items {
839                    if let Some(full) = indexed_colour_full.get(&item_id) {
840                        return Some(full);
841                    }
842                }
843            }
844        }
845    }
846    None
847}
848
849fn is_opening_with_subparts(ifc_type: &IfcType) -> bool {
850    matches!(ifc_type, IfcType::IfcWindow | IfcType::IfcDoor)
851}
852
853/// Synthesize a material name for window/door sub-parts that carry no
854/// authored style: transparency is a practical proxy for glazing in many BIM
855/// exports.
856pub(crate) fn infer_opening_subpart_material_name(
857    ifc_type: &IfcType,
858    color: [f32; 4],
859    geometry_id: u32,
860) -> Option<String> {
861    if !is_opening_with_subparts(ifc_type) {
862        return None;
863    }
864
865    let prefix = match ifc_type {
866        IfcType::IfcDoor => "Door",
867        _ => "Window",
868    };
869
870    if color[3] <= 0.65 {
871        return Some(format!("{}_Glass", prefix));
872    }
873
874    Some(format!("{}_Frame_{}", prefix, geometry_id))
875}
876
877#[cfg(test)]
878#[path = "element_tests.rs"]
879mod tests;