Skip to main content

oxiphysics_geometry/
spatial_hash.rs

1#![allow(clippy::needless_range_loop, clippy::type_complexity)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! Spatial hashing, loose octree, k-nearest neighbours, ray grid traversal,
6//! and spatial statistics.
7
8#![allow(dead_code)]
9
10use std::collections::{BinaryHeap, HashMap};
11
12// ─── Helper math ─────────────────────────────────────────────────────────────
13
14fn dist_sq(a: [f64; 3], b: [f64; 3]) -> f64 {
15    (a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)
16}
17
18// ─── SpatialHash3D ────────────────────────────────────────────────────────────
19
20/// A uniform-grid spatial hash that maps 3-D positions to arbitrary data.
21///
22/// Items are bucketed by their cell coordinate
23/// `(floor(x/cell_size), floor(y/cell_size), floor(z/cell_size))`.
24pub struct SpatialHash3D<T: Clone> {
25    /// Side length of each cubic cell.
26    pub cell_size: f64,
27    cells: HashMap<(i32, i32, i32), Vec<(usize, T)>>,
28    /// Total number of items currently stored.
29    pub n_items: usize,
30}
31
32impl<T: Clone> SpatialHash3D<T> {
33    /// Creates a new empty spatial hash with the given cell size.
34    pub fn new(cell_size: f64) -> Self {
35        Self {
36            cell_size,
37            cells: HashMap::new(),
38            n_items: 0,
39        }
40    }
41
42    /// Maps a single coordinate to its cell index via `floor(x / cell_size)`.
43    pub fn cell_coord(&self, x: f64) -> i32 {
44        (x / self.cell_size).floor() as i32
45    }
46
47    /// Returns the 3-D cell key for a world-space position.
48    pub fn cell_key(&self, pos: [f64; 3]) -> (i32, i32, i32) {
49        (
50            self.cell_coord(pos[0]),
51            self.cell_coord(pos[1]),
52            self.cell_coord(pos[2]),
53        )
54    }
55
56    /// Inserts `(id, data)` at `pos` and returns the cell key it was placed in.
57    pub fn insert(&mut self, id: usize, pos: [f64; 3], data: T) -> (i32, i32, i32) {
58        let key = self.cell_key(pos);
59        self.cells.entry(key).or_default().push((id, data));
60        self.n_items += 1;
61        key
62    }
63
64    /// Removes the entry with the given `id` from the cell that contains `pos`.
65    pub fn remove(&mut self, id: usize, pos: [f64; 3]) {
66        let key = self.cell_key(pos);
67        if let Some(bucket) = self.cells.get_mut(&key) {
68            let before = bucket.len();
69            bucket.retain(|(item_id, _)| *item_id != id);
70            let removed = before - bucket.len();
71            self.n_items = self.n_items.saturating_sub(removed);
72            if bucket.is_empty() {
73                self.cells.remove(&key);
74            }
75        }
76    }
77
78    /// Returns all items within the cells overlapping a sphere of `radius`
79    /// centred at `center`. Note: without stored positions, exact distance
80    /// filtering is not possible; use `SpatialHashPos3D` for exact queries.
81    pub fn query_radius(&self, center: [f64; 3], radius: f64) -> Vec<(usize, &T)> {
82        let r_cells = (radius / self.cell_size).ceil() as i32;
83        let cx = self.cell_coord(center[0]);
84        let cy = self.cell_coord(center[1]);
85        let cz = self.cell_coord(center[2]);
86
87        let mut result = Vec::new();
88        for dx in -r_cells..=r_cells {
89            for dy in -r_cells..=r_cells {
90                for dz in -r_cells..=r_cells {
91                    let key = (cx + dx, cy + dy, cz + dz);
92                    if let Some(bucket) = self.cells.get(&key) {
93                        for (id, data) in bucket {
94                            result.push((*id, data));
95                        }
96                    }
97                }
98            }
99        }
100        result
101    }
102
103    /// Returns all items stored in the given cell key.
104    pub fn query_cell(&self, key: (i32, i32, i32)) -> Vec<(usize, &T)> {
105        match self.cells.get(&key) {
106            Some(bucket) => bucket.iter().map(|(id, data)| (*id, data)).collect(),
107            None => Vec::new(),
108        }
109    }
110
111    /// Removes all items.
112    pub fn clear(&mut self) {
113        self.cells.clear();
114        self.n_items = 0;
115    }
116
117    /// Returns the total number of items stored.
118    pub fn len(&self) -> usize {
119        self.n_items
120    }
121
122    /// Returns `true` if no items are stored.
123    pub fn is_empty(&self) -> bool {
124        self.n_items == 0
125    }
126
127    /// Returns the number of non-empty cells.
128    pub fn n_cells(&self) -> usize {
129        self.cells.len()
130    }
131}
132
133// ─── SpatialHashPos3D ─────────────────────────────────────────────────────────
134
135/// A uniform-grid spatial hash with position storage for exact radius queries.
136pub struct SpatialHashPos3D<T: Clone> {
137    /// Side length of each cubic cell.
138    pub cell_size: f64,
139    cells: HashMap<(i32, i32, i32), Vec<(usize, [f64; 3], T)>>,
140    /// Total number of items currently stored.
141    pub n_items: usize,
142}
143
144impl<T: Clone> SpatialHashPos3D<T> {
145    /// Creates a new empty hash with the given cell size.
146    pub fn new(cell_size: f64) -> Self {
147        Self {
148            cell_size,
149            cells: HashMap::new(),
150            n_items: 0,
151        }
152    }
153
154    /// Maps a coordinate to its cell index.
155    pub fn cell_coord(&self, x: f64) -> i32 {
156        (x / self.cell_size).floor() as i32
157    }
158
159    /// Returns the 3-D cell key for a world-space position.
160    pub fn cell_key(&self, pos: [f64; 3]) -> (i32, i32, i32) {
161        (
162            self.cell_coord(pos[0]),
163            self.cell_coord(pos[1]),
164            self.cell_coord(pos[2]),
165        )
166    }
167
168    /// Inserts item and returns its cell key.
169    pub fn insert(&mut self, id: usize, pos: [f64; 3], data: T) -> (i32, i32, i32) {
170        let key = self.cell_key(pos);
171        self.cells.entry(key).or_default().push((id, pos, data));
172        self.n_items += 1;
173        key
174    }
175
176    /// Removes the entry with the given id from the cell containing pos.
177    pub fn remove(&mut self, id: usize, pos: [f64; 3]) {
178        let key = self.cell_key(pos);
179        if let Some(bucket) = self.cells.get_mut(&key) {
180            let before = bucket.len();
181            bucket.retain(|(item_id, _, _)| *item_id != id);
182            let removed = before - bucket.len();
183            self.n_items = self.n_items.saturating_sub(removed);
184            if bucket.is_empty() {
185                self.cells.remove(&key);
186            }
187        }
188    }
189
190    /// Returns all items within a sphere, with exact distance filtering.
191    pub fn query_radius(&self, center: [f64; 3], radius: f64) -> Vec<(usize, &T)> {
192        let r_cells = (radius / self.cell_size).ceil() as i32;
193        let cx = self.cell_coord(center[0]);
194        let cy = self.cell_coord(center[1]);
195        let cz = self.cell_coord(center[2]);
196        let r2 = radius * radius;
197        let mut result = Vec::new();
198
199        for dx in -r_cells..=r_cells {
200            for dy in -r_cells..=r_cells {
201                for dz in -r_cells..=r_cells {
202                    let key = (cx + dx, cy + dy, cz + dz);
203                    if let Some(bucket) = self.cells.get(&key) {
204                        for (id, pos, data) in bucket {
205                            let d2 = dist_sq(*pos, center);
206                            if d2 <= r2 {
207                                result.push((*id, data));
208                            }
209                        }
210                    }
211                }
212            }
213        }
214        result
215    }
216
217    /// Returns all items within an axis-aligned box defined by `min` and `max`.
218    pub fn query_aabb(&self, min: [f64; 3], max: [f64; 3]) -> Vec<(usize, &T)> {
219        let cmin = [
220            self.cell_coord(min[0]),
221            self.cell_coord(min[1]),
222            self.cell_coord(min[2]),
223        ];
224        let cmax = [
225            self.cell_coord(max[0]),
226            self.cell_coord(max[1]),
227            self.cell_coord(max[2]),
228        ];
229        let mut result = Vec::new();
230        for cx in cmin[0]..=cmax[0] {
231            for cy in cmin[1]..=cmax[1] {
232                for cz in cmin[2]..=cmax[2] {
233                    if let Some(bucket) = self.cells.get(&(cx, cy, cz)) {
234                        for (id, pos, data) in bucket {
235                            if pos[0] >= min[0]
236                                && pos[0] <= max[0]
237                                && pos[1] >= min[1]
238                                && pos[1] <= max[1]
239                                && pos[2] >= min[2]
240                                && pos[2] <= max[2]
241                            {
242                                result.push((*id, data));
243                            }
244                        }
245                    }
246                }
247            }
248        }
249        result
250    }
251
252    /// Find the k nearest neighbours of `center`.
253    ///
254    /// Uses an expanding search radius starting at cell_size and doubling
255    /// until at least k candidates are found, then filters to the true k
256    /// nearest.
257    pub fn k_nearest(&self, center: [f64; 3], k: usize) -> Vec<(usize, f64, &T)> {
258        if k == 0 || self.n_items == 0 {
259            return Vec::new();
260        }
261
262        // Collect all items with their distances
263        let mut all: Vec<(usize, f64, &T)> = Vec::new();
264        for bucket in self.cells.values() {
265            for (id, pos, data) in bucket {
266                let d2 = dist_sq(*pos, center);
267                all.push((*id, d2, data));
268            }
269        }
270        all.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
271        all.truncate(k);
272        // Convert d2 to distance
273        all.iter()
274            .map(|(id, d2, data)| (*id, d2.sqrt(), *data))
275            .collect()
276    }
277
278    /// Returns all items stored in the given cell.
279    pub fn query_cell(&self, key: (i32, i32, i32)) -> Vec<(usize, &T)> {
280        match self.cells.get(&key) {
281            Some(bucket) => bucket.iter().map(|(id, _, data)| (*id, data)).collect(),
282            None => Vec::new(),
283        }
284    }
285
286    /// Removes all items.
287    pub fn clear(&mut self) {
288        self.cells.clear();
289        self.n_items = 0;
290    }
291
292    /// Returns the total number of items stored.
293    pub fn len(&self) -> usize {
294        self.n_items
295    }
296
297    /// Returns `true` if no items are stored.
298    pub fn is_empty(&self) -> bool {
299        self.n_items == 0
300    }
301
302    /// Returns the number of non-empty cells.
303    pub fn n_cells(&self) -> usize {
304        self.cells.len()
305    }
306
307    /// Compute statistics about the spatial hash distribution.
308    pub fn statistics(&self) -> SpatialHashStats {
309        let n_cells = self.cells.len();
310        if n_cells == 0 {
311            return SpatialHashStats {
312                n_items: 0,
313                n_occupied_cells: 0,
314                min_bucket_size: 0,
315                max_bucket_size: 0,
316                avg_bucket_size: 0.0,
317                load_factor: 0.0,
318            };
319        }
320        let sizes: Vec<usize> = self.cells.values().map(|b| b.len()).collect();
321        let min_b = *sizes.iter().min().unwrap_or(&0);
322        let max_b = *sizes.iter().max().unwrap_or(&0);
323        let sum: usize = sizes.iter().sum();
324        SpatialHashStats {
325            n_items: self.n_items,
326            n_occupied_cells: n_cells,
327            min_bucket_size: min_b,
328            max_bucket_size: max_b,
329            avg_bucket_size: sum as f64 / n_cells as f64,
330            load_factor: sum as f64 / n_cells as f64,
331        }
332    }
333
334    /// Update the position of an item (remove from old cell, insert into new).
335    pub fn update_position(&mut self, id: usize, old_pos: [f64; 3], new_pos: [f64; 3], data: T) {
336        self.remove(id, old_pos);
337        self.insert(id, new_pos, data);
338    }
339}
340
341/// Statistics about a spatial hash's distribution.
342#[derive(Debug, Clone)]
343pub struct SpatialHashStats {
344    /// Total items stored.
345    pub n_items: usize,
346    /// Number of occupied cells.
347    pub n_occupied_cells: usize,
348    /// Smallest bucket.
349    pub min_bucket_size: usize,
350    /// Largest bucket.
351    pub max_bucket_size: usize,
352    /// Average items per occupied cell.
353    pub avg_bucket_size: f64,
354    /// Load factor (same as avg_bucket_size).
355    pub load_factor: f64,
356}
357
358// ─── Ray traversal through uniform grid (3D DDA) ────────────────────────────
359
360/// Result of a ray traversal step through the grid.
361#[derive(Debug, Clone, Copy)]
362pub struct RayGridStep {
363    /// The cell key visited.
364    pub cell: (i32, i32, i32),
365    /// Parameter t at entry to this cell.
366    pub t_enter: f64,
367    /// Parameter t at exit from this cell.
368    pub t_exit: f64,
369}
370
371/// Traverse a ray through a uniform grid using 3D DDA (Amanatides-Woo).
372///
373/// Returns the sequence of cells visited by the ray from `origin` in
374/// `direction`, up to `max_t`.
375pub fn ray_traverse_grid(
376    origin: [f64; 3],
377    direction: [f64; 3],
378    cell_size: f64,
379    max_t: f64,
380    max_steps: usize,
381) -> Vec<RayGridStep> {
382    let mut result = Vec::new();
383    if cell_size <= 0.0 {
384        return result;
385    }
386
387    // Normalize direction
388    let dir_len =
389        (direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2])
390            .sqrt();
391    if dir_len < 1e-15 {
392        return result;
393    }
394    let dir = [
395        direction[0] / dir_len,
396        direction[1] / dir_len,
397        direction[2] / dir_len,
398    ];
399
400    // Current cell
401    let mut cell = [
402        (origin[0] / cell_size).floor() as i32,
403        (origin[1] / cell_size).floor() as i32,
404        (origin[2] / cell_size).floor() as i32,
405    ];
406
407    // Step direction (+1 or -1) and t_delta (how much t to cross one cell)
408    let mut step = [0i32; 3];
409    let mut t_max = [0.0f64; 3]; // t at which ray crosses next cell boundary
410    let mut t_delta = [f64::INFINITY; 3];
411
412    for i in 0..3 {
413        if dir[i] > 0.0 {
414            step[i] = 1;
415            let next_boundary = (cell[i] as f64 + 1.0) * cell_size;
416            t_max[i] = (next_boundary - origin[i]) / dir[i];
417            t_delta[i] = cell_size / dir[i];
418        } else if dir[i] < 0.0 {
419            step[i] = -1;
420            let next_boundary = cell[i] as f64 * cell_size;
421            t_max[i] = (next_boundary - origin[i]) / dir[i];
422            t_delta[i] = cell_size / (-dir[i]);
423        } else {
424            step[i] = 0;
425            t_max[i] = f64::INFINITY;
426            t_delta[i] = f64::INFINITY;
427        }
428    }
429
430    let mut t = 0.0;
431    for _ in 0..max_steps {
432        // Find the axis with smallest t_max
433        let min_t = t_max[0].min(t_max[1]).min(t_max[2]);
434        let t_exit = min_t.min(max_t);
435
436        result.push(RayGridStep {
437            cell: (cell[0], cell[1], cell[2]),
438            t_enter: t,
439            t_exit,
440        });
441
442        if min_t >= max_t {
443            break;
444        }
445
446        // Advance along the axis with smallest t_max
447        if t_max[0] <= t_max[1] && t_max[0] <= t_max[2] {
448            cell[0] += step[0];
449            t = t_max[0];
450            t_max[0] += t_delta[0];
451        } else if t_max[1] <= t_max[2] {
452            cell[1] += step[1];
453            t = t_max[1];
454            t_max[1] += t_delta[1];
455        } else {
456            cell[2] += step[2];
457            t = t_max[2];
458            t_max[2] += t_delta[2];
459        }
460    }
461
462    result
463}
464
465// ─── LooseOctree ──────────────────────────────────────────────────────────────
466
467/// A loose octree node that stores items at every level.
468pub struct LooseOctree<T: Clone> {
469    /// World-space centre of this node.
470    pub center: [f64; 3],
471    /// Half-size of the *tight* cell (the loose cell is `2 * half_size`).
472    pub half_size: f64,
473    /// Remaining subdivision depth (0 = leaf).
474    pub depth: u32,
475    /// Items stored at this node (not yet pushed to children).
476    pub items: Vec<(usize, [f64; 3], T)>,
477    /// Eight children, present after the first subdivision.
478    pub children: Option<Box<[LooseOctree<T>; 8]>>,
479}
480
481impl<T: Clone> LooseOctree<T> {
482    /// Creates a new root node.
483    pub fn new(center: [f64; 3], half_size: f64, max_depth: u32) -> Self {
484        Self {
485            center,
486            half_size,
487            depth: max_depth,
488            items: Vec::new(),
489            children: None,
490        }
491    }
492
493    /// Returns the octant index (0..7) for `pos` relative to `self.center`.
494    pub fn child_index(&self, pos: [f64; 3]) -> usize {
495        let mut idx = 0usize;
496        if pos[0] >= self.center[0] {
497            idx |= 1;
498        }
499        if pos[1] >= self.center[1] {
500            idx |= 2;
501        }
502        if pos[2] >= self.center[2] {
503            idx |= 4;
504        }
505        idx
506    }
507
508    /// Builds child centres for octant `i`.
509    fn child_center(&self, i: usize) -> [f64; 3] {
510        let q = self.half_size * 0.5;
511        [
512            self.center[0] + if i & 1 != 0 { q } else { -q },
513            self.center[1] + if i & 2 != 0 { q } else { -q },
514            self.center[2] + if i & 4 != 0 { q } else { -q },
515        ]
516    }
517
518    /// Subdivides this node, creating eight children.
519    fn subdivide(&mut self) {
520        if self.children.is_some() {
521            return;
522        }
523        let child_depth = self.depth.saturating_sub(1);
524        let child_half = self.half_size * 0.5;
525        let children = std::array::from_fn(|i| {
526            LooseOctree::new(self.child_center(i), child_half, child_depth)
527        });
528        self.children = Some(Box::new(children));
529    }
530
531    /// Inserts `(id, pos, data)` into the tree.
532    pub fn insert(&mut self, id: usize, pos: [f64; 3], data: T, max_items: usize) {
533        self.items.push((id, pos, data.clone()));
534
535        if self.items.len() > max_items && self.depth > 0 {
536            self.subdivide();
537            let items: Vec<_> = self.items.drain(..).collect();
538            for (iid, ipos, idata) in items {
539                let ci = self.child_index(ipos);
540                if let Some(children) = self.children.as_mut() {
541                    children[ci].insert(iid, ipos, idata, max_items);
542                }
543            }
544        }
545    }
546
547    /// Returns all items whose stored position is within `radius` of `center`.
548    pub fn query_sphere(&self, center: [f64; 3], radius: f64) -> Vec<(usize, &T)> {
549        let loose = self.half_size * 2.0;
550        let mut closest2 = 0.0f64;
551        for i in 0..3 {
552            let lo = self.center[i] - loose;
553            let hi = self.center[i] + loose;
554            if center[i] < lo {
555                closest2 += (center[i] - lo).powi(2);
556            } else if center[i] > hi {
557                closest2 += (center[i] - hi).powi(2);
558            }
559        }
560        if closest2 > radius * radius {
561            return Vec::new();
562        }
563
564        let r2 = radius * radius;
565        let mut result = Vec::new();
566
567        for (id, pos, data) in &self.items {
568            let d2 = dist_sq(*pos, center);
569            if d2 <= r2 {
570                result.push((*id, data));
571            }
572        }
573
574        if let Some(children) = &self.children {
575            for child in children.iter() {
576                result.extend(child.query_sphere(center, radius));
577            }
578        }
579
580        result
581    }
582
583    /// Returns all items within an axis-aligned box.
584    pub fn query_aabb(&self, min: [f64; 3], max: [f64; 3]) -> Vec<(usize, &T)> {
585        // Quick rejection: does loose node box overlap query box?
586        let loose = self.half_size * 2.0;
587        for i in 0..3 {
588            if self.center[i] - loose > max[i] || self.center[i] + loose < min[i] {
589                return Vec::new();
590            }
591        }
592
593        let mut result = Vec::new();
594        for (id, pos, data) in &self.items {
595            if pos[0] >= min[0]
596                && pos[0] <= max[0]
597                && pos[1] >= min[1]
598                && pos[1] <= max[1]
599                && pos[2] >= min[2]
600                && pos[2] <= max[2]
601            {
602                result.push((*id, data));
603            }
604        }
605
606        if let Some(children) = &self.children {
607            for child in children.iter() {
608                result.extend(child.query_aabb(min, max));
609            }
610        }
611
612        result
613    }
614
615    /// Counts all items stored in this node and all descendants.
616    pub fn item_count(&self) -> usize {
617        let mut count = self.items.len();
618        if let Some(children) = &self.children {
619            for child in children.iter() {
620                count += child.item_count();
621            }
622        }
623        count
624    }
625
626    /// Returns the maximum depth reached in the tree.
627    pub fn max_depth_reached(&self) -> u32 {
628        if let Some(children) = &self.children {
629            let child_max = children
630                .iter()
631                .map(|c| c.max_depth_reached())
632                .max()
633                .unwrap_or(0);
634            child_max + 1
635        } else {
636            0
637        }
638    }
639
640    /// Count the total number of nodes in the tree.
641    pub fn node_count(&self) -> usize {
642        let mut count = 1;
643        if let Some(children) = &self.children {
644            for child in children.iter() {
645                count += child.node_count();
646            }
647        }
648        count
649    }
650
651    /// K-nearest neighbours search via the octree.
652    pub fn k_nearest(&self, center: [f64; 3], k: usize) -> Vec<(usize, f64, &T)> {
653        // Use a max-heap to keep track of the k closest
654        let mut heap: BinaryHeap<KnnEntry<&T>> = BinaryHeap::new();
655        self.knn_recurse(center, k, &mut heap);
656
657        let mut result: Vec<(usize, f64, &T)> =
658            heap.into_iter().map(|e| (e.id, e.dist, e.data)).collect();
659        result.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
660        result
661    }
662
663    fn knn_recurse<'a>(
664        &'a self,
665        center: [f64; 3],
666        k: usize,
667        heap: &mut BinaryHeap<KnnEntry<&'a T>>,
668    ) {
669        // Check items at this node
670        for (id, pos, data) in &self.items {
671            let d = dist_sq(*pos, center).sqrt();
672            if heap.len() < k {
673                heap.push(KnnEntry {
674                    dist: d,
675                    id: *id,
676                    data,
677                });
678            } else if let Some(top) = heap.peek()
679                && d < top.dist
680            {
681                heap.pop();
682                heap.push(KnnEntry {
683                    dist: d,
684                    id: *id,
685                    data,
686                });
687            }
688        }
689
690        // Recurse into children that could contain closer points
691        if let Some(children) = &self.children {
692            for child in children.iter() {
693                // Minimum distance from center to child's loose box
694                let loose = child.half_size * 2.0;
695                let mut min_d2 = 0.0f64;
696                for i in 0..3 {
697                    let lo = child.center[i] - loose;
698                    let hi = child.center[i] + loose;
699                    if center[i] < lo {
700                        min_d2 += (center[i] - lo).powi(2);
701                    } else if center[i] > hi {
702                        min_d2 += (center[i] - hi).powi(2);
703                    }
704                }
705                let min_d = min_d2.sqrt();
706                let should_search =
707                    heap.len() < k || heap.peek().is_some_and(|top| min_d < top.dist);
708                if should_search {
709                    child.knn_recurse(center, k, heap);
710                }
711            }
712        }
713    }
714}
715
716/// Internal entry for KNN max-heap (ordered by distance descending).
717struct KnnEntry<D> {
718    dist: f64,
719    id: usize,
720    data: D,
721}
722
723impl<D> PartialEq for KnnEntry<D> {
724    fn eq(&self, other: &Self) -> bool {
725        self.dist == other.dist
726    }
727}
728
729impl<D> Eq for KnnEntry<D> {}
730
731impl<D> PartialOrd for KnnEntry<D> {
732    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
733        Some(self.cmp(other))
734    }
735}
736
737impl<D> Ord for KnnEntry<D> {
738    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
739        self.dist
740            .partial_cmp(&other.dist)
741            .unwrap_or(std::cmp::Ordering::Equal)
742    }
743}
744
745// ─── Parallel Spatial Hash ────────────────────────────────────────────────────
746
747/// A parallel-build spatial hash.
748///
749/// All entries are inserted in one batch call, making the build trivially
750/// parallelisable (simulated here sequentially).  Queries are identical to
751/// `SpatialHashPos3D`.
752pub struct ParallelSpatialHash<T: Clone> {
753    inner: SpatialHashPos3D<T>,
754}
755
756impl<T: Clone> ParallelSpatialHash<T> {
757    /// Build from a slice of `(id, position, data)` tuples.
758    pub fn build(entries: &[(usize, [f64; 3], T)], cell_size: f64) -> Self {
759        let mut inner = SpatialHashPos3D::new(cell_size);
760        for (id, pos, data) in entries {
761            inner.insert(*id, *pos, data.clone());
762        }
763        Self { inner }
764    }
765
766    /// Query all items within `radius` of `center`.
767    pub fn query_radius(&self, center: [f64; 3], radius: f64) -> Vec<(usize, &T)> {
768        self.inner.query_radius(center, radius)
769    }
770
771    /// Query all items within an AABB.
772    pub fn query_aabb(&self, min: [f64; 3], max: [f64; 3]) -> Vec<(usize, &T)> {
773        self.inner.query_aabb(min, max)
774    }
775
776    /// Number of items.
777    pub fn len(&self) -> usize {
778        self.inner.len()
779    }
780
781    /// Whether the hash is empty.
782    pub fn is_empty(&self) -> bool {
783        self.inner.is_empty()
784    }
785
786    /// Number of occupied cells.
787    pub fn n_cells(&self) -> usize {
788        self.inner.n_cells()
789    }
790}
791
792// ─── Spatial Hash Merging ─────────────────────────────────────────────────────
793
794/// Merge two `SpatialHashPos3D` instances (same cell size) into a new one.
795///
796/// IDs are *not* re-numbered; callers must ensure they are disjoint.
797pub fn merge_spatial_hashes<T: Clone>(
798    a: &SpatialHashPos3D<T>,
799    b: &SpatialHashPos3D<T>,
800) -> SpatialHashPos3D<T> {
801    assert!(
802        (a.cell_size - b.cell_size).abs() < 1e-12,
803        "merge requires identical cell sizes"
804    );
805    let mut merged = SpatialHashPos3D::new(a.cell_size);
806
807    // Copy all entries from a
808    for (key, bucket) in &a.cells {
809        for (id, pos, data) in bucket {
810            // Insert directly into merged map
811            merged
812                .cells
813                .entry(*key)
814                .or_default()
815                .push((*id, *pos, data.clone()));
816            merged.n_items += 1;
817        }
818    }
819
820    // Copy all entries from b
821    for (key, bucket) in &b.cells {
822        for (id, pos, data) in bucket {
823            merged
824                .cells
825                .entry(*key)
826                .or_default()
827                .push((*id, *pos, data.clone()));
828            merged.n_items += 1;
829        }
830    }
831
832    merged
833}
834
835// ─── Persistent Spatial Hash ──────────────────────────────────────────────────
836
837/// A spatial hash that persists across frames.
838///
839/// Tracks the last known position of each object so that `insert_or_update`
840/// correctly removes the object from its old cell before re-inserting.
841pub struct PersistentSpatialHash<T: Clone> {
842    /// Underlying hash with positions.
843    hash: SpatialHashPos3D<T>,
844    /// Tracks last position of each object: `id → last_position`.
845    last_positions: HashMap<usize, [f64; 3]>,
846    /// Current frame number.
847    frame: u64,
848}
849
850impl<T: Clone> PersistentSpatialHash<T> {
851    /// Create a new persistent hash with the given cell size.
852    pub fn new(cell_size: f64) -> Self {
853        Self {
854            hash: SpatialHashPos3D::new(cell_size),
855            last_positions: HashMap::new(),
856            frame: 0,
857        }
858    }
859
860    /// Insert a new object or update an existing one.
861    ///
862    /// If the object already exists, it is first removed from its old cell.
863    pub fn insert_or_update(&mut self, id: usize, pos: [f64; 3], data: T) {
864        if let Some(&old_pos) = self.last_positions.get(&id) {
865            self.hash.remove(id, old_pos);
866        }
867        self.hash.insert(id, pos, data);
868        self.last_positions.insert(id, pos);
869    }
870
871    /// Remove an object.
872    pub fn remove(&mut self, id: usize) {
873        if let Some(&pos) = self.last_positions.get(&id) {
874            self.hash.remove(id, pos);
875            self.last_positions.remove(&id);
876        }
877    }
878
879    /// Query all items within `radius` of `center`.
880    pub fn query_radius(&self, center: [f64; 3], radius: f64) -> Vec<(usize, &T)> {
881        self.hash.query_radius(center, radius)
882    }
883
884    /// Query all items in an AABB.
885    pub fn query_aabb(&self, min: [f64; 3], max: [f64; 3]) -> Vec<(usize, &T)> {
886        self.hash.query_aabb(min, max)
887    }
888
889    /// Number of objects.
890    pub fn len(&self) -> usize {
891        self.hash.len()
892    }
893
894    /// Whether the hash is empty.
895    pub fn is_empty(&self) -> bool {
896        self.hash.is_empty()
897    }
898
899    /// Advance the frame counter (call once per simulation step).
900    pub fn advance_frame(&mut self) {
901        self.frame += 1;
902    }
903
904    /// Current frame number.
905    pub fn current_frame(&self) -> u64 {
906        self.frame
907    }
908
909    /// Compute statistics.
910    pub fn statistics(&self) -> SpatialHashStats {
911        self.hash.statistics()
912    }
913}
914
915// ─── GPU-Friendly Spatial Hash Layout ────────────────────────────────────────
916
917/// A spatial hash with an interleaved, flat-array layout for GPU upload.
918///
919/// Data is stored as sorted arrays of cell keys and per-item records,
920/// matching the layout expected by GPU compute shaders.
921pub struct GpuSpatialHashLayout<T: Clone> {
922    cell_size: f32,
923    /// Sorted cell keys.
924    cell_keys: Vec<(i32, i32, i32)>,
925    /// Cell start/end indices into `items`.
926    cell_offsets: Vec<(usize, usize)>,
927    /// Items sorted by cell key: (id, position, data).
928    items: Vec<(usize, [f32; 3], T)>,
929}
930
931impl<T: Clone> GpuSpatialHashLayout<T> {
932    /// Build the GPU-friendly layout from a slice of `(id, position, data)`.
933    pub fn build(entries: &[(usize, [f32; 3], T)], cell_size: f32) -> Self {
934        // Bucket entries by cell key
935        let mut buckets: HashMap<(i32, i32, i32), Vec<(usize, [f32; 3], T)>> = HashMap::new();
936        for (id, pos, data) in entries {
937            let key = (
938                (pos[0] / cell_size).floor() as i32,
939                (pos[1] / cell_size).floor() as i32,
940                (pos[2] / cell_size).floor() as i32,
941            );
942            buckets
943                .entry(key)
944                .or_default()
945                .push((*id, *pos, data.clone()));
946        }
947
948        // Sort cell keys for cache-friendly GPU traversal
949        let mut cell_keys: Vec<(i32, i32, i32)> = buckets.keys().copied().collect();
950        cell_keys.sort_unstable();
951
952        let mut items = Vec::new();
953        let mut cell_offsets = Vec::new();
954
955        for &key in &cell_keys {
956            let start = items.len();
957            for entry in &buckets[&key] {
958                items.push(entry.clone());
959            }
960            cell_offsets.push((start, items.len()));
961        }
962
963        Self {
964            cell_size,
965            cell_keys,
966            cell_offsets,
967            items,
968        }
969    }
970
971    /// Total number of items.
972    pub fn n_items(&self) -> usize {
973        self.items.len()
974    }
975
976    /// Number of non-empty cells.
977    pub fn n_cells(&self) -> usize {
978        self.cell_keys.len()
979    }
980
981    /// Query items in a specific cell.
982    pub fn query_cell_flat(&self, cx: i32, cy: i32, cz: i32) -> Vec<(usize, &T)> {
983        let key = (cx, cy, cz);
984        if let Ok(pos) = self.cell_keys.binary_search(&key) {
985            let (start, end) = self.cell_offsets[pos];
986            self.items[start..end]
987                .iter()
988                .map(|(id, _, data)| (*id, data))
989                .collect()
990        } else {
991            Vec::new()
992        }
993    }
994
995    /// Export all items as a flat `f32` buffer for GPU upload.
996    ///
997    /// Format per item: `[x, y, z, cell_x_f32, cell_y_f32, cell_z_f32]`
998    pub fn to_flat_f32_buffer(&self) -> Vec<f32> {
999        let mut buf = Vec::with_capacity(self.items.len() * 6);
1000        for (item_idx, (_, pos, _)) in self.items.iter().enumerate() {
1001            // Find which cell this item belongs to
1002            let key = self
1003                .cell_keys
1004                .iter()
1005                .enumerate()
1006                .find(|(ci, _)| {
1007                    let (start, end) = self.cell_offsets[*ci];
1008                    item_idx >= start && item_idx < end
1009                })
1010                .map(|(_, &k)| k)
1011                .unwrap_or((0, 0, 0));
1012            buf.push(pos[0]);
1013            buf.push(pos[1]);
1014            buf.push(pos[2]);
1015            buf.push(key.0 as f32);
1016            buf.push(key.1 as f32);
1017            buf.push(key.2 as f32);
1018        }
1019        buf
1020    }
1021
1022    /// Cell size used for this layout.
1023    pub fn cell_size(&self) -> f32 {
1024        self.cell_size
1025    }
1026}
1027
1028// ─── Tests ────────────────────────────────────────────────────────────────────
1029
1030#[cfg(test)]
1031mod tests {
1032    use super::*;
1033
1034    // ── SpatialHash3D ──────────────────────────────────────────────────────
1035
1036    #[test]
1037    fn spatial_hash_basic_insert_query() {
1038        let mut sh: SpatialHash3D<&str> = SpatialHash3D::new(1.0);
1039        sh.insert(0, [0.5, 0.5, 0.5], "a");
1040        assert_eq!(sh.len(), 1);
1041        assert_eq!(sh.n_cells(), 1);
1042        let items = sh.query_cell((0, 0, 0));
1043        assert_eq!(items.len(), 1);
1044        assert_eq!(items[0].0, 0);
1045    }
1046
1047    #[test]
1048    fn spatial_hash_remove() {
1049        let mut sh: SpatialHash3D<i32> = SpatialHash3D::new(1.0);
1050        sh.insert(0, [0.0, 0.0, 0.0], 42);
1051        sh.insert(1, [0.5, 0.0, 0.0], 7);
1052        sh.remove(0, [0.0, 0.0, 0.0]);
1053        assert_eq!(sh.len(), 1);
1054    }
1055
1056    #[test]
1057    fn spatial_hash_cell_key() {
1058        let sh: SpatialHash3D<()> = SpatialHash3D::new(2.0);
1059        assert_eq!(sh.cell_key([0.5, 0.5, 0.5]), (0, 0, 0));
1060        assert_eq!(sh.cell_key([2.5, 4.5, -0.5]), (1, 2, -1));
1061    }
1062
1063    // ── SpatialHashPos3D ───────────────────────────────────────────────────
1064
1065    #[test]
1066    fn spatial_hash_insert_query_radius() {
1067        let mut sh: SpatialHashPos3D<&str> = SpatialHashPos3D::new(1.0);
1068        sh.insert(0, [0.0, 0.0, 0.0], "origin");
1069        sh.insert(1, [0.5, 0.0, 0.0], "near");
1070        sh.insert(2, [10.0, 0.0, 0.0], "far");
1071
1072        let results = sh.query_radius([0.0, 0.0, 0.0], 1.0);
1073        let ids: Vec<usize> = results.iter().map(|(id, _)| *id).collect();
1074        assert!(ids.contains(&0), "origin should be found");
1075        assert!(ids.contains(&1), "near should be found");
1076        assert!(!ids.contains(&2), "far should NOT be found");
1077    }
1078
1079    #[test]
1080    fn spatial_hash_clear_len_zero() {
1081        let mut sh: SpatialHashPos3D<i32> = SpatialHashPos3D::new(1.0);
1082        sh.insert(0, [0.0, 0.0, 0.0], 42);
1083        sh.insert(1, [1.0, 1.0, 1.0], 7);
1084        assert_eq!(sh.len(), 2);
1085        sh.clear();
1086        assert_eq!(sh.len(), 0);
1087        assert!(sh.is_empty());
1088    }
1089
1090    #[test]
1091    fn spatial_hash_query_radius_excludes_outside() {
1092        let mut sh: SpatialHashPos3D<u32> = SpatialHashPos3D::new(2.0);
1093        let radius = 1.5_f64;
1094        sh.insert(0, [0.0, 0.0, 0.0], 0);
1095        sh.insert(1, [radius + 0.1, 0.0, 0.0], 1);
1096        sh.insert(2, [radius - 0.1, 0.0, 0.0], 2);
1097
1098        let results = sh.query_radius([0.0, 0.0, 0.0], radius);
1099        let ids: Vec<usize> = results.iter().map(|(id, _)| *id).collect();
1100        assert!(ids.contains(&0));
1101        assert!(ids.contains(&2));
1102        assert!(
1103            !ids.contains(&1),
1104            "item just outside radius must be excluded"
1105        );
1106    }
1107
1108    #[test]
1109    fn spatial_hash_query_aabb() {
1110        let mut sh: SpatialHashPos3D<&str> = SpatialHashPos3D::new(1.0);
1111        sh.insert(0, [0.5, 0.5, 0.5], "inside");
1112        sh.insert(1, [1.5, 0.5, 0.5], "inside2");
1113        sh.insert(2, [5.0, 5.0, 5.0], "outside");
1114
1115        let results = sh.query_aabb([0.0, 0.0, 0.0], [2.0, 1.0, 1.0]);
1116        let ids: Vec<usize> = results.iter().map(|(id, _)| *id).collect();
1117        assert!(ids.contains(&0));
1118        assert!(ids.contains(&1));
1119        assert!(!ids.contains(&2));
1120    }
1121
1122    #[test]
1123    fn spatial_hash_k_nearest() {
1124        let mut sh: SpatialHashPos3D<&str> = SpatialHashPos3D::new(1.0);
1125        sh.insert(0, [0.0, 0.0, 0.0], "a");
1126        sh.insert(1, [1.0, 0.0, 0.0], "b");
1127        sh.insert(2, [2.0, 0.0, 0.0], "c");
1128        sh.insert(3, [10.0, 0.0, 0.0], "d");
1129
1130        let nearest = sh.k_nearest([0.0, 0.0, 0.0], 2);
1131        assert_eq!(nearest.len(), 2);
1132        assert_eq!(nearest[0].0, 0, "closest should be item 0");
1133        assert_eq!(nearest[1].0, 1, "second closest should be item 1");
1134    }
1135
1136    #[test]
1137    fn spatial_hash_k_nearest_more_than_items() {
1138        let mut sh: SpatialHashPos3D<i32> = SpatialHashPos3D::new(1.0);
1139        sh.insert(0, [0.0, 0.0, 0.0], 1);
1140
1141        let nearest = sh.k_nearest([0.0, 0.0, 0.0], 5);
1142        assert_eq!(nearest.len(), 1, "should return all items when k > n_items");
1143    }
1144
1145    #[test]
1146    fn spatial_hash_statistics() {
1147        let mut sh: SpatialHashPos3D<i32> = SpatialHashPos3D::new(1.0);
1148        sh.insert(0, [0.0, 0.0, 0.0], 1);
1149        sh.insert(1, [0.5, 0.0, 0.0], 2);
1150        sh.insert(2, [5.0, 5.0, 5.0], 3);
1151
1152        let stats = sh.statistics();
1153        assert_eq!(stats.n_items, 3);
1154        assert_eq!(stats.n_occupied_cells, 2);
1155        assert_eq!(stats.max_bucket_size, 2);
1156        assert_eq!(stats.min_bucket_size, 1);
1157    }
1158
1159    #[test]
1160    fn spatial_hash_statistics_empty() {
1161        let sh: SpatialHashPos3D<i32> = SpatialHashPos3D::new(1.0);
1162        let stats = sh.statistics();
1163        assert_eq!(stats.n_items, 0);
1164        assert_eq!(stats.n_occupied_cells, 0);
1165    }
1166
1167    #[test]
1168    fn spatial_hash_update_position() {
1169        let mut sh: SpatialHashPos3D<&str> = SpatialHashPos3D::new(1.0);
1170        sh.insert(0, [0.0, 0.0, 0.0], "a");
1171        sh.update_position(0, [0.0, 0.0, 0.0], [5.0, 5.0, 5.0], "a");
1172        assert_eq!(sh.len(), 1);
1173
1174        // Should no longer be at origin
1175        let at_origin = sh.query_radius([0.0, 0.0, 0.0], 0.5);
1176        assert!(at_origin.is_empty(), "item should no longer be at origin");
1177
1178        // Should be at new position
1179        let at_new = sh.query_radius([5.0, 5.0, 5.0], 0.5);
1180        assert_eq!(at_new.len(), 1);
1181    }
1182
1183    // ── Ray traversal ──────────────────────────────────────────────────────
1184
1185    #[test]
1186    fn ray_traverse_along_x_axis() {
1187        let steps = ray_traverse_grid([0.5, 0.5, 0.5], [1.0, 0.0, 0.0], 1.0, 3.0, 100);
1188        assert!(!steps.is_empty());
1189        // Should visit cells (0,0,0), (1,0,0), (2,0,0), (3,0,0)
1190        let cells: Vec<(i32, i32, i32)> = steps.iter().map(|s| s.cell).collect();
1191        assert!(cells.contains(&(0, 0, 0)));
1192        assert!(cells.contains(&(1, 0, 0)));
1193        assert!(cells.contains(&(2, 0, 0)));
1194    }
1195
1196    #[test]
1197    fn ray_traverse_diagonal() {
1198        let steps = ray_traverse_grid([0.5, 0.5, 0.5], [1.0, 1.0, 0.0], 1.0, 3.0, 100);
1199        assert!(!steps.is_empty());
1200        // First cell should be (0,0,0)
1201        assert_eq!(steps[0].cell, (0, 0, 0));
1202    }
1203
1204    #[test]
1205    fn ray_traverse_zero_direction() {
1206        let steps = ray_traverse_grid([0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 1.0, 10.0, 100);
1207        assert!(steps.is_empty(), "zero direction should produce no steps");
1208    }
1209
1210    #[test]
1211    fn ray_traverse_negative_direction() {
1212        let steps = ray_traverse_grid([2.5, 0.5, 0.5], [-1.0, 0.0, 0.0], 1.0, 3.0, 100);
1213        assert!(!steps.is_empty());
1214        let cells: Vec<(i32, i32, i32)> = steps.iter().map(|s| s.cell).collect();
1215        assert!(cells.contains(&(2, 0, 0)));
1216        assert!(cells.contains(&(1, 0, 0)));
1217        assert!(cells.contains(&(0, 0, 0)));
1218    }
1219
1220    #[test]
1221    fn ray_traverse_max_steps_limit() {
1222        let steps = ray_traverse_grid([0.5, 0.5, 0.5], [1.0, 0.0, 0.0], 1.0, 1000.0, 5);
1223        assert!(steps.len() <= 5, "should respect max_steps limit");
1224    }
1225
1226    // ── LooseOctree ────────────────────────────────────────────────────────
1227
1228    #[test]
1229    fn loose_octree_insert_query_sphere() {
1230        let mut tree: LooseOctree<&str> = LooseOctree::new([0.0; 3], 16.0, 4);
1231        tree.insert(0, [1.0, 0.0, 0.0], "a", 2);
1232        tree.insert(1, [0.0, 1.0, 0.0], "b", 2);
1233        tree.insert(2, [100.0, 0.0, 0.0], "far", 2);
1234
1235        let results = tree.query_sphere([0.0, 0.0, 0.0], 5.0);
1236        let ids: Vec<usize> = results.iter().map(|(id, _)| *id).collect();
1237        assert!(ids.contains(&0), "item 0 should be found");
1238        assert!(ids.contains(&1), "item 1 should be found");
1239        assert!(!ids.contains(&2), "far item must not appear");
1240    }
1241
1242    #[test]
1243    fn loose_octree_item_count() {
1244        let mut tree: LooseOctree<i32> = LooseOctree::new([0.0; 3], 32.0, 3);
1245        for i in 0..5_usize {
1246            tree.insert(i, [i as f64, 0.0, 0.0], i as i32, 2);
1247        }
1248        assert!(
1249            tree.item_count() >= 5,
1250            "item_count should be at least 5, got {}",
1251            tree.item_count()
1252        );
1253    }
1254
1255    #[test]
1256    fn loose_octree_query_aabb() {
1257        let mut tree: LooseOctree<&str> = LooseOctree::new([0.0; 3], 16.0, 4);
1258        tree.insert(0, [1.0, 1.0, 1.0], "inside", 2);
1259        tree.insert(1, [-1.0, -1.0, -1.0], "inside2", 2);
1260        tree.insert(2, [50.0, 50.0, 50.0], "outside", 2);
1261
1262        let results = tree.query_aabb([-2.0, -2.0, -2.0], [2.0, 2.0, 2.0]);
1263        let ids: Vec<usize> = results.iter().map(|(id, _)| *id).collect();
1264        assert!(ids.contains(&0));
1265        assert!(ids.contains(&1));
1266        assert!(!ids.contains(&2));
1267    }
1268
1269    #[test]
1270    fn loose_octree_k_nearest() {
1271        let mut tree: LooseOctree<&str> = LooseOctree::new([0.0; 3], 32.0, 4);
1272        tree.insert(0, [0.0, 0.0, 0.0], "origin", 2);
1273        tree.insert(1, [1.0, 0.0, 0.0], "near", 2);
1274        tree.insert(2, [5.0, 0.0, 0.0], "mid", 2);
1275        tree.insert(3, [100.0, 0.0, 0.0], "far", 2);
1276
1277        let nearest = tree.k_nearest([0.0, 0.0, 0.0], 2);
1278        assert_eq!(nearest.len(), 2);
1279        assert_eq!(nearest[0].0, 0, "closest should be item 0");
1280        assert_eq!(nearest[1].0, 1, "second closest should be item 1");
1281    }
1282
1283    #[test]
1284    fn loose_octree_max_depth() {
1285        let mut tree: LooseOctree<i32> = LooseOctree::new([0.0; 3], 32.0, 4);
1286        // Insert enough items to trigger subdivision
1287        for i in 0..10_usize {
1288            tree.insert(i, [i as f64 * 0.1, 0.0, 0.0], i as i32, 2);
1289        }
1290        let depth = tree.max_depth_reached();
1291        assert!(depth > 0, "should have subdivided at least once");
1292    }
1293
1294    #[test]
1295    fn loose_octree_node_count() {
1296        let tree: LooseOctree<i32> = LooseOctree::new([0.0; 3], 32.0, 4);
1297        assert_eq!(tree.node_count(), 1, "empty tree has one root node");
1298    }
1299
1300    #[test]
1301    fn loose_octree_node_count_after_subdivision() {
1302        let mut tree: LooseOctree<i32> = LooseOctree::new([0.0; 3], 32.0, 4);
1303        for i in 0..10_usize {
1304            tree.insert(i, [i as f64, 0.0, 0.0], i as i32, 2);
1305        }
1306        assert!(tree.node_count() > 1, "should have created child nodes");
1307    }
1308
1309    // ── Parallel spatial hash tests ────────────────────────────────────
1310
1311    #[test]
1312    fn parallel_spatial_hash_build_query() {
1313        let entries: Vec<(usize, [f64; 3], i32)> = (0..8)
1314            .map(|i| (i, [i as f64, 0.0, 0.0], i as i32))
1315            .collect();
1316        let sh = ParallelSpatialHash::build(&entries, 2.0);
1317        let results = sh.query_radius([0.0, 0.0, 0.0], 2.5);
1318        let ids: Vec<usize> = results.iter().map(|(id, _)| *id).collect();
1319        assert!(ids.contains(&0));
1320        assert!(ids.contains(&1));
1321        assert!(ids.contains(&2));
1322    }
1323
1324    #[test]
1325    fn parallel_spatial_hash_len() {
1326        let entries: Vec<(usize, [f64; 3], &str)> =
1327            vec![(0, [0.0, 0.0, 0.0], "a"), (1, [1.0, 0.0, 0.0], "b")];
1328        let sh = ParallelSpatialHash::build(&entries, 1.0);
1329        assert_eq!(sh.len(), 2);
1330        assert!(!sh.is_empty());
1331    }
1332
1333    #[test]
1334    fn parallel_spatial_hash_empty() {
1335        let entries: Vec<(usize, [f64; 3], i32)> = Vec::new();
1336        let sh = ParallelSpatialHash::build(&entries, 1.0);
1337        assert!(sh.is_empty());
1338        assert_eq!(sh.len(), 0);
1339    }
1340
1341    // ── Spatial hash merging tests ─────────────────────────────────────
1342
1343    #[test]
1344    fn spatial_hash_merge_basic() {
1345        let mut a: SpatialHashPos3D<i32> = SpatialHashPos3D::new(1.0);
1346        a.insert(0, [0.0, 0.0, 0.0], 10);
1347        a.insert(1, [1.0, 0.0, 0.0], 20);
1348
1349        let mut b: SpatialHashPos3D<i32> = SpatialHashPos3D::new(1.0);
1350        b.insert(2, [5.0, 0.0, 0.0], 30);
1351        b.insert(3, [6.0, 0.0, 0.0], 40);
1352
1353        let merged = merge_spatial_hashes(&a, &b);
1354        assert_eq!(merged.len(), 4);
1355    }
1356
1357    #[test]
1358    fn spatial_hash_merge_query_all() {
1359        let mut a: SpatialHashPos3D<&str> = SpatialHashPos3D::new(1.0);
1360        a.insert(0, [0.0, 0.0, 0.0], "a");
1361
1362        let mut b: SpatialHashPos3D<&str> = SpatialHashPos3D::new(1.0);
1363        b.insert(1, [10.0, 0.0, 0.0], "b");
1364
1365        let merged = merge_spatial_hashes(&a, &b);
1366        let r0 = merged.query_radius([0.0, 0.0, 0.0], 0.5);
1367        let r1 = merged.query_radius([10.0, 0.0, 0.0], 0.5);
1368        assert_eq!(r0.len(), 1);
1369        assert_eq!(r1.len(), 1);
1370    }
1371
1372    // ── Persistent spatial hash tests ─────────────────────────────────
1373
1374    #[test]
1375    fn persistent_hash_update_positions() {
1376        let mut ph: PersistentSpatialHash<i32> = PersistentSpatialHash::new(1.0);
1377        ph.insert_or_update(0, [0.0, 0.0, 0.0], 100);
1378        ph.insert_or_update(1, [5.0, 0.0, 0.0], 200);
1379
1380        // Query frame 0
1381        let r = ph.query_radius([0.0, 0.0, 0.0], 0.5);
1382        assert_eq!(r.len(), 1);
1383        assert_eq!(r[0].0, 0);
1384
1385        // Move item 0 to new position
1386        ph.insert_or_update(0, [5.5, 0.0, 0.0], 100);
1387
1388        let r2 = ph.query_radius([0.0, 0.0, 0.0], 0.5);
1389        assert!(r2.is_empty(), "item 0 should have moved");
1390    }
1391
1392    #[test]
1393    fn persistent_hash_remove() {
1394        let mut ph: PersistentSpatialHash<i32> = PersistentSpatialHash::new(1.0);
1395        ph.insert_or_update(0, [0.0, 0.0, 0.0], 1);
1396        ph.insert_or_update(1, [0.5, 0.0, 0.0], 2);
1397        ph.remove(0);
1398        assert_eq!(ph.len(), 1);
1399    }
1400
1401    #[test]
1402    fn persistent_hash_frame_advance() {
1403        let mut ph: PersistentSpatialHash<i32> = PersistentSpatialHash::new(1.0);
1404        ph.insert_or_update(0, [0.0, 0.0, 0.0], 42);
1405        ph.advance_frame();
1406        assert_eq!(ph.current_frame(), 1);
1407        // Data persists across frames
1408        let r = ph.query_radius([0.0, 0.0, 0.0], 0.5);
1409        assert_eq!(r.len(), 1);
1410    }
1411
1412    // ── GPU-friendly layout tests ──────────────────────────────────────
1413
1414    #[test]
1415    fn gpu_layout_build_basic() {
1416        let entries: Vec<(usize, [f32; 3], u32)> = vec![
1417            (0, [0.0, 0.0, 0.0], 10),
1418            (1, [1.0, 0.0, 0.0], 20),
1419            (2, [2.0, 0.0, 0.0], 30),
1420        ];
1421        let layout = GpuSpatialHashLayout::build(&entries, 1.0);
1422        assert_eq!(layout.n_items(), 3);
1423    }
1424
1425    #[test]
1426    fn gpu_layout_query_cell() {
1427        let entries: Vec<(usize, [f32; 3], u32)> = vec![
1428            (0, [0.5, 0.0, 0.0], 1),
1429            (1, [0.7, 0.0, 0.0], 2),
1430            (2, [5.0, 0.0, 0.0], 3),
1431        ];
1432        let layout = GpuSpatialHashLayout::build(&entries, 1.0);
1433        let cell_items = layout.query_cell_flat(0, 0, 0);
1434        assert_eq!(cell_items.len(), 2, "two items in cell (0,0,0)");
1435    }
1436
1437    #[test]
1438    fn gpu_layout_flat_buffer_format() {
1439        let entries: Vec<(usize, [f32; 3], u32)> = vec![(0, [1.0, 2.0, 3.0], 99)];
1440        let layout = GpuSpatialHashLayout::build(&entries, 1.0);
1441        let buf = layout.to_flat_f32_buffer();
1442        // Format: [x, y, z, cell_x as f32, cell_y as f32, cell_z as f32]
1443        assert!(!buf.is_empty());
1444        assert_eq!(buf[0], 1.0_f32);
1445        assert_eq!(buf[1], 2.0_f32);
1446        assert_eq!(buf[2], 3.0_f32);
1447    }
1448}