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_verdict, 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/// The f32-collapse degenerate backstop, its per-element tally, and the reason
50/// that tally now gates the closure verdict. A CHILD module: it exists only to
51/// serve this file's produce/emit cycle.
52#[path = "element_degenerate.rs"]
53mod degenerate;
54
55/// Element-level metadata stamped on every produced [`MeshData`]. The native
56/// pipeline resolves these during its metadata phase; the browser passes
57/// `None` (its viewer gets metadata from the parser worker instead).
58#[derive(Debug, Clone, Default)]
59pub struct ElementMeshMetadata {
60    pub global_id: Option<String>,
61    pub name: Option<String>,
62    pub presentation_layer: Option<String>,
63    pub space_zone_properties: Option<BTreeMap<String, String>>,
64}
65
66/// What the job renders.
67#[derive(Debug, Clone)]
68pub enum ElementJobKind {
69    /// Ordinary product occurrence — walk its IfcProductDefinitionShape.
70    Product,
71    /// #957 type geometry: render these RepresentationMaps directly (baking
72    /// their MappingOrigin), each pre-tagged with its geometry_class
73    /// (1 = orphan, 2 = instanced). Produce the list with
74    /// [`plan_type_geometry`] — callers must not hand-roll the filter.
75    TypeProduct { rep_maps: Vec<(u32, u8)> },
76}
77
78/// One unit of mesh production.
79pub struct ElementMeshJob<'a> {
80    pub id: u32,
81    pub ifc_type: IfcType,
82    /// The decoded product (or type-product) entity. Callers decode it —
83    /// they own skip-set checks and decode-failure policy.
84    pub entity: &'a DecodedEntity,
85    pub kind: ElementJobKind,
86    /// Caller-resolved element fallback colour (direct style > material
87    /// chain > type default). `None` ⇒ `default_color_for_type`.
88    pub element_color: Option<[f32; 4]>,
89    pub metadata: Option<&'a ElementMeshMetadata>,
90}
91
92/// Read-only shared state for one production run. Every field is a borrow of
93/// `Sync` data, so `&MeshProductionContext` can be captured by a rayon
94/// closure (native) or used serially (wasm).
95pub struct MeshProductionContext<'a> {
96    /// Host element id → opening ids (post void-propagation / opening filter).
97    pub void_index: &'a FxHashMap<u32, Vec<u32>>,
98    /// Geometry item id → resolved style (styled-item index).
99    pub geometry_style_index: &'a FxHashMap<u32, GeometryStyleInfo>,
100    /// Geometry item id → full per-triangle palette (#858).
101    pub indexed_colour_full: &'a FxHashMap<u32, FullIndexedColourMap>,
102    /// Element id → material colour list (#407/#913 transparent/opaque
103    /// alternation). Empty map when the caller has no material chain data.
104    pub element_material_colors: &'a FxHashMap<u32, Vec<[f32; 4]>>,
105    /// Surface textures + UV maps keyed by face-set id (#961).
106    pub texture_index: &'a FxHashMap<u32, ResolvedTextureMap>,
107    /// Site-local rotation (native `site_local` coordinate space only).
108    /// `None` for the browser — its Z-up→Y-up swap happens at the FFI
109    /// boundary, after this function.
110    pub site_local_rotation: Option<&'a Vec<f64>>,
111}
112
113/// RTC-invariant per-element fingerprint configuration (#971/#924).
114#[derive(Debug, Clone, Copy)]
115pub struct GeometryHashConfig {
116    /// Quantization grid in metres.
117    pub tolerance: f64,
118    /// World-reconstruction offset added back to local positions (the batch
119    /// RTC when a shift was applied, else zeros) so the file's RTC choice
120    /// never registers as a geometry change.
121    pub world_rtc: [f64; 3],
122}
123
124#[derive(Debug, Clone, Copy, Default)]
125pub struct MeshProductionOptions {
126    /// `Some` ⇒ compute one fingerprint per element (browser diff feature).
127    /// Type-product jobs are never hashed (diffing type-library shapes is a
128    /// separate feature decision).
129    pub geometry_hash: Option<GeometryHashConfig>,
130}
131
132/// The #957 suppress-vs-tag decision — an explicit product-requirement fork,
133/// not drift. See [`plan_type_geometry`].
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum TypeGeometryMode {
136    /// Native/export: instanced types are suppressed entirely (an export must
137    /// never duplicate geometry); orphan maps emit with geometry_class 1.
138    SuppressInstanced,
139    /// Viewer: instanced types emit too, tagged geometry_class 2, so the
140    /// Model/Types view switch can filter at render time.
141    EmitTagged,
142}
143
144/// The single home of the #957 orphan/instanced RepresentationMap decision.
145///
146/// A map referenced by an `IfcMappedItem` always draws through its occurrence
147/// — emitting it again would double-render at the MappingOrigin (the
148/// AC20/ArchiCAD duplicate-boxes regression), so referenced maps are filtered
149/// in every mode. What remains is classified by whether the type has an
150/// occurrence (`IfcRelDefinesByType`): orphans are class 1 (part of the
151/// model — nothing else renders them), instanced types are class 2 (the
152/// type-library shape) and only emitted in [`TypeGeometryMode::EmitTagged`].
153pub fn plan_type_geometry(
154    rep_map_ids: &[u32],
155    referenced_representation_maps: &FxHashSet<u32>,
156    type_is_instantiated: bool,
157    mode: TypeGeometryMode,
158) -> Vec<(u32, u8)> {
159    if mode == TypeGeometryMode::SuppressInstanced && type_is_instantiated {
160        return Vec::new();
161    }
162    let class: u8 = if type_is_instantiated { 2 } else { 1 };
163    rep_map_ids
164        .iter()
165        .filter(|rm| !referenced_representation_maps.contains(rm))
166        .map(|rm| (*rm, class))
167        .collect()
168}
169
170/// Everything one element produced.
171pub struct ProducedElementMeshes {
172    pub meshes: Vec<MeshData>,
173    /// #1623 Phase 2 don't-bake output: this element's occurrences of a repeated
174    /// `IfcRepresentationMap` that skipped the per-occurrence materialize. Empty
175    /// unless the router was armed with an instancing plan
176    /// (`GeometryRouter::enable_output_instancing`); the streaming finalize resolves
177    /// each into a [`crate::InstanceRecord`] against the shared template MeshData.
178    pub instance_occurrences: Vec<RawInstanceOccurrence>,
179    /// Per-ELEMENT fingerprint, accumulated across all of the element's
180    /// meshes in the native IFC frame (pre-split, pre-site-rotation).
181    /// `None` when hashing is off, nothing was produced, or the job is a
182    /// TypeProduct.
183    pub geometry_hash: Option<u64>,
184    /// The same pass's world-space AABB, `[minx, miny, minz, maxx, maxy, maxz]`
185    /// in unquantized `f64` world coordinates (the file's RTC folded back in),
186    /// over every triangle corner the hasher saw. `Some` exactly when
187    /// [`Self::geometry_hash`] is `Some`, so the two stay index-parallel at the
188    /// FFI boundary.
189    ///
190    /// Why the diff engine needs it: the hash conflates moved / reshaped /
191    /// re-tessellated into one "different" bit. The box separates them — same
192    /// extent at a new centre is a MOVE, a different extent is a reshape, an
193    /// identical box with a different hash is retriangulation.
194    pub geometry_aabb: Option<[f64; 6]>,
195    /// The element's enclosed volume in m³ from the SAME pass — `Some` ONLY
196    /// when the produced geometry was provably a single closed orientable
197    /// solid, `None` otherwise (#1891). `None` is the common case for
198    /// material-layered walls, open `SurfaceModel` geometry, and any element
199    /// assembled from more than one representation item.
200    ///
201    /// Read `ifc_lite_geometry::GeometryHasher::volume` before widening any
202    /// clause of that gate: the alternative is not a slightly-off volume, it is
203    /// a confidently wrong one with nothing about it that looks wrong.
204    pub geometry_volume: Option<f64>,
205    /// The folded per-segment topology verdict behind [`Self::geometry_volume`]
206    /// — which clause held and which refused. `Some` exactly when
207    /// [`Self::geometry_hash`] is. A model checker wants it: "open shell" and
208    /// "multi-item assembly" are different findings with different fixes.
209    pub geometry_closure: Option<ifc_lite_geometry::GeometryClosure>,
210    /// CSG diagnostics recorded while producing THIS element, attributed by
211    /// product id. The router is fully drained on return, so a warm router
212    /// reused across a batch never leaks one element's failures into the
213    /// next. Failures from a superseded strategy (a fallback re-attempting
214    /// the same cuts) are discarded — only the path that produced the
215    /// returned meshes contributes.
216    pub csg_failures: FxHashMap<u32, Vec<BoolFailure>>,
217    /// Triangles dropped by the f32-collapse degenerate-triangle backstop
218    /// (see the `degenerate` child module) across ALL of this element's meshes.
219    /// Zero when the backstop is disabled or nothing was degenerate.
220    /// Request-local (scoped per `produce_element_meshes` call) so concurrent
221    /// passes never cross-contaminate. Non-zero also RETRACTS
222    /// [`Self::geometry_closure`] and [`Self::geometry_volume`] — the drop
223    /// happens after the verdict was taken and can open a certified shell.
224    pub degenerate_triangles_dropped: u64,
225}
226
227/// THE canonical per-element mesh producer.
228///
229/// Decoder and router are caller-supplied so each pipeline keeps its reuse
230/// policy: the native rayon loop builds a fresh seeded decoder + router per
231/// element; the browser batch path reuses one warm pair per batch. The
232/// decoder MUST have its unit-scale caches seeded
233/// (`EntityDecoder::seed_unit_scales`) — otherwise arc tessellation re-pays
234/// an O(file) IFCPROJECT scan per fresh decoder.
235pub fn produce_element_meshes(
236    job: &ElementMeshJob<'_>,
237    ctx: &MeshProductionContext<'_>,
238    opts: &MeshProductionOptions,
239    decoder: &mut EntityDecoder,
240    router: &GeometryRouter,
241) -> ProducedElementMeshes {
242    // Open a per-element CSG escalation scope (#1109). Every boolean this element
243    // issues (one per opening, plus clip cuts) accumulates into ONE deterministic
244    // budget, so a boolean-heavy element (a slab cut by 24+ openings, a Tekla
245    // member with stacked half-space clips) degrades as a UNIT — its remaining
246    // cuts bail to the #635 AABB fallback — instead of grinding the geometry
247    // stream past the 95% watchdog. The per-boolean cap alone could not see this
248    // distributed cost. Unbounded under the server/offline-export profile.
249    ifc_lite_geometry::kernel::budget::begin_element();
250
251    // Open this element's degenerate-backstop scope (same begin/drain shape as
252    // the kernel budget above); see the `degenerate` child module.
253    degenerate::begin_element();
254
255    let mut hasher = match (&job.kind, opts.geometry_hash) {
256        (ElementJobKind::Product, Some(cfg)) => {
257            Some(GeometryHasher::new(cfg.tolerance, cfg.world_rtc))
258        }
259        _ => None,
260    };
261
262    let (meshes, instance_occurrences) = produce_inner(job, ctx, decoder, router, &mut hasher);
263
264    // Drain the router's per-element CSG diagnostics on EVERY return path so
265    // a warm (batch-reused) router starts the next element clean.
266    let csg_failures = router.take_csg_failures();
267
268    // A hash with NO box is reachable and deliberately KEPT (a NaN axis hashes
269    // but never accumulates); `push_geometry_hash` reserves NaN slots so the FFI
270    // arrays still cannot misalign. Box-without-hash is impossible. VOLUME may
271    // likewise be `None` within an emitted entry (landing as NaN) — the normal
272    // answer for most elements. See `world_aabb` / `GeometryHasher::volume`.
273    let degenerate_triangles_dropped = degenerate::dropped_this_element();
274
275    // The verdict was taken where the orienter runs; `build_mesh_data` then ran
276    // the degenerate backstop over the same triangles, and a dropped triangle
277    // opens every neighbour along its three edges. Retract before reading, so
278    // what ships describes the mesh actually returned (see
279    // `retract_closure_if_mesh_edited`).
280    let (geometry_hash, geometry_aabb, geometry_volume, geometry_closure) = match hasher {
281        Some(mut h) if !h.is_empty() => {
282            h.retract_closure_if_mesh_edited(degenerate_triangles_dropped);
283            (Some(h.finish()), h.world_aabb(), h.volume(), Some(h.closure()))
284        }
285        _ => (None, None, None, None),
286    };
287
288    ProducedElementMeshes {
289        meshes,
290        instance_occurrences,
291        geometry_hash,
292        geometry_aabb,
293        geometry_volume,
294        geometry_closure,
295        csg_failures,
296        degenerate_triangles_dropped,
297    }
298}
299
300fn produce_inner(
301    job: &ElementMeshJob<'_>,
302    ctx: &MeshProductionContext<'_>,
303    decoder: &mut EntityDecoder,
304    router: &GeometryRouter,
305    hasher: &mut Option<GeometryHasher>,
306) -> (Vec<MeshData>, Vec<RawInstanceOccurrence>) {
307    // Representation gate, with the IfcAlignment exception: alignments carry
308    // their geometry on IfcAlignment*Segment children, so a null
309    // Representation attribute does not mean "nothing to render".
310    let has_representation = job.entity.get(6).is_some_and(|a| !a.is_null());
311    if !has_representation && job.ifc_type != IfcType::IfcAlignment {
312        return (Vec::new(), Vec::new());
313    }
314
315    let element_color = job
316        .element_color
317        .unwrap_or_else(|| crate::style::default_color_for_type(job.ifc_type).to_array());
318
319    if let ElementJobKind::TypeProduct { rep_maps } = &job.kind {
320        // Type-product geometry (orphan/instanced RepresentationMaps) never rides the
321        // don't-bake path — it is view-mode-gated by geometry_class, not instanced.
322        return (
323            produce_type_geometry(job, rep_maps, element_color, ctx, decoder, router),
324            Vec::new(),
325        );
326    }
327
328    let has_openings = ctx
329        .void_index
330        .get(&job.id)
331        .is_some_and(|openings| !openings.is_empty());
332
333    // Material-layer wall: tag its per-layer slices GEOM_CLASS_LAYER_SLICE so the
334    // 2D/section cut can split the cut into per-layer fills (one sub-mesh = one
335    // layer = one colour). Since #1311 the slices are OPEN bands whose union is
336    // the wall's watertight outer skin (no coincident interface caps), and the
337    // renderer draws them DOUBLE-SIDED like all other IFC geometry — IFC winding
338    // is not reliably outward, so the previous backface-culling of these slices
339    // dropped inward-wound faces and made the wall read hollow. The tag no longer
340    // drives any culling; it is purely the per-layer-fill marker.
341    let layer_class = if router.is_material_layer_sliceable(job.id) {
342        GEOM_CLASS_LAYER_SLICE
343    } else {
344        0
345    };
346
347    if has_openings {
348        // Voided elements: submesh-aware cut FIRST, so per-part colours
349        // survive the void subtraction (a voided window keeps frame/glass
350        // split; a voided multi-layer wall keeps its layer colours).
351        if let Ok(sub_meshes) =
352            router.process_element_with_submeshes_and_voids(job.entity, decoder, ctx.void_index)
353        {
354            if !sub_meshes.is_empty() {
355                let (out, occ) =
356                    emit_sub_meshes(job, sub_meshes, element_color, ctx, decoder, hasher, layer_class);
357                if !out.is_empty() || !occ.is_empty() {
358                    return (out, occ);
359                }
360            }
361        }
362    } else {
363        // Submesh path for ALL types: per-geometry-item colours (window glass
364        // transparency, multi-material doors) and per-item error skipping —
365        // one unsupported representation item no longer blanks the whole
366        // element (`process_element` aborts with `?`). #858 palette split
367        // happens per item inside `emit_sub_meshes`.
368        if let Ok(sub_meshes) =
369            router.process_element_with_submeshes_textured(job.entity, decoder, ctx.texture_index)
370        {
371            if !sub_meshes.is_empty() {
372                let (out, occ) =
373                    emit_sub_meshes(job, sub_meshes, element_color, ctx, decoder, hasher, layer_class);
374                // #1623 Phase 2: a pure don't-bake occurrence produces NO flat mesh
375                // (only instance placeholders); treat that as success so the fallback
376                // chain below does not re-materialize the element flat.
377                if !out.is_empty() || !occ.is_empty() {
378                    return (out, occ);
379                }
380            }
381        }
382    }
383
384    // Fallback chain. A superseding strategy is about to re-process this
385    // element's representation and re-attempt the same (deterministic)
386    // cuts/booleans; discard the abandoned attempt's diagnostics so
387    // re-failures aren't double-counted. (The voids→plain-element
388    // mini-fallback below intentionally keeps its records: a failed/emptying
389    // cut that leaves the host uncut IS the diagnostic.)
390    let _ = router.take_csg_failures();
391
392    let mut mesh_candidate = router
393        .process_element_with_voids(job.entity, decoder, ctx.void_index)
394        .ok();
395    let needs_fallback = match mesh_candidate.as_ref() {
396        // An empty void-cut result normally means the cut FAILED and emptied
397        // the host, so we re-render it un-cut. But when a containing void
398        // genuinely CONSUMED the host (`host_consumed_by_void`), the empty
399        // result is correct — keep it, or the un-cut host re-appears as a
400        // spurious solid.
401        Some(mesh) => mesh.is_empty() && !router.host_consumed_by_void(job.id),
402        None => true,
403    };
404    if needs_fallback {
405        mesh_candidate = router.process_element(job.entity, decoder).ok();
406    }
407
408    let Some(mut mesh) = mesh_candidate else {
409        return (Vec::new(), Vec::new());
410    };
411    if mesh.is_empty() {
412        return (Vec::new(), Vec::new());
413    }
414
415    // Make the assembled body consistently outward-wound. A faceted brep (IFC
416    // face loops are not reliably outward) or a merged multi-item body (extrusion
417    // unioned with a boolean cut) can carry MIXED winding that corrupts signed
418    // volume and the smooth normals computed below. No-op for already-consistent
419    // bodies (every extrusion), so their index buffer + normals are untouched; a
420    // flip invalidates any baked normals, so recompute them.
421    //
422    // The verdict rides along to the hasher below: this pass is the only place
423    // that knows whether the assembled body is a closed orientable solid, and
424    // without that a per-element volume cannot be emitted honestly (#1891).
425    let verdict = orient_mesh_outward_verdict(&mut mesh);
426    if verdict.flipped {
427        calculate_normals(&mut mesh);
428    }
429
430    // Multi-colour IfcIndexedColourMap → one mesh per palette group (#858),
431    // resolved by walking the element's representation for the colour-mapped
432    // face set. Only applies while the produced triangle count still matches
433    // the face set's CoordIndex (no CSG/void retopology) — the splitter
434    // guards this; otherwise the single dominant-coloured mesh below wins.
435    if !ctx.indexed_colour_full.is_empty() {
436        if let Some(full) =
437            find_indexed_colour_for_element(job.entity, ctx.indexed_colour_full, decoder)
438        {
439            let geometry_id = full.geometry_id;
440            if let Some(groups) = crate::style::split_mesh_by_indexed_colour(&mesh, full) {
441                if let Some(h) = hasher.as_mut() {
442                    // The palette split below only partitions triangles; the
443                    // verdict from the un-split body is the one that describes
444                    // this hashed buffer.
445                    h.add_oriented_mesh(&mesh.positions, &mesh.indices, mesh.origin, verdict);
446                }
447                let mut out: Vec<MeshData> = Vec::with_capacity(groups.len());
448                for (color, mut part) in groups {
449                    if part.normals.len() != part.positions.len() {
450                        calculate_normals(&mut part);
451                    }
452                    out.push(build_mesh_data(
453                        job,
454                        part,
455                        color.to_array(),
456                        None,
457                        Some(geometry_id),
458                        0,
459                        ctx,
460                        None,
461                    ));
462                }
463                if !out.is_empty() {
464                    return (out, Vec::new());
465                }
466            }
467        }
468    }
469
470    if mesh.normals.len() != mesh.positions.len() {
471        calculate_normals(&mut mesh);
472    }
473    if let Some(h) = hasher.as_mut() {
474        h.add_oriented_mesh(&mesh.positions, &mesh.indices, mesh.origin, verdict);
475    }
476    (
477        vec![build_mesh_data(job, mesh, element_color, None, None, 0, ctx, None)],
478        Vec::new(),
479    )
480}
481
482/// Emit a sub-mesh collection: per-item colour resolution through the
483/// canonical `resolve_submesh_color` precedence (#913 §4.2), material-name
484/// inference for window/door parts, and the #858 per-item palette split.
485fn emit_sub_meshes(
486    job: &ElementMeshJob<'_>,
487    sub_meshes: SubMeshCollection,
488    element_color: [f32; 4],
489    ctx: &MeshProductionContext<'_>,
490    decoder: &mut EntityDecoder,
491    hasher: &mut Option<GeometryHasher>,
492    // geometry_class stamped on every emitted sub-mesh. 0 for normal occurrence
493    // geometry; GEOM_CLASS_LAYER_SLICE (3) when these are the per-layer slices of
494    // a material-layer wall — a section-only detail the 3D renderer skips (the
495    // wall renders as one solid) but the 2D/section cut consumes.
496    slice_class: u8,
497) -> (Vec<MeshData>, Vec<RawInstanceOccurrence>) {
498    let mut out: Vec<MeshData> = Vec::with_capacity(sub_meshes.len());
499    let mut occurrences: Vec<RawInstanceOccurrence> = Vec::new();
500    // Material colours for this element, used when a sub-mesh has no direct
501    // style — alternated so frame (opaque) and glazing (transparent) split
502    // across the window's parts (#913 §2.3).
503    let material_colors = ctx.element_material_colors.get(&job.id);
504    let mut mat_color_idx = 0usize;
505
506    for sub in sub_meshes.sub_meshes {
507        let mut sub_mesh = sub.mesh;
508        if sub_mesh.is_empty() {
509            // #1623 Phase 2 don't-bake: an EMPTY sub-mesh carrying instanceable
510            // InstanceMeta is a non-template occurrence of a shared template. Convert
511            // it to a RawInstanceOccurrence (resolving its colour EXACTLY as a
512            // materialized sub-mesh would, keyed on the same nested-solid geometry_id)
513            // instead of dropping it. `transform` was folded into `im.transform` by
514            // `apply_submesh_placement`; we compose the full pre-RTC world transform
515            // here and let the streaming finalize derive the template-relative mat4.
516            if let Some(im) = sub_mesh.instance_meta.as_ref().filter(|im| im.instanceable) {
517                let style = ctx.geometry_style_index.get(&sub.geometry_id);
518                let direct_color = style.map(|s| s.color).or_else(|| {
519                    find_geometry_item_color(sub.geometry_id, ctx.geometry_style_index, decoder)
520                });
521                let color = crate::style::resolve_submesh_color(
522                    direct_color,
523                    material_colors.map(|v| v.as_slice()),
524                    &mut mat_color_idx,
525                    element_color,
526                );
527                occurrences.push(RawInstanceOccurrence {
528                    express_id: job.id,
529                    ifc_type: job.ifc_type.name().to_string(),
530                    global_id: job.metadata.and_then(|m| m.global_id.clone()),
531                    name: job.metadata.and_then(|m| m.name.clone()),
532                    presentation_layer: job.metadata.and_then(|m| m.presentation_layer.clone()),
533                    color,
534                    rep_identity: im.rep_identity,
535                    world_transform: compose_instance_world_row_major(im),
536                });
537            }
538            continue;
539        }
540        // Consistently outward-wind each sub-body (see the single-mesh path); a
541        // flip invalidates baked normals, so recompute on flip or when absent.
542        // The verdict is per SUB-BODY, which is also the hasher's segment
543        // granularity, so closedness is attributed to exactly what it describes.
544        let verdict = orient_mesh_outward_verdict(&mut sub_mesh);
545        if verdict.flipped || sub_mesh.normals.len() != sub_mesh.positions.len() {
546            calculate_normals(&mut sub_mesh);
547        }
548
549        let style = ctx.geometry_style_index.get(&sub.geometry_id);
550        // Direct style wins; else chase IfcMappedItem so mapped sub-geometry
551        // inherits its underlying style (#913 §2.7).
552        let direct_color = style.map(|s| s.color).or_else(|| {
553            find_geometry_item_color(sub.geometry_id, ctx.geometry_style_index, decoder)
554        });
555        let color = crate::style::resolve_submesh_color(
556            direct_color,
557            material_colors.map(|v| v.as_slice()),
558            &mut mat_color_idx,
559            element_color,
560        );
561        let material_name = style
562            .and_then(|s| s.material_name.as_ref())
563            .map(ToString::to_string)
564            .or_else(|| infer_opening_subpart_material_name(&job.ifc_type, color, sub.geometry_id));
565
566        if let Some(h) = hasher.as_mut() {
567            h.add_oriented_mesh(&sub_mesh.positions, &sub_mesh.indices, sub_mesh.origin, verdict);
568        }
569
570        // Textured face set (#1781): thread the per-vertex UVs through the
571        // weld (kept 1:1 with positions, seams stay split) and attach the
572        // texture, mirroring the type-geometry path (#961). The length guard
573        // drops the texture instead of sampling garbage if any upstream step
574        // rebuilt vertices without maintaining the UV channel.
575        if let (Some(uvs), Some(texture)) = (sub.uvs, sub.texture.as_ref()) {
576            if uvs.len() / 2 == sub_mesh.positions.len() / 3 {
577                let mut mesh_data = build_mesh_data(
578                    job,
579                    sub_mesh,
580                    color,
581                    material_name,
582                    Some(sub.geometry_id),
583                    slice_class,
584                    ctx,
585                    Some(uvs),
586                );
587                mesh_data.texture = Some(MeshTextureData::from_attachment(texture));
588                out.push(mesh_data);
589                continue;
590            }
591        }
592
593        // #858: a face set with a per-triangle colour map splits into one
594        // mesh per palette group (guards inside the splitter: triangle count
595        // must still match, ≥2 distinct colours). Palette colours supersede
596        // the resolved style colour for the split parts.
597        if let Some(full) = ctx.indexed_colour_full.get(&sub.geometry_id) {
598            if let Some(groups) = crate::style::split_mesh_by_indexed_colour(&sub_mesh, full) {
599                for (rgba, mut part) in groups {
600                    if part.normals.len() != part.positions.len() {
601                        calculate_normals(&mut part);
602                    }
603                    out.push(build_mesh_data(
604                        job,
605                        part,
606                        rgba.to_array(),
607                        None,
608                        Some(sub.geometry_id),
609                        slice_class,
610                        ctx,
611                        None,
612                    ));
613                }
614                continue;
615            }
616        }
617
618        out.push(build_mesh_data(
619            job,
620            sub_mesh,
621            color,
622            material_name,
623            Some(sub.geometry_id),
624            slice_class,
625            ctx,
626            None,
627        ));
628    }
629    (out, occurrences)
630}
631
632/// geometry_class for the per-layer slices of a material-layer wall. The wall's
633/// slices have verified outward winding, so the 3D renderer draws THIS class
634/// BACKFACE-CULLED — the build-up shows on the faces/edges but the interior
635/// coincident caps never rasterise, so the thin stacked solids don't z-fight
636/// into a hollow shell. The 2D/section cut consumes the same class (never
637/// culled) for its per-layer fills.
638pub const GEOM_CLASS_LAYER_SLICE: u8 = 3;
639
640/// Render a type-product's planned RepresentationMaps (#957), texture-aware
641/// (#961), each mesh tagged with its planned geometry_class.
642fn produce_type_geometry(
643    job: &ElementMeshJob<'_>,
644    rep_maps: &[(u32, u8)],
645    element_color: [f32; 4],
646    ctx: &MeshProductionContext<'_>,
647    decoder: &mut EntityDecoder,
648    router: &GeometryRouter,
649) -> Vec<MeshData> {
650    let mut out: Vec<MeshData> = Vec::new();
651    for &(rep_map_id, geometry_class) in rep_maps {
652        let Ok(rep_map) = decoder.decode_by_id(rep_map_id) else {
653            continue;
654        };
655        // One part per output mesh: each textured face set carries its own
656        // UVs + decoded image; untextured items merge into one part (#961).
657        let Ok(parts) =
658            router.process_representation_map_with_texture(&rep_map, decoder, ctx.texture_index)
659        else {
660            continue;
661        };
662        if parts.is_empty() {
663            continue;
664        }
665
666        let color =
667            resolve_color_for_representation_map(rep_map_id, ctx.geometry_style_index, decoder)
668                .unwrap_or(element_color);
669
670        for (mut mesh, uvs, texture) in parts {
671            if mesh.is_empty() {
672                continue;
673            }
674            if mesh.normals.len() != mesh.positions.len() {
675                calculate_normals(&mut mesh);
676            }
677            // Thread the per-vertex UVs through `build_mesh_data` so the source
678            // weld remaps them WITH the deduped positions (and keeps texture
679            // seams split). Only textured parts carry UVs; untextured parts pass
680            // `None` and get the full position+normal weld.
681            let part_uvs = if texture.is_some() { Some(uvs) } else { None };
682            let mut mesh_data =
683                build_mesh_data(job, mesh, color, None, None, geometry_class, ctx, part_uvs);
684            if let Some(tex) = texture {
685                // UVs were already welded onto `mesh_data`; attach only the
686                // texture (decoded image or #1781 external reference) here.
687                mesh_data.texture = Some(MeshTextureData::from_attachment(&tex));
688            }
689            out.push(mesh_data);
690        }
691    }
692    out
693}
694
695/// Construct the final [`MeshData`]: metadata stamp, style metadata,
696/// geometry-class tag, and the optional site-local rotation. ALWAYS the last
697/// step — geometry hashing happens before this (native IFC frame), which is why
698/// the degenerate drop below has to report what it removed: it edits a mesh the
699/// hasher has already ruled on.
700#[allow(clippy::too_many_arguments)] // distinct per-mesh funnel inputs
701fn build_mesh_data(
702    job: &ElementMeshJob<'_>,
703    mut mesh: Mesh,
704    color: [f32; 4],
705    material_name: Option<String>,
706    geometry_item_id: Option<u32>,
707    geometry_class: u8,
708    ctx: &MeshProductionContext<'_>,
709    // Per-vertex texture coordinates (2 per vertex, 1:1 with `mesh.positions`),
710    // present only for textured type geometry (#961). Threaded through the weld
711    // so the UVs are remapped WITH the deduped positions and stay aligned; a UV
712    // difference also keeps a texture seam's coincident corners split.
713    uvs: Option<Vec<f32>>,
714) -> MeshData {
715    // Backstop for f32 vertex-storage collapse, at the single funnel for every
716    // element MeshData, tallying what it removed — `produce_element_meshes`
717    // drains that tally both into the result and into the closure retraction.
718    degenerate::clean(&mut mesh);
719    // Source vertex weld (see `mesh_weld::weld_indexed`): the faceted-brep
720    // mesher emits per-`IfcFace` geometry duplicating every shared corner once
721    // per incident face (~3-6x). Collapse coincident vertices (identical f32
722    // position + quantized normal + quantized UV) at this single per-element
723    // funnel — the normal/UV keys keep creases and texture seams split (flat
724    // shading, no torn textures), and UVs are remapped WITH the positions.
725    // `None` = nothing merged (already-welded swept solids): keep originals, no
726    // realloc; triangles, winding, and AABB unchanged either way.
727    let welded_uvs = match ifc_lite_geometry::mesh_weld::weld_indexed(
728        &mesh.positions,
729        &mesh.normals,
730        uvs.as_deref(),
731        &mesh.indices,
732    ) {
733        Some((wp, wn, wuv, wi)) => {
734            mesh.positions = wp;
735            mesh.normals = wn;
736            mesh.indices = wi;
737            wuv
738        }
739        None => uvs,
740    };
741    let mesh_origin = mesh.origin;
742    // Instancing: capture before the fields are moved into MeshData. A site-local
743    // rotation (below) re-transforms positions/origin and would invalidate the
744    // captured transform, so drop instancing when one is active (rare; conservative).
745    let instance = if ctx.site_local_rotation.is_none() {
746        mesh.instance_meta.take()
747    } else {
748        None
749    };
750    // Local bounds/placement transform (issue #1474): same caveat as instancing
751    // above — a site-local rotation re-transforms positions and would invalidate
752    // the captured placement, so drop both when one is active.
753    let (local_bounds, local_to_world) = if ctx.site_local_rotation.is_none() {
754        (mesh.local_bounds, mesh.local_to_world)
755    } else {
756        (None, None)
757    };
758    let mut mesh_data = MeshData::new(
759        job.id,
760        job.ifc_type.name().to_string(),
761        mesh.positions,
762        mesh.normals,
763        mesh.indices,
764        color,
765    )
766    .with_origin(mesh_origin)
767    .with_instance(instance)
768    .with_local_bounds(local_bounds)
769    .with_local_to_world(local_to_world);
770    if let Some(meta) = job.metadata {
771        mesh_data = mesh_data
772            .with_element_metadata(
773                meta.global_id.clone(),
774                meta.name.clone(),
775                meta.presentation_layer.clone(),
776            )
777            .with_properties(meta.space_zone_properties.clone());
778    }
779    if material_name.is_some() || geometry_item_id.is_some() {
780        mesh_data = mesh_data.with_style_metadata(material_name, geometry_item_id);
781    }
782    if geometry_class != 0 {
783        mesh_data = mesh_data.with_geometry_class(geometry_class);
784    }
785    // Attach the welded UVs (kept 1:1 with the welded positions by the weld).
786    // The texture IMAGE is attached by the caller; here we only carry the
787    // per-vertex coordinates through the funnel so they can't desync.
788    mesh_data.uvs = welded_uvs;
789    convert_mesh_to_site_local(&mut mesh_data, ctx.site_local_rotation);
790    mesh_data
791}
792
793/// Longest `IfcMappedItem → IfcRepresentationMap → MappedRepresentation`
794/// chain the colour chase will follow, matching the geometry router's limit
795/// of the same name (`ifc_lite_geometry::router::processing`) — a cap below
796/// the router's would leave a 17-to-32 link chain rendering its geometry
797/// while silently losing the authored style on its leaf. `pub(crate)`:
798/// `processor::color_layer` delegates here rather than keeping its own copy.
799pub(crate) const MAX_MAPPED_ITEM_DEPTH: u32 = 32;
800
801/// Resolve a geometry item's authored colour: direct style on the item, else
802/// chase `IfcMappedItem → IfcRepresentationMap → MappedRepresentation.Items`
803/// recursively (#913 §2.7 — mapped sub-geometry inherits its underlying
804/// item's style), to at most `MAX_MAPPED_ITEM_DEPTH` hops.
805pub(crate) fn find_geometry_item_color(
806    geometry_id: u32,
807    geometry_styles: &FxHashMap<u32, GeometryStyleInfo>,
808    decoder: &mut EntityDecoder,
809) -> Option<[f32; 4]> {
810    let mut visited = FxHashMap::default();
811    find_geometry_item_color_at(geometry_id, geometry_styles, decoder, 0, &mut visited)
812}
813
814/// The visited map is GLOBAL to one resolution, not path-scoped: the geometry
815/// router removes each id on the way out because it accumulates geometry per
816/// path, whereas a colour is a pure function of the item id and the style map.
817///
818/// It records the DEPTH each item was explored at and permits a revisit from
819/// strictly nearer the root. A plain SET is wrong in combination with the cap:
820/// an item first reached near the limit is cut before its subtree is searched
821/// yet stays marked, so a later shorter branch that WOULD have resolved is
822/// skipped and the colour is silently lost — a wrong value rather than a
823/// crash, so nothing reports it (Codex, #2868 review). Work stays bounded: an
824/// item is re-explored only from closer to the root, at most
825/// `MAX_MAPPED_ITEM_DEPTH` times.
826///
827/// That matters for more than tidiness. A depth cap alone bounds the chain but
828/// not the fan-out: a malformed representation holding `k` items that each
829/// lead back into the cycle costs `O(k^depth)` decodes, so four self-references
830/// at depth 32 is ~2^64 calls — no stack overflow, just a worker pinned
831/// forever. Trading an abort for a hang would not have been a fix (#2863).
832fn find_geometry_item_color_at(
833    geometry_id: u32,
834    geometry_styles: &FxHashMap<u32, GeometryStyleInfo>,
835    decoder: &mut EntityDecoder,
836    depth: u32,
837    visited: &mut FxHashMap<u32, u32>,
838) -> Option<[f32; 4]> {
839    // Direct style on this exact geometry item wins.
840    if let Some(style) = geometry_styles.get(&geometry_id) {
841        return Some(style.color);
842    }
843
844    // Otherwise, if it's a mapped item, chase the mapping to the underlying
845    // geometry and resolve there (recursing handles nested mapped items).
846    // Refuse to go deeper than the cap: a cyclic mapping would otherwise
847    // recurse until the stack overflows and the process aborts (#2863).
848    if depth >= MAX_MAPPED_ITEM_DEPTH {
849        return None;
850    }
851    match visited.get(&geometry_id) {
852        // Explored from here or from CLOSER to the root already: that attempt
853        // had at least as much room under the cap, so it cannot find anything
854        // new. Skipping is safe, and it is what breaks cycles.
855        Some(&seen_at) if seen_at <= depth => return None,
856        _ => visited.insert(geometry_id, depth),
857    };
858    let geom = decoder.decode_by_id(geometry_id).ok()?;
859    if geom.ifc_type != IfcType::IfcMappedItem {
860        return None;
861    }
862    // IfcMappedItem.MappingSource (attr 0) → IfcRepresentationMap.
863    let mapping_source_id = geom.get_ref(0)?;
864    // IfcRepresentationMap.MappedRepresentation (attr 1) → IfcShapeRepresentation.
865    let representation_map = decoder.decode_by_id(mapping_source_id).ok()?;
866    let mapped_representation_id = representation_map.get_ref(1)?;
867    let mapped_representation = decoder.decode_by_id(mapped_representation_id).ok()?;
868    // IfcShapeRepresentation.Items (attr 3).
869    let items = get_refs_from_list(&mapped_representation, 3)?;
870    for underlying in items {
871        if let Some(color) =
872            find_geometry_item_color_at(underlying, geometry_styles, decoder, depth + 1, visited)
873        {
874            return Some(color);
875        }
876    }
877    None
878}
879
880/// Resolve the authored colour for a type's `IfcRepresentationMap` (#957) by
881/// looking up its mapped geometry items in the styled-item index — the same
882/// index that colours ordinary products. `None` ⇒ caller falls back to the
883/// type's default colour.
884pub(crate) fn resolve_color_for_representation_map(
885    rep_map_id: u32,
886    geometry_style_index: &FxHashMap<u32, GeometryStyleInfo>,
887    decoder: &mut EntityDecoder,
888) -> Option<[f32; 4]> {
889    let rep_map = decoder.decode_by_id(rep_map_id).ok()?;
890    // IfcRepresentationMap.MappedRepresentation = attr 1.
891    let mapped_rep_id = rep_map.get_ref(1)?;
892    let mapped_rep = decoder.decode_by_id(mapped_rep_id).ok()?;
893    // IfcShapeRepresentation.Items = attr 3.
894    let item_ids = get_refs_from_list(&mapped_rep, 3)?;
895    for item_id in item_ids {
896        if let Some(style) = geometry_style_index.get(&item_id) {
897            return Some(style.color);
898        }
899        if let Some(color) = find_geometry_item_color(item_id, geometry_style_index, decoder) {
900            return Some(color);
901        }
902    }
903    None
904}
905
906/// Find the first representation item of `entity` that carries a full
907/// `IfcIndexedColourMap` (#858). Drives the element-level palette split on
908/// the single-mesh fallback path.
909pub(crate) fn find_indexed_colour_for_element<'a>(
910    entity: &DecodedEntity,
911    indexed_colour_full: &'a FxHashMap<u32, FullIndexedColourMap>,
912    decoder: &mut EntityDecoder,
913) -> Option<&'a FullIndexedColourMap> {
914    let pds_id = entity.get_ref(6)?;
915    let pds = decoder.decode_by_id(pds_id).ok()?;
916    let repr_ids = get_refs_from_list(&pds, 2)?;
917    for repr_id in repr_ids {
918        if let Ok(repr) = decoder.decode_by_id(repr_id) {
919            if let Some(items) = get_refs_from_list(&repr, 3) {
920                for item_id in items {
921                    if let Some(full) = indexed_colour_full.get(&item_id) {
922                        return Some(full);
923                    }
924                }
925            }
926        }
927    }
928    None
929}
930
931fn is_opening_with_subparts(ifc_type: &IfcType) -> bool {
932    matches!(ifc_type, IfcType::IfcWindow | IfcType::IfcDoor)
933}
934
935/// Synthesize a material name for window/door sub-parts that carry no
936/// authored style: transparency is a practical proxy for glazing in many BIM
937/// exports.
938pub(crate) fn infer_opening_subpart_material_name(
939    ifc_type: &IfcType,
940    color: [f32; 4],
941    geometry_id: u32,
942) -> Option<String> {
943    if !is_opening_with_subparts(ifc_type) {
944        return None;
945    }
946
947    let prefix = match ifc_type {
948        IfcType::IfcDoor => "Door",
949        _ => "Window",
950    };
951
952    if color[3] <= 0.65 {
953        return Some(format!("{}_Glass", prefix));
954    }
955
956    Some(format!("{}_Frame_{}", prefix, geometry_id))
957}
958
959#[cfg(test)]
960#[path = "element_tests.rs"]
961mod tests;