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/// The kernel's f64 supporting plane for one output triangle, carried forward
33/// from `kernel::mesh_bridge::tris_to_mesh` (issue #3914). `consolidate_coplanar`
34/// buckets triangles into coplanar groups by re-deriving a plane from each
35/// triangle's f32-ROUNDED vertices; on a rotated/tilted face this can straddle
36/// the bucket's quantization boundary and split one physical plane into two
37/// adjacent buckets that its cross-bucket seam-conform pass (tangential-only)
38/// cannot stitch back together, tearing the mesh. The kernel already computes
39/// each output triangle's plane in f64, BEFORE that f32 cast — carrying it
40/// forward gives `consolidate_coplanar` the same plane identity for both
41/// triangles without inventing a new tolerance or clustering pass.
42#[derive(Debug, Clone, Copy, PartialEq)]
43pub struct PlaneTag {
44    /// Unit plane normal (f64), oriented to match the triangle's winding.
45    pub n: [f64; 3],
46    /// Plane offset: `n . v` for any vertex `v` of the triangle.
47    pub d: f64,
48}
49
50/// Triangle mesh
51#[derive(Debug, Clone)]
52pub struct Mesh {
53    /// Vertex positions (x, y, z)
54    pub positions: Vec<f32>,
55    /// Vertex normals (nx, ny, nz)
56    pub normals: Vec<f32>,
57    /// Triangle indices (i0, i1, i2)
58    pub indices: Vec<u32>,
59    /// Whether RTC offset has already been subtracted from positions.
60    /// Set by a processor's `GeometryProcessor::process_in_rtc_frame` hook
61    /// (which rebases in f64 before narrowing) so the final world transform
62    /// does not subtract RTC a second time.
63    pub rtc_applied: bool,
64    /// Per-mesh local origin (f64), in the RTC/world frame. When non-zero,
65    /// `positions` are stored RELATIVE to this origin (so they stay small and
66    /// f32-precise regardless of the element's world placement), and the world
67    /// position of a vertex is `origin + position`. Set by `transform_mesh_world`
68    /// to the element's centroid so building-scale coordinates (~hundreds of
69    /// metres) never collapse adjacent vertices to bit-identical f32. Default
70    /// `[0, 0, 0]` means positions are already absolute (legacy/local meshes).
71    pub origin: [f64; 3],
72    /// Instancing side-channel (see [`InstanceMeta`]); `None` on the flat path.
73    pub instance_meta: Option<InstanceMeta>,
74    /// Local (pre-placement, object-space) AABB — `positions` bounds as they
75    /// were BEFORE `apply_placement`'s transform was baked in. `None` for an
76    /// empty mesh or one that never went through `transform_mesh_world_framed`
77    /// (e.g. synthetic/test meshes). Unrelated to `origin`, which is a
78    /// *world*-space translation captured AFTER the transform, purely for f32
79    /// precision — see issue #1474.
80    pub local_bounds: Option<[f32; 6]>, // minX,minY,minZ,maxX,maxY,maxZ
81    /// The resolved `IfcLocalPlacement` chain applied to this mesh by
82    /// `apply_placement` (row-major, same convention as
83    /// [`InstanceMeta::transform`]). `None` when no placement was applied
84    /// (synthetic/test meshes) — see issue #1474.
85    pub local_to_world: Option<[f64; 16]>,
86    /// Welded in the OBJECT FRAME by `mesh_weld::weld_mesh`/`weld_sub_mesh`
87    /// (its only setters; `rebuilt_like` clears it like `instance_meta`). The
88    /// real "already welded?" answer `build_mesh_data` today INFERS (#4122).
89    pub welded_in_object_frame: bool,
90    /// Per-triangle kernel-f64 supporting planes (issue #3914), 1:1 with
91    /// `indices.chunks_exact(3)` when `Some`. Carried by direct kernel union
92    /// output so `consolidate_coplanar` can repair a plane bucket split caused
93    /// by the f64-to-f32 cast. Difference and intersection outputs deliberately
94    /// leave this `None`: they can become operands of later booleans, where
95    /// merging their intermediate plane buckets changes subsequent cuts
96    /// (#5012). Every constructor/weld/merge/transform that changes triangle
97    /// count, order, or position MUST also leave this `None` (mirrors
98    /// `instance_meta`'s "no longer canonical" contract): a stale tag pointing
99    /// at the wrong post-edit triangle would silently mis-bucket
100    /// `consolidate_coplanar`. `None` is always safe; callers fall back to the
101    /// geometric re-derivation.
102    pub plane_tags: Option<Vec<PlaneTag>>,
103}
104
105/// A sub-mesh with its source geometry item ID.
106/// Used to track which geometry items contribute to an element's mesh,
107/// allowing per-item color/style lookup.
108#[derive(Debug, Clone)]
109pub struct SubMesh {
110    /// The geometry item ID (e.g., IfcFacetedBrep ID) for style lookup
111    pub geometry_id: u32,
112    /// The triangulated mesh data
113    pub mesh: Mesh,
114    /// Per-vertex texture coordinates (u, v pairs, 1:1 with `mesh.positions`),
115    /// present only for textured face sets (#1781). Downstream index-only edits
116    /// (winding orientation, degenerate-triangle drops) and rigid transforms
117    /// keep them aligned; anything that rebuilds vertices must drop them.
118    pub uvs: Option<Vec<f32>>,
119    /// The surface texture sampled by `uvs` (#1781).
120    pub texture: Option<crate::processors::texture::TextureAttachment>,
121}
122
123impl SubMesh {
124    /// Create a new sub-mesh
125    pub fn new(geometry_id: u32, mesh: Mesh) -> Self {
126        Self {
127            geometry_id,
128            mesh,
129            uvs: None,
130            texture: None,
131        }
132    }
133
134    /// Create a textured sub-mesh (#1781): `uvs` are 1:1 with `mesh.positions`.
135    pub fn textured(
136        geometry_id: u32,
137        mesh: Mesh,
138        uvs: Vec<f32>,
139        texture: crate::processors::texture::TextureAttachment,
140    ) -> Self {
141        Self {
142            geometry_id,
143            mesh,
144            uvs: Some(uvs),
145            texture: Some(texture),
146        }
147    }
148}
149
150/// Collection of sub-meshes from an element, preserving per-item identity
151#[derive(Debug, Clone, Default)]
152pub struct SubMeshCollection {
153    pub sub_meshes: Vec<SubMesh>,
154    /// What `SubMesh::geometry_id` MEANS here: `false` (default) an
155    /// `IfcRepresentationItem`, `true` an `IfcMaterial` (only `layers.rs` builds
156    /// those). Uniform across the collection, which is why it lives on it.
157    ///
158    /// Deliberately NOT `geometry_class == GEOM_CLASS_LAYER_SLICE`, which cannot
159    /// answer it: that class is stamped from `is_material_layer_sliceable`, a
160    /// static check made BEFORE the geometry runs, while
161    /// `try_layered_sub_meshes` can still return `None` and fall through to the
162    /// rep-item path — so rep-item ids can carry class 3 (#3199).
163    /// SCOPE: it decides only which FIELD `emit_sub_meshes` emits the id under —
164    /// five style lookups in `element.rs` still read `geometry_id` as a rep item
165    /// on BOTH paths, safe only by express-id uniqueness (#3211).
166    pub ids_are_materials: bool,
167}
168
169impl SubMeshCollection {
170    /// Create a new empty collection
171    pub fn new() -> Self {
172        Self::default()
173    }
174
175    /// `geometry_id`s are `IfcMaterial` ids. Only `layers.rs` builds these.
176    pub fn of_materials() -> Self {
177        Self {
178            ids_are_materials: true,
179            ..Self::default()
180        }
181    }
182
183    /// Add a sub-mesh
184    pub fn add(&mut self, geometry_id: u32, mesh: Mesh) {
185        if !mesh.is_empty() {
186            self.sub_meshes.push(SubMesh::new(geometry_id, mesh));
187        }
188    }
189
190    /// Add a textured sub-mesh (#1781).
191    pub fn add_textured(
192        &mut self,
193        geometry_id: u32,
194        mesh: Mesh,
195        uvs: Vec<f32>,
196        texture: crate::processors::texture::TextureAttachment,
197    ) {
198        if !mesh.is_empty() {
199            self.sub_meshes
200                .push(SubMesh::textured(geometry_id, mesh, uvs, texture));
201        }
202    }
203
204    /// Check if collection is empty
205    pub fn is_empty(&self) -> bool {
206        self.sub_meshes.is_empty()
207    }
208
209    /// Get number of sub-meshes
210    pub fn len(&self) -> usize {
211        self.sub_meshes.len()
212    }
213
214    /// Merge all sub-meshes into a single mesh (loses per-item identity)
215    pub fn into_combined_mesh(self) -> Mesh {
216        let mut combined = Mesh::new();
217        for sub in self.sub_meshes {
218            combined.merge(&sub.mesh);
219        }
220        combined
221    }
222
223    /// Iterate over sub-meshes
224    pub fn iter(&self) -> impl Iterator<Item = &SubMesh> {
225        self.sub_meshes.iter()
226    }
227}
228
229impl Mesh {
230    /// Create a new empty mesh
231    pub fn new() -> Self {
232        Self {
233            positions: Vec::new(),
234            normals: Vec::new(),
235            indices: Vec::new(),
236            rtc_applied: false,
237            origin: [0.0; 3],
238            instance_meta: None,
239            local_bounds: None,
240            local_to_world: None,
241            welded_in_object_frame: false,
242            plane_tags: None,
243        }
244    }
245
246    /// Create a mesh with capacity
247    pub fn with_capacity(vertex_count: usize, index_count: usize) -> Self {
248        Self {
249            positions: Vec::with_capacity(vertex_count * 3),
250            normals: Vec::with_capacity(vertex_count * 3),
251            indices: Vec::with_capacity(index_count),
252            rtc_applied: false,
253            origin: [0.0; 3],
254            instance_meta: None,
255            local_bounds: None,
256            local_to_world: None,
257            welded_in_object_frame: false,
258            plane_tags: None,
259        }
260    }
261
262    /// Build a mesh with FRESH geometry buffers (`positions` / `normals` /
263    /// `indices`) that carries THIS mesh's placement/frame metadata forward:
264    /// `origin` (RTC / local-frame translation), `rtc_applied`, `local_bounds`
265    /// and `local_to_world` (the #1474 placement capture).
266    ///
267    /// This is the correct constructor for an in-place rebuild pass that
268    /// REPLACES the vertex buffers of an already-placed mesh (sliver refine,
269    /// subdivide, weld). Constructing a bare `Mesh` and copying back only a
270    /// field or two silently resets `origin` and the #1474 capture to their
271    /// defaults, which mis-places the rebuilt host at the world origin on
272    /// local-framed (large / georeferenced) models — see facet_weld's
273    /// sliver-refine and this module's `subdivide_once` / `weld_impl`.
274    ///
275    /// `instance_meta` is intentionally NOT carried. Every such rebuild CHANGES
276    /// the vertices, so the mesh no longer reproduces its representation's
277    /// canonical geometry; carrying the (vertex-invariant) `rep_identity`
278    /// forward would let the GPU-instancing collator dedup this changed mesh
279    /// against an *unrefined* sibling that shares the same `rep_identity` and
280    /// draw the wrong geometry. Dropping it mirrors the void-cut path, which
281    /// nulls `instance_meta` for exactly this reason.
282    ///
283    /// # Precondition
284    ///
285    /// The new buffers MUST NOT extend the mesh's spatial extent beyond the
286    /// original: the carried `local_bounds` stays valid only because it remains
287    /// a *superset* of the rebuilt vertices' extent. This holds for every
288    /// current caller — sliver-refine and subdivide insert edge/interior
289    /// midpoints (convex combinations that lie inside the existing hull), and
290    /// weld only merges/moves coincident vertices to a snapped position (a
291    /// subset extent). A future caller that GROWS the extent (adds vertices
292    /// outside the original hull) must NOT use this constructor for
293    /// `local_bounds`: it has to recompute `local_bounds` from the new positions
294    /// or pass through a variant that sets it to `None`.
295    pub fn rebuilt_like(&self, positions: Vec<f32>, normals: Vec<f32>, indices: Vec<u32>) -> Mesh {
296        Mesh {
297            positions,
298            normals,
299            indices,
300            rtc_applied: self.rtc_applied,
301            origin: self.origin,
302            instance_meta: None,
303            local_bounds: self.local_bounds,
304            local_to_world: self.local_to_world,
305            welded_in_object_frame: false,
306            plane_tags: None,
307        }
308    }
309
310    /// Create a mesh from a single triangle
311    pub fn from_triangle(
312        v0: &Point3<f64>,
313        v1: &Point3<f64>,
314        v2: &Point3<f64>,
315        normal: &Vector3<f64>,
316    ) -> Self {
317        let mut mesh = Self::with_capacity(3, 3);
318        mesh.positions = vec![
319            v0.x as f32,
320            v0.y as f32,
321            v0.z as f32,
322            v1.x as f32,
323            v1.y as f32,
324            v1.z as f32,
325            v2.x as f32,
326            v2.y as f32,
327            v2.z as f32,
328        ];
329        mesh.normals = vec![
330            normal.x as f32,
331            normal.y as f32,
332            normal.z as f32,
333            normal.x as f32,
334            normal.y as f32,
335            normal.z as f32,
336            normal.x as f32,
337            normal.y as f32,
338            normal.z as f32,
339        ];
340        mesh.indices = vec![0, 1, 2];
341        mesh
342    }
343
344    /// Add a vertex with normal
345    #[inline]
346    pub fn add_vertex(&mut self, position: Point3<f64>, normal: Vector3<f64>) {
347        self.positions.push(position.x as f32);
348        self.positions.push(position.y as f32);
349        self.positions.push(position.z as f32);
350
351        self.normals.push(normal.x as f32);
352        self.normals.push(normal.y as f32);
353        self.normals.push(normal.z as f32);
354    }
355
356    /// Add a triangle
357    #[inline]
358    pub fn add_triangle(&mut self, i0: u32, i1: u32, i2: u32) {
359        self.indices.push(i0);
360        self.indices.push(i1);
361        self.indices.push(i2);
362    }
363
364    /// Merge another mesh into this one.
365    ///
366    /// Positions are stored relative to `origin`. The common case is merging
367    /// local/origin-zero meshes (sub-meshes combined BEFORE the world transform),
368    /// where origins match and concatenation is exact. If the two meshes carry
369    /// different non-zero origins, `other` is rebased into self's frame so the
370    /// merged positions stay consistent (correct, though large-coordinate if the
371    /// origins are far apart — which the pre-transform merge order avoids).
372    #[inline]
373    pub fn merge(&mut self, other: &Mesh) {
374        if other.is_empty() {
375            return;
376        }
377        if self.positions.is_empty() {
378            self.origin = other.origin;
379        }
380        let d = [
381            other.origin[0] - self.origin[0],
382            other.origin[1] - self.origin[1],
383            other.origin[2] - self.origin[2],
384        ];
385
386        let vertex_offset = (self.positions.len() / 3) as u32;
387
388        // Pre-allocate for the incoming data
389        self.positions.reserve(other.positions.len());
390        self.normals.reserve(other.normals.len());
391        self.indices.reserve(other.indices.len());
392
393        if d == [0.0, 0.0, 0.0] {
394            self.positions.extend_from_slice(&other.positions);
395        } else {
396            for chunk in other.positions.chunks_exact(3) {
397                self.positions.push((chunk[0] as f64 + d[0]) as f32);
398                self.positions.push((chunk[1] as f64 + d[1]) as f32);
399                self.positions.push((chunk[2] as f64 + d[2]) as f32);
400            }
401        }
402        self.normals.extend_from_slice(&other.normals);
403
404        // Vectorized index offset - more cache-friendly than loop
405        self.indices
406            .extend(other.indices.iter().map(|&i| i + vertex_offset));
407
408        // Preserve RTC state: if either mesh has RTC applied, the merged result does too
409        if other.rtc_applied {
410            self.rtc_applied = true;
411        }
412    }
413
414    /// Batch merge multiple meshes at once (more efficient than individual merges)
415    #[inline]
416    pub fn merge_all(&mut self, meshes: &[Mesh]) {
417        // Calculate total size needed
418        let total_positions: usize = meshes.iter().map(|m| m.positions.len()).sum();
419        let total_indices: usize = meshes.iter().map(|m| m.indices.len()).sum();
420
421        // Reserve capacity upfront to avoid reallocations
422        self.positions.reserve(total_positions);
423        self.normals.reserve(total_positions);
424        self.indices.reserve(total_indices);
425
426        // Delegate to `merge` for origin reconciliation (positions are stored
427        // relative to `origin`; a naive concat would be wrong across differing
428        // origins).
429        for mesh in meshes {
430            self.merge(mesh);
431        }
432    }
433
434    /// Get vertex count
435    #[inline]
436    pub fn vertex_count(&self) -> usize {
437        self.positions.len() / 3
438    }
439
440    /// Get triangle count
441    #[inline]
442    pub fn triangle_count(&self) -> usize {
443        self.indices.len() / 3
444    }
445
446    /// Uniform 1→4 midpoint subdivision applied `levels` times. Each triangle is
447    /// split into four by its three edge midpoints; midpoint positions/normals are
448    /// the f32 average of the edge endpoints (commutative ⇒ a shared edge yields
449    /// the SAME midpoint from either adjacent triangle, so the result stays
450    /// watertight once the kernel's interner welds coincident vertices).
451    ///
452    /// Purpose: a host face that is one or two huge triangles concentrates ALL of
453    /// a wall's opening cuts onto it, so the exact arrangement re-triangulates a
454    /// single triangle carrying dozens of constraint segments — O(k²) and, worse,
455    /// dense enough that the batched N-ary subtract leaves unrecovered constraints
456    /// and falls back to the O(N²) sequential path. Spreading the face into many
457    /// small triangles localises each opening to a few of them (small k), so the
458    /// batched cut recovers. `consolidate_coplanar` re-triangulates each coplanar
459    /// group afterwards, so the extra interior vertices do not survive into the
460    /// final mesh except where a hole boundary pins them.
461    pub fn subdivided(&self, levels: usize) -> Mesh {
462        let mut cur = self.clone();
463        for _ in 0..levels {
464            cur = cur.subdivide_once();
465        }
466        cur
467    }
468
469    fn subdivide_once(&self) -> Mesh {
470        let vcount = self.positions.len() / 3;
471        let has_normals = self.normals.len() == self.positions.len();
472        let mut positions = self.positions.clone();
473        let mut normals = if has_normals { self.normals.clone() } else { Vec::new() };
474        let mut indices = Vec::with_capacity(self.indices.len() * 4);
475        // Edge → midpoint vertex index, keyed by the ordered endpoint pair so the
476        // two triangles sharing an edge reuse one midpoint (no T-junctions).
477        let mut mid_of: rustc_hash::FxHashMap<(u32, u32), u32> = rustc_hash::FxHashMap::default();
478        let mut midpoint = |a: u32, b: u32, positions: &mut Vec<f32>, normals: &mut Vec<f32>| -> u32 {
479            let key = if a < b { (a, b) } else { (b, a) };
480            if let Some(&m) = mid_of.get(&key) {
481                return m;
482            }
483            let (ia, ib) = (a as usize * 3, b as usize * 3);
484            let m = (positions.len() / 3) as u32;
485            for k in 0..3 {
486                positions.push((self.positions[ia + k] + self.positions[ib + k]) * 0.5);
487            }
488            if has_normals {
489                // Average then re-normalise: the rest of the pipeline treats
490                // stored normals as unit vectors. On a flat face both endpoints
491                // share a normal so this is a no-op; only a midpoint on an edge
492                // between non-coplanar facets needs the renormalisation (and a
493                // degenerate near-zero average falls back to endpoint `a`).
494                let mut n = [
495                    (self.normals[ia] + self.normals[ib]) * 0.5,
496                    (self.normals[ia + 1] + self.normals[ib + 1]) * 0.5,
497                    (self.normals[ia + 2] + self.normals[ib + 2]) * 0.5,
498                ];
499                let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
500                if len > 1.0e-6 {
501                    n = [n[0] / len, n[1] / len, n[2] / len];
502                } else {
503                    n = [self.normals[ia], self.normals[ia + 1], self.normals[ia + 2]];
504                }
505                normals.extend_from_slice(&n);
506            }
507            mid_of.insert(key, m);
508            m
509        };
510        for tri in self.indices.chunks_exact(3) {
511            let (a, b, c) = (tri[0], tri[1], tri[2]);
512            if a as usize >= vcount || b as usize >= vcount || c as usize >= vcount {
513                continue;
514            }
515            let ab = midpoint(a, b, &mut positions, &mut normals);
516            let bc = midpoint(b, c, &mut positions, &mut normals);
517            let ca = midpoint(c, a, &mut positions, &mut normals);
518            // four sub-triangles, preserving the parent winding
519            indices.extend_from_slice(&[a, ab, ca, ab, b, bc, ca, bc, c, ab, bc, ca]);
520        }
521        // Adding midpoints changes the vertex buffers, so carry the placement /
522        // frame metadata (origin, rtc, #1474 capture) but drop instance_meta
523        // (this mesh no longer matches its canonical rep) via `rebuilt_like`.
524        self.rebuilt_like(positions, normals, indices)
525    }
526
527    /// Remove triangle indices that reference vertices beyond the positions array.
528    /// This prevents panics from malformed IFC data (e.g. Revit exports with invalid indices).
529    #[inline]
530    pub fn validate_indices(&mut self) {
531        let vertex_count = self.positions.len() / 3;
532        if vertex_count == 0 {
533            self.indices.clear();
534            return;
535        }
536        let original_len = self.indices.len();
537        let mut kept = 0;
538        for read in (0..original_len / 3 * 3).step_by(3) {
539            let chunk = [self.indices[read], self.indices[read + 1], self.indices[read + 2]];
540            if (chunk[0] as usize) < vertex_count
541                && (chunk[1] as usize) < vertex_count
542                && (chunk[2] as usize) < vertex_count {
543                if kept != read { self.indices[kept..kept + 3].copy_from_slice(&chunk); }
544                kept += 3;
545            }
546        }
547        self.indices.truncate(kept);
548        self.indices.shrink_to(original_len);
549    }
550
551    /// Drop triangles that collapsed into degenerate needles when the mesh was
552    /// stored at f32 precision.
553    ///
554    /// At building-scale world coordinates (e.g. ~220 m) an f32 mantissa only
555    /// resolves ~15 µm, so two genuinely-distinct vertices less than one ULP
556    /// apart round to the *same* (or near-same) f32 value. The triangle that
557    /// joined them becomes a zero-area sliver — and when its third vertex is far
558    /// away, a long thin "fan" that visibly spans the model (the gross
559    /// corruption seen on large georeferenced buildings).
560    ///
561    /// These slivers carry effectively no area, so the neighbouring triangles of
562    /// the same face already cover the surface; removing them is visually
563    /// lossless while eliminating the fans. The proper fix (local-frame / tiled
564    /// vertex storage) keeps the vertices distinct in the first place; this is
565    /// the backstop for meshes that still arrive degenerate.
566    ///
567    /// Conservative by design — only drops triangles that are *unambiguously*
568    /// garbage: a bit-identical f32 vertex pair (exact zero area) or an aspect
569    /// ratio (longest edge / shortest edge) above 1e5. Legitimate thin members
570    /// (mullions, braces) sit far below that. Only `indices` change; the vertex
571    /// buffer and per-vertex data are left intact, so the operation is
572    /// deterministic and keeps vertex indices stable.
573    pub fn drop_degenerate_triangles(&mut self) {
574        if self.indices.len() < 3 {
575            return;
576        }
577        const MAX_ASPECT: f64 = 1.0e5;
578        let vertex_count = self.positions.len() / 3;
579        let vert = |i: u32| -> Option<[f64; 3]> {
580            let i = i as usize;
581            if i >= vertex_count {
582                return None;
583            }
584            Some([
585                self.positions[i * 3] as f64,
586                self.positions[i * 3 + 1] as f64,
587                self.positions[i * 3 + 2] as f64,
588            ])
589        };
590        let bits = |i: u32| -> [u32; 3] {
591            let i = i as usize;
592            [
593                self.positions[i * 3].to_bits(),
594                self.positions[i * 3 + 1].to_bits(),
595                self.positions[i * 3 + 2].to_bits(),
596            ]
597        };
598        let dist = |a: [f64; 3], b: [f64; 3]| -> f64 {
599            ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
600        };
601        // #3988: compact only after a drop; bound retained capacity as before.
602        let original_len = self.indices.len();
603        let mut kept = 0;
604        for read in (0..original_len / 3 * 3).step_by(3) {
605            let tri = [self.indices[read], self.indices[read + 1], self.indices[read + 2]];
606            let (ia, ib, ic) = (tri[0], tri[1], tri[2]);
607            // Guard before bits() indexes positions; invalid triangles are dropped.
608            if ia as usize >= vertex_count
609                || ib as usize >= vertex_count
610                || ic as usize >= vertex_count
611            {
612                continue;
613            }
614            // Bit-identical f32 vertex pair → exact zero-area collapse.
615            let (ba, bb, bc) = (bits(ia), bits(ib), bits(ic));
616            if ba == bb || bb == bc || ba == bc {
617                continue;
618            }
619            let (va, vb, vc) = match (vert(ia), vert(ib), vert(ic)) {
620                (Some(a), Some(b), Some(c)) => (a, b, c),
621                _ => continue, // out-of-range index: drop (matches validate_indices)
622            };
623            let e0 = dist(va, vb);
624            let e1 = dist(vb, vc);
625            let e2 = dist(vc, va);
626            let min_edge = e0.min(e1).min(e2);
627            let max_edge = e0.max(e1).max(e2);
628            // Drop catastrophic needles; bit-identical zero edges were handled above.
629            if min_edge > 0.0 && max_edge / min_edge > MAX_ASPECT {
630                continue;
631            }
632            if kept != read { self.indices[kept..kept + 3].copy_from_slice(&tri); }
633            kept += 3;
634        }
635        self.indices.truncate(kept);
636        self.indices.shrink_to(original_len);
637    }
638    /// Check if mesh is empty
639    #[inline]
640    pub fn is_empty(&self) -> bool {
641        self.positions.is_empty()
642    }
643
644    /// Calculate bounds (min, max) - optimized with chunk iteration
645    #[inline]
646    pub fn bounds(&self) -> (Point3<f32>, Point3<f32>) {
647        if self.is_empty() {
648            return (Point3::origin(), Point3::origin());
649        }
650
651        let mut min = Point3::new(f32::MAX, f32::MAX, f32::MAX);
652        let mut max = Point3::new(f32::MIN, f32::MIN, f32::MIN);
653
654        // Use chunks for better cache locality
655        self.positions.chunks_exact(3).for_each(|chunk| {
656            let (x, y, z) = (chunk[0], chunk[1], chunk[2]);
657            min.x = min.x.min(x);
658            min.y = min.y.min(y);
659            min.z = min.z.min(z);
660            max.x = max.x.max(x);
661            max.y = max.y.max(y);
662            max.z = max.z.max(z);
663        });
664
665        (min, max)
666    }
667
668    /// Calculate centroid in f64 precision (for RTC offset calculation)
669    /// Returns the average of all vertex positions
670    #[inline]
671    pub fn centroid_f64(&self) -> Point3<f64> {
672        if self.is_empty() {
673            return Point3::origin();
674        }
675
676        let mut sum = Point3::new(0.0f64, 0.0f64, 0.0f64);
677        let count = self.positions.len() / 3;
678
679        self.positions.chunks_exact(3).for_each(|chunk| {
680            sum.x += chunk[0] as f64;
681            sum.y += chunk[1] as f64;
682            sum.z += chunk[2] as f64;
683        });
684
685        Point3::new(
686            sum.x / count as f64,
687            sum.y / count as f64,
688            sum.z / count as f64,
689        )
690    }
691
692    /// Clear the mesh
693    #[inline]
694    pub fn clear(&mut self) {
695        self.positions.clear();
696        self.normals.clear();
697        self.indices.clear();
698        self.rtc_applied = false;
699        // Reset instancing metadata so a cleared+reused mesh can't carry stale
700        // rep-identity / transform into unrelated geometry. (#1238 review)
701        self.instance_meta = None;
702        // Same concern for the local-bounds/placement capture (issue #1474).
703        self.local_bounds = None;
704        self.local_to_world = None;
705    }
706
707    /// Weld vertices that share a position, regardless of normal.
708    ///
709    /// Returns a new mesh where vertices at the same position (within
710    /// `position_eps`) collapse to one canonical vertex; the welded
711    /// vertex's normal is the sum of contributing normals, re-normalized
712    /// (or a neutral up-Z `(0, 0, 1)` default if the sum is degenerate,
713    /// e.g. exactly opposing normals cancelling out).
714    /// Triangles that collapse to a degenerate edge or point are dropped.
715    ///
716    /// **Use this when you need a topologically connected, manifold-
717    /// candidate mesh** — volume queries, CSG operands, watertight
718    /// checks, mesh repair pipelines. Shading at sharp corners gets
719    /// averaged.
720    ///
721    /// `position_eps` is the bucket size in metres (1 µm is a safe
722    /// default for IFC).
723    pub fn welded_by_position(&self, position_eps: f32) -> Mesh {
724        weld_impl(self, position_eps, /*average_normals=*/ true)
725    }
726
727    /// Drop triangles whose perpendicular height (= 2·area / longest edge) is
728    /// below `h_eps` metres — i.e. genuinely-degenerate **collinear** slivers
729    /// (three distinct but near-collinear vertices, zero area). These come from
730    /// redundant collinear vertices in source brep faces / extrusion profiles
731    /// triangulated as-is; vertex welding can't merge them (the vertices are
732    /// distinct), so this catches them. At `h_eps` ≈ 15 µm — far below any real
733    /// architectural feature — the dropped triangles carry no area, so the
734    /// surrounding triangulation still covers the face (visually lossless).
735    /// Only `indices` change.
736    ///
737    /// NOT watertight-preserving on its own: when the sliver's apex is a vertex
738    /// the neighbours still use (an extrusion's side walls, the next brep face),
739    /// dropping it leaves a T-junction (#5313). Use
740    /// [`clean_degenerate_watertight`](Self::clean_degenerate_watertight) where
741    /// the surface must stay closed.
742    pub fn drop_thin_triangles(&mut self, h_eps: f64) {
743        self.drop_thin_triangles_inner(h_eps, false);
744    }
745
746    fn drop_thin_triangles_inner(&mut self, h_eps: f64, keep_closed: bool) {
747        if self.indices.len() < 3 {
748            return;
749        }
750        let vertex_count = self.positions.len() / 3;
751        let mut kept = Vec::with_capacity(self.indices.len());
752        let mut slivers: Vec<[u32; 3]> = Vec::new();
753        for tri in self.indices.chunks_exact(3) {
754            if (tri[0] as usize) >= vertex_count
755                || (tri[1] as usize) >= vertex_count
756                || (tri[2] as usize) >= vertex_count
757            {
758                continue;
759            }
760            let [a, b, c] = [tri[0], tri[1], tri[2]].map(|i| tjunction::pos_of(self, i));
761            let Some(height) = tjunction::height(a, b, c) else {
762                continue; // fully collapsed
763            };
764            if height < h_eps {
765                slivers.push([tri[0], tri[1], tri[2]]);
766                continue; // collinear / zero-area sliver
767            }
768            kept.extend_from_slice(tri);
769        }
770        if keep_closed && !slivers.is_empty() {
771            tjunction::split_across_dropped_slivers(self, &mut kept, &slivers, h_eps);
772        }
773        self.indices = kept;
774    }
775
776    /// Mesh hygiene applied to every element mesh before it leaves the router.
777    ///
778    /// Restores the cleanup the pure-Rust pipeline lost when #1024 removed
779    /// Manifold (which implicitly dropped degenerate output). Without it,
780    /// redundant/near-collinear source vertices in faceted breps and extrusion
781    /// profiles get triangulated into visible needle "spikes" and jagged
782    /// silhouettes (the regression reported on large breps); BIMcollab and
783    /// other viewers don't show them because they clean degenerates on import.
784    ///
785    /// Deliberately **does not weld vertices**: that is the source weld's job
786    /// (`mesh_weld`, run from the placement appliers), and that weld keys on the
787    /// quantized NORMAL too, so two facets meeting at a crease keep their own
788    /// vertices and stay sharp (issue #846). Here we drop only
789    /// the genuinely-degenerate triangles via
790    /// [`drop_thin_triangles`](Self::drop_thin_triangles) below the kernel's
791    /// reconcile grid (`1/65536 ≈ 15.3 µm`): coincident-pair needles (area 0)
792    /// and collinear slivers (three distinct near-collinear vertices). The grid
793    /// is the kernel's own representable resolution, so sub-grid triangles are
794    /// degenerate by definition; measured triangle counts are flat from
795    /// 10–50 µm and only start touching real geometry at ~100 µm (6.5× higher),
796    /// confirming nothing real lives in that band. Positions/normals are left
797    /// untouched, so it is visually lossless and bit-deterministic.
798    ///
799    /// The 15.3 µm threshold is most precise when applied in a small-magnitude
800    /// (element-local) frame, where f32 positions resolve well below it — which
801    /// the tessellation chokepoints honour (they clean *before* world
802    /// placement). The void-cut output is cleaned in world coordinates (the cut
803    /// runs there), so on a model georeferenced a few hundred metres to ~10 km
804    /// from origin — below the RTC re-basing threshold — the f32 grid at that
805    /// magnitude approaches the threshold and the margin near opening seams
806    /// erodes slightly; the `longest <= 0` guard still catches full collapse at
807    /// extreme scale. NaN/Inf triangles are kept (the comparison is false),
808    /// i.e. non-finite geometry is left for upstream to handle, never dropped.
809    pub fn clean_degenerate(&mut self) {
810        // The kernel's canonical reconcile grid (power-of-two for
811        // bit-determinism). Sub-grid triangles are below kernel resolution.
812        self.drop_thin_triangles(crate::kernel::mesh_bridge::SNAP_GRID);
813    }
814
815    /// [`clean_degenerate`](Self::clean_degenerate) for SOURCE geometry that
816    /// must stay closed: drops the same slivers, then splits the triangle
817    /// across each dropped sliver's long edge at the sliver's apex whenever a
818    /// kept triangle still uses that apex, so the drop cannot open a
819    /// T-junction (#5313). May append vertices (an apex copy carrying the
820    /// split face's normal), so it is only for meshes with no parallel
821    /// per-vertex array outside `Mesh` (the textured channel's UVs rule it out
822    /// there). The router's source-hygiene chokepoints use it; the void/CSG
823    /// paths keep `clean_degenerate`, whose output their own closure gates and
824    /// census judge.
825    pub fn clean_degenerate_watertight(&mut self) {
826        self.drop_thin_triangles_inner(crate::kernel::mesh_bridge::SNAP_GRID, true);
827    }
828
829    /// Drop triangles with ANY vertex outside `[min - pad, max + pad]`, then
830    /// compact away the now-unreferenced vertices. Returns the count dropped.
831    ///
832    /// Boolean subtraction can only REMOVE material, so the cut of a host whose
833    /// pre-cut AABB is `[min, max]` is mathematically contained in that AABB.
834    /// A malformed cutter — self-intersecting, or carrying garbage vertices
835    /// metres from the real opening (e.g. an exporter that welds stray points
836    /// into a tessellated void, the multi-body-cutter case) — can make the
837    /// exact mesh-arrangement leak a spurious far-flung "flap" triangle into the
838    /// output: a visible spike poking metres out of the wall. Such a triangle
839    /// only appears once a SECOND cutter perturbs the arrangement, so it slips
840    /// past the per-cutter admission guards. Any output vertex beyond the host
841    /// AABB (past `pad`, which absorbs kernel snap / f64→f32 round-trip jitter)
842    /// is provably such an artifact, so the triangle is dropped and its orphaned
843    /// vertices removed (they would otherwise skew `bounds()` and every
844    /// AABB-derived consumer: framing, picking, clash, export).
845    ///
846    /// A no-op on clean cuts — when nothing lies outside, `positions`/`normals`
847    /// are left bit-identical so the frozen snapshot corpus is unperturbed. Also
848    /// a no-op in the degenerate case where EVERY triangle would be dropped (an
849    /// upstream frame/placement bug, not a cut artifact): the mesh is preserved
850    /// rather than silently emptied.
851    pub fn clip_triangles_to_aabb(&mut self, min: [f32; 3], max: [f32; 3], pad: f32) -> usize {
852        if self.indices.is_empty() {
853            return 0;
854        }
855        let lo = [min[0] - pad, min[1] - pad, min[2] - pad];
856        let hi = [max[0] + pad, max[1] + pad, max[2] + pad];
857        let inside = |i: u32| -> bool {
858            let b = i as usize * 3;
859            let (x, y, z) = (self.positions[b], self.positions[b + 1], self.positions[b + 2]);
860            x >= lo[0] && x <= hi[0] && y >= lo[1] && y <= hi[1] && z >= lo[2] && z <= hi[2]
861        };
862        let tri_count = self.indices.len() / 3;
863        let mut kept: Vec<u32> = Vec::with_capacity(self.indices.len());
864        for t in self.indices.chunks_exact(3) {
865            if inside(t[0]) && inside(t[1]) && inside(t[2]) {
866                kept.extend_from_slice(t);
867            }
868        }
869        let dropped = tri_count - kept.len() / 3;
870        // No-op when nothing protrudes (bit-identical) or when the whole mesh
871        // would vanish (preserve it — that signals a bug elsewhere, not a spike).
872        if dropped == 0 || kept.is_empty() {
873            return 0;
874        }
875        // Compact: remap referenced vertices, drop orphans.
876        let has_normals = self.normals.len() == self.positions.len();
877        let mut remap: Vec<i32> = vec![-1; self.positions.len() / 3];
878        let mut new_pos: Vec<f32> = Vec::with_capacity(kept.len() * 3);
879        let mut new_nrm: Vec<f32> = Vec::with_capacity(if has_normals { kept.len() * 3 } else { 0 });
880        let mut new_idx: Vec<u32> = Vec::with_capacity(kept.len());
881        for &i in &kept {
882            let old = i as usize;
883            let slot = if remap[old] < 0 {
884                let n = (new_pos.len() / 3) as u32;
885                remap[old] = n as i32;
886                new_pos.extend_from_slice(&self.positions[old * 3..old * 3 + 3]);
887                if has_normals {
888                    new_nrm.extend_from_slice(&self.normals[old * 3..old * 3 + 3]);
889                }
890                n
891            } else {
892                remap[old] as u32
893            };
894            new_idx.push(slot);
895        }
896        self.positions = new_pos;
897        if has_normals {
898            self.normals = new_nrm;
899        } else {
900            // Per-vertex normal array was absent or already inconsistent; clear
901            // it so a stale, mis-indexed buffer never ships downstream.
902            self.normals.clear();
903        }
904        self.indices = new_idx;
905        dropped
906    }
907
908    /// Clip a void-cut result to the host's pre-cut AABB `[min, max]`, dropping
909    /// any triangle poking beyond it (see [`Mesh::clip_triangles_to_aabb`]). A
910    /// subtract can only remove material, so anything past the host AABB is a cut
911    /// artifact. The tolerance absorbs f64→f32 round-trip jitter (sub-mm), so it
912    /// is a small ABSOLUTE band, NOT a fraction of host size: an unbounded
913    /// `1e-3 * diag` reaches 0.13 m on a 130 m floor slab — wider than the
914    /// ~0.105 m flush-cap reveal overhang it must trap, which is why only large
915    /// slabs/roofs leaked it (a 5 m wall's 5 mm pad already trims the identical
916    /// overhang, #1633). Clamped to [5 mm, 10 mm]: byte-identical to the former
917    /// `1e-3 * diag` for hosts ≤ 10 m diagonal (`1e-3 * diag ≤ 1e-2`), trimming
918    /// on every larger one. Returns the count dropped.
919    pub fn clip_triangles_to_host_aabb(&mut self, min: [f32; 3], max: [f32; 3]) -> usize {
920        // Widen to f64 BEFORE subtracting (not `(max - min) as f64`) so `diag`,
921        // and thus `pad`, is bit-for-bit what the former inline `wall_max.x -
922        // wall_min.x` (f64) computed — the clamp is the only intended change.
923        let diag = ((max[0] as f64 - min[0] as f64).powi(2)
924            + (max[1] as f64 - min[1] as f64).powi(2)
925            + (max[2] as f64 - min[2] as f64).powi(2))
926        .sqrt();
927        let pad = (1.0e-3 * diag).clamp(5.0e-3, 1.0e-2) as f32;
928        self.clip_triangles_to_aabb(min, max, pad)
929    }
930}
931
932impl Default for Mesh {
933    fn default() -> Self {
934        Self::new()
935    }
936}
937
938#[path = "mesh_position_weld.rs"]
939mod mesh_position_weld;
940use mesh_position_weld::weld_impl;
941
942#[path = "mesh_tjunction.rs"]
943mod tjunction;
944
945#[cfg(test)]
946#[path = "mesh_tests.rs"]
947mod tests;