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
8const MAX_DENSE_DEDUP_SLOTS: usize = 262_144;
9
10/// Occupancy count for one non-empty cell.
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub struct CellOccupancy {
13    /// Cell coordinate.
14    pub cell: CellCoord3,
15    /// Number of indexed entity handles in the cell.
16    pub entities: usize,
17}
18
19/// Result of inserting or updating one entity in a [`CellIndex`].
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum CellIndexUpdate {
22    /// The handle was not indexed and has been inserted.
23    Inserted,
24    /// The handle already occupied the same cells; index storage was untouched.
25    Unchanged,
26    /// The handle moved to a different set of cells.
27    Relocated,
28}
29
30/// Work performed while changing one entity's spatial membership.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub struct CellIndexUpdateReport {
33    /// High-level membership outcome.
34    pub update: CellIndexUpdate,
35    /// Existing cells retained without bucket mutation.
36    pub retained_cells: usize,
37    /// Cells whose bucket membership was removed.
38    pub removed_cells: usize,
39    /// Cells whose bucket membership was inserted.
40    pub inserted_cells: usize,
41    /// Whether sorted multi-cell membership was updated by merge-diff.
42    pub incremental_diff: bool,
43}
44
45/// Caller-owned storage for allocation-light multi-cell membership changes.
46#[derive(Clone, Debug, Default)]
47pub struct CellIndexUpdateScratch {
48    cells: Vec<CellCoord3>,
49}
50
51impl CellIndexUpdateScratch {
52    /// Cell-coordinate capacity retained for the next membership change.
53    pub fn retained_cell_capacity(&self) -> usize {
54        self.cells.capacity()
55    }
56
57    /// Releases unused retained cell-coordinate storage.
58    pub fn reclaim_retained_capacity(&mut self) {
59        self.cells.clear();
60        self.cells.shrink_to_fit();
61    }
62}
63
64/// Strategy used by the last scratch-backed cell query.
65#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
66pub enum CellQueryStrategy {
67    /// Probe every cell touched by the query bounds.
68    #[default]
69    Grid,
70    /// Scan non-empty cells when the query covers a larger sparse volume.
71    OccupiedCells,
72}
73
74/// Work counters from the last scratch-backed cell query.
75#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
76pub struct CellQueryStats {
77    /// Query strategy selected from query volume and current index occupancy.
78    pub strategy: CellQueryStrategy,
79    /// Grid cells probed directly by a grid query.
80    pub grid_cells_probed: usize,
81    /// Non-empty cells inspected by an occupied-cell scan.
82    pub occupied_cells_scanned: usize,
83    /// Non-empty cells overlapping the query bounds.
84    pub matched_cells: usize,
85    /// Unique candidate handles produced by the query.
86    pub candidate_handles: usize,
87}
88
89/// Reusable scratch storage for allocation-aware cell queries.
90#[derive(Clone, Debug, Default)]
91pub struct CellQueryScratch {
92    seen_dense: Vec<u64>,
93    seen_collisions: Vec<u32>,
94    seen_sparse: HashSet<EntityHandle>,
95    query_epoch: u32,
96    handles: Vec<EntityHandle>,
97    matching_cells: Vec<CellCoord3>,
98    stats: CellQueryStats,
99}
100
101impl CellQueryScratch {
102    /// Clears retained query results while keeping allocated capacity.
103    pub fn clear(&mut self) {
104        self.begin_query();
105        self.handles.clear();
106        self.matching_cells.clear();
107        self.stats = CellQueryStats::default();
108    }
109
110    /// Returns handles produced by the last query.
111    pub fn handles(&self) -> &[EntityHandle] {
112        &self.handles
113    }
114
115    /// Number of handles produced by the last query.
116    pub fn len(&self) -> usize {
117        self.handles.len()
118    }
119
120    /// Returns whether the last query produced no handles.
121    pub fn is_empty(&self) -> bool {
122        self.handles.is_empty()
123    }
124
125    /// Work counters produced by the last query.
126    pub const fn stats(&self) -> CellQueryStats {
127        self.stats
128    }
129
130    /// Capacity retained for unique candidate handles.
131    pub fn handle_capacity(&self) -> usize {
132        self.handles.capacity()
133    }
134
135    /// Capacity retained by the candidate deduplication set.
136    pub fn dedup_capacity(&self) -> usize {
137        self.seen_dense
138            .capacity()
139            .saturating_add(self.seen_collisions.capacity())
140            .saturating_add(self.seen_sparse.capacity())
141    }
142
143    /// Capacity retained for occupied cells matched by sparse queries.
144    pub fn matching_cell_capacity(&self) -> usize {
145        self.matching_cells.capacity()
146    }
147
148    fn begin_query(&mut self) {
149        self.query_epoch = self.query_epoch.wrapping_add(1);
150        if self.query_epoch == 0 {
151            self.seen_dense.fill(0);
152            self.seen_collisions.fill(0);
153            self.query_epoch = 1;
154        }
155        self.seen_sparse.clear();
156    }
157
158    fn insert_seen(&mut self, handle: EntityHandle) -> bool {
159        let Ok(index) = usize::try_from(handle.index()) else {
160            return self.seen_sparse.insert(handle);
161        };
162        if index >= MAX_DENSE_DEDUP_SLOTS {
163            return self.seen_sparse.insert(handle);
164        }
165        if index >= self.seen_dense.len() {
166            self.seen_dense.resize(index + 1, 0);
167            self.seen_collisions.resize(index + 1, 0);
168        }
169        let marker = (u64::from(self.query_epoch) << 32) | u64::from(handle.generation());
170        let previous = self.seen_dense[index];
171        let previous_epoch = u32::try_from(previous >> 32).expect("marker epoch fits u32");
172        if previous_epoch != self.query_epoch {
173            self.seen_dense[index] = marker;
174            true
175        } else if self.seen_collisions[index] == self.query_epoch {
176            self.seen_sparse.insert(handle)
177        } else if previous == marker {
178            false
179        } else {
180            self.seen_collisions[index] = self.query_epoch;
181            let previous_generation =
182                u32::try_from(previous & u64::from(u32::MAX)).expect("marker generation fits u32");
183            self.seen_sparse
184                .insert(EntityHandle::new(handle.index(), previous_generation));
185            self.seen_sparse.insert(handle)
186        }
187    }
188}
189
190/// Internal compact membership representation for point and bounded entities.
191#[derive(Clone, Debug)]
192enum CellMembership {
193    Point(CellCoord3),
194    Multiple(Vec<CellCoord3>),
195}
196
197impl CellMembership {
198    fn as_slice(&self) -> &[CellCoord3] {
199        match self {
200            Self::Point(cell) => std::slice::from_ref(cell),
201            Self::Multiple(cells) => cells,
202        }
203    }
204
205    fn matches_range(&self, min: CellCoord3, max: CellCoord3) -> bool {
206        cells_match_range(self.as_slice(), min, max)
207    }
208}
209
210/// Station-local 3D cell index.
211#[derive(Clone, Debug)]
212pub struct CellIndex {
213    grid: GridSpec,
214    cells: HashMap<CellCoord3, Vec<EntityHandle>>,
215    entity_cells: HashMap<EntityHandle, CellMembership>,
216}
217
218impl CellIndex {
219    /// Creates an empty cell index.
220    pub fn new(grid: GridSpec) -> Self {
221        Self::with_capacity(grid, 0, 0)
222    }
223
224    /// Creates an empty index with explicit entity and occupied-cell capacity.
225    pub fn with_capacity(
226        grid: GridSpec,
227        entity_capacity: usize,
228        occupied_cell_capacity: usize,
229    ) -> Self {
230        Self {
231            grid,
232            cells: HashMap::with_capacity(occupied_cell_capacity),
233            entity_cells: HashMap::with_capacity(entity_capacity),
234        }
235    }
236
237    /// Reserves capacity for additional indexed entities and occupied cells.
238    pub fn reserve(&mut self, additional_entities: usize, additional_cells: usize) {
239        self.entity_cells.reserve(additional_entities);
240        self.cells.reserve(additional_cells);
241    }
242
243    /// Releases unused retained map, bucket, and multi-cell membership storage.
244    pub fn reclaim_retained_capacity(&mut self) {
245        for handles in self.cells.values_mut() {
246            handles.shrink_to_fit();
247        }
248        for membership in self.entity_cells.values_mut() {
249            if let CellMembership::Multiple(cells) = membership {
250                cells.shrink_to_fit();
251            }
252        }
253        self.cells.shrink_to_fit();
254        self.entity_cells.shrink_to_fit();
255    }
256
257    /// Indexed-entity entries currently retained without another rehash.
258    pub fn entity_capacity(&self) -> usize {
259        self.entity_cells.capacity()
260    }
261
262    /// Occupied-cell entries currently retained without another rehash.
263    pub fn occupied_cell_capacity(&self) -> usize {
264        self.cells.capacity()
265    }
266
267    /// Returns the grid spec.
268    pub const fn grid(&self) -> GridSpec {
269        self.grid
270    }
271
272    /// Inserts or updates an entity in all cells touched by its bounds.
273    pub fn upsert(&mut self, handle: EntityHandle, position: Position3, bounds: Bounds) {
274        self.upsert_tracked(handle, position, bounds);
275    }
276
277    /// Inserts or updates an entity and reports whether index membership changed.
278    pub fn upsert_tracked(
279        &mut self,
280        handle: EntityHandle,
281        position: Position3,
282        bounds: Bounds,
283    ) -> CellIndexUpdate {
284        let mut scratch = CellIndexUpdateScratch::default();
285        self.upsert_with_scratch(handle, position, bounds, &mut scratch)
286            .update
287    }
288
289    /// Inserts or updates membership with reusable storage and detailed work counters.
290    pub fn upsert_with_scratch(
291        &mut self,
292        handle: EntityHandle,
293        position: Position3,
294        bounds: Bounds,
295        scratch: &mut CellIndexUpdateScratch,
296    ) -> CellIndexUpdateReport {
297        if bounds == Bounds::Point {
298            return self.upsert_point(handle, self.grid.cell_at(position), scratch);
299        }
300
301        let aabb = bounds.to_aabb(position);
302        let min = self.grid.cell_at(aabb.min);
303        let max = self.grid.cell_at(aabb.max);
304        if let Some(current) = self.entity_cells.get(&handle)
305            && current.matches_range(min, max)
306        {
307            return CellIndexUpdateReport {
308                update: CellIndexUpdate::Unchanged,
309                retained_cells: current.as_slice().len(),
310                removed_cells: 0,
311                inserted_cells: 0,
312                incremental_diff: false,
313            };
314        }
315
316        scratch.cells.clear();
317        extend_cell_range(&mut scratch.cells, min, max);
318        let Some(previous) = self.entity_cells.remove(&handle) else {
319            for cell in &scratch.cells {
320                self.cells.entry(*cell).or_default().push(handle);
321            }
322            let inserted_cells = scratch.cells.len();
323            self.entity_cells.insert(
324                handle,
325                CellMembership::Multiple(std::mem::take(&mut scratch.cells)),
326            );
327            return CellIndexUpdateReport {
328                update: CellIndexUpdate::Inserted,
329                retained_cells: 0,
330                removed_cells: 0,
331                inserted_cells,
332                incremental_diff: false,
333            };
334        };
335
336        let incremental_diff = matches!(&previous, CellMembership::Multiple(_));
337        let (retained_cells, removed_cells, inserted_cells) =
338            self.apply_membership_diff(handle, previous.as_slice(), &scratch.cells);
339        let new_cells = std::mem::take(&mut scratch.cells);
340        if let CellMembership::Multiple(previous_cells) = previous {
341            scratch.cells = previous_cells;
342        }
343        self.entity_cells
344            .insert(handle, CellMembership::Multiple(new_cells));
345        CellIndexUpdateReport {
346            update: CellIndexUpdate::Relocated,
347            retained_cells,
348            removed_cells,
349            inserted_cells,
350            incremental_diff,
351        }
352    }
353
354    fn upsert_point(
355        &mut self,
356        handle: EntityHandle,
357        cell: CellCoord3,
358        scratch: &mut CellIndexUpdateScratch,
359    ) -> CellIndexUpdateReport {
360        let Some(previous) = self.entity_cells.remove(&handle) else {
361            self.cells.entry(cell).or_default().push(handle);
362            self.entity_cells
363                .insert(handle, CellMembership::Point(cell));
364            return CellIndexUpdateReport {
365                update: CellIndexUpdate::Inserted,
366                retained_cells: 0,
367                removed_cells: 0,
368                inserted_cells: 1,
369                incremental_diff: false,
370            };
371        };
372        if matches!(&previous, CellMembership::Point(current) if *current == cell) {
373            self.entity_cells.insert(handle, previous);
374            return CellIndexUpdateReport {
375                update: CellIndexUpdate::Unchanged,
376                retained_cells: 1,
377                removed_cells: 0,
378                inserted_cells: 0,
379                incremental_diff: false,
380            };
381        }
382
383        let target = [cell];
384        let (retained_cells, removed_cells, inserted_cells) =
385            self.apply_membership_diff(handle, previous.as_slice(), &target);
386        if let CellMembership::Multiple(previous_cells) = previous {
387            scratch.cells = previous_cells;
388        }
389        self.entity_cells
390            .insert(handle, CellMembership::Point(cell));
391        CellIndexUpdateReport {
392            update: CellIndexUpdate::Relocated,
393            retained_cells,
394            removed_cells,
395            inserted_cells,
396            incremental_diff: false,
397        }
398    }
399
400    fn apply_membership_diff(
401        &mut self,
402        handle: EntityHandle,
403        previous: &[CellCoord3],
404        next: &[CellCoord3],
405    ) -> (usize, usize, usize) {
406        let (mut old_index, mut new_index) = (0, 0);
407        let (mut retained, mut removed, mut inserted) = (0, 0, 0);
408        while old_index < previous.len() || new_index < next.len() {
409            match (previous.get(old_index), next.get(new_index)) {
410                (Some(old), Some(new)) if old == new => {
411                    retained += 1;
412                    old_index += 1;
413                    new_index += 1;
414                }
415                (Some(old), Some(new)) if old < new => {
416                    self.remove_handle_from_cell(*old, handle);
417                    removed += 1;
418                    old_index += 1;
419                }
420                (_, Some(new)) => {
421                    self.cells.entry(*new).or_default().push(handle);
422                    inserted += 1;
423                    new_index += 1;
424                }
425                (Some(old), None) => {
426                    self.remove_handle_from_cell(*old, handle);
427                    removed += 1;
428                    old_index += 1;
429                }
430                (None, None) => break,
431            }
432        }
433        (retained, removed, inserted)
434    }
435
436    /// Removes an entity from the index.
437    pub fn remove(&mut self, handle: EntityHandle) -> bool {
438        let Some(cells) = self.entity_cells.remove(&handle) else {
439            return false;
440        };
441
442        for cell in cells.as_slice() {
443            self.remove_handle_from_cell(*cell, handle);
444        }
445
446        true
447    }
448
449    fn remove_handle_from_cell(&mut self, cell: CellCoord3, handle: EntityHandle) {
450        let remove_cell = if let Some(handles) = self.cells.get_mut(&cell) {
451            if let Some(index) = handles.iter().position(|candidate| *candidate == handle) {
452                handles.remove(index);
453            }
454            handles.is_empty()
455        } else {
456            false
457        };
458        if remove_cell {
459            self.cells.remove(&cell);
460        }
461    }
462
463    /// Queries candidate handles overlapping an AABB.
464    pub fn query_aabb(&self, aabb: Aabb3) -> Vec<EntityHandle> {
465        let mut scratch = CellQueryScratch::default();
466        self.query_aabb_into(aabb, &mut scratch);
467        scratch.handles
468    }
469
470    /// Queries candidate handles overlapping an AABB using caller scratch.
471    pub fn query_aabb_into<'a>(
472        &self,
473        aabb: Aabb3,
474        scratch: &'a mut CellQueryScratch,
475    ) -> &'a [EntityHandle] {
476        scratch.clear();
477        let min = self.grid.cell_at(aabb.min);
478        let max = self.grid.cell_at(aabb.max);
479
480        let grid_cells = query_cell_volume(min, max);
481        if grid_cells <= self.cells.len() {
482            scratch.stats.strategy = CellQueryStrategy::Grid;
483            scratch.stats.grid_cells_probed = grid_cells;
484            for x in min.x..=max.x {
485                for y in min.y..=max.y {
486                    for z in min.z..=max.z {
487                        self.collect_cell(CellCoord3::new(x, y, z), scratch);
488                    }
489                }
490            }
491        } else {
492            scratch.stats.strategy = CellQueryStrategy::OccupiedCells;
493            scratch.stats.occupied_cells_scanned = self.cells.len();
494            scratch.matching_cells.extend(
495                self.cells
496                    .keys()
497                    .copied()
498                    .filter(|cell| cell_in_range(*cell, min, max)),
499            );
500            scratch.matching_cells.sort_unstable();
501            for index in 0..scratch.matching_cells.len() {
502                self.collect_cell(scratch.matching_cells[index], scratch);
503            }
504        }
505
506        scratch.stats.candidate_handles = scratch.handles.len();
507
508        scratch.handles()
509    }
510
511    /// Queries candidate handles inside cells touched by a sphere.
512    pub fn query_sphere(&self, center: Position3, radius: f32) -> Vec<EntityHandle> {
513        self.query_aabb(Bounds::Sphere { radius }.to_aabb(center))
514    }
515
516    /// Queries candidate handles inside cells touched by a sphere using caller scratch.
517    pub fn query_sphere_into<'a>(
518        &self,
519        center: Position3,
520        radius: f32,
521        scratch: &'a mut CellQueryScratch,
522    ) -> &'a [EntityHandle] {
523        self.query_aabb_into(Bounds::Sphere { radius }.to_aabb(center), scratch)
524    }
525
526    fn collect_cell(&self, cell: CellCoord3, scratch: &mut CellQueryScratch) {
527        if let Some(handles) = self.cells.get(&cell) {
528            scratch.stats.matched_cells = scratch.stats.matched_cells.saturating_add(1);
529            for handle in handles {
530                if scratch.insert_seen(*handle) {
531                    scratch.handles.push(*handle);
532                }
533            }
534        }
535    }
536
537    /// Returns handles indexed directly in one cell.
538    pub fn handles_in_cell(&self, cell: CellCoord3) -> Vec<EntityHandle> {
539        self.cells.get(&cell).cloned().unwrap_or_default()
540    }
541
542    /// Returns handles indexed directly in one cell without allocating.
543    pub fn handles_in_cell_slice(&self, cell: CellCoord3) -> &[EntityHandle] {
544        self.cells.get(&cell).map_or(&[], Vec::as_slice)
545    }
546
547    /// Returns cells currently occupied by one entity handle.
548    pub fn cells_for_handle(&self, handle: EntityHandle) -> Option<&[CellCoord3]> {
549        self.entity_cells.get(&handle).map(CellMembership::as_slice)
550    }
551
552    /// Number of indexed entities.
553    pub fn entity_count(&self) -> usize {
554        self.entity_cells.len()
555    }
556
557    /// Number of entities using allocation-free single-cell membership.
558    pub fn point_membership_count(&self) -> usize {
559        self.entity_cells
560            .values()
561            .filter(|membership| matches!(membership, CellMembership::Point(_)))
562            .count()
563    }
564
565    /// Number of non-empty cells.
566    pub fn occupied_cell_count(&self) -> usize {
567        self.cells.len()
568    }
569
570    /// Returns deterministic occupancy counts for all non-empty cells.
571    pub fn cell_occupancy(&self) -> Vec<CellOccupancy> {
572        let mut cells = Vec::with_capacity(self.cells.len());
573        self.cell_occupancy_into(&mut cells);
574        cells
575    }
576
577    /// Writes deterministic occupancy counts into caller-owned reusable storage.
578    pub fn cell_occupancy_into(&self, out: &mut Vec<CellOccupancy>) {
579        out.clear();
580        out.extend(self.cells.iter().map(|(cell, handles)| CellOccupancy {
581            cell: *cell,
582            entities: handles.len(),
583        }));
584        out.sort_by_key(|occupancy| occupancy.cell);
585    }
586}
587
588fn cells_match_range(cells: &[CellCoord3], min: CellCoord3, max: CellCoord3) -> bool {
589    if cells.len() != query_cell_volume(min, max) {
590        return false;
591    }
592    let mut cells = cells.iter();
593    for x in min.x..=max.x {
594        for y in min.y..=max.y {
595            for z in min.z..=max.z {
596                if cells.next() != Some(&CellCoord3::new(x, y, z)) {
597                    return false;
598                }
599            }
600        }
601    }
602    cells.next().is_none()
603}
604
605fn extend_cell_range(cells: &mut Vec<CellCoord3>, min: CellCoord3, max: CellCoord3) {
606    cells.reserve(query_cell_volume(min, max));
607    for x in min.x..=max.x {
608        for y in min.y..=max.y {
609            for z in min.z..=max.z {
610                cells.push(CellCoord3::new(x, y, z));
611            }
612        }
613    }
614}
615
616fn query_cell_volume(min: CellCoord3, max: CellCoord3) -> usize {
617    fn axis_cells(min: i32, max: i32) -> usize {
618        if max < min {
619            return 0;
620        }
621        usize::try_from(i64::from(max) - i64::from(min) + 1).unwrap_or(usize::MAX)
622    }
623
624    axis_cells(min.x, max.x)
625        .saturating_mul(axis_cells(min.y, max.y))
626        .saturating_mul(axis_cells(min.z, max.z))
627}
628
629const fn cell_in_range(cell: CellCoord3, min: CellCoord3, max: CellCoord3) -> bool {
630    cell.x >= min.x
631        && cell.x <= max.x
632        && cell.y >= min.y
633        && cell.y <= max.y
634        && cell.z >= min.z
635        && cell.z <= max.z
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    #[test]
643    fn explicit_index_capacity_is_retained_and_grows_on_request() {
644        let grid = GridSpec::new(10.0).expect("valid grid");
645        let mut index = CellIndex::with_capacity(grid, 8, 4);
646
647        assert!(index.entity_capacity() >= 8);
648        assert!(index.occupied_cell_capacity() >= 4);
649        index.reserve(32, 16);
650        assert!(index.entity_capacity() >= 32);
651        assert!(index.occupied_cell_capacity() >= 16);
652
653        let handle = EntityHandle::new(1, 0);
654        index.upsert(handle, Position3::new(1.0, 2.0, 3.0), Bounds::Point);
655        assert_eq!(
656            index.query_sphere(Position3::new(1.0, 2.0, 3.0), 1.0),
657            vec![handle]
658        );
659    }
660
661    #[test]
662    fn reclaim_retained_capacity_preserves_queries_and_membership() {
663        let grid = GridSpec::new(10.0).expect("valid grid");
664        let mut index = CellIndex::with_capacity(grid, 64, 64);
665        let handle = EntityHandle::new(1, 0);
666        let position = Position3::new(9.0, 0.0, 0.0);
667        index.upsert(handle, position, Bounds::Sphere { radius: 2.0 });
668        let expected = index.query_sphere(position, 4.0);
669
670        index.reclaim_retained_capacity();
671
672        assert_eq!(index.query_sphere(position, 4.0), expected);
673        assert!(
674            !index
675                .cells_for_handle(handle)
676                .expect("membership")
677                .is_empty()
678        );
679    }
680
681    #[test]
682    fn tracked_upsert_skips_same_cell_point_updates() {
683        let grid = GridSpec::new(10.0).expect("valid grid");
684        let mut index = CellIndex::with_capacity(grid, 1, 2);
685        let handle = EntityHandle::new(1, 0);
686        let first_cell = grid.cell_at(Position3::new(1.0, 2.0, 3.0));
687        let second_cell = grid.cell_at(Position3::new(11.0, 2.0, 3.0));
688
689        assert_eq!(
690            index.upsert_tracked(handle, Position3::new(1.0, 2.0, 3.0), Bounds::Point),
691            CellIndexUpdate::Inserted
692        );
693        let entity_capacity = index.entity_capacity();
694        let cell_capacity = index.occupied_cell_capacity();
695        assert_eq!(
696            index.upsert_tracked(handle, Position3::new(9.0, 2.0, 3.0), Bounds::Point),
697            CellIndexUpdate::Unchanged
698        );
699        assert_eq!(index.handles_in_cell_slice(first_cell), &[handle]);
700        assert_eq!(index.entity_capacity(), entity_capacity);
701        assert_eq!(index.occupied_cell_capacity(), cell_capacity);
702
703        assert!(matches!(
704            index.entity_cells.get(&handle),
705            Some(CellMembership::Point(cell)) if *cell == first_cell
706        ));
707        assert_eq!(
708            index.upsert_tracked(handle, Position3::new(11.0, 2.0, 3.0), Bounds::Point),
709            CellIndexUpdate::Relocated
710        );
711        assert!(matches!(
712            index.entity_cells.get(&handle),
713            Some(CellMembership::Point(cell)) if *cell == second_cell
714        ));
715        assert_eq!(index.point_membership_count(), 1);
716        assert!(index.handles_in_cell_slice(first_cell).is_empty());
717        assert_eq!(index.handles_in_cell_slice(second_cell), &[handle]);
718    }
719
720    #[test]
721    fn tracked_upsert_skips_unchanged_multi_cell_bounds() {
722        let grid = GridSpec::new(10.0).expect("valid grid");
723        let mut index = CellIndex::new(grid);
724        let handle = EntityHandle::new(1, 0);
725        let bounds = Bounds::Sphere { radius: 2.0 };
726
727        assert_eq!(
728            index.upsert_tracked(handle, Position3::new(9.0, 0.0, 0.0), bounds),
729            CellIndexUpdate::Inserted
730        );
731        let retained_cells = index
732            .entity_cells
733            .get(&handle)
734            .expect("bounded entity has cells")
735            .as_slice()
736            .as_ptr();
737        assert_eq!(
738            index.upsert_tracked(handle, Position3::new(9.5, 0.0, 0.0), bounds),
739            CellIndexUpdate::Unchanged
740        );
741        assert_eq!(
742            index
743                .entity_cells
744                .get(&handle)
745                .expect("unchanged bounds retain cells")
746                .as_slice()
747                .as_ptr(),
748            retained_cells
749        );
750
751        let relocated_position = Position3::new(12.5, 0.0, 0.0);
752        assert_eq!(
753            index.upsert_tracked(handle, relocated_position, bounds),
754            CellIndexUpdate::Relocated
755        );
756        assert_eq!(
757            index.cells_for_handle(handle),
758            Some(grid.cells_for_bounds(relocated_position, bounds).as_slice())
759        );
760    }
761
762    #[test]
763    fn multi_cell_crossing_updates_only_membership_difference() {
764        let grid = GridSpec::new(32.0).expect("valid grid");
765        let bounds = Bounds::Sphere { radius: 20.0 };
766        let handle = EntityHandle::new(1, 0);
767        let start = Position3::new(16.0, 16.0, 16.0);
768        let moved = Position3::new(48.0, 16.0, 16.0);
769        let mut index = CellIndex::new(grid);
770        let mut scratch = CellIndexUpdateScratch::default();
771
772        let inserted = index.upsert_with_scratch(handle, start, bounds, &mut scratch);
773        assert_eq!(inserted.update, CellIndexUpdate::Inserted);
774        assert_eq!(inserted.inserted_cells, 27);
775
776        let relocated = index.upsert_with_scratch(handle, moved, bounds, &mut scratch);
777        assert_eq!(
778            relocated,
779            CellIndexUpdateReport {
780                update: CellIndexUpdate::Relocated,
781                retained_cells: 18,
782                removed_cells: 9,
783                inserted_cells: 9,
784                incremental_diff: true,
785            }
786        );
787        assert!(scratch.retained_cell_capacity() >= 27);
788
789        let returned = index.upsert_with_scratch(handle, start, bounds, &mut scratch);
790        assert_eq!(returned.retained_cells, 18);
791        assert_eq!(returned.removed_cells, 9);
792        assert_eq!(returned.inserted_cells, 9);
793        assert!(returned.incremental_diff);
794
795        let mut rebuilt = CellIndex::new(grid);
796        rebuilt.upsert(handle, start, bounds);
797        assert_eq!(
798            index.cells_for_handle(handle),
799            rebuilt.cells_for_handle(handle)
800        );
801        assert_eq!(
802            index.query_sphere(start, 64.0),
803            rebuilt.query_sphere(start, 64.0)
804        );
805    }
806
807    #[test]
808    fn index_exposes_handles_by_cell() {
809        let grid = GridSpec::new(10.0).expect("valid grid");
810        let mut index = CellIndex::new(grid);
811        let handle = EntityHandle::new(1, 0);
812        index.upsert(handle, Position3::new(1.0, 2.0, 3.0), Bounds::Point);
813        let cell = grid.cell_at(Position3::new(1.0, 2.0, 3.0));
814
815        assert_eq!(index.handles_in_cell(cell), vec![handle]);
816        assert_eq!(index.handles_in_cell_slice(cell), &[handle]);
817        assert!(
818            index
819                .handles_in_cell_slice(CellCoord3::new(99, 99, 99))
820                .is_empty()
821        );
822        assert_eq!(index.cells_for_handle(handle), Some([cell].as_slice()));
823    }
824
825    #[test]
826    fn occupancy_output_is_sorted_and_reuses_capacity() {
827        let grid = GridSpec::new(10.0).expect("valid grid");
828        let mut index = CellIndex::new(grid);
829        let left = EntityHandle::new(1, 0);
830        let right = EntityHandle::new(2, 0);
831        index.upsert(right, Position3::new(21.0, 0.0, 0.0), Bounds::Point);
832        index.upsert(left, Position3::new(-11.0, 0.0, 0.0), Bounds::Point);
833
834        let expected = index.cell_occupancy();
835        let mut occupancy = Vec::new();
836        index.cell_occupancy_into(&mut occupancy);
837        assert_eq!(occupancy, expected);
838        assert!(
839            occupancy
840                .windows(2)
841                .all(|cells| cells[0].cell < cells[1].cell)
842        );
843
844        let retained = occupancy.as_ptr();
845        index.cell_occupancy_into(&mut occupancy);
846        assert_eq!(occupancy, expected);
847        assert_eq!(occupancy.as_ptr(), retained);
848    }
849
850    #[test]
851    fn scratch_query_deduplicates_and_reuses_storage() {
852        let grid = GridSpec::new(10.0).expect("valid grid");
853        let mut index = CellIndex::new(grid);
854        let handle = EntityHandle::new(1, 0);
855        index.upsert(
856            handle,
857            Position3::new(9.0, 0.0, 0.0),
858            Bounds::Sphere { radius: 2.0 },
859        );
860        let mut scratch = CellQueryScratch::default();
861
862        let first = index.query_aabb_into(
863            Bounds::Sphere { radius: 4.0 }.to_aabb(Position3::new(10.0, 0.0, 0.0)),
864            &mut scratch,
865        );
866        assert_eq!(first, &[handle]);
867        assert_eq!(scratch.len(), 1);
868        assert_eq!(scratch.stats().strategy, CellQueryStrategy::Grid);
869        assert_eq!(scratch.stats().candidate_handles, 1);
870        assert!(scratch.handle_capacity() >= 1);
871        assert!(scratch.dedup_capacity() >= 1);
872
873        let second = index.query_aabb_into(
874            Bounds::Point.to_aabb(Position3::new(100.0, 0.0, 0.0)),
875            &mut scratch,
876        );
877        assert!(second.is_empty());
878        assert!(scratch.is_empty());
879    }
880
881    #[test]
882    fn dense_dedup_preserves_generations_and_bounds_sparse_handles() {
883        let grid = GridSpec::new(10.0).expect("valid grid");
884        let mut index = CellIndex::new(grid);
885        let old = EntityHandle::new(7, 1);
886        let current = EntityHandle::new(7, 2);
887        let sparse = EntityHandle::new(u32::MAX, 0);
888        let spanning = Bounds::Sphere { radius: 2.0 };
889        index.upsert(old, Position3::new(9.0, 0.0, 0.0), spanning);
890        index.upsert(current, Position3::new(9.0, 0.0, 0.0), spanning);
891        index.upsert(sparse, Position3::new(9.0, 0.0, 0.0), Bounds::Point);
892        let mut scratch = CellQueryScratch::default();
893
894        let handles = index.query_aabb_into(
895            spanning.to_aabb(Position3::new(10.0, 0.0, 0.0)),
896            &mut scratch,
897        );
898
899        assert_eq!(handles, &[old, current, sparse]);
900        assert!(scratch.dedup_capacity() < MAX_DENSE_DEDUP_SLOTS);
901    }
902
903    #[test]
904    fn sparse_large_query_scans_occupied_cells_deterministically() {
905        let grid = GridSpec::new(10.0).expect("valid grid");
906        let mut index = CellIndex::new(grid);
907        let high = EntityHandle::new(2, 0);
908        let low = EntityHandle::new(1, 0);
909        index.upsert(high, Position3::new(95.0, 0.0, 0.0), Bounds::Point);
910        index.upsert(low, Position3::new(-95.0, 0.0, 0.0), Bounds::Point);
911        let mut scratch = CellQueryScratch::default();
912
913        let handles = index.query_aabb_into(
914            Aabb3::new(
915                Position3::new(-100.0, -100.0, -100.0),
916                Position3::new(100.0, 100.0, 100.0),
917            ),
918            &mut scratch,
919        );
920
921        assert_eq!(handles, &[low, high]);
922        assert_eq!(scratch.stats().strategy, CellQueryStrategy::OccupiedCells);
923        assert_eq!(scratch.stats().occupied_cells_scanned, 2);
924        assert_eq!(scratch.stats().matched_cells, 2);
925        assert_eq!(scratch.stats().candidate_handles, 2);
926        assert!(scratch.matching_cell_capacity() >= 2);
927    }
928}