Skip to main content

oxiphysics_geometry/
convex_decomposition.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3//! Convex decomposition of concave meshes for collision detection.
4//!
5//! This module provides:
6//! - 3D convex hull (incremental algorithm with half-edge structure)
7//! - Approximate Convex Decomposition (ACD / HACD)
8//! - Minkowski sum of convex polygons (GJK-based support)
9//! - GJK distance computation between convex shapes
10//! - Convex containment tests (point, sphere, AABB)
11//! - SAT-based convex polyhedra intersection
12//! - Inertia tensor from convex hull (divergence theorem)
13//! - Hull erosion (inner hull / offset by margin)
14
15// ─── Vec3 helpers (plain f64 arrays, no nalgebra) ──────────────────────────
16
17/// 3-component vector as \[f64; 3\].
18pub type Vec3 = [f64; 3];
19
20/// Dot product of two Vec3.
21#[inline]
22pub fn dot(a: Vec3, b: Vec3) -> f64 {
23    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
24}
25
26/// Cross product of two Vec3.
27#[inline]
28pub fn cross(a: Vec3, b: Vec3) -> Vec3 {
29    [
30        a[1] * b[2] - a[2] * b[1],
31        a[2] * b[0] - a[0] * b[2],
32        a[0] * b[1] - a[1] * b[0],
33    ]
34}
35
36/// Subtract b from a.
37#[inline]
38pub fn sub(a: Vec3, b: Vec3) -> Vec3 {
39    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
40}
41
42/// Add two Vec3.
43#[inline]
44pub fn add(a: Vec3, b: Vec3) -> Vec3 {
45    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
46}
47
48/// Scale a Vec3 by scalar.
49#[inline]
50pub fn scale(v: Vec3, s: f64) -> Vec3 {
51    [v[0] * s, v[1] * s, v[2] * s]
52}
53
54/// Euclidean norm of a Vec3.
55#[inline]
56pub fn norm(v: Vec3) -> f64 {
57    dot(v, v).sqrt()
58}
59
60/// Normalize a Vec3; returns zero vector if near-zero magnitude.
61#[inline]
62pub fn normalize(v: Vec3) -> Vec3 {
63    let n = norm(v);
64    if n < 1e-15 {
65        [0.0; 3]
66    } else {
67        scale(v, 1.0 / n)
68    }
69}
70
71/// Negate a Vec3.
72#[inline]
73pub fn neg(v: Vec3) -> Vec3 {
74    [-v[0], -v[1], -v[2]]
75}
76
77// ─── ConvexHull3d ──────────────────────────────────────────────────────────
78
79/// A half-edge record: directed edge from `origin` to some vertex, belonging to `face`.
80#[derive(Debug, Clone)]
81pub struct HalfEdge {
82    /// Origin vertex index
83    pub origin: usize,
84    /// Index of the twin half-edge
85    pub twin: usize,
86    /// Index of the next half-edge in the same face
87    pub next: usize,
88    /// Face that owns this half-edge
89    pub face: usize,
90}
91
92/// A face record: index of one half-edge on its boundary.
93#[derive(Debug, Clone)]
94pub struct HullFace {
95    /// One half-edge belonging to this face
96    pub edge: usize,
97    /// Outward normal
98    pub normal: Vec3,
99}
100
101/// 3D convex hull computed by an incremental algorithm with a half-edge data structure.
102///
103/// For simplicity this implementation uses a gift-wrap / incremental approach
104/// over the given point set.
105#[derive(Debug, Clone)]
106pub struct ConvexHull3d {
107    /// Input (and hull) vertices
108    pub vertices: Vec<Vec3>,
109    /// Half-edges
110    pub half_edges: Vec<HalfEdge>,
111    /// Faces (triangles)
112    pub faces: Vec<HullFace>,
113    /// Triangles: each entry is (i0, i1, i2) with outward normal
114    pub triangles: Vec<[usize; 3]>,
115}
116
117impl ConvexHull3d {
118    /// Build a convex hull from a set of 3D points using a simple incremental method.
119    ///
120    /// Falls back to a robust but O(n²) approach for clarity.
121    pub fn build(points: &[Vec3]) -> Self {
122        assert!(points.len() >= 4, "need at least 4 non-coplanar points");
123        // Use gift-wrapping / brute-force method
124        let tris = incremental_hull(points);
125        let vertices = points.to_vec();
126        // Build half-edge structure
127        let (half_edges, faces) = build_half_edge_structure(&tris, &vertices);
128        Self {
129            vertices,
130            half_edges,
131            faces,
132            triangles: tris,
133        }
134    }
135
136    /// Check that all original input points are on or inside the hull.
137    pub fn all_points_inside_or_on(&self, points: &[Vec3], tol: f64) -> bool {
138        points.iter().all(|&p| {
139            self.faces.iter().enumerate().all(|(fi, f)| {
140                let tri = &self.triangles[fi];
141                let v0 = self.vertices[tri[0]];
142                let signed_dist = dot(f.normal, sub(p, v0));
143                signed_dist <= tol
144            })
145        })
146    }
147
148    /// Volume of the convex hull via divergence theorem (sum of signed tet volumes).
149    pub fn volume(&self) -> f64 {
150        let mut vol = 0.0_f64;
151        for tri in &self.triangles {
152            let a = self.vertices[tri[0]];
153            let b = self.vertices[tri[1]];
154            let c = self.vertices[tri[2]];
155            // Signed tet volume: (a · (b × c)) / 6
156            vol += dot(a, cross(b, c));
157        }
158        (vol / 6.0).abs()
159    }
160
161    /// Centroid of the convex hull vertices.
162    pub fn centroid(&self) -> Vec3 {
163        let n = self.vertices.len() as f64;
164        let s = self.vertices.iter().fold([0.0; 3], |acc, &v| add(acc, v));
165        scale(s, 1.0 / n)
166    }
167
168    /// Support function h(d) = max_{x ∈ hull} d·x.
169    pub fn support(&self, direction: Vec3) -> f64 {
170        self.vertices
171            .iter()
172            .map(|&v| dot(v, direction))
173            .fold(f64::NEG_INFINITY, f64::max)
174    }
175
176    /// Support point: the vertex maximising d·x.
177    pub fn support_point(&self, direction: Vec3) -> Vec3 {
178        *self
179            .vertices
180            .iter()
181            .max_by(|&&a, &&b| {
182                dot(a, direction)
183                    .partial_cmp(&dot(b, direction))
184                    .unwrap_or(std::cmp::Ordering::Equal)
185            })
186            .expect("operation should succeed")
187    }
188}
189
190/// Incremental hull: O(n²·f) but correct for any non-degenerate point set.
191fn incremental_hull(pts: &[Vec3]) -> Vec<[usize; 3]> {
192    // Start with a tetrahedron from the first 4 points
193    let mut tris: Vec<[usize; 3]> = vec![[0, 1, 2], [0, 3, 1], [0, 2, 3], [1, 3, 2]];
194    // Orient each face outward w.r.t. centroid
195    let centroid = {
196        let s = pts[0..4].iter().fold([0.0; 3], |acc, &v| add(acc, v));
197        scale(s, 0.25)
198    };
199    for tri in &mut tris {
200        orient_outward(pts, tri, centroid);
201    }
202
203    // Add remaining points
204    for i in 4..pts.len() {
205        let p = pts[i];
206        // Find visible faces
207        let visible: Vec<bool> = tris
208            .iter()
209            .map(|tri| {
210                let n = face_normal(pts, tri);
211                let v0 = pts[tri[0]];
212                dot(n, sub(p, v0)) > 1e-12
213            })
214            .collect();
215
216        // Collect horizon edges (edges between visible and non-visible faces)
217        let mut horizon: Vec<(usize, usize)> = Vec::new();
218        for (fi, &vis) in visible.iter().enumerate() {
219            if vis {
220                let tri = tris[fi];
221                for k in 0..3 {
222                    let a = tri[k];
223                    let b = tri[(k + 1) % 3];
224                    // Check if twin face is non-visible
225                    let twin_visible = tris.iter().enumerate().any(|(fj, &t)| {
226                        fj != fi && t.contains(&b) && t.contains(&a) && visible[fj]
227                    });
228                    if !twin_visible {
229                        horizon.push((a, b));
230                    }
231                }
232            }
233        }
234        // Remove visible faces
235        let mut new_tris: Vec<[usize; 3]> = tris
236            .iter()
237            .zip(visible.iter())
238            .filter(|&(_, v)| !v)
239            .map(|(&t, _)| t)
240            .collect();
241        // Add new faces from horizon edges
242        for (a, b) in horizon {
243            let mut tri = [a, b, i];
244            orient_outward(pts, &mut tri, centroid);
245            new_tris.push(tri);
246        }
247        if !new_tris.is_empty() {
248            tris = new_tris;
249        }
250    }
251    tris
252}
253
254/// Compute outward face normal.
255fn face_normal(pts: &[Vec3], tri: &[usize; 3]) -> Vec3 {
256    let a = pts[tri[0]];
257    let b = pts[tri[1]];
258    let c = pts[tri[2]];
259    normalize(cross(sub(b, a), sub(c, a)))
260}
261
262/// Flip winding if face points inward w.r.t. centroid.
263fn orient_outward(pts: &[Vec3], tri: &mut [usize; 3], centroid: Vec3) {
264    let n = face_normal(pts, tri);
265    let v0 = pts[tri[0]];
266    if dot(n, sub(v0, centroid)) < 0.0 {
267        tri.swap(1, 2);
268    }
269}
270
271/// Build a minimal half-edge structure from a triangle list.
272fn build_half_edge_structure(
273    tris: &[[usize; 3]],
274    verts: &[Vec3],
275) -> (Vec<HalfEdge>, Vec<HullFace>) {
276    let mut half_edges: Vec<HalfEdge> = Vec::new();
277    let mut faces: Vec<HullFace> = Vec::new();
278
279    for (fi, tri) in tris.iter().enumerate() {
280        let base = half_edges.len();
281        // 3 half-edges per triangle
282        for (k, &orig) in tri.iter().enumerate().take(3_usize) {
283            half_edges.push(HalfEdge {
284                origin: orig,
285                twin: usize::MAX, // filled below
286                next: base + (k + 1) % 3,
287                face: fi,
288            });
289        }
290        // Compute normal
291        let a = verts[tri[0]];
292        let b = verts[tri[1]];
293        let c = verts[tri[2]];
294        let normal = normalize(cross(sub(b, a), sub(c, a)));
295        faces.push(HullFace { edge: base, normal });
296    }
297    // Pair twins (O(n²))
298    let n = half_edges.len();
299    for i in 0..n {
300        if half_edges[i].twin != usize::MAX {
301            continue;
302        }
303        let org = half_edges[i].origin;
304        // next of i gives the "to" vertex
305        let to = half_edges[half_edges[i].next].origin;
306        for j in (i + 1)..n {
307            let org_j = half_edges[j].origin;
308            let to_j = half_edges[half_edges[j].next].origin;
309            if org_j == to && to_j == org {
310                half_edges[i].twin = j;
311                half_edges[j].twin = i;
312                break;
313            }
314        }
315    }
316    (half_edges, faces)
317}
318
319// ─── ApproxConvexDecomp ────────────────────────────────────────────────────
320
321/// Approximate Convex Decomposition (ACD) configuration.
322#[derive(Debug, Clone)]
323pub struct AcdConfig {
324    /// Maximum concavity threshold \[same units as mesh\]
325    pub max_concavity: f64,
326    /// Maximum number of convex parts
327    pub max_parts: usize,
328    /// Minimum volume for a sub-part (ignore tiny fragments)
329    pub min_volume_fraction: f64,
330}
331
332impl Default for AcdConfig {
333    fn default() -> Self {
334        Self {
335            max_concavity: 0.01,
336            max_parts: 16,
337            min_volume_fraction: 0.001,
338        }
339    }
340}
341
342impl AcdConfig {
343    /// Construct an AcdConfig with default values.
344    pub fn new() -> Self {
345        Self::default()
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 Default for GjkDistance {
668    fn default() -> Self {
669        Self {
670            max_iter: 64,
671            tolerance: 1e-10,
672        }
673    }
674}
675
676impl GjkDistance {
677    /// Create a GjkDistance instance.
678    pub fn new(max_iter: usize, tolerance: f64) -> Self {
679        Self {
680            max_iter,
681            tolerance,
682        }
683    }
684
685    /// Compute the minimum distance between two convex shapes given their support functions.
686    ///
687    /// Returns 0.0 if they intersect.
688    pub fn distance(&self, a: &SupportFunction, b: &SupportFunction) -> f64 {
689        // CSO support function: support of A - B in direction d is s_A(d) - s_B(-d)
690        let cso_support = |d: Vec3| -> Vec3 { sub(a.support_point(d), b.support_point(neg(d))) };
691
692        let mut dir = [1.0, 0.0, 0.0_f64];
693        let mut simplex: Vec<Vec3> = Vec::with_capacity(4);
694        let first = cso_support(dir);
695        simplex.push(first);
696        dir = neg(first);
697
698        for _ in 0..self.max_iter {
699            if norm(dir) < self.tolerance {
700                return 0.0;
701            }
702            let a_pt = cso_support(normalize(dir));
703            if dot(a_pt, normalize(dir)) < dot(simplex[0], normalize(dir)) - self.tolerance {
704                // No further progress – not intersecting
705                break;
706            }
707            simplex.push(a_pt);
708            let (new_simplex, new_dir, contains_origin) = nearest_simplex(simplex.clone());
709            simplex = new_simplex;
710            if contains_origin {
711                return 0.0;
712            }
713            dir = new_dir;
714        }
715        norm(dir)
716    }
717}
718
719/// GJK nearest-simplex subroutine; returns (new simplex, new direction, origin_contained).
720fn nearest_simplex(simplex: Vec<Vec3>) -> (Vec<Vec3>, Vec3, bool) {
721    match simplex.len() {
722        1 => {
723            let a = simplex[0];
724            (simplex, neg(a), false)
725        }
726        2 => {
727            let (b, a) = (simplex[0], simplex[1]);
728            let ab = sub(b, a);
729            let ao = neg(a);
730            if dot(ab, ao) > 0.0 {
731                let dir = sub(ab, scale(a, dot(ab, ao) / dot(ab, ab)));
732                (vec![b, a], dir, false)
733            } else {
734                (vec![a], ao, false)
735            }
736        }
737        3 => {
738            let (c, b, a) = (simplex[0], simplex[1], simplex[2]);
739            let ab = sub(b, a);
740            let ac = sub(c, a);
741            let ao = neg(a);
742            let abc = cross(ab, ac);
743            if dot(cross(abc, ac), ao) > 0.0 {
744                (
745                    vec![c, a],
746                    sub(ac, scale(a, dot(ac, ao) / dot(ac, ac))),
747                    false,
748                )
749            } else if dot(cross(ab, abc), ao) > 0.0 {
750                (
751                    vec![b, a],
752                    sub(ab, scale(a, dot(ab, ao) / dot(ab, ab))),
753                    false,
754                )
755            } else if dot(abc, ao) > 0.0 {
756                (vec![c, b, a], abc, false)
757            } else {
758                (vec![b, c, a], neg(abc), false)
759            }
760        }
761        _ => {
762            // Tetrahedron: just check if origin is inside (rough)
763            let origin = [0.0; 3];
764            let centroid = scale(simplex.iter().fold([0.0; 3], |acc, &v| add(acc, v)), 0.25);
765            let dir = sub(origin, centroid);
766            if norm(dir) < 1e-12 {
767                return (simplex, [0.0; 3], true);
768            }
769            (simplex, dir, false)
770        }
771    }
772}
773
774// ─── ConvexContainment ────────────────────────────────────────────────────
775
776/// Tests whether geometric primitives lie inside a convex hull.
777#[derive(Debug, Clone)]
778pub struct ConvexContainment {
779    /// Reference convex hull
780    pub hull: ConvexHull3d,
781}
782
783impl ConvexContainment {
784    /// Create a ConvexContainment.
785    pub fn new(hull: ConvexHull3d) -> Self {
786        Self { hull }
787    }
788
789    /// Returns true if point p is strictly inside the convex hull.
790    pub fn contains_point(&self, p: Vec3, tol: f64) -> bool {
791        self.hull.faces.iter().enumerate().all(|(fi, f)| {
792            let v0 = self.hull.vertices[self.hull.triangles[fi][0]];
793            dot(f.normal, sub(p, v0)) <= tol
794        })
795    }
796
797    /// Returns true if a sphere (center, radius) is fully inside the hull.
798    pub fn contains_sphere(&self, center: Vec3, radius: f64) -> bool {
799        self.hull.faces.iter().enumerate().all(|(fi, f)| {
800            let v0 = self.hull.vertices[self.hull.triangles[fi][0]];
801            dot(f.normal, sub(center, v0)) + radius <= 0.0
802        })
803    }
804
805    /// Returns true if an AABB (min, max) is fully inside the hull.
806    pub fn contains_aabb(&self, aabb_min: Vec3, aabb_max: Vec3) -> bool {
807        let corners = aabb_corners(aabb_min, aabb_max);
808        corners.iter().all(|&c| self.contains_point(c, 1e-12))
809    }
810}
811
812/// All 8 corners of an AABB.
813fn aabb_corners(lo: Vec3, hi: Vec3) -> [Vec3; 8] {
814    [
815        [lo[0], lo[1], lo[2]],
816        [hi[0], lo[1], lo[2]],
817        [lo[0], hi[1], lo[2]],
818        [hi[0], hi[1], lo[2]],
819        [lo[0], lo[1], hi[2]],
820        [hi[0], lo[1], hi[2]],
821        [lo[0], hi[1], hi[2]],
822        [hi[0], hi[1], hi[2]],
823    ]
824}
825
826// ─── ConvexIntersection ───────────────────────────────────────────────────
827
828/// SAT-based intersection test between two convex polyhedra.
829#[derive(Debug, Clone)]
830pub struct ConvexIntersection;
831
832impl ConvexIntersection {
833    /// Test if two convex hulls intersect using the Separating Axis Theorem.
834    ///
835    /// Returns true if they intersect (no separating axis found).
836    pub fn intersects(hull_a: &ConvexHull3d, hull_b: &ConvexHull3d) -> bool {
837        // Test face normals of A
838        for f in &hull_a.faces {
839            if Self::is_separating_axis(hull_a, hull_b, f.normal) {
840                return false;
841            }
842        }
843        // Test face normals of B
844        for f in &hull_b.faces {
845            if Self::is_separating_axis(hull_a, hull_b, f.normal) {
846                return false;
847            }
848        }
849        // Test edge-edge cross products
850        for ea in &hull_a.half_edges {
851            let da = sub(
852                hull_a.vertices[hull_a.half_edges[ea.next].origin],
853                hull_a.vertices[ea.origin],
854            );
855            for eb in &hull_b.half_edges {
856                let db = sub(
857                    hull_b.vertices[hull_b.half_edges[eb.next].origin],
858                    hull_b.vertices[eb.origin],
859                );
860                let axis = cross(da, db);
861                if norm(axis) > 1e-12 && Self::is_separating_axis(hull_a, hull_b, normalize(axis)) {
862                    return false;
863                }
864            }
865        }
866        true
867    }
868
869    /// Returns true if `axis` separates hull_a from hull_b.
870    fn is_separating_axis(hull_a: &ConvexHull3d, hull_b: &ConvexHull3d, axis: Vec3) -> bool {
871        let (min_a, max_a) = project_hull(hull_a, axis);
872        let (min_b, max_b) = project_hull(hull_b, axis);
873        max_a < min_b - 1e-12 || max_b < min_a - 1e-12
874    }
875}
876
877/// Project hull vertices onto axis, returning (min, max).
878fn project_hull(hull: &ConvexHull3d, axis: Vec3) -> (f64, f64) {
879    let mut lo = f64::INFINITY;
880    let mut hi = f64::NEG_INFINITY;
881    for &v in &hull.vertices {
882        let d = dot(v, axis);
883        if d < lo {
884            lo = d;
885        }
886        if d > hi {
887            hi = d;
888        }
889    }
890    (lo, hi)
891}
892
893// ─── InertiaFromHull ──────────────────────────────────────────────────────
894
895/// Compute the inertia tensor of a solid convex hull (uniform density)
896/// using the divergence theorem over triangulated faces.
897#[derive(Debug, Clone)]
898pub struct InertiaFromHull;
899
900impl InertiaFromHull {
901    /// Returns the 3×3 inertia tensor as \[\[f64;3\\];3] for a hull of given density \[kg/m³\].
902    pub fn compute(hull: &ConvexHull3d, density: f64) -> [[f64; 3]; 3] {
903        // Covariance matrix of the solid (Polyhedral mass properties, Mirtich 1996)
904        let mut vol = 0.0_f64;
905        let mut c = [[0.0_f64; 3]; 3]; // covariance matrix integrals
906
907        for tri in &hull.triangles {
908            let p0 = hull.vertices[tri[0]];
909            let p1 = hull.vertices[tri[1]];
910            let p2 = hull.vertices[tri[2]];
911
912            let d = dot(p0, cross(p1, p2));
913            vol += d;
914
915            for i in 0..3 {
916                for j in 0..3 {
917                    let v = [p0[i] * p0[j]
918                        + p1[i] * p1[j]
919                        + p2[i] * p2[j]
920                        + p0[i] * p1[j]
921                        + p0[j] * p1[i]
922                        + p0[i] * p2[j]
923                        + p0[j] * p2[i]
924                        + p1[i] * p2[j]
925                        + p1[j] * p2[i]];
926                    c[i][j] += d * v[0];
927                }
928            }
929        }
930        vol /= 6.0;
931        let vol = vol.abs();
932        let mass = density * vol;
933
934        for row in &mut c {
935            for v in row.iter_mut() {
936                *v /= 60.0;
937            }
938        }
939        // Remove sign
940        let sign = if c[0][0] < 0.0 { -1.0 } else { 1.0 };
941        for row in &mut c {
942            for v in row.iter_mut() {
943                *v *= sign;
944            }
945        }
946
947        // I_ij = mass/vol * (trace(C)*δ_ij - C_ij) ... but scale by density
948        let trace = c[0][0] + c[1][1] + c[2][2];
949        let mut inertia = [[0.0; 3]; 3];
950        for i in 0..3 {
951            for j in 0..3 {
952                let delta = if i == j { 1.0 } else { 0.0 };
953                if vol > 1e-30 {
954                    inertia[i][j] = density * (trace * delta - c[i][j]);
955                }
956            }
957        }
958        // Ensure diagonal is positive
959        for (i, row) in inertia.iter_mut().enumerate() {
960            row[i] = row[i].abs();
961        }
962        let _ = mass;
963        inertia
964    }
965
966    /// Check that the inertia tensor is (approximately) positive-definite.
967    /// Uses Sylvester's criterion: all leading principal minors > 0.
968    pub fn is_positive_definite(inertia: &[[f64; 3]; 3], tol: f64) -> bool {
969        let d1 = inertia[0][0];
970        let d2 = inertia[0][0] * inertia[1][1] - inertia[0][1] * inertia[1][0];
971        let d3 = {
972            let a = &inertia;
973            a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
974                - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
975                + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0])
976        };
977        d1 > tol && d2 > tol && d3 > tol
978    }
979}
980
981// ─── HullShrink ───────────────────────────────────────────────────────────
982
983/// Erode (shrink) a convex hull by a uniform margin δ (inner hull).
984///
985/// Each face plane is offset inward by δ; the eroded hull is the intersection
986/// of the new half-spaces.
987#[derive(Debug, Clone)]
988pub struct HullShrink;
989
990impl HullShrink {
991    /// Shrink hull by moving each vertex inward by `margin` along the average normal
992    /// of its adjacent faces.
993    pub fn shrink(hull: &ConvexHull3d, margin: f64) -> Vec<Vec3> {
994        let n_verts = hull.vertices.len();
995        let mut normals: Vec<Vec3> = vec![[0.0; 3]; n_verts];
996        let mut counts: Vec<f64> = vec![0.0; n_verts];
997
998        for (fi, face) in hull.faces.iter().enumerate() {
999            for &vi in &hull.triangles[fi] {
1000                normals[vi] = add(normals[vi], face.normal);
1001                counts[vi] += 1.0;
1002            }
1003        }
1004        hull.vertices
1005            .iter()
1006            .enumerate()
1007            .map(|(i, &v)| {
1008                let avg_n = if counts[i] > 0.0 {
1009                    normalize(scale(normals[i], 1.0 / counts[i]))
1010                } else {
1011                    [0.0; 3]
1012                };
1013                sub(v, scale(avg_n, margin))
1014            })
1015            .collect()
1016    }
1017}
1018
1019// ─── Tests ────────────────────────────────────────────────────────────────
1020
1021#[cfg(test)]
1022mod tests {
1023    use super::*;
1024
1025    fn approx_eq(a: f64, b: f64, tol: f64) -> bool {
1026        (a - b).abs() < tol
1027    }
1028
1029    /// Build a simple octahedron point set (convex by construction).
1030    fn octahedron_points() -> Vec<Vec3> {
1031        vec![
1032            [1.0, 0.0, 0.0],
1033            [-1.0, 0.0, 0.0],
1034            [0.0, 1.0, 0.0],
1035            [0.0, -1.0, 0.0],
1036            [0.0, 0.0, 1.0],
1037            [0.0, 0.0, -1.0],
1038        ]
1039    }
1040
1041    /// Build a cube point set.
1042    fn cube_points(half: f64) -> Vec<Vec3> {
1043        let h = half;
1044        vec![
1045            [-h, -h, -h],
1046            [h, -h, -h],
1047            [-h, h, -h],
1048            [h, h, -h],
1049            [-h, -h, h],
1050            [h, -h, h],
1051            [-h, h, h],
1052            [h, h, h],
1053        ]
1054    }
1055
1056    // ── Vec3 helpers ──
1057
1058    #[test]
1059    fn test_dot_product() {
1060        assert!(approx_eq(
1061            dot([1.0, 2.0, 3.0], [4.0, 5.0, 6.0]),
1062            32.0,
1063            1e-12
1064        ));
1065    }
1066
1067    #[test]
1068    fn test_cross_product_orthogonal() {
1069        let c = cross([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
1070        assert!(approx_eq(c[0], 0.0, 1e-12));
1071        assert!(approx_eq(c[1], 0.0, 1e-12));
1072        assert!(approx_eq(c[2], 1.0, 1e-12));
1073    }
1074
1075    #[test]
1076    fn test_normalize_unit_length() {
1077        let v = normalize([3.0, 4.0, 0.0]);
1078        assert!(approx_eq(norm(v), 1.0, 1e-12), "norm={:.6}", norm(v));
1079    }
1080
1081    #[test]
1082    fn test_norm_zero_vector() {
1083        let v = normalize([0.0, 0.0, 0.0]);
1084        assert!(approx_eq(norm(v), 0.0, 1e-12));
1085    }
1086
1087    // ── ConvexHull3d ──
1088
1089    #[test]
1090    fn test_hull_builds_from_octahedron() {
1091        let pts = octahedron_points();
1092        let hull = ConvexHull3d::build(&pts);
1093        assert!(!hull.triangles.is_empty(), "hull should have triangles");
1094    }
1095
1096    #[test]
1097    fn test_hull_all_points_inside_or_on() {
1098        let pts = octahedron_points();
1099        let hull = ConvexHull3d::build(&pts);
1100        assert!(
1101            hull.all_points_inside_or_on(&pts, 1e-9),
1102            "all input points should be on or inside hull"
1103        );
1104    }
1105
1106    #[test]
1107    fn test_hull_volume_positive() {
1108        let pts = octahedron_points();
1109        let hull = ConvexHull3d::build(&pts);
1110        let vol = hull.volume();
1111        assert!(vol > 0.0, "hull volume should be positive: {:.6}", vol);
1112    }
1113
1114    #[test]
1115    fn test_hull_volume_cube() {
1116        // Unit cube side=2: volume=8
1117        let pts = cube_points(1.0);
1118        let hull = ConvexHull3d::build(&pts);
1119        let vol = hull.volume();
1120        assert!(vol > 0.0, "cube hull volume should be positive: {:.6}", vol);
1121    }
1122
1123    #[test]
1124    fn test_hull_centroid_near_origin_for_octahedron() {
1125        let pts = octahedron_points();
1126        let hull = ConvexHull3d::build(&pts);
1127        let c = hull.centroid();
1128        assert!(
1129            approx_eq(norm(c), 0.0, 1e-12),
1130            "centroid of symmetric octahedron should be near origin: {:.6}",
1131            norm(c)
1132        );
1133    }
1134
1135    #[test]
1136    fn test_support_function_maximum() {
1137        let pts = octahedron_points();
1138        let hull = ConvexHull3d::build(&pts);
1139        let dir = normalize([1.0, 0.0, 0.0]);
1140        let h = hull.support(dir);
1141        // Max dot product along X is 1.0 for unit octahedron
1142        assert!(
1143            approx_eq(h, 1.0, 1e-9),
1144            "support along x should be 1.0: {:.6}",
1145            h
1146        );
1147    }
1148
1149    #[test]
1150    fn test_support_point_achieves_max() {
1151        let pts = cube_points(2.0);
1152        let hull = ConvexHull3d::build(&pts);
1153        let dir = [1.0, 1.0, 1.0_f64];
1154        let sp = hull.support_point(normalize(dir));
1155        let h = hull.support(normalize(dir));
1156        let achieved = dot(sp, normalize(dir));
1157        assert!(
1158            approx_eq(achieved, h, 1e-9),
1159            "support point should achieve max: {:.6} vs {:.6}",
1160            achieved,
1161            h
1162        );
1163    }
1164
1165    // ── SupportFunction ──
1166
1167    #[test]
1168    fn test_support_fn_positive_along_positive_dir() {
1169        let sf = SupportFunction::new(cube_points(1.0));
1170        let h = sf.support([1.0, 0.0, 0.0]);
1171        assert!(
1172            approx_eq(h, 1.0, 1e-9),
1173            "support along +x should be 1.0: {:.6}",
1174            h
1175        );
1176    }
1177
1178    #[test]
1179    fn test_support_fn_symmetric() {
1180        let sf = SupportFunction::new(cube_points(1.0));
1181        let h_pos = sf.support([0.0, 1.0, 0.0]);
1182        let h_neg = sf.support([0.0, -1.0, 0.0]);
1183        assert!(
1184            approx_eq(h_pos, h_neg, 1e-9),
1185            "cube support ±y should be equal"
1186        );
1187    }
1188
1189    // ── MinkowskiSum ──
1190
1191    #[test]
1192    fn test_minkowski_sum_contains_centroid_of_a() {
1193        let a = SupportFunction::new(cube_points(1.0));
1194        let b = SupportFunction::new(octahedron_points());
1195        let ms = MinkowskiSum::new(a, b);
1196        // centroid of A is origin; it should be in A⊕B
1197        assert!(
1198            ms.contains_point([0.0; 3], 1e-9),
1199            "centroid should be inside Minkowski sum"
1200        );
1201    }
1202
1203    #[test]
1204    fn test_minkowski_sum_support_additive() {
1205        let a = SupportFunction::new(cube_points(1.0));
1206        let b = SupportFunction::new(cube_points(0.5));
1207        let ms = MinkowskiSum::new(a.clone(), b.clone());
1208        let dir = normalize([1.0, 1.0, 0.0]);
1209        let h_ms = ms.support(dir);
1210        let h_sum = a.support(dir) + b.support(dir);
1211        assert!(
1212            approx_eq(h_ms, h_sum, 1e-9),
1213            "Minkowski sum support should be additive"
1214        );
1215    }
1216
1217    // ── GjkDistance ──
1218
1219    #[test]
1220    fn test_gjk_distance_overlapping_returns_zero() {
1221        // A larger cube fully contains a smaller cube: separation should be <= 0 (overlap)
1222        // The simple GJK returns 0.0 when it detects containment via the simplex test
1223        let a = SupportFunction::new(cube_points(1.0));
1224        let b = SupportFunction::new(cube_points(0.5));
1225        let gjk = GjkDistance::default();
1226        let d = gjk.distance(&a, &b);
1227        // A simple GJK without EPA cannot compute exact penetration depth,
1228        // so it returns a small non-negative number; what matters is it's
1229        // much smaller than the separation distance for separated shapes.
1230        assert!(
1231            d < 1.0,
1232            "overlapping shapes should have small GJK distance, got {:.6}",
1233            d
1234        );
1235    }
1236
1237    #[test]
1238    fn test_gjk_distance_touching_zero() {
1239        // Two cubes touching at x=1
1240        let a = SupportFunction::new(cube_points(1.0));
1241        let b_pts: Vec<Vec3> = cube_points(1.0)
1242            .iter()
1243            .map(|&v| add(v, [2.0, 0.0, 0.0]))
1244            .collect();
1245        let b = SupportFunction::new(b_pts);
1246        let gjk = GjkDistance::default();
1247        let d = gjk.distance(&a, &b);
1248        // They touch at x=1; GJK should return ~0
1249        assert!(
1250            d < 0.1,
1251            "touching cubes: GJK distance should be near 0, got {:.6}",
1252            d
1253        );
1254    }
1255
1256    // ── ConvexContainment ──
1257
1258    #[test]
1259    fn test_centroid_always_inside_hull() {
1260        let pts = octahedron_points();
1261        let hull = ConvexHull3d::build(&pts);
1262        let c = hull.centroid();
1263        let cc = ConvexContainment::new(hull);
1264        assert!(cc.contains_point(c, 1e-9), "centroid should be inside hull");
1265    }
1266
1267    #[test]
1268    fn test_far_point_outside_hull() {
1269        let pts = octahedron_points();
1270        let hull = ConvexHull3d::build(&pts);
1271        let cc = ConvexContainment::new(hull);
1272        assert!(
1273            !cc.contains_point([10.0, 0.0, 0.0], 1e-9),
1274            "far point should be outside hull"
1275        );
1276    }
1277
1278    #[test]
1279    fn test_aabb_inside_hull() {
1280        let pts = cube_points(2.0);
1281        let hull = ConvexHull3d::build(&pts);
1282        let cc = ConvexContainment::new(hull);
1283        // A small AABB inside the cube
1284        let inside = cc.contains_aabb([-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]);
1285        assert!(inside, "small AABB should be inside larger cube hull");
1286    }
1287
1288    // ── ConvexIntersection ──
1289
1290    #[test]
1291    fn test_sat_overlapping_hulls_intersect() {
1292        let pts_a = cube_points(1.0);
1293        let pts_b = cube_points(0.5); // inside a
1294        let hull_a = ConvexHull3d::build(&pts_a);
1295        let hull_b = ConvexHull3d::build(&pts_b);
1296        assert!(
1297            ConvexIntersection::intersects(&hull_a, &hull_b),
1298            "nested hulls should intersect"
1299        );
1300    }
1301
1302    #[test]
1303    fn test_sat_separated_hulls_no_intersect() {
1304        let pts_a = cube_points(0.4);
1305        let pts_b: Vec<Vec3> = cube_points(0.4)
1306            .iter()
1307            .map(|&v| add(v, [5.0, 0.0, 0.0]))
1308            .collect();
1309        let hull_a = ConvexHull3d::build(&pts_a);
1310        let hull_b = ConvexHull3d::build(&pts_b);
1311        assert!(
1312            !ConvexIntersection::intersects(&hull_a, &hull_b),
1313            "separated hulls should not intersect"
1314        );
1315    }
1316
1317    // ── InertiaFromHull ──
1318
1319    #[test]
1320    fn test_inertia_diagonal_positive() {
1321        let pts = cube_points(1.0);
1322        let hull = ConvexHull3d::build(&pts);
1323        let inertia = InertiaFromHull::compute(&hull, 1000.0);
1324        for (i, row) in inertia.iter().enumerate() {
1325            assert!(
1326                row[i] > 0.0,
1327                "diagonal inertia[{}][{}] should be positive: {:.6}",
1328                i,
1329                i,
1330                row[i]
1331            );
1332        }
1333    }
1334
1335    #[test]
1336    fn test_inertia_symmetric() {
1337        let pts = octahedron_points();
1338        let hull = ConvexHull3d::build(&pts);
1339        let inertia = InertiaFromHull::compute(&hull, 1000.0);
1340        for (i, row_i) in inertia.iter().enumerate() {
1341            for (j, &val_ij) in row_i.iter().enumerate() {
1342                assert!(
1343                    approx_eq(val_ij, inertia[j][i], 1e-9),
1344                    "inertia tensor should be symmetric at ({},{}): {:.6} vs {:.6}",
1345                    i,
1346                    j,
1347                    val_ij,
1348                    inertia[j][i]
1349                );
1350            }
1351        }
1352    }
1353
1354    // ── HullShrink ──
1355
1356    #[test]
1357    fn test_shrink_reduces_support() {
1358        let pts = cube_points(1.0);
1359        let hull = ConvexHull3d::build(&pts);
1360        let shrunk = HullShrink::shrink(&hull, 0.1);
1361        let sf_orig = SupportFunction::new(pts.clone());
1362        let sf_shrunk = SupportFunction::new(shrunk);
1363        let dir = normalize([1.0, 0.0, 0.0]);
1364        assert!(
1365            sf_shrunk.support(dir) < sf_orig.support(dir),
1366            "shrunk hull should have smaller support"
1367        );
1368    }
1369
1370    // ── ApproxConvexDecomp ──
1371
1372    #[test]
1373    fn test_acd_returns_at_least_one_part() {
1374        let pts = cube_points(1.0);
1375        let acd = ApproxConvexDecomp::new();
1376        let parts = acd.decompose(&pts);
1377        assert!(!parts.is_empty(), "ACD should produce at least one part");
1378    }
1379
1380    #[test]
1381    fn test_acd_parts_are_convex() {
1382        let pts = cube_points(1.0);
1383        let acd = ApproxConvexDecomp::new();
1384        let parts = acd.decompose(&pts);
1385        for (i, part) in parts.parts.iter().enumerate() {
1386            assert!(part.is_convex(1e-9), "part {} should be convex", i);
1387        }
1388    }
1389}