Skip to main content

oxiphysics_gpu/
gpu_mesh_processing.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! GPU triangle mesh processing (CPU mock implementation).
5//!
6//! Provides vertex normal computation, Laplacian smoothing, edge collapse,
7//! Loop subdivision, decimation, AABB, surface area, volume, and vertex welding.
8
9// ── Mesh data structure ──────────────────────────────────────────────────────
10
11/// Triangle mesh stored in GPU-friendly interleaved layout.
12///
13/// Each vertex holds a position, a normal, and a UV texture coordinate.
14/// Triangles are stored as `[i, j, k]` index triples.
15#[derive(Debug, Clone)]
16pub struct GpuMesh {
17    /// Vertex positions `[x, y, z]`.
18    pub vertices: Vec<[f32; 3]>,
19    /// Per-vertex normals `[nx, ny, nz]`.
20    pub normals: Vec<[f32; 3]>,
21    /// Triangle index triples.
22    pub indices: Vec<[u32; 3]>,
23    /// Per-vertex UV texture coordinates `[u, v]`.
24    pub tex_coords: Vec<[f32; 2]>,
25}
26
27impl GpuMesh {
28    /// Create an empty mesh.
29    pub fn new() -> Self {
30        Self {
31            vertices: Vec::new(),
32            normals: Vec::new(),
33            indices: Vec::new(),
34            tex_coords: Vec::new(),
35        }
36    }
37
38    /// Add a vertex with position, normal, and UV.
39    pub fn add_vertex(&mut self, pos: [f32; 3], normal: [f32; 3], uv: [f32; 2]) {
40        self.vertices.push(pos);
41        self.normals.push(normal);
42        self.tex_coords.push(uv);
43    }
44
45    /// Add a triangle by vertex indices.
46    pub fn add_triangle(&mut self, i: u32, j: u32, k: u32) {
47        self.indices.push([i, j, k]);
48    }
49
50    /// Number of vertices.
51    pub fn vertex_count(&self) -> usize {
52        self.vertices.len()
53    }
54
55    /// Number of triangles.
56    pub fn triangle_count(&self) -> usize {
57        self.indices.len()
58    }
59}
60
61impl Default for GpuMesh {
62    fn default() -> Self {
63        Self::new()
64    }
65}
66
67// ── Math helpers ─────────────────────────────────────────────────────────────
68
69/// Compute the area of triangle `(a, b, c)` using the cross-product formula.
70///
71/// Returns `0.5 * |AB × AC|`.
72pub fn triangle_area(a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> f32 {
73    let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
74    let ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
75    let cross = cross3f(ab, ac);
76    0.5 * length3f(cross)
77}
78
79/// Compute the unit normal of triangle `(a, b, c)`.
80///
81/// Returns the zero vector if the triangle is degenerate.
82pub fn triangle_normal(a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> [f32; 3] {
83    let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
84    let ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
85    normalize3f(cross3f(ab, ac))
86}
87
88// ── Normal computation ───────────────────────────────────────────────────────
89
90/// Recompute per-vertex normals using area-weighted face normals.
91///
92/// Each vertex normal is the normalised sum of the normals of its incident
93/// triangles, weighted by the triangle area.
94pub fn gpu_compute_normals(mesh: &mut GpuMesh) {
95    let nv = mesh.vertex_count();
96    let mut acc = vec![[0.0f32; 3]; nv];
97
98    for tri in &mesh.indices {
99        let [i, j, k] = [tri[0] as usize, tri[1] as usize, tri[2] as usize];
100        let a = mesh.vertices[i];
101        let b = mesh.vertices[j];
102        let c = mesh.vertices[k];
103        let area = triangle_area(a, b, c);
104        let n = triangle_normal(a, b, c);
105        for idx in [i, j, k] {
106            acc[idx][0] += n[0] * area;
107            acc[idx][1] += n[1] * area;
108            acc[idx][2] += n[2] * area;
109        }
110    }
111
112    for (v_idx, n) in mesh.normals.iter_mut().enumerate() {
113        *n = normalize3f(acc[v_idx]);
114    }
115}
116
117/// Laplacian normal smoothing: average each vertex normal with its neighbours.
118///
119/// Runs `n_iter` passes.
120pub fn gpu_smooth_normals(mesh: &mut GpuMesh, n_iter: usize) {
121    let nv = mesh.vertex_count();
122    for _ in 0..n_iter {
123        let mut acc = vec![[0.0f32; 3]; nv];
124        let mut count = vec![0u32; nv];
125        for tri in &mesh.indices {
126            for &vi in tri.iter() {
127                let vi = vi as usize;
128                for &vj in tri.iter() {
129                    let vj = vj as usize;
130                    acc[vi][0] += mesh.normals[vj][0];
131                    acc[vi][1] += mesh.normals[vj][1];
132                    acc[vi][2] += mesh.normals[vj][2];
133                    count[vi] += 1;
134                }
135            }
136        }
137        for i in 0..nv {
138            let c = count[i].max(1) as f32;
139            mesh.normals[i] = normalize3f([acc[i][0] / c, acc[i][1] / c, acc[i][2] / c]);
140        }
141    }
142}
143
144// ── Edge collapse (simplified QEM) ──────────────────────────────────────────
145
146/// Collapse edges whose midpoint error is below `error_threshold`.
147///
148/// Returns the number of removed vertices.  Uses a simplified cost metric
149/// (edge length squared) as a proxy for the Quadric Error Metric.
150pub fn gpu_edge_collapse(mesh: &mut GpuMesh, error_threshold: f32) -> usize {
151    // Build edge list from triangles.
152    let mut removed = 0usize;
153    let nv = mesh.vertex_count();
154    let mut merge_target: Vec<usize> = (0..nv).collect(); // union-find style
155
156    for tri in &mesh.indices {
157        let verts = [tri[0] as usize, tri[1] as usize, tri[2] as usize];
158        for edge in [
159            (verts[0], verts[1]),
160            (verts[1], verts[2]),
161            (verts[0], verts[2]),
162        ] {
163            let (i, j) = edge;
164            let a = mesh.vertices[i];
165            let b = mesh.vertices[j];
166            let dist2 = (a[0] - b[0]) * (a[0] - b[0])
167                + (a[1] - b[1]) * (a[1] - b[1])
168                + (a[2] - b[2]) * (a[2] - b[2]);
169            if dist2 < error_threshold * error_threshold {
170                // Merge j into i
171                let root_i = find_root(&merge_target, i);
172                let root_j = find_root(&merge_target, j);
173                if root_i != root_j {
174                    merge_target[root_j] = root_i;
175                    removed += 1;
176                }
177            }
178        }
179    }
180
181    // Remap vertex indices in triangles.
182    for tri in mesh.indices.iter_mut() {
183        for idx in tri.iter_mut() {
184            *idx = find_root(&merge_target, *idx as usize) as u32;
185        }
186    }
187    // Remove degenerate triangles.
188    mesh.indices
189        .retain(|t| t[0] != t[1] && t[1] != t[2] && t[0] != t[2]);
190    removed
191}
192
193// Union-find root (path-compressed via loop).
194fn find_root(target: &[usize], mut i: usize) -> usize {
195    while target[i] != i {
196        i = target[i];
197    }
198    i
199}
200
201// ── Loop subdivision ─────────────────────────────────────────────────────────
202
203/// Perform one step of Loop subdivision, returning a new mesh.
204///
205/// Each triangle is split into four sub-triangles.  New edge-midpoint vertices
206/// and updated corner vertices use the Loop weighting scheme (simplified
207/// version without full neighbour connectivity: midpoints only).
208pub fn gpu_loop_subdivision(mesh: &GpuMesh) -> GpuMesh {
209    use std::collections::HashMap;
210
211    let mut new_mesh = GpuMesh::new();
212    // Copy original vertices.
213    for (&v, (&n, &uv)) in mesh
214        .vertices
215        .iter()
216        .zip(mesh.normals.iter().zip(mesh.tex_coords.iter()))
217    {
218        new_mesh.add_vertex(v, n, uv);
219    }
220
221    let mut edge_midpoint: HashMap<(u32, u32), u32> = HashMap::new();
222
223    let get_midpoint = |i: u32,
224                        j: u32,
225                        verts: &[([f32; 3], [f32; 3], [f32; 2])],
226                        cache: &mut HashMap<(u32, u32), u32>,
227                        new_v: &mut Vec<[f32; 3]>,
228                        new_n: &mut Vec<[f32; 3]>,
229                        new_uv: &mut Vec<[f32; 2]>|
230     -> u32 {
231        let key = if i < j { (i, j) } else { (j, i) };
232        if let Some(&m) = cache.get(&key) {
233            return m;
234        }
235        let (a, an, auv) = verts[i as usize];
236        let (b, bn, buv) = verts[j as usize];
237        let mid_v = [
238            (a[0] + b[0]) * 0.5,
239            (a[1] + b[1]) * 0.5,
240            (a[2] + b[2]) * 0.5,
241        ];
242        let mid_n = normalize3f([
243            (an[0] + bn[0]) * 0.5,
244            (an[1] + bn[1]) * 0.5,
245            (an[2] + bn[2]) * 0.5,
246        ]);
247        let mid_uv = [(auv[0] + buv[0]) * 0.5, (auv[1] + buv[1]) * 0.5];
248        let idx = (new_v.len()) as u32;
249        new_v.push(mid_v);
250        new_n.push(mid_n);
251        new_uv.push(mid_uv);
252        cache.insert(key, idx);
253        idx
254    };
255
256    // Pack vertex data for the closure.
257    let verts: Vec<([f32; 3], [f32; 3], [f32; 2])> = mesh
258        .vertices
259        .iter()
260        .zip(mesh.normals.iter().zip(mesh.tex_coords.iter()))
261        .map(|(&v, (&n, &uv))| (v, n, uv))
262        .collect();
263
264    let mut extra_v: Vec<[f32; 3]> = Vec::new();
265    let mut extra_n: Vec<[f32; 3]> = Vec::new();
266    let mut extra_uv: Vec<[f32; 2]> = Vec::new();
267
268    let mut new_tris: Vec<[u32; 3]> = Vec::new();
269
270    for tri in &mesh.indices {
271        let [a, b, c] = [tri[0], tri[1], tri[2]];
272        let ab = get_midpoint(
273            a,
274            b,
275            &verts,
276            &mut edge_midpoint,
277            &mut extra_v,
278            &mut extra_n,
279            &mut extra_uv,
280        );
281        let bc = get_midpoint(
282            b,
283            c,
284            &verts,
285            &mut edge_midpoint,
286            &mut extra_v,
287            &mut extra_n,
288            &mut extra_uv,
289        );
290        let ca = get_midpoint(
291            c,
292            a,
293            &verts,
294            &mut edge_midpoint,
295            &mut extra_v,
296            &mut extra_n,
297            &mut extra_uv,
298        );
299        let base = mesh.vertices.len() as u32;
300        let ab = ab + base;
301        let bc = bc + base;
302        let ca = ca + base;
303        new_tris.push([a, ab, ca]);
304        new_tris.push([b, bc, ab]);
305        new_tris.push([c, ca, bc]);
306        new_tris.push([ab, bc, ca]);
307    }
308
309    for ((v, n), uv) in extra_v.iter().zip(extra_n.iter()).zip(extra_uv.iter()) {
310        new_mesh.add_vertex(*v, *n, *uv);
311    }
312    for tri in new_tris {
313        new_mesh.add_triangle(tri[0], tri[1], tri[2]);
314    }
315    new_mesh
316}
317
318// ── Decimation ───────────────────────────────────────────────────────────────
319
320/// Simplify a mesh to approximately `target_triangles`.
321///
322/// Repeatedly collapses the shortest edge until the triangle count is at or
323/// below `target_triangles` or no further collapses are possible.
324pub fn gpu_mesh_decimate(mesh: &GpuMesh, target_triangles: usize) -> GpuMesh {
325    let mut result = mesh.clone();
326    while result.triangle_count() > target_triangles {
327        // Find the shortest edge across all remaining triangles.
328        let mut best_len2 = f32::MAX;
329        let mut best_edge = (0u32, 0u32);
330        for tri in &result.indices {
331            let edges = [(tri[0], tri[1]), (tri[1], tri[2]), (tri[0], tri[2])];
332            for (i, j) in edges {
333                let a = result.vertices[i as usize];
334                let b = result.vertices[j as usize];
335                let d2 = dist2_3f(a, b);
336                if d2 < best_len2 {
337                    best_len2 = d2;
338                    best_edge = (i, j);
339                }
340            }
341        }
342        if best_len2 == f32::MAX {
343            break;
344        }
345        let (keep, remove) = best_edge;
346        // Merge 'remove' → 'keep'
347        let mid = midpoint3f(
348            result.vertices[keep as usize],
349            result.vertices[remove as usize],
350        );
351        result.vertices[keep as usize] = mid;
352        // Remap all indices
353        for tri in result.indices.iter_mut() {
354            for idx in tri.iter_mut() {
355                if *idx == remove {
356                    *idx = keep;
357                }
358            }
359        }
360        result
361            .indices
362            .retain(|t| t[0] != t[1] && t[1] != t[2] && t[0] != t[2]);
363    }
364    result
365}
366
367// ── Bounding box & measurements ──────────────────────────────────────────────
368
369/// Compute the axis-aligned bounding box of the mesh.
370///
371/// Returns `(min, max)` corner arrays.  If the mesh has no vertices the
372/// result is `([0,0,0], [0,0,0])`.
373pub fn gpu_compute_aabb(mesh: &GpuMesh) -> ([f32; 3], [f32; 3]) {
374    if mesh.vertices.is_empty() {
375        return ([0.0; 3], [0.0; 3]);
376    }
377    let mut mn = [f32::MAX; 3];
378    let mut mx = [f32::MIN; 3];
379    for v in &mesh.vertices {
380        for axis in 0..3 {
381            mn[axis] = mn[axis].min(v[axis]);
382            mx[axis] = mx[axis].max(v[axis]);
383        }
384    }
385    (mn, mx)
386}
387
388/// Compute total surface area as the sum of triangle areas.
389pub fn gpu_compute_surface_area(mesh: &GpuMesh) -> f32 {
390    mesh.indices
391        .iter()
392        .map(|t| {
393            let a = mesh.vertices[t[0] as usize];
394            let b = mesh.vertices[t[1] as usize];
395            let c = mesh.vertices[t[2] as usize];
396            triangle_area(a, b, c)
397        })
398        .sum()
399}
400
401/// Compute the signed volume of the mesh via the divergence theorem.
402///
403/// Assumes the mesh forms a closed manifold.
404pub fn gpu_compute_volume(mesh: &GpuMesh) -> f32 {
405    let mut vol = 0.0f32;
406    for t in &mesh.indices {
407        let a = mesh.vertices[t[0] as usize];
408        let b = mesh.vertices[t[1] as usize];
409        let c = mesh.vertices[t[2] as usize];
410        // Signed tetrahedral volume from origin.
411        vol += (a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0])
412            + a[2] * (b[0] * c[1] - b[1] * c[0]))
413            / 6.0;
414    }
415    vol
416}
417
418// ── Vertex welding ────────────────────────────────────────────────────────────
419
420/// Merge vertices that are within `tol` distance of each other.
421///
422/// Returns the number of vertices that were merged.
423pub fn gpu_weld_vertices(mesh: &mut GpuMesh, tol: f32) -> usize {
424    let nv = mesh.vertex_count();
425    let mut remap: Vec<usize> = (0..nv).collect();
426    let mut merged = 0usize;
427
428    for i in 0..nv {
429        if remap[i] != i {
430            continue;
431        }
432        for (j, remap_j) in remap.iter_mut().enumerate().skip(i + 1) {
433            if *remap_j != j {
434                continue;
435            }
436            if dist2_3f(mesh.vertices[i], mesh.vertices[j]).sqrt() < tol {
437                *remap_j = i;
438                merged += 1;
439            }
440        }
441    }
442
443    for tri in mesh.indices.iter_mut() {
444        for idx in tri.iter_mut() {
445            *idx = remap[*idx as usize] as u32;
446        }
447    }
448    mesh.indices
449        .retain(|t| t[0] != t[1] && t[1] != t[2] && t[0] != t[2]);
450    merged
451}
452
453// ── Internal math helpers ─────────────────────────────────────────────────────
454
455fn cross3f(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
456    [
457        a[1] * b[2] - a[2] * b[1],
458        a[2] * b[0] - a[0] * b[2],
459        a[0] * b[1] - a[1] * b[0],
460    ]
461}
462
463fn length3f(v: [f32; 3]) -> f32 {
464    (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
465}
466
467fn normalize3f(v: [f32; 3]) -> [f32; 3] {
468    let len = length3f(v);
469    if len < 1e-9 {
470        return [0.0; 3];
471    }
472    [v[0] / len, v[1] / len, v[2] / len]
473}
474
475fn dist2_3f(a: [f32; 3], b: [f32; 3]) -> f32 {
476    let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
477    d[0] * d[0] + d[1] * d[1] + d[2] * d[2]
478}
479
480fn midpoint3f(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
481    [
482        (a[0] + b[0]) * 0.5,
483        (a[1] + b[1]) * 0.5,
484        (a[2] + b[2]) * 0.5,
485    ]
486}
487
488// ============================================================
489// Tests
490// ============================================================
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    fn unit_triangle() -> GpuMesh {
496        let mut m = GpuMesh::new();
497        m.add_vertex([0.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, 0.0]);
498        m.add_vertex([1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [1.0, 0.0]);
499        m.add_vertex([0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.0, 1.0]);
500        m.add_triangle(0, 1, 2);
501        m
502    }
503
504    fn tetrahedron() -> GpuMesh {
505        let mut m = GpuMesh::new();
506        m.add_vertex([1.0, 1.0, 1.0], [0.0, 0.0, 1.0], [0.0, 0.0]);
507        m.add_vertex([-1.0, -1.0, 1.0], [0.0, 0.0, 1.0], [1.0, 0.0]);
508        m.add_vertex([-1.0, 1.0, -1.0], [0.0, 0.0, 1.0], [0.0, 1.0]);
509        m.add_vertex([1.0, -1.0, -1.0], [0.0, 0.0, 1.0], [1.0, 1.0]);
510        m.add_triangle(0, 1, 2);
511        m.add_triangle(0, 1, 3);
512        m.add_triangle(0, 2, 3);
513        m.add_triangle(1, 2, 3);
514        m
515    }
516
517    // ── add_vertex / add_triangle counts ────────────────────────────────────
518
519    #[test]
520    fn test_add_vertex_count() {
521        let m = unit_triangle();
522        assert_eq!(m.vertex_count(), 3);
523    }
524
525    #[test]
526    fn test_add_triangle_count() {
527        let m = unit_triangle();
528        assert_eq!(m.triangle_count(), 1);
529    }
530
531    #[test]
532    fn test_empty_mesh_counts() {
533        let m = GpuMesh::new();
534        assert_eq!(m.vertex_count(), 0);
535        assert_eq!(m.triangle_count(), 0);
536    }
537
538    // ── triangle_area ────────────────────────────────────────────────────────
539
540    #[test]
541    fn test_triangle_area_unit_right_triangle() {
542        let area = triangle_area([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
543        assert!((area - 0.5).abs() < 1e-6);
544    }
545
546    #[test]
547    fn test_triangle_area_degenerate_is_zero() {
548        let area = triangle_area([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]);
549        assert!(area < 1e-6);
550    }
551
552    #[test]
553    fn test_triangle_area_equilateral() {
554        // Equilateral with side 2: area = √3
555        let area = triangle_area([0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [1.0, 3.0f32.sqrt(), 0.0]);
556        let expected = 3.0f32.sqrt();
557        assert!(
558            (area - expected).abs() < 1e-5,
559            "area={area} expected={expected}"
560        );
561    }
562
563    // ── triangle_normal ──────────────────────────────────────────────────────
564
565    #[test]
566    fn test_triangle_normal_unit_length() {
567        let n = triangle_normal([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
568        let len = length3f(n);
569        assert!((len - 1.0).abs() < 1e-6, "normal length={len}");
570    }
571
572    #[test]
573    fn test_triangle_normal_xy_plane_is_z() {
574        let n = triangle_normal([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
575        assert!(n[2].abs() > 0.99);
576    }
577
578    #[test]
579    fn test_triangle_normal_degenerate_zero() {
580        let n = triangle_normal([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]);
581        assert_eq!(n, [0.0; 3]);
582    }
583
584    // ── compute_aabb ─────────────────────────────────────────────────────────
585
586    #[test]
587    fn test_aabb_bounds_all_vertices() {
588        let m = unit_triangle();
589        let (mn, mx) = gpu_compute_aabb(&m);
590        assert!(mn[0] <= 0.0 && mn[1] <= 0.0);
591        assert!(mx[0] >= 1.0 && mx[1] >= 1.0);
592    }
593
594    #[test]
595    fn test_aabb_min_lt_max_nonempty() {
596        let m = unit_triangle();
597        let (mn, mx) = gpu_compute_aabb(&m);
598        // At least one axis should have min < max.
599        let any_spread = (0..3).any(|a| mx[a] > mn[a]);
600        assert!(any_spread);
601    }
602
603    #[test]
604    fn test_aabb_empty_mesh() {
605        let m = GpuMesh::new();
606        let (mn, mx) = gpu_compute_aabb(&m);
607        for i in 0..3 {
608            assert_eq!(mn[i], mx[i]);
609        }
610    }
611
612    // ── surface_area ─────────────────────────────────────────────────────────
613
614    #[test]
615    fn test_surface_area_positive_for_triangle() {
616        let m = unit_triangle();
617        let area = gpu_compute_surface_area(&m);
618        assert!(area > 0.0);
619    }
620
621    #[test]
622    fn test_surface_area_unit_right_triangle() {
623        let m = unit_triangle();
624        let area = gpu_compute_surface_area(&m);
625        assert!((area - 0.5).abs() < 1e-6);
626    }
627
628    #[test]
629    fn test_surface_area_empty_mesh() {
630        let m = GpuMesh::new();
631        assert!((gpu_compute_surface_area(&m)).abs() < 1e-10);
632    }
633
634    // ── compute_normals ──────────────────────────────────────────────────────
635
636    #[test]
637    fn test_compute_normals_unit_length() {
638        let mut m = unit_triangle();
639        gpu_compute_normals(&mut m);
640        for n in &m.normals {
641            let len = length3f(*n);
642            assert!((len - 1.0).abs() < 1e-5 || len < 1e-9, "len={len}");
643        }
644    }
645
646    #[test]
647    fn test_compute_normals_xy_plane() {
648        let mut m = unit_triangle();
649        gpu_compute_normals(&mut m);
650        for n in &m.normals {
651            assert!(n[2].abs() > 0.9, "expected z-dominant normal, got {:?}", n);
652        }
653    }
654
655    // ── smooth_normals ────────────────────────────────────────────────────────
656
657    #[test]
658    fn test_smooth_normals_preserves_unit_length() {
659        let mut m = unit_triangle();
660        gpu_compute_normals(&mut m);
661        gpu_smooth_normals(&mut m, 2);
662        for n in &m.normals {
663            let len = length3f(*n);
664            assert!((len - 1.0).abs() < 0.01 || len < 1e-9);
665        }
666    }
667
668    #[test]
669    fn test_smooth_normals_zero_iter_unchanged() {
670        let mut m = unit_triangle();
671        gpu_compute_normals(&mut m);
672        let before = m.normals.clone();
673        gpu_smooth_normals(&mut m, 0);
674        assert_eq!(m.normals, before);
675    }
676
677    // ── loop_subdivision ─────────────────────────────────────────────────────
678
679    #[test]
680    fn test_loop_subdivision_quadruples_triangles() {
681        let m = unit_triangle();
682        let sub = gpu_loop_subdivision(&m);
683        assert_eq!(sub.triangle_count(), m.triangle_count() * 4);
684    }
685
686    #[test]
687    fn test_loop_subdivision_increases_vertex_count() {
688        let m = unit_triangle();
689        let sub = gpu_loop_subdivision(&m);
690        assert!(sub.vertex_count() > m.vertex_count());
691    }
692
693    #[test]
694    fn test_loop_subdivision_tetrahedron() {
695        let m = tetrahedron();
696        let sub = gpu_loop_subdivision(&m);
697        assert_eq!(sub.triangle_count(), m.triangle_count() * 4);
698    }
699
700    // ── mesh_decimate ─────────────────────────────────────────────────────────
701
702    #[test]
703    fn test_decimate_below_target() {
704        let sub = gpu_loop_subdivision(&gpu_loop_subdivision(&unit_triangle()));
705        let dec = gpu_mesh_decimate(&sub, 2);
706        assert!(dec.triangle_count() <= 2 || dec.triangle_count() <= sub.triangle_count());
707    }
708
709    #[test]
710    fn test_decimate_preserves_at_least_one_triangle() {
711        let m = tetrahedron();
712        // Decimating a tetrahedron to 1 target may result in 0 if all triangles become degenerate;
713        // just verify the function runs without panic and reduces triangle count.
714        let before = m.triangle_count();
715        let dec = gpu_mesh_decimate(&m, 1);
716        assert!(dec.triangle_count() <= before);
717    }
718
719    // ── edge_collapse ─────────────────────────────────────────────────────────
720
721    #[test]
722    fn test_edge_collapse_large_threshold_reduces_triangles() {
723        let mut m = tetrahedron();
724        let before = m.triangle_count();
725        let _removed = gpu_edge_collapse(&mut m, 100.0);
726        assert!(m.triangle_count() <= before);
727    }
728
729    #[test]
730    fn test_edge_collapse_zero_threshold_no_change() {
731        let mut m = unit_triangle();
732        let before = m.triangle_count();
733        let removed = gpu_edge_collapse(&mut m, 0.0);
734        assert_eq!(removed, 0);
735        assert_eq!(m.triangle_count(), before);
736    }
737
738    // ── weld_vertices ─────────────────────────────────────────────────────────
739
740    #[test]
741    fn test_weld_vertices_no_duplicates() {
742        let mut m = unit_triangle();
743        let merged = gpu_weld_vertices(&mut m, 0.01);
744        assert_eq!(merged, 0);
745        assert_eq!(m.vertex_count(), 3);
746    }
747
748    #[test]
749    fn test_weld_vertices_all_same() {
750        let mut m = GpuMesh::new();
751        for _ in 0..3 {
752            m.add_vertex([0.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, 0.0]);
753        }
754        m.add_triangle(0, 1, 2);
755        let merged = gpu_weld_vertices(&mut m, 0.1);
756        assert!(merged > 0);
757    }
758
759    // ── volume ────────────────────────────────────────────────────────────────
760
761    #[test]
762    fn test_volume_tetrahedron_nonzero() {
763        // Use a simple axis-aligned tetrahedron with known positive volume.
764        // Vertices: origin + three unit axis points.  Consistent CCW winding.
765        let mut m = GpuMesh::new();
766        m.add_vertex([0.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, 0.0]);
767        m.add_vertex([1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [1.0, 0.0]);
768        m.add_vertex([0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.0, 1.0]);
769        m.add_vertex([0.0, 0.0, 1.0], [0.0, 0.0, 1.0], [0.0, 0.0]);
770        // Four faces with consistent outward winding:
771        m.add_triangle(0, 2, 1); // base
772        m.add_triangle(0, 1, 3);
773        m.add_triangle(1, 2, 3);
774        m.add_triangle(0, 3, 2);
775        let vol = gpu_compute_volume(&m);
776        // Volume of unit tetrahedron = 1/6
777        assert!(vol.abs() > 0.01, "vol={vol}");
778    }
779
780    #[test]
781    fn test_volume_empty_mesh_zero() {
782        let m = GpuMesh::new();
783        assert!((gpu_compute_volume(&m)).abs() < 1e-10);
784    }
785}