ifc_lite_geometry/
mesh_weld.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//! Intra-mesh vertex weld + index dedup, applied at the mesh SOURCE.
6//!
7//! The faceted-brep mesher emits geometry per `IfcFace` with no cross-face
8//! vertex sharing, so a closed shell duplicates every shared corner once per
9//! incident face (~3-6x). That is the direct cause of the ~8x-larger GLBs the
10//! reference-extractor comparison flagged on structural (faceted-brep-heavy)
11//! models, and it inflates every downstream mesh (render, export, analysis).
12//! This weld collapses vertices that share an identical f32 position AND a
13//! coinciding (quantized) normal into one, then remaps indices.
14//!
15//! It runs AT LEAST once per element, in the OBJECT frame, at the last moment
16//! before the placement is baked in: [`weld_mesh`] from `apply_placement` and
17//! [`weld_sub_mesh`] from `apply_submesh_placement`. Every element — voided or
18//! not, faceted brep or swept solid, single-item or per-style sub-meshes —
19//! therefore arrives welded in its `MeshData`. Some are welded a SECOND time,
20//! post-bake, by [`weld`] called from `build_mesh_data`; its doc says which and
21//! why. Because it keys on the quantized
22//! normal, coincident positions carrying DISTINCT normals (a crease / cube
23//! corner) stay split, so flat shading is preserved (a cube keeps its 24
24//! vertices). Triangles and the AABB are preserved exactly (welded vertices sit
25//! at identical positions; triangle count and winding are unchanged).
26//!
27//! ## Why the object frame, and not after the bake (#4103)
28//!
29//! The position part of the key is the raw f32 BIT PATTERN, so what the weld
30//! merges depends on the magnitude of the coordinates it is handed. An f32 ULP
31//! is ~6e-8 m at 0.5 m and ~2e-6 m at 30 m, so welding baked world coordinates
32//! silently applies an epsilon that grows with the element's distance from the
33//! origin, and welding the same source geometry at two different placements
34//! merges two different sets of vertices.
35//!
36//! That broke a contract the pipeline depends on. Every occurrence of one
37//! `IfcRepresentationMap` is a clone of ONE cached source mesh
38//! (`router::mapped_item`), and every direct-solid `rep_identity` is a hash of
39//! the mesh BEFORE placement (`router::processing::direct_rep_identity`), so
40//! occurrences of one representation are meant to be bit-identical. A post-bake
41//! weld rewrote each of them differently, and `instancing::collate_refs` refuses
42//! a group whose members disagree on vertex count, so nothing ever collated:
43//! ten armchairs sharing one `IfcRepresentationMap` shipped as ten full meshes
44//! with vertex counts spread over 0.9%, and a cached Parquet artifact came out
45//! three times the size of its own IFC.
46//!
47//! In the object frame the key no longer depends on the element's PLACEMENT, so
48//! occurrences that differ only by where they were put weld identically.
49//! Placement is rigid, so nothing that merges here would have failed to merge
50//! after the bake for a geometric reason; only the accidental collisions go
51//! away, and those were never intended.
52//!
53//! ## What this does NOT fix, measured
54//!
55//! Three things survive, all narrower than the bug above but none zero.
56//!
57//! A per-occurrence `IfcMappedItem` MappingTarget is baked in by
58//! `router::mapped_item` BEFORE the mesh reaches `apply_placement`, so the weld
59//! still sees target-transformed coordinates. Occurrences of one
60//! `IfcRepresentationMap` whose targets differ by enough to move the f32
61//! exponent still weld to different vertex counts, and `collate_refs` still
62//! refuses that group. Measured on the `issue_4103_shared_map_buffer_identity`
63//! fixture with the offsets moved from the placement into the target: 8/8/8
64//! vertices when the placement varies (fixed), 8/4/4 when the target varies
65//! (unfixed). Closing that means welding the cached source once, before the
66//! target bake, and suppressing this weld for a mesh already welded — a
67//! different change.
68//!
69//! The invariant this weld establishes — no two vertices sharing a (position
70//! bits, quantized normal) key — holds in the frame the weld RAN in, not
71//! necessarily in the world buffer that ships. A rigid placement can map two
72//! vertices that are distinct in the object frame onto one f32 world position;
73//! main merged those post-bake and this deliberately does not, because doing so
74//! is exactly the placement-dependence being removed. Measured across the six
75//! ara3d models: 104 of 15,211 shipped meshes carry at least one such duplicate
76//! key, 494 of 1,093,616 vertices (0.68% and 0.045%); AC20-FZK-Haus has none.
77//! A post-bake weld to remove them would reintroduce #4103.
78//!
79//! Welding is not the only stage that can make two occurrences disagree. Anything
80//! that edits INDICES after the bake can too, and two do: `degenerate::clean`
81//! compares a triangle height computed from f32 world positions against an
82//! ABSOLUTE threshold, and `mesh_orient`'s adjacency grid is 10 um, which is
83//! finer than the f32 world grid at 128 m and beyond (one ULP there is 1.5e-5 m).
84//! Either can drop or reorient a different triangle in one occurrence than in
85//! another, and `instancing::group` rejects a group whose members' index buffers
86//! differ, which is the #4103 symptom reached by another route. UNMEASURED: this
87//! was reasoned from the thresholds, not observed, and the fixture here uses
88//! half-unit squares that cannot exercise it. Recorded so the next person
89//! investigating a stubborn collation rejection does not assume the weld is the
90//! only candidate.
91//!
92//! ## Per-vertex attributes
93//!
94//! `MeshData`'s only per-vertex-parallel arrays are `positions`, `normals`, and
95//! (for textured meshes, #961) `uvs`. The weld carries the UVs through the same
96//! remap so they stay 1:1 with the welded positions, AND folds the (quantized)
97//! UV into the merge key: two vertices at the same position + normal but
98//! DIFFERENT UVs are a legitimate texture SEAM and must stay split, or the
99//! texture mapping tears. An untextured mesh (`uvs == None`) contributes a
100//! constant `(0, 0)` UV, so its key is effectively position + normal and it gets
101//! the full weld benefit (steel faceted breps unaffected).
102//!
103//! Deterministic and cross-arch (native == wasm32): first-seen order over the
104//! original vertex array, integer keys (f32 position bits + a quantized normal +
105//! a quantized UV), no float comparison, FMA-free.
106
107use rustc_hash::FxHashMap;
108use std::cell::RefCell;
109
110/// Normal quantization grid: components are multiplied by this and rounded to an
111/// integer before keying. The shared grid also used by [`crate::facet_weld`]'s
112/// `NORMAL_QUANT` and the `consolidate_coplanar` grid, so the weld merges
113/// exactly the f32-jittered coplanar normals while keeping any real crease
114/// (normals that differ by more than ~1e-3 in a component) split.
115use crate::grid::NORMAL_QUANT_F32 as NORMAL_QUANT;
116
117/// UV quantization grid (~0.001 texel-fraction resolution). Coarse enough to
118/// merge f32 UV jitter on a shared corner, far finer than any real texture seam
119/// (a seam jumps the UV by a large fraction of the atlas), so seams stay split.
120const UV_QUANT: f32 = 1.0e3;
121
122/// Vertex identity key: exact position bits + quantized normal + quantized UV.
123type VKey = (u32, u32, u32, i32, i32, i32, i32, i32);
124
125#[inline]
126fn vkey(p: &[f32], n: &[f32], uv: [f32; 2]) -> VKey {
127    (
128        p[0].to_bits(),
129        p[1].to_bits(),
130        p[2].to_bits(),
131        (n[0] * NORMAL_QUANT).round() as i32,
132        (n[1] * NORMAL_QUANT).round() as i32,
133        (n[2] * NORMAL_QUANT).round() as i32,
134        (uv[0] * UV_QUANT).round() as i32,
135        (uv[1] * UV_QUANT).round() as i32,
136    )
137}
138
139/// Per-worker reusable scratch for [`weld_indexed`]'s INTERNAL buffers, cleared
140/// (never freed) between meshes. The output buffers (`out_pos`/`out_nrm`/…) still
141/// allocate (they escape to the caller); only the transient `map`/`remap`/
142/// `first_vert` — allocated and dropped per element by the pre-pool code — are
143/// pooled. BYTE-IDENTICAL: `map` is only `.get()`/`.insert()`-ed, never iterated
144/// (so its bucket count / residual capacity can't reach the output); `remap` is
145/// fully overwritten; `first_vert` is refilled by push in first-seen order and
146/// iterated in push order. A cleared, reused buffer replays the identical fill.
147#[derive(Default)]
148struct WeldScratch {
149    map: FxHashMap<VKey, u32>,
150    remap: Vec<u32>,
151    first_vert: Vec<u32>,
152}
153
154thread_local! {
155    /// One scratch per rayon worker (and the main / wasm single thread): a
156    /// `thread_local!`, not the shared worker-slot pattern, since these LEAF buffers
157    /// never cross a crate boundary, need no lock, and work when
158    /// `rayon::current_thread_index()` is `None`. Re-entrancy is TAKE / put-back (the
159    /// `RefCell` borrow is never held across the hash pass), so a re-entrant call
160    /// mints a fresh scratch rather than panicking — though `weld_indexed` runs no
161    /// rayon and cannot re-enter on the same thread.
162    static WELD_SCRATCH: RefCell<Option<WeldScratch>> = const { RefCell::new(None) };
163}
164
165/// Weld `positions`/`normals` (3 floats per vertex, equal length), optional
166/// `uvs` (2 floats per vertex), and remap `indices`.
167///
168/// Returns `Some((positions, normals, uvs, indices))` ONLY when at least two
169/// vertices actually merged; `uvs` is `Some` iff the input `uvs` was, always
170/// 1:1 with the welded positions. Returns `None` when nothing changes — a mesh
171/// that is already welded / all-crease (a swept solid, an indexed mesher, a
172/// flat-shaded cube), OR a malformed input (normals not matching positions,
173/// empty, a UV array not 2-per-vertex, or an out-of-range index). In every
174/// `None` case the identity remap would reproduce the input byte-for-byte, so
175/// the caller keeps its ORIGINAL buffers and skips the copy: no per-element
176/// reallocation on the (common) already-welded path, and a malformed input
177/// stays invalid-but-present rather than panicking or being re-associated.
178///
179/// Because the decision is purely "did any key collide", the funnel stays
180/// uniform — no per-geometry-type branching. The weld is idempotent: welding a
181/// welded mesh returns `None`.
182pub fn weld_indexed(
183    positions: &[f32],
184    normals: &[f32],
185    uvs: Option<&[f32]>,
186    indices: &[u32],
187) -> Option<(Vec<f32>, Vec<f32>, Option<Vec<f32>>, Vec<u32>)> {
188    let nverts = positions.len() / 3;
189    let uv_len_ok = uvs.is_none_or(|u| u.len() == nverts * 2);
190    if normals.len() != positions.len()
191        || nverts == 0
192        || !uv_len_ok
193        || indices.iter().any(|&i| i as usize >= nverts)
194    {
195        return None; // malformed: caller keeps the (unvalidated) originals
196    }
197
198    // Take this worker's warm scratch (or mint one). The `.with` borrow is momentary
199    // at both ends, never held across the hash pass (re-entrancy-safe); the scratch
200    // is handed back on the single return path below. Disjoint `&mut` field bindings.
201    let mut scratch = WELD_SCRATCH.with(|c| c.borrow_mut().take()).unwrap_or_default();
202    let WeldScratch { map, remap, first_vert } = &mut scratch;
203
204    // Single hash pass: assign each distinct key a first-seen id, record the source
205    // vertex that minted it, and fill the remap. `first_vert` doubles as the merge
206    // detector — same length as `nverts` ⇒ nothing collided, the remap is identity.
207    // Clear (retain capacity) before use so no stale data leaks in; `remap` is resized
208    // to all-zero (every slot is overwritten by `remap[v] = id` below).
209    map.clear();
210    map.reserve(nverts);
211    remap.clear();
212    remap.resize(nverts, 0u32);
213    first_vert.clear();
214    for v in 0..nverts {
215        let p = &positions[v * 3..v * 3 + 3];
216        let n = &normals[v * 3..v * 3 + 3];
217        let uv = match uvs {
218            Some(u) => [u[v * 2], u[v * 2 + 1]],
219            None => [0.0, 0.0],
220        };
221        let id = *map.entry(vkey(p, n, uv)).or_insert_with(|| {
222            let id = first_vert.len() as u32;
223            first_vert.push(v as u32);
224            id
225        });
226        remap[v] = id;
227    }
228
229    let unique = first_vert.len();
230    let out = if unique == nverts {
231        // Nothing merged: the identity remap reproduces the input exactly.
232        None
233    } else {
234        // Merges happened: gather the first-seen vertex per id (byte-identical to
235        // extending on first insert above) and remap the indices.
236        let mut out_pos: Vec<f32> = Vec::with_capacity(unique * 3);
237        let mut out_nrm: Vec<f32> = Vec::with_capacity(unique * 3);
238        let mut out_uv: Vec<f32> =
239            Vec::with_capacity(if uvs.is_some() { unique * 2 } else { 0 });
240        for &fv in first_vert.iter() {
241            let fv = fv as usize;
242            out_pos.extend_from_slice(&positions[fv * 3..fv * 3 + 3]);
243            out_nrm.extend_from_slice(&normals[fv * 3..fv * 3 + 3]);
244            if let Some(u) = uvs {
245                out_uv.extend_from_slice(&u[fv * 2..fv * 2 + 2]);
246            }
247        }
248        let out_idx: Vec<u32> = indices.iter().map(|&i| remap[i as usize]).collect();
249        Some((out_pos, out_nrm, uvs.map(|_| out_uv), out_idx))
250    };
251    // Field borrows have ended (NLL); hand the now-warm scratch back to this worker.
252    WELD_SCRATCH.with(|c| *c.borrow_mut() = Some(scratch));
253    out
254}
255
256/// Weld a mesh's vertices in place, carrying `uvs` through the same remap and
257/// returning them still 1:1 with the welded positions.
258///
259/// The one body behind every entry point here. It welds in whatever frame the
260/// CALLER hands it, which is the decision the caller owns: see the module doc
261/// for why the object frame is the right one for shared geometry, and what a
262/// world-frame weld does to it.
263///
264/// Computes the normals first when a processor left them absent or short. The
265/// key carries the quantized normal so a crease stays split, and `weld_indexed`
266/// REFUSES a mesh whose normals do not match its positions 1:1 while signalling
267/// that refusal with the same `None` it returns for "nothing collided" — so
268/// without this, a silently skipped weld is indistinguishable from an
269/// already-welded one, on every model. `calculate_normals` accumulates from the
270/// triangle winding, so it needs only positions and indices and is happy in any
271/// frame; `transform_mesh_world` then rotates the result into world space with
272/// the positions. `build_mesh_data`'s call already guarantees 1:1 normals; the
273/// two placement appliers do NOT, which is where this earns its keep.
274///
275/// `None` from `weld_indexed` leaves the mesh and the UVs untouched, with no
276/// reallocation.
277///
278/// ## What legitimately calls this AFTER the bake
279///
280/// `element::build_mesh_data`, for geometry with no cross-occurrence identity to
281/// protect (`instance_meta` absent). Two populations arrive there, and they are
282/// not the same:
283///
284/// - Geometry BORN after the placement bake, which no earlier weld could have
285///   reached: CSG void-cut output, layer slices, the #858 palette-split parts.
286///   This is the weld that collapses the kernel's per-face output for them.
287/// - Geometry already welded in the object frame that merely lost its
288///   `instance_meta` on the way there — a void host (welded in `apply_placement`,
289///   then cut, then nulled at `voids::process_element_with_voids`), a multi-item
290///   element (`processing.rs` keeps the metadata only for a single instanceable
291///   item), a textured face-set sub-mesh. For these it is a SECOND weld: cheap
292///   when nothing new collides, but it CAN merge world-frame coincidences the
293///   object frame kept apart, so their shipped buffers are placement-dependent in
294///   the way the module doc describes. Accepted because none of them is shared.
295///
296/// Shared geometry must not reach that call site. `build_mesh_data` keeps it out
297/// on `instance_meta`; #4122 is about recording the answer instead of inferring
298/// it.
299pub fn weld(mesh: &mut crate::Mesh, uvs: Option<Vec<f32>>) -> Option<Vec<f32>> {
300    if mesh.normals.len() != mesh.positions.len() {
301        crate::csg::calculate_normals(mesh);
302    }
303    match weld_indexed(&mesh.positions, &mesh.normals, uvs.as_deref(), &mesh.indices) {
304        Some((positions, normals, welded_uvs, indices)) => {
305            mesh.positions = positions;
306            mesh.normals = normals;
307            mesh.indices = indices;
308            welded_uvs
309        }
310        None => uvs,
311    }
312}
313
314/// Weld a `Mesh`'s source vertices in place, from `apply_placement`, where the
315/// vertices are still in the object frame. See the module doc for why that is
316/// the frame that matters.
317///
318/// Stamps `welded_in_object_frame` regardless of whether `weld` actually
319/// merged anything: "already welded" and "nothing collided" both leave the
320/// buffers valid for `build_mesh_data`'s guard (#4122), and `weld_indexed`'s
321/// `None` is exactly the "no merge needed" signal, not a refusal to run.
322pub(crate) fn weld_mesh(mesh: &mut crate::Mesh) {
323    weld(mesh, None);
324    mesh.welded_in_object_frame = true;
325}
326
327/// [`weld_mesh`] for a `SubMesh`, carrying its UVs through the same remap so they
328/// stay 1:1 with the welded positions. The quantized UV is part of the key, so a
329/// texture seam's coincident corners stay split (#961).
330pub(crate) fn weld_sub_mesh(sub: &mut crate::SubMesh) {
331    sub.uvs = weld(&mut sub.mesh, sub.uvs.take());
332    sub.mesh.welded_in_object_frame = true;
333}
334
335#[cfg(test)]
336#[path = "mesh_weld_tests.rs"]
337mod tests;