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