Skip to main content

oxiphysics_gpu/
gpu_mesh_processing.rs

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