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/// A uniform-grid spatial hash map keyed by `(cell_x, cell_y)`.
47///
48/// `T` must be `Clone` because a single large item may be registered in
49/// multiple cells simultaneously.
50pub struct SpatialHash<T> {
51 /// Backing storage: each cell holds a vec of items that touch it.
52 cells: HashMap<(i32, i32), Vec<T>>,
53 /// Edge length of each square grid cell in world-space units.
54 cell_size: f32,
55 /// Total number of individual cell-item registrations (not unique items).
56 registration_count: usize,
57}
58
59impl<T: Clone> SpatialHash<T> {
60 /// Create an empty spatial hash with the given cell size.
61 ///
62 /// `cell_size` should be chosen to match the typical object size: too small
63 /// wastes memory, too large reduces query selectivity. 64.0 px is a good
64 /// default for UI graphs; use smaller values for dense physics simulations.
65 pub fn new(cell_size: f32) -> Option<Self> {
66 if cell_size <= 0.0 {
67 return None;
68 }
69 Some(Self {
70 cells: HashMap::new(),
71 cell_size,
72 registration_count: 0,
73 })
74 }
75
76 /// Insert `item` into every cell that `rect` overlaps.
77 ///
78 /// The item is cloned once per overlapping cell. For items that span many
79 /// cells this can be expensive — prefer large `cell_size` for large objects.
80 pub fn insert(&mut self, rect: Rect, item: T) {
81 let (min_cx, min_cy, max_cx, max_cy) = self.cells_for_rect(rect);
82 for cx in min_cx..=max_cx {
83 for cy in min_cy..=max_cy {
84 self.cells.entry((cx, cy)).or_default().push(item.clone());
85 self.registration_count += 1;
86 }
87 }
88 }
89
90 /// Return all items registered in cells that overlap `rect`.
91 ///
92 /// The returned list may contain duplicates if an item spans multiple cells
93 /// that all overlap the query rect. Callers must deduplicate if needed.
94 /// This is intentionally a broad-phase query — exact AABB testing is the
95 /// caller's responsibility.
96 pub fn query(&self, rect: Rect) -> Vec<T> {
97 let (min_cx, min_cy, max_cx, max_cy) = self.cells_for_rect(rect);
98 let mut results = Vec::new();
99 for cx in min_cx..=max_cx {
100 for cy in min_cy..=max_cy {
101 if let Some(cell) = self.cells.get(&(cx, cy)) {
102 results.extend_from_slice(cell);
103 }
104 }
105 }
106 results
107 }
108
109 /// Remove all items from every cell, resetting to an empty grid.
110 ///
111 /// This does NOT free the backing HashMap's allocated memory; call this at
112 /// the start of each frame before re-inserting the current object set.
113 pub fn clear(&mut self) {
114 self.cells.clear();
115 self.registration_count = 0;
116 }
117
118 /// Return the number of cell-item registrations (not unique item count).
119 ///
120 /// This is useful for profiling and capacity planning. An item spanning
121 /// N cells contributes N to this count.
122 pub fn len(&self) -> usize {
123 self.registration_count
124 }
125
126 /// Returns `true` if no items have been inserted since the last `clear()`.
127 pub fn is_empty(&self) -> bool {
128 self.registration_count == 0
129 }
130
131 /// Compute the inclusive cell range `(min_cx, min_cy, max_cx, max_cy)` for a rect.
132 fn cells_for_rect(&self, rect: Rect) -> (i32, i32, i32, i32) {
133 let min_cx = (rect.x / self.cell_size)
134 .floor()
135 .clamp(i32::MIN as f32, i32::MAX as f32) as i32;
136 let min_cy = (rect.y / self.cell_size)
137 .floor()
138 .clamp(i32::MIN as f32, i32::MAX as f32) as i32;
139 let max_cx = ((rect.x + rect.width) / self.cell_size)
140 .floor()
141 .clamp(i32::MIN as f32, i32::MAX as f32) as i32;
142 let max_cy = ((rect.y + rect.height) / self.cell_size)
143 .floor()
144 .clamp(i32::MIN as f32, i32::MAX as f32) as i32;
145 (min_cx, min_cy, max_cx, max_cy)
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use cvkg_core::Rect;
153
154 fn rect(x: f32, y: f32, w: f32, h: f32) -> Rect {
155 Rect {
156 x,
157 y,
158 width: w,
159 height: h,
160 }
161 }
162
163 #[test]
164 fn test_insert_and_query() {
165 let mut sh: SpatialHash<u32> = SpatialHash::new(100.0).unwrap();
166 sh.insert(rect(0.0, 0.0, 50.0, 50.0), 1);
167 sh.insert(rect(200.0, 200.0, 50.0, 50.0), 2);
168
169 let results = sh.query(rect(0.0, 0.0, 60.0, 60.0));
170 assert!(results.contains(&1), "Should find item 1");
171 assert!(!results.contains(&2), "Should NOT find item 2");
172 }
173
174 #[test]
175 fn test_clear_resets_state() {
176 let mut sh: SpatialHash<u32> = SpatialHash::new(64.0).unwrap();
177 sh.insert(rect(0.0, 0.0, 10.0, 10.0), 42);
178 assert!(!sh.is_empty());
179
180 sh.clear();
181 assert!(sh.is_empty());
182 assert_eq!(sh.len(), 0);
183 let results = sh.query(rect(0.0, 0.0, 10.0, 10.0));
184 assert!(results.is_empty());
185 }
186
187 #[test]
188 fn test_multi_cell_item() {
189 // An item spanning 2 cells horizontally should appear in queries to either cell.
190 let mut sh: SpatialHash<&str> = SpatialHash::new(100.0).unwrap();
191 // rect spans cells (0,0) and (1,0)
192 sh.insert(rect(50.0, 0.0, 100.0, 50.0), "wide");
193
194 let left = sh.query(rect(0.0, 0.0, 60.0, 50.0));
195 let right = sh.query(rect(100.0, 0.0, 60.0, 50.0));
196 assert!(left.contains(&"wide"), "wide should appear in left cell query");
197 assert!(
198 right.contains(&"wide"),
199 "wide should appear in right cell query"
200 );
201 }
202
203 #[test]
204 fn test_negative_coordinates() {
205 let mut sh: SpatialHash<i32> = SpatialHash::new(50.0).unwrap();
206 sh.insert(rect(-100.0, -100.0, 20.0, 20.0), 99);
207
208 let results = sh.query(rect(-110.0, -110.0, 40.0, 40.0));
209 assert!(results.contains(&99), "Should handle negative coordinates");
210 }
211}