Skip to main content

ifc_lite_geometry/
mesh.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//! Mesh data structures
6
7use nalgebra::{Point3, Vector3};
8
9/// Side-channel instancing metadata, attached only when GPU instancing is
10/// enabled (the `IFC_LITE_INSTANCING` flag). NEVER read by geometry processing
11/// and excluded from `compute_mesh_hash` / `meshes_equal`, so content-dedup and
12/// the default flat path are unaffected. The native helper collates occurrences
13/// into unique geometry + per-instance transforms. Reconstruction contract:
14/// `world = (transform . local_transform) * canonical_local_vertex - rtc_offset`.
15#[derive(Debug, Clone)]
16pub struct InstanceMeta {
17    /// Full world placement (parent . local, scaled), pre-RTC, row-major homogeneous.
18    pub transform: [f64; 16],
19    /// IfcMappedItem mapping_transform (scaled), composed after `transform`.
20    pub local_transform: Option<[f64; 16]>,
21    /// Rigid-congruence canonical→local transform `C_k` (row-major), set by the
22    /// rotation-normalized tier (`IFC_LITE_RIGID_INSTANCING`) when this mesh was
23    /// grouped to a congruent-but-not-identical template. `None` ⇒ identity (the
24    /// exact-bit tier). Composed innermost: world = transform · local · canonical.
25    pub canonical_transform: Option<[f64; 16]>,
26    /// Representation-identity key: RepresentationMap id (mapped) or geometry hash (direct).
27    pub rep_identity: u128,
28    /// Whether this mesh is provably shareable (not void-cut / not site-rotated).
29    pub instanceable: bool,
30}
31
32/// Triangle mesh
33#[derive(Debug, Clone)]
34pub struct Mesh {
35    /// Vertex positions (x, y, z)
36    pub positions: Vec<f32>,
37    /// Vertex normals (nx, ny, nz)
38    pub normals: Vec<f32>,
39    /// Triangle indices (i0, i1, i2)
40    pub indices: Vec<u32>,
41    /// Whether RTC offset has already been subtracted from positions.
42    /// Set by `FacetedBrepProcessor::process_with_rtc` to prevent
43    /// `transform_mesh` from double-subtracting RTC.
44    pub rtc_applied: bool,
45    /// Per-mesh local origin (f64), in the RTC/world frame. When non-zero,
46    /// `positions` are stored RELATIVE to this origin (so they stay small and
47    /// f32-precise regardless of the element's world placement), and the world
48    /// position of a vertex is `origin + position`. Set by `transform_mesh_world`
49    /// to the element's centroid so building-scale coordinates (~hundreds of
50    /// metres) never collapse adjacent vertices to bit-identical f32. Default
51    /// `[0, 0, 0]` means positions are already absolute (legacy/local meshes).
52    pub origin: [f64; 3],
53    /// Instancing side-channel (see [`InstanceMeta`]); `None` on the flat path.
54    pub instance_meta: Option<InstanceMeta>,
55    /// Local (pre-placement, object-space) AABB — `positions` bounds as they
56    /// were BEFORE `apply_placement`'s transform was baked in. `None` for an
57    /// empty mesh or one that never went through `transform_mesh_world_framed`
58    /// (e.g. synthetic/test meshes). Unrelated to `origin`, which is a
59    /// *world*-space translation captured AFTER the transform, purely for f32
60    /// precision — see issue #1474.
61    pub local_bounds: Option<[f32; 6]>, // minX,minY,minZ,maxX,maxY,maxZ
62    /// The resolved `IfcLocalPlacement` chain applied to this mesh by
63    /// `apply_placement` (row-major, same convention as
64    /// [`InstanceMeta::transform`]). `None` when no placement was applied
65    /// (synthetic/test meshes) — see issue #1474.
66    pub local_to_world: Option<[f64; 16]>,
67}
68
69/// A sub-mesh with its source geometry item ID.
70/// Used to track which geometry items contribute to an element's mesh,
71/// allowing per-item color/style lookup.
72#[derive(Debug, Clone)]
73pub struct SubMesh {
74    /// The geometry item ID (e.g., IfcFacetedBrep ID) for style lookup
75    pub geometry_id: u32,
76    /// The triangulated mesh data
77    pub mesh: Mesh,
78    /// Per-vertex texture coordinates (u, v pairs, 1:1 with `mesh.positions`),
79    /// present only for textured face sets (#1781). Downstream index-only edits
80    /// (winding orientation, degenerate-triangle drops) and rigid transforms
81    /// keep them aligned; anything that rebuilds vertices must drop them.
82    pub uvs: Option<Vec<f32>>,
83    /// The surface texture sampled by `uvs` (#1781).
84    pub texture: Option<crate::processors::texture::TextureAttachment>,
85}
86
87impl SubMesh {
88    /// Create a new sub-mesh
89    pub fn new(geometry_id: u32, mesh: Mesh) -> Self {
90        Self {
91            geometry_id,
92            mesh,
93            uvs: None,
94            texture: None,
95        }
96    }
97
98    /// Create a textured sub-mesh (#1781): `uvs` are 1:1 with `mesh.positions`.
99    pub fn textured(
100        geometry_id: u32,
101        mesh: Mesh,
102        uvs: Vec<f32>,
103        texture: crate::processors::texture::TextureAttachment,
104    ) -> Self {
105        Self {
106            geometry_id,
107            mesh,
108            uvs: Some(uvs),
109            texture: Some(texture),
110        }
111    }
112}
113
114/// Collection of sub-meshes from an element, preserving per-item identity
115#[derive(Debug, Clone, Default)]
116pub struct SubMeshCollection {
117    pub sub_meshes: Vec<SubMesh>,
118}
119
120impl SubMeshCollection {
121    /// Create a new empty collection
122    pub fn new() -> Self {
123        Self {
124            sub_meshes: Vec::new(),
125        }
126    }
127
128    /// Add a sub-mesh
129    pub fn add(&mut self, geometry_id: u32, mesh: Mesh) {
130        if !mesh.is_empty() {
131            self.sub_meshes.push(SubMesh::new(geometry_id, mesh));
132        }
133    }
134
135    /// Add a textured sub-mesh (#1781).
136    pub fn add_textured(
137        &mut self,
138        geometry_id: u32,
139        mesh: Mesh,
140        uvs: Vec<f32>,
141        texture: crate::processors::texture::TextureAttachment,
142    ) {
143        if !mesh.is_empty() {
144            self.sub_meshes
145                .push(SubMesh::textured(geometry_id, mesh, uvs, texture));
146        }
147    }
148
149    /// Check if collection is empty
150    pub fn is_empty(&self) -> bool {
151        self.sub_meshes.is_empty()
152    }
153
154    /// Get number of sub-meshes
155    pub fn len(&self) -> usize {
156        self.sub_meshes.len()
157    }
158
159    /// Merge all sub-meshes into a single mesh (loses per-item identity)
160    pub fn into_combined_mesh(self) -> Mesh {
161        let mut combined = Mesh::new();
162        for sub in self.sub_meshes {
163            combined.merge(&sub.mesh);
164        }
165        combined
166    }
167
168    /// Iterate over sub-meshes
169    pub fn iter(&self) -> impl Iterator<Item = &SubMesh> {
170        self.sub_meshes.iter()
171    }
172}
173
174impl Mesh {
175    /// Create a new empty mesh
176    pub fn new() -> Self {
177        Self {
178            positions: Vec::new(),
179            normals: Vec::new(),
180            indices: Vec::new(),
181            rtc_applied: false,
182            origin: [0.0; 3],
183            instance_meta: None,
184            local_bounds: None,
185            local_to_world: None,
186        }
187    }
188
189    /// Create a mesh with capacity
190    pub fn with_capacity(vertex_count: usize, index_count: usize) -> Self {
191        Self {
192            positions: Vec::with_capacity(vertex_count * 3),
193            normals: Vec::with_capacity(vertex_count * 3),
194            indices: Vec::with_capacity(index_count),
195            rtc_applied: false,
196            origin: [0.0; 3],
197            instance_meta: None,
198            local_bounds: None,
199            local_to_world: None,
200        }
201    }
202
203    /// Build a mesh with FRESH geometry buffers (`positions` / `normals` /
204    /// `indices`) that carries THIS mesh's placement/frame metadata forward:
205    /// `origin` (RTC / local-frame translation), `rtc_applied`, `local_bounds`
206    /// and `local_to_world` (the #1474 placement capture).
207    ///
208    /// This is the correct constructor for an in-place rebuild pass that
209    /// REPLACES the vertex buffers of an already-placed mesh (sliver refine,
210    /// subdivide, weld). Constructing a bare `Mesh` and copying back only a
211    /// field or two silently resets `origin` and the #1474 capture to their
212    /// defaults, which mis-places the rebuilt host at the world origin on
213    /// local-framed (large / georeferenced) models — see facet_weld's
214    /// sliver-refine and this module's `subdivide_once` / `weld_impl`.
215    ///
216    /// `instance_meta` is intentionally NOT carried. Every such rebuild CHANGES
217    /// the vertices, so the mesh no longer reproduces its representation's
218    /// canonical geometry; carrying the (vertex-invariant) `rep_identity`
219    /// forward would let the GPU-instancing collator dedup this changed mesh
220    /// against an *unrefined* sibling that shares the same `rep_identity` and
221    /// draw the wrong geometry. Dropping it mirrors the void-cut path, which
222    /// nulls `instance_meta` for exactly this reason.
223    ///
224    /// # Precondition
225    ///
226    /// The new buffers MUST NOT extend the mesh's spatial extent beyond the
227    /// original: the carried `local_bounds` stays valid only because it remains
228    /// a *superset* of the rebuilt vertices' extent. This holds for every
229    /// current caller — sliver-refine and subdivide insert edge/interior
230    /// midpoints (convex combinations that lie inside the existing hull), and
231    /// weld only merges/moves coincident vertices to a snapped position (a
232    /// subset extent). A future caller that GROWS the extent (adds vertices
233    /// outside the original hull) must NOT use this constructor for
234    /// `local_bounds`: it has to recompute `local_bounds` from the new positions
235    /// or pass through a variant that sets it to `None`.
236    pub fn rebuilt_like(&self, positions: Vec<f32>, normals: Vec<f32>, indices: Vec<u32>) -> Mesh {
237        Mesh {
238            positions,
239            normals,
240            indices,
241            rtc_applied: self.rtc_applied,
242            origin: self.origin,
243            instance_meta: None,
244            local_bounds: self.local_bounds,
245            local_to_world: self.local_to_world,
246        }
247    }
248
249    /// Create a mesh from a single triangle
250    pub fn from_triangle(
251        v0: &Point3<f64>,
252        v1: &Point3<f64>,
253        v2: &Point3<f64>,
254        normal: &Vector3<f64>,
255    ) -> Self {
256        let mut mesh = Self::with_capacity(3, 3);
257        mesh.positions = vec![
258            v0.x as f32,
259            v0.y as f32,
260            v0.z as f32,
261            v1.x as f32,
262            v1.y as f32,
263            v1.z as f32,
264            v2.x as f32,
265            v2.y as f32,
266            v2.z as f32,
267        ];
268        mesh.normals = vec![
269            normal.x as f32,
270            normal.y as f32,
271            normal.z as f32,
272            normal.x as f32,
273            normal.y as f32,
274            normal.z as f32,
275            normal.x as f32,
276            normal.y as f32,
277            normal.z as f32,
278        ];
279        mesh.indices = vec![0, 1, 2];
280        mesh
281    }
282
283    /// Add a vertex with normal
284    #[inline]
285    pub fn add_vertex(&mut self, position: Point3<f64>, normal: Vector3<f64>) {
286        self.positions.push(position.x as f32);
287        self.positions.push(position.y as f32);
288        self.positions.push(position.z as f32);
289
290        self.normals.push(normal.x as f32);
291        self.normals.push(normal.y as f32);
292        self.normals.push(normal.z as f32);
293    }
294
295    /// Add a triangle
296    #[inline]
297    pub fn add_triangle(&mut self, i0: u32, i1: u32, i2: u32) {
298        self.indices.push(i0);
299        self.indices.push(i1);
300        self.indices.push(i2);
301    }
302
303    /// Merge another mesh into this one.
304    ///
305    /// Positions are stored relative to `origin`. The common case is merging
306    /// local/origin-zero meshes (sub-meshes combined BEFORE the world transform),
307    /// where origins match and concatenation is exact. If the two meshes carry
308    /// different non-zero origins, `other` is rebased into self's frame so the
309    /// merged positions stay consistent (correct, though large-coordinate if the
310    /// origins are far apart — which the pre-transform merge order avoids).
311    #[inline]
312    pub fn merge(&mut self, other: &Mesh) {
313        if other.is_empty() {
314            return;
315        }
316        if self.positions.is_empty() {
317            self.origin = other.origin;
318        }
319        let d = [
320            other.origin[0] - self.origin[0],
321            other.origin[1] - self.origin[1],
322            other.origin[2] - self.origin[2],
323        ];
324
325        let vertex_offset = (self.positions.len() / 3) as u32;
326
327        // Pre-allocate for the incoming data
328        self.positions.reserve(other.positions.len());
329        self.normals.reserve(other.normals.len());
330        self.indices.reserve(other.indices.len());
331
332        if d == [0.0, 0.0, 0.0] {
333            self.positions.extend_from_slice(&other.positions);
334        } else {
335            for chunk in other.positions.chunks_exact(3) {
336                self.positions.push((chunk[0] as f64 + d[0]) as f32);
337                self.positions.push((chunk[1] as f64 + d[1]) as f32);
338                self.positions.push((chunk[2] as f64 + d[2]) as f32);
339            }
340        }
341        self.normals.extend_from_slice(&other.normals);
342
343        // Vectorized index offset - more cache-friendly than loop
344        self.indices
345            .extend(other.indices.iter().map(|&i| i + vertex_offset));
346
347        // Preserve RTC state: if either mesh has RTC applied, the merged result does too
348        if other.rtc_applied {
349            self.rtc_applied = true;
350        }
351    }
352
353    /// Batch merge multiple meshes at once (more efficient than individual merges)
354    #[inline]
355    pub fn merge_all(&mut self, meshes: &[Mesh]) {
356        // Calculate total size needed
357        let total_positions: usize = meshes.iter().map(|m| m.positions.len()).sum();
358        let total_indices: usize = meshes.iter().map(|m| m.indices.len()).sum();
359
360        // Reserve capacity upfront to avoid reallocations
361        self.positions.reserve(total_positions);
362        self.normals.reserve(total_positions);
363        self.indices.reserve(total_indices);
364
365        // Delegate to `merge` for origin reconciliation (positions are stored
366        // relative to `origin`; a naive concat would be wrong across differing
367        // origins).
368        for mesh in meshes {
369            self.merge(mesh);
370        }
371    }
372
373    /// Get vertex count
374    #[inline]
375    pub fn vertex_count(&self) -> usize {
376        self.positions.len() / 3
377    }
378
379    /// Get triangle count
380    #[inline]
381    pub fn triangle_count(&self) -> usize {
382        self.indices.len() / 3
383    }
384
385    /// Uniform 1→4 midpoint subdivision applied `levels` times. Each triangle is
386    /// split into four by its three edge midpoints; midpoint positions/normals are
387    /// the f32 average of the edge endpoints (commutative ⇒ a shared edge yields
388    /// the SAME midpoint from either adjacent triangle, so the result stays
389    /// watertight once the kernel's interner welds coincident vertices).
390    ///
391    /// Purpose: a host face that is one or two huge triangles concentrates ALL of
392    /// a wall's opening cuts onto it, so the exact arrangement re-triangulates a
393    /// single triangle carrying dozens of constraint segments — O(k²) and, worse,
394    /// dense enough that the batched N-ary subtract leaves unrecovered constraints
395    /// and falls back to the O(N²) sequential path. Spreading the face into many
396    /// small triangles localises each opening to a few of them (small k), so the
397    /// batched cut recovers. `consolidate_coplanar` re-triangulates each coplanar
398    /// group afterwards, so the extra interior vertices do not survive into the
399    /// final mesh except where a hole boundary pins them.
400    pub fn subdivided(&self, levels: usize) -> Mesh {
401        let mut cur = self.clone();
402        for _ in 0..levels {
403            cur = cur.subdivide_once();
404        }
405        cur
406    }
407
408    fn subdivide_once(&self) -> Mesh {
409        let vcount = self.positions.len() / 3;
410        let has_normals = self.normals.len() == self.positions.len();
411        let mut positions = self.positions.clone();
412        let mut normals = if has_normals { self.normals.clone() } else { Vec::new() };
413        let mut indices = Vec::with_capacity(self.indices.len() * 4);
414        // Edge → midpoint vertex index, keyed by the ordered endpoint pair so the
415        // two triangles sharing an edge reuse one midpoint (no T-junctions).
416        let mut mid_of: rustc_hash::FxHashMap<(u32, u32), u32> = rustc_hash::FxHashMap::default();
417        let mut midpoint = |a: u32, b: u32, positions: &mut Vec<f32>, normals: &mut Vec<f32>| -> u32 {
418            let key = if a < b { (a, b) } else { (b, a) };
419            if let Some(&m) = mid_of.get(&key) {
420                return m;
421            }
422            let (ia, ib) = (a as usize * 3, b as usize * 3);
423            let m = (positions.len() / 3) as u32;
424            for k in 0..3 {
425                positions.push((self.positions[ia + k] + self.positions[ib + k]) * 0.5);
426            }
427            if has_normals {
428                // Average then re-normalise: the rest of the pipeline treats
429                // stored normals as unit vectors. On a flat face both endpoints
430                // share a normal so this is a no-op; only a midpoint on an edge
431                // between non-coplanar facets needs the renormalisation (and a
432                // degenerate near-zero average falls back to endpoint `a`).
433                let mut n = [
434                    (self.normals[ia] + self.normals[ib]) * 0.5,
435                    (self.normals[ia + 1] + self.normals[ib + 1]) * 0.5,
436                    (self.normals[ia + 2] + self.normals[ib + 2]) * 0.5,
437                ];
438                let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
439                if len > 1.0e-6 {
440                    n = [n[0] / len, n[1] / len, n[2] / len];
441                } else {
442                    n = [self.normals[ia], self.normals[ia + 1], self.normals[ia + 2]];
443                }
444                normals.extend_from_slice(&n);
445            }
446            mid_of.insert(key, m);
447            m
448        };
449        for tri in self.indices.chunks_exact(3) {
450            let (a, b, c) = (tri[0], tri[1], tri[2]);
451            if a as usize >= vcount || b as usize >= vcount || c as usize >= vcount {
452                continue;
453            }
454            let ab = midpoint(a, b, &mut positions, &mut normals);
455            let bc = midpoint(b, c, &mut positions, &mut normals);
456            let ca = midpoint(c, a, &mut positions, &mut normals);
457            // four sub-triangles, preserving the parent winding
458            indices.extend_from_slice(&[a, ab, ca, ab, b, bc, ca, bc, c, ab, bc, ca]);
459        }
460        // Adding midpoints changes the vertex buffers, so carry the placement /
461        // frame metadata (origin, rtc, #1474 capture) but drop instance_meta
462        // (this mesh no longer matches its canonical rep) via `rebuilt_like`.
463        self.rebuilt_like(positions, normals, indices)
464    }
465
466    /// Remove triangle indices that reference vertices beyond the positions array.
467    /// This prevents panics from malformed IFC data (e.g. Revit exports with invalid indices).
468    #[inline]
469    pub fn validate_indices(&mut self) {
470        let vertex_count = self.positions.len() / 3;
471        if vertex_count == 0 {
472            self.indices.clear();
473            return;
474        }
475        let mut valid = Vec::with_capacity(self.indices.len());
476        for chunk in self.indices.chunks(3) {
477            if chunk.len() == 3
478                && (chunk[0] as usize) < vertex_count
479                && (chunk[1] as usize) < vertex_count
480                && (chunk[2] as usize) < vertex_count
481            {
482                valid.extend_from_slice(chunk);
483            }
484        }
485        self.indices = valid;
486    }
487
488    /// Drop triangles that collapsed into degenerate needles when the mesh was
489    /// stored at f32 precision.
490    ///
491    /// At building-scale world coordinates (e.g. ~220 m) an f32 mantissa only
492    /// resolves ~15 µm, so two genuinely-distinct vertices less than one ULP
493    /// apart round to the *same* (or near-same) f32 value. The triangle that
494    /// joined them becomes a zero-area sliver — and when its third vertex is far
495    /// away, a long thin "fan" that visibly spans the model (the gross
496    /// corruption seen on large georeferenced buildings).
497    ///
498    /// These slivers carry effectively no area, so the neighbouring triangles of
499    /// the same face already cover the surface; removing them is visually
500    /// lossless while eliminating the fans. The proper fix (local-frame / tiled
501    /// vertex storage) keeps the vertices distinct in the first place; this is
502    /// the backstop for meshes that still arrive degenerate.
503    ///
504    /// Conservative by design — only drops triangles that are *unambiguously*
505    /// garbage: a bit-identical f32 vertex pair (exact zero area) or an aspect
506    /// ratio (longest edge / shortest edge) above 1e5. Legitimate thin members
507    /// (mullions, braces) sit far below that. Only `indices` change; the vertex
508    /// buffer and per-vertex data are left intact, so the operation is
509    /// deterministic and keeps vertex indices stable.
510    pub fn drop_degenerate_triangles(&mut self) {
511        if self.indices.len() < 3 {
512            return;
513        }
514        const MAX_ASPECT: f64 = 1.0e5;
515        let vertex_count = self.positions.len() / 3;
516        let vert = |i: u32| -> Option<[f64; 3]> {
517            let i = i as usize;
518            if i >= vertex_count {
519                return None;
520            }
521            Some([
522                self.positions[i * 3] as f64,
523                self.positions[i * 3 + 1] as f64,
524                self.positions[i * 3 + 2] as f64,
525            ])
526        };
527        let bits = |i: u32| -> [u32; 3] {
528            let i = i as usize;
529            [
530                self.positions[i * 3].to_bits(),
531                self.positions[i * 3 + 1].to_bits(),
532                self.positions[i * 3 + 2].to_bits(),
533            ]
534        };
535        let dist = |a: [f64; 3], b: [f64; 3]| -> f64 {
536            ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
537        };
538
539        let mut kept = Vec::with_capacity(self.indices.len());
540        for tri in self.indices.chunks_exact(3) {
541            let (ia, ib, ic) = (tri[0], tri[1], tri[2]);
542            // Drop any out-of-range triangle BEFORE the unchecked `bits()` closure
543            // indexes positions[] (unlike `vert()` below, `bits()` has no bounds
544            // check). Matches the drop-not-panic contract of vert()/validate_indices;
545            // sibling drop_thin_triangles guards the same way.
546            if ia as usize >= vertex_count
547                || ib as usize >= vertex_count
548                || ic as usize >= vertex_count
549            {
550                continue;
551            }
552            // Bit-identical f32 vertex pair → exact zero-area collapse.
553            let (ba, bb, bc) = (bits(ia), bits(ib), bits(ic));
554            if ba == bb || bb == bc || ba == bc {
555                continue;
556            }
557            let (va, vb, vc) = match (vert(ia), vert(ib), vert(ic)) {
558                (Some(a), Some(b), Some(c)) => (a, b, c),
559                _ => continue, // out-of-range index: drop (matches validate_indices)
560            };
561            let e0 = dist(va, vb);
562            let e1 = dist(vb, vc);
563            let e2 = dist(vc, va);
564            let min_edge = e0.min(e1).min(e2);
565            let max_edge = e0.max(e1).max(e2);
566            // Catastrophic needle: a sliver whose longest edge dwarfs its
567            // shortest by >1e5. min_edge==0 is already handled by the bit check
568            // above, so a finite ratio here means near-but-not-identical f32.
569            if min_edge > 0.0 && max_edge / min_edge > MAX_ASPECT {
570                continue;
571            }
572            kept.extend_from_slice(tri);
573        }
574        self.indices = kept;
575    }
576
577    /// Check if mesh is empty
578    #[inline]
579    pub fn is_empty(&self) -> bool {
580        self.positions.is_empty()
581    }
582
583    /// Calculate bounds (min, max) - optimized with chunk iteration
584    #[inline]
585    pub fn bounds(&self) -> (Point3<f32>, Point3<f32>) {
586        if self.is_empty() {
587            return (Point3::origin(), Point3::origin());
588        }
589
590        let mut min = Point3::new(f32::MAX, f32::MAX, f32::MAX);
591        let mut max = Point3::new(f32::MIN, f32::MIN, f32::MIN);
592
593        // Use chunks for better cache locality
594        self.positions.chunks_exact(3).for_each(|chunk| {
595            let (x, y, z) = (chunk[0], chunk[1], chunk[2]);
596            min.x = min.x.min(x);
597            min.y = min.y.min(y);
598            min.z = min.z.min(z);
599            max.x = max.x.max(x);
600            max.y = max.y.max(y);
601            max.z = max.z.max(z);
602        });
603
604        (min, max)
605    }
606
607    /// Calculate centroid in f64 precision (for RTC offset calculation)
608    /// Returns the average of all vertex positions
609    #[inline]
610    pub fn centroid_f64(&self) -> Point3<f64> {
611        if self.is_empty() {
612            return Point3::origin();
613        }
614
615        let mut sum = Point3::new(0.0f64, 0.0f64, 0.0f64);
616        let count = self.positions.len() / 3;
617
618        self.positions.chunks_exact(3).for_each(|chunk| {
619            sum.x += chunk[0] as f64;
620            sum.y += chunk[1] as f64;
621            sum.z += chunk[2] as f64;
622        });
623
624        Point3::new(
625            sum.x / count as f64,
626            sum.y / count as f64,
627            sum.z / count as f64,
628        )
629    }
630
631    /// Clear the mesh
632    #[inline]
633    pub fn clear(&mut self) {
634        self.positions.clear();
635        self.normals.clear();
636        self.indices.clear();
637        self.rtc_applied = false;
638        // Reset instancing metadata so a cleared+reused mesh can't carry stale
639        // rep-identity / transform into unrelated geometry. (#1238 review)
640        self.instance_meta = None;
641        // Same concern for the local-bounds/placement capture (issue #1474).
642        self.local_bounds = None;
643        self.local_to_world = None;
644    }
645
646    /// Weld vertices that share a position, regardless of normal.
647    ///
648    /// Returns a new mesh where vertices at the same position (within
649    /// `position_eps`) collapse to one canonical vertex; the welded
650    /// vertex's normal is the sum of contributing normals, re-normalized
651    /// (or a neutral up-Z `(0, 0, 1)` default if the sum is degenerate,
652    /// e.g. exactly opposing normals cancelling out).
653    /// Triangles that collapse to a degenerate edge or point are dropped.
654    ///
655    /// **Use this when you need a topologically connected, manifold-
656    /// candidate mesh** — volume queries, CSG operands, watertight
657    /// checks, mesh repair pipelines. Shading at sharp corners gets
658    /// averaged.
659    ///
660    /// `position_eps` is the bucket size in metres (1 µm is a safe
661    /// default for IFC).
662    pub fn welded_by_position(&self, position_eps: f32) -> Mesh {
663        weld_impl(self, position_eps, /*average_normals=*/ true)
664    }
665
666    /// Drop triangles whose perpendicular height (= 2·area / longest edge) is
667    /// below `h_eps` metres — i.e. genuinely-degenerate **collinear** slivers
668    /// (three distinct but near-collinear vertices, zero area). These come from
669    /// redundant collinear vertices in source brep faces / extrusion profiles
670    /// triangulated as-is; vertex welding can't merge them (the vertices are
671    /// distinct), so this catches them. At `h_eps` ≈ 15 µm — far below any real
672    /// architectural feature — the dropped triangles carry no area, so the
673    /// surrounding triangulation still covers the face (visually lossless,
674    /// watertight-preserving). Only `indices` change.
675    pub fn drop_thin_triangles(&mut self, h_eps: f64) {
676        if self.indices.len() < 3 {
677            return;
678        }
679        let vertex_count = self.positions.len() / 3;
680        let p = |i: u32| -> [f64; 3] {
681            let i = i as usize;
682            [
683                self.positions[i * 3] as f64,
684                self.positions[i * 3 + 1] as f64,
685                self.positions[i * 3 + 2] as f64,
686            ]
687        };
688        let mut kept = Vec::with_capacity(self.indices.len());
689        for tri in self.indices.chunks_exact(3) {
690            if (tri[0] as usize) >= vertex_count
691                || (tri[1] as usize) >= vertex_count
692                || (tri[2] as usize) >= vertex_count
693            {
694                continue;
695            }
696            let (a, b, c) = (p(tri[0]), p(tri[1]), p(tri[2]));
697            let d = |u: [f64; 3], v: [f64; 3]| {
698                ((u[0] - v[0]).powi(2) + (u[1] - v[1]).powi(2) + (u[2] - v[2]).powi(2)).sqrt()
699            };
700            let longest = d(a, b).max(d(b, c)).max(d(c, a));
701            if longest <= 0.0 {
702                continue; // fully collapsed
703            }
704            let ux = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
705            let vx = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
706            let cr = [
707                ux[1] * vx[2] - ux[2] * vx[1],
708                ux[2] * vx[0] - ux[0] * vx[2],
709                ux[0] * vx[1] - ux[1] * vx[0],
710            ];
711            let area = 0.5 * (cr[0] * cr[0] + cr[1] * cr[1] + cr[2] * cr[2]).sqrt();
712            let height = 2.0 * area / longest;
713            if height < h_eps {
714                continue; // collinear / zero-area sliver
715            }
716            kept.extend_from_slice(tri);
717        }
718        self.indices = kept;
719    }
720
721    /// Mesh hygiene applied to every element mesh before it leaves the router.
722    ///
723    /// Restores the cleanup the pure-Rust pipeline lost when #1024 removed
724    /// Manifold (which implicitly dropped degenerate output). Without it,
725    /// redundant/near-collinear source vertices in faceted breps and extrusion
726    /// profiles get triangulated into visible needle "spikes" and jagged
727    /// silhouettes (the regression reported on large breps); BIMcollab and
728    /// other viewers don't show them because they clean degenerates on import.
729    ///
730    /// Deliberately **does not weld vertices**. The pipeline emits per-face
731    /// flat-shaded facet soup on purpose (each facet keeps its own vertices +
732    /// normal so creases stay sharp — see issue #846); welding would share
733    /// vertices across facets and re-smooth every crease. Instead we drop only
734    /// the genuinely-degenerate triangles via
735    /// [`drop_thin_triangles`](Self::drop_thin_triangles) below the kernel's
736    /// reconcile grid (`1/65536 ≈ 15.3 µm`): coincident-pair needles (area 0)
737    /// and collinear slivers (three distinct near-collinear vertices). The grid
738    /// is the kernel's own representable resolution, so sub-grid triangles are
739    /// degenerate by definition; measured triangle counts are flat from
740    /// 10–50 µm and only start touching real geometry at ~100 µm (6.5× higher),
741    /// confirming nothing real lives in that band. Positions/normals are left
742    /// untouched, so it is visually lossless and bit-deterministic.
743    ///
744    /// The 15.3 µm threshold is most precise when applied in a small-magnitude
745    /// (element-local) frame, where f32 positions resolve well below it — which
746    /// the tessellation chokepoints honour (they clean *before* world
747    /// placement). The void-cut output is cleaned in world coordinates (the cut
748    /// runs there), so on a model georeferenced a few hundred metres to ~10 km
749    /// from origin — below the RTC re-basing threshold — the f32 grid at that
750    /// magnitude approaches the threshold and the margin near opening seams
751    /// erodes slightly; the `longest <= 0` guard still catches full collapse at
752    /// extreme scale. NaN/Inf triangles are kept (the comparison is false),
753    /// i.e. non-finite geometry is left for upstream to handle, never dropped.
754    pub fn clean_degenerate(&mut self) {
755        // The kernel's canonical reconcile grid (power-of-two for
756        // bit-determinism). Sub-grid triangles are below kernel resolution.
757        self.drop_thin_triangles(crate::kernel::mesh_bridge::SNAP_GRID);
758    }
759
760    /// Drop triangles with ANY vertex outside `[min - pad, max + pad]`, then
761    /// compact away the now-unreferenced vertices. Returns the count dropped.
762    ///
763    /// Boolean subtraction can only REMOVE material, so the cut of a host whose
764    /// pre-cut AABB is `[min, max]` is mathematically contained in that AABB.
765    /// A malformed cutter — self-intersecting, or carrying garbage vertices
766    /// metres from the real opening (e.g. an exporter that welds stray points
767    /// into a tessellated void, the multi-body-cutter case) — can make the
768    /// exact mesh-arrangement leak a spurious far-flung "flap" triangle into the
769    /// output: a visible spike poking metres out of the wall. Such a triangle
770    /// only appears once a SECOND cutter perturbs the arrangement, so it slips
771    /// past the per-cutter admission guards. Any output vertex beyond the host
772    /// AABB (past `pad`, which absorbs kernel snap / f64→f32 round-trip jitter)
773    /// is provably such an artifact, so the triangle is dropped and its orphaned
774    /// vertices removed (they would otherwise skew `bounds()` and every
775    /// AABB-derived consumer: framing, picking, clash, export).
776    ///
777    /// A no-op on clean cuts — when nothing lies outside, `positions`/`normals`
778    /// are left bit-identical so the frozen snapshot corpus is unperturbed. Also
779    /// a no-op in the degenerate case where EVERY triangle would be dropped (an
780    /// upstream frame/placement bug, not a cut artifact): the mesh is preserved
781    /// rather than silently emptied.
782    pub fn clip_triangles_to_aabb(&mut self, min: [f32; 3], max: [f32; 3], pad: f32) -> usize {
783        if self.indices.is_empty() {
784            return 0;
785        }
786        let lo = [min[0] - pad, min[1] - pad, min[2] - pad];
787        let hi = [max[0] + pad, max[1] + pad, max[2] + pad];
788        let inside = |i: u32| -> bool {
789            let b = i as usize * 3;
790            let (x, y, z) = (self.positions[b], self.positions[b + 1], self.positions[b + 2]);
791            x >= lo[0] && x <= hi[0] && y >= lo[1] && y <= hi[1] && z >= lo[2] && z <= hi[2]
792        };
793        let tri_count = self.indices.len() / 3;
794        let mut kept: Vec<u32> = Vec::with_capacity(self.indices.len());
795        for t in self.indices.chunks_exact(3) {
796            if inside(t[0]) && inside(t[1]) && inside(t[2]) {
797                kept.extend_from_slice(t);
798            }
799        }
800        let dropped = tri_count - kept.len() / 3;
801        // No-op when nothing protrudes (bit-identical) or when the whole mesh
802        // would vanish (preserve it — that signals a bug elsewhere, not a spike).
803        if dropped == 0 || kept.is_empty() {
804            return 0;
805        }
806        // Compact: remap referenced vertices, drop orphans.
807        let has_normals = self.normals.len() == self.positions.len();
808        let mut remap: Vec<i32> = vec![-1; self.positions.len() / 3];
809        let mut new_pos: Vec<f32> = Vec::with_capacity(kept.len() * 3);
810        let mut new_nrm: Vec<f32> = Vec::with_capacity(if has_normals { kept.len() * 3 } else { 0 });
811        let mut new_idx: Vec<u32> = Vec::with_capacity(kept.len());
812        for &i in &kept {
813            let old = i as usize;
814            let slot = if remap[old] < 0 {
815                let n = (new_pos.len() / 3) as u32;
816                remap[old] = n as i32;
817                new_pos.extend_from_slice(&self.positions[old * 3..old * 3 + 3]);
818                if has_normals {
819                    new_nrm.extend_from_slice(&self.normals[old * 3..old * 3 + 3]);
820                }
821                n
822            } else {
823                remap[old] as u32
824            };
825            new_idx.push(slot);
826        }
827        self.positions = new_pos;
828        if has_normals {
829            self.normals = new_nrm;
830        } else {
831            // Per-vertex normal array was absent or already inconsistent; clear
832            // it so a stale, mis-indexed buffer never ships downstream.
833            self.normals.clear();
834        }
835        self.indices = new_idx;
836        dropped
837    }
838
839    /// Clip a void-cut result to the host's pre-cut AABB `[min, max]`, dropping
840    /// any triangle poking beyond it (see [`Mesh::clip_triangles_to_aabb`]). A
841    /// subtract can only remove material, so anything past the host AABB is a cut
842    /// artifact. The tolerance absorbs f64→f32 round-trip jitter (sub-mm), so it
843    /// is a small ABSOLUTE band, NOT a fraction of host size: an unbounded
844    /// `1e-3 * diag` reaches 0.13 m on a 130 m floor slab — wider than the
845    /// ~0.105 m flush-cap reveal overhang it must trap, which is why only large
846    /// slabs/roofs leaked it (a 5 m wall's 5 mm pad already trims the identical
847    /// overhang, #1633). Clamped to [5 mm, 10 mm]: byte-identical to the former
848    /// `1e-3 * diag` for hosts ≤ 10 m diagonal (`1e-3 * diag ≤ 1e-2`), trimming
849    /// on every larger one. Returns the count dropped.
850    pub fn clip_triangles_to_host_aabb(&mut self, min: [f32; 3], max: [f32; 3]) -> usize {
851        // Widen to f64 BEFORE subtracting (not `(max - min) as f64`) so `diag`,
852        // and thus `pad`, is bit-for-bit what the former inline `wall_max.x -
853        // wall_min.x` (f64) computed — the clamp is the only intended change.
854        let diag = ((max[0] as f64 - min[0] as f64).powi(2)
855            + (max[1] as f64 - min[1] as f64).powi(2)
856            + (max[2] as f64 - min[2] as f64).powi(2))
857        .sqrt();
858        let pad = (1.0e-3 * diag).clamp(5.0e-3, 1.0e-2) as f32;
859        self.clip_triangles_to_aabb(min, max, pad)
860    }
861}
862
863impl Default for Mesh {
864    fn default() -> Self {
865        Self::new()
866    }
867}
868
869/// Shared welding implementation backing `Mesh::welded_by_position`.
870///
871/// The dedupe key is `quantized_position` only. `average_normals=true`
872/// accumulates contributing normals into the welded vertex and
873/// renormalizes at the end.
874fn weld_impl(mesh: &Mesh, position_eps: f32, average_normals: bool) -> Mesh {
875    use rustc_hash::FxHashMap;
876
877    let n_verts = mesh.positions.len() / 3;
878    if n_verts == 0 {
879        return Mesh::new();
880    }
881
882    let has_normals = mesh.normals.len() == mesh.positions.len();
883    let pos_scale = 1.0 / position_eps.max(f32::MIN_POSITIVE);
884    let q_pos = |v: f32| -> i64 { (v * pos_scale).round() as i64 };
885
886    // Dedupe key: quantized position only.
887    type Key = [i64; 3];
888    let mut canonical: FxHashMap<Key, u32> = FxHashMap::default();
889    let mut old_to_new: Vec<u32> = Vec::with_capacity(n_verts);
890    let mut new_positions: Vec<f32> = Vec::with_capacity(n_verts * 3);
891    let mut new_normals: Vec<f32> = Vec::with_capacity(n_verts * 3);
892    // For the average-normals path, accumulate the un-normalized sum so
893    // a final pass can normalize. The sum buffer is parallel to
894    // `new_positions` chunks.
895    let mut normal_accum: Vec<(f64, f64, f64)> = Vec::new();
896    if average_normals {
897        normal_accum.reserve(n_verts);
898    }
899
900    for i in 0..n_verts {
901        let px = mesh.positions[i * 3];
902        let py = mesh.positions[i * 3 + 1];
903        let pz = mesh.positions[i * 3 + 2];
904        let (nx, ny, nz) = if has_normals {
905            (
906                mesh.normals[i * 3],
907                mesh.normals[i * 3 + 1],
908                mesh.normals[i * 3 + 2],
909            )
910        } else {
911            (0.0, 0.0, 0.0)
912        };
913        let key: Key = [q_pos(px), q_pos(py), q_pos(pz)];
914
915        if let Some(&new_idx) = canonical.get(&key) {
916            old_to_new.push(new_idx);
917            if average_normals {
918                let slot = &mut normal_accum[new_idx as usize];
919                slot.0 += nx as f64;
920                slot.1 += ny as f64;
921                slot.2 += nz as f64;
922            }
923        } else {
924            let new_idx = (new_positions.len() / 3) as u32;
925            canonical.insert(key, new_idx);
926            old_to_new.push(new_idx);
927            new_positions.push(px);
928            new_positions.push(py);
929            new_positions.push(pz);
930            if has_normals {
931                new_normals.push(nx);
932                new_normals.push(ny);
933                new_normals.push(nz);
934            }
935            if average_normals {
936                normal_accum.push((nx as f64, ny as f64, nz as f64));
937            }
938        }
939    }
940
941    // For average-normals path: normalize the accumulated sums and
942    // write them back over the first-vertex-wins values stored above.
943    if average_normals && has_normals {
944        new_normals.clear();
945        new_normals.reserve(normal_accum.len() * 3);
946        for (sx, sy, sz) in &normal_accum {
947            let len_sq = sx * sx + sy * sy + sz * sz;
948            if len_sq > 1e-24 {
949                let inv = 1.0 / len_sq.sqrt();
950                new_normals.push((*sx * inv) as f32);
951                new_normals.push((*sy * inv) as f32);
952                new_normals.push((*sz * inv) as f32);
953            } else {
954                // Degenerate accumulation (opposing normals cancelled);
955                // fall back to a neutral up-Z so consumers don't see NaN.
956                new_normals.push(0.0);
957                new_normals.push(0.0);
958                new_normals.push(1.0);
959            }
960        }
961    }
962
963    // Re-index triangles, dropping degenerates and out-of-bound input
964    // triangles the same way `validate_indices` does so a malformed
965    // input mesh weld-then-renders fine instead of panicking later.
966    let mut new_indices: Vec<u32> = Vec::with_capacity(mesh.indices.len());
967    for chunk in mesh.indices.chunks_exact(3) {
968        let i0_raw = chunk[0] as usize;
969        let i1_raw = chunk[1] as usize;
970        let i2_raw = chunk[2] as usize;
971        if i0_raw >= n_verts || i1_raw >= n_verts || i2_raw >= n_verts {
972            continue;
973        }
974        let i0 = old_to_new[i0_raw];
975        let i1 = old_to_new[i1_raw];
976        let i2 = old_to_new[i2_raw];
977        if i0 == i1 || i1 == i2 || i0 == i2 {
978            continue;
979        }
980        new_indices.push(i0);
981        new_indices.push(i1);
982        new_indices.push(i2);
983    }
984
985    // Welding collapses / moves vertices, so carry the placement / frame
986    // metadata (origin, rtc, #1474 capture) but drop instance_meta (the welded
987    // mesh no longer matches its canonical rep) via `rebuilt_like`.
988    mesh.rebuilt_like(new_positions, new_normals, new_indices)
989}
990
991#[cfg(test)]
992mod tests {
993    use super::*;
994
995    /// Build a mesh from explicit triangles (each tri = 3 xyz triples).
996    fn mesh_from_tris(tris: &[[[f32; 3]; 3]]) -> Mesh {
997        let mut m = Mesh::new();
998        for (i, t) in tris.iter().enumerate() {
999            for v in t {
1000                m.positions.extend_from_slice(v);
1001                m.normals.extend_from_slice(&[0.0, 0.0, 1.0]);
1002            }
1003            let b = (i * 3) as u32;
1004            m.indices.extend_from_slice(&[b, b + 1, b + 2]);
1005        }
1006        m
1007    }
1008
1009    #[test]
1010    fn clip_to_aabb_drops_protruding_flap_and_compacts() {
1011        // Two in-bounds triangles forming a unit quad in z=0, plus one spurious
1012        // "spike" flap whose apex pokes far below the host AABB (the malformed-
1013        // cutter artifact): apex at y = -5 while the host is y in [0,1].
1014        let mut m = mesh_from_tris(&[
1015            [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]],
1016            [[0.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]],
1017            // spike: one vertex 5 m below the host
1018            [[1.0, 1.0, 0.0], [0.0, 1.0, 0.0], [0.5, -5.0, 0.0]],
1019        ]);
1020        let dropped = m.clip_triangles_to_aabb([0.0, 0.0, 0.0], [1.0, 1.0, 0.0], 0.01);
1021        assert_eq!(dropped, 1, "only the spike triangle should be dropped");
1022        assert_eq!(m.triangle_count(), 2);
1023        // Orphaned spike apex must be compacted away so bounds() is clean.
1024        let (lo, hi) = m.bounds();
1025        assert!(lo.y >= -0.01, "protruding apex left in positions: lo.y = {}", lo.y);
1026        let _ = hi;
1027        // 9 input verts (3 per tri, unshared) → after dropping the spike's 3 and
1028        // compacting the orphaned apex, the 2 kept tris keep their 6 verts.
1029        assert_eq!(m.positions.len() / 3, 6, "orphaned apex must be compacted out");
1030        assert_eq!(m.normals.len(), m.positions.len(), "normals stay in sync");
1031        // every surviving index is in range
1032        assert!(m.indices.iter().all(|&i| (i as usize) < m.positions.len() / 3));
1033    }
1034
1035    #[test]
1036    fn clip_to_aabb_is_noop_when_nothing_protrudes() {
1037        let tris = [
1038            [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]],
1039            [[0.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]],
1040        ];
1041        let mut m = mesh_from_tris(&tris);
1042        let before_pos = m.positions.clone();
1043        let before_idx = m.indices.clone();
1044        let dropped = m.clip_triangles_to_aabb([0.0, 0.0, 0.0], [1.0, 1.0, 0.0], 0.01);
1045        assert_eq!(dropped, 0);
1046        // bit-identical: clean cuts must not perturb the frozen snapshot corpus
1047        assert_eq!(m.positions, before_pos);
1048        assert_eq!(m.indices, before_idx);
1049    }
1050
1051    #[test]
1052    fn clip_to_aabb_preserves_mesh_when_all_would_drop() {
1053        // Degenerate guard: if EVERY triangle is outside (an upstream frame bug,
1054        // not a spike), preserve the mesh rather than silently emptying it.
1055        let mut m = mesh_from_tris(&[[
1056            [100.0, 100.0, 0.0],
1057            [101.0, 100.0, 0.0],
1058            [101.0, 101.0, 0.0],
1059        ]]);
1060        let dropped = m.clip_triangles_to_aabb([0.0, 0.0, 0.0], [1.0, 1.0, 0.0], 0.01);
1061        assert_eq!(dropped, 0);
1062        assert_eq!(m.triangle_count(), 1, "mesh preserved, not emptied");
1063    }
1064
1065    #[test]
1066    fn clip_to_host_aabb_trims_reveal_overhang_on_a_large_slab_1633() {
1067        // #1633: a flush-capped through-opening's reveal is extended ~0.3·depth
1068        // past the host cap for a clean transversal cut, so the exact subtract
1069        // leaves a reveal triangle ~0.105 m past a 0.35 m floor slab. The auto pad
1070        // MUST trim it regardless of host size — the bug was that `1e-3 · diag`
1071        // grew to 0.13 m on this ~130 m-diagonal slab (wider than the overhang) and
1072        // let it through, while a 5 m wall's 5 mm pad trimmed the identical overhang.
1073        let big = 92.0_f32; // 92 × 92 × 0.35 slab ⇒ diag ≈ 130 m ⇒ old pad ≈ 0.13 m
1074        let mut m = mesh_from_tris(&[
1075            // two in-bounds cap triangles (host top face at z = 0.35)
1076            [[0.0, 0.0, 0.35], [big, 0.0, 0.35], [big, big, 0.35]],
1077            [[0.0, 0.0, 0.35], [big, big, 0.35], [0.0, big, 0.35]],
1078            // reveal-overhang sliver: apex 0.105 m above the slab top cap
1079            [[40.0, 40.0, 0.35], [41.0, 40.0, 0.35], [40.5, 40.5, 0.455]],
1080        ]);
1081        let dropped = m.clip_triangles_to_host_aabb([0.0, 0.0, 0.0], [big, big, 0.35]);
1082        assert_eq!(dropped, 1, "the 0.105 m reveal overhang must be trimmed on a large host");
1083        let (_lo, hi) = m.bounds();
1084        assert!(hi.z <= 0.36, "no vertex may remain above the slab top cap: hi.z = {}", hi.z);
1085    }
1086
1087    #[test]
1088    fn clip_to_host_aabb_is_byte_identical_to_1e3_diag_for_small_hosts() {
1089        // The bound only changes behaviour above a 10 m diagonal; a normal wall
1090        // (~5 m diag ⇒ pad = 5 mm) is untouched, so the frozen corpus is safe.
1091        let tris = [
1092            [[0.0, 0.0, 0.0], [3.0, 0.0, 0.0], [3.0, 4.0, 0.0]],
1093            [[0.0, 0.0, 0.0], [3.0, 4.0, 0.0], [0.0, 4.0, 0.0]],
1094        ];
1095        let mut auto = mesh_from_tris(&tris);
1096        let mut manual = mesh_from_tris(&tris);
1097        let diag = (3.0_f64 * 3.0 + 4.0 * 4.0).sqrt(); // 5 m
1098        let old_pad = (1.0e-3 * diag).max(5.0e-3) as f32;
1099        auto.clip_triangles_to_host_aabb([0.0, 0.0, 0.0], [3.0, 4.0, 0.0]);
1100        manual.clip_triangles_to_aabb([0.0, 0.0, 0.0], [3.0, 4.0, 0.0], old_pad);
1101        assert_eq!(auto.positions, manual.positions);
1102        assert_eq!(auto.indices, manual.indices);
1103    }
1104
1105    #[test]
1106    fn test_merge() {
1107        let mut mesh1 = Mesh::new();
1108        mesh1.add_vertex(Point3::new(0.0, 0.0, 0.0), Vector3::z());
1109        mesh1.add_triangle(0, 1, 2);
1110
1111        let mut mesh2 = Mesh::new();
1112        mesh2.add_vertex(Point3::new(1.0, 1.0, 1.0), Vector3::y());
1113        mesh2.add_triangle(0, 1, 2);
1114
1115        mesh1.merge(&mesh2);
1116        assert_eq!(mesh1.vertex_count(), 2);
1117        assert_eq!(mesh1.triangle_count(), 2);
1118    }
1119
1120    #[test]
1121    fn test_centroid_f64() {
1122        let mut mesh = Mesh::new();
1123        mesh.positions = vec![0.0, 0.0, 0.0, 10.0, 10.0, 10.0, 20.0, 20.0, 20.0];
1124        mesh.normals = vec![0.0; 9];
1125
1126        let centroid = mesh.centroid_f64();
1127        assert!((centroid.x - 10.0).abs() < 0.001);
1128        assert!((centroid.y - 10.0).abs() < 0.001);
1129        assert!((centroid.z - 10.0).abs() < 0.001);
1130    }
1131
1132    #[test]
1133    fn test_validate_indices_strips_out_of_bounds() {
1134        let mut mesh = Mesh {
1135            positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0], // 3 vertices
1136            normals: vec![],
1137            indices: vec![
1138                0, 1, 2, // valid
1139                0, 1, 5, // invalid: vertex 5 out of bounds
1140                3, 4, 5, // invalid: all out of bounds
1141            ],
1142            rtc_applied: false,
1143            origin: [0.0; 3],
1144            instance_meta: None,
1145            local_bounds: None,
1146            local_to_world: None,
1147        };
1148        mesh.validate_indices();
1149        assert_eq!(mesh.indices, vec![0, 1, 2]);
1150    }
1151
1152    #[test]
1153    fn test_validate_indices_empty_positions() {
1154        let mut mesh = Mesh {
1155            positions: vec![],
1156            normals: vec![],
1157            indices: vec![0, 1, 2],
1158            rtc_applied: false,
1159            origin: [0.0; 3],
1160            instance_meta: None,
1161            local_bounds: None,
1162            local_to_world: None,
1163        };
1164        mesh.validate_indices();
1165        assert!(mesh.indices.is_empty());
1166    }
1167
1168    #[test]
1169    fn test_validate_indices_incomplete_triangle() {
1170        let mut mesh = Mesh {
1171            positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
1172            normals: vec![],
1173            indices: vec![0, 1, 2, 0, 1], // trailing incomplete triangle
1174            rtc_applied: false,
1175            origin: [0.0; 3],
1176            instance_meta: None,
1177            local_bounds: None,
1178            local_to_world: None,
1179        };
1180        mesh.validate_indices();
1181        assert_eq!(mesh.indices, vec![0, 1, 2]);
1182    }
1183
1184    fn make_unwelded_box() -> Mesh {
1185        // A 1×1×1 cube emitted as triangle soup: each face has its own 4
1186        // vertices (not shared with adjacent faces), so 6 faces × 4 verts
1187        // = 24 vertices, 12 triangles. This is what the extrusion path
1188        // produces today.
1189        let mut m = Mesh::new();
1190        let corners = [
1191            (0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (1.0, 1.0, 0.0), (0.0, 1.0, 0.0),
1192            (0.0, 0.0, 1.0), (1.0, 0.0, 1.0), (1.0, 1.0, 1.0), (0.0, 1.0, 1.0),
1193        ];
1194        let faces: [([usize; 4], [f32; 3]); 6] = [
1195            ([0, 3, 2, 1], [0.0, 0.0, -1.0]), // bottom
1196            ([4, 5, 6, 7], [0.0, 0.0, 1.0]),  // top
1197            ([0, 1, 5, 4], [0.0, -1.0, 0.0]), // front
1198            ([2, 3, 7, 6], [0.0, 1.0, 0.0]),  // back
1199            ([0, 4, 7, 3], [-1.0, 0.0, 0.0]), // left
1200            ([1, 2, 6, 5], [1.0, 0.0, 0.0]),  // right
1201        ];
1202        for (idx, normal) in faces {
1203            let base = (m.positions.len() / 3) as u32;
1204            for &i in idx.iter() {
1205                let (x, y, z) = corners[i];
1206                m.positions.extend_from_slice(&[x, y, z]);
1207                m.normals.extend_from_slice(&normal);
1208            }
1209            m.indices.extend_from_slice(&[base, base + 1, base + 2]);
1210            m.indices.extend_from_slice(&[base, base + 2, base + 3]);
1211        }
1212        m
1213    }
1214
1215    #[test]
1216    fn welded_by_position_collapses_corner_to_one_vertex() {
1217        let m = make_unwelded_box();
1218        // Position-only weld: all 24 input vertices map to the 8 box
1219        // corners. 8 vertices, 12 triangles (no degenerates since a
1220        // 1×1×1 box's corner-only mesh is non-degenerate).
1221        let welded = m.welded_by_position(1e-6);
1222        assert_eq!(
1223            welded.vertex_count(),
1224            8,
1225            "position-only weld must collapse 24 face-corner duplicates to 8 box corners"
1226        );
1227        assert_eq!(welded.triangle_count(), 12);
1228        // Averaged normal at each corner must be unit length (within f32
1229        // precision); we don't pin a specific direction because three
1230        // faces' normals sum to a face-diagonal direction.
1231        for chunk in welded.normals.chunks_exact(3) {
1232            let len_sq = chunk[0] * chunk[0] + chunk[1] * chunk[1] + chunk[2] * chunk[2];
1233            assert!(
1234                (len_sq - 1.0).abs() < 1e-4,
1235                "welded normal must be unit length, got |n|^2 = {}",
1236                len_sq
1237            );
1238        }
1239    }
1240
1241    /// #1474 / B7 regression guard: a vertex-changing rebuild MUST carry the
1242    /// mesh's placement/frame metadata (origin, rtc_applied, local_bounds,
1243    /// local_to_world) forward and MUST drop instance_meta (the changed vertices
1244    /// no longer match the canonical rep). Directly exercises `rebuilt_like` and
1245    /// the two public seams that route through it (`subdivided`,
1246    /// `welded_by_position`). Pure unit test — no fixture.
1247    #[test]
1248    fn rebuild_carries_placement_metadata_and_drops_instancing() {
1249        // One triangle, placed: non-default origin/rtc + a set #1474 capture and
1250        // an attached instance side-channel.
1251        let mut m = mesh_from_tris(&[[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]]]);
1252        m.origin = [100.0, 200.0, 300.0];
1253        m.rtc_applied = true;
1254        m.local_bounds = Some([0.0, 0.0, 0.0, 1.0, 1.0, 0.0]);
1255        let l2w: [f64; 16] = [
1256            1.0, 0.0, 0.0, 10.0, //
1257            0.0, 1.0, 0.0, 20.0, //
1258            0.0, 0.0, 1.0, 30.0, //
1259            0.0, 0.0, 0.0, 1.0,
1260        ];
1261        m.local_to_world = Some(l2w);
1262        m.instance_meta = Some(InstanceMeta {
1263            transform: l2w,
1264            local_transform: None,
1265            canonical_transform: None,
1266            rep_identity: 0xDEAD_BEEF,
1267            instanceable: true,
1268        });
1269
1270        // Metadata carried; instancing dropped. Assert on every seam.
1271        let via_ctor = m.rebuilt_like(vec![0.0, 0.0, 0.0], vec![0.0, 0.0, 1.0], vec![]);
1272        let via_subdivide = m.subdivided(1);
1273        let via_weld = m.welded_by_position(1e-6);
1274
1275        for (label, out) in [
1276            ("rebuilt_like", &via_ctor),
1277            ("subdivided", &via_subdivide),
1278            ("welded_by_position", &via_weld),
1279        ] {
1280            assert_eq!(out.origin, [100.0, 200.0, 300.0], "{label}: origin must carry");
1281            assert!(out.rtc_applied, "{label}: rtc_applied must carry");
1282            assert_eq!(
1283                out.local_bounds,
1284                Some([0.0, 0.0, 0.0, 1.0, 1.0, 0.0]),
1285                "{label}: local_bounds (#1474) must carry"
1286            );
1287            assert_eq!(out.local_to_world, Some(l2w), "{label}: local_to_world (#1474) must carry");
1288            assert!(
1289                out.instance_meta.is_none(),
1290                "{label}: instance_meta must be dropped (vertices changed -> not the canonical rep)"
1291            );
1292        }
1293    }
1294
1295    #[test]
1296    fn welded_drops_degenerate_triangles() {
1297        // A triangle whose three vertices all quantize to the same
1298        // position should be dropped after welding (it collapsed to a
1299        // point).
1300        let mut m = Mesh::new();
1301        m.positions = vec![
1302            0.0, 0.0, 0.0,
1303            // Two more "vertices" within position_eps of vertex 0:
1304            5e-8, 0.0, 0.0,
1305            0.0, 5e-8, 0.0,
1306            // A real non-degenerate triangle:
1307            1.0, 0.0, 0.0,
1308            1.0, 1.0, 0.0,
1309        ];
1310        m.normals = vec![
1311            0.0, 0.0, 1.0,
1312            0.0, 0.0, 1.0,
1313            0.0, 0.0, 1.0,
1314            0.0, 0.0, 1.0,
1315            0.0, 0.0, 1.0,
1316        ];
1317        m.indices = vec![
1318            0, 1, 2,   // collapses to a point after weld at eps=1e-6
1319            0, 3, 4,   // survives
1320        ];
1321        let welded = m.welded_by_position(1e-6);
1322        assert_eq!(welded.triangle_count(), 1);
1323    }
1324
1325    #[test]
1326    fn welded_handles_empty_mesh() {
1327        let m = Mesh::new();
1328        let welded_pos = m.welded_by_position(1e-6);
1329        assert!(welded_pos.is_empty());
1330    }
1331
1332    #[test]
1333    fn welded_strips_out_of_bound_indices() {
1334        let mut m = Mesh::new();
1335        m.positions = vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0];
1336        m.normals = vec![0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0];
1337        m.indices = vec![0, 1, 2, 0, 1, 99];
1338        let welded = m.welded_by_position(1e-6);
1339        assert_eq!(welded.triangle_count(), 1);
1340    }
1341
1342    #[test]
1343    fn test_validate_indices_all_valid() {
1344        let mut mesh = Mesh {
1345            positions: vec![0.0; 12], // 4 vertices
1346            normals: vec![],
1347            indices: vec![0, 1, 2, 1, 2, 3],
1348            rtc_applied: false,
1349            origin: [0.0; 3],
1350            instance_meta: None,
1351            local_bounds: None,
1352            local_to_world: None,
1353        };
1354        mesh.validate_indices();
1355        assert_eq!(mesh.indices, vec![0, 1, 2, 1, 2, 3]);
1356    }
1357
1358    // ── drop_thin_triangles / clean_degenerate ───────────────────────────
1359
1360    const GRID: f64 = 1.0 / 65536.0; // ≈ 15.26 µm, the kernel reconcile grid
1361
1362    #[test]
1363    fn drop_thin_removes_collinear_sliver_keeps_real_triangle() {
1364        // v0,v1,v2 are near-collinear: v2 sits 5 µm off the v0→v1 line over a
1365        // 1 m span — a zero-area sliver. A second, well-formed triangle
1366        // (v3,v4,v5, height 0.5 m) must survive.
1367        let mut mesh = Mesh {
1368            positions: vec![
1369                0.0, 0.0, 0.0, // v0
1370                1.0, 0.0, 0.0, // v1
1371                0.5, 5.0e-6, 0.0, // v2  (5 µm off the line → sliver)
1372                0.0, 0.0, 0.0, // v3
1373                1.0, 0.0, 0.0, // v4
1374                0.5, 0.5, 0.0, // v5  (real, 0.5 m tall)
1375            ],
1376            normals: vec![],
1377            indices: vec![0, 1, 2, 3, 4, 5],
1378            rtc_applied: false,
1379            origin: [0.0; 3],
1380        instance_meta: None, local_bounds: None, local_to_world: None };
1381        mesh.drop_thin_triangles(GRID);
1382        assert_eq!(mesh.indices, vec![3, 4, 5], "sliver dropped, real kept");
1383        // Positions/normals are never touched (orphan vertices are fine).
1384        assert_eq!(mesh.positions.len(), 18);
1385    }
1386
1387    #[test]
1388    fn drop_thin_removes_coincident_pair_needle() {
1389        // Two vertices identical → zero area regardless of the third.
1390        let mut mesh = Mesh {
1391            positions: vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0],
1392            normals: vec![],
1393            indices: vec![0, 1, 2],
1394            rtc_applied: false,
1395            origin: [0.0; 3],
1396        instance_meta: None, local_bounds: None, local_to_world: None };
1397        mesh.drop_thin_triangles(GRID);
1398        assert!(mesh.indices.is_empty(), "coincident-pair needle dropped");
1399    }
1400
1401    #[test]
1402    fn drop_thin_keeps_thin_but_real_triangle_just_above_grid() {
1403        // Height 30 µm (> 15.26 µm grid) over a 1 m base — thin but real.
1404        let mut mesh = Mesh {
1405            positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.5, 30.0e-6, 0.0],
1406            normals: vec![],
1407            indices: vec![0, 1, 2],
1408            rtc_applied: false,
1409            origin: [0.0; 3],
1410        instance_meta: None, local_bounds: None, local_to_world: None };
1411        mesh.drop_thin_triangles(GRID);
1412        assert_eq!(mesh.indices, vec![0, 1, 2], "above-grid triangle kept");
1413    }
1414
1415    #[test]
1416    fn drop_thin_does_not_open_a_crack_in_a_closed_solid() {
1417        // A closed tetrahedron with ONE extra degenerate sliver triangle glued
1418        // along an existing edge. Dropping the sliver must leave exactly the 4
1419        // real faces — i.e. it removes the sliver and nothing else, so the
1420        // watertight surface is unchanged (no real face is collateral-dropped).
1421        let a = [0.0f32, 0.0, 0.0];
1422        let b = [1.0f32, 0.0, 0.0];
1423        let c = [0.0f32, 1.0, 0.0];
1424        let d = [0.0f32, 0.0, 1.0];
1425        let mut pos = vec![];
1426        for v in [a, b, c, d] {
1427            pos.extend_from_slice(&v);
1428        }
1429        // sliver vertex on edge a→b, 5 µm off-line
1430        pos.extend_from_slice(&[0.5, 5.0e-6, 0.0]); // index 4
1431        let mut mesh = Mesh {
1432            positions: pos,
1433            normals: vec![],
1434            indices: vec![
1435                0, 1, 2, // 4 tetra faces
1436                0, 1, 3, 0, 2, 3, 1, 2, 3, // (winding irrelevant for this test)
1437                0, 1, 4, // the degenerate sliver along edge 0→1
1438            ],
1439            rtc_applied: false,
1440            origin: [0.0; 3],
1441        instance_meta: None, local_bounds: None, local_to_world: None };
1442        mesh.drop_thin_triangles(GRID);
1443        assert_eq!(
1444            mesh.indices,
1445            vec![0, 1, 2, 0, 1, 3, 0, 2, 3, 1, 2, 3],
1446            "only the sliver dropped; the 4 closed faces are intact"
1447        );
1448    }
1449
1450    #[test]
1451    fn drop_thin_skips_oob_and_fully_collapsed_without_panic() {
1452        let mut mesh = Mesh {
1453            positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
1454            normals: vec![],
1455            indices: vec![
1456                0, 1, 2, // valid, real
1457                0, 1, 9, // out-of-bounds index → skipped
1458                0, 0, 0, // fully collapsed (longest == 0) → skipped
1459            ],
1460            rtc_applied: false,
1461            origin: [0.0; 3],
1462        instance_meta: None, local_bounds: None, local_to_world: None };
1463        mesh.drop_thin_triangles(GRID);
1464        assert_eq!(mesh.indices, vec![0, 1, 2]);
1465    }
1466
1467    // Sibling of the above for drop_degenerate_triangles: the bits() closure
1468    // indexed positions[] unchecked before vert()'s bounds check, so an OOB index
1469    // panicked. It must now drop the bad triangle without panicking.
1470    #[test]
1471    fn drop_degenerate_skips_oob_index_without_panic() {
1472        let mut mesh = Mesh {
1473            positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
1474            normals: vec![],
1475            indices: vec![
1476                0, 1, 2, // valid
1477                0, 1, 9, // out-of-bounds index → would panic in bits() pre-fix
1478            ],
1479            rtc_applied: false,
1480            origin: [0.0; 3],
1481            instance_meta: None,
1482            local_bounds: None,
1483            local_to_world: None,
1484        };
1485        mesh.drop_degenerate_triangles();
1486        assert_eq!(mesh.indices, vec![0, 1, 2]);
1487    }
1488
1489    #[test]
1490    fn drop_thin_is_idempotent() {
1491        let mut mesh = Mesh {
1492            positions: vec![
1493                0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.5, 5.0e-6, 0.0, // sliver
1494                0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.5, 0.5, 0.0, // real
1495            ],
1496            normals: vec![],
1497            indices: vec![0, 1, 2, 3, 4, 5],
1498            rtc_applied: false,
1499            origin: [0.0; 3],
1500        instance_meta: None, local_bounds: None, local_to_world: None };
1501        mesh.drop_thin_triangles(GRID);
1502        let once = mesh.indices.clone();
1503        mesh.drop_thin_triangles(GRID);
1504        assert_eq!(mesh.indices, once, "second pass is a no-op");
1505    }
1506
1507    #[test]
1508    fn clean_degenerate_uses_the_reconcile_grid() {
1509        // clean_degenerate must drop a 10 µm sliver (below grid) and keep a
1510        // 30 µm one (above grid).
1511        let mut mesh = Mesh {
1512            positions: vec![
1513                0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.5, 10.0e-6, 0.0, // below grid
1514                0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.5, 30.0e-6, 0.0, // above grid
1515            ],
1516            normals: vec![],
1517            indices: vec![0, 1, 2, 3, 4, 5],
1518            rtc_applied: false,
1519            origin: [0.0; 3],
1520        instance_meta: None, local_bounds: None, local_to_world: None };
1521        mesh.clean_degenerate();
1522        assert_eq!(mesh.indices, vec![3, 4, 5]);
1523    }
1524}