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}