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