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/// 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}