Skip to main content

oxiphysics_geometry/
mesh_boolean.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Mesh boolean operations on triangle meshes.
5//!
6//! Provides robust boolean operations (union, intersection, difference) on
7//! closed triangle meshes using:
8//!
9//! - **Triangle-triangle intersection detection** — Möller's separating axis
10//!   test and segment/plane clipping.
11//! - **Contour extraction** — intersection curves between two meshes.
12//! - **Surface splitting** — re-triangulation of intersected faces.
13//! - **Inside/outside classification** — generalised winding number.
14//! - **Co-planar triangle handling** — projection-based merging.
15//! - **Mesh stitching** — combining split surfaces into a watertight result.
16//! - **Result cleanup** — degenerate triangle removal, vertex welding.
17
18// ─────────────────────────────────────────────────────────────────────────────
19// Vector helpers (no nalgebra in non-core crates)
20// ─────────────────────────────────────────────────────────────────────────────
21
22/// Three-component vector type alias.
23type V3 = [f64; 3];
24
25/// Small tolerance for geometric tests.
26const GEO_EPS: f64 = 1e-10;
27
28/// Tolerance for vertex welding.
29const WELD_EPS: f64 = 1e-9;
30
31#[inline]
32fn sub(a: V3, b: V3) -> V3 {
33    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
34}
35
36#[inline]
37fn add(a: V3, b: V3) -> V3 {
38    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
39}
40
41#[inline]
42fn scale(a: V3, s: f64) -> V3 {
43    [a[0] * s, a[1] * s, a[2] * s]
44}
45
46#[inline]
47fn dot(a: V3, b: V3) -> f64 {
48    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
49}
50
51#[inline]
52fn cross(a: V3, b: V3) -> V3 {
53    [
54        a[1] * b[2] - a[2] * b[1],
55        a[2] * b[0] - a[0] * b[2],
56        a[0] * b[1] - a[1] * b[0],
57    ]
58}
59
60#[inline]
61fn length(a: V3) -> f64 {
62    dot(a, a).sqrt()
63}
64
65#[inline]
66fn normalize(a: V3) -> V3 {
67    let l = length(a);
68    if l < GEO_EPS {
69        [0.0, 0.0, 0.0]
70    } else {
71        [a[0] / l, a[1] / l, a[2] / l]
72    }
73}
74
75#[inline]
76fn lerp(a: V3, b: V3, t: f64) -> V3 {
77    [
78        a[0] + t * (b[0] - a[0]),
79        a[1] + t * (b[1] - a[1]),
80        a[2] + t * (b[2] - a[2]),
81    ]
82}
83
84#[inline]
85fn dist_sq(a: V3, b: V3) -> f64 {
86    let d = sub(a, b);
87    dot(d, d)
88}
89
90/// Triangle area from three vertices.
91#[inline]
92fn triangle_area(a: V3, b: V3, c: V3) -> f64 {
93    0.5 * length(cross(sub(b, a), sub(c, a)))
94}
95
96/// Triangle normal (unnormalised).
97#[inline]
98fn triangle_normal(a: V3, b: V3, c: V3) -> V3 {
99    cross(sub(b, a), sub(c, a))
100}
101
102// ─────────────────────────────────────────────────────────────────────────────
103// BooleanOp
104// ─────────────────────────────────────────────────────────────────────────────
105
106/// Boolean operation type.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum MeshBooleanOp {
109    /// A ∪ B.
110    Union,
111    /// A ∩ B.
112    Intersection,
113    /// A \ B.
114    Difference,
115}
116
117// ─────────────────────────────────────────────────────────────────────────────
118// SimpleMesh — lightweight triangle mesh
119// ─────────────────────────────────────────────────────────────────────────────
120
121/// A simple indexed triangle mesh using `[f64;3]` vertices.
122#[derive(Debug, Clone)]
123pub struct SimpleMesh {
124    /// Vertex positions.
125    pub vertices: Vec<V3>,
126    /// Triangle indices (each triple is one triangle).
127    pub triangles: Vec<[usize; 3]>,
128}
129
130impl SimpleMesh {
131    /// Create a new empty mesh.
132    pub fn new() -> Self {
133        Self {
134            vertices: Vec::new(),
135            triangles: Vec::new(),
136        }
137    }
138
139    /// Create a mesh from vertices and triangles.
140    pub fn from_data(vertices: Vec<V3>, triangles: Vec<[usize; 3]>) -> Self {
141        Self {
142            vertices,
143            triangles,
144        }
145    }
146
147    /// Number of triangles.
148    pub fn n_triangles(&self) -> usize {
149        self.triangles.len()
150    }
151
152    /// Number of vertices.
153    pub fn n_vertices(&self) -> usize {
154        self.vertices.len()
155    }
156
157    /// Get triangle vertex positions.
158    pub fn triangle_verts(&self, tri_idx: usize) -> (V3, V3, V3) {
159        let t = self.triangles[tri_idx];
160        (
161            self.vertices[t[0]],
162            self.vertices[t[1]],
163            self.vertices[t[2]],
164        )
165    }
166
167    /// Compute the axis-aligned bounding box.
168    pub fn aabb(&self) -> (V3, V3) {
169        if self.vertices.is_empty() {
170            return ([0.0; 3], [0.0; 3]);
171        }
172        let mut mn = self.vertices[0];
173        let mut mx = self.vertices[0];
174        for v in &self.vertices {
175            for d in 0..3 {
176                if v[d] < mn[d] {
177                    mn[d] = v[d];
178                }
179                if v[d] > mx[d] {
180                    mx[d] = v[d];
181                }
182            }
183        }
184        (mn, mx)
185    }
186
187    /// Add a vertex and return its index.
188    pub fn add_vertex(&mut self, v: V3) -> usize {
189        let idx = self.vertices.len();
190        self.vertices.push(v);
191        idx
192    }
193
194    /// Add a triangle.
195    pub fn add_triangle(&mut self, tri: [usize; 3]) {
196        self.triangles.push(tri);
197    }
198
199    /// Compute total surface area.
200    pub fn surface_area(&self) -> f64 {
201        let mut area = 0.0;
202        for &t in &self.triangles {
203            area += triangle_area(
204                self.vertices[t[0]],
205                self.vertices[t[1]],
206                self.vertices[t[2]],
207            );
208        }
209        area
210    }
211
212    /// Compute signed volume (for closed meshes).
213    pub fn signed_volume(&self) -> f64 {
214        let mut vol = 0.0;
215        for &t in &self.triangles {
216            let a = self.vertices[t[0]];
217            let b = self.vertices[t[1]];
218            let c = self.vertices[t[2]];
219            vol += dot(a, cross(b, c));
220        }
221        vol / 6.0
222    }
223
224    /// Flip all triangle orientations.
225    pub fn flip_normals(&mut self) {
226        for t in &mut self.triangles {
227            t.swap(0, 1);
228        }
229    }
230
231    /// Create a unit cube mesh (for testing).
232    pub fn unit_cube(centre: V3, half_extent: f64) -> Self {
233        let h = half_extent;
234        let c = centre;
235        let vertices = vec![
236            [c[0] - h, c[1] - h, c[2] - h], // 0
237            [c[0] + h, c[1] - h, c[2] - h], // 1
238            [c[0] + h, c[1] + h, c[2] - h], // 2
239            [c[0] - h, c[1] + h, c[2] - h], // 3
240            [c[0] - h, c[1] - h, c[2] + h], // 4
241            [c[0] + h, c[1] - h, c[2] + h], // 5
242            [c[0] + h, c[1] + h, c[2] + h], // 6
243            [c[0] - h, c[1] + h, c[2] + h], // 7
244        ];
245        let triangles = vec![
246            // -Z face
247            [0, 2, 1],
248            [0, 3, 2],
249            // +Z face
250            [4, 5, 6],
251            [4, 6, 7],
252            // -Y face
253            [0, 1, 5],
254            [0, 5, 4],
255            // +Y face
256            [2, 3, 7],
257            [2, 7, 6],
258            // -X face
259            [0, 4, 7],
260            [0, 7, 3],
261            // +X face
262            [1, 2, 6],
263            [1, 6, 5],
264        ];
265        Self {
266            vertices,
267            triangles,
268        }
269    }
270
271    /// Create a tetrahedron mesh (for testing).
272    pub fn tetrahedron(centre: V3, _size: f64) -> Self {
273        let s = _size;
274        let vertices = vec![
275            [centre[0] + s, centre[1] + s, centre[2] + s],
276            [centre[0] + s, centre[1] - s, centre[2] - s],
277            [centre[0] - s, centre[1] + s, centre[2] - s],
278            [centre[0] - s, centre[1] - s, centre[2] + s],
279        ];
280        let triangles = vec![[0, 1, 2], [0, 3, 1], [0, 2, 3], [1, 3, 2]];
281        Self {
282            vertices,
283            triangles,
284        }
285    }
286}
287
288impl Default for SimpleMesh {
289    fn default() -> Self {
290        Self::new()
291    }
292}
293
294// ─────────────────────────────────────────────────────────────────────────────
295// Triangle-Triangle Intersection
296// ─────────────────────────────────────────────────────────────────────────────
297
298/// Result of a triangle-triangle intersection test.
299#[derive(Debug, Clone)]
300pub enum TriTriResult {
301    /// No intersection.
302    None,
303    /// Triangles intersect along a segment.
304    Segment(V3, V3),
305    /// Triangles are co-planar and overlap.
306    Coplanar,
307    /// Single point contact.
308    Point(V3),
309}
310
311/// Test if two triangles intersect using Möller's method.
312///
313/// Returns the intersection type and geometry.
314pub fn triangle_triangle_intersection(
315    a0: V3,
316    a1: V3,
317    a2: V3,
318    b0: V3,
319    b1: V3,
320    b2: V3,
321) -> TriTriResult {
322    // Plane of triangle A
323    let na = triangle_normal(a0, a1, a2);
324    let da = dot(na, a0);
325    let db0 = dot(na, b0) - da;
326    let db1 = dot(na, b1) - da;
327    let db2 = dot(na, b2) - da;
328
329    // Snap near-zero to zero
330    let db0 = if db0.abs() < GEO_EPS { 0.0 } else { db0 };
331    let db1 = if db1.abs() < GEO_EPS { 0.0 } else { db1 };
332    let db2 = if db2.abs() < GEO_EPS { 0.0 } else { db2 };
333
334    // All on same side → no intersection
335    if db0 > 0.0 && db1 > 0.0 && db2 > 0.0 {
336        return TriTriResult::None;
337    }
338    if db0 < 0.0 && db1 < 0.0 && db2 < 0.0 {
339        return TriTriResult::None;
340    }
341
342    // Check co-planar
343    if db0.abs() < GEO_EPS && db1.abs() < GEO_EPS && db2.abs() < GEO_EPS {
344        if coplanar_triangles_overlap(a0, a1, a2, b0, b1, b2, na) {
345            return TriTriResult::Coplanar;
346        }
347        return TriTriResult::None;
348    }
349
350    // Plane of triangle B
351    let nb = triangle_normal(b0, b1, b2);
352    let _db_val = dot(nb, b0);
353    let da0 = dot(nb, a0) - _db_val;
354    let da1 = dot(nb, a1) - _db_val;
355    let da2 = dot(nb, a2) - _db_val;
356
357    let da0 = if da0.abs() < GEO_EPS { 0.0 } else { da0 };
358    let da1 = if da1.abs() < GEO_EPS { 0.0 } else { da1 };
359    let da2 = if da2.abs() < GEO_EPS { 0.0 } else { da2 };
360
361    if da0 > 0.0 && da1 > 0.0 && da2 > 0.0 {
362        return TriTriResult::None;
363    }
364    if da0 < 0.0 && da1 < 0.0 && da2 < 0.0 {
365        return TriTriResult::None;
366    }
367
368    // Intersection line direction
369    let dir = cross(na, nb);
370    let dir_len = length(dir);
371    if dir_len < GEO_EPS {
372        return TriTriResult::None;
373    }
374    let dir = normalize(dir);
375
376    // Project vertices onto intersection line
377    let seg_a = compute_interval_on_line(a0, a1, a2, da0, da1, da2, dir);
378    let seg_b = compute_interval_on_line(b0, b1, b2, db0, db1, db2, dir);
379
380    if let (Some((ta_min, ta_max, pa_min, pa_max)), Some((tb_min, tb_max, pb_min, pb_max))) =
381        (seg_a, seg_b)
382    {
383        // Overlap of [ta_min, ta_max] and [tb_min, tb_max]
384        let t0 = ta_min.max(tb_min);
385        let t1 = ta_max.min(tb_max);
386        if t0 > t1 + GEO_EPS {
387            return TriTriResult::None;
388        }
389        // Interpolate actual 3D points
390        let p0 = if t0 >= ta_min - GEO_EPS && t0 <= ta_max + GEO_EPS {
391            interp_seg_param(pa_min, pa_max, ta_min, ta_max, t0)
392        } else {
393            interp_seg_param(pb_min, pb_max, tb_min, tb_max, t0)
394        };
395        let p1 = if t1 >= ta_min - GEO_EPS && t1 <= ta_max + GEO_EPS {
396            interp_seg_param(pa_min, pa_max, ta_min, ta_max, t1)
397        } else {
398            interp_seg_param(pb_min, pb_max, tb_min, tb_max, t1)
399        };
400        if dist_sq(p0, p1) < GEO_EPS * GEO_EPS {
401            return TriTriResult::Point(p0);
402        }
403        TriTriResult::Segment(p0, p1)
404    } else {
405        TriTriResult::None
406    }
407}
408
409/// Compute the interval where a triangle's edges cross the opposite plane,
410/// projected onto the intersection line direction.
411/// Returns `(t_min, t_max, point_at_t_min, point_at_t_max)`.
412fn compute_interval_on_line(
413    v0: V3,
414    v1: V3,
415    v2: V3,
416    d0: f64,
417    d1: f64,
418    d2: f64,
419    dir: V3,
420) -> Option<(f64, f64, V3, V3)> {
421    let verts = [v0, v1, v2];
422    let dists = [d0, d1, d2];
423
424    // Find crossing points: edges where sign changes
425    let mut points: Vec<(f64, V3)> = Vec::new();
426
427    // Check vertices on the plane
428    for i in 0..3 {
429        if dists[i].abs() < GEO_EPS {
430            let t = dot(verts[i], dir);
431            points.push((t, verts[i]));
432        }
433    }
434
435    // Check edges crossing the plane
436    for (i, j) in [(0, 1), (1, 2), (2, 0)] {
437        if (dists[i] > GEO_EPS && dists[j] < -GEO_EPS)
438            || (dists[i] < -GEO_EPS && dists[j] > GEO_EPS)
439        {
440            let s = dists[i] / (dists[i] - dists[j]);
441            let p = lerp(verts[i], verts[j], s);
442            let t = dot(p, dir);
443            points.push((t, p));
444        }
445    }
446
447    if points.is_empty() {
448        return None;
449    }
450
451    // Find min and max t
452    let mut min_idx = 0;
453    let mut max_idx = 0;
454    for (k, (t, _)) in points.iter().enumerate() {
455        if *t < points[min_idx].0 {
456            min_idx = k;
457        }
458        if *t > points[max_idx].0 {
459            max_idx = k;
460        }
461    }
462
463    Some((
464        points[min_idx].0,
465        points[max_idx].0,
466        points[min_idx].1,
467        points[max_idx].1,
468    ))
469}
470
471/// Interpolate between segment endpoints at a parameter value.
472fn interp_seg_param(p_min: V3, p_max: V3, t_min: f64, t_max: f64, t: f64) -> V3 {
473    if (t_max - t_min).abs() < GEO_EPS {
474        return p_min;
475    }
476    let s = (t - t_min) / (t_max - t_min);
477    lerp(p_min, p_max, s.clamp(0.0, 1.0))
478}
479
480// ─────────────────────────────────────────────────────────────────────────────
481// Co-planar triangle overlap
482// ─────────────────────────────────────────────────────────────────────────────
483
484/// Test if two co-planar triangles overlap by projecting onto the dominant
485/// axis and performing 2D edge-edge and containment tests.
486fn coplanar_triangles_overlap(a0: V3, a1: V3, a2: V3, b0: V3, b1: V3, b2: V3, normal: V3) -> bool {
487    // Find dominant axis (largest component of normal)
488    let abs_n = [normal[0].abs(), normal[1].abs(), normal[2].abs()];
489    let (ax1, ax2) = if abs_n[0] >= abs_n[1] && abs_n[0] >= abs_n[2] {
490        (1, 2)
491    } else if abs_n[1] >= abs_n[2] {
492        (0, 2)
493    } else {
494        (0, 1)
495    };
496
497    let proj = |v: V3| -> [f64; 2] { [v[ax1], v[ax2]] };
498
499    let pa = [proj(a0), proj(a1), proj(a2)];
500    let pb = [proj(b0), proj(b1), proj(b2)];
501
502    // Check edge-edge intersections
503    for i in 0..3 {
504        let j = (i + 1) % 3;
505        for k in 0..3 {
506            let l = (k + 1) % 3;
507            if segments_intersect_2d(pa[i], pa[j], pb[k], pb[l]) {
508                return true;
509            }
510        }
511    }
512
513    // Check containment
514    if point_in_triangle_2d(pa[0], pb[0], pb[1], pb[2]) {
515        return true;
516    }
517    if point_in_triangle_2d(pb[0], pa[0], pa[1], pa[2]) {
518        return true;
519    }
520
521    false
522}
523
524/// 2D segment intersection test.
525fn segments_intersect_2d(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> bool {
526    let ab = [b[0] - a[0], b[1] - a[1]];
527    let cd = [d[0] - c[0], d[1] - c[1]];
528    let denom = ab[0] * cd[1] - ab[1] * cd[0];
529    if denom.abs() < GEO_EPS {
530        return false; // parallel
531    }
532    let ac = [c[0] - a[0], c[1] - a[1]];
533    let t = (ac[0] * cd[1] - ac[1] * cd[0]) / denom;
534    let u = (ac[0] * ab[1] - ac[1] * ab[0]) / denom;
535    (-GEO_EPS..=1.0 + GEO_EPS).contains(&t) && (-GEO_EPS..=1.0 + GEO_EPS).contains(&u)
536}
537
538/// 2D point-in-triangle using barycentric coordinates.
539fn point_in_triangle_2d(p: [f64; 2], a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> bool {
540    let v0 = [c[0] - a[0], c[1] - a[1]];
541    let v1 = [b[0] - a[0], b[1] - a[1]];
542    let v2 = [p[0] - a[0], p[1] - a[1]];
543    let d00 = v0[0] * v0[0] + v0[1] * v0[1];
544    let d01 = v0[0] * v1[0] + v0[1] * v1[1];
545    let d02 = v0[0] * v2[0] + v0[1] * v2[1];
546    let d11 = v1[0] * v1[0] + v1[1] * v1[1];
547    let d12 = v1[0] * v2[0] + v1[1] * v2[1];
548    let inv_denom = d00 * d11 - d01 * d01;
549    if inv_denom.abs() < GEO_EPS {
550        return false;
551    }
552    let inv = 1.0 / inv_denom;
553    let u = (d11 * d02 - d01 * d12) * inv;
554    let v = (d00 * d12 - d01 * d02) * inv;
555    u >= -GEO_EPS && v >= -GEO_EPS && (u + v) <= 1.0 + GEO_EPS
556}
557
558// ─────────────────────────────────────────────────────────────────────────────
559// Winding Number (inside/outside classification)
560// ─────────────────────────────────────────────────────────────────────────────
561
562/// Compute the generalised winding number of a closed mesh at point `p`.
563///
564/// A point is inside if |winding_number| ≈ 1.
565pub fn winding_number(mesh: &SimpleMesh, p: V3) -> f64 {
566    let mut wn = 0.0;
567    for &tri in &mesh.triangles {
568        let a = sub(mesh.vertices[tri[0]], p);
569        let b = sub(mesh.vertices[tri[1]], p);
570        let c = sub(mesh.vertices[tri[2]], p);
571        let la = length(a);
572        let lb = length(b);
573        let lc = length(c);
574        if la < GEO_EPS || lb < GEO_EPS || lc < GEO_EPS {
575            continue;
576        }
577        let num = dot(a, cross(b, c));
578        let denom = la * lb * lc + dot(a, b) * lc + dot(b, c) * la + dot(c, a) * lb;
579        wn += num.atan2(denom);
580    }
581    wn / (2.0 * std::f64::consts::PI)
582}
583
584/// Classify whether a point is inside a closed mesh.
585pub fn is_inside(mesh: &SimpleMesh, p: V3) -> bool {
586    winding_number(mesh, p).abs() > 0.5
587}
588
589// ─────────────────────────────────────────────────────────────────────────────
590// Contour Extraction
591// ─────────────────────────────────────────────────────────────────────────────
592
593/// An intersection segment between two meshes.
594#[derive(Debug, Clone)]
595pub struct IntersectionSegment {
596    /// Start point of the segment.
597    pub start: V3,
598    /// End point of the segment.
599    pub end: V3,
600    /// Triangle index in mesh A.
601    pub tri_a: usize,
602    /// Triangle index in mesh B.
603    pub tri_b: usize,
604}
605
606/// Extract all intersection segments between two meshes.
607pub fn extract_intersection_contours(
608    mesh_a: &SimpleMesh,
609    mesh_b: &SimpleMesh,
610) -> Vec<IntersectionSegment> {
611    let mut segments = Vec::new();
612    for i in 0..mesh_a.n_triangles() {
613        let (a0, a1, a2) = mesh_a.triangle_verts(i);
614        for j in 0..mesh_b.n_triangles() {
615            let (b0, b1, b2) = mesh_b.triangle_verts(j);
616            match triangle_triangle_intersection(a0, a1, a2, b0, b1, b2) {
617                TriTriResult::Segment(p0, p1) => {
618                    segments.push(IntersectionSegment {
619                        start: p0,
620                        end: p1,
621                        tri_a: i,
622                        tri_b: j,
623                    });
624                }
625                TriTriResult::Point(p) => {
626                    segments.push(IntersectionSegment {
627                        start: p,
628                        end: p,
629                        tri_a: i,
630                        tri_b: j,
631                    });
632                }
633                _ => {}
634            }
635        }
636    }
637    segments
638}
639
640// ─────────────────────────────────────────────────────────────────────────────
641// Surface Splitting
642// ─────────────────────────────────────────────────────────────────────────────
643
644/// Split a triangle by a plane defined by (normal, point_on_plane).
645/// Returns (front_triangles, back_triangles) as vertex lists.
646pub fn split_triangle_by_plane(
647    v0: V3,
648    v1: V3,
649    v2: V3,
650    plane_normal: V3,
651    plane_point: V3,
652) -> (Vec<[V3; 3]>, Vec<[V3; 3]>) {
653    let verts = [v0, v1, v2];
654    let dists: Vec<f64> = verts
655        .iter()
656        .map(|v| dot(sub(*v, plane_point), plane_normal))
657        .collect();
658
659    let pos_count = dists.iter().filter(|&&d| d > GEO_EPS).count();
660    let neg_count = dists.iter().filter(|&&d| d < -GEO_EPS).count();
661
662    // All on one side
663    if neg_count == 0 {
664        return (vec![[v0, v1, v2]], Vec::new());
665    }
666    if pos_count == 0 {
667        return (Vec::new(), vec![[v0, v1, v2]]);
668    }
669
670    // Find the isolated vertex
671    let mut front = Vec::new();
672    let mut back = Vec::new();
673
674    // Re-order so that the isolated vertex is first
675    for start in 0..3 {
676        let i0 = start;
677        let i1 = (start + 1) % 3;
678        let i2 = (start + 2) % 3;
679
680        if (dists[i0] > GEO_EPS && dists[i1] < -GEO_EPS && dists[i2] < -GEO_EPS)
681            || (dists[i0] > GEO_EPS
682                && dists[i1] <= GEO_EPS
683                && dists[i2] < -GEO_EPS
684                && dists[i1].abs() <= GEO_EPS)
685        {
686            // i0 alone on positive side
687            let t01 = dists[i0] / (dists[i0] - dists[i1]);
688            let t02 = dists[i0] / (dists[i0] - dists[i2]);
689            let p01 = lerp(verts[i0], verts[i1], t01);
690            let p02 = lerp(verts[i0], verts[i2], t02);
691            front.push([verts[i0], p01, p02]);
692            back.push([p01, verts[i1], verts[i2]]);
693            back.push([p01, verts[i2], p02]);
694            return (front, back);
695        }
696        if (dists[i0] < -GEO_EPS && dists[i1] > GEO_EPS && dists[i2] > GEO_EPS)
697            || (dists[i0] < -GEO_EPS
698                && dists[i1] >= -GEO_EPS
699                && dists[i2] > GEO_EPS
700                && dists[i1].abs() <= GEO_EPS)
701        {
702            // i0 alone on negative side
703            let t01 = dists[i0] / (dists[i0] - dists[i1]);
704            let t02 = dists[i0] / (dists[i0] - dists[i2]);
705            let p01 = lerp(verts[i0], verts[i1], t01);
706            let p02 = lerp(verts[i0], verts[i2], t02);
707            back.push([verts[i0], p01, p02]);
708            front.push([p01, verts[i1], verts[i2]]);
709            front.push([p01, verts[i2], p02]);
710            return (front, back);
711        }
712    }
713
714    // Fallback: put on front side
715    (vec![[v0, v1, v2]], Vec::new())
716}
717
718// ─────────────────────────────────────────────────────────────────────────────
719// Vertex Welding / Cleanup
720// ─────────────────────────────────────────────────────────────────────────────
721
722/// Weld vertices that are closer than `tolerance`.
723/// Returns a new mesh with merged vertices.
724pub fn weld_vertices(mesh: &SimpleMesh, tolerance: f64) -> SimpleMesh {
725    let tol_sq = tolerance * tolerance;
726    let mut new_verts: Vec<V3> = Vec::new();
727    let mut remap: Vec<usize> = Vec::new();
728
729    for v in &mesh.vertices {
730        let mut found = None;
731        for (k, nv) in new_verts.iter().enumerate() {
732            if dist_sq(*v, *nv) < tol_sq {
733                found = Some(k);
734                break;
735            }
736        }
737        if let Some(k) = found {
738            remap.push(k);
739        } else {
740            remap.push(new_verts.len());
741            new_verts.push(*v);
742        }
743    }
744
745    let new_tris: Vec<[usize; 3]> = mesh
746        .triangles
747        .iter()
748        .map(|t| [remap[t[0]], remap[t[1]], remap[t[2]]])
749        .collect();
750
751    SimpleMesh::from_data(new_verts, new_tris)
752}
753
754/// Remove degenerate triangles (zero-area or duplicate vertex indices).
755pub fn remove_degenerate_triangles(mesh: &mut SimpleMesh) {
756    mesh.triangles.retain(|t| {
757        if t[0] == t[1] || t[1] == t[2] || t[0] == t[2] {
758            return false;
759        }
760        let a = mesh.vertices[t[0]];
761        let b = mesh.vertices[t[1]];
762        let c = mesh.vertices[t[2]];
763        triangle_area(a, b, c) > GEO_EPS
764    });
765}
766
767/// Remove unreferenced vertices and compact the index buffer.
768pub fn remove_unused_vertices(mesh: &mut SimpleMesh) {
769    let n = mesh.vertices.len();
770    let mut used = vec![false; n];
771    for t in &mesh.triangles {
772        used[t[0]] = true;
773        used[t[1]] = true;
774        used[t[2]] = true;
775    }
776    let mut remap = vec![0usize; n];
777    let mut new_verts = Vec::new();
778    for (i, &u) in used.iter().enumerate() {
779        if u {
780            remap[i] = new_verts.len();
781            new_verts.push(mesh.vertices[i]);
782        }
783    }
784    for t in &mut mesh.triangles {
785        t[0] = remap[t[0]];
786        t[1] = remap[t[1]];
787        t[2] = remap[t[2]];
788    }
789    mesh.vertices = new_verts;
790}
791
792/// Full cleanup: weld, remove degenerates, remove unused.
793pub fn cleanup_mesh(mesh: &SimpleMesh) -> SimpleMesh {
794    let mut result = weld_vertices(mesh, WELD_EPS);
795    remove_degenerate_triangles(&mut result);
796    remove_unused_vertices(&mut result);
797    result
798}
799
800// ─────────────────────────────────────────────────────────────────────────────
801// Mesh Stitching
802// ─────────────────────────────────────────────────────────────────────────────
803
804/// Combine two meshes into one, re-indexing the second mesh's triangles.
805pub fn stitch_meshes(a: &SimpleMesh, b: &SimpleMesh) -> SimpleMesh {
806    let offset = a.vertices.len();
807    let mut vertices = a.vertices.clone();
808    vertices.extend_from_slice(&b.vertices);
809    let mut triangles = a.triangles.clone();
810    for t in &b.triangles {
811        triangles.push([t[0] + offset, t[1] + offset, t[2] + offset]);
812    }
813    SimpleMesh {
814        vertices,
815        triangles,
816    }
817}
818
819// ─────────────────────────────────────────────────────────────────────────────
820// Mesh Boolean (high-level)
821// ─────────────────────────────────────────────────────────────────────────────
822
823/// Classify each triangle of a mesh as inside or outside another mesh.
824/// Returns a boolean per triangle: `true` = inside.
825pub fn classify_triangles(mesh: &SimpleMesh, other: &SimpleMesh) -> Vec<bool> {
826    let mut result = Vec::with_capacity(mesh.n_triangles());
827    for i in 0..mesh.n_triangles() {
828        let (a, b, c) = mesh.triangle_verts(i);
829        let centroid = scale(add(add(a, b), c), 1.0 / 3.0);
830        result.push(is_inside(other, centroid));
831    }
832    result
833}
834
835/// Perform a mesh boolean operation.
836///
837/// This is a simplified approach that classifies triangles by their centroid
838/// and selects/rejects based on the operation. For production use, a full
839/// re-triangulation along intersection curves is needed.
840pub fn mesh_boolean(mesh_a: &SimpleMesh, mesh_b: &SimpleMesh, op: MeshBooleanOp) -> SimpleMesh {
841    let a_inside_b = classify_triangles(mesh_a, mesh_b);
842    let b_inside_a = classify_triangles(mesh_b, mesh_a);
843
844    let mut result = SimpleMesh::new();
845
846    match op {
847        MeshBooleanOp::Union => {
848            // Keep triangles of A that are outside B
849            collect_triangles(mesh_a, &a_inside_b, false, &mut result);
850            // Keep triangles of B that are outside A
851            collect_triangles(mesh_b, &b_inside_a, false, &mut result);
852        }
853        MeshBooleanOp::Intersection => {
854            // Keep triangles of A that are inside B
855            collect_triangles(mesh_a, &a_inside_b, true, &mut result);
856            // Keep triangles of B that are inside A
857            collect_triangles(mesh_b, &b_inside_a, true, &mut result);
858        }
859        MeshBooleanOp::Difference => {
860            // Keep triangles of A that are outside B
861            collect_triangles(mesh_a, &a_inside_b, false, &mut result);
862            // Keep triangles of B that are inside A (flipped)
863            collect_triangles_flipped(mesh_b, &b_inside_a, true, &mut result);
864        }
865    }
866
867    cleanup_mesh(&result)
868}
869
870/// Collect triangles from a mesh based on inside/outside classification.
871fn collect_triangles(
872    mesh: &SimpleMesh,
873    classification: &[bool],
874    keep_inside: bool,
875    result: &mut SimpleMesh,
876) {
877    let offset = result.vertices.len();
878    result.vertices.extend_from_slice(&mesh.vertices);
879    for (i, &is_inside_val) in classification.iter().enumerate() {
880        if is_inside_val == keep_inside {
881            let t = mesh.triangles[i];
882            result
883                .triangles
884                .push([t[0] + offset, t[1] + offset, t[2] + offset]);
885        }
886    }
887}
888
889/// Collect triangles with flipped normals.
890fn collect_triangles_flipped(
891    mesh: &SimpleMesh,
892    classification: &[bool],
893    keep_inside: bool,
894    result: &mut SimpleMesh,
895) {
896    let offset = result.vertices.len();
897    result.vertices.extend_from_slice(&mesh.vertices);
898    for (i, &is_inside_val) in classification.iter().enumerate() {
899        if is_inside_val == keep_inside {
900            let t = mesh.triangles[i];
901            // Flip winding order
902            result
903                .triangles
904                .push([t[1] + offset, t[0] + offset, t[2] + offset]);
905        }
906    }
907}
908
909// ─────────────────────────────────────────────────────────────────────────────
910// Ray–triangle intersection (used by inside/outside via ray casting)
911// ─────────────────────────────────────────────────────────────────────────────
912
913/// Möller–Trumbore ray–triangle intersection.
914/// Returns `Some(t)` where `t` is the ray parameter at intersection.
915pub fn ray_triangle_intersect(origin: V3, dir: V3, v0: V3, v1: V3, v2: V3) -> Option<f64> {
916    let e1 = sub(v1, v0);
917    let e2 = sub(v2, v0);
918    let h = cross(dir, e2);
919    let a = dot(e1, h);
920    if a.abs() < GEO_EPS {
921        return None;
922    }
923    let f = 1.0 / a;
924    let s = sub(origin, v0);
925    let u = f * dot(s, h);
926    if !(0.0..=1.0).contains(&u) {
927        return None;
928    }
929    let q = cross(s, e1);
930    let v = f * dot(dir, q);
931    if v < 0.0 || u + v > 1.0 {
932        return None;
933    }
934    let t = f * dot(e2, q);
935    if t > GEO_EPS { Some(t) } else { None }
936}
937
938/// Count ray-mesh intersections for parity-based inside/outside.
939pub fn ray_mesh_intersection_count(mesh: &SimpleMesh, origin: V3, dir: V3) -> usize {
940    let mut count = 0;
941    for &tri in &mesh.triangles {
942        if ray_triangle_intersect(
943            origin,
944            dir,
945            mesh.vertices[tri[0]],
946            mesh.vertices[tri[1]],
947            mesh.vertices[tri[2]],
948        )
949        .is_some()
950        {
951            count += 1;
952        }
953    }
954    count
955}
956
957/// Parity-based inside test using ray casting.
958pub fn is_inside_ray(mesh: &SimpleMesh, p: V3) -> bool {
959    let dir = [1.0, 0.0, 0.0];
960    ray_mesh_intersection_count(mesh, p, dir) % 2 == 1
961}
962
963// ─────────────────────────────────────────────────────────────────────────────
964// Mesh statistics
965// ─────────────────────────────────────────────────────────────────────────────
966
967/// Compute mesh quality statistics.
968#[derive(Debug, Clone)]
969pub struct MeshStats {
970    /// Number of vertices.
971    pub n_vertices: usize,
972    /// Number of triangles.
973    pub n_triangles: usize,
974    /// Total surface area.
975    pub surface_area: f64,
976    /// Signed volume.
977    pub signed_volume: f64,
978    /// Minimum triangle area.
979    pub min_triangle_area: f64,
980    /// Maximum triangle area.
981    pub max_triangle_area: f64,
982}
983
984/// Compute statistics for a mesh.
985pub fn compute_mesh_stats(mesh: &SimpleMesh) -> MeshStats {
986    let mut min_area = f64::INFINITY;
987    let mut max_area = 0.0_f64;
988    let mut total_area = 0.0;
989    for i in 0..mesh.n_triangles() {
990        let (a, b, c) = mesh.triangle_verts(i);
991        let area = triangle_area(a, b, c);
992        total_area += area;
993        if area < min_area {
994            min_area = area;
995        }
996        if area > max_area {
997            max_area = area;
998        }
999    }
1000    MeshStats {
1001        n_vertices: mesh.n_vertices(),
1002        n_triangles: mesh.n_triangles(),
1003        surface_area: total_area,
1004        signed_volume: mesh.signed_volume(),
1005        min_triangle_area: min_area,
1006        max_triangle_area: max_area,
1007    }
1008}
1009
1010/// Check if a mesh is watertight (every edge shared by exactly 2 triangles).
1011pub fn is_watertight(mesh: &SimpleMesh) -> bool {
1012    use std::collections::HashMap;
1013    let mut edge_count: HashMap<(usize, usize), u32> = HashMap::new();
1014    for t in &mesh.triangles {
1015        for (&a, &b) in t.iter().zip(t.iter().cycle().skip(1).take(3)) {
1016            let key = if a < b { (a, b) } else { (b, a) };
1017            *edge_count.entry(key).or_insert(0) += 1;
1018        }
1019    }
1020    edge_count.values().all(|&c| c == 2)
1021}
1022
1023/// Compute the Euler characteristic: V - E + F.
1024pub fn euler_characteristic(mesh: &SimpleMesh) -> i64 {
1025    use std::collections::HashSet;
1026    let v = mesh.n_vertices() as i64;
1027    let f = mesh.n_triangles() as i64;
1028    let mut edges = HashSet::new();
1029    for t in &mesh.triangles {
1030        for (&a, &b) in t.iter().zip(t.iter().cycle().skip(1).take(3)) {
1031            let key = if a < b { (a, b) } else { (b, a) };
1032            edges.insert(key);
1033        }
1034    }
1035    let e = edges.len() as i64;
1036    v - e + f
1037}
1038
1039// ─────────────────────────────────────────────────────────────────────────────
1040// Tests
1041// ─────────────────────────────────────────────────────────────────────────────
1042
1043#[cfg(test)]
1044mod tests {
1045    use super::*;
1046
1047    // ── SimpleMesh basics ───────────────────────────────────────────────
1048
1049    #[test]
1050    fn test_unit_cube_creation() {
1051        let cube = SimpleMesh::unit_cube([0.0; 3], 1.0);
1052        assert_eq!(cube.n_vertices(), 8);
1053        assert_eq!(cube.n_triangles(), 12);
1054    }
1055
1056    #[test]
1057    fn test_cube_surface_area() {
1058        let cube = SimpleMesh::unit_cube([0.0; 3], 1.0);
1059        let area = cube.surface_area();
1060        // 6 faces, each 2x2 = 4, total = 24
1061        assert!((area - 24.0).abs() < 1e-8, "area = {area}");
1062    }
1063
1064    #[test]
1065    fn test_cube_signed_volume() {
1066        let cube = SimpleMesh::unit_cube([0.0; 3], 1.0);
1067        let vol = cube.signed_volume();
1068        // 2^3 = 8
1069        assert!((vol.abs() - 8.0).abs() < 1e-8, "vol = {vol}");
1070    }
1071
1072    #[test]
1073    fn test_tetrahedron_creation() {
1074        let tet = SimpleMesh::tetrahedron([0.0; 3], 1.0);
1075        assert_eq!(tet.n_vertices(), 4);
1076        assert_eq!(tet.n_triangles(), 4);
1077    }
1078
1079    #[test]
1080    fn test_empty_mesh() {
1081        let m = SimpleMesh::new();
1082        assert_eq!(m.n_vertices(), 0);
1083        assert_eq!(m.n_triangles(), 0);
1084        let (mn, mx) = m.aabb();
1085        assert_eq!(mn, [0.0; 3]);
1086        assert_eq!(mx, [0.0; 3]);
1087    }
1088
1089    #[test]
1090    fn test_add_vertex_and_triangle() {
1091        let mut m = SimpleMesh::new();
1092        let a = m.add_vertex([0.0, 0.0, 0.0]);
1093        let b = m.add_vertex([1.0, 0.0, 0.0]);
1094        let c = m.add_vertex([0.0, 1.0, 0.0]);
1095        m.add_triangle([a, b, c]);
1096        assert_eq!(m.n_triangles(), 1);
1097        let area = m.surface_area();
1098        assert!((area - 0.5).abs() < 1e-10);
1099    }
1100
1101    // ── Triangle-triangle intersection ──────────────────────────────────
1102
1103    #[test]
1104    fn test_tri_tri_no_intersection() {
1105        let a0 = [0.0, 0.0, 0.0];
1106        let a1 = [1.0, 0.0, 0.0];
1107        let a2 = [0.0, 1.0, 0.0];
1108        let b0 = [5.0, 5.0, 5.0];
1109        let b1 = [6.0, 5.0, 5.0];
1110        let b2 = [5.0, 6.0, 5.0];
1111        match triangle_triangle_intersection(a0, a1, a2, b0, b1, b2) {
1112            TriTriResult::None => {}
1113            other => panic!("expected None, got {other:?}"),
1114        }
1115    }
1116
1117    #[test]
1118    fn test_tri_tri_segment_intersection() {
1119        // Two triangles crossing through each other
1120        let a0 = [-1.0, 0.0, 0.0];
1121        let a1 = [1.0, 0.0, 0.0];
1122        let a2 = [0.0, 0.0, 1.0];
1123        let b0 = [0.0, -1.0, 0.25];
1124        let b1 = [0.0, 1.0, 0.25];
1125        let b2 = [0.0, 0.0, 0.75];
1126        match triangle_triangle_intersection(a0, a1, a2, b0, b1, b2) {
1127            TriTriResult::Segment(p0, p1) => {
1128                assert!(dist_sq(p0, p1) > GEO_EPS, "segment too short");
1129            }
1130            other => panic!("expected Segment, got {other:?}"),
1131        }
1132    }
1133
1134    #[test]
1135    fn test_tri_tri_coplanar_overlap() {
1136        let a0 = [0.0, 0.0, 0.0];
1137        let a1 = [2.0, 0.0, 0.0];
1138        let a2 = [0.0, 2.0, 0.0];
1139        let b0 = [1.0, 0.0, 0.0];
1140        let b1 = [3.0, 0.0, 0.0];
1141        let b2 = [1.0, 2.0, 0.0];
1142        match triangle_triangle_intersection(a0, a1, a2, b0, b1, b2) {
1143            TriTriResult::Coplanar => {}
1144            other => panic!("expected Coplanar, got {other:?}"),
1145        }
1146    }
1147
1148    #[test]
1149    fn test_tri_tri_coplanar_no_overlap() {
1150        let a0 = [0.0, 0.0, 0.0];
1151        let a1 = [1.0, 0.0, 0.0];
1152        let a2 = [0.0, 1.0, 0.0];
1153        let b0 = [5.0, 5.0, 0.0];
1154        let b1 = [6.0, 5.0, 0.0];
1155        let b2 = [5.0, 6.0, 0.0];
1156        match triangle_triangle_intersection(a0, a1, a2, b0, b1, b2) {
1157            TriTriResult::None => {}
1158            other => panic!("expected None for separated coplanar, got {other:?}"),
1159        }
1160    }
1161
1162    // ── Winding number / inside-outside ─────────────────────────────────
1163
1164    #[test]
1165    fn test_winding_number_inside_cube() {
1166        let cube = SimpleMesh::unit_cube([0.0; 3], 1.0);
1167        let wn = winding_number(&cube, [0.0, 0.0, 0.0]);
1168        assert!(wn.abs() > 0.4, "wn = {wn}, expected ~1");
1169    }
1170
1171    #[test]
1172    fn test_winding_number_outside_cube() {
1173        let cube = SimpleMesh::unit_cube([0.0; 3], 1.0);
1174        let wn = winding_number(&cube, [5.0, 5.0, 5.0]);
1175        assert!(wn.abs() < 0.1, "wn = {wn}, expected ~0");
1176    }
1177
1178    #[test]
1179    fn test_is_inside_cube() {
1180        let cube = SimpleMesh::unit_cube([0.0; 3], 1.0);
1181        assert!(is_inside(&cube, [0.0, 0.0, 0.0]));
1182        assert!(!is_inside(&cube, [5.0, 5.0, 5.0]));
1183    }
1184
1185    // ── Ray-triangle ────────────────────────────────────────────────────
1186
1187    #[test]
1188    fn test_ray_triangle_hit() {
1189        let v0 = [-1.0, -1.0, 1.0];
1190        let v1 = [1.0, -1.0, 1.0];
1191        let v2 = [0.0, 1.0, 1.0];
1192        let origin = [0.0, 0.0, 0.0];
1193        let dir = [0.0, 0.0, 1.0];
1194        let t = ray_triangle_intersect(origin, dir, v0, v1, v2);
1195        assert!(t.is_some());
1196        assert!((t.unwrap() - 1.0).abs() < 1e-8);
1197    }
1198
1199    #[test]
1200    fn test_ray_triangle_miss() {
1201        let v0 = [-1.0, -1.0, 1.0];
1202        let v1 = [1.0, -1.0, 1.0];
1203        let v2 = [0.0, 1.0, 1.0];
1204        let origin = [10.0, 10.0, 0.0];
1205        let dir = [0.0, 0.0, 1.0];
1206        assert!(ray_triangle_intersect(origin, dir, v0, v1, v2).is_none());
1207    }
1208
1209    #[test]
1210    fn test_ray_mesh_count() {
1211        let cube = SimpleMesh::unit_cube([0.0; 3], 1.0);
1212        let count = ray_mesh_intersection_count(&cube, [-5.0, 0.0, 0.0], [1.0, 0.0, 0.0]);
1213        // Should hit two faces (entering and exiting)
1214        assert_eq!(count % 2, 0, "count = {count}");
1215    }
1216
1217    // ── Contour extraction ──────────────────────────────────────────────
1218
1219    #[test]
1220    fn test_contour_overlapping_cubes() {
1221        let a = SimpleMesh::unit_cube([0.0; 3], 1.0);
1222        let b = SimpleMesh::unit_cube([1.0, 0.0, 0.0], 1.0);
1223        let segs = extract_intersection_contours(&a, &b);
1224        // Overlapping cubes should produce some intersection segments
1225        assert!(!segs.is_empty(), "expected intersection segments");
1226    }
1227
1228    #[test]
1229    fn test_contour_separated_cubes() {
1230        let a = SimpleMesh::unit_cube([0.0; 3], 1.0);
1231        let b = SimpleMesh::unit_cube([10.0, 0.0, 0.0], 1.0);
1232        let segs = extract_intersection_contours(&a, &b);
1233        assert!(
1234            segs.is_empty(),
1235            "expected no intersections, got {}",
1236            segs.len()
1237        );
1238    }
1239
1240    // ── Surface splitting ───────────────────────────────────────────────
1241
1242    #[test]
1243    fn test_split_triangle_all_front() {
1244        let (front, back) = split_triangle_by_plane(
1245            [0.0, 0.0, 1.0],
1246            [1.0, 0.0, 1.0],
1247            [0.0, 1.0, 1.0],
1248            [0.0, 0.0, 1.0], // normal pointing up
1249            [0.0, 0.0, 0.0], // plane at z=0
1250        );
1251        assert_eq!(front.len(), 1);
1252        assert!(back.is_empty());
1253    }
1254
1255    #[test]
1256    fn test_split_triangle_straddles_plane() {
1257        let (front, back) = split_triangle_by_plane(
1258            [0.0, 0.0, 1.0],
1259            [1.0, 0.0, -1.0],
1260            [0.0, 1.0, -1.0],
1261            [0.0, 0.0, 1.0],
1262            [0.0, 0.0, 0.0],
1263        );
1264        assert!(!front.is_empty(), "should have front triangles");
1265        assert!(!back.is_empty(), "should have back triangles");
1266    }
1267
1268    // ── Vertex welding ──────────────────────────────────────────────────
1269
1270    #[test]
1271    fn test_weld_vertices_merges_close() {
1272        let mut m = SimpleMesh::new();
1273        m.add_vertex([0.0, 0.0, 0.0]);
1274        m.add_vertex([1e-12, 0.0, 0.0]); // very close to first
1275        m.add_vertex([1.0, 0.0, 0.0]);
1276        m.add_triangle([0, 2, 1]);
1277        let welded = weld_vertices(&m, 1e-8);
1278        assert_eq!(welded.n_vertices(), 2, "close vertices should merge");
1279    }
1280
1281    #[test]
1282    fn test_remove_degenerate() {
1283        let mut m = SimpleMesh::from_data(
1284            vec![[0.0; 3], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
1285            vec![[0, 0, 1], [0, 1, 2]], // first triangle is degenerate
1286        );
1287        remove_degenerate_triangles(&mut m);
1288        assert_eq!(m.n_triangles(), 1);
1289    }
1290
1291    #[test]
1292    fn test_cleanup_mesh() {
1293        let mut m = SimpleMesh::new();
1294        m.add_vertex([0.0, 0.0, 0.0]);
1295        m.add_vertex([1e-12, 0.0, 0.0]);
1296        m.add_vertex([1.0, 0.0, 0.0]);
1297        m.add_vertex([0.0, 1.0, 0.0]);
1298        m.add_vertex([99.0, 99.0, 99.0]); // unused
1299        m.add_triangle([0, 2, 3]);
1300        let cleaned = cleanup_mesh(&m);
1301        assert!(cleaned.n_vertices() <= 3);
1302        assert_eq!(cleaned.n_triangles(), 1);
1303    }
1304
1305    // ── Mesh stitching ──────────────────────────────────────────────────
1306
1307    #[test]
1308    fn test_stitch_meshes() {
1309        let a = SimpleMesh::unit_cube([0.0; 3], 1.0);
1310        let b = SimpleMesh::unit_cube([5.0, 0.0, 0.0], 1.0);
1311        let combined = stitch_meshes(&a, &b);
1312        assert_eq!(combined.n_vertices(), 16);
1313        assert_eq!(combined.n_triangles(), 24);
1314    }
1315
1316    // ── Boolean operations ──────────────────────────────────────────────
1317
1318    #[test]
1319    fn test_boolean_union_separated() {
1320        let a = SimpleMesh::unit_cube([0.0; 3], 1.0);
1321        let b = SimpleMesh::unit_cube([10.0, 0.0, 0.0], 1.0);
1322        let result = mesh_boolean(&a, &b, MeshBooleanOp::Union);
1323        // No overlap, so union should keep all triangles
1324        assert!(
1325            result.n_triangles() >= 20,
1326            "union tris = {}",
1327            result.n_triangles()
1328        );
1329    }
1330
1331    #[test]
1332    fn test_boolean_intersection_separated() {
1333        let a = SimpleMesh::unit_cube([0.0; 3], 1.0);
1334        let b = SimpleMesh::unit_cube([10.0, 0.0, 0.0], 1.0);
1335        let result = mesh_boolean(&a, &b, MeshBooleanOp::Intersection);
1336        // No overlap → empty intersection
1337        assert_eq!(result.n_triangles(), 0);
1338    }
1339
1340    #[test]
1341    fn test_boolean_difference_separated() {
1342        let a = SimpleMesh::unit_cube([0.0; 3], 1.0);
1343        let b = SimpleMesh::unit_cube([10.0, 0.0, 0.0], 1.0);
1344        let result = mesh_boolean(&a, &b, MeshBooleanOp::Difference);
1345        // No overlap → A unchanged
1346        assert!(
1347            result.n_triangles() >= 10,
1348            "diff tris = {}",
1349            result.n_triangles()
1350        );
1351    }
1352
1353    // ── Mesh stats ──────────────────────────────────────────────────────
1354
1355    #[test]
1356    fn test_mesh_stats() {
1357        let cube = SimpleMesh::unit_cube([0.0; 3], 1.0);
1358        let stats = compute_mesh_stats(&cube);
1359        assert_eq!(stats.n_vertices, 8);
1360        assert_eq!(stats.n_triangles, 12);
1361        assert!((stats.surface_area - 24.0).abs() < 1e-8);
1362    }
1363
1364    #[test]
1365    fn test_euler_characteristic_cube() {
1366        let cube = SimpleMesh::unit_cube([0.0; 3], 1.0);
1367        let chi = euler_characteristic(&cube);
1368        assert_eq!(
1369            chi, 2,
1370            "Euler characteristic of a cube should be 2, got {chi}"
1371        );
1372    }
1373
1374    #[test]
1375    fn test_flip_normals() {
1376        let mut cube = SimpleMesh::unit_cube([0.0; 3], 1.0);
1377        let vol_before = cube.signed_volume();
1378        cube.flip_normals();
1379        let vol_after = cube.signed_volume();
1380        assert!(
1381            (vol_before + vol_after).abs() < 1e-8,
1382            "flipping should negate volume"
1383        );
1384    }
1385
1386    #[test]
1387    fn test_is_watertight_cube() {
1388        let cube = SimpleMesh::unit_cube([0.0; 3], 1.0);
1389        assert!(is_watertight(&cube), "unit cube should be watertight");
1390    }
1391
1392    #[test]
1393    fn test_classify_triangles_inside() {
1394        let big_cube = SimpleMesh::unit_cube([0.0; 3], 2.0);
1395        let small_cube = SimpleMesh::unit_cube([0.0; 3], 0.5);
1396        let classification = classify_triangles(&small_cube, &big_cube);
1397        // All triangles of small cube should be inside big cube
1398        let all_inside = classification.iter().all(|&x| x);
1399        assert!(all_inside, "small cube should be inside big cube");
1400    }
1401}