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