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