Skip to main content

sectorsync_core/
spatial_index.rs

1//! Low-level cell index for station-local AOI candidate queries.
2
3use std::collections::{HashMap, HashSet};
4
5use crate::ids::EntityHandle;
6use crate::spatial::{Aabb3, Bounds, CellCoord3, GridSpec, Position3};
7
8/// Occupancy count for one non-empty cell.
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub struct CellOccupancy {
11    /// Cell coordinate.
12    pub cell: CellCoord3,
13    /// Number of indexed entity handles in the cell.
14    pub entities: usize,
15}
16
17/// Strategy used by the last scratch-backed cell query.
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
19pub enum CellQueryStrategy {
20    /// Probe every cell touched by the query bounds.
21    #[default]
22    Grid,
23    /// Scan non-empty cells when the query covers a larger sparse volume.
24    OccupiedCells,
25}
26
27/// Work counters from the last scratch-backed cell query.
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
29pub struct CellQueryStats {
30    /// Query strategy selected from query volume and current index occupancy.
31    pub strategy: CellQueryStrategy,
32    /// Grid cells probed directly by a grid query.
33    pub grid_cells_probed: usize,
34    /// Non-empty cells inspected by an occupied-cell scan.
35    pub occupied_cells_scanned: usize,
36    /// Non-empty cells overlapping the query bounds.
37    pub matched_cells: usize,
38    /// Unique candidate handles produced by the query.
39    pub candidate_handles: usize,
40}
41
42/// Reusable scratch storage for allocation-aware cell queries.
43#[derive(Clone, Debug, Default)]
44pub struct CellQueryScratch {
45    seen: HashSet<EntityHandle>,
46    handles: Vec<EntityHandle>,
47    matching_cells: Vec<CellCoord3>,
48    stats: CellQueryStats,
49}
50
51impl CellQueryScratch {
52    /// Clears retained query results while keeping allocated capacity.
53    pub fn clear(&mut self) {
54        self.seen.clear();
55        self.handles.clear();
56        self.matching_cells.clear();
57        self.stats = CellQueryStats::default();
58    }
59
60    /// Returns handles produced by the last query.
61    pub fn handles(&self) -> &[EntityHandle] {
62        &self.handles
63    }
64
65    /// Number of handles produced by the last query.
66    pub fn len(&self) -> usize {
67        self.handles.len()
68    }
69
70    /// Returns whether the last query produced no handles.
71    pub fn is_empty(&self) -> bool {
72        self.handles.is_empty()
73    }
74
75    /// Work counters produced by the last query.
76    pub const fn stats(&self) -> CellQueryStats {
77        self.stats
78    }
79
80    /// Capacity retained for unique candidate handles.
81    pub fn handle_capacity(&self) -> usize {
82        self.handles.capacity()
83    }
84
85    /// Capacity retained by the candidate deduplication set.
86    pub fn dedup_capacity(&self) -> usize {
87        self.seen.capacity()
88    }
89
90    /// Capacity retained for occupied cells matched by sparse queries.
91    pub fn matching_cell_capacity(&self) -> usize {
92        self.matching_cells.capacity()
93    }
94}
95
96/// Station-local 3D cell index.
97#[derive(Clone, Debug)]
98pub struct CellIndex {
99    grid: GridSpec,
100    cells: HashMap<CellCoord3, Vec<EntityHandle>>,
101    entity_cells: HashMap<EntityHandle, Vec<CellCoord3>>,
102}
103
104impl CellIndex {
105    /// Creates an empty cell index.
106    pub fn new(grid: GridSpec) -> Self {
107        Self {
108            grid,
109            cells: HashMap::new(),
110            entity_cells: HashMap::new(),
111        }
112    }
113
114    /// Returns the grid spec.
115    pub const fn grid(&self) -> GridSpec {
116        self.grid
117    }
118
119    /// Inserts or updates an entity in all cells touched by its bounds.
120    pub fn upsert(&mut self, handle: EntityHandle, position: Position3, bounds: Bounds) {
121        self.remove(handle);
122        let cells = self.grid.cells_for_bounds(position, bounds);
123        for cell in &cells {
124            self.cells.entry(*cell).or_default().push(handle);
125        }
126        self.entity_cells.insert(handle, cells);
127    }
128
129    /// Removes an entity from the index.
130    pub fn remove(&mut self, handle: EntityHandle) -> bool {
131        let Some(cells) = self.entity_cells.remove(&handle) else {
132            return false;
133        };
134
135        for cell in cells {
136            let remove_cell = if let Some(handles) = self.cells.get_mut(&cell) {
137                handles.retain(|candidate| *candidate != handle);
138                handles.is_empty()
139            } else {
140                false
141            };
142            if remove_cell {
143                self.cells.remove(&cell);
144            }
145        }
146
147        true
148    }
149
150    /// Queries candidate handles overlapping an AABB.
151    pub fn query_aabb(&self, aabb: Aabb3) -> Vec<EntityHandle> {
152        let mut scratch = CellQueryScratch::default();
153        self.query_aabb_into(aabb, &mut scratch);
154        scratch.handles
155    }
156
157    /// Queries candidate handles overlapping an AABB using caller scratch.
158    pub fn query_aabb_into<'a>(
159        &self,
160        aabb: Aabb3,
161        scratch: &'a mut CellQueryScratch,
162    ) -> &'a [EntityHandle] {
163        scratch.clear();
164        let min = self.grid.cell_at(aabb.min);
165        let max = self.grid.cell_at(aabb.max);
166
167        let grid_cells = query_cell_volume(min, max);
168        if grid_cells <= self.cells.len() {
169            scratch.stats.strategy = CellQueryStrategy::Grid;
170            scratch.stats.grid_cells_probed = grid_cells;
171            for x in min.x..=max.x {
172                for y in min.y..=max.y {
173                    for z in min.z..=max.z {
174                        self.collect_cell(CellCoord3::new(x, y, z), scratch);
175                    }
176                }
177            }
178        } else {
179            scratch.stats.strategy = CellQueryStrategy::OccupiedCells;
180            scratch.stats.occupied_cells_scanned = self.cells.len();
181            scratch.matching_cells.extend(
182                self.cells
183                    .keys()
184                    .copied()
185                    .filter(|cell| cell_in_range(*cell, min, max)),
186            );
187            scratch.matching_cells.sort_unstable();
188            for index in 0..scratch.matching_cells.len() {
189                self.collect_cell(scratch.matching_cells[index], scratch);
190            }
191        }
192
193        scratch.stats.candidate_handles = scratch.handles.len();
194
195        scratch.handles()
196    }
197
198    /// Queries candidate handles inside cells touched by a sphere.
199    pub fn query_sphere(&self, center: Position3, radius: f32) -> Vec<EntityHandle> {
200        self.query_aabb(Bounds::Sphere { radius }.to_aabb(center))
201    }
202
203    /// Queries candidate handles inside cells touched by a sphere using caller scratch.
204    pub fn query_sphere_into<'a>(
205        &self,
206        center: Position3,
207        radius: f32,
208        scratch: &'a mut CellQueryScratch,
209    ) -> &'a [EntityHandle] {
210        self.query_aabb_into(Bounds::Sphere { radius }.to_aabb(center), scratch)
211    }
212
213    fn collect_cell(&self, cell: CellCoord3, scratch: &mut CellQueryScratch) {
214        if let Some(handles) = self.cells.get(&cell) {
215            scratch.stats.matched_cells = scratch.stats.matched_cells.saturating_add(1);
216            for handle in handles {
217                if scratch.seen.insert(*handle) {
218                    scratch.handles.push(*handle);
219                }
220            }
221        }
222    }
223
224    /// Returns handles indexed directly in one cell.
225    pub fn handles_in_cell(&self, cell: CellCoord3) -> Vec<EntityHandle> {
226        self.cells.get(&cell).cloned().unwrap_or_default()
227    }
228
229    /// Returns handles indexed directly in one cell without allocating.
230    pub fn handles_in_cell_slice(&self, cell: CellCoord3) -> &[EntityHandle] {
231        self.cells.get(&cell).map_or(&[], Vec::as_slice)
232    }
233
234    /// Returns cells currently occupied by one entity handle.
235    pub fn cells_for_handle(&self, handle: EntityHandle) -> Option<&[CellCoord3]> {
236        self.entity_cells.get(&handle).map(Vec::as_slice)
237    }
238
239    /// Number of indexed entities.
240    pub fn entity_count(&self) -> usize {
241        self.entity_cells.len()
242    }
243
244    /// Number of non-empty cells.
245    pub fn occupied_cell_count(&self) -> usize {
246        self.cells.len()
247    }
248
249    /// Returns deterministic occupancy counts for all non-empty cells.
250    pub fn cell_occupancy(&self) -> Vec<CellOccupancy> {
251        let mut cells = self
252            .cells
253            .iter()
254            .map(|(cell, handles)| CellOccupancy {
255                cell: *cell,
256                entities: handles.len(),
257            })
258            .collect::<Vec<_>>();
259        cells.sort_by_key(|occupancy| occupancy.cell);
260        cells
261    }
262}
263
264fn query_cell_volume(min: CellCoord3, max: CellCoord3) -> usize {
265    fn axis_cells(min: i32, max: i32) -> usize {
266        if max < min {
267            return 0;
268        }
269        usize::try_from(i64::from(max) - i64::from(min) + 1).unwrap_or(usize::MAX)
270    }
271
272    axis_cells(min.x, max.x)
273        .saturating_mul(axis_cells(min.y, max.y))
274        .saturating_mul(axis_cells(min.z, max.z))
275}
276
277const fn cell_in_range(cell: CellCoord3, min: CellCoord3, max: CellCoord3) -> bool {
278    cell.x >= min.x
279        && cell.x <= max.x
280        && cell.y >= min.y
281        && cell.y <= max.y
282        && cell.z >= min.z
283        && cell.z <= max.z
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn index_exposes_handles_by_cell() {
292        let grid = GridSpec::new(10.0).expect("valid grid");
293        let mut index = CellIndex::new(grid);
294        let handle = EntityHandle::new(1, 0);
295        index.upsert(handle, Position3::new(1.0, 2.0, 3.0), Bounds::Point);
296        let cell = grid.cell_at(Position3::new(1.0, 2.0, 3.0));
297
298        assert_eq!(index.handles_in_cell(cell), vec![handle]);
299        assert_eq!(index.handles_in_cell_slice(cell), &[handle]);
300        assert!(
301            index
302                .handles_in_cell_slice(CellCoord3::new(99, 99, 99))
303                .is_empty()
304        );
305        assert_eq!(index.cells_for_handle(handle), Some([cell].as_slice()));
306    }
307
308    #[test]
309    fn scratch_query_deduplicates_and_reuses_storage() {
310        let grid = GridSpec::new(10.0).expect("valid grid");
311        let mut index = CellIndex::new(grid);
312        let handle = EntityHandle::new(1, 0);
313        index.upsert(
314            handle,
315            Position3::new(9.0, 0.0, 0.0),
316            Bounds::Sphere { radius: 2.0 },
317        );
318        let mut scratch = CellQueryScratch::default();
319
320        let first = index.query_aabb_into(
321            Bounds::Sphere { radius: 4.0 }.to_aabb(Position3::new(10.0, 0.0, 0.0)),
322            &mut scratch,
323        );
324        assert_eq!(first, &[handle]);
325        assert_eq!(scratch.len(), 1);
326        assert_eq!(scratch.stats().strategy, CellQueryStrategy::Grid);
327        assert_eq!(scratch.stats().candidate_handles, 1);
328        assert!(scratch.handle_capacity() >= 1);
329        assert!(scratch.dedup_capacity() >= 1);
330
331        let second = index.query_aabb_into(
332            Bounds::Point.to_aabb(Position3::new(100.0, 0.0, 0.0)),
333            &mut scratch,
334        );
335        assert!(second.is_empty());
336        assert!(scratch.is_empty());
337    }
338
339    #[test]
340    fn sparse_large_query_scans_occupied_cells_deterministically() {
341        let grid = GridSpec::new(10.0).expect("valid grid");
342        let mut index = CellIndex::new(grid);
343        let high = EntityHandle::new(2, 0);
344        let low = EntityHandle::new(1, 0);
345        index.upsert(high, Position3::new(95.0, 0.0, 0.0), Bounds::Point);
346        index.upsert(low, Position3::new(-95.0, 0.0, 0.0), Bounds::Point);
347        let mut scratch = CellQueryScratch::default();
348
349        let handles = index.query_aabb_into(
350            Aabb3::new(
351                Position3::new(-100.0, -100.0, -100.0),
352                Position3::new(100.0, 100.0, 100.0),
353            ),
354            &mut scratch,
355        );
356
357        assert_eq!(handles, &[low, high]);
358        assert_eq!(scratch.stats().strategy, CellQueryStrategy::OccupiedCells);
359        assert_eq!(scratch.stats().occupied_cells_scanned, 2);
360        assert_eq!(scratch.stats().matched_cells, 2);
361        assert_eq!(scratch.stats().candidate_handles, 2);
362        assert!(scratch.matching_cell_capacity() >= 2);
363    }
364}