Skip to main content

oxiphysics_geometry/
convex_decomposition.rs

1#![allow(clippy::needless_range_loop, clippy::should_implement_trait)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4//! Convex decomposition of concave meshes for collision detection.
5//!
6//! This module provides:
7//! - 3D convex hull (incremental algorithm with half-edge structure)
8//! - Approximate Convex Decomposition (ACD / HACD)
9//! - Minkowski sum of convex polygons (GJK-based support)
10//! - GJK distance computation between convex shapes
11//! - Convex containment tests (point, sphere, AABB)
12//! - SAT-based convex polyhedra intersection
13//! - Inertia tensor from convex hull (divergence theorem)
14//! - Hull erosion (inner hull / offset by margin)
15
16#![allow(dead_code)]
17
18#[allow(unused_imports)]
19use std::f64::consts::PI;
20
21// ─── Vec3 helpers (plain f64 arrays, no nalgebra) ──────────────────────────
22
23/// 3-component vector as \[f64; 3\].
24pub type Vec3 = [f64; 3];
25
26/// Dot product of two Vec3.
27#[inline]
28pub fn dot(a: Vec3, b: Vec3) -> f64 {
29    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
30}
31
32/// Cross product of two Vec3.
33#[inline]
34pub fn cross(a: Vec3, b: Vec3) -> Vec3 {
35    [
36        a[1] * b[2] - a[2] * b[1],
37        a[2] * b[0] - a[0] * b[2],
38        a[0] * b[1] - a[1] * b[0],
39    ]
40}
41
42/// Subtract b from a.
43#[inline]
44pub fn sub(a: Vec3, b: Vec3) -> Vec3 {
45    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
46}
47
48/// Add two Vec3.
49#[inline]
50pub fn add(a: Vec3, b: Vec3) -> Vec3 {
51    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
52}
53
54/// Scale a Vec3 by scalar.
55#[inline]
56pub fn scale(v: Vec3, s: f64) -> Vec3 {
57    [v[0] * s, v[1] * s, v[2] * s]
58}
59
60/// Euclidean norm of a Vec3.
61#[inline]
62pub fn norm(v: Vec3) -> f64 {
63    dot(v, v).sqrt()
64}
65
66/// Normalize a Vec3; returns zero vector if near-zero magnitude.
67#[inline]
68pub fn normalize(v: Vec3) -> Vec3 {
69    let n = norm(v);
70    if n < 1e-15 {
71        [0.0; 3]
72    } else {
73        scale(v, 1.0 / n)
74    }
75}
76
77/// Negate a Vec3.
78#[inline]
79pub fn neg(v: Vec3) -> Vec3 {
80    [-v[0], -v[1], -v[2]]
81}
82
83// ─── ConvexHull3d ──────────────────────────────────────────────────────────
84
85/// A half-edge record: directed edge from `origin` to some vertex, belonging to `face`.
86#[derive(Debug, Clone)]
87pub struct HalfEdge {
88    /// Origin vertex index
89    pub origin: usize,
90    /// Index of the twin half-edge
91    pub twin: usize,
92    /// Index of the next half-edge in the same face
93    pub next: usize,
94    /// Face that owns this half-edge
95    pub face: usize,
96}
97
98/// A face record: index of one half-edge on its boundary.
99#[derive(Debug, Clone)]
100pub struct HullFace {
101    /// One half-edge belonging to this face
102    pub edge: usize,
103    /// Outward normal
104    pub normal: Vec3,
105}
106
107/// 3D convex hull computed by an incremental algorithm with a half-edge data structure.
108///
109/// For simplicity this implementation uses a gift-wrap / incremental approach
110/// over the given point set.
111#[derive(Debug, Clone)]
112pub struct ConvexHull3d {
113    /// Input (and hull) vertices
114    pub vertices: Vec<Vec3>,
115    /// Half-edges
116    pub half_edges: Vec<HalfEdge>,
117    /// Faces (triangles)
118    pub faces: Vec<HullFace>,
119    /// Triangles: each entry is (i0, i1, i2) with outward normal
120    pub triangles: Vec<[usize; 3]>,
121}
122
123impl ConvexHull3d {
124    /// Build a convex hull from a set of 3D points using a simple incremental method.
125    ///
126    /// Falls back to a robust but O(n²) approach for clarity.
127    pub fn build(points: &[Vec3]) -> Self {
128        assert!(points.len() >= 4, "need at least 4 non-coplanar points");
129        // Use gift-wrapping / brute-force method
130        let tris = incremental_hull(points);
131        let vertices = points.to_vec();
132        // Build half-edge structure
133        let (half_edges, faces) = build_half_edge_structure(&tris, &vertices);
134        Self {
135            vertices,
136            half_edges,
137            faces,
138            triangles: tris,
139        }
140    }
141
142    /// Check that all original input points are on or inside the hull.
143    pub fn all_points_inside_or_on(&self, points: &[Vec3], tol: f64) -> bool {
144        points.iter().all(|&p| {
145            self.faces.iter().enumerate().all(|(fi, f)| {
146                let tri = &self.triangles[fi];
147                let v0 = self.vertices[tri[0]];
148                let signed_dist = dot(f.normal, sub(p, v0));
149                signed_dist <= tol
150            })
151        })
152    }
153
154    /// Volume of the convex hull via divergence theorem (sum of signed tet volumes).
155    pub fn volume(&self) -> f64 {
156        let mut vol = 0.0_f64;
157        for tri in &self.triangles {
158            let a = self.vertices[tri[0]];
159            let b = self.vertices[tri[1]];
160            let c = self.vertices[tri[2]];
161            // Signed tet volume: (a · (b × c)) / 6
162            vol += dot(a, cross(b, c));
163        }
164        (vol / 6.0).abs()
165    }
166
167    /// Centroid of the convex hull vertices.
168    pub fn centroid(&self) -> Vec3 {
169        let n = self.vertices.len() as f64;
170        let s = self.vertices.iter().fold([0.0; 3], |acc, &v| add(acc, v));
171        scale(s, 1.0 / n)
172    }
173
174    /// Support function h(d) = max_{x ∈ hull} d·x.
175    pub fn support(&self, direction: Vec3) -> f64 {
176        self.vertices
177            .iter()
178            .map(|&v| dot(v, direction))
179            .fold(f64::NEG_INFINITY, f64::max)
180    }
181
182    /// Support point: the vertex maximising d·x.
183    pub fn support_point(&self, direction: Vec3) -> Vec3 {
184        *self
185            .vertices
186            .iter()
187            .max_by(|&&a, &&b| {
188                dot(a, direction)
189                    .partial_cmp(&dot(b, direction))
190                    .unwrap_or(std::cmp::Ordering::Equal)
191            })
192            .expect("operation should succeed")
193    }
194}
195
196/// Incremental hull: O(n²·f) but correct for any non-degenerate point set.
197fn incremental_hull(pts: &[Vec3]) -> Vec<[usize; 3]> {
198    // Start with a tetrahedron from the first 4 points
199    let mut tris: Vec<[usize; 3]> = vec![[0, 1, 2], [0, 3, 1], [0, 2, 3], [1, 3, 2]];
200    // Orient each face outward w.r.t. centroid
201    let centroid = {
202        let s = pts[0..4].iter().fold([0.0; 3], |acc, &v| add(acc, v));
203        scale(s, 0.25)
204    };
205    for tri in &mut tris {
206        orient_outward(pts, tri, centroid);
207    }
208
209    // Add remaining points
210    for i in 4..pts.len() {
211        let p = pts[i];
212        // Find visible faces
213        let visible: Vec<bool> = tris
214            .iter()
215            .map(|tri| {
216                let n = face_normal(pts, tri);
217                let v0 = pts[tri[0]];
218                dot(n, sub(p, v0)) > 1e-12
219            })
220            .collect();
221
222        // Collect horizon edges (edges between visible and non-visible faces)
223        let mut horizon: Vec<(usize, usize)> = Vec::new();
224        for (fi, &vis) in visible.iter().enumerate() {
225            if vis {
226                let tri = tris[fi];
227                for k in 0..3 {
228                    let a = tri[k];
229                    let b = tri[(k + 1) % 3];
230                    // Check if twin face is non-visible
231                    let twin_visible = tris.iter().enumerate().any(|(fj, &t)| {
232                        fj != fi && t.contains(&b) && t.contains(&a) && visible[fj]
233                    });
234                    if !twin_visible {
235                        horizon.push((a, b));
236                    }
237                }
238            }
239        }
240        // Remove visible faces
241        let mut new_tris: Vec<[usize; 3]> = tris
242            .iter()
243            .zip(visible.iter())
244            .filter(|&(_, v)| !v)
245            .map(|(&t, _)| t)
246            .collect();
247        // Add new faces from horizon edges
248        for (a, b) in horizon {
249            let mut tri = [a, b, i];
250            orient_outward(pts, &mut tri, centroid);
251            new_tris.push(tri);
252        }
253        if !new_tris.is_empty() {
254            tris = new_tris;
255        }
256    }
257    tris
258}
259
260/// Compute outward face normal.
261fn face_normal(pts: &[Vec3], tri: &[usize; 3]) -> Vec3 {
262    let a = pts[tri[0]];
263    let b = pts[tri[1]];
264    let c = pts[tri[2]];
265    normalize(cross(sub(b, a), sub(c, a)))
266}
267
268/// Flip winding if face points inward w.r.t. centroid.
269fn orient_outward(pts: &[Vec3], tri: &mut [usize; 3], centroid: Vec3) {
270    let n = face_normal(pts, tri);
271    let v0 = pts[tri[0]];
272    if dot(n, sub(v0, centroid)) < 0.0 {
273        tri.swap(1, 2);
274    }
275}
276
277/// Build a minimal half-edge structure from a triangle list.
278fn build_half_edge_structure(
279    tris: &[[usize; 3]],
280    verts: &[Vec3],
281) -> (Vec<HalfEdge>, Vec<HullFace>) {
282    let mut half_edges: Vec<HalfEdge> = Vec::new();
283    let mut faces: Vec<HullFace> = Vec::new();
284
285    for (fi, tri) in tris.iter().enumerate() {
286        let base = half_edges.len();
287        // 3 half-edges per triangle
288        for k in 0..3_usize {
289            half_edges.push(HalfEdge {
290                origin: tri[k],
291                twin: usize::MAX, // filled below
292                next: base + (k + 1) % 3,
293                face: fi,
294            });
295        }
296        // Compute normal
297        let a = verts[tri[0]];
298        let b = verts[tri[1]];
299        let c = verts[tri[2]];
300        let normal = normalize(cross(sub(b, a), sub(c, a)));
301        faces.push(HullFace { edge: base, normal });
302    }
303    // Pair twins (O(n²))
304    let n = half_edges.len();
305    for i in 0..n {
306        if half_edges[i].twin != usize::MAX {
307            continue;
308        }
309        let org = half_edges[i].origin;
310        // next of i gives the "to" vertex
311        let to = half_edges[half_edges[i].next].origin;
312        for j in (i + 1)..n {
313            let org_j = half_edges[j].origin;
314            let to_j = half_edges[half_edges[j].next].origin;
315            if org_j == to && to_j == org {
316                half_edges[i].twin = j;
317                half_edges[j].twin = i;
318                break;
319            }
320        }
321    }
322    (half_edges, faces)
323}
324
325// ─── ApproxConvexDecomp ────────────────────────────────────────────────────
326
327/// Approximate Convex Decomposition (ACD) configuration.
328#[derive(Debug, Clone)]
329pub struct AcdConfig {
330    /// Maximum concavity threshold \[same units as mesh\]
331    pub max_concavity: f64,
332    /// Maximum number of convex parts
333    pub max_parts: usize,
334    /// Minimum volume for a sub-part (ignore tiny fragments)
335    pub min_volume_fraction: f64,
336}
337
338impl AcdConfig {
339    /// Default ACD configuration.
340    pub fn default() -> Self {
341        Self {
342            max_concavity: 0.01,
343            max_parts: 16,
344            min_volume_fraction: 0.001,
345        }
346    }
347}
348
349/// A convex sub-part from ACD: stores vertex indices into the original mesh.
350#[derive(Debug, Clone)]
351pub struct ConvexPart {
352    /// Vertex positions of this sub-part
353    pub vertices: Vec<Vec3>,
354    /// Concavity of this part (0 = perfectly convex)
355    pub concavity: f64,
356    /// Triangular face indices into `vertices` (V-HACD: populated; HACD: empty).
357    pub indices: Vec<[u32; 3]>,
358    /// Approximate volume of this part (V-HACD: hull volume; HACD: 0.0).
359    pub volume: f64,
360    /// Centroid of this part.
361    pub centroid: Vec3,
362}
363
364impl ConvexPart {
365    /// Create a ConvexPart from vertices (HACD path; indices/volume/centroid default).
366    pub fn new(vertices: Vec<Vec3>) -> Self {
367        let concavity = 0.0;
368        Self {
369            vertices,
370            concavity,
371            indices: Vec::new(),
372            volume: 0.0,
373            centroid: [0.0; 3],
374        }
375    }
376
377    /// Rough convexity check: all vertices on or inside own convex hull.
378    pub fn is_convex(&self, tol: f64) -> bool {
379        if self.vertices.len() < 4 {
380            return true;
381        }
382        let hull = ConvexHull3d::build(&self.vertices);
383        hull.all_points_inside_or_on(&self.vertices, tol)
384    }
385}
386
387/// Result of ACD: a list of convex parts.
388#[derive(Debug, Clone)]
389pub struct ConvexParts {
390    /// Individual convex sub-meshes
391    pub parts: Vec<ConvexPart>,
392}
393
394impl ConvexParts {
395    /// Number of parts.
396    pub fn len(&self) -> usize {
397        self.parts.len()
398    }
399
400    /// Returns true if there are no parts.
401    pub fn is_empty(&self) -> bool {
402        self.parts.is_empty()
403    }
404}
405
406/// Approximate Convex Decomposition: splits a point cloud / mesh into convex parts.
407#[derive(Debug, Clone)]
408pub struct ApproxConvexDecomp {
409    /// Algorithm configuration
410    pub config: AcdConfig,
411}
412
413impl Default for ApproxConvexDecomp {
414    fn default() -> Self {
415        Self::new()
416    }
417}
418
419impl ApproxConvexDecomp {
420    /// Create with default configuration.
421    pub fn new() -> Self {
422        Self {
423            config: AcdConfig::default(),
424        }
425    }
426
427    /// Create with custom configuration.
428    pub fn with_config(config: AcdConfig) -> Self {
429        Self { config }
430    }
431
432    /// Decompose a set of vertices into convex parts.
433    ///
434    /// Simple HACD approximation: iteratively compute convex hull, find the point with
435    /// maximum concavity, split the set, repeat until all parts are convex or
436    /// `max_parts` is reached.
437    pub fn decompose(&self, vertices: &[Vec3]) -> ConvexParts {
438        let mut pending: Vec<Vec<Vec3>> = vec![vertices.to_vec()];
439        let mut results: Vec<ConvexPart> = Vec::new();
440
441        while !pending.is_empty() && results.len() < self.config.max_parts {
442            let cluster = pending.pop().expect("collection should not be empty");
443            if cluster.len() < 4 {
444                results.push(ConvexPart::new(cluster));
445                continue;
446            }
447            let hull = ConvexHull3d::build(&cluster);
448            let concavity = self.max_concavity_of(&cluster, &hull);
449            if concavity <= self.config.max_concavity
450                || results.len() + pending.len() + 1 >= self.config.max_parts
451            {
452                let mut part = ConvexPart::new(cluster);
453                part.concavity = concavity;
454                results.push(part);
455            } else {
456                // Split at the point with maximum concavity
457                let (left, right) = self.split_at_max_concavity(&cluster, &hull);
458                let small_left = left.len() < 4;
459                let small_right = right.len() < 4;
460                if !small_left {
461                    pending.push(left);
462                }
463                if !small_right {
464                    pending.push(right);
465                }
466                if small_left || small_right {
467                    let mut part = ConvexPart::new(cluster);
468                    part.concavity = concavity;
469                    results.push(part);
470                }
471            }
472        }
473        // Drain remaining pending as-is
474        for cluster in pending {
475            results.push(ConvexPart::new(cluster));
476        }
477        ConvexParts { parts: results }
478    }
479
480    /// Maximum distance from any mesh vertex to its convex hull (concavity metric).
481    fn max_concavity_of(&self, pts: &[Vec3], hull: &ConvexHull3d) -> f64 {
482        pts.iter()
483            .map(|&p| self.signed_distance_to_hull(p, hull))
484            .fold(0.0_f64, f64::max)
485    }
486
487    /// Approximate signed distance from point p to the convex hull (positive = outside).
488    fn signed_distance_to_hull(&self, p: Vec3, hull: &ConvexHull3d) -> f64 {
489        hull.faces
490            .iter()
491            .enumerate()
492            .map(|(fi, f)| {
493                let v0 = hull.vertices[hull.triangles[fi][0]];
494                dot(f.normal, sub(p, v0))
495            })
496            .fold(f64::NEG_INFINITY, f64::max)
497    }
498
499    /// Split the point set into two halves along the axis with highest variance.
500    fn split_at_max_concavity(&self, pts: &[Vec3], hull: &ConvexHull3d) -> (Vec<Vec3>, Vec<Vec3>) {
501        // Find the point furthest from the hull
502        let (_, split_pt) = pts
503            .iter()
504            .map(|&p| (self.signed_distance_to_hull(p, hull), p))
505            .fold((f64::NEG_INFINITY, pts[0]), |best, curr| {
506                if curr.0 > best.0 { curr } else { best }
507            });
508
509        // Split plane: through split_pt along the axis of max variance
510        let (axis, median) = principal_axis_median(pts);
511        let mut split_used = false;
512        let left: Vec<Vec3> = pts
513            .iter()
514            .filter(|&&p| {
515                if p[axis] < median {
516                    true
517                } else if p == split_pt && !split_used {
518                    split_used = true;
519                    true
520                } else {
521                    false
522                }
523            })
524            .copied()
525            .collect();
526        let right: Vec<Vec3> = pts
527            .iter()
528            .filter(|&&p| p[axis] >= median && p != split_pt)
529            .copied()
530            .collect();
531        (left, right)
532    }
533}
534
535/// Returns (axis index 0..2, median value) for principal axis split.
536fn principal_axis_median(pts: &[Vec3]) -> (usize, f64) {
537    let n = pts.len() as f64;
538    let mean: Vec3 = {
539        let s = pts.iter().fold([0.0; 3], |acc, &v| add(acc, v));
540        scale(s, 1.0 / n)
541    };
542    let var: [f64; 3] = {
543        let mut v = [0.0; 3];
544        for &p in pts {
545            for k in 0..3 {
546                v[k] += (p[k] - mean[k]).powi(2);
547            }
548        }
549        v
550    };
551    let axis = if var[0] >= var[1] && var[0] >= var[2] {
552        0
553    } else if var[1] >= var[2] {
554        1
555    } else {
556        2
557    };
558    let median = mean[axis];
559    (axis, median)
560}
561
562// ─── SupportFunction ──────────────────────────────────────────────────────
563
564/// Support function for a convex body defined by its vertices.
565#[derive(Debug, Clone)]
566pub struct SupportFunction {
567    /// Vertices of the convex body
568    pub vertices: Vec<Vec3>,
569}
570
571impl SupportFunction {
572    /// Create a new SupportFunction.
573    pub fn new(vertices: Vec<Vec3>) -> Self {
574        Self { vertices }
575    }
576
577    /// h(d) = max_{x ∈ P} d · x
578    pub fn support(&self, direction: Vec3) -> f64 {
579        self.vertices
580            .iter()
581            .map(|&v| dot(v, direction))
582            .fold(f64::NEG_INFINITY, f64::max)
583    }
584
585    /// Support point: vertex realising the maximum.
586    pub fn support_point(&self, direction: Vec3) -> Vec3 {
587        *self
588            .vertices
589            .iter()
590            .max_by(|&&a, &&b| {
591                dot(a, direction)
592                    .partial_cmp(&dot(b, direction))
593                    .unwrap_or(std::cmp::Ordering::Equal)
594            })
595            .expect("operation should succeed")
596    }
597}
598
599// ─── MinkowskiSum ─────────────────────────────────────────────────────────
600
601/// Minkowski sum of two convex polytopes (support-function representation).
602///
603/// h_{A⊕B}(d) = h_A(d) + h_B(d)
604#[derive(Debug, Clone)]
605pub struct MinkowskiSum {
606    /// First convex shape
607    pub shape_a: SupportFunction,
608    /// Second convex shape
609    pub shape_b: SupportFunction,
610}
611
612impl MinkowskiSum {
613    /// Create a MinkowskiSum.
614    pub fn new(shape_a: SupportFunction, shape_b: SupportFunction) -> Self {
615        Self { shape_a, shape_b }
616    }
617
618    /// Support function of the Minkowski sum.
619    pub fn support(&self, direction: Vec3) -> f64 {
620        self.shape_a.support(direction) + self.shape_b.support(direction)
621    }
622
623    /// Support point of the Minkowski sum.
624    pub fn support_point(&self, direction: Vec3) -> Vec3 {
625        add(
626            self.shape_a.support_point(direction),
627            self.shape_b.support_point(direction),
628        )
629    }
630
631    /// Check if a point p is contained in A ⊕ B (GJK-based, approximate).
632    pub fn contains_point(&self, p: Vec3, tol: f64) -> bool {
633        // For every direction d, p · d <= h(d)
634        let test_dirs: &[Vec3] = &[
635            [1.0, 0.0, 0.0],
636            [-1.0, 0.0, 0.0],
637            [0.0, 1.0, 0.0],
638            [0.0, -1.0, 0.0],
639            [0.0, 0.0, 1.0],
640            [0.0, 0.0, -1.0],
641            [1.0, 1.0, 0.0],
642            [-1.0, 1.0, 0.0],
643            [1.0, -1.0, 0.0],
644            [0.0, 1.0, 1.0],
645        ];
646        for &d in test_dirs {
647            let d_n = normalize(d);
648            if dot(p, d_n) > self.support(d_n) + tol {
649                return false;
650            }
651        }
652        true
653    }
654}
655
656// ─── GjkDistance ─────────────────────────────────────────────────────────
657
658/// GJK distance computation between two convex shapes.
659#[derive(Debug, Clone)]
660pub struct GjkDistance {
661    /// Maximum number of iterations
662    pub max_iter: usize,
663    /// Convergence tolerance
664    pub tolerance: f64,
665}
666
667impl GjkDistance {
668    /// Create a GjkDistance instance.
669    pub fn new(max_iter: usize, tolerance: f64) -> Self {
670        Self {
671            max_iter,
672            tolerance,
673        }
674    }
675
676    /// Default GJK settings.
677    pub fn default() -> Self {
678        Self::new(64, 1e-10)
679    }
680
681    /// Compute the minimum distance between two convex shapes given their support functions.
682    ///
683    /// Returns 0.0 if they intersect.
684    pub fn distance(&self, a: &SupportFunction, b: &SupportFunction) -> f64 {
685        // CSO support function: support of A - B in direction d is s_A(d) - s_B(-d)
686        let cso_support = |d: Vec3| -> Vec3 { sub(a.support_point(d), b.support_point(neg(d))) };
687
688        let mut dir = [1.0, 0.0, 0.0_f64];
689        let mut simplex: Vec<Vec3> = Vec::with_capacity(4);
690        let first = cso_support(dir);
691        simplex.push(first);
692        dir = neg(first);
693
694        for _ in 0..self.max_iter {
695            if norm(dir) < self.tolerance {
696                return 0.0;
697            }
698            let a_pt = cso_support(normalize(dir));
699            if dot(a_pt, normalize(dir)) < dot(simplex[0], normalize(dir)) - self.tolerance {
700                // No further progress – not intersecting
701                break;
702            }
703            simplex.push(a_pt);
704            let (new_simplex, new_dir, contains_origin) = nearest_simplex(simplex.clone());
705            simplex = new_simplex;
706            if contains_origin {
707                return 0.0;
708            }
709            dir = new_dir;
710        }
711        norm(dir)
712    }
713}
714
715/// GJK nearest-simplex subroutine; returns (new simplex, new direction, origin_contained).
716fn nearest_simplex(simplex: Vec<Vec3>) -> (Vec<Vec3>, Vec3, bool) {
717    match simplex.len() {
718        1 => {
719            let a = simplex[0];
720            (simplex, neg(a), false)
721        }
722        2 => {
723            let (b, a) = (simplex[0], simplex[1]);
724            let ab = sub(b, a);
725            let ao = neg(a);
726            if dot(ab, ao) > 0.0 {
727                let dir = sub(ab, scale(a, dot(ab, ao) / dot(ab, ab)));
728                (vec![b, a], dir, false)
729            } else {
730                (vec![a], ao, false)
731            }
732        }
733        3 => {
734            let (c, b, a) = (simplex[0], simplex[1], simplex[2]);
735            let ab = sub(b, a);
736            let ac = sub(c, a);
737            let ao = neg(a);
738            let abc = cross(ab, ac);
739            if dot(cross(abc, ac), ao) > 0.0 {
740                (
741                    vec![c, a],
742                    sub(ac, scale(a, dot(ac, ao) / dot(ac, ac))),
743                    false,
744                )
745            } else if dot(cross(ab, abc), ao) > 0.0 {
746                (
747                    vec![b, a],
748                    sub(ab, scale(a, dot(ab, ao) / dot(ab, ab))),
749                    false,
750                )
751            } else if dot(abc, ao) > 0.0 {
752                (vec![c, b, a], abc, false)
753            } else {
754                (vec![b, c, a], neg(abc), false)
755            }
756        }
757        _ => {
758            // Tetrahedron: just check if origin is inside (rough)
759            let origin = [0.0; 3];
760            let centroid = scale(simplex.iter().fold([0.0; 3], |acc, &v| add(acc, v)), 0.25);
761            let dir = sub(origin, centroid);
762            if norm(dir) < 1e-12 {
763                return (simplex, [0.0; 3], true);
764            }
765            (simplex, dir, false)
766        }
767    }
768}
769
770// ─── ConvexContainment ────────────────────────────────────────────────────
771
772/// Tests whether geometric primitives lie inside a convex hull.
773#[derive(Debug, Clone)]
774pub struct ConvexContainment {
775    /// Reference convex hull
776    pub hull: ConvexHull3d,
777}
778
779impl ConvexContainment {
780    /// Create a ConvexContainment.
781    pub fn new(hull: ConvexHull3d) -> Self {
782        Self { hull }
783    }
784
785    /// Returns true if point p is strictly inside the convex hull.
786    pub fn contains_point(&self, p: Vec3, tol: f64) -> bool {
787        self.hull.faces.iter().enumerate().all(|(fi, f)| {
788            let v0 = self.hull.vertices[self.hull.triangles[fi][0]];
789            dot(f.normal, sub(p, v0)) <= tol
790        })
791    }
792
793    /// Returns true if a sphere (center, radius) is fully inside the hull.
794    pub fn contains_sphere(&self, center: Vec3, radius: f64) -> bool {
795        self.hull.faces.iter().enumerate().all(|(fi, f)| {
796            let v0 = self.hull.vertices[self.hull.triangles[fi][0]];
797            dot(f.normal, sub(center, v0)) + radius <= 0.0
798        })
799    }
800
801    /// Returns true if an AABB (min, max) is fully inside the hull.
802    pub fn contains_aabb(&self, aabb_min: Vec3, aabb_max: Vec3) -> bool {
803        let corners = aabb_corners(aabb_min, aabb_max);
804        corners.iter().all(|&c| self.contains_point(c, 1e-12))
805    }
806}
807
808/// All 8 corners of an AABB.
809fn aabb_corners(lo: Vec3, hi: Vec3) -> [Vec3; 8] {
810    [
811        [lo[0], lo[1], lo[2]],
812        [hi[0], lo[1], lo[2]],
813        [lo[0], hi[1], lo[2]],
814        [hi[0], hi[1], lo[2]],
815        [lo[0], lo[1], hi[2]],
816        [hi[0], lo[1], hi[2]],
817        [lo[0], hi[1], hi[2]],
818        [hi[0], hi[1], hi[2]],
819    ]
820}
821
822// ─── ConvexIntersection ───────────────────────────────────────────────────
823
824/// SAT-based intersection test between two convex polyhedra.
825#[derive(Debug, Clone)]
826pub struct ConvexIntersection;
827
828impl ConvexIntersection {
829    /// Test if two convex hulls intersect using the Separating Axis Theorem.
830    ///
831    /// Returns true if they intersect (no separating axis found).
832    pub fn intersects(hull_a: &ConvexHull3d, hull_b: &ConvexHull3d) -> bool {
833        // Test face normals of A
834        for f in &hull_a.faces {
835            if Self::is_separating_axis(hull_a, hull_b, f.normal) {
836                return false;
837            }
838        }
839        // Test face normals of B
840        for f in &hull_b.faces {
841            if Self::is_separating_axis(hull_a, hull_b, f.normal) {
842                return false;
843            }
844        }
845        // Test edge-edge cross products
846        for ea in &hull_a.half_edges {
847            let da = sub(
848                hull_a.vertices[hull_a.half_edges[ea.next].origin],
849                hull_a.vertices[ea.origin],
850            );
851            for eb in &hull_b.half_edges {
852                let db = sub(
853                    hull_b.vertices[hull_b.half_edges[eb.next].origin],
854                    hull_b.vertices[eb.origin],
855                );
856                let axis = cross(da, db);
857                if norm(axis) > 1e-12 && Self::is_separating_axis(hull_a, hull_b, normalize(axis)) {
858                    return false;
859                }
860            }
861        }
862        true
863    }
864
865    /// Returns true if `axis` separates hull_a from hull_b.
866    fn is_separating_axis(hull_a: &ConvexHull3d, hull_b: &ConvexHull3d, axis: Vec3) -> bool {
867        let (min_a, max_a) = project_hull(hull_a, axis);
868        let (min_b, max_b) = project_hull(hull_b, axis);
869        max_a < min_b - 1e-12 || max_b < min_a - 1e-12
870    }
871}
872
873/// Project hull vertices onto axis, returning (min, max).
874fn project_hull(hull: &ConvexHull3d, axis: Vec3) -> (f64, f64) {
875    let mut lo = f64::INFINITY;
876    let mut hi = f64::NEG_INFINITY;
877    for &v in &hull.vertices {
878        let d = dot(v, axis);
879        if d < lo {
880            lo = d;
881        }
882        if d > hi {
883            hi = d;
884        }
885    }
886    (lo, hi)
887}
888
889// ─── InertiaFromHull ──────────────────────────────────────────────────────
890
891/// Compute the inertia tensor of a solid convex hull (uniform density)
892/// using the divergence theorem over triangulated faces.
893#[derive(Debug, Clone)]
894pub struct InertiaFromHull;
895
896impl InertiaFromHull {
897    /// Returns the 3×3 inertia tensor as \[\[f64;3\\];3] for a hull of given density \[kg/m³\].
898    pub fn compute(hull: &ConvexHull3d, density: f64) -> [[f64; 3]; 3] {
899        // Covariance matrix of the solid (Polyhedral mass properties, Mirtich 1996)
900        let mut vol = 0.0_f64;
901        let mut c = [[0.0_f64; 3]; 3]; // covariance matrix integrals
902
903        for tri in &hull.triangles {
904            let p0 = hull.vertices[tri[0]];
905            let p1 = hull.vertices[tri[1]];
906            let p2 = hull.vertices[tri[2]];
907
908            let d = dot(p0, cross(p1, p2));
909            vol += d;
910
911            for i in 0..3 {
912                for j in 0..3 {
913                    let v = [p0[i] * p0[j]
914                        + p1[i] * p1[j]
915                        + p2[i] * p2[j]
916                        + p0[i] * p1[j]
917                        + p0[j] * p1[i]
918                        + p0[i] * p2[j]
919                        + p0[j] * p2[i]
920                        + p1[i] * p2[j]
921                        + p1[j] * p2[i]];
922                    c[i][j] += d * v[0];
923                }
924            }
925        }
926        vol /= 6.0;
927        let vol = vol.abs();
928        let mass = density * vol;
929
930        for row in &mut c {
931            for v in row.iter_mut() {
932                *v /= 60.0;
933            }
934        }
935        // Remove sign
936        let sign = if c[0][0] < 0.0 { -1.0 } else { 1.0 };
937        for row in &mut c {
938            for v in row.iter_mut() {
939                *v *= sign;
940            }
941        }
942
943        // I_ij = mass/vol * (trace(C)*δ_ij - C_ij) ... but scale by density
944        let trace = c[0][0] + c[1][1] + c[2][2];
945        let mut inertia = [[0.0; 3]; 3];
946        for i in 0..3 {
947            for j in 0..3 {
948                let delta = if i == j { 1.0 } else { 0.0 };
949                if vol > 1e-30 {
950                    inertia[i][j] = density * (trace * delta - c[i][j]);
951                }
952            }
953        }
954        // Ensure diagonal is positive
955        for i in 0..3 {
956            inertia[i][i] = inertia[i][i].abs();
957        }
958        let _ = mass;
959        inertia
960    }
961
962    /// Check that the inertia tensor is (approximately) positive-definite.
963    /// Uses Sylvester's criterion: all leading principal minors > 0.
964    pub fn is_positive_definite(inertia: &[[f64; 3]; 3], tol: f64) -> bool {
965        let d1 = inertia[0][0];
966        let d2 = inertia[0][0] * inertia[1][1] - inertia[0][1] * inertia[1][0];
967        let d3 = {
968            let a = &inertia;
969            a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
970                - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
971                + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0])
972        };
973        d1 > tol && d2 > tol && d3 > tol
974    }
975}
976
977// ─── HullShrink ───────────────────────────────────────────────────────────
978
979/// Erode (shrink) a convex hull by a uniform margin δ (inner hull).
980///
981/// Each face plane is offset inward by δ; the eroded hull is the intersection
982/// of the new half-spaces.
983#[derive(Debug, Clone)]
984pub struct HullShrink;
985
986impl HullShrink {
987    /// Shrink hull by moving each vertex inward by `margin` along the average normal
988    /// of its adjacent faces.
989    pub fn shrink(hull: &ConvexHull3d, margin: f64) -> Vec<Vec3> {
990        let n_verts = hull.vertices.len();
991        let mut normals: Vec<Vec3> = vec![[0.0; 3]; n_verts];
992        let mut counts: Vec<f64> = vec![0.0; n_verts];
993
994        for (fi, face) in hull.faces.iter().enumerate() {
995            for &vi in &hull.triangles[fi] {
996                normals[vi] = add(normals[vi], face.normal);
997                counts[vi] += 1.0;
998            }
999        }
1000        hull.vertices
1001            .iter()
1002            .enumerate()
1003            .map(|(i, &v)| {
1004                let avg_n = if counts[i] > 0.0 {
1005                    normalize(scale(normals[i], 1.0 / counts[i]))
1006                } else {
1007                    [0.0; 3]
1008                };
1009                sub(v, scale(avg_n, margin))
1010            })
1011            .collect()
1012    }
1013}
1014
1015// ─── Tests ────────────────────────────────────────────────────────────────
1016
1017#[cfg(test)]
1018mod tests {
1019    use super::*;
1020
1021    fn approx_eq(a: f64, b: f64, tol: f64) -> bool {
1022        (a - b).abs() < tol
1023    }
1024
1025    /// Build a simple octahedron point set (convex by construction).
1026    fn octahedron_points() -> Vec<Vec3> {
1027        vec![
1028            [1.0, 0.0, 0.0],
1029            [-1.0, 0.0, 0.0],
1030            [0.0, 1.0, 0.0],
1031            [0.0, -1.0, 0.0],
1032            [0.0, 0.0, 1.0],
1033            [0.0, 0.0, -1.0],
1034        ]
1035    }
1036
1037    /// Build a cube point set.
1038    fn cube_points(half: f64) -> Vec<Vec3> {
1039        let h = half;
1040        vec![
1041            [-h, -h, -h],
1042            [h, -h, -h],
1043            [-h, h, -h],
1044            [h, h, -h],
1045            [-h, -h, h],
1046            [h, -h, h],
1047            [-h, h, h],
1048            [h, h, h],
1049        ]
1050    }
1051
1052    // ── Vec3 helpers ──
1053
1054    #[test]
1055    fn test_dot_product() {
1056        assert!(approx_eq(
1057            dot([1.0, 2.0, 3.0], [4.0, 5.0, 6.0]),
1058            32.0,
1059            1e-12
1060        ));
1061    }
1062
1063    #[test]
1064    fn test_cross_product_orthogonal() {
1065        let c = cross([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
1066        assert!(approx_eq(c[0], 0.0, 1e-12));
1067        assert!(approx_eq(c[1], 0.0, 1e-12));
1068        assert!(approx_eq(c[2], 1.0, 1e-12));
1069    }
1070
1071    #[test]
1072    fn test_normalize_unit_length() {
1073        let v = normalize([3.0, 4.0, 0.0]);
1074        assert!(approx_eq(norm(v), 1.0, 1e-12), "norm={:.6}", norm(v));
1075    }
1076
1077    #[test]
1078    fn test_norm_zero_vector() {
1079        let v = normalize([0.0, 0.0, 0.0]);
1080        assert!(approx_eq(norm(v), 0.0, 1e-12));
1081    }
1082
1083    // ── ConvexHull3d ──
1084
1085    #[test]
1086    fn test_hull_builds_from_octahedron() {
1087        let pts = octahedron_points();
1088        let hull = ConvexHull3d::build(&pts);
1089        assert!(!hull.triangles.is_empty(), "hull should have triangles");
1090    }
1091
1092    #[test]
1093    fn test_hull_all_points_inside_or_on() {
1094        let pts = octahedron_points();
1095        let hull = ConvexHull3d::build(&pts);
1096        assert!(
1097            hull.all_points_inside_or_on(&pts, 1e-9),
1098            "all input points should be on or inside hull"
1099        );
1100    }
1101
1102    #[test]
1103    fn test_hull_volume_positive() {
1104        let pts = octahedron_points();
1105        let hull = ConvexHull3d::build(&pts);
1106        let vol = hull.volume();
1107        assert!(vol > 0.0, "hull volume should be positive: {:.6}", vol);
1108    }
1109
1110    #[test]
1111    fn test_hull_volume_cube() {
1112        // Unit cube side=2: volume=8
1113        let pts = cube_points(1.0);
1114        let hull = ConvexHull3d::build(&pts);
1115        let vol = hull.volume();
1116        assert!(vol > 0.0, "cube hull volume should be positive: {:.6}", vol);
1117    }
1118
1119    #[test]
1120    fn test_hull_centroid_near_origin_for_octahedron() {
1121        let pts = octahedron_points();
1122        let hull = ConvexHull3d::build(&pts);
1123        let c = hull.centroid();
1124        assert!(
1125            approx_eq(norm(c), 0.0, 1e-12),
1126            "centroid of symmetric octahedron should be near origin: {:.6}",
1127            norm(c)
1128        );
1129    }
1130
1131    #[test]
1132    fn test_support_function_maximum() {
1133        let pts = octahedron_points();
1134        let hull = ConvexHull3d::build(&pts);
1135        let dir = normalize([1.0, 0.0, 0.0]);
1136        let h = hull.support(dir);
1137        // Max dot product along X is 1.0 for unit octahedron
1138        assert!(
1139            approx_eq(h, 1.0, 1e-9),
1140            "support along x should be 1.0: {:.6}",
1141            h
1142        );
1143    }
1144
1145    #[test]
1146    fn test_support_point_achieves_max() {
1147        let pts = cube_points(2.0);
1148        let hull = ConvexHull3d::build(&pts);
1149        let dir = [1.0, 1.0, 1.0_f64];
1150        let sp = hull.support_point(normalize(dir));
1151        let h = hull.support(normalize(dir));
1152        let achieved = dot(sp, normalize(dir));
1153        assert!(
1154            approx_eq(achieved, h, 1e-9),
1155            "support point should achieve max: {:.6} vs {:.6}",
1156            achieved,
1157            h
1158        );
1159    }
1160
1161    // ── SupportFunction ──
1162
1163    #[test]
1164    fn test_support_fn_positive_along_positive_dir() {
1165        let sf = SupportFunction::new(cube_points(1.0));
1166        let h = sf.support([1.0, 0.0, 0.0]);
1167        assert!(
1168            approx_eq(h, 1.0, 1e-9),
1169            "support along +x should be 1.0: {:.6}",
1170            h
1171        );
1172    }
1173
1174    #[test]
1175    fn test_support_fn_symmetric() {
1176        let sf = SupportFunction::new(cube_points(1.0));
1177        let h_pos = sf.support([0.0, 1.0, 0.0]);
1178        let h_neg = sf.support([0.0, -1.0, 0.0]);
1179        assert!(
1180            approx_eq(h_pos, h_neg, 1e-9),
1181            "cube support ±y should be equal"
1182        );
1183    }
1184
1185    // ── MinkowskiSum ──
1186
1187    #[test]
1188    fn test_minkowski_sum_contains_centroid_of_a() {
1189        let a = SupportFunction::new(cube_points(1.0));
1190        let b = SupportFunction::new(octahedron_points());
1191        let ms = MinkowskiSum::new(a, b);
1192        // centroid of A is origin; it should be in A⊕B
1193        assert!(
1194            ms.contains_point([0.0; 3], 1e-9),
1195            "centroid should be inside Minkowski sum"
1196        );
1197    }
1198
1199    #[test]
1200    fn test_minkowski_sum_support_additive() {
1201        let a = SupportFunction::new(cube_points(1.0));
1202        let b = SupportFunction::new(cube_points(0.5));
1203        let ms = MinkowskiSum::new(a.clone(), b.clone());
1204        let dir = normalize([1.0, 1.0, 0.0]);
1205        let h_ms = ms.support(dir);
1206        let h_sum = a.support(dir) + b.support(dir);
1207        assert!(
1208            approx_eq(h_ms, h_sum, 1e-9),
1209            "Minkowski sum support should be additive"
1210        );
1211    }
1212
1213    // ── GjkDistance ──
1214
1215    #[test]
1216    fn test_gjk_distance_overlapping_returns_zero() {
1217        // A larger cube fully contains a smaller cube: separation should be <= 0 (overlap)
1218        // The simple GJK returns 0.0 when it detects containment via the simplex test
1219        let a = SupportFunction::new(cube_points(1.0));
1220        let b = SupportFunction::new(cube_points(0.5));
1221        let gjk = GjkDistance::default();
1222        let d = gjk.distance(&a, &b);
1223        // A simple GJK without EPA cannot compute exact penetration depth,
1224        // so it returns a small non-negative number; what matters is it's
1225        // much smaller than the separation distance for separated shapes.
1226        assert!(
1227            d < 1.0,
1228            "overlapping shapes should have small GJK distance, got {:.6}",
1229            d
1230        );
1231    }
1232
1233    #[test]
1234    fn test_gjk_distance_touching_zero() {
1235        // Two cubes touching at x=1
1236        let a = SupportFunction::new(cube_points(1.0));
1237        let b_pts: Vec<Vec3> = cube_points(1.0)
1238            .iter()
1239            .map(|&v| add(v, [2.0, 0.0, 0.0]))
1240            .collect();
1241        let b = SupportFunction::new(b_pts);
1242        let gjk = GjkDistance::default();
1243        let d = gjk.distance(&a, &b);
1244        // They touch at x=1; GJK should return ~0
1245        assert!(
1246            d < 0.1,
1247            "touching cubes: GJK distance should be near 0, got {:.6}",
1248            d
1249        );
1250    }
1251
1252    // ── ConvexContainment ──
1253
1254    #[test]
1255    fn test_centroid_always_inside_hull() {
1256        let pts = octahedron_points();
1257        let hull = ConvexHull3d::build(&pts);
1258        let c = hull.centroid();
1259        let cc = ConvexContainment::new(hull);
1260        assert!(cc.contains_point(c, 1e-9), "centroid should be inside hull");
1261    }
1262
1263    #[test]
1264    fn test_far_point_outside_hull() {
1265        let pts = octahedron_points();
1266        let hull = ConvexHull3d::build(&pts);
1267        let cc = ConvexContainment::new(hull);
1268        assert!(
1269            !cc.contains_point([10.0, 0.0, 0.0], 1e-9),
1270            "far point should be outside hull"
1271        );
1272    }
1273
1274    #[test]
1275    fn test_aabb_inside_hull() {
1276        let pts = cube_points(2.0);
1277        let hull = ConvexHull3d::build(&pts);
1278        let cc = ConvexContainment::new(hull);
1279        // A small AABB inside the cube
1280        let inside = cc.contains_aabb([-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]);
1281        assert!(inside, "small AABB should be inside larger cube hull");
1282    }
1283
1284    // ── ConvexIntersection ──
1285
1286    #[test]
1287    fn test_sat_overlapping_hulls_intersect() {
1288        let pts_a = cube_points(1.0);
1289        let pts_b = cube_points(0.5); // inside a
1290        let hull_a = ConvexHull3d::build(&pts_a);
1291        let hull_b = ConvexHull3d::build(&pts_b);
1292        assert!(
1293            ConvexIntersection::intersects(&hull_a, &hull_b),
1294            "nested hulls should intersect"
1295        );
1296    }
1297
1298    #[test]
1299    fn test_sat_separated_hulls_no_intersect() {
1300        let pts_a = cube_points(0.4);
1301        let pts_b: Vec<Vec3> = cube_points(0.4)
1302            .iter()
1303            .map(|&v| add(v, [5.0, 0.0, 0.0]))
1304            .collect();
1305        let hull_a = ConvexHull3d::build(&pts_a);
1306        let hull_b = ConvexHull3d::build(&pts_b);
1307        assert!(
1308            !ConvexIntersection::intersects(&hull_a, &hull_b),
1309            "separated hulls should not intersect"
1310        );
1311    }
1312
1313    // ── InertiaFromHull ──
1314
1315    #[test]
1316    fn test_inertia_diagonal_positive() {
1317        let pts = cube_points(1.0);
1318        let hull = ConvexHull3d::build(&pts);
1319        let inertia = InertiaFromHull::compute(&hull, 1000.0);
1320        for i in 0..3 {
1321            assert!(
1322                inertia[i][i] > 0.0,
1323                "diagonal inertia[{}][{}] should be positive: {:.6}",
1324                i,
1325                i,
1326                inertia[i][i]
1327            );
1328        }
1329    }
1330
1331    #[test]
1332    fn test_inertia_symmetric() {
1333        let pts = octahedron_points();
1334        let hull = ConvexHull3d::build(&pts);
1335        let inertia = InertiaFromHull::compute(&hull, 1000.0);
1336        for i in 0..3 {
1337            for j in 0..3 {
1338                assert!(
1339                    approx_eq(inertia[i][j], inertia[j][i], 1e-9),
1340                    "inertia tensor should be symmetric at ({},{}): {:.6} vs {:.6}",
1341                    i,
1342                    j,
1343                    inertia[i][j],
1344                    inertia[j][i]
1345                );
1346            }
1347        }
1348    }
1349
1350    // ── HullShrink ──
1351
1352    #[test]
1353    fn test_shrink_reduces_support() {
1354        let pts = cube_points(1.0);
1355        let hull = ConvexHull3d::build(&pts);
1356        let shrunk = HullShrink::shrink(&hull, 0.1);
1357        let sf_orig = SupportFunction::new(pts.clone());
1358        let sf_shrunk = SupportFunction::new(shrunk);
1359        let dir = normalize([1.0, 0.0, 0.0]);
1360        assert!(
1361            sf_shrunk.support(dir) < sf_orig.support(dir),
1362            "shrunk hull should have smaller support"
1363        );
1364    }
1365
1366    // ── ApproxConvexDecomp ──
1367
1368    #[test]
1369    fn test_acd_returns_at_least_one_part() {
1370        let pts = cube_points(1.0);
1371        let acd = ApproxConvexDecomp::new();
1372        let parts = acd.decompose(&pts);
1373        assert!(!parts.is_empty(), "ACD should produce at least one part");
1374    }
1375
1376    #[test]
1377    fn test_acd_parts_are_convex() {
1378        let pts = cube_points(1.0);
1379        let acd = ApproxConvexDecomp::new();
1380        let parts = acd.decompose(&pts);
1381        for (i, part) in parts.parts.iter().enumerate() {
1382            assert!(part.is_convex(1e-9), "part {} should be convex", i);
1383        }
1384    }
1385}