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;