mesh_repair/
types.rs

1//! Core mesh data types.
2
3use nalgebra::{Point3, Vector3};
4
5/// RGB color with 8-bit components.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub struct VertexColor {
8    pub r: u8,
9    pub g: u8,
10    pub b: u8,
11}
12
13impl VertexColor {
14    /// Create a new color from RGB components.
15    #[inline]
16    pub fn new(r: u8, g: u8, b: u8) -> Self {
17        Self { r, g, b }
18    }
19
20    /// Create a color from floating point values in [0, 1] range.
21    #[inline]
22    pub fn from_float(r: f32, g: f32, b: f32) -> Self {
23        Self {
24            r: (r.clamp(0.0, 1.0) * 255.0) as u8,
25            g: (g.clamp(0.0, 1.0) * 255.0) as u8,
26            b: (b.clamp(0.0, 1.0) * 255.0) as u8,
27        }
28    }
29
30    /// Convert to floating point values in [0, 1] range.
31    #[inline]
32    pub fn to_float(&self) -> (f32, f32, f32) {
33        (
34            self.r as f32 / 255.0,
35            self.g as f32 / 255.0,
36            self.b as f32 / 255.0,
37        )
38    }
39}
40
41/// A vertex in the mesh with optional computed attributes.
42///
43/// Coordinates are typically in millimeters but the library is unit-agnostic.
44#[derive(Debug, Clone)]
45pub struct Vertex {
46    /// 3D position.
47    pub position: Point3<f64>,
48
49    /// Unit normal vector, computed from adjacent faces.
50    pub normal: Option<Vector3<f64>>,
51
52    /// Vertex color (RGB).
53    pub color: Option<VertexColor>,
54
55    /// Application-specific tag (e.g., zone ID, material ID).
56    pub tag: Option<u32>,
57
58    /// Offset distance for this vertex (used by mesh-shell for variable offset).
59    /// Positive = outward expansion, negative = compression.
60    pub offset: Option<f32>,
61}
62
63impl Vertex {
64    /// Create a new vertex with only position set.
65    #[inline]
66    pub fn new(position: Point3<f64>) -> Self {
67        Self {
68            position,
69            normal: None,
70            color: None,
71            tag: None,
72            offset: None,
73        }
74    }
75
76    /// Create a vertex from raw coordinates.
77    #[inline]
78    pub fn from_coords(x: f64, y: f64, z: f64) -> Self {
79        Self::new(Point3::new(x, y, z))
80    }
81
82    /// Create a vertex with position and color.
83    #[inline]
84    pub fn with_color(position: Point3<f64>, color: VertexColor) -> Self {
85        Self {
86            position,
87            normal: None,
88            color: Some(color),
89            tag: None,
90            offset: None,
91        }
92    }
93}
94
95/// A triangle mesh with indexed vertices and faces.
96#[derive(Debug, Clone)]
97pub struct Mesh {
98    /// Vertex data.
99    pub vertices: Vec<Vertex>,
100
101    /// Triangle faces as indices into the vertex array.
102    /// Each face is [v0, v1, v2] with counter-clockwise winding.
103    pub faces: Vec<[u32; 3]>,
104}
105
106impl Mesh {
107    /// Create a new empty mesh.
108    pub fn new() -> Self {
109        Self {
110            vertices: Vec::new(),
111            faces: Vec::new(),
112        }
113    }
114
115    /// Create a mesh with pre-allocated capacity.
116    pub fn with_capacity(vertex_count: usize, face_count: usize) -> Self {
117        Self {
118            vertices: Vec::with_capacity(vertex_count),
119            faces: Vec::with_capacity(face_count),
120        }
121    }
122
123    /// Number of vertices in the mesh.
124    #[inline]
125    pub fn vertex_count(&self) -> usize {
126        self.vertices.len()
127    }
128
129    /// Number of faces (triangles) in the mesh.
130    #[inline]
131    pub fn face_count(&self) -> usize {
132        self.faces.len()
133    }
134
135    /// Check if mesh is empty (no vertices or faces).
136    #[inline]
137    pub fn is_empty(&self) -> bool {
138        self.vertices.is_empty() || self.faces.is_empty()
139    }
140
141    /// Compute the axis-aligned bounding box.
142    /// Returns (min_corner, max_corner) or None if mesh is empty.
143    pub fn bounds(&self) -> Option<(Point3<f64>, Point3<f64>)> {
144        if self.vertices.is_empty() {
145            return None;
146        }
147
148        let mut min = self.vertices[0].position;
149        let mut max = self.vertices[0].position;
150
151        for vertex in &self.vertices[1..] {
152            let p = &vertex.position;
153            min.x = min.x.min(p.x);
154            min.y = min.y.min(p.y);
155            min.z = min.z.min(p.z);
156            max.x = max.x.max(p.x);
157            max.y = max.y.max(p.y);
158            max.z = max.z.max(p.z);
159        }
160
161        Some((min, max))
162    }
163
164    /// Iterate over triangles, yielding Triangle structs with actual vertex data.
165    pub fn triangles(&self) -> impl Iterator<Item = Triangle> + '_ {
166        self.faces.iter().map(|&[i0, i1, i2]| Triangle {
167            v0: self.vertices[i0 as usize].position,
168            v1: self.vertices[i1 as usize].position,
169            v2: self.vertices[i2 as usize].position,
170        })
171    }
172
173    /// Get a specific triangle by face index.
174    pub fn triangle(&self, face_idx: usize) -> Option<Triangle> {
175        self.faces.get(face_idx).map(|&[i0, i1, i2]| Triangle {
176            v0: self.vertices[i0 as usize].position,
177            v1: self.vertices[i1 as usize].position,
178            v2: self.vertices[i2 as usize].position,
179        })
180    }
181
182    /// Rotate mesh 90 degrees around X axis (Y becomes Z, Z becomes -Y).
183    pub fn rotate_x_90(&mut self) {
184        for vertex in &mut self.vertices {
185            let old_y = vertex.position.y;
186            let old_z = vertex.position.z;
187            vertex.position.y = -old_z;
188            vertex.position.z = old_y;
189
190            if let Some(ref mut normal) = vertex.normal {
191                let old_ny = normal.y;
192                let old_nz = normal.z;
193                normal.y = -old_nz;
194                normal.z = old_ny;
195            }
196        }
197    }
198
199    /// Translate mesh so minimum Z is at zero.
200    pub fn place_on_z_zero(&mut self) {
201        if let Some((min, _)) = self.bounds() {
202            let offset = -min.z;
203            for vertex in &mut self.vertices {
204                vertex.position.z += offset;
205            }
206        }
207    }
208
209    /// Translate mesh by the given vector.
210    pub fn translate(&mut self, offset: Vector3<f64>) {
211        for vertex in &mut self.vertices {
212            vertex.position += offset;
213        }
214    }
215
216    /// Scale mesh uniformly around the origin.
217    pub fn scale(&mut self, factor: f64) {
218        for vertex in &mut self.vertices {
219            vertex.position.coords *= factor;
220        }
221    }
222
223    /// Compute the signed volume of the mesh.
224    ///
225    /// Uses the divergence theorem: the signed volume is the sum of signed tetrahedra
226    /// volumes formed by each face and the origin. For a closed mesh with outward-facing
227    /// normals (CCW winding when viewed from outside), this returns a positive value.
228    ///
229    /// # Returns
230    /// - Positive value: normals point outward (correct orientation)
231    /// - Negative value: normals point inward (inside-out mesh)
232    /// - Near-zero: mesh is not closed or has inconsistent winding
233    ///
234    /// # Note
235    /// This calculation assumes the mesh is closed (watertight). For open meshes,
236    /// the result is not meaningful as a volume measurement.
237    pub fn signed_volume(&self) -> f64 {
238        let mut volume = 0.0;
239
240        for &[i0, i1, i2] in &self.faces {
241            let v0 = &self.vertices[i0 as usize].position;
242            let v1 = &self.vertices[i1 as usize].position;
243            let v2 = &self.vertices[i2 as usize].position;
244
245            // Signed volume of tetrahedron with origin = (v0 · (v1 × v2)) / 6
246            // This is equivalent to the scalar triple product / 6
247            let cross = Vector3::new(
248                v1.y * v2.z - v1.z * v2.y,
249                v1.z * v2.x - v1.x * v2.z,
250                v1.x * v2.y - v1.y * v2.x,
251            );
252            volume += v0.x * cross.x + v0.y * cross.y + v0.z * cross.z;
253        }
254
255        volume / 6.0
256    }
257
258    /// Compute the absolute volume of the mesh.
259    ///
260    /// Returns the absolute value of `signed_volume()`. This gives the enclosed
261    /// volume regardless of normal orientation.
262    ///
263    /// # Note
264    /// This calculation assumes the mesh is closed (watertight). For open meshes,
265    /// the result is not meaningful as a volume measurement.
266    #[inline]
267    pub fn volume(&self) -> f64 {
268        self.signed_volume().abs()
269    }
270
271    /// Check if the mesh appears to be inside-out (inverted normals).
272    ///
273    /// A mesh is considered inside-out if its signed volume is negative,
274    /// meaning the face normals point inward rather than outward.
275    ///
276    /// # Returns
277    /// - `true` if signed volume is negative (inside-out)
278    /// - `false` if signed volume is positive or zero
279    ///
280    /// # Note
281    /// This is only meaningful for closed meshes. Open meshes or meshes
282    /// with inconsistent winding may give unreliable results.
283    #[inline]
284    pub fn is_inside_out(&self) -> bool {
285        self.signed_volume() < 0.0
286    }
287
288    /// Compute the total surface area of the mesh.
289    ///
290    /// Sums the area of all triangles in the mesh.
291    pub fn surface_area(&self) -> f64 {
292        self.triangles().map(|tri| tri.area()).sum()
293    }
294}
295
296impl Default for Mesh {
297    fn default() -> Self {
298        Self::new()
299    }
300}
301
302/// A triangle with concrete vertex positions.
303///
304/// Utility type for geometric calculations. Winding is counter-clockwise
305/// when viewed from the front (normal points toward viewer).
306#[derive(Debug, Clone, Copy)]
307pub struct Triangle {
308    pub v0: Point3<f64>,
309    pub v1: Point3<f64>,
310    pub v2: Point3<f64>,
311}
312
313impl Triangle {
314    /// Create a new triangle from three points.
315    #[inline]
316    pub fn new(v0: Point3<f64>, v1: Point3<f64>, v2: Point3<f64>) -> Self {
317        Self { v0, v1, v2 }
318    }
319
320    /// Compute the (unnormalized) face normal via cross product.
321    /// The direction follows the right-hand rule with CCW winding.
322    #[inline]
323    pub fn normal_unnormalized(&self) -> Vector3<f64> {
324        let e1 = self.v1 - self.v0;
325        let e2 = self.v2 - self.v0;
326        e1.cross(&e2)
327    }
328
329    /// Compute the unit face normal.
330    /// Returns None for degenerate triangles (zero area).
331    pub fn normal(&self) -> Option<Vector3<f64>> {
332        let n = self.normal_unnormalized();
333        let len_sq = n.norm_squared();
334        if len_sq > f64::EPSILON {
335            Some(n / len_sq.sqrt())
336        } else {
337            None
338        }
339    }
340
341    /// Compute the area of the triangle.
342    #[inline]
343    pub fn area(&self) -> f64 {
344        self.normal_unnormalized().norm() * 0.5
345    }
346
347    /// Compute the centroid (center of mass).
348    #[inline]
349    pub fn centroid(&self) -> Point3<f64> {
350        Point3::new(
351            (self.v0.x + self.v1.x + self.v2.x) / 3.0,
352            (self.v0.y + self.v1.y + self.v2.y) / 3.0,
353            (self.v0.z + self.v1.z + self.v2.z) / 3.0,
354        )
355    }
356
357    /// Get the three edges as (start, end) pairs.
358    pub fn edges(&self) -> [(Point3<f64>, Point3<f64>); 3] {
359        [(self.v0, self.v1), (self.v1, self.v2), (self.v2, self.v0)]
360    }
361
362    /// Compute the lengths of the three edges.
363    /// Returns [len01, len12, len20] where lenXY is the distance from vX to vY.
364    #[inline]
365    pub fn edge_lengths(&self) -> [f64; 3] {
366        [
367            (self.v1 - self.v0).norm(),
368            (self.v2 - self.v1).norm(),
369            (self.v0 - self.v2).norm(),
370        ]
371    }
372
373    /// Get the length of the shortest edge.
374    #[inline]
375    pub fn min_edge_length(&self) -> f64 {
376        let lengths = self.edge_lengths();
377        lengths[0].min(lengths[1]).min(lengths[2])
378    }
379
380    /// Get the length of the longest edge.
381    #[inline]
382    pub fn max_edge_length(&self) -> f64 {
383        let lengths = self.edge_lengths();
384        lengths[0].max(lengths[1]).max(lengths[2])
385    }
386
387    /// Compute the aspect ratio of the triangle.
388    ///
389    /// Aspect ratio is defined as longest_edge / shortest_altitude.
390    /// A well-shaped equilateral triangle has aspect ratio ≈ 1.15.
391    /// Very thin/needle triangles have high aspect ratios (>10 is problematic).
392    ///
393    /// Returns `f64::INFINITY` for degenerate triangles (zero area).
394    pub fn aspect_ratio(&self) -> f64 {
395        let area = self.area();
396        if area < f64::EPSILON {
397            return f64::INFINITY;
398        }
399
400        let max_edge = self.max_edge_length();
401
402        // Altitude = 2 * area / base
403        // Shortest altitude corresponds to longest edge as base
404        let shortest_altitude = 2.0 * area / max_edge;
405
406        if shortest_altitude < f64::EPSILON {
407            return f64::INFINITY;
408        }
409
410        max_edge / shortest_altitude
411    }
412
413    /// Check if triangle vertices are nearly collinear.
414    ///
415    /// Uses the cross product magnitude relative to edge lengths to detect
416    /// triangles where all three vertices are nearly on a line.
417    pub fn is_nearly_collinear(&self, epsilon: f64) -> bool {
418        let e1 = self.v1 - self.v0;
419        let e2 = self.v2 - self.v0;
420
421        let cross_magnitude = e1.cross(&e2).norm();
422        let edge_product = e1.norm() * e2.norm();
423
424        if edge_product < f64::EPSILON {
425            return true; // Degenerate edges
426        }
427
428        // sin(angle) = |cross| / (|e1| * |e2|)
429        // For nearly collinear, sin(angle) ≈ 0
430        cross_magnitude / edge_product < epsilon
431    }
432
433    /// Check if the triangle is degenerate (zero or near-zero area).
434    pub fn is_degenerate(&self, epsilon: f64) -> bool {
435        self.area() < epsilon
436    }
437
438    /// Check if the triangle is degenerate using multiple criteria.
439    ///
440    /// A triangle is considered degenerate if any of these conditions are met:
441    /// - Area is below `area_threshold`
442    /// - Aspect ratio exceeds `max_aspect_ratio`
443    /// - Shortest edge is below `min_edge_length`
444    ///
445    /// # Arguments
446    /// * `area_threshold` - Minimum acceptable area (e.g., 1e-9)
447    /// * `max_aspect_ratio` - Maximum acceptable aspect ratio (e.g., 1000.0)
448    /// * `min_edge_length` - Minimum acceptable edge length (e.g., 1e-9)
449    pub fn is_degenerate_enhanced(
450        &self,
451        area_threshold: f64,
452        max_aspect_ratio: f64,
453        min_edge_length: f64,
454    ) -> bool {
455        // Check area
456        if self.area() < area_threshold {
457            return true;
458        }
459
460        // Check aspect ratio
461        if self.aspect_ratio() > max_aspect_ratio {
462            return true;
463        }
464
465        // Check minimum edge length
466        if self.min_edge_length() < min_edge_length {
467            return true;
468        }
469
470        false
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    fn approx_eq(a: f64, b: f64) -> bool {
479        (a - b).abs() < 1e-10
480    }
481
482    #[test]
483    fn test_vertex_creation() {
484        let v = Vertex::from_coords(1.0, 2.0, 3.0);
485        assert!(approx_eq(v.position.x, 1.0));
486        assert!(approx_eq(v.position.y, 2.0));
487        assert!(approx_eq(v.position.z, 3.0));
488        assert!(v.normal.is_none());
489        assert!(v.tag.is_none());
490        assert!(v.offset.is_none());
491    }
492
493    #[test]
494    fn test_triangle_normal() {
495        let tri = Triangle::new(
496            Point3::new(0.0, 0.0, 0.0),
497            Point3::new(1.0, 0.0, 0.0),
498            Point3::new(0.0, 1.0, 0.0),
499        );
500
501        let normal = tri.normal().expect("non-degenerate triangle");
502        assert!(approx_eq(normal.x, 0.0));
503        assert!(approx_eq(normal.y, 0.0));
504        assert!(approx_eq(normal.z, 1.0));
505    }
506
507    #[test]
508    fn test_triangle_area() {
509        let tri = Triangle::new(
510            Point3::new(0.0, 0.0, 0.0),
511            Point3::new(1.0, 0.0, 0.0),
512            Point3::new(0.0, 1.0, 0.0),
513        );
514        assert!(approx_eq(tri.area(), 0.5));
515    }
516
517    #[test]
518    fn test_triangle_centroid() {
519        let tri = Triangle::new(
520            Point3::new(0.0, 0.0, 0.0),
521            Point3::new(3.0, 0.0, 0.0),
522            Point3::new(0.0, 3.0, 0.0),
523        );
524        let c = tri.centroid();
525        assert!(approx_eq(c.x, 1.0));
526        assert!(approx_eq(c.y, 1.0));
527        assert!(approx_eq(c.z, 0.0));
528    }
529
530    #[test]
531    fn test_degenerate_triangle_normal() {
532        let tri = Triangle::new(
533            Point3::new(0.0, 0.0, 0.0),
534            Point3::new(1.0, 0.0, 0.0),
535            Point3::new(2.0, 0.0, 0.0),
536        );
537        assert!(tri.normal().is_none());
538    }
539
540    #[test]
541    fn test_mesh_bounds() {
542        let mut mesh = Mesh::new();
543        mesh.vertices.push(Vertex::from_coords(0.0, 0.0, 0.0));
544        mesh.vertices.push(Vertex::from_coords(10.0, 5.0, 3.0));
545        mesh.vertices.push(Vertex::from_coords(-2.0, 8.0, 1.0));
546
547        let (min, max) = mesh.bounds().expect("non-empty mesh");
548        assert!(approx_eq(min.x, -2.0));
549        assert!(approx_eq(min.y, 0.0));
550        assert!(approx_eq(min.z, 0.0));
551        assert!(approx_eq(max.x, 10.0));
552        assert!(approx_eq(max.y, 8.0));
553        assert!(approx_eq(max.z, 3.0));
554    }
555
556    #[test]
557    fn test_empty_mesh_bounds() {
558        let mesh = Mesh::new();
559        assert!(mesh.bounds().is_none());
560    }
561
562    #[test]
563    fn test_mesh_is_empty() {
564        let mesh = Mesh::new();
565        assert!(mesh.is_empty());
566
567        let mut mesh2 = Mesh::new();
568        mesh2.vertices.push(Vertex::from_coords(0.0, 0.0, 0.0));
569        assert!(mesh2.is_empty()); // no faces
570
571        mesh2.faces.push([0, 0, 0]);
572        assert!(!mesh2.is_empty());
573    }
574
575    #[test]
576    fn test_triangle_edge_lengths() {
577        // Right triangle with legs of length 3 and 4
578        let tri = Triangle::new(
579            Point3::new(0.0, 0.0, 0.0),
580            Point3::new(3.0, 0.0, 0.0),
581            Point3::new(0.0, 4.0, 0.0),
582        );
583        let lengths = tri.edge_lengths();
584        assert!(approx_eq(lengths[0], 3.0)); // v0 -> v1
585        assert!(approx_eq(lengths[1], 5.0)); // v1 -> v2 (hypotenuse)
586        assert!(approx_eq(lengths[2], 4.0)); // v2 -> v0
587    }
588
589    #[test]
590    fn test_triangle_min_max_edge_length() {
591        // Right triangle with legs of length 3 and 4, hypotenuse 5
592        let tri = Triangle::new(
593            Point3::new(0.0, 0.0, 0.0),
594            Point3::new(3.0, 0.0, 0.0),
595            Point3::new(0.0, 4.0, 0.0),
596        );
597        assert!(approx_eq(tri.min_edge_length(), 3.0));
598        assert!(approx_eq(tri.max_edge_length(), 5.0));
599    }
600
601    #[test]
602    fn test_triangle_aspect_ratio_equilateral() {
603        // Equilateral triangle with side length 2
604        let sqrt3 = 3.0_f64.sqrt();
605        let tri = Triangle::new(
606            Point3::new(0.0, 0.0, 0.0),
607            Point3::new(2.0, 0.0, 0.0),
608            Point3::new(1.0, sqrt3, 0.0),
609        );
610        // For equilateral: aspect ratio = edge / altitude = 2 / sqrt(3) ≈ 1.1547
611        let ar = tri.aspect_ratio();
612        assert!(
613            ar > 1.1 && ar < 1.2,
614            "Equilateral aspect ratio should be ~1.15, got {}",
615            ar
616        );
617    }
618
619    #[test]
620    fn test_triangle_aspect_ratio_thin() {
621        // Very thin triangle (needle-like)
622        let tri = Triangle::new(
623            Point3::new(0.0, 0.0, 0.0),
624            Point3::new(100.0, 0.0, 0.0),
625            Point3::new(50.0, 0.1, 0.0),
626        );
627        let ar = tri.aspect_ratio();
628        // Should be very high for thin triangles
629        assert!(
630            ar > 100.0,
631            "Thin triangle should have high aspect ratio, got {}",
632            ar
633        );
634    }
635
636    #[test]
637    fn test_triangle_aspect_ratio_degenerate() {
638        // Collinear points (degenerate)
639        let tri = Triangle::new(
640            Point3::new(0.0, 0.0, 0.0),
641            Point3::new(1.0, 0.0, 0.0),
642            Point3::new(2.0, 0.0, 0.0),
643        );
644        assert!(tri.aspect_ratio().is_infinite());
645    }
646
647    #[test]
648    fn test_triangle_is_nearly_collinear() {
649        // Collinear points
650        let tri_collinear = Triangle::new(
651            Point3::new(0.0, 0.0, 0.0),
652            Point3::new(1.0, 0.0, 0.0),
653            Point3::new(2.0, 0.0, 0.0),
654        );
655        assert!(tri_collinear.is_nearly_collinear(0.01));
656
657        // Nearly collinear (small deviation)
658        let tri_nearly = Triangle::new(
659            Point3::new(0.0, 0.0, 0.0),
660            Point3::new(100.0, 0.0, 0.0),
661            Point3::new(50.0, 0.001, 0.0),
662        );
663        assert!(tri_nearly.is_nearly_collinear(0.001));
664
665        // Not collinear - well-formed triangle
666        let tri_good = Triangle::new(
667            Point3::new(0.0, 0.0, 0.0),
668            Point3::new(1.0, 0.0, 0.0),
669            Point3::new(0.5, 1.0, 0.0),
670        );
671        assert!(!tri_good.is_nearly_collinear(0.01));
672    }
673
674    #[test]
675    fn test_triangle_is_degenerate_basic() {
676        // Degenerate by area
677        let tri_zero_area = Triangle::new(
678            Point3::new(0.0, 0.0, 0.0),
679            Point3::new(1.0, 0.0, 0.0),
680            Point3::new(2.0, 0.0, 0.0),
681        );
682        assert!(tri_zero_area.is_degenerate(1e-9));
683
684        // Not degenerate
685        let tri_good = Triangle::new(
686            Point3::new(0.0, 0.0, 0.0),
687            Point3::new(1.0, 0.0, 0.0),
688            Point3::new(0.0, 1.0, 0.0),
689        );
690        assert!(!tri_good.is_degenerate(1e-9));
691    }
692
693    #[test]
694    fn test_triangle_is_degenerate_enhanced() {
695        // Good triangle - should pass all checks
696        let tri_good = Triangle::new(
697            Point3::new(0.0, 0.0, 0.0),
698            Point3::new(1.0, 0.0, 0.0),
699            Point3::new(0.5, 1.0, 0.0),
700        );
701        assert!(!tri_good.is_degenerate_enhanced(1e-9, 1000.0, 1e-9));
702
703        // Degenerate by area threshold
704        let tri_tiny = Triangle::new(
705            Point3::new(0.0, 0.0, 0.0),
706            Point3::new(1e-6, 0.0, 0.0),
707            Point3::new(0.0, 1e-6, 0.0),
708        );
709        assert!(tri_tiny.is_degenerate_enhanced(1e-9, 1000.0, 1e-9));
710
711        // Degenerate by aspect ratio
712        let tri_thin = Triangle::new(
713            Point3::new(0.0, 0.0, 0.0),
714            Point3::new(100.0, 0.0, 0.0),
715            Point3::new(50.0, 0.01, 0.0),
716        );
717        assert!(tri_thin.is_degenerate_enhanced(1e-9, 100.0, 1e-9));
718
719        // Degenerate by minimum edge length
720        let tri_short_edge = Triangle::new(
721            Point3::new(0.0, 0.0, 0.0),
722            Point3::new(1e-6, 0.0, 0.0),
723            Point3::new(0.5, 1.0, 0.0),
724        );
725        assert!(tri_short_edge.is_degenerate_enhanced(1e-12, 1000.0, 1e-5));
726    }
727
728    #[test]
729    fn test_triangle_edges() {
730        let tri = Triangle::new(
731            Point3::new(0.0, 0.0, 0.0),
732            Point3::new(1.0, 0.0, 0.0),
733            Point3::new(0.0, 1.0, 0.0),
734        );
735        let edges = tri.edges();
736
737        // Check edge 0: v0 -> v1
738        assert!(approx_eq(edges[0].0.x, 0.0) && approx_eq(edges[0].1.x, 1.0));
739        // Check edge 1: v1 -> v2
740        assert!(approx_eq(edges[1].0.x, 1.0) && approx_eq(edges[1].1.y, 1.0));
741        // Check edge 2: v2 -> v0
742        assert!(approx_eq(edges[2].0.y, 1.0) && approx_eq(edges[2].1.x, 0.0));
743    }
744
745    /// Create a unit cube mesh with outward-facing normals (CCW winding from outside).
746    /// Vertices at (0,0,0) to (1,1,1).
747    fn make_unit_cube() -> Mesh {
748        let mut mesh = Mesh::new();
749
750        // 8 vertices of the cube
751        mesh.vertices.push(Vertex::from_coords(0.0, 0.0, 0.0)); // 0
752        mesh.vertices.push(Vertex::from_coords(1.0, 0.0, 0.0)); // 1
753        mesh.vertices.push(Vertex::from_coords(1.0, 1.0, 0.0)); // 2
754        mesh.vertices.push(Vertex::from_coords(0.0, 1.0, 0.0)); // 3
755        mesh.vertices.push(Vertex::from_coords(0.0, 0.0, 1.0)); // 4
756        mesh.vertices.push(Vertex::from_coords(1.0, 0.0, 1.0)); // 5
757        mesh.vertices.push(Vertex::from_coords(1.0, 1.0, 1.0)); // 6
758        mesh.vertices.push(Vertex::from_coords(0.0, 1.0, 1.0)); // 7
759
760        // 12 triangles (2 per face), CCW winding when viewed from outside
761
762        // Bottom face (z=0) - normal points -Z, CCW from below
763        mesh.faces.push([0, 2, 1]);
764        mesh.faces.push([0, 3, 2]);
765
766        // Top face (z=1) - normal points +Z, CCW from above
767        mesh.faces.push([4, 5, 6]);
768        mesh.faces.push([4, 6, 7]);
769
770        // Front face (y=0) - normal points -Y
771        mesh.faces.push([0, 1, 5]);
772        mesh.faces.push([0, 5, 4]);
773
774        // Back face (y=1) - normal points +Y
775        mesh.faces.push([3, 7, 6]);
776        mesh.faces.push([3, 6, 2]);
777
778        // Left face (x=0) - normal points -X
779        mesh.faces.push([0, 4, 7]);
780        mesh.faces.push([0, 7, 3]);
781
782        // Right face (x=1) - normal points +X
783        mesh.faces.push([1, 2, 6]);
784        mesh.faces.push([1, 6, 5]);
785
786        mesh
787    }
788
789    #[test]
790    fn test_signed_volume_unit_cube() {
791        let mesh = make_unit_cube();
792        let vol = mesh.signed_volume();
793        // Unit cube volume = 1.0
794        assert!(
795            (vol - 1.0).abs() < 1e-10,
796            "Unit cube signed volume should be 1.0, got {}",
797            vol
798        );
799    }
800
801    #[test]
802    fn test_volume_unit_cube() {
803        let mesh = make_unit_cube();
804        let vol = mesh.volume();
805        assert!(
806            (vol - 1.0).abs() < 1e-10,
807            "Unit cube volume should be 1.0, got {}",
808            vol
809        );
810    }
811
812    #[test]
813    fn test_signed_volume_scaled_cube() {
814        let mut mesh = make_unit_cube();
815        mesh.scale(2.0); // 2x2x2 cube
816        let vol = mesh.signed_volume();
817        // Volume = 8.0
818        assert!(
819            (vol - 8.0).abs() < 1e-10,
820            "2x2x2 cube signed volume should be 8.0, got {}",
821            vol
822        );
823    }
824
825    #[test]
826    fn test_signed_volume_inverted_cube() {
827        let mut mesh = make_unit_cube();
828        // Invert all faces by swapping indices
829        for face in &mut mesh.faces {
830            face.swap(1, 2);
831        }
832        let vol = mesh.signed_volume();
833        // Should be negative for inside-out mesh
834        assert!(
835            (vol + 1.0).abs() < 1e-10,
836            "Inverted cube signed volume should be -1.0, got {}",
837            vol
838        );
839    }
840
841    #[test]
842    fn test_is_inside_out_normal_cube() {
843        let mesh = make_unit_cube();
844        assert!(
845            !mesh.is_inside_out(),
846            "Normal cube should not be inside-out"
847        );
848    }
849
850    #[test]
851    fn test_is_inside_out_inverted_cube() {
852        let mut mesh = make_unit_cube();
853        // Invert all faces
854        for face in &mut mesh.faces {
855            face.swap(1, 2);
856        }
857        assert!(mesh.is_inside_out(), "Inverted cube should be inside-out");
858    }
859
860    #[test]
861    fn test_signed_volume_tetrahedron() {
862        // Regular tetrahedron with one vertex at origin
863        let mut mesh = Mesh::new();
864        mesh.vertices.push(Vertex::from_coords(0.0, 0.0, 0.0)); // 0
865        mesh.vertices.push(Vertex::from_coords(1.0, 0.0, 0.0)); // 1
866        mesh.vertices.push(Vertex::from_coords(0.5, 0.866025, 0.0)); // 2 (approx sqrt(3)/2)
867        mesh.vertices
868            .push(Vertex::from_coords(0.5, 0.288675, 0.816497)); // 3 (apex)
869
870        // Faces with outward normals (CCW from outside)
871        mesh.faces.push([0, 2, 1]); // Bottom face
872        mesh.faces.push([0, 1, 3]); // Front face
873        mesh.faces.push([1, 2, 3]); // Right face
874        mesh.faces.push([2, 0, 3]); // Left face
875
876        let vol = mesh.signed_volume();
877        // Tetrahedron volume = (edge^3) / (6 * sqrt(2)) ≈ 0.1178 for edge=1
878        // With our vertices, the expected volume is approximately 0.1178
879        assert!(
880            vol > 0.1 && vol < 0.15,
881            "Tetrahedron volume should be ~0.1178, got {}",
882            vol
883        );
884    }
885
886    #[test]
887    fn test_signed_volume_translated_cube() {
888        let mut mesh = make_unit_cube();
889        // Translate away from origin
890        mesh.translate(Vector3::new(10.0, 20.0, 30.0));
891        let vol = mesh.signed_volume();
892        // Volume should still be 1.0 (translation invariant)
893        assert!(
894            (vol - 1.0).abs() < 1e-10,
895            "Translated cube volume should still be 1.0, got {}",
896            vol
897        );
898    }
899
900    #[test]
901    fn test_signed_volume_empty_mesh() {
902        let mesh = Mesh::new();
903        let vol = mesh.signed_volume();
904        assert!(
905            vol.abs() < 1e-10,
906            "Empty mesh volume should be 0, got {}",
907            vol
908        );
909    }
910
911    #[test]
912    fn test_surface_area_unit_cube() {
913        let mesh = make_unit_cube();
914        let area = mesh.surface_area();
915        // Unit cube surface area = 6 faces * 1.0 = 6.0
916        assert!(
917            (area - 6.0).abs() < 1e-10,
918            "Unit cube surface area should be 6.0, got {}",
919            area
920        );
921    }
922
923    #[test]
924    fn test_surface_area_single_triangle() {
925        let mut mesh = Mesh::new();
926        mesh.vertices.push(Vertex::from_coords(0.0, 0.0, 0.0));
927        mesh.vertices.push(Vertex::from_coords(1.0, 0.0, 0.0));
928        mesh.vertices.push(Vertex::from_coords(0.0, 1.0, 0.0));
929        mesh.faces.push([0, 1, 2]);
930
931        let area = mesh.surface_area();
932        // Right triangle with legs 1 and 1, area = 0.5
933        assert!(
934            (area - 0.5).abs() < 1e-10,
935            "Triangle area should be 0.5, got {}",
936            area
937        );
938    }
939
940    #[test]
941    fn test_surface_area_empty_mesh() {
942        let mesh = Mesh::new();
943        let area = mesh.surface_area();
944        assert!(
945            area.abs() < 1e-10,
946            "Empty mesh area should be 0, got {}",
947            area
948        );
949    }
950}