Skip to main content

oxiphysics_gpu/
gpu_collision_ext.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! GPU collision detection extensions (CPU mock backend).
5//!
6//! Provides additional broadphase and narrowphase utilities that complement
7//! [`gpu_collision_detection`](super::gpu_collision_detection):
8//! - [`GpuBroadphaseGrid`]: uniform-grid broadphase
9//! - [`GpuContactManifold`]: contact point storage
10//! - Free functions: AABB overlap, sphere overlap, point containment,
11//!   ray-AABB / ray-sphere intersection, GJK distance estimate.
12
13// ── GpuBroadphaseGrid ────────────────────────────────────────────────────────
14
15/// A uniform-grid broadphase accelerator.
16///
17/// The 3-D domain is partitioned into `nx × ny × nz` axis-aligned cells.
18/// Particles are inserted by position and can be queried by neighbourhood.
19#[derive(Debug, Clone)]
20pub struct GpuBroadphaseGrid {
21    /// Size of one cell along each axis.
22    pub cell_size: f64,
23    /// Number of cells along X.
24    pub nx: usize,
25    /// Number of cells along Y.
26    pub ny: usize,
27    /// Number of cells along Z.
28    pub nz: usize,
29    /// Flattened cell storage: `cells[flat_index]` is the list of particle IDs.
30    pub cells: Vec<Vec<usize>>,
31}
32
33impl GpuBroadphaseGrid {
34    /// Create a new broadphase grid.
35    ///
36    /// # Arguments
37    /// * `cell_size` – edge length of each cubic cell.
38    /// * `nx`, `ny`, `nz` – cell counts along each axis.
39    pub fn new(cell_size: f64, nx: usize, ny: usize, nz: usize) -> Self {
40        let total = nx * ny * nz;
41        Self {
42            cell_size,
43            nx,
44            ny,
45            nz,
46            cells: vec![Vec::new(); total],
47        }
48    }
49
50    /// Insert `particle_id` at world-space position `pos`.
51    ///
52    /// Positions that fall outside the grid are clamped to the nearest cell.
53    pub fn insert(&mut self, particle_id: usize, pos: [f64; 3]) {
54        let (ix, iy, iz) = self.cell_index(pos);
55        let flat = iz * self.nx * self.ny + iy * self.nx + ix;
56        if flat < self.cells.len() {
57            self.cells[flat].push(particle_id);
58        }
59    }
60
61    /// Map a world-space position to grid-cell indices `(ix, iy, iz)`.
62    ///
63    /// Result is clamped so it always lies within `[0, nx-1] × [0, ny-1] × [0, nz-1]`.
64    pub fn cell_index(&self, pos: [f64; 3]) -> (usize, usize, usize) {
65        let clamp = |v: f64, n: usize| {
66            let i = (v / self.cell_size).floor() as isize;
67            i.max(0).min(n as isize - 1) as usize
68        };
69        let ix = clamp(pos[0], self.nx.max(1));
70        let iy = clamp(pos[1], self.ny.max(1));
71        let iz = clamp(pos[2], self.nz.max(1));
72        (ix, iy, iz)
73    }
74
75    /// Return all particle IDs in the cell containing `pos` and its 26 neighbours.
76    pub fn query_neighbors(&self, pos: [f64; 3]) -> Vec<usize> {
77        let (cx, cy, cz) = self.cell_index(pos);
78        let mut result = Vec::new();
79        for dz in -1isize..=1 {
80            for dy in -1isize..=1 {
81                for dx in -1isize..=1 {
82                    let ix = cx as isize + dx;
83                    let iy = cy as isize + dy;
84                    let iz = cz as isize + dz;
85                    if ix < 0
86                        || iy < 0
87                        || iz < 0
88                        || ix >= self.nx as isize
89                        || iy >= self.ny as isize
90                        || iz >= self.nz as isize
91                    {
92                        continue;
93                    }
94                    let flat =
95                        iz as usize * self.nx * self.ny + iy as usize * self.nx + ix as usize;
96                    result.extend_from_slice(&self.cells[flat]);
97                }
98            }
99        }
100        result
101    }
102
103    /// Total number of cells in the grid.
104    pub fn cell_count(&self) -> usize {
105        self.nx * self.ny * self.nz
106    }
107
108    /// Remove all particle IDs from every cell.
109    pub fn clear(&mut self) {
110        for cell in &mut self.cells {
111            cell.clear();
112        }
113    }
114}
115
116// ── Overlap tests ─────────────────────────────────────────────────────────────
117
118/// Test whether two axis-aligned bounding boxes overlap.
119///
120/// Returns `true` when the boxes touch or interpenetrate on all three axes.
121pub fn gpu_aabb_overlap(
122    a_min: [f32; 3],
123    a_max: [f32; 3],
124    b_min: [f32; 3],
125    b_max: [f32; 3],
126) -> bool {
127    a_min[0] <= b_max[0]
128        && a_max[0] >= b_min[0]
129        && a_min[1] <= b_max[1]
130        && a_max[1] >= b_min[1]
131        && a_min[2] <= b_max[2]
132        && a_max[2] >= b_min[2]
133}
134
135/// Test whether two spheres overlap (or touch).
136///
137/// Returns `true` when the distance between centres is ≤ `r1 + r2`.
138pub fn gpu_sphere_sphere_overlap(c1: [f32; 3], r1: f32, c2: [f32; 3], r2: f32) -> bool {
139    let dx = c1[0] - c2[0];
140    let dy = c1[1] - c2[1];
141    let dz = c1[2] - c2[2];
142    let dist2 = dx * dx + dy * dy + dz * dz;
143    let rsum = r1 + r2;
144    dist2 <= rsum * rsum
145}
146
147/// Test whether point `p` is inside (or on the boundary of) the AABB.
148pub fn gpu_point_in_aabb(p: [f32; 3], min: [f32; 3], max: [f32; 3]) -> bool {
149    p[0] >= min[0]
150        && p[0] <= max[0]
151        && p[1] >= min[1]
152        && p[1] <= max[1]
153        && p[2] >= min[2]
154        && p[2] <= max[2]
155}
156
157// ── Ray intersection ──────────────────────────────────────────────────────────
158
159/// Intersect a ray with an AABB using the slab method.
160///
161/// Returns `Some(t)` (the entry distance along the ray) if the ray hits the
162/// box, or `None` on a miss.  The ray direction need not be normalised.
163pub fn gpu_ray_aabb_intersect(
164    ray_origin: [f32; 3],
165    ray_dir: [f32; 3],
166    min: [f32; 3],
167    max: [f32; 3],
168) -> Option<f32> {
169    let mut t_min = f32::NEG_INFINITY;
170    let mut t_max = f32::INFINITY;
171    for i in 0..3 {
172        let inv_d = if ray_dir[i].abs() > 1e-12 {
173            1.0 / ray_dir[i]
174        } else {
175            // Ray parallel to slab – check if origin is inside
176            if ray_origin[i] < min[i] || ray_origin[i] > max[i] {
177                return None;
178            }
179            continue;
180        };
181        let t1 = (min[i] - ray_origin[i]) * inv_d;
182        let t2 = (max[i] - ray_origin[i]) * inv_d;
183        let (ta, tb) = if t1 < t2 { (t1, t2) } else { (t2, t1) };
184        t_min = t_min.max(ta);
185        t_max = t_max.min(tb);
186        if t_min > t_max {
187            return None;
188        }
189    }
190    if t_max < 0.0 {
191        return None;
192    }
193    Some(if t_min >= 0.0 { t_min } else { t_max })
194}
195
196/// Intersect a ray with a sphere.
197///
198/// Returns `Some(t)` for the nearest positive intersection, or `None` on miss.
199pub fn gpu_ray_sphere_intersect(
200    ray_origin: [f32; 3],
201    ray_dir: [f32; 3],
202    center: [f32; 3],
203    radius: f32,
204) -> Option<f32> {
205    let ocx = ray_origin[0] - center[0];
206    let ocy = ray_origin[1] - center[1];
207    let ocz = ray_origin[2] - center[2];
208    let a = ray_dir[0] * ray_dir[0] + ray_dir[1] * ray_dir[1] + ray_dir[2] * ray_dir[2];
209    let b = 2.0 * (ray_dir[0] * ocx + ray_dir[1] * ocy + ray_dir[2] * ocz);
210    let c = ocx * ocx + ocy * ocy + ocz * ocz - radius * radius;
211    let discriminant = b * b - 4.0 * a * c;
212    if discriminant < 0.0 {
213        return None;
214    }
215    let sqrt_d = discriminant.sqrt();
216    let t1 = (-b - sqrt_d) / (2.0 * a);
217    let t2 = (-b + sqrt_d) / (2.0 * a);
218    if t1 >= 0.0 {
219        Some(t1)
220    } else if t2 >= 0.0 {
221        Some(t2)
222    } else {
223        None
224    }
225}
226
227// ── GpuContactManifold ────────────────────────────────────────────────────────
228
229/// Contact manifold between two rigid bodies.
230///
231/// Stores the list of contact points, surface normals, and penetration depths
232/// generated during narrowphase collision resolution.
233#[derive(Debug, Clone)]
234pub struct GpuContactManifold {
235    /// Index of the first body.
236    pub body_a: usize,
237    /// Index of the second body.
238    pub body_b: usize,
239    /// World-space contact points.
240    pub contact_points: Vec<[f32; 3]>,
241    /// Outward contact normals (from A towards B).
242    pub normals: Vec<[f32; 3]>,
243    /// Penetration depths (positive = overlapping).
244    pub depths: Vec<f32>,
245}
246
247impl GpuContactManifold {
248    /// Create an empty manifold for the given body pair.
249    pub fn new(body_a: usize, body_b: usize) -> Self {
250        Self {
251            body_a,
252            body_b,
253            contact_points: Vec::new(),
254            normals: Vec::new(),
255            depths: Vec::new(),
256        }
257    }
258
259    /// Add a single contact to the manifold.
260    ///
261    /// * `point`  – world-space contact point.
262    /// * `normal` – surface normal pointing from A to B.
263    /// * `depth`  – penetration depth (positive value).
264    pub fn add_contact(&mut self, point: [f32; 3], normal: [f32; 3], depth: f32) {
265        self.contact_points.push(point);
266        self.normals.push(normal);
267        self.depths.push(depth);
268    }
269
270    /// Number of contact points in this manifold.
271    pub fn contact_count(&self) -> usize {
272        self.contact_points.len()
273    }
274
275    /// Maximum penetration depth across all contacts, or `0.0` if empty.
276    pub fn max_penetration(&self) -> f32 {
277        self.depths.iter().copied().fold(0.0_f32, f32::max)
278    }
279}
280
281// ── GJK distance (simplified) ────────────────────────────────────────────────
282
283/// Estimate the minimum distance between two convex hulls using a simplified
284/// GJK approach (brute-force vertex-pair minimum for the mock GPU backend).
285///
286/// Returns `0.0` if either set is empty.
287pub fn gpu_gjk_distance(vertices_a: &[[f32; 3]], vertices_b: &[[f32; 3]]) -> f32 {
288    if vertices_a.is_empty() || vertices_b.is_empty() {
289        return 0.0;
290    }
291    let mut min_dist2 = f32::MAX;
292    for &va in vertices_a {
293        for &vb in vertices_b {
294            let dx = va[0] - vb[0];
295            let dy = va[1] - vb[1];
296            let dz = va[2] - vb[2];
297            let d2 = dx * dx + dy * dy + dz * dz;
298            if d2 < min_dist2 {
299                min_dist2 = d2;
300            }
301        }
302    }
303    min_dist2.sqrt()
304}
305
306// ── Tests ─────────────────────────────────────────────────────────────────────
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    // ── GpuBroadphaseGrid ────────────────────────────────────────────────
313
314    #[test]
315    fn broadphase_cell_count_basic() {
316        let g = GpuBroadphaseGrid::new(1.0, 4, 4, 4);
317        assert_eq!(g.cell_count(), 64);
318    }
319
320    #[test]
321    fn broadphase_cell_count_non_uniform() {
322        let g = GpuBroadphaseGrid::new(0.5, 2, 3, 5);
323        assert_eq!(g.cell_count(), 30);
324    }
325
326    #[test]
327    fn broadphase_insert_and_query() {
328        let mut g = GpuBroadphaseGrid::new(1.0, 4, 4, 4);
329        g.insert(0, [0.5, 0.5, 0.5]);
330        let neighbors = g.query_neighbors([0.5, 0.5, 0.5]);
331        assert!(!neighbors.is_empty());
332        assert!(neighbors.contains(&0));
333    }
334
335    #[test]
336    fn broadphase_query_nearby_cell() {
337        let mut g = GpuBroadphaseGrid::new(1.0, 4, 4, 4);
338        g.insert(42, [1.5, 1.5, 1.5]);
339        // Query from adjacent cell should still find it
340        let neighbors = g.query_neighbors([0.5, 0.5, 0.5]);
341        assert!(neighbors.contains(&42));
342    }
343
344    #[test]
345    fn broadphase_clear_removes_particles() {
346        let mut g = GpuBroadphaseGrid::new(1.0, 4, 4, 4);
347        g.insert(0, [0.5, 0.5, 0.5]);
348        g.clear();
349        let neighbors = g.query_neighbors([0.5, 0.5, 0.5]);
350        assert!(neighbors.is_empty());
351    }
352
353    #[test]
354    fn broadphase_multiple_particles_same_cell() {
355        let mut g = GpuBroadphaseGrid::new(1.0, 4, 4, 4);
356        g.insert(1, [0.1, 0.1, 0.1]);
357        g.insert(2, [0.9, 0.9, 0.9]);
358        let neighbors = g.query_neighbors([0.5, 0.5, 0.5]);
359        assert!(neighbors.contains(&1));
360        assert!(neighbors.contains(&2));
361    }
362
363    #[test]
364    fn broadphase_cell_index_origin() {
365        let g = GpuBroadphaseGrid::new(1.0, 4, 4, 4);
366        let (ix, iy, iz) = g.cell_index([0.0, 0.0, 0.0]);
367        assert_eq!((ix, iy, iz), (0, 0, 0));
368    }
369
370    #[test]
371    fn broadphase_cell_index_clamped() {
372        let g = GpuBroadphaseGrid::new(1.0, 4, 4, 4);
373        // position outside grid is clamped
374        let (ix, _iy, _iz) = g.cell_index([100.0, 0.0, 0.0]);
375        assert_eq!(ix, 3);
376    }
377
378    #[test]
379    fn broadphase_query_empty_grid() {
380        let g = GpuBroadphaseGrid::new(1.0, 4, 4, 4);
381        let neighbors = g.query_neighbors([0.5, 0.5, 0.5]);
382        assert!(neighbors.is_empty());
383    }
384
385    #[test]
386    fn broadphase_insert_many() {
387        let mut g = GpuBroadphaseGrid::new(1.0, 8, 8, 8);
388        for i in 0..20 {
389            g.insert(i, [(i % 8) as f64 * 0.9, 0.5, 0.5]);
390        }
391        // Grid should still have cell_count = 512
392        assert_eq!(g.cell_count(), 512);
393    }
394
395    // ── gpu_aabb_overlap ─────────────────────────────────────────────────
396
397    #[test]
398    fn aabb_overlap_basic() {
399        let a_min = [0.0f32; 3];
400        let a_max = [1.0f32; 3];
401        let b_min = [0.5f32; 3];
402        let b_max = [1.5f32; 3];
403        assert!(gpu_aabb_overlap(a_min, a_max, b_min, b_max));
404    }
405
406    #[test]
407    fn aabb_overlap_no_overlap() {
408        let a_min = [0.0f32; 3];
409        let a_max = [1.0f32; 3];
410        let b_min = [2.0f32; 3];
411        let b_max = [3.0f32; 3];
412        assert!(!gpu_aabb_overlap(a_min, a_max, b_min, b_max));
413    }
414
415    #[test]
416    fn aabb_overlap_touching_face() {
417        // Boxes touching at x=1
418        let a_min = [0.0f32, 0.0, 0.0];
419        let a_max = [1.0f32, 1.0, 1.0];
420        let b_min = [1.0f32, 0.0, 0.0];
421        let b_max = [2.0f32, 1.0, 1.0];
422        assert!(gpu_aabb_overlap(a_min, a_max, b_min, b_max));
423    }
424
425    #[test]
426    fn aabb_overlap_separated_in_y() {
427        let a_min = [0.0f32, 0.0, 0.0];
428        let a_max = [1.0f32, 1.0, 1.0];
429        let b_min = [0.0f32, 2.0, 0.0];
430        let b_max = [1.0f32, 3.0, 1.0];
431        assert!(!gpu_aabb_overlap(a_min, a_max, b_min, b_max));
432    }
433
434    #[test]
435    fn aabb_overlap_contained() {
436        let outer_min = [0.0f32; 3];
437        let outer_max = [10.0f32; 3];
438        let inner_min = [2.0f32; 3];
439        let inner_max = [3.0f32; 3];
440        assert!(gpu_aabb_overlap(outer_min, outer_max, inner_min, inner_max));
441    }
442
443    // ── gpu_sphere_sphere_overlap ────────────────────────────────────────
444
445    #[test]
446    fn sphere_overlap_clearly_overlapping() {
447        let c1 = [0.0f32; 3];
448        let c2 = [1.0f32, 0.0, 0.0];
449        assert!(gpu_sphere_sphere_overlap(c1, 1.0, c2, 1.0));
450    }
451
452    #[test]
453    fn sphere_overlap_just_touching() {
454        // Centers 2.0 apart, radii 1.0 each → exactly touching
455        let c1 = [0.0f32; 3];
456        let c2 = [2.0f32, 0.0, 0.0];
457        assert!(gpu_sphere_sphere_overlap(c1, 1.0, c2, 1.0));
458    }
459
460    #[test]
461    fn sphere_overlap_separated() {
462        let c1 = [0.0f32; 3];
463        let c2 = [3.0f32, 0.0, 0.0];
464        assert!(!gpu_sphere_sphere_overlap(c1, 1.0, c2, 1.0));
465    }
466
467    #[test]
468    fn sphere_overlap_same_center() {
469        let c = [0.0f32; 3];
470        assert!(gpu_sphere_sphere_overlap(c, 1.0, c, 1.0));
471    }
472
473    // ── gpu_point_in_aabb ────────────────────────────────────────────────
474
475    #[test]
476    fn point_in_aabb_inside() {
477        let min = [0.0f32; 3];
478        let max = [1.0f32; 3];
479        assert!(gpu_point_in_aabb([0.5, 0.5, 0.5], min, max));
480    }
481
482    #[test]
483    fn point_in_aabb_outside() {
484        let min = [0.0f32; 3];
485        let max = [1.0f32; 3];
486        assert!(!gpu_point_in_aabb([2.0, 0.5, 0.5], min, max));
487    }
488
489    #[test]
490    fn point_in_aabb_corner() {
491        let min = [0.0f32; 3];
492        let max = [1.0f32; 3];
493        assert!(gpu_point_in_aabb([0.0, 0.0, 0.0], min, max));
494        assert!(gpu_point_in_aabb([1.0, 1.0, 1.0], min, max));
495    }
496
497    #[test]
498    fn point_in_aabb_just_outside() {
499        let min = [0.0f32; 3];
500        let max = [1.0f32; 3];
501        assert!(!gpu_point_in_aabb([1.0001, 0.5, 0.5], min, max));
502    }
503
504    // ── gpu_ray_aabb_intersect ────────────────────────────────────────────
505
506    #[test]
507    fn ray_aabb_hit() {
508        let origin = [-2.0f32, 0.5, 0.5];
509        let dir = [1.0f32, 0.0, 0.0];
510        let min = [0.0f32; 3];
511        let max = [1.0f32; 3];
512        let t = gpu_ray_aabb_intersect(origin, dir, min, max);
513        assert!(t.is_some());
514        assert!((t.unwrap() - 2.0).abs() < 1e-5);
515    }
516
517    #[test]
518    fn ray_aabb_miss() {
519        let origin = [-2.0f32, 5.0, 0.5];
520        let dir = [1.0f32, 0.0, 0.0];
521        let min = [0.0f32; 3];
522        let max = [1.0f32; 3];
523        assert!(gpu_ray_aabb_intersect(origin, dir, min, max).is_none());
524    }
525
526    #[test]
527    fn ray_aabb_origin_inside() {
528        let origin = [0.5f32; 3];
529        let dir = [1.0f32, 0.0, 0.0];
530        let min = [0.0f32; 3];
531        let max = [1.0f32; 3];
532        // Origin inside box — should return positive t
533        let t = gpu_ray_aabb_intersect(origin, dir, min, max);
534        assert!(t.is_some());
535        assert!(t.unwrap() >= 0.0);
536    }
537
538    #[test]
539    fn ray_aabb_opposite_direction() {
540        // Ray pointing away from box
541        let origin = [5.0f32, 0.5, 0.5];
542        let dir = [1.0f32, 0.0, 0.0];
543        let min = [0.0f32; 3];
544        let max = [1.0f32; 3];
545        assert!(gpu_ray_aabb_intersect(origin, dir, min, max).is_none());
546    }
547
548    // ── gpu_ray_sphere_intersect ──────────────────────────────────────────
549
550    #[test]
551    fn ray_sphere_hit() {
552        let origin = [-5.0f32, 0.0, 0.0];
553        let dir = [1.0f32, 0.0, 0.0];
554        let center = [0.0f32; 3];
555        let t = gpu_ray_sphere_intersect(origin, dir, center, 1.0);
556        assert!(t.is_some());
557        // Entry at x = -1, so t = 5 - 1 = 4
558        assert!((t.unwrap() - 4.0).abs() < 1e-4);
559    }
560
561    #[test]
562    fn ray_sphere_miss() {
563        let origin = [0.0f32, 5.0, 0.0];
564        let dir = [1.0f32, 0.0, 0.0];
565        let center = [0.0f32; 3];
566        assert!(gpu_ray_sphere_intersect(origin, dir, center, 1.0).is_none());
567    }
568
569    #[test]
570    fn ray_sphere_origin_inside() {
571        let origin = [0.0f32; 3];
572        let dir = [1.0f32, 0.0, 0.0];
573        let center = [0.0f32; 3];
574        let t = gpu_ray_sphere_intersect(origin, dir, center, 2.0);
575        // Should return the exit distance
576        assert!(t.is_some());
577        assert!(t.unwrap() > 0.0);
578    }
579
580    // ── GpuContactManifold ───────────────────────────────────────────────
581
582    #[test]
583    fn manifold_new_empty() {
584        let m = GpuContactManifold::new(0, 1);
585        assert_eq!(m.body_a, 0);
586        assert_eq!(m.body_b, 1);
587        assert_eq!(m.contact_count(), 0);
588    }
589
590    #[test]
591    fn manifold_add_contact_increases_count() {
592        let mut m = GpuContactManifold::new(0, 1);
593        m.add_contact([0.0; 3], [0.0, 1.0, 0.0], 0.1);
594        assert_eq!(m.contact_count(), 1);
595        m.add_contact([1.0, 0.0, 0.0], [0.0, 1.0, 0.0], 0.2);
596        assert_eq!(m.contact_count(), 2);
597    }
598
599    #[test]
600    fn manifold_max_penetration_empty() {
601        let m = GpuContactManifold::new(0, 1);
602        assert!((m.max_penetration() - 0.0).abs() < 1e-6);
603    }
604
605    #[test]
606    fn manifold_max_penetration_multiple() {
607        let mut m = GpuContactManifold::new(0, 1);
608        m.add_contact([0.0; 3], [0.0, 1.0, 0.0], 0.1);
609        m.add_contact([0.0; 3], [0.0, 1.0, 0.0], 0.5);
610        m.add_contact([0.0; 3], [0.0, 1.0, 0.0], 0.3);
611        assert!((m.max_penetration() - 0.5).abs() < 1e-6);
612    }
613
614    #[test]
615    fn manifold_contact_points_stored() {
616        let mut m = GpuContactManifold::new(2, 3);
617        let pt = [1.0, 2.0, 3.0];
618        m.add_contact(pt, [0.0, 1.0, 0.0], 0.05);
619        assert!((m.contact_points[0][0] - 1.0).abs() < 1e-6);
620    }
621
622    // ── gpu_gjk_distance ─────────────────────────────────────────────────
623
624    #[test]
625    fn gjk_distance_touching() {
626        let verts_a: Vec<[f32; 3]> = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]];
627        let verts_b: Vec<[f32; 3]> = vec![[1.0, 0.0, 0.0], [2.0, 0.0, 0.0]];
628        let d = gpu_gjk_distance(&verts_a, &verts_b);
629        assert!(d < 1e-5, "expected ~0, got {d}");
630    }
631
632    #[test]
633    fn gjk_distance_separated() {
634        let verts_a: Vec<[f32; 3]> = vec![[0.0; 3]];
635        let verts_b: Vec<[f32; 3]> = vec![[3.0, 0.0, 0.0]];
636        let d = gpu_gjk_distance(&verts_a, &verts_b);
637        assert!((d - 3.0).abs() < 1e-5);
638    }
639
640    #[test]
641    fn gjk_distance_empty_a() {
642        let d = gpu_gjk_distance(&[], &[[1.0, 0.0, 0.0]]);
643        assert!((d - 0.0).abs() < 1e-6);
644    }
645
646    #[test]
647    fn gjk_distance_empty_b() {
648        let d = gpu_gjk_distance(&[[1.0, 0.0, 0.0]], &[]);
649        assert!((d - 0.0).abs() < 1e-6);
650    }
651
652    #[test]
653    fn gjk_distance_same_point() {
654        let v = [[0.0f32; 3]];
655        let d = gpu_gjk_distance(&v, &v);
656        assert!(d < 1e-6);
657    }
658}