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}