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    let mut q_mat: Vec<[f64; 10]> = vec![[0.0; 10]; n];
580
581    for tri in tris {
582        let a = verts[tri[0]];
583        let b = verts[tri[1]];
584        let c = verts[tri[2]];
585        let e1 = mo_sub(b, a);
586        let e2 = mo_sub(c, a);
587        let nn = mo_cross(e1, e2);
588        let len = mo_norm(nn);
589        if len < 1e-14 {
590            continue;
591        }
592        let n4 = [
593            nn[0] / len,
594            nn[1] / len,
595            nn[2] / len,
596            -(nn[0] * a[0] + nn[1] * a[1] + nn[2] * a[2]) / len,
597        ];
598        // Outer product n4 n4^T (symmetric)
599        for &vi in tri {
600            let q = &mut q_mat[vi];
601            q[0] += n4[0] * n4[0];
602            q[1] += n4[0] * n4[1];
603            q[2] += n4[0] * n4[2];
604            q[3] += n4[0] * n4[3];
605            q[4] += n4[1] * n4[1];
606            q[5] += n4[1] * n4[2];
607            q[6] += n4[1] * n4[3];
608            q[7] += n4[2] * n4[2];
609            q[8] += n4[2] * n4[3];
610            q[9] += n4[3] * n4[3];
611        }
612    }
613
614    let mut cur_verts: Vec<[f64; 3]> = verts.to_vec();
615    let mut cur_tris: Vec<[usize; 3]> = tris.to_vec();
616    let mut remap: Vec<usize> = (0..n).collect();
617
618    let add_q = |qa: &[f64; 10], qb: &[f64; 10]| -> [f64; 10] {
619        let mut r = [0.0f64; 10];
620        for i in 0..10 {
621            r[i] = qa[i] + qb[i];
622        }
623        r
624    };
625
626    let eval_q = |q: &[f64; 10], v: [f64; 3]| -> f64 {
627        let p = [v[0], v[1], v[2], 1.0];
628        // p^T Q p (symmetric 4×4)
629        let mut result = 0.0;
630        let idx = |r: usize, c: usize| -> usize {
631            if r <= c {
632                r * 4 - r * (r - 1) / 2 + c - r
633            } else {
634                c * 4 - c * (c - 1) / 2 + r - c
635            }
636        };
637        // Direct expansion for 4×4 symmetric
638        result += q[0] * p[0] * p[0]
639            + 2.0 * q[1] * p[0] * p[1]
640            + 2.0 * q[2] * p[0] * p[2]
641            + 2.0 * q[3] * p[0] * p[3];
642        result += q[4] * p[1] * p[1] + 2.0 * q[5] * p[1] * p[2] + 2.0 * q[6] * p[1] * p[3];
643        result += q[7] * p[2] * p[2] + 2.0 * q[8] * p[2] * p[3];
644        result += q[9] * p[3] * p[3];
645        let _ = idx;
646        result
647    };
648
649    for _ in 0..n_collapses {
650        // Find the edge with minimum quadric error cost
651        let mut best_cost = f64::INFINITY;
652        let mut best_edge = (usize::MAX, usize::MAX);
653        let mut best_pos = [0.0f64; 3];
654
655        // Collect all unique edges
656        let mut edge_set = std::collections::BTreeSet::new();
657        for tri in &cur_tris {
658            for k in 0..3 {
659                let a = remap[tri[k]];
660                let b = remap[tri[(k + 1) % 3]];
661                let key = (a.min(b), a.max(b));
662                edge_set.insert(key);
663            }
664        }
665
666        for (ea, eb) in &edge_set {
667            let qa = &q_mat[*ea];
668            let qb = &q_mat[*eb];
669            let qsum = add_q(qa, qb);
670            // Use midpoint as the new vertex position (optimal for QEM requires solving 3x3 system)
671            let mid = [
672                (cur_verts[*ea][0] + cur_verts[*eb][0]) * 0.5,
673                (cur_verts[*ea][1] + cur_verts[*eb][1]) * 0.5,
674                (cur_verts[*ea][2] + cur_verts[*eb][2]) * 0.5,
675            ];
676            let cost = eval_q(&qsum, mid);
677            if cost < best_cost {
678                best_cost = cost;
679                best_edge = (*ea, *eb);
680                best_pos = mid;
681            }
682        }
683
684        if best_edge.0 == usize::MAX {
685            break;
686        }
687
688        let (ea, eb) = best_edge;
689        // Merge eb into ea
690        cur_verts[ea] = best_pos;
691        let qa = q_mat[ea];
692        let qb = q_mat[eb];
693        q_mat[ea] = add_q(&qa, &qb);
694
695        // Update remap: all eb references → ea
696        for r in remap.iter_mut() {
697            if *r == eb {
698                *r = ea;
699            }
700        }
701
702        // Update triangles and remove degenerate
703        cur_tris = cur_tris
704            .iter()
705            .map(|tri| [remap[tri[0]], remap[tri[1]], remap[tri[2]]])
706            .filter(|tri| tri[0] != tri[1] && tri[1] != tri[2] && tri[0] != tri[2])
707            .collect();
708    }
709
710    (cur_verts, cur_tris)
711}
712
713// ─── Laplacian Smoothing ──────────────────────────────────────────────────────
714
715/// Apply `iterations` passes of Laplacian smoothing to `verts`.
716///
717/// Each pass moves each vertex toward the average of its neighbors by `lambda`.
718/// Returns the smoothed vertex array.
719pub fn laplacian_smooth(
720    verts: &[[f64; 3]],
721    tris: &[[usize; 3]],
722    iterations: usize,
723    lambda: f64,
724) -> Vec<[f64; 3]> {
725    let mut v = verts.to_vec();
726    let adj = compute_adjacency(tris, verts.len());
727    for _ in 0..iterations {
728        let prev = v.clone();
729        for i in 0..v.len() {
730            if adj[i].is_empty() {
731                continue;
732            }
733            let mut avg = [0.0f64; 3];
734            for &nb in &adj[i] {
735                avg[0] += prev[nb][0];
736                avg[1] += prev[nb][1];
737                avg[2] += prev[nb][2];
738            }
739            let k = adj[i].len() as f64;
740            avg[0] /= k;
741            avg[1] /= k;
742            avg[2] /= k;
743            v[i][0] += lambda * (avg[0] - prev[i][0]);
744            v[i][1] += lambda * (avg[1] - prev[i][1]);
745            v[i][2] += lambda * (avg[2] - prev[i][2]);
746        }
747    }
748    v
749}
750
751// ─── Mesh Merging ─────────────────────────────────────────────────────────────
752
753/// Merge two meshes (verts + tris) into one by concatenating vertices and
754/// offsetting triangle indices of the second mesh.
755pub fn merge_meshes(
756    verts1: &[[f64; 3]],
757    tris1: &[[usize; 3]],
758    verts2: &[[f64; 3]],
759    tris2: &[[usize; 3]],
760) -> (Vec<[f64; 3]>, Vec<[usize; 3]>) {
761    let offset = verts1.len();
762    let mut verts = verts1.to_vec();
763    verts.extend_from_slice(verts2);
764    let mut tris = tris1.to_vec();
765    for tri in tris2 {
766        tris.push([tri[0] + offset, tri[1] + offset, tri[2] + offset]);
767    }
768    (verts, tris)
769}
770
771// ─── Edge Split ───────────────────────────────────────────────────────────────
772
773/// Split edge `(v0, v1)` in the mesh by inserting a midpoint vertex.
774///
775/// All triangles containing that edge are split into two.  Updates `verts` and `tris` in place.
776pub fn split_edge(verts: &mut Vec<[f64; 3]>, tris: &mut Vec<[usize; 3]>, v0: usize, v1: usize) {
777    let mid_idx = verts.len();
778    let mid = [
779        (verts[v0][0] + verts[v1][0]) * 0.5,
780        (verts[v0][1] + verts[v1][1]) * 0.5,
781        (verts[v0][2] + verts[v1][2]) * 0.5,
782    ];
783    verts.push(mid);
784
785    let mut new_tris: Vec<[usize; 3]> = Vec::new();
786    let mut keep = vec![true; tris.len()];
787
788    for (ti, tri) in tris.iter().enumerate() {
789        let has_edge = (tri[0] == v0 || tri[1] == v0 || tri[2] == v0)
790            && (tri[0] == v1 || tri[1] == v1 || tri[2] == v1);
791        if has_edge {
792            keep[ti] = false;
793            // Find the third vertex
794            let opp = tri
795                .iter()
796                .copied()
797                .find(|&v| v != v0 && v != v1)
798                .unwrap_or(v0);
799            new_tris.push([v0, mid_idx, opp]);
800            new_tris.push([mid_idx, v1, opp]);
801        }
802    }
803
804    let kept: Vec<[usize; 3]> = tris
805        .iter()
806        .enumerate()
807        .filter_map(|(i, &t)| if keep[i] { Some(t) } else { None })
808        .collect();
809    *tris = kept;
810    tris.extend(new_tris);
811}
812
813// ─── Boundary Edge Detection ──────────────────────────────────────────────────
814
815/// Find all boundary edges: edges shared by exactly one triangle.
816///
817/// Returns a list of `(v0, v1)` edge pairs.
818pub fn find_boundary_edges(tris: &[[usize; 3]]) -> Vec<(usize, usize)> {
819    let mut edge_count: HashMap<(usize, usize), usize> = HashMap::new();
820    for tri in tris {
821        let edges = [
822            (tri[0].min(tri[1]), tri[0].max(tri[1])),
823            (tri[1].min(tri[2]), tri[1].max(tri[2])),
824            (tri[0].min(tri[2]), tri[0].max(tri[2])),
825        ];
826        for e in edges {
827            *edge_count.entry(e).or_insert(0) += 1;
828        }
829    }
830    edge_count
831        .into_iter()
832        .filter(|(_, cnt)| *cnt == 1)
833        .map(|(e, _)| e)
834        .collect()
835}
836
837// ─── Mesh Simplification (vertex clustering) ─────────────────────────────────
838
839/// Cluster vertices into a grid of `grid_res × grid_res × grid_res` cells.
840///
841/// Vertices in the same cell are merged to the cell centroid.
842/// Returns `(new_verts, new_tris)` with degenerates removed.
843pub fn vertex_cluster_simplify(
844    verts: &[[f64; 3]],
845    tris: &[[usize; 3]],
846    grid_res: usize,
847) -> (Vec<[f64; 3]>, Vec<[usize; 3]>) {
848    if verts.is_empty() || grid_res == 0 {
849        return (verts.to_vec(), tris.to_vec());
850    }
851
852    // Compute bounding box
853    let mut bb_min = verts[0];
854    let mut bb_max = verts[0];
855    for v in verts {
856        for k in 0..3 {
857            if v[k] < bb_min[k] {
858                bb_min[k] = v[k];
859            }
860            if v[k] > bb_max[k] {
861                bb_max[k] = v[k];
862            }
863        }
864    }
865    let extent = [
866        (bb_max[0] - bb_min[0]).max(1e-12),
867        (bb_max[1] - bb_min[1]).max(1e-12),
868        (bb_max[2] - bb_min[2]).max(1e-12),
869    ];
870
871    let cell = |v: &[f64; 3]| -> (usize, usize, usize) {
872        let cx = ((v[0] - bb_min[0]) / extent[0] * grid_res as f64) as usize;
873        let cy = ((v[1] - bb_min[1]) / extent[1] * grid_res as f64) as usize;
874        let cz = ((v[2] - bb_min[2]) / extent[2] * grid_res as f64) as usize;
875        (
876            cx.min(grid_res - 1),
877            cy.min(grid_res - 1),
878            cz.min(grid_res - 1),
879        )
880    };
881
882    let mut cell_to_idx: HashMap<(usize, usize, usize), usize> = HashMap::new();
883    let mut cell_sums: Vec<[f64; 3]> = Vec::new();
884    let mut cell_counts: Vec<usize> = Vec::new();
885    let mut remap: Vec<usize> = vec![0; verts.len()];
886
887    for (i, v) in verts.iter().enumerate() {
888        let c = cell(v);
889        let idx = *cell_to_idx.entry(c).or_insert_with(|| {
890            let idx = cell_sums.len();
891            cell_sums.push([0.0; 3]);
892            cell_counts.push(0);
893            idx
894        });
895        cell_sums[idx][0] += v[0];
896        cell_sums[idx][1] += v[1];
897        cell_sums[idx][2] += v[2];
898        cell_counts[idx] += 1;
899        remap[i] = idx;
900    }
901
902    let new_verts: Vec<[f64; 3]> = cell_sums
903        .iter()
904        .zip(cell_counts.iter())
905        .map(|(s, &c)| {
906            let cf = c as f64;
907            [s[0] / cf, s[1] / cf, s[2] / cf]
908        })
909        .collect();
910
911    let new_tris: Vec<[usize; 3]> = tris
912        .iter()
913        .map(|tri| [remap[tri[0]], remap[tri[1]], remap[tri[2]]])
914        .filter(|tri| tri[0] != tri[1] && tri[1] != tri[2] && tri[0] != tri[2])
915        .collect();
916
917    (new_verts, new_tris)
918}
919
920// ─── Mesh Statistics ──────────────────────────────────────────────────────────
921
922/// Compute mesh statistics: min/max/average edge length.
923pub fn edge_length_stats(verts: &[[f64; 3]], tris: &[[usize; 3]]) -> (f64, f64, f64) {
924    let mut min_len = f64::INFINITY;
925    let mut max_len = 0.0f64;
926    let mut total_len = 0.0f64;
927    let mut count = 0usize;
928
929    use std::collections::BTreeSet;
930    let mut seen: BTreeSet<(usize, usize)> = BTreeSet::new();
931    for tri in tris {
932        for k in 0..3 {
933            let a = tri[k];
934            let b = tri[(k + 1) % 3];
935            let key = (a.min(b), a.max(b));
936            if seen.insert(key) {
937                let d = mo_norm(mo_sub(verts[b], verts[a]));
938                min_len = min_len.min(d);
939                max_len = max_len.max(d);
940                total_len += d;
941                count += 1;
942            }
943        }
944    }
945    let avg = if count > 0 {
946        total_len / count as f64
947    } else {
948        0.0
949    };
950    (min_len, max_len, avg)
951}
952
953#[cfg(test)]
954mod tests {
955    use super::*;
956    use std::f64::consts::PI;
957
958    #[test]
959    fn test_trimesh_face_normal() {
960        // Triangle in XY plane with vertices CCW → normal should point +Z
961        let verts = vec![
962            Vec3::new(0.0, 0.0, 0.0),
963            Vec3::new(1.0, 0.0, 0.0),
964            Vec3::new(0.0, 1.0, 0.0),
965        ];
966        let tris = vec![[0, 1, 2]];
967        let mesh = TriMesh::new(verts, tris);
968        let n = mesh.face_normal(0);
969        assert!((n.z - 1.0).abs() < 1e-10, "normal.z={}, expected 1.0", n.z);
970        assert!(n.x.abs() < 1e-10);
971        assert!(n.y.abs() < 1e-10);
972    }
973
974    #[test]
975    fn test_trimesh_volume_sphere() {
976        let mesh = TriMesh::uv_sphere(1.0, 20, 40);
977        let vol = mesh.signed_volume().abs();
978        let expected = (4.0 / 3.0) * PI; // ≈ 4.189
979        let rel_err = (vol - expected).abs() / expected;
980        assert!(
981            rel_err < 0.01,
982            "sphere volume: got={}, expected≈{}, rel_err={}",
983            vol,
984            expected,
985            rel_err
986        );
987    }
988
989    #[test]
990    fn test_trimesh_surface_area_sphere() {
991        let mesh = TriMesh::uv_sphere(1.0, 20, 40);
992        let sa = mesh.surface_area();
993        let expected = 4.0 * PI; // ≈ 12.566
994        let rel_err = (sa - expected).abs() / expected;
995        assert!(
996            rel_err < 0.01,
997            "sphere surface area: got={}, expected≈{}, rel_err={}",
998            sa,
999            expected,
1000            rel_err
1001        );
1002    }
1003
1004    #[test]
1005    fn test_trimesh_vertex_normals() {
1006        // For a unit sphere mesh, vertex normals should point roughly outward
1007        // (close to normalized vertex position)
1008        let mesh = TriMesh::uv_sphere(1.0, 20, 40);
1009        let vnormals = mesh.vertex_normals();
1010        for (i, (v, n)) in mesh.vertices.iter().zip(vnormals.iter()).enumerate() {
1011            let v_len = v.norm();
1012            if v_len < 1e-6 {
1013                continue; // skip degenerate poles
1014            }
1015            let v_norm = v / v_len;
1016            let dot = v_norm.dot(n);
1017            assert!(
1018                dot > 0.0,
1019                "vertex {} normal does not point outward: dot={}",
1020                i,
1021                dot
1022            );
1023        }
1024    }
1025
1026    #[test]
1027    fn test_trimesh_contains_point() {
1028        let mesh = TriMesh::uv_sphere(1.0, 20, 40);
1029        // Center of sphere → inside
1030        let inside = mesh.contains_point(Vec3::new(0.0, 0.0, 0.0));
1031        assert!(inside, "center of sphere should be inside");
1032        // Far point → outside
1033        let outside = mesh.contains_point(Vec3::new(10.0, 0.0, 0.0));
1034        assert!(!outside, "far point should be outside");
1035    }
1036
1037    #[test]
1038    fn test_trimesh_bounding_box() {
1039        let r = 1.5;
1040        let mesh = TriMesh::uv_sphere(r, 20, 40);
1041        let (min, max) = mesh.bounding_box();
1042        // All coordinates should be within [-r, r]
1043        assert!(min.x >= -r - 1e-10, "min.x={} < -r={}", min.x, -r);
1044        assert!(min.y >= -r - 1e-10, "min.y={} < -r={}", min.y, -r);
1045        assert!(min.z >= -r - 1e-10, "min.z={} < -r={}", min.z, -r);
1046        assert!(max.x <= r + 1e-10, "max.x={} > r={}", max.x, r);
1047        assert!(max.y <= r + 1e-10, "max.y={} > r={}", max.y, r);
1048        assert!(max.z <= r + 1e-10, "max.z={} > r={}", max.z, r);
1049        // Max should be close to r in each dimension
1050        assert!(max.x > r * 0.9, "max.x={} too small", max.x);
1051        assert!(max.y > r * 0.9, "max.y={} too small", max.y);
1052        assert!(max.z > r * 0.9, "max.z={} too small", max.z);
1053    }
1054
1055    // ── standalone function tests ────────────────────────────────────────────
1056
1057    /// Build a simple unit tetrahedron (4 triangles, watertight).
1058    fn unit_tetrahedron() -> (Vec<[f64; 3]>, Vec<[usize; 3]>) {
1059        let verts = vec![
1060            [0.0f64, 0.0, 0.0],
1061            [1.0, 0.0, 0.0],
1062            [0.5, 1.0, 0.0],
1063            [0.5, 0.5, 1.0],
1064        ];
1065        let tris = vec![[0usize, 2, 1], [0, 1, 3], [1, 2, 3], [0, 3, 2]];
1066        (verts, tris)
1067    }
1068
1069    #[test]
1070    fn test_compute_face_normals_unit_length() {
1071        let (verts, tris) = unit_tetrahedron();
1072        let normals = compute_face_normals(&verts, &tris);
1073        assert_eq!(normals.len(), tris.len());
1074        for (i, n) in normals.iter().enumerate() {
1075            let len = (n[0].powi(2) + n[1].powi(2) + n[2].powi(2)).sqrt();
1076            assert!(
1077                (len - 1.0).abs() < 1e-10,
1078                "face {i} normal not unit: len={len}"
1079            );
1080        }
1081    }
1082
1083    #[test]
1084    fn test_compute_face_normals_xy_plane() {
1085        // Triangle in XY plane → normal = [0, 0, ±1]
1086        let verts = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
1087        let tris = vec![[0usize, 1, 2]];
1088        let normals = compute_face_normals(&verts, &tris);
1089        assert!(
1090            (normals[0][2].abs() - 1.0).abs() < 1e-10,
1091            "nz={}",
1092            normals[0][2]
1093        );
1094    }
1095
1096    #[test]
1097    fn test_compute_vertex_normals_count() {
1098        let (verts, tris) = unit_tetrahedron();
1099        let vnormals = compute_vertex_normals(&verts, &tris);
1100        assert_eq!(vnormals.len(), verts.len());
1101    }
1102
1103    #[test]
1104    fn test_compute_vertex_normals_unit_length() {
1105        let (verts, tris) = unit_tetrahedron();
1106        let vnormals = compute_vertex_normals(&verts, &tris);
1107        for (i, n) in vnormals.iter().enumerate() {
1108            let len = (n[0].powi(2) + n[1].powi(2) + n[2].powi(2)).sqrt();
1109            assert!(
1110                (len - 1.0).abs() < 1e-10,
1111                "vertex {i} normal not unit: {len}"
1112            );
1113        }
1114    }
1115
1116    #[test]
1117    fn test_weld_vertices_basic() {
1118        // Two vertices that are essentially the same within tolerance
1119        let verts = vec![
1120            [0.0f64, 0.0, 0.0],
1121            [0.0, 0.0, 1e-10], // < tol=1e-6 away from above
1122            [1.0, 0.0, 0.0],
1123        ];
1124        let tris = vec![[0usize, 1, 2]];
1125        let (new_verts, _new_tris) = weld_vertices(&verts, &tris, 1e-6);
1126        assert_eq!(new_verts.len(), 2, "expected 2 unique verts after weld");
1127    }
1128
1129    #[test]
1130    fn test_weld_vertices_no_merge() {
1131        // Vertices that are far apart should not be merged
1132        let verts = vec![[0.0f64, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]];
1133        let tris = vec![[0usize, 1, 2]];
1134        let (new_verts, _) = weld_vertices(&verts, &tris, 1e-6);
1135        assert_eq!(new_verts.len(), 3);
1136    }
1137
1138    #[test]
1139    fn test_compute_adjacency_tetrahedron() {
1140        let (verts, tris) = unit_tetrahedron();
1141        let adj = compute_adjacency(&tris, verts.len());
1142        // In a tetrahedron every vertex connects to every other
1143        assert_eq!(adj.len(), 4);
1144        for (i, neighbors) in adj.iter().enumerate() {
1145            assert_eq!(neighbors.len(), 3, "vertex {i} should have 3 neighbors");
1146        }
1147    }
1148
1149    #[test]
1150    fn test_is_watertight_tetrahedron() {
1151        let (_verts, tris) = unit_tetrahedron();
1152        assert!(is_watertight(&tris), "tetrahedron should be watertight");
1153    }
1154
1155    #[test]
1156    fn test_is_watertight_open_mesh() {
1157        // A single triangle has 3 boundary edges → not watertight
1158        let tris = vec![[0usize, 1, 2]];
1159        assert!(
1160            !is_watertight(&tris),
1161            "single triangle should not be watertight"
1162        );
1163    }
1164
1165    #[test]
1166    fn test_compute_genus_tetrahedron() {
1167        // Tetrahedron: V=4, E=6, F=4 → χ=2 → g=0
1168        let (verts, tris) = unit_tetrahedron();
1169        let g = compute_genus(verts.len(), &tris);
1170        assert_eq!(g, 0, "tetrahedron genus should be 0");
1171    }
1172
1173    #[test]
1174    fn test_triangulate_polygon_triangle() {
1175        let poly = vec![[0.0f64, 0.0, 0.0], [1.0, 0.0, 0.0], [0.5, 1.0, 0.0]];
1176        let result = triangulate_polygon(&poly);
1177        assert_eq!(result.len(), 1, "triangle polygon = 1 tri");
1178        assert_eq!(result[0], [0, 1, 2]);
1179    }
1180
1181    #[test]
1182    fn test_triangulate_polygon_quad() {
1183        // A convex quad in XY should yield 2 triangles
1184        let poly = vec![
1185            [0.0f64, 0.0, 0.0],
1186            [1.0, 0.0, 0.0],
1187            [1.0, 1.0, 0.0],
1188            [0.0, 1.0, 0.0],
1189        ];
1190        let result = triangulate_polygon(&poly);
1191        assert_eq!(result.len(), 2, "quad polygon = 2 tris");
1192    }
1193
1194    #[test]
1195    fn test_triangulate_polygon_pentagon() {
1196        // A convex pentagon should yield 3 triangles
1197        let poly: Vec<[f64; 3]> = (0..5)
1198            .map(|i| {
1199                let a = 2.0 * std::f64::consts::PI * i as f64 / 5.0;
1200                [a.cos(), a.sin(), 0.0]
1201            })
1202            .collect();
1203        let result = triangulate_polygon(&poly);
1204        assert_eq!(result.len(), 3, "pentagon polygon = 3 tris");
1205    }
1206
1207    // ── Quadric error decimation tests ────────────────────────────────────────
1208
1209    #[test]
1210    fn test_decimate_reduces_triangles() {
1211        let (verts, tris) = unit_tetrahedron();
1212        let (_, new_tris) = quadric_decimate(&verts, &tris, 2);
1213        // After removing 2 edges, should have fewer triangles (or equal if no valid collapses)
1214        assert!(
1215            new_tris.len() <= tris.len(),
1216            "decimation should not increase triangles"
1217        );
1218    }
1219
1220    #[test]
1221    fn test_decimate_preserves_non_degenerate_tris() {
1222        let (verts, tris) = unit_tetrahedron();
1223        let (new_verts, new_tris) = quadric_decimate(&verts, &tris, 1);
1224        for tri in &new_tris {
1225            let a = new_verts[tri[0]];
1226            let b = new_verts[tri[1]];
1227            let c = new_verts[tri[2]];
1228            let e1 = mo_sub(b, a);
1229            let e2 = mo_sub(c, a);
1230            let cross = mo_cross(e1, e2);
1231            let area = mo_norm(cross) * 0.5;
1232            assert!(area >= 0.0, "negative area triangle");
1233        }
1234    }
1235
1236    // ── Laplacian smoothing tests ─────────────────────────────────────────────
1237
1238    #[test]
1239    fn test_laplacian_smooth_reduces_roughness() {
1240        // Create a rough mesh: tetrahedron with one vertex displaced
1241        let (mut verts, tris) = unit_tetrahedron();
1242        verts[0] = [0.5, 0.5, 0.5]; // displace from origin
1243        let smoothed = laplacian_smooth(&verts, &tris, 5, 0.5);
1244        // Smoothed version should have vertex 0 closer to its neighbors' average
1245        assert_eq!(smoothed.len(), verts.len());
1246    }
1247
1248    #[test]
1249    fn test_laplacian_smooth_returns_correct_count() {
1250        let (verts, tris) = unit_tetrahedron();
1251        let smoothed = laplacian_smooth(&verts, &tris, 3, 0.5);
1252        assert_eq!(
1253            smoothed.len(),
1254            verts.len(),
1255            "smoothed should have same vertex count"
1256        );
1257    }
1258
1259    // ── Mesh merging tests ────────────────────────────────────────────────────
1260
1261    #[test]
1262    fn test_merge_meshes_vertex_count() {
1263        let (v1, t1) = unit_tetrahedron();
1264        let v2: Vec<[f64; 3]> = v1.iter().map(|&v| [v[0] + 5.0, v[1], v[2]]).collect();
1265        let (mv, mt) = merge_meshes(&v1, &t1, &v2, &t1);
1266        assert_eq!(mv.len(), v1.len() + v2.len(), "merged vertices");
1267        assert_eq!(mt.len(), t1.len() * 2, "merged triangles");
1268    }
1269
1270    #[test]
1271    fn test_merge_meshes_index_offset() {
1272        let (v1, t1) = unit_tetrahedron();
1273        let v2: Vec<[f64; 3]> = v1.iter().map(|&v| [v[0] + 5.0, v[1], v[2]]).collect();
1274        let (mv, mt) = merge_meshes(&v1, &t1, &v2, &t1);
1275        // Second mesh triangles should reference indices >= v1.len()
1276        for tri in &mt[t1.len()..] {
1277            for &vi in tri {
1278                assert!(vi >= v1.len(), "index {} should be offset", vi);
1279            }
1280        }
1281        assert_eq!(mv.len(), v1.len() + v2.len());
1282    }
1283
1284    // ── Edge split tests ──────────────────────────────────────────────────────
1285
1286    #[test]
1287    fn test_split_edge_increases_vertices() {
1288        let (mut verts, mut tris) = unit_tetrahedron();
1289        let n_before = verts.len();
1290        split_edge(&mut verts, &mut tris, 0, 1);
1291        assert_eq!(verts.len(), n_before + 1, "split should add one vertex");
1292    }
1293
1294    #[test]
1295    fn test_split_edge_increases_triangles() {
1296        let (mut verts, mut tris) = unit_tetrahedron();
1297        let n_before = tris.len();
1298        split_edge(&mut verts, &mut tris, 0, 1);
1299        assert!(tris.len() > n_before, "split should add triangles");
1300    }
1301
1302    // ── Boundary detection tests ──────────────────────────────────────────────
1303
1304    #[test]
1305    fn test_find_boundary_edges_tetrahedron() {
1306        let (_verts, tris) = unit_tetrahedron();
1307        let boundary = find_boundary_edges(&tris);
1308        // Closed tetrahedron has no boundary edges
1309        assert!(
1310            boundary.is_empty(),
1311            "tetrahedron has no boundary, got {:?}",
1312            boundary
1313        );
1314    }
1315
1316    #[test]
1317    fn test_find_boundary_edges_open_mesh() {
1318        // Two triangles sharing one edge → 4 boundary edges
1319        let tris = vec![[0usize, 1, 2], [1, 3, 2]];
1320        let boundary = find_boundary_edges(&tris);
1321        assert!(!boundary.is_empty(), "open mesh should have boundary edges");
1322    }
1323}