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}
357
358impl ConvexPart {
359    /// Create a ConvexPart from vertices.
360    pub fn new(vertices: Vec<Vec3>) -> Self {
361        let concavity = 0.0;
362        Self {
363            vertices,
364            concavity,
365        }
366    }
367
368    /// Rough convexity check: all vertices on or inside own convex hull.
369    pub fn is_convex(&self, tol: f64) -> bool {
370        if self.vertices.len() < 4 {
371            return true;
372        }
373        let hull = ConvexHull3d::build(&self.vertices);
374        hull.all_points_inside_or_on(&self.vertices, tol)
375    }
376}
377
378/// Result of ACD: a list of convex parts.
379#[derive(Debug, Clone)]
380pub struct ConvexParts {
381    /// Individual convex sub-meshes
382    pub parts: Vec<ConvexPart>,
383}
384
385impl ConvexParts {
386    /// Number of parts.
387    pub fn len(&self) -> usize {
388        self.parts.len()
389    }
390
391    /// Returns true if there are no parts.
392    pub fn is_empty(&self) -> bool {
393        self.parts.is_empty()
394    }
395}
396
397/// Approximate Convex Decomposition: splits a point cloud / mesh into convex parts.
398#[derive(Debug, Clone)]
399pub struct ApproxConvexDecomp {
400    /// Algorithm configuration
401    pub config: AcdConfig,
402}
403
404impl Default for ApproxConvexDecomp {
405    fn default() -> Self {
406        Self::new()
407    }
408}
409
410impl ApproxConvexDecomp {
411    /// Create with default configuration.
412    pub fn new() -> Self {
413        Self {
414            config: AcdConfig::default(),
415        }
416    }
417
418    /// Create with custom configuration.
419    pub fn with_config(config: AcdConfig) -> Self {
420        Self { config }
421    }
422
423    /// Decompose a set of vertices into convex parts.
424    ///
425    /// Simple HACD approximation: iteratively compute convex hull, find the point with
426    /// maximum concavity, split the set, repeat until all parts are convex or
427    /// `max_parts` is reached.
428    pub fn decompose(&self, vertices: &[Vec3]) -> ConvexParts {
429        let mut pending: Vec<Vec<Vec3>> = vec![vertices.to_vec()];
430        let mut results: Vec<ConvexPart> = Vec::new();
431
432        while !pending.is_empty() && results.len() < self.config.max_parts {
433            let cluster = pending.pop().expect("collection should not be empty");
434            if cluster.len() < 4 {
435                results.push(ConvexPart::new(cluster));
436                continue;
437            }
438            let hull = ConvexHull3d::build(&cluster);
439            let concavity = self.max_concavity_of(&cluster, &hull);
440            if concavity <= self.config.max_concavity
441                || results.len() + pending.len() + 1 >= self.config.max_parts
442            {
443                let mut part = ConvexPart::new(cluster);
444                part.concavity = concavity;
445                results.push(part);
446            } else {
447                // Split at the point with maximum concavity
448                let (left, right) = self.split_at_max_concavity(&cluster, &hull);
449                let small_left = left.len() < 4;
450                let small_right = right.len() < 4;
451                if !small_left {
452                    pending.push(left);
453                }
454                if !small_right {
455                    pending.push(right);
456                }
457                if small_left || small_right {
458                    let mut part = ConvexPart::new(cluster);
459                    part.concavity = concavity;
460                    results.push(part);
461                }
462            }
463        }
464        // Drain remaining pending as-is
465        for cluster in pending {
466            results.push(ConvexPart::new(cluster));
467        }
468        ConvexParts { parts: results }
469    }
470
471    /// Maximum distance from any mesh vertex to its convex hull (concavity metric).
472    fn max_concavity_of(&self, pts: &[Vec3], hull: &ConvexHull3d) -> f64 {
473        pts.iter()
474            .map(|&p| self.signed_distance_to_hull(p, hull))
475            .fold(0.0_f64, f64::max)
476    }
477
478    /// Approximate signed distance from point p to the convex hull (positive = outside).
479    fn signed_distance_to_hull(&self, p: Vec3, hull: &ConvexHull3d) -> f64 {
480        hull.faces
481            .iter()
482            .enumerate()
483            .map(|(fi, f)| {
484                let v0 = hull.vertices[hull.triangles[fi][0]];
485                dot(f.normal, sub(p, v0))
486            })
487            .fold(f64::NEG_INFINITY, f64::max)
488    }
489
490    /// Split the point set into two halves along the axis with highest variance.
491    fn split_at_max_concavity(&self, pts: &[Vec3], hull: &ConvexHull3d) -> (Vec<Vec3>, Vec<Vec3>) {
492        // Find the point furthest from the hull
493        let (_, split_pt) = pts
494            .iter()
495            .map(|&p| (self.signed_distance_to_hull(p, hull), p))
496            .fold((f64::NEG_INFINITY, pts[0]), |best, curr| {
497                if curr.0 > best.0 { curr } else { best }
498            });
499
500        // Split plane: through split_pt along the axis of max variance
501        let (axis, median) = principal_axis_median(pts);
502        let mut split_used = false;
503        let left: Vec<Vec3> = pts
504            .iter()
505            .filter(|&&p| {
506                if p[axis] < median {
507                    true
508                } else if p == split_pt && !split_used {
509                    split_used = true;
510                    true
511                } else {
512                    false
513                }
514            })
515            .copied()
516            .collect();
517        let right: Vec<Vec3> = pts
518            .iter()
519            .filter(|&&p| p[axis] >= median && p != split_pt)
520            .copied()
521            .collect();
522        (left, right)
523    }
524}
525
526/// Returns (axis index 0..2, median value) for principal axis split.
527fn principal_axis_median(pts: &[Vec3]) -> (usize, f64) {
528    let n = pts.len() as f64;
529    let mean: Vec3 = {
530        let s = pts.iter().fold([0.0; 3], |acc, &v| add(acc, v));
531        scale(s, 1.0 / n)
532    };
533    let var: [f64; 3] = {
534        let mut v = [0.0; 3];
535        for &p in pts {
536            for k in 0..3 {
537                v[k] += (p[k] - mean[k]).powi(2);
538            }
539        }
540        v
541    };
542    let axis = if var[0] >= var[1] && var[0] >= var[2] {
543        0
544    } else if var[1] >= var[2] {
545        1
546    } else {
547        2
548    };
549    let median = mean[axis];
550    (axis, median)
551}
552
553// ─── SupportFunction ──────────────────────────────────────────────────────
554
555/// Support function for a convex body defined by its vertices.
556#[derive(Debug, Clone)]
557pub struct SupportFunction {
558    /// Vertices of the convex body
559    pub vertices: Vec<Vec3>,
560}
561
562impl SupportFunction {
563    /// Create a new SupportFunction.
564    pub fn new(vertices: Vec<Vec3>) -> Self {
565        Self { vertices }
566    }
567
568    /// h(d) = max_{x ∈ P} d · x
569    pub fn support(&self, direction: Vec3) -> f64 {
570        self.vertices
571            .iter()
572            .map(|&v| dot(v, direction))
573            .fold(f64::NEG_INFINITY, f64::max)
574    }
575
576    /// Support point: vertex realising the maximum.
577    pub fn support_point(&self, direction: Vec3) -> Vec3 {
578        *self
579            .vertices
580            .iter()
581            .max_by(|&&a, &&b| {
582                dot(a, direction)
583                    .partial_cmp(&dot(b, direction))
584                    .unwrap_or(std::cmp::Ordering::Equal)
585            })
586            .expect("operation should succeed")
587    }
588}
589
590// ─── MinkowskiSum ─────────────────────────────────────────────────────────
591
592/// Minkowski sum of two convex polytopes (support-function representation).
593///
594/// h_{A⊕B}(d) = h_A(d) + h_B(d)
595#[derive(Debug, Clone)]
596pub struct MinkowskiSum {
597    /// First convex shape
598    pub shape_a: SupportFunction,
599    /// Second convex shape
600    pub shape_b: SupportFunction,
601}
602
603impl MinkowskiSum {
604    /// Create a MinkowskiSum.
605    pub fn new(shape_a: SupportFunction, shape_b: SupportFunction) -> Self {
606        Self { shape_a, shape_b }
607    }
608
609    /// Support function of the Minkowski sum.
610    pub fn support(&self, direction: Vec3) -> f64 {
611        self.shape_a.support(direction) + self.shape_b.support(direction)
612    }
613
614    /// Support point of the Minkowski sum.
615    pub fn support_point(&self, direction: Vec3) -> Vec3 {
616        add(
617            self.shape_a.support_point(direction),
618            self.shape_b.support_point(direction),
619        )
620    }
621
622    /// Check if a point p is contained in A ⊕ B (GJK-based, approximate).
623    pub fn contains_point(&self, p: Vec3, tol: f64) -> bool {
624        // For every direction d, p · d <= h(d)
625        let test_dirs: &[Vec3] = &[
626            [1.0, 0.0, 0.0],
627            [-1.0, 0.0, 0.0],
628            [0.0, 1.0, 0.0],
629            [0.0, -1.0, 0.0],
630            [0.0, 0.0, 1.0],
631            [0.0, 0.0, -1.0],
632            [1.0, 1.0, 0.0],
633            [-1.0, 1.0, 0.0],
634            [1.0, -1.0, 0.0],
635            [0.0, 1.0, 1.0],
636        ];
637        for &d in test_dirs {
638            let d_n = normalize(d);
639            if dot(p, d_n) > self.support(d_n) + tol {
640                return false;
641            }
642        }
643        true
644    }
645}
646
647// ─── GjkDistance ─────────────────────────────────────────────────────────
648
649/// GJK distance computation between two convex shapes.
650#[derive(Debug, Clone)]
651pub struct GjkDistance {
652    /// Maximum number of iterations
653    pub max_iter: usize,
654    /// Convergence tolerance
655    pub tolerance: f64,
656}
657
658impl GjkDistance {
659    /// Create a GjkDistance instance.
660    pub fn new(max_iter: usize, tolerance: f64) -> Self {
661        Self {
662            max_iter,
663            tolerance,
664        }
665    }
666
667    /// Default GJK settings.
668    pub fn default() -> Self {
669        Self::new(64, 1e-10)
670    }
671
672    /// Compute the minimum distance between two convex shapes given their support functions.
673    ///
674    /// Returns 0.0 if they intersect.
675    pub fn distance(&self, a: &SupportFunction, b: &SupportFunction) -> f64 {
676        // CSO support function: support of A - B in direction d is s_A(d) - s_B(-d)
677        let cso_support = |d: Vec3| -> Vec3 { sub(a.support_point(d), b.support_point(neg(d))) };
678
679        let mut dir = [1.0, 0.0, 0.0_f64];
680        let mut simplex: Vec<Vec3> = Vec::with_capacity(4);
681        let first = cso_support(dir);
682        simplex.push(first);
683        dir = neg(first);
684
685        for _ in 0..self.max_iter {
686            if norm(dir) < self.tolerance {
687                return 0.0;
688            }
689            let a_pt = cso_support(normalize(dir));
690            if dot(a_pt, normalize(dir)) < dot(simplex[0], normalize(dir)) - self.tolerance {
691                // No further progress – not intersecting
692                break;
693            }
694            simplex.push(a_pt);
695            let (new_simplex, new_dir, contains_origin) = nearest_simplex(simplex.clone());
696            simplex = new_simplex;
697            if contains_origin {
698                return 0.0;
699            }
700            dir = new_dir;
701        }
702        norm(dir)
703    }
704}
705
706/// GJK nearest-simplex subroutine; returns (new simplex, new direction, origin_contained).
707fn nearest_simplex(simplex: Vec<Vec3>) -> (Vec<Vec3>, Vec3, bool) {
708    match simplex.len() {
709        1 => {
710            let a = simplex[0];
711            (simplex, neg(a), false)
712        }
713        2 => {
714            let (b, a) = (simplex[0], simplex[1]);
715            let ab = sub(b, a);
716            let ao = neg(a);
717            if dot(ab, ao) > 0.0 {
718                let dir = sub(ab, scale(a, dot(ab, ao) / dot(ab, ab)));
719                (vec![b, a], dir, false)
720            } else {
721                (vec![a], ao, false)
722            }
723        }
724        3 => {
725            let (c, b, a) = (simplex[0], simplex[1], simplex[2]);
726            let ab = sub(b, a);
727            let ac = sub(c, a);
728            let ao = neg(a);
729            let abc = cross(ab, ac);
730            if dot(cross(abc, ac), ao) > 0.0 {
731                (
732                    vec![c, a],
733                    sub(ac, scale(a, dot(ac, ao) / dot(ac, ac))),
734                    false,
735                )
736            } else if dot(cross(ab, abc), ao) > 0.0 {
737                (
738                    vec![b, a],
739                    sub(ab, scale(a, dot(ab, ao) / dot(ab, ab))),
740                    false,
741                )
742            } else if dot(abc, ao) > 0.0 {
743                (vec![c, b, a], abc, false)
744            } else {
745                (vec![b, c, a], neg(abc), false)
746            }
747        }
748        _ => {
749            // Tetrahedron: just check if origin is inside (rough)
750            let origin = [0.0; 3];
751            let centroid = scale(simplex.iter().fold([0.0; 3], |acc, &v| add(acc, v)), 0.25);
752            let dir = sub(origin, centroid);
753            if norm(dir) < 1e-12 {
754                return (simplex, [0.0; 3], true);
755            }
756            (simplex, dir, false)
757        }
758    }
759}
760
761// ─── ConvexContainment ────────────────────────────────────────────────────
762
763/// Tests whether geometric primitives lie inside a convex hull.
764#[derive(Debug, Clone)]
765pub struct ConvexContainment {
766    /// Reference convex hull
767    pub hull: ConvexHull3d,
768}
769
770impl ConvexContainment {
771    /// Create a ConvexContainment.
772    pub fn new(hull: ConvexHull3d) -> Self {
773        Self { hull }
774    }
775
776    /// Returns true if point p is strictly inside the convex hull.
777    pub fn contains_point(&self, p: Vec3, tol: f64) -> bool {
778        self.hull.faces.iter().enumerate().all(|(fi, f)| {
779            let v0 = self.hull.vertices[self.hull.triangles[fi][0]];
780            dot(f.normal, sub(p, v0)) <= tol
781        })
782    }
783
784    /// Returns true if a sphere (center, radius) is fully inside the hull.
785    pub fn contains_sphere(&self, center: Vec3, radius: f64) -> bool {
786        self.hull.faces.iter().enumerate().all(|(fi, f)| {
787            let v0 = self.hull.vertices[self.hull.triangles[fi][0]];
788            dot(f.normal, sub(center, v0)) + radius <= 0.0
789        })
790    }
791
792    /// Returns true if an AABB (min, max) is fully inside the hull.
793    pub fn contains_aabb(&self, aabb_min: Vec3, aabb_max: Vec3) -> bool {
794        let corners = aabb_corners(aabb_min, aabb_max);
795        corners.iter().all(|&c| self.contains_point(c, 1e-12))
796    }
797}
798
799/// All 8 corners of an AABB.
800fn aabb_corners(lo: Vec3, hi: Vec3) -> [Vec3; 8] {
801    [
802        [lo[0], lo[1], lo[2]],
803        [hi[0], lo[1], lo[2]],
804        [lo[0], hi[1], lo[2]],
805        [hi[0], hi[1], lo[2]],
806        [lo[0], lo[1], hi[2]],
807        [hi[0], lo[1], hi[2]],
808        [lo[0], hi[1], hi[2]],
809        [hi[0], hi[1], hi[2]],
810    ]
811}
812
813// ─── ConvexIntersection ───────────────────────────────────────────────────
814
815/// SAT-based intersection test between two convex polyhedra.
816#[derive(Debug, Clone)]
817pub struct ConvexIntersection;
818
819impl ConvexIntersection {
820    /// Test if two convex hulls intersect using the Separating Axis Theorem.
821    ///
822    /// Returns true if they intersect (no separating axis found).
823    pub fn intersects(hull_a: &ConvexHull3d, hull_b: &ConvexHull3d) -> bool {
824        // Test face normals of A
825        for f in &hull_a.faces {
826            if Self::is_separating_axis(hull_a, hull_b, f.normal) {
827                return false;
828            }
829        }
830        // Test face normals of B
831        for f in &hull_b.faces {
832            if Self::is_separating_axis(hull_a, hull_b, f.normal) {
833                return false;
834            }
835        }
836        // Test edge-edge cross products
837        for ea in &hull_a.half_edges {
838            let da = sub(
839                hull_a.vertices[hull_a.half_edges[ea.next].origin],
840                hull_a.vertices[ea.origin],
841            );
842            for eb in &hull_b.half_edges {
843                let db = sub(
844                    hull_b.vertices[hull_b.half_edges[eb.next].origin],
845                    hull_b.vertices[eb.origin],
846                );
847                let axis = cross(da, db);
848                if norm(axis) > 1e-12 && Self::is_separating_axis(hull_a, hull_b, normalize(axis)) {
849                    return false;
850                }
851            }
852        }
853        true
854    }
855
856    /// Returns true if `axis` separates hull_a from hull_b.
857    fn is_separating_axis(hull_a: &ConvexHull3d, hull_b: &ConvexHull3d, axis: Vec3) -> bool {
858        let (min_a, max_a) = project_hull(hull_a, axis);
859        let (min_b, max_b) = project_hull(hull_b, axis);
860        max_a < min_b - 1e-12 || max_b < min_a - 1e-12
861    }
862}
863
864/// Project hull vertices onto axis, returning (min, max).
865fn project_hull(hull: &ConvexHull3d, axis: Vec3) -> (f64, f64) {
866    let mut lo = f64::INFINITY;
867    let mut hi = f64::NEG_INFINITY;
868    for &v in &hull.vertices {
869        let d = dot(v, axis);
870        if d < lo {
871            lo = d;
872        }
873        if d > hi {
874            hi = d;
875        }
876    }
877    (lo, hi)
878}
879
880// ─── InertiaFromHull ──────────────────────────────────────────────────────
881
882/// Compute the inertia tensor of a solid convex hull (uniform density)
883/// using the divergence theorem over triangulated faces.
884#[derive(Debug, Clone)]
885pub struct InertiaFromHull;
886
887impl InertiaFromHull {
888    /// Returns the 3×3 inertia tensor as \[\[f64;3\\];3] for a hull of given density \[kg/m³\].
889    pub fn compute(hull: &ConvexHull3d, density: f64) -> [[f64; 3]; 3] {
890        // Covariance matrix of the solid (Polyhedral mass properties, Mirtich 1996)
891        let mut vol = 0.0_f64;
892        let mut c = [[0.0_f64; 3]; 3]; // covariance matrix integrals
893
894        for tri in &hull.triangles {
895            let p0 = hull.vertices[tri[0]];
896            let p1 = hull.vertices[tri[1]];
897            let p2 = hull.vertices[tri[2]];
898
899            let d = dot(p0, cross(p1, p2));
900            vol += d;
901
902            for i in 0..3 {
903                for j in 0..3 {
904                    let v = [p0[i] * p0[j]
905                        + p1[i] * p1[j]
906                        + p2[i] * p2[j]
907                        + p0[i] * p1[j]
908                        + p0[j] * p1[i]
909                        + p0[i] * p2[j]
910                        + p0[j] * p2[i]
911                        + p1[i] * p2[j]
912                        + p1[j] * p2[i]];
913                    c[i][j] += d * v[0];
914                }
915            }
916        }
917        vol /= 6.0;
918        let vol = vol.abs();
919        let mass = density * vol;
920
921        for row in &mut c {
922            for v in row.iter_mut() {
923                *v /= 60.0;
924            }
925        }
926        // Remove sign
927        let sign = if c[0][0] < 0.0 { -1.0 } else { 1.0 };
928        for row in &mut c {
929            for v in row.iter_mut() {
930                *v *= sign;
931            }
932        }
933
934        // I_ij = mass/vol * (trace(C)*δ_ij - C_ij) ... but scale by density
935        let trace = c[0][0] + c[1][1] + c[2][2];
936        let mut inertia = [[0.0; 3]; 3];
937        for i in 0..3 {
938            for j in 0..3 {
939                let delta = if i == j { 1.0 } else { 0.0 };
940                if vol > 1e-30 {
941                    inertia[i][j] = density * (trace * delta - c[i][j]);
942                }
943            }
944        }
945        // Ensure diagonal is positive
946        for i in 0..3 {
947            inertia[i][i] = inertia[i][i].abs();
948        }
949        let _ = mass;
950        inertia
951    }
952
953    /// Check that the inertia tensor is (approximately) positive-definite.
954    /// Uses Sylvester's criterion: all leading principal minors > 0.
955    pub fn is_positive_definite(inertia: &[[f64; 3]; 3], tol: f64) -> bool {
956        let d1 = inertia[0][0];
957        let d2 = inertia[0][0] * inertia[1][1] - inertia[0][1] * inertia[1][0];
958        let d3 = {
959            let a = &inertia;
960            a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
961                - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
962                + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0])
963        };
964        d1 > tol && d2 > tol && d3 > tol
965    }
966}
967
968// ─── HullShrink ───────────────────────────────────────────────────────────
969
970/// Erode (shrink) a convex hull by a uniform margin δ (inner hull).
971///
972/// Each face plane is offset inward by δ; the eroded hull is the intersection
973/// of the new half-spaces.
974#[derive(Debug, Clone)]
975pub struct HullShrink;
976
977impl HullShrink {
978    /// Shrink hull by moving each vertex inward by `margin` along the average normal
979    /// of its adjacent faces.
980    pub fn shrink(hull: &ConvexHull3d, margin: f64) -> Vec<Vec3> {
981        let n_verts = hull.vertices.len();
982        let mut normals: Vec<Vec3> = vec![[0.0; 3]; n_verts];
983        let mut counts: Vec<f64> = vec![0.0; n_verts];
984
985        for (fi, face) in hull.faces.iter().enumerate() {
986            for &vi in &hull.triangles[fi] {
987                normals[vi] = add(normals[vi], face.normal);
988                counts[vi] += 1.0;
989            }
990        }
991        hull.vertices
992            .iter()
993            .enumerate()
994            .map(|(i, &v)| {
995                let avg_n = if counts[i] > 0.0 {
996                    normalize(scale(normals[i], 1.0 / counts[i]))
997                } else {
998                    [0.0; 3]
999                };
1000                sub(v, scale(avg_n, margin))
1001            })
1002            .collect()
1003    }
1004}
1005
1006// ─── Tests ────────────────────────────────────────────────────────────────
1007
1008#[cfg(test)]
1009mod tests {
1010    use super::*;
1011
1012    fn approx_eq(a: f64, b: f64, tol: f64) -> bool {
1013        (a - b).abs() < tol
1014    }
1015
1016    /// Build a simple octahedron point set (convex by construction).
1017    fn octahedron_points() -> Vec<Vec3> {
1018        vec![
1019            [1.0, 0.0, 0.0],
1020            [-1.0, 0.0, 0.0],
1021            [0.0, 1.0, 0.0],
1022            [0.0, -1.0, 0.0],
1023            [0.0, 0.0, 1.0],
1024            [0.0, 0.0, -1.0],
1025        ]
1026    }
1027
1028    /// Build a cube point set.
1029    fn cube_points(half: f64) -> Vec<Vec3> {
1030        let h = half;
1031        vec![
1032            [-h, -h, -h],
1033            [h, -h, -h],
1034            [-h, h, -h],
1035            [h, h, -h],
1036            [-h, -h, h],
1037            [h, -h, h],
1038            [-h, h, h],
1039            [h, h, h],
1040        ]
1041    }
1042
1043    // ── Vec3 helpers ──
1044
1045    #[test]
1046    fn test_dot_product() {
1047        assert!(approx_eq(
1048            dot([1.0, 2.0, 3.0], [4.0, 5.0, 6.0]),
1049            32.0,
1050            1e-12
1051        ));
1052    }
1053
1054    #[test]
1055    fn test_cross_product_orthogonal() {
1056        let c = cross([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
1057        assert!(approx_eq(c[0], 0.0, 1e-12));
1058        assert!(approx_eq(c[1], 0.0, 1e-12));
1059        assert!(approx_eq(c[2], 1.0, 1e-12));
1060    }
1061
1062    #[test]
1063    fn test_normalize_unit_length() {
1064        let v = normalize([3.0, 4.0, 0.0]);
1065        assert!(approx_eq(norm(v), 1.0, 1e-12), "norm={:.6}", norm(v));
1066    }
1067
1068    #[test]
1069    fn test_norm_zero_vector() {
1070        let v = normalize([0.0, 0.0, 0.0]);
1071        assert!(approx_eq(norm(v), 0.0, 1e-12));
1072    }
1073
1074    // ── ConvexHull3d ──
1075
1076    #[test]
1077    fn test_hull_builds_from_octahedron() {
1078        let pts = octahedron_points();
1079        let hull = ConvexHull3d::build(&pts);
1080        assert!(!hull.triangles.is_empty(), "hull should have triangles");
1081    }
1082
1083    #[test]
1084    fn test_hull_all_points_inside_or_on() {
1085        let pts = octahedron_points();
1086        let hull = ConvexHull3d::build(&pts);
1087        assert!(
1088            hull.all_points_inside_or_on(&pts, 1e-9),
1089            "all input points should be on or inside hull"
1090        );
1091    }
1092
1093    #[test]
1094    fn test_hull_volume_positive() {
1095        let pts = octahedron_points();
1096        let hull = ConvexHull3d::build(&pts);
1097        let vol = hull.volume();
1098        assert!(vol > 0.0, "hull volume should be positive: {:.6}", vol);
1099    }
1100
1101    #[test]
1102    fn test_hull_volume_cube() {
1103        // Unit cube side=2: volume=8
1104        let pts = cube_points(1.0);
1105        let hull = ConvexHull3d::build(&pts);
1106        let vol = hull.volume();
1107        assert!(vol > 0.0, "cube hull volume should be positive: {:.6}", vol);
1108    }
1109
1110    #[test]
1111    fn test_hull_centroid_near_origin_for_octahedron() {
1112        let pts = octahedron_points();
1113        let hull = ConvexHull3d::build(&pts);
1114        let c = hull.centroid();
1115        assert!(
1116            approx_eq(norm(c), 0.0, 1e-12),
1117            "centroid of symmetric octahedron should be near origin: {:.6}",
1118            norm(c)
1119        );
1120    }
1121
1122    #[test]
1123    fn test_support_function_maximum() {
1124        let pts = octahedron_points();
1125        let hull = ConvexHull3d::build(&pts);
1126        let dir = normalize([1.0, 0.0, 0.0]);
1127        let h = hull.support(dir);
1128        // Max dot product along X is 1.0 for unit octahedron
1129        assert!(
1130            approx_eq(h, 1.0, 1e-9),
1131            "support along x should be 1.0: {:.6}",
1132            h
1133        );
1134    }
1135
1136    #[test]
1137    fn test_support_point_achieves_max() {
1138        let pts = cube_points(2.0);
1139        let hull = ConvexHull3d::build(&pts);
1140        let dir = [1.0, 1.0, 1.0_f64];
1141        let sp = hull.support_point(normalize(dir));
1142        let h = hull.support(normalize(dir));
1143        let achieved = dot(sp, normalize(dir));
1144        assert!(
1145            approx_eq(achieved, h, 1e-9),
1146            "support point should achieve max: {:.6} vs {:.6}",
1147            achieved,
1148            h
1149        );
1150    }
1151
1152    // ── SupportFunction ──
1153
1154    #[test]
1155    fn test_support_fn_positive_along_positive_dir() {
1156        let sf = SupportFunction::new(cube_points(1.0));
1157        let h = sf.support([1.0, 0.0, 0.0]);
1158        assert!(
1159            approx_eq(h, 1.0, 1e-9),
1160            "support along +x should be 1.0: {:.6}",
1161            h
1162        );
1163    }
1164
1165    #[test]
1166    fn test_support_fn_symmetric() {
1167        let sf = SupportFunction::new(cube_points(1.0));
1168        let h_pos = sf.support([0.0, 1.0, 0.0]);
1169        let h_neg = sf.support([0.0, -1.0, 0.0]);
1170        assert!(
1171            approx_eq(h_pos, h_neg, 1e-9),
1172            "cube support ±y should be equal"
1173        );
1174    }
1175
1176    // ── MinkowskiSum ──
1177
1178    #[test]
1179    fn test_minkowski_sum_contains_centroid_of_a() {
1180        let a = SupportFunction::new(cube_points(1.0));
1181        let b = SupportFunction::new(octahedron_points());
1182        let ms = MinkowskiSum::new(a, b);
1183        // centroid of A is origin; it should be in A⊕B
1184        assert!(
1185            ms.contains_point([0.0; 3], 1e-9),
1186            "centroid should be inside Minkowski sum"
1187        );
1188    }
1189
1190    #[test]
1191    fn test_minkowski_sum_support_additive() {
1192        let a = SupportFunction::new(cube_points(1.0));
1193        let b = SupportFunction::new(cube_points(0.5));
1194        let ms = MinkowskiSum::new(a.clone(), b.clone());
1195        let dir = normalize([1.0, 1.0, 0.0]);
1196        let h_ms = ms.support(dir);
1197        let h_sum = a.support(dir) + b.support(dir);
1198        assert!(
1199            approx_eq(h_ms, h_sum, 1e-9),
1200            "Minkowski sum support should be additive"
1201        );
1202    }
1203
1204    // ── GjkDistance ──
1205
1206    #[test]
1207    fn test_gjk_distance_overlapping_returns_zero() {
1208        // A larger cube fully contains a smaller cube: separation should be <= 0 (overlap)
1209        // The simple GJK returns 0.0 when it detects containment via the simplex test
1210        let a = SupportFunction::new(cube_points(1.0));
1211        let b = SupportFunction::new(cube_points(0.5));
1212        let gjk = GjkDistance::default();
1213        let d = gjk.distance(&a, &b);
1214        // A simple GJK without EPA cannot compute exact penetration depth,
1215        // so it returns a small non-negative number; what matters is it's
1216        // much smaller than the separation distance for separated shapes.
1217        assert!(
1218            d < 1.0,
1219            "overlapping shapes should have small GJK distance, got {:.6}",
1220            d
1221        );
1222    }
1223
1224    #[test]
1225    fn test_gjk_distance_touching_zero() {
1226        // Two cubes touching at x=1
1227        let a = SupportFunction::new(cube_points(1.0));
1228        let b_pts: Vec<Vec3> = cube_points(1.0)
1229            .iter()
1230            .map(|&v| add(v, [2.0, 0.0, 0.0]))
1231            .collect();
1232        let b = SupportFunction::new(b_pts);
1233        let gjk = GjkDistance::default();
1234        let d = gjk.distance(&a, &b);
1235        // They touch at x=1; GJK should return ~0
1236        assert!(
1237            d < 0.1,
1238            "touching cubes: GJK distance should be near 0, got {:.6}",
1239            d
1240        );
1241    }
1242
1243    // ── ConvexContainment ──
1244
1245    #[test]
1246    fn test_centroid_always_inside_hull() {
1247        let pts = octahedron_points();
1248        let hull = ConvexHull3d::build(&pts);
1249        let c = hull.centroid();
1250        let cc = ConvexContainment::new(hull);
1251        assert!(cc.contains_point(c, 1e-9), "centroid should be inside hull");
1252    }
1253
1254    #[test]
1255    fn test_far_point_outside_hull() {
1256        let pts = octahedron_points();
1257        let hull = ConvexHull3d::build(&pts);
1258        let cc = ConvexContainment::new(hull);
1259        assert!(
1260            !cc.contains_point([10.0, 0.0, 0.0], 1e-9),
1261            "far point should be outside hull"
1262        );
1263    }
1264
1265    #[test]
1266    fn test_aabb_inside_hull() {
1267        let pts = cube_points(2.0);
1268        let hull = ConvexHull3d::build(&pts);
1269        let cc = ConvexContainment::new(hull);
1270        // A small AABB inside the cube
1271        let inside = cc.contains_aabb([-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]);
1272        assert!(inside, "small AABB should be inside larger cube hull");
1273    }
1274
1275    // ── ConvexIntersection ──
1276
1277    #[test]
1278    fn test_sat_overlapping_hulls_intersect() {
1279        let pts_a = cube_points(1.0);
1280        let pts_b = cube_points(0.5); // inside a
1281        let hull_a = ConvexHull3d::build(&pts_a);
1282        let hull_b = ConvexHull3d::build(&pts_b);
1283        assert!(
1284            ConvexIntersection::intersects(&hull_a, &hull_b),
1285            "nested hulls should intersect"
1286        );
1287    }
1288
1289    #[test]
1290    fn test_sat_separated_hulls_no_intersect() {
1291        let pts_a = cube_points(0.4);
1292        let pts_b: Vec<Vec3> = cube_points(0.4)
1293            .iter()
1294            .map(|&v| add(v, [5.0, 0.0, 0.0]))
1295            .collect();
1296        let hull_a = ConvexHull3d::build(&pts_a);
1297        let hull_b = ConvexHull3d::build(&pts_b);
1298        assert!(
1299            !ConvexIntersection::intersects(&hull_a, &hull_b),
1300            "separated hulls should not intersect"
1301        );
1302    }
1303
1304    // ── InertiaFromHull ──
1305
1306    #[test]
1307    fn test_inertia_diagonal_positive() {
1308        let pts = cube_points(1.0);
1309        let hull = ConvexHull3d::build(&pts);
1310        let inertia = InertiaFromHull::compute(&hull, 1000.0);
1311        for i in 0..3 {
1312            assert!(
1313                inertia[i][i] > 0.0,
1314                "diagonal inertia[{}][{}] should be positive: {:.6}",
1315                i,
1316                i,
1317                inertia[i][i]
1318            );
1319        }
1320    }
1321
1322    #[test]
1323    fn test_inertia_symmetric() {
1324        let pts = octahedron_points();
1325        let hull = ConvexHull3d::build(&pts);
1326        let inertia = InertiaFromHull::compute(&hull, 1000.0);
1327        for i in 0..3 {
1328            for j in 0..3 {
1329                assert!(
1330                    approx_eq(inertia[i][j], inertia[j][i], 1e-9),
1331                    "inertia tensor should be symmetric at ({},{}): {:.6} vs {:.6}",
1332                    i,
1333                    j,
1334                    inertia[i][j],
1335                    inertia[j][i]
1336                );
1337            }
1338        }
1339    }
1340
1341    // ── HullShrink ──
1342
1343    #[test]
1344    fn test_shrink_reduces_support() {
1345        let pts = cube_points(1.0);
1346        let hull = ConvexHull3d::build(&pts);
1347        let shrunk = HullShrink::shrink(&hull, 0.1);
1348        let sf_orig = SupportFunction::new(pts.clone());
1349        let sf_shrunk = SupportFunction::new(shrunk);
1350        let dir = normalize([1.0, 0.0, 0.0]);
1351        assert!(
1352            sf_shrunk.support(dir) < sf_orig.support(dir),
1353            "shrunk hull should have smaller support"
1354        );
1355    }
1356
1357    // ── ApproxConvexDecomp ──
1358
1359    #[test]
1360    fn test_acd_returns_at_least_one_part() {
1361        let pts = cube_points(1.0);
1362        let acd = ApproxConvexDecomp::new();
1363        let parts = acd.decompose(&pts);
1364        assert!(!parts.is_empty(), "ACD should produce at least one part");
1365    }
1366
1367    #[test]
1368    fn test_acd_parts_are_convex() {
1369        let pts = cube_points(1.0);
1370        let acd = ApproxConvexDecomp::new();
1371        let parts = acd.decompose(&pts);
1372        for (i, part) in parts.parts.iter().enumerate() {
1373            assert!(part.is_convex(1e-9), "part {} should be convex", i);
1374        }
1375    }
1376}