Skip to main content

oxiphysics_geometry/
mesh_ops.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Triangle mesh operations: normals, volume, surface area, point containment.
5
6use oxiphysics_core::math::Vec3;
7use std::collections::HashMap;
8
9/// Vertex indices for a triangle.
10pub type Triangle = [usize; 3];
11
12/// A simple triangle mesh.
13#[derive(Debug, Clone)]
14pub struct TriMesh {
15    /// Vertices of the mesh.
16    pub vertices: Vec<Vec3>,
17    /// Triangles defined by vertex indices.
18    pub triangles: Vec<Triangle>,
19}
20
21impl TriMesh {
22    /// Create a new triangle mesh.
23    pub fn new(vertices: Vec<Vec3>, triangles: Vec<Triangle>) -> Self {
24        Self {
25            vertices,
26            triangles,
27        }
28    }
29
30    /// Compute the face normal for triangle at `tri_idx`.
31    ///
32    /// Returns `cross(v1-v0, v2-v0).normalize()`.
33    pub fn face_normal(&self, tri_idx: usize) -> Vec3 {
34        let tri = &self.triangles[tri_idx];
35        let v0 = self.vertices[tri[0]];
36        let v1 = self.vertices[tri[1]];
37        let v2 = self.vertices[tri[2]];
38        let e1 = v1 - v0;
39        let e2 = v2 - v0;
40        e1.cross(&e2).normalize()
41    }
42
43    /// Volume of a closed mesh using the divergence theorem (signed volume sum).
44    ///
45    /// Assumes a watertight mesh with consistent winding.
46    /// `V = (1/6) * Σ (v0 · (v1 × v2))`
47    pub fn signed_volume(&self) -> f64 {
48        let mut sum = 0.0_f64;
49        for tri in &self.triangles {
50            let v0 = self.vertices[tri[0]];
51            let v1 = self.vertices[tri[1]];
52            let v2 = self.vertices[tri[2]];
53            sum += v0.dot(&v1.cross(&v2));
54        }
55        sum / 6.0
56    }
57
58    /// Surface area of the mesh.
59    ///
60    /// Sum of triangle areas = `0.5 * |cross(e1, e2)|`.
61    pub fn surface_area(&self) -> f64 {
62        let mut area = 0.0_f64;
63        for tri in &self.triangles {
64            let v0 = self.vertices[tri[0]];
65            let v1 = self.vertices[tri[1]];
66            let v2 = self.vertices[tri[2]];
67            let e1 = v1 - v0;
68            let e2 = v2 - v0;
69            area += 0.5 * e1.cross(&e2).norm();
70        }
71        area
72    }
73
74    /// Compute vertex normals by averaging adjacent face normals.
75    pub fn vertex_normals(&self) -> Vec<Vec3> {
76        let n = self.vertices.len();
77        let mut normals = vec![Vec3::zeros(); n];
78        for tri in &self.triangles {
79            let v0 = self.vertices[tri[0]];
80            let v1 = self.vertices[tri[1]];
81            let v2 = self.vertices[tri[2]];
82            let e1 = v1 - v0;
83            let e2 = v2 - v0;
84            let face_n = e1.cross(&e2); // weighted by area (no normalize yet)
85            normals[tri[0]] += face_n;
86            normals[tri[1]] += face_n;
87            normals[tri[2]] += face_n;
88        }
89        normals
90            .into_iter()
91            .map(|n| {
92                let len = n.norm();
93                if len < 1e-10 {
94                    Vec3::new(0.0, 1.0, 0.0)
95                } else {
96                    n / len
97                }
98            })
99            .collect()
100    }
101
102    /// Check if a point is inside the mesh using ray casting in the +Z direction.
103    ///
104    /// Counts intersections; odd count means inside. Assumes watertight mesh.
105    pub fn contains_point(&self, point: Vec3) -> bool {
106        let mut count = 0usize;
107        for tri in &self.triangles {
108            let v0 = self.vertices[tri[0]];
109            let v1 = self.vertices[tri[1]];
110            let v2 = self.vertices[tri[2]];
111            if ray_intersects_triangle_z(point, v0, v1, v2) {
112                count += 1;
113            }
114        }
115        count % 2 == 1
116    }
117
118    /// Generate a UV sphere mesh for testing purposes.
119    ///
120    /// `rings` is the number of latitude rings (excluding poles),
121    /// `sectors` is the number of longitude sectors.
122    pub fn uv_sphere(radius: f64, rings: u32, sectors: u32) -> Self {
123        use std::f64::consts::PI;
124        let mut vertices = Vec::new();
125        let mut triangles = Vec::new();
126
127        // Top pole
128        vertices.push(Vec3::new(0.0, radius, 0.0));
129
130        // Middle rings
131        for ring in 1..=rings {
132            let phi = PI * ring as f64 / (rings + 1) as f64; // 0..PI
133            let y = radius * phi.cos();
134            let r_xz = radius * phi.sin();
135            for sector in 0..sectors {
136                let theta = 2.0 * PI * sector as f64 / sectors as f64;
137                let x = r_xz * theta.cos();
138                let z = r_xz * theta.sin();
139                vertices.push(Vec3::new(x, y, z));
140            }
141        }
142
143        // Bottom pole
144        vertices.push(Vec3::new(0.0, -radius, 0.0));
145
146        let top_idx = 0usize;
147        let bottom_idx = vertices.len() - 1;
148
149        // Triangles from top pole to first ring (outward normals: CCW from outside)
150        // Vertices on the first ring go CCW when viewed from above (+Y).
151        // Winding [top, b, a] gives outward normal at the top.
152        for s in 0..sectors as usize {
153            let next_s = (s + 1) % sectors as usize;
154            let a = 1 + s;
155            let b = 1 + next_s;
156            triangles.push([top_idx, b, a]);
157        }
158
159        // Quads for middle rings
160        // Upper ring: row_start; lower ring: next_row_start.
161        // For outward normals (viewing sphere from outside = CCW winding).
162        for ring in 0..(rings as usize - 1) {
163            let row_start = 1 + ring * sectors as usize;
164            let next_row_start = row_start + sectors as usize;
165            for s in 0..sectors as usize {
166                let next_s = (s + 1) % sectors as usize;
167                let a = row_start + s; // upper-left
168                let b = row_start + next_s; // upper-right
169                let c = next_row_start + s; // lower-left
170                let d = next_row_start + next_s; // lower-right
171                // Two triangles per quad, outward CCW
172                triangles.push([a, b, c]);
173                triangles.push([b, d, c]);
174            }
175        }
176
177        // Triangles from last ring to bottom pole (outward normals).
178        let last_ring_start = 1 + (rings as usize - 1) * sectors as usize;
179        for s in 0..sectors as usize {
180            let next_s = (s + 1) % sectors as usize;
181            let a = last_ring_start + s;
182            let b = last_ring_start + next_s;
183            triangles.push([a, b, bottom_idx]);
184        }
185
186        Self {
187            vertices,
188            triangles,
189        }
190    }
191
192    /// Compute the axis-aligned bounding box of the mesh.
193    ///
194    /// Returns `(min, max)` corners. Panics if the mesh has no vertices.
195    pub fn bounding_box(&self) -> (Vec3, Vec3) {
196        let mut min = self.vertices[0];
197        let mut max = self.vertices[0];
198        for v in &self.vertices {
199            min.x = min.x.min(v.x);
200            min.y = min.y.min(v.y);
201            min.z = min.z.min(v.z);
202            max.x = max.x.max(v.x);
203            max.y = max.y.max(v.y);
204            max.z = max.z.max(v.z);
205        }
206        (min, max)
207    }
208}
209
210// ─── Standalone mesh operation helpers ──────────────────────────────────────
211
212#[inline]
213fn mo_sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
214    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
215}
216
217#[inline]
218fn mo_add(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
219    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
220}
221
222#[inline]
223fn mo_scale(a: [f64; 3], s: f64) -> [f64; 3] {
224    [a[0] * s, a[1] * s, a[2] * s]
225}
226
227#[inline]
228fn mo_cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
229    [
230        a[1] * b[2] - a[2] * b[1],
231        a[2] * b[0] - a[0] * b[2],
232        a[0] * b[1] - a[1] * b[0],
233    ]
234}
235
236#[inline]
237fn mo_norm(a: [f64; 3]) -> f64 {
238    (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt()
239}
240
241#[inline]
242fn mo_normalize(a: [f64; 3]) -> [f64; 3] {
243    let n = mo_norm(a);
244    if n < 1e-12 {
245        [0.0, 1.0, 0.0]
246    } else {
247        mo_scale(a, 1.0 / n)
248    }
249}
250
251// ─── compute_face_normals ────────────────────────────────────────────────────
252
253/// Compute a unit normal for every triangle in the mesh.
254///
255/// The normal is `normalize(cross(v1 - v0, v2 - v0))`.
256/// If the triangle is degenerate (zero area) a zero vector is returned.
257pub fn compute_face_normals(verts: &[[f64; 3]], tris: &[[usize; 3]]) -> Vec<[f64; 3]> {
258    tris.iter()
259        .map(|tri| {
260            let v0 = verts[tri[0]];
261            let v1 = verts[tri[1]];
262            let v2 = verts[tri[2]];
263            let e1 = mo_sub(v1, v0);
264            let e2 = mo_sub(v2, v0);
265            let c = mo_cross(e1, e2);
266            let len = mo_norm(c);
267            if len < 1e-14 {
268                [0.0; 3]
269            } else {
270                mo_scale(c, 1.0 / len)
271            }
272        })
273        .collect()
274}
275
276// ─── compute_vertex_normals ──────────────────────────────────────────────────
277
278/// Compute per-vertex normals using area-weighted averaging of adjacent face normals.
279///
280/// Each face contributes its area-weighted normal (`cross(e1, e2)`) to its
281/// three vertices; the accumulated vectors are then normalized.
282pub fn compute_vertex_normals(verts: &[[f64; 3]], tris: &[[usize; 3]]) -> Vec<[f64; 3]> {
283    let n = verts.len();
284    let mut acc: Vec<[f64; 3]> = vec![[0.0; 3]; n];
285    for tri in tris {
286        let v0 = verts[tri[0]];
287        let v1 = verts[tri[1]];
288        let v2 = verts[tri[2]];
289        let e1 = mo_sub(v1, v0);
290        let e2 = mo_sub(v2, v0);
291        let fn_ = mo_cross(e1, e2); // area-weighted face normal
292        for &vi in tri {
293            acc[vi] = mo_add(acc[vi], fn_);
294        }
295    }
296    acc.into_iter().map(mo_normalize).collect()
297}
298
299// ─── weld_vertices ───────────────────────────────────────────────────────────
300
301/// Merge vertices that are closer than `tol` into a single representative vertex.
302///
303/// Returns the de-duplicated vertex array and remapped triangle indices.
304/// This is an O(n²) brute-force implementation suitable for small meshes.
305pub fn weld_vertices(
306    verts: &[[f64; 3]],
307    tris: &[[usize; 3]],
308    tol: f64,
309) -> (Vec<[f64; 3]>, Vec<[usize; 3]>) {
310    let tol2 = tol * tol;
311    let mut new_verts: Vec<[f64; 3]> = Vec::with_capacity(verts.len());
312    let mut remap: Vec<usize> = vec![0; verts.len()];
313
314    for (i, &v) in verts.iter().enumerate() {
315        // Search for an existing vertex within tolerance
316        let mut found = None;
317        for (j, &w) in new_verts.iter().enumerate() {
318            let d = mo_sub(v, w);
319            if d[0] * d[0] + d[1] * d[1] + d[2] * d[2] <= tol2 {
320                found = Some(j);
321                break;
322            }
323        }
324        remap[i] = match found {
325            Some(j) => j,
326            None => {
327                let idx = new_verts.len();
328                new_verts.push(v);
329                idx
330            }
331        };
332    }
333
334    let new_tris: Vec<[usize; 3]> = tris
335        .iter()
336        .map(|tri| [remap[tri[0]], remap[tri[1]], remap[tri[2]]])
337        .collect();
338
339    (new_verts, new_tris)
340}
341
342// ─── compute_adjacency ───────────────────────────────────────────────────────
343
344/// Build a vertex-to-vertex adjacency list from a triangle mesh.
345///
346/// Returns a `Vec` of length `n_verts`, where each element is the sorted
347/// list of vertex indices adjacent to vertex `i`.
348pub fn compute_adjacency(tris: &[[usize; 3]], n_verts: usize) -> Vec<Vec<usize>> {
349    let mut adj: Vec<std::collections::BTreeSet<usize>> =
350        vec![std::collections::BTreeSet::new(); n_verts];
351    for tri in tris {
352        let [a, b, c] = *tri;
353        adj[a].insert(b);
354        adj[a].insert(c);
355        adj[b].insert(a);
356        adj[b].insert(c);
357        adj[c].insert(a);
358        adj[c].insert(b);
359    }
360    adj.into_iter().map(|s| s.into_iter().collect()).collect()
361}
362
363// ─── is_watertight ───────────────────────────────────────────────────────────
364
365/// Return `true` if the mesh is watertight (every edge is shared by exactly two faces).
366///
367/// A mesh is watertight (manifold, no boundary) when the edge–face incidence
368/// count for every directed edge (v_a, v_b) satisfies:
369/// `count(a→b) == 1` for all edges when counting undirected pairs,
370/// which is equivalent to each unordered edge having exactly 2 incident triangles.
371pub fn is_watertight(tris: &[[usize; 3]]) -> bool {
372    use std::collections::HashMap;
373    let mut edge_count: HashMap<(usize, usize), usize> = HashMap::new();
374    for tri in tris {
375        let [a, b, c] = *tri;
376        let edges = [
377            (a.min(b), a.max(b)),
378            (b.min(c), b.max(c)),
379            (a.min(c), a.max(c)),
380        ];
381        for e in edges {
382            *edge_count.entry(e).or_insert(0) += 1;
383        }
384    }
385    edge_count.values().all(|&cnt| cnt == 2)
386}
387
388// ─── compute_genus ───────────────────────────────────────────────────────────
389
390/// Compute the genus of a closed orientable triangulated surface via the
391/// Euler characteristic: `χ = V - E + F = 2 - 2g`.
392///
393/// Returns `g = (2 - χ) / 2`.  For a topological sphere `g = 0`.
394pub fn compute_genus(n_verts: usize, tris: &[[usize; 3]]) -> i32 {
395    use std::collections::HashSet;
396    let v = n_verts as i32;
397    let f = tris.len() as i32;
398    let mut edges: HashSet<(usize, usize)> = HashSet::new();
399    for tri in tris {
400        let [a, b, c] = *tri;
401        edges.insert((a.min(b), a.max(b)));
402        edges.insert((b.min(c), b.max(c)));
403        edges.insert((a.min(c), a.max(c)));
404    }
405    let e = edges.len() as i32;
406    let chi = v - e + f; // Euler characteristic
407    (2 - chi) / 2
408}
409
410// ─── triangulate_polygon ─────────────────────────────────────────────────────
411
412/// Triangulate a simple convex or concave planar polygon using ear clipping.
413///
414/// `polygon` must be a list of at least 3 coplanar vertices in order.  The
415/// function returns triangles as index triples (into the input slice).
416///
417/// # Panics
418/// Panics if `polygon.len() < 3`.
419pub fn triangulate_polygon(polygon: &[[f64; 3]]) -> Vec<[usize; 3]> {
420    let n = polygon.len();
421    assert!(n >= 3, "triangulate_polygon needs at least 3 vertices");
422    if n == 3 {
423        return vec![[0, 1, 2]];
424    }
425
426    // Work with indices
427    let mut remaining: Vec<usize> = (0..n).collect();
428    let mut result = Vec::with_capacity(n - 2);
429
430    // Compute polygon normal via Newell's method for robust 2-D projection
431    let mut poly_normal = [0.0f64; 3];
432    for i in 0..n {
433        let j = (i + 1) % n;
434        let p = polygon[i];
435        let q = polygon[j];
436        poly_normal[0] += (p[1] - q[1]) * (p[2] + q[2]);
437        poly_normal[1] += (p[2] - q[2]) * (p[0] + q[0]);
438        poly_normal[2] += (p[0] - q[0]) * (p[1] + q[1]);
439    }
440    let poly_normal = mo_normalize(poly_normal);
441
442    let mut iterations = 0;
443    while remaining.len() > 3 {
444        let m = remaining.len();
445        let mut ear_found = false;
446
447        for i in 0..m {
448            let prev = remaining[(i + m - 1) % m];
449            let curr = remaining[i];
450            let next = remaining[(i + 1) % m];
451
452            let a = polygon[prev];
453            let b = polygon[curr];
454            let c = polygon[next];
455
456            // Check convexity: cross(AB, AC) must align with polygon normal
457            let ab = mo_sub(b, a);
458            let ac = mo_sub(c, a);
459            let cross = mo_cross(ab, ac);
460            let dot_with_normal =
461                cross[0] * poly_normal[0] + cross[1] * poly_normal[1] + cross[2] * poly_normal[2];
462            if dot_with_normal < 0.0 {
463                continue; // reflex angle
464            }
465
466            // Check no other vertex is inside this ear triangle
467            let is_ear = remaining.iter().all(|&vi| {
468                if vi == prev || vi == curr || vi == next {
469                    return true;
470                }
471                let p = polygon[vi];
472                !point_in_triangle_3d(p, a, b, c, poly_normal)
473            });
474
475            if is_ear {
476                result.push([prev, curr, next]);
477                remaining.remove(i);
478                ear_found = true;
479                break;
480            }
481        }
482
483        if !ear_found {
484            // Fallback: force-clip to avoid infinite loop on degenerate input
485            if remaining.len() >= 3 {
486                result.push([remaining[0], remaining[1], remaining[2]]);
487                remaining.remove(1);
488            } else {
489                break;
490            }
491        }
492
493        iterations += 1;
494        if iterations > n * n {
495            break; // safety exit
496        }
497    }
498
499    if remaining.len() == 3 {
500        result.push([remaining[0], remaining[1], remaining[2]]);
501    }
502
503    result
504}
505
506/// Test whether `p` lies inside triangle `(a, b, c)` by checking the sign
507/// of barycentric coordinates projected onto the dominant axis of `normal`.
508fn point_in_triangle_3d(
509    p: [f64; 3],
510    a: [f64; 3],
511    b: [f64; 3],
512    c: [f64; 3],
513    normal: [f64; 3],
514) -> bool {
515    // Use same-side test (cross products w.r.t. each edge must align with normal)
516    let ab = mo_sub(b, a);
517    let bc = mo_sub(c, b);
518    let ca = mo_sub(a, c);
519    let ap = mo_sub(p, a);
520    let bp = mo_sub(p, b);
521    let cp = mo_sub(p, c);
522
523    let n0 = mo_cross(ab, ap);
524    let n1 = mo_cross(bc, bp);
525    let n2 = mo_cross(ca, cp);
526
527    let dot_n = |v: [f64; 3]| v[0] * normal[0] + v[1] * normal[1] + v[2] * normal[2];
528
529    let d0 = dot_n(n0);
530    let d1 = dot_n(n1);
531    let d2 = dot_n(n2);
532
533    d0 >= 0.0 && d1 >= 0.0 && d2 >= 0.0
534}
535
536/// Ray-triangle intersection for a ray from `origin` in the +Z direction.
537///
538/// Returns true if the ray intersects the triangle and the intersection is in front (z > origin.z).
539fn ray_intersects_triangle_z(origin: Vec3, v0: Vec3, v1: Vec3, v2: Vec3) -> bool {
540    // Möller–Trumbore for ray direction = (0, 0, 1)
541    let dir = Vec3::new(0.0, 0.0, 1.0);
542    let edge1 = v1 - v0;
543    let edge2 = v2 - v0;
544    let h = dir.cross(&edge2);
545    let det = edge1.dot(&h);
546    if det.abs() < 1e-10 {
547        return false;
548    }
549    let inv_det = 1.0 / det;
550    let s = origin - v0;
551    let u = inv_det * s.dot(&h);
552    if !(0.0..=1.0).contains(&u) {
553        return false;
554    }
555    let q = s.cross(&edge1);
556    let v = inv_det * dir.dot(&q);
557    if v < 0.0 || u + v > 1.0 {
558        return false;
559    }
560    let t = inv_det * edge2.dot(&q);
561    t > 0.0
562}
563
564// ─── Quadric Error Mesh Decimation ───────────────────────────────────────────
565
566/// Quadric Error Metrics (QEM) mesh decimation.
567///
568/// Iteratively collapses the `n_collapses` cheapest edges (by quadric error).
569/// Returns the simplified `(verts, tris)`.
570pub fn quadric_decimate(
571    verts: &[[f64; 3]],
572    tris: &[[usize; 3]],
573    n_collapses: usize,
574) -> (Vec<[f64; 3]>, Vec<[usize; 3]>) {
575    let n = verts.len();
576    // Build per-vertex quadric matrices (4×4 symmetric, stored as upper triangle).
577    // Quadric Q_v = sum of (n·p=0) plane equations for all adjacent faces.
578    // We store Q as a flat [10] array for 4×4 symmetric: indices (0,0)(0,1)(0,2)(0,3)(1,1)(1,2)(1,3)(2,2)(2,3)(3,3)
579    #[allow(non_snake_case)]
580    let mut q_mat: Vec<[f64; 10]> = vec![[0.0; 10]; n];
581
582    for tri in tris {
583        let a = verts[tri[0]];
584        let b = verts[tri[1]];
585        let c = verts[tri[2]];
586        let e1 = mo_sub(b, a);
587        let e2 = mo_sub(c, a);
588        let nn = mo_cross(e1, e2);
589        let len = mo_norm(nn);
590        if len < 1e-14 {
591            continue;
592        }
593        let n4 = [
594            nn[0] / len,
595            nn[1] / len,
596            nn[2] / len,
597            -(nn[0] * a[0] + nn[1] * a[1] + nn[2] * a[2]) / len,
598        ];
599        // Outer product n4 n4^T (symmetric)
600        for &vi in tri {
601            let q = &mut q_mat[vi];
602            q[0] += n4[0] * n4[0];
603            q[1] += n4[0] * n4[1];
604            q[2] += n4[0] * n4[2];
605            q[3] += n4[0] * n4[3];
606            q[4] += n4[1] * n4[1];
607            q[5] += n4[1] * n4[2];
608            q[6] += n4[1] * n4[3];
609            q[7] += n4[2] * n4[2];
610            q[8] += n4[2] * n4[3];
611            q[9] += n4[3] * n4[3];
612        }
613    }
614
615    let mut cur_verts: Vec<[f64; 3]> = verts.to_vec();
616    let mut cur_tris: Vec<[usize; 3]> = tris.to_vec();
617    let mut remap: Vec<usize> = (0..n).collect();
618
619    let add_q = |qa: &[f64; 10], qb: &[f64; 10]| -> [f64; 10] {
620        let mut r = [0.0f64; 10];
621        for i in 0..10 {
622            r[i] = qa[i] + qb[i];
623        }
624        r
625    };
626
627    let eval_q = |q: &[f64; 10], v: [f64; 3]| -> f64 {
628        let p = [v[0], v[1], v[2], 1.0];
629        // p^T Q p (symmetric 4×4)
630        let mut result = 0.0;
631        let idx = |r: usize, c: usize| -> usize {
632            if r <= c {
633                r * 4 - r * (r - 1) / 2 + c - r
634            } else {
635                c * 4 - c * (c - 1) / 2 + r - c
636            }
637        };
638        // Direct expansion for 4×4 symmetric
639        result += q[0] * p[0] * p[0]
640            + 2.0 * q[1] * p[0] * p[1]
641            + 2.0 * q[2] * p[0] * p[2]
642            + 2.0 * q[3] * p[0] * p[3];
643        result += q[4] * p[1] * p[1] + 2.0 * q[5] * p[1] * p[2] + 2.0 * q[6] * p[1] * p[3];
644        result += q[7] * p[2] * p[2] + 2.0 * q[8] * p[2] * p[3];
645        result += q[9] * p[3] * p[3];
646        let _ = idx;
647        result
648    };
649
650    for _ in 0..n_collapses {
651        // Find the edge with minimum quadric error cost
652        let mut best_cost = f64::INFINITY;
653        let mut best_edge = (usize::MAX, usize::MAX);
654        let mut best_pos = [0.0f64; 3];
655
656        // Collect all unique edges
657        let mut edge_set = std::collections::BTreeSet::new();
658        for tri in &cur_tris {
659            for k in 0..3 {
660                let a = remap[tri[k]];
661                let b = remap[tri[(k + 1) % 3]];
662                let key = (a.min(b), a.max(b));
663                edge_set.insert(key);
664            }
665        }
666
667        for (ea, eb) in &edge_set {
668            let qa = &q_mat[*ea];
669            let qb = &q_mat[*eb];
670            let qsum = add_q(qa, qb);
671            // Use midpoint as the new vertex position (optimal for QEM requires solving 3x3 system)
672            let mid = [
673                (cur_verts[*ea][0] + cur_verts[*eb][0]) * 0.5,
674                (cur_verts[*ea][1] + cur_verts[*eb][1]) * 0.5,
675                (cur_verts[*ea][2] + cur_verts[*eb][2]) * 0.5,
676            ];
677            let cost = eval_q(&qsum, mid);
678            if cost < best_cost {
679                best_cost = cost;
680                best_edge = (*ea, *eb);
681                best_pos = mid;
682            }
683        }
684
685        if best_edge.0 == usize::MAX {
686            break;
687        }
688
689        let (ea, eb) = best_edge;
690        // Merge eb into ea
691        cur_verts[ea] = best_pos;
692        let qa = q_mat[ea];
693        let qb = q_mat[eb];
694        q_mat[ea] = add_q(&qa, &qb);
695
696        // Update remap: all eb references → ea
697        for r in remap.iter_mut() {
698            if *r == eb {
699                *r = ea;
700            }
701        }
702
703        // Update triangles and remove degenerate
704        cur_tris = cur_tris
705            .iter()
706            .map(|tri| [remap[tri[0]], remap[tri[1]], remap[tri[2]]])
707            .filter(|tri| tri[0] != tri[1] && tri[1] != tri[2] && tri[0] != tri[2])
708            .collect();
709    }
710
711    (cur_verts, cur_tris)
712}
713
714// ─── Laplacian Smoothing ──────────────────────────────────────────────────────
715
716/// Apply `iterations` passes of Laplacian smoothing to `verts`.
717///
718/// Each pass moves each vertex toward the average of its neighbors by `lambda`.
719/// Returns the smoothed vertex array.
720pub fn laplacian_smooth(
721    verts: &[[f64; 3]],
722    tris: &[[usize; 3]],
723    iterations: usize,
724    lambda: f64,
725) -> Vec<[f64; 3]> {
726    let mut v = verts.to_vec();
727    let adj = compute_adjacency(tris, verts.len());
728    for _ in 0..iterations {
729        let prev = v.clone();
730        for i in 0..v.len() {
731            if adj[i].is_empty() {
732                continue;
733            }
734            let mut avg = [0.0f64; 3];
735            for &nb in &adj[i] {
736                avg[0] += prev[nb][0];
737                avg[1] += prev[nb][1];
738                avg[2] += prev[nb][2];
739            }
740            let k = adj[i].len() as f64;
741            avg[0] /= k;
742            avg[1] /= k;
743            avg[2] /= k;
744            v[i][0] += lambda * (avg[0] - prev[i][0]);
745            v[i][1] += lambda * (avg[1] - prev[i][1]);
746            v[i][2] += lambda * (avg[2] - prev[i][2]);
747        }
748    }
749    v
750}
751
752// ─── Mesh Merging ─────────────────────────────────────────────────────────────
753
754/// Merge two meshes (verts + tris) into one by concatenating vertices and
755/// offsetting triangle indices of the second mesh.
756pub fn merge_meshes(
757    verts1: &[[f64; 3]],
758    tris1: &[[usize; 3]],
759    verts2: &[[f64; 3]],
760    tris2: &[[usize; 3]],
761) -> (Vec<[f64; 3]>, Vec<[usize; 3]>) {
762    let offset = verts1.len();
763    let mut verts = verts1.to_vec();
764    verts.extend_from_slice(verts2);
765    let mut tris = tris1.to_vec();
766    for tri in tris2 {
767        tris.push([tri[0] + offset, tri[1] + offset, tri[2] + offset]);
768    }
769    (verts, tris)
770}
771
772// ─── Edge Split ───────────────────────────────────────────────────────────────
773
774/// Split edge `(v0, v1)` in the mesh by inserting a midpoint vertex.
775///
776/// All triangles containing that edge are split into two.  Updates `verts` and `tris` in place.
777pub fn split_edge(verts: &mut Vec<[f64; 3]>, tris: &mut Vec<[usize; 3]>, v0: usize, v1: usize) {
778    let mid_idx = verts.len();
779    let mid = [
780        (verts[v0][0] + verts[v1][0]) * 0.5,
781        (verts[v0][1] + verts[v1][1]) * 0.5,
782        (verts[v0][2] + verts[v1][2]) * 0.5,
783    ];
784    verts.push(mid);
785
786    let mut new_tris: Vec<[usize; 3]> = Vec::new();
787    let mut keep = vec![true; tris.len()];
788
789    for (ti, tri) in tris.iter().enumerate() {
790        let has_edge = (tri[0] == v0 || tri[1] == v0 || tri[2] == v0)
791            && (tri[0] == v1 || tri[1] == v1 || tri[2] == v1);
792        if has_edge {
793            keep[ti] = false;
794            // Find the third vertex
795            let opp = tri
796                .iter()
797                .copied()
798                .find(|&v| v != v0 && v != v1)
799                .unwrap_or(v0);
800            new_tris.push([v0, mid_idx, opp]);
801            new_tris.push([mid_idx, v1, opp]);
802        }
803    }
804
805    let kept: Vec<[usize; 3]> = tris
806        .iter()
807        .enumerate()
808        .filter_map(|(i, &t)| if keep[i] { Some(t) } else { None })
809        .collect();
810    *tris = kept;
811    tris.extend(new_tris);
812}
813
814// ─── Boundary Edge Detection ──────────────────────────────────────────────────
815
816/// Find all boundary edges: edges shared by exactly one triangle.
817///
818/// Returns a list of `(v0, v1)` edge pairs.
819pub fn find_boundary_edges(tris: &[[usize; 3]]) -> Vec<(usize, usize)> {
820    let mut edge_count: HashMap<(usize, usize), usize> = HashMap::new();
821    for tri in tris {
822        let edges = [
823            (tri[0].min(tri[1]), tri[0].max(tri[1])),
824            (tri[1].min(tri[2]), tri[1].max(tri[2])),
825            (tri[0].min(tri[2]), tri[0].max(tri[2])),
826        ];
827        for e in edges {
828            *edge_count.entry(e).or_insert(0) += 1;
829        }
830    }
831    edge_count
832        .into_iter()
833        .filter(|(_, cnt)| *cnt == 1)
834        .map(|(e, _)| e)
835        .collect()
836}
837
838// ─── Mesh Simplification (vertex clustering) ─────────────────────────────────
839
840/// Cluster vertices into a grid of `grid_res × grid_res × grid_res` cells.
841///
842/// Vertices in the same cell are merged to the cell centroid.
843/// Returns `(new_verts, new_tris)` with degenerates removed.
844pub fn vertex_cluster_simplify(
845    verts: &[[f64; 3]],
846    tris: &[[usize; 3]],
847    grid_res: usize,
848) -> (Vec<[f64; 3]>, Vec<[usize; 3]>) {
849    if verts.is_empty() || grid_res == 0 {
850        return (verts.to_vec(), tris.to_vec());
851    }
852
853    // Compute bounding box
854    let mut bb_min = verts[0];
855    let mut bb_max = verts[0];
856    for v in verts {
857        for k in 0..3 {
858            if v[k] < bb_min[k] {
859                bb_min[k] = v[k];
860            }
861            if v[k] > bb_max[k] {
862                bb_max[k] = v[k];
863            }
864        }
865    }
866    let extent = [
867        (bb_max[0] - bb_min[0]).max(1e-12),
868        (bb_max[1] - bb_min[1]).max(1e-12),
869        (bb_max[2] - bb_min[2]).max(1e-12),
870    ];
871
872    let cell = |v: &[f64; 3]| -> (usize, usize, usize) {
873        let cx = ((v[0] - bb_min[0]) / extent[0] * grid_res as f64) as usize;
874        let cy = ((v[1] - bb_min[1]) / extent[1] * grid_res as f64) as usize;
875        let cz = ((v[2] - bb_min[2]) / extent[2] * grid_res as f64) as usize;
876        (
877            cx.min(grid_res - 1),
878            cy.min(grid_res - 1),
879            cz.min(grid_res - 1),
880        )
881    };
882
883    let mut cell_to_idx: HashMap<(usize, usize, usize), usize> = HashMap::new();
884    let mut cell_sums: Vec<[f64; 3]> = Vec::new();
885    let mut cell_counts: Vec<usize> = Vec::new();
886    let mut remap: Vec<usize> = vec![0; verts.len()];
887
888    for (i, v) in verts.iter().enumerate() {
889        let c = cell(v);
890        let idx = *cell_to_idx.entry(c).or_insert_with(|| {
891            let idx = cell_sums.len();
892            cell_sums.push([0.0; 3]);
893            cell_counts.push(0);
894            idx
895        });
896        cell_sums[idx][0] += v[0];
897        cell_sums[idx][1] += v[1];
898        cell_sums[idx][2] += v[2];
899        cell_counts[idx] += 1;
900        remap[i] = idx;
901    }
902
903    let new_verts: Vec<[f64; 3]> = cell_sums
904        .iter()
905        .zip(cell_counts.iter())
906        .map(|(s, &c)| {
907            let cf = c as f64;
908            [s[0] / cf, s[1] / cf, s[2] / cf]
909        })
910        .collect();
911
912    let new_tris: Vec<[usize; 3]> = tris
913        .iter()
914        .map(|tri| [remap[tri[0]], remap[tri[1]], remap[tri[2]]])
915        .filter(|tri| tri[0] != tri[1] && tri[1] != tri[2] && tri[0] != tri[2])
916        .collect();
917
918    (new_verts, new_tris)
919}
920
921// ─── Mesh Statistics ──────────────────────────────────────────────────────────
922
923/// Compute mesh statistics: min/max/average edge length.
924pub fn edge_length_stats(verts: &[[f64; 3]], tris: &[[usize; 3]]) -> (f64, f64, f64) {
925    let mut min_len = f64::INFINITY;
926    let mut max_len = 0.0f64;
927    let mut total_len = 0.0f64;
928    let mut count = 0usize;
929
930    use std::collections::BTreeSet;
931    let mut seen: BTreeSet<(usize, usize)> = BTreeSet::new();
932    for tri in tris {
933        for k in 0..3 {
934            let a = tri[k];
935            let b = tri[(k + 1) % 3];
936            let key = (a.min(b), a.max(b));
937            if seen.insert(key) {
938                let d = mo_norm(mo_sub(verts[b], verts[a]));
939                min_len = min_len.min(d);
940                max_len = max_len.max(d);
941                total_len += d;
942                count += 1;
943            }
944        }
945    }
946    let avg = if count > 0 {
947        total_len / count as f64
948    } else {
949        0.0
950    };
951    (min_len, max_len, avg)
952}
953
954#[cfg(test)]
955mod tests {
956    use super::*;
957    use std::f64::consts::PI;
958
959    #[test]
960    fn test_trimesh_face_normal() {
961        // Triangle in XY plane with vertices CCW → normal should point +Z
962        let verts = vec![
963            Vec3::new(0.0, 0.0, 0.0),
964            Vec3::new(1.0, 0.0, 0.0),
965            Vec3::new(0.0, 1.0, 0.0),
966        ];
967        let tris = vec![[0, 1, 2]];
968        let mesh = TriMesh::new(verts, tris);
969        let n = mesh.face_normal(0);
970        assert!((n.z - 1.0).abs() < 1e-10, "normal.z={}, expected 1.0", n.z);
971        assert!(n.x.abs() < 1e-10);
972        assert!(n.y.abs() < 1e-10);
973    }
974
975    #[test]
976    fn test_trimesh_volume_sphere() {
977        let mesh = TriMesh::uv_sphere(1.0, 20, 40);
978        let vol = mesh.signed_volume().abs();
979        let expected = (4.0 / 3.0) * PI; // ≈ 4.189
980        let rel_err = (vol - expected).abs() / expected;
981        assert!(
982            rel_err < 0.01,
983            "sphere volume: got={}, expected≈{}, rel_err={}",
984            vol,
985            expected,
986            rel_err
987        );
988    }
989
990    #[test]
991    fn test_trimesh_surface_area_sphere() {
992        let mesh = TriMesh::uv_sphere(1.0, 20, 40);
993        let sa = mesh.surface_area();
994        let expected = 4.0 * PI; // ≈ 12.566
995        let rel_err = (sa - expected).abs() / expected;
996        assert!(
997            rel_err < 0.01,
998            "sphere surface area: got={}, expected≈{}, rel_err={}",
999            sa,
1000            expected,
1001            rel_err
1002        );
1003    }
1004
1005    #[test]
1006    fn test_trimesh_vertex_normals() {
1007        // For a unit sphere mesh, vertex normals should point roughly outward
1008        // (close to normalized vertex position)
1009        let mesh = TriMesh::uv_sphere(1.0, 20, 40);
1010        let vnormals = mesh.vertex_normals();
1011        for (i, (v, n)) in mesh.vertices.iter().zip(vnormals.iter()).enumerate() {
1012            let v_len = v.norm();
1013            if v_len < 1e-6 {
1014                continue; // skip degenerate poles
1015            }
1016            let v_norm = v / v_len;
1017            let dot = v_norm.dot(n);
1018            assert!(
1019                dot > 0.0,
1020                "vertex {} normal does not point outward: dot={}",
1021                i,
1022                dot
1023            );
1024        }
1025    }
1026
1027    #[test]
1028    fn test_trimesh_contains_point() {
1029        let mesh = TriMesh::uv_sphere(1.0, 20, 40);
1030        // Center of sphere → inside
1031        let inside = mesh.contains_point(Vec3::new(0.0, 0.0, 0.0));
1032        assert!(inside, "center of sphere should be inside");
1033        // Far point → outside
1034        let outside = mesh.contains_point(Vec3::new(10.0, 0.0, 0.0));
1035        assert!(!outside, "far point should be outside");
1036    }
1037
1038    #[test]
1039    fn test_trimesh_bounding_box() {
1040        let r = 1.5;
1041        let mesh = TriMesh::uv_sphere(r, 20, 40);
1042        let (min, max) = mesh.bounding_box();
1043        // All coordinates should be within [-r, r]
1044        assert!(min.x >= -r - 1e-10, "min.x={} < -r={}", min.x, -r);
1045        assert!(min.y >= -r - 1e-10, "min.y={} < -r={}", min.y, -r);
1046        assert!(min.z >= -r - 1e-10, "min.z={} < -r={}", min.z, -r);
1047        assert!(max.x <= r + 1e-10, "max.x={} > r={}", max.x, r);
1048        assert!(max.y <= r + 1e-10, "max.y={} > r={}", max.y, r);
1049        assert!(max.z <= r + 1e-10, "max.z={} > r={}", max.z, r);
1050        // Max should be close to r in each dimension
1051        assert!(max.x > r * 0.9, "max.x={} too small", max.x);
1052        assert!(max.y > r * 0.9, "max.y={} too small", max.y);
1053        assert!(max.z > r * 0.9, "max.z={} too small", max.z);
1054    }
1055
1056    // ── standalone function tests ────────────────────────────────────────────
1057
1058    /// Build a simple unit tetrahedron (4 triangles, watertight).
1059    fn unit_tetrahedron() -> (Vec<[f64; 3]>, Vec<[usize; 3]>) {
1060        let verts = vec![
1061            [0.0f64, 0.0, 0.0],
1062            [1.0, 0.0, 0.0],
1063            [0.5, 1.0, 0.0],
1064            [0.5, 0.5, 1.0],
1065        ];
1066        let tris = vec![[0usize, 2, 1], [0, 1, 3], [1, 2, 3], [0, 3, 2]];
1067        (verts, tris)
1068    }
1069
1070    #[test]
1071    fn test_compute_face_normals_unit_length() {
1072        let (verts, tris) = unit_tetrahedron();
1073        let normals = compute_face_normals(&verts, &tris);
1074        assert_eq!(normals.len(), tris.len());
1075        for (i, n) in normals.iter().enumerate() {
1076            let len = (n[0].powi(2) + n[1].powi(2) + n[2].powi(2)).sqrt();
1077            assert!(
1078                (len - 1.0).abs() < 1e-10,
1079                "face {i} normal not unit: len={len}"
1080            );
1081        }
1082    }
1083
1084    #[test]
1085    fn test_compute_face_normals_xy_plane() {
1086        // Triangle in XY plane → normal = [0, 0, ±1]
1087        let verts = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1088        let tris = vec![[0usize, 1, 2]];
1089        let normals = compute_face_normals(&verts, &tris);
1090        assert!(
1091            (normals[0][2].abs() - 1.0).abs() < 1e-10,
1092            "nz={}",
1093            normals[0][2]
1094        );
1095    }
1096
1097    #[test]
1098    fn test_compute_vertex_normals_count() {
1099        let (verts, tris) = unit_tetrahedron();
1100        let vnormals = compute_vertex_normals(&verts, &tris);
1101        assert_eq!(vnormals.len(), verts.len());
1102    }
1103
1104    #[test]
1105    fn test_compute_vertex_normals_unit_length() {
1106        let (verts, tris) = unit_tetrahedron();
1107        let vnormals = compute_vertex_normals(&verts, &tris);
1108        for (i, n) in vnormals.iter().enumerate() {
1109            let len = (n[0].powi(2) + n[1].powi(2) + n[2].powi(2)).sqrt();
1110            assert!(
1111                (len - 1.0).abs() < 1e-10,
1112                "vertex {i} normal not unit: {len}"
1113            );
1114        }
1115    }
1116
1117    #[test]
1118    fn test_weld_vertices_basic() {
1119        // Two vertices that are essentially the same within tolerance
1120        let verts = vec![
1121            [0.0f64, 0.0, 0.0],
1122            [0.0, 0.0, 1e-10], // < tol=1e-6 away from above
1123            [1.0, 0.0, 0.0],
1124        ];
1125        let tris = vec![[0usize, 1, 2]];
1126        let (new_verts, _new_tris) = weld_vertices(&verts, &tris, 1e-6);
1127        assert_eq!(new_verts.len(), 2, "expected 2 unique verts after weld");
1128    }
1129
1130    #[test]
1131    fn test_weld_vertices_no_merge() {
1132        // Vertices that are far apart should not be merged
1133        let verts = vec![[0.0f64, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]];
1134        let tris = vec![[0usize, 1, 2]];
1135        let (new_verts, _) = weld_vertices(&verts, &tris, 1e-6);
1136        assert_eq!(new_verts.len(), 3);
1137    }
1138
1139    #[test]
1140    fn test_compute_adjacency_tetrahedron() {
1141        let (verts, tris) = unit_tetrahedron();
1142        let adj = compute_adjacency(&tris, verts.len());
1143        // In a tetrahedron every vertex connects to every other
1144        assert_eq!(adj.len(), 4);
1145        for (i, neighbors) in adj.iter().enumerate() {
1146            assert_eq!(neighbors.len(), 3, "vertex {i} should have 3 neighbors");
1147        }
1148    }
1149
1150    #[test]
1151    fn test_is_watertight_tetrahedron() {
1152        let (_verts, tris) = unit_tetrahedron();
1153        assert!(is_watertight(&tris), "tetrahedron should be watertight");
1154    }
1155
1156    #[test]
1157    fn test_is_watertight_open_mesh() {
1158        // A single triangle has 3 boundary edges → not watertight
1159        let tris = vec![[0usize, 1, 2]];
1160        assert!(
1161            !is_watertight(&tris),
1162            "single triangle should not be watertight"
1163        );
1164    }
1165
1166    #[test]
1167    fn test_compute_genus_tetrahedron() {
1168        // Tetrahedron: V=4, E=6, F=4 → χ=2 → g=0
1169        let (verts, tris) = unit_tetrahedron();
1170        let g = compute_genus(verts.len(), &tris);
1171        assert_eq!(g, 0, "tetrahedron genus should be 0");
1172    }
1173
1174    #[test]
1175    fn test_triangulate_polygon_triangle() {
1176        let poly = vec![[0.0f64, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 1.0, 0.0]];
1177        let result = triangulate_polygon(&poly);
1178        assert_eq!(result.len(), 1, "triangle polygon = 1 tri");
1179        assert_eq!(result[0], [0, 1, 2]);
1180    }
1181
1182    #[test]
1183    fn test_triangulate_polygon_quad() {
1184        // A convex quad in XY should yield 2 triangles
1185        let poly = vec![
1186            [0.0f64, 0.0, 0.0],
1187            [1.0, 0.0, 0.0],
1188            [1.0, 1.0, 0.0],
1189            [0.0, 1.0, 0.0],
1190        ];
1191        let result = triangulate_polygon(&poly);
1192        assert_eq!(result.len(), 2, "quad polygon = 2 tris");
1193    }
1194
1195    #[test]
1196    fn test_triangulate_polygon_pentagon() {
1197        // A convex pentagon should yield 3 triangles
1198        let poly: Vec<[f64; 3]> = (0..5)
1199            .map(|i| {
1200                let a = 2.0 * std::f64::consts::PI * i as f64 / 5.0;
1201                [a.cos(), a.sin(), 0.0]
1202            })
1203            .collect();
1204        let result = triangulate_polygon(&poly);
1205        assert_eq!(result.len(), 3, "pentagon polygon = 3 tris");
1206    }
1207
1208    // ── Quadric error decimation tests ────────────────────────────────────────
1209
1210    #[test]
1211    fn test_decimate_reduces_triangles() {
1212        let (verts, tris) = unit_tetrahedron();
1213        let (_, new_tris) = quadric_decimate(&verts, &tris, 2);
1214        // After removing 2 edges, should have fewer triangles (or equal if no valid collapses)
1215        assert!(
1216            new_tris.len() <= tris.len(),
1217            "decimation should not increase triangles"
1218        );
1219    }
1220
1221    #[test]
1222    fn test_decimate_preserves_non_degenerate_tris() {
1223        let (verts, tris) = unit_tetrahedron();
1224        let (new_verts, new_tris) = quadric_decimate(&verts, &tris, 1);
1225        for tri in &new_tris {
1226            let a = new_verts[tri[0]];
1227            let b = new_verts[tri[1]];
1228            let c = new_verts[tri[2]];
1229            let e1 = mo_sub(b, a);
1230            let e2 = mo_sub(c, a);
1231            let cross = mo_cross(e1, e2);
1232            let area = mo_norm(cross) * 0.5;
1233            assert!(area >= 0.0, "negative area triangle");
1234        }
1235    }
1236
1237    // ── Laplacian smoothing tests ─────────────────────────────────────────────
1238
1239    #[test]
1240    fn test_laplacian_smooth_reduces_roughness() {
1241        // Create a rough mesh: tetrahedron with one vertex displaced
1242        let (mut verts, tris) = unit_tetrahedron();
1243        verts[0] = [0.5, 0.5, 0.5]; // displace from origin
1244        let smoothed = laplacian_smooth(&verts, &tris, 5, 0.5);
1245        // Smoothed version should have vertex 0 closer to its neighbors' average
1246        assert_eq!(smoothed.len(), verts.len());
1247    }
1248
1249    #[test]
1250    fn test_laplacian_smooth_returns_correct_count() {
1251        let (verts, tris) = unit_tetrahedron();
1252        let smoothed = laplacian_smooth(&verts, &tris, 3, 0.5);
1253        assert_eq!(
1254            smoothed.len(),
1255            verts.len(),
1256            "smoothed should have same vertex count"
1257        );
1258    }
1259
1260    // ── Mesh merging tests ────────────────────────────────────────────────────
1261
1262    #[test]
1263    fn test_merge_meshes_vertex_count() {
1264        let (v1, t1) = unit_tetrahedron();
1265        let v2: Vec<[f64; 3]> = v1.iter().map(|&v| [v[0] + 5.0, v[1], v[2]]).collect();
1266        let (mv, mt) = merge_meshes(&v1, &t1, &v2, &t1);
1267        assert_eq!(mv.len(), v1.len() + v2.len(), "merged vertices");
1268        assert_eq!(mt.len(), t1.len() * 2, "merged triangles");
1269    }
1270
1271    #[test]
1272    fn test_merge_meshes_index_offset() {
1273        let (v1, t1) = unit_tetrahedron();
1274        let v2: Vec<[f64; 3]> = v1.iter().map(|&v| [v[0] + 5.0, v[1], v[2]]).collect();
1275        let (mv, mt) = merge_meshes(&v1, &t1, &v2, &t1);
1276        // Second mesh triangles should reference indices >= v1.len()
1277        for tri in &mt[t1.len()..] {
1278            for &vi in tri {
1279                assert!(vi >= v1.len(), "index {} should be offset", vi);
1280            }
1281        }
1282        assert_eq!(mv.len(), v1.len() + v2.len());
1283    }
1284
1285    // ── Edge split tests ──────────────────────────────────────────────────────
1286
1287    #[test]
1288    fn test_split_edge_increases_vertices() {
1289        let (mut verts, mut tris) = unit_tetrahedron();
1290        let n_before = verts.len();
1291        split_edge(&mut verts, &mut tris, 0, 1);
1292        assert_eq!(verts.len(), n_before + 1, "split should add one vertex");
1293    }
1294
1295    #[test]
1296    fn test_split_edge_increases_triangles() {
1297        let (mut verts, mut tris) = unit_tetrahedron();
1298        let n_before = tris.len();
1299        split_edge(&mut verts, &mut tris, 0, 1);
1300        assert!(tris.len() > n_before, "split should add triangles");
1301    }
1302
1303    // ── Boundary detection tests ──────────────────────────────────────────────
1304
1305    #[test]
1306    fn test_find_boundary_edges_tetrahedron() {
1307        let (_verts, tris) = unit_tetrahedron();
1308        let boundary = find_boundary_edges(&tris);
1309        // Closed tetrahedron has no boundary edges
1310        assert!(
1311            boundary.is_empty(),
1312            "tetrahedron has no boundary, got {:?}",
1313            boundary
1314        );
1315    }
1316
1317    #[test]
1318    fn test_find_boundary_edges_open_mesh() {
1319        // Two triangles sharing one edge → 4 boundary edges
1320        let tris = vec![[0usize, 1, 2], [1, 3, 2]];
1321        let boundary = find_boundary_edges(&tris);
1322        assert!(!boundary.is_empty(), "open mesh should have boundary edges");
1323    }
1324}