Skip to main content

cvkg_spatial/
spatial_hash.rs

1//! # CVKG Agentic Development Guidelines (v1.2)
2//!
3//! All AI agents contributing to this crate MUST follow ALL seven rules:
4//!
5//! ── Karpathy Guidelines (1–4) ────────────────────────────────────────────
6//! 1. THINK FIRST     -- State assumptions. Surface ambiguity. Push back on complexity.
7//! 2. STAY SIMPLE     -- Minimum code. No speculative features. No unasked-for abstractions.
8//! 3. BE SURGICAL     -- Touch only what's required. Own your orphans. Don't improve neighbors.
9//! 4. VERIFY GOALS    -- Turn tasks into checkable criteria. Loop until they pass. Never commit broken.
10//!
11//! ── CVKG Extended Protocols (5–7) ────────────────────────────────────────
12//! 5. TRIPLE-PASS     -- Read the target, its surrounding context, and its full call graph
13//!                      at least THREE TIMES before making any edit or revision.
14//! 6. COMMENT ALL     -- Every major pub fn, unsafe block, and non-trivial algorithm in
15//!                      every .rs/.ts/.h/.wgsl file MUST have a descriptive doc comment.
16//!                      Comments describe WHY and WHAT CONTRACT, not HOW mechanically.
17//! 7. MONITOR LOOPS   -- Check every tool call / command for progress every 30 seconds.
18//!                      After 3 consecutive identical failures, stop, write BLOCKED.md,
19//!                      and move to unblocked work. Never silently accept a broken state.
20//!
21//! Sources:
22//  Karpathy: https://github.com/multica-ai/andrej-karpathy-skills
23//  CVKG Extended: Section 2 of the CVKG Design Specification
24
25//! Generic spatial hash grid for O(1) insert and O(k) region queries.
26//!
27//! # Why this exists
28//! Finding #5 from the crosscrate audit: Scene, Physics, and Flow each maintained
29//! hand-rolled spatial grids with no deduplication or shared contract. This
30//! generic implementation replaces all of them.
31//!
32//! # Algorithm
33//! Space is divided into a uniform grid of square cells of size `cell_size`.
34//! Each inserted item is registered in every cell its bounding rect overlaps.
35//! Queries collect items from all cells the query rect overlaps — O(1) amortized
36//! insert, O(k) query where k = items per cell (typically small for uniform data).
37//!
38//! # Limitation
39//! Items are stored by value (cloned on insert). There is no removal API — to
40//! update positions, call `clear()` and re-insert. This is appropriate for
41//! per-frame rebuild patterns used by CVKG's retained scene graph.
42
43use cvkg_core::Rect;
44use std::collections::HashMap;
45
46/// Maximum number of cells spanned in any single dimension for insert/query.
47/// This bounds the loop in `insert()` and `query()` to prevent CPU exhaustion
48/// when callers pass extreme coordinate values.
49const MAX_CELL_SPAN: i32 = 1000;
50
51/// A uniform-grid spatial hash map keyed by `(cell_x, cell_y)`.
52///
53/// `T` must be `Clone` because a single large item may be registered in
54/// multiple cells simultaneously.
55pub struct SpatialHash<T> {
56    /// Backing storage: each cell holds a vec of items that touch it.
57    cells: HashMap<(i32, i32), Vec<T>>,
58    /// Edge length of each square grid cell in world-space units.
59    cell_size: f32,
60    /// Total number of individual cell-item registrations (not unique items).
61    registration_count: usize,
62}
63
64impl<T: Clone> SpatialHash<T> {
65    /// Create an empty spatial hash with the given cell size.
66    ///
67    /// `cell_size` should be chosen to match the typical object size: too small
68    /// wastes memory, too large reduces query selectivity. 64.0 px is a good
69    /// default for UI graphs; use smaller values for dense physics simulations.
70    pub fn new(cell_size: f32) -> Option<Self> {
71        if cell_size <= 0.0 {
72            return None;
73        }
74        Some(Self {
75            cells: HashMap::new(),
76            cell_size,
77            registration_count: 0,
78        })
79    }
80
81    /// Insert `item` into every cell that `rect` overlaps.
82    ///
83    /// The item is cloned once per overlapping cell. For items that span many
84    /// cells this can be expensive — prefer large `cell_size` for large objects.
85    pub fn insert(&mut self, rect: Rect, item: T) {
86        let (min_cx, min_cy, max_cx, max_cy) = self.cells_for_rect(rect);
87        for cx in min_cx..=max_cx {
88            for cy in min_cy..=max_cy {
89                self.cells.entry((cx, cy)).or_default().push(item.clone());
90                self.registration_count += 1;
91            }
92        }
93    }
94
95    /// Return all items registered in cells that overlap `rect`.
96    ///
97    /// The returned list may contain duplicates if an item spans multiple cells
98    /// that all overlap the query rect. Callers must deduplicate if needed.
99    /// This is intentionally a broad-phase query — exact AABB testing is the
100    /// caller's responsibility.
101    pub fn query(&self, rect: Rect) -> Vec<T> {
102        let (min_cx, min_cy, max_cx, max_cy) = self.cells_for_rect(rect);
103        let mut results = Vec::new();
104        for cx in min_cx..=max_cx {
105            for cy in min_cy..=max_cy {
106                if let Some(cell) = self.cells.get(&(cx, cy)) {
107                    results.extend_from_slice(cell);
108                }
109            }
110        }
111        results
112    }
113
114    /// Remove all items from every cell, resetting to an empty grid.
115    ///
116    /// This does NOT free the backing HashMap's allocated memory; call this at
117    /// the start of each frame before re-inserting the current object set.
118    pub fn clear(&mut self) {
119        self.cells.clear();
120        self.registration_count = 0;
121    }
122
123    /// Return the number of cell-item registrations (not unique item count).
124    ///
125    /// This is useful for profiling and capacity planning. An item spanning
126    /// N cells contributes N to this count.
127    pub fn len(&self) -> usize {
128        self.registration_count
129    }
130
131    /// Returns `true` if no items have been inserted since the last `clear()`.
132    pub fn is_empty(&self) -> bool {
133        self.registration_count == 0
134    }
135
136    /// Compute the inclusive cell range `(min_cx, min_cy, max_cx, max_cy)` for a rect.
137    fn cells_for_rect(&self, rect: Rect) -> (i32, i32, i32, i32) {
138        let min_cx = (rect.x / self.cell_size)
139            .floor()
140            .clamp(i32::MIN as f32, i32::MAX as f32) as i32;
141        let min_cy = (rect.y / self.cell_size)
142            .floor()
143            .clamp(i32::MIN as f32, i32::MAX as f32) as i32;
144        let max_cx = ((rect.x + rect.width) / self.cell_size)
145            .floor()
146            .clamp(i32::MIN as f32, i32::MAX as f32) as i32;
147        let max_cy = ((rect.y + rect.height) / self.cell_size)
148            .floor()
149            .clamp(i32::MIN as f32, i32::MAX as f32) as i32;
150
151        // Clamp the total span to prevent CPU exhaustion from extreme coordinates.
152        let max_cx = max_cx.min(min_cx.saturating_add(MAX_CELL_SPAN));
153        let max_cy = max_cy.min(min_cy.saturating_add(MAX_CELL_SPAN));
154
155        (min_cx, min_cy, max_cx, max_cy)
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use cvkg_core::Rect;
163
164    fn rect(x: f32, y: f32, w: f32, h: f32) -> Rect {
165        Rect {
166            x,
167            y,
168            width: w,
169            height: h,
170        }
171    }
172
173    #[test]
174    fn test_insert_and_query() {
175        let mut sh: SpatialHash<u32> = SpatialHash::new(100.0).unwrap();
176        sh.insert(rect(0.0, 0.0, 50.0, 50.0), 1);
177        sh.insert(rect(200.0, 200.0, 50.0, 50.0), 2);
178
179        let results = sh.query(rect(0.0, 0.0, 60.0, 60.0));
180        assert!(results.contains(&1), "Should find item 1");
181        assert!(!results.contains(&2), "Should NOT find item 2");
182    }
183
184    #[test]
185    fn test_clear_resets_state() {
186        let mut sh: SpatialHash<u32> = SpatialHash::new(64.0).unwrap();
187        sh.insert(rect(0.0, 0.0, 10.0, 10.0), 42);
188        assert!(!sh.is_empty());
189
190        sh.clear();
191        assert!(sh.is_empty());
192        assert_eq!(sh.len(), 0);
193        let results = sh.query(rect(0.0, 0.0, 10.0, 10.0));
194        assert!(results.is_empty());
195    }
196
197    #[test]
198    fn test_multi_cell_item() {
199        // An item spanning 2 cells horizontally should appear in queries to either cell.
200        let mut sh: SpatialHash<&str> = SpatialHash::new(100.0).unwrap();
201        // rect spans cells (0,0) and (1,0)
202        sh.insert(rect(50.0, 0.0, 100.0, 50.0), "wide");
203
204        let left = sh.query(rect(0.0, 0.0, 60.0, 50.0));
205        let right = sh.query(rect(100.0, 0.0, 60.0, 50.0));
206        assert!(
207            left.contains(&"wide"),
208            "wide should appear in left cell query"
209        );
210        assert!(
211            right.contains(&"wide"),
212            "wide should appear in right cell query"
213        );
214    }
215
216    #[test]
217    fn test_negative_coordinates() {
218        let mut sh: SpatialHash<i32> = SpatialHash::new(50.0).unwrap();
219        sh.insert(rect(-100.0, -100.0, 20.0, 20.0), 99);
220
221        let results = sh.query(rect(-110.0, -110.0, 40.0, 40.0));
222        assert!(results.contains(&99), "Should handle negative coordinates");
223    }
224}