Skip to main content

dreamwell_engine/
spatial.rs

1// Spatial math utilities. Pure functions for cell computation and distance.
2
3/// Cell size in world units.
4pub const CELL_SIZE: i32 = 128;
5
6/// Compute cell coordinate from world position (floor division).
7///
8/// Uses `div_euclid` to correctly handle all negative values including `i32::MIN`.
9pub fn cell_coord(pos: i32) -> i32 {
10    pos.div_euclid(CELL_SIZE)
11}
12
13/// Compute deterministic cell ID from world/region/position.
14pub fn compute_cell_id(world_id: &str, region_id: &str, x: i32, y: i32) -> String {
15    let cx = cell_coord(x);
16    let cy = cell_coord(y);
17    format!("cell:{}:{}:{}:{}", world_id, region_id, cx, cy)
18}
19
20/// Compute squared distance between two points. Integer-only, no sqrt.
21pub fn distance_squared(x1: i32, y1: i32, x2: i32, y2: i32) -> i64 {
22    let dx = (x2 as i64) - (x1 as i64);
23    let dy = (y2 as i64) - (y1 as i64);
24    dx.saturating_mul(dx).saturating_add(dy.saturating_mul(dy))
25}
26
27/// Return 3x3 neighbor cell coordinates for AOI queries.
28pub fn action_horizon_cells(cx: i32, cy: i32) -> [(i32, i32); 9] {
29    [
30        (cx - 1, cy - 1),
31        (cx, cy - 1),
32        (cx + 1, cy - 1),
33        (cx - 1, cy),
34        (cx, cy),
35        (cx + 1, cy),
36        (cx - 1, cy + 1),
37        (cx, cy + 1),
38        (cx + 1, cy + 1),
39    ]
40}
41
42/// Compute field-of-view positions from (ox, oy) with given radius.
43/// Uses 8-octant recursive shadowcasting.
44pub fn compute_fov<F>(ox: i32, oy: i32, radius: i32, blocks_sight_fn: F) -> Vec<(i32, i32)>
45where
46    F: Fn(i32, i32) -> bool,
47{
48    let mut visible = Vec::with_capacity((radius as usize * 2 + 1).pow(2));
49    visible.push((ox, oy));
50
51    for octant in 0..8u8 {
52        cast_light(&mut visible, &blocks_sight_fn, ox, oy, radius, 1, 1.0, 0.0, octant);
53    }
54    visible
55}
56
57fn cast_light<F>(
58    visible: &mut Vec<(i32, i32)>,
59    blocks: &F,
60    ox: i32,
61    oy: i32,
62    radius: i32,
63    row: i32,
64    mut start_slope: f64,
65    end_slope: f64,
66    octant: u8,
67) where
68    F: Fn(i32, i32) -> bool,
69{
70    if start_slope < end_slope || row > radius {
71        return;
72    }
73
74    let mut prev_blocked = false;
75    let mut saved_start = start_slope;
76
77    for j in row..=radius {
78        let mut blocked = false;
79        let dy = -(j as f64);
80        let dx_f = dy * start_slope;
81        let dx_start = dx_f.round() as i32;
82
83        for dx in ((-j)..=dx_start).rev() {
84            let dx_f = dx as f64;
85            let slope_l = (dx_f + 0.5) / (dy - 0.5);
86            let slope_r = (dx_f - 0.5) / (dy + 0.5);
87
88            if slope_l < end_slope {
89                break;
90            }
91            if slope_r > start_slope {
92                continue;
93            }
94
95            let (mx, my) = transform_octant(dx, j, octant);
96            let ax = ox + mx;
97            let ay = oy + my;
98
99            if dx * dx + j * j <= radius * radius {
100                visible.push((ax, ay));
101            }
102
103            if blocks(ax, ay) {
104                if !prev_blocked {
105                    saved_start = start_slope;
106                }
107                prev_blocked = true;
108                blocked = true;
109                start_slope = slope_r;
110            } else if prev_blocked {
111                prev_blocked = false;
112                cast_light(visible, blocks, ox, oy, radius, j + 1, saved_start, slope_l, octant);
113            }
114        }
115
116        if blocked {
117            return;
118        }
119    }
120}
121
122fn transform_octant(dx: i32, dy: i32, octant: u8) -> (i32, i32) {
123    match octant {
124        0 => (dx, -dy),
125        1 => (-dx, -dy),
126        2 => (dx, dy),
127        3 => (-dx, dy),
128        4 => (-dy, dx),
129        5 => (-dy, -dx),
130        6 => (dy, dx),
131        7 => (dy, -dx),
132        _ => (0, 0),
133    }
134}
135
136// =============================================================================
137// Observer Visibility Masks — CPU-side meshlet visibility for Quantum Culling.
138// =============================================================================
139
140/// Compute observer visibility mask: 1 for visible meshlets, 0 for culled.
141/// `fov_cells` is the output of `compute_fov`.
142/// `meshlet_coords` are the (x, y) cell positions of each meshlet.
143pub fn compute_observer_visibility_mask(fov_cells: &[(i32, i32)], meshlet_coords: &[(i32, i32)]) -> Vec<u32> {
144    let visible_set: rustc_hash::FxHashSet<(i32, i32)> = fov_cells.iter().copied().collect();
145    meshlet_coords
146        .iter()
147        .map(|coord| if visible_set.contains(coord) { 1 } else { 0 })
148        .collect()
149}
150
151/// Zero-allocation version: writes into caller-provided output slice.
152/// `out` must be at least `meshlet_coords.len()` long.
153pub fn fill_observer_visibility_mask(fov_cells: &[(i32, i32)], meshlet_coords: &[(i32, i32)], out: &mut [u32]) {
154    let visible_set: rustc_hash::FxHashSet<(i32, i32)> = fov_cells.iter().copied().collect();
155    for (i, coord) in meshlet_coords.iter().enumerate() {
156        if i < out.len() {
157            out[i] = if visible_set.contains(coord) { 1 } else { 0 };
158        }
159    }
160}
161
162// =============================================================================
163// A* Pathfinding — Bounded, deterministic, integer-only heuristic.
164// =============================================================================
165
166/// A* node for the open set. Uses integer cost (no floats in authoritative logic).
167struct AStarNode {
168    x: i32,
169    y: i32,
170    g: u32,
171    f: u32,
172}
173
174/// Integer heuristic: Chebyshev distance (allows 8-directional movement).
175fn heuristic(x1: i32, y1: i32, x2: i32, y2: i32) -> u32 {
176    let dx = (x2 - x1).unsigned_abs();
177    let dy = (y2 - y1).unsigned_abs();
178    if dx > dy {
179        dx
180    } else {
181        dy
182    }
183}
184
185/// 4-directional neighbors. Deterministic iteration order.
186const DIRS: [(i32, i32); 4] = [(0, -1), (1, 0), (0, 1), (-1, 0)];
187
188/// Bounded A* pathfinding. Returns path as Vec<(i32, i32)> excluding start.
189/// `cost_fn` returns movement cost (999 = impassable) for a given (x, y).
190/// Bounded by `max_steps` to prevent unbounded work.
191pub fn astar<F>(
192    start_x: i32,
193    start_y: i32,
194    goal_x: i32,
195    goal_y: i32,
196    max_steps: u32,
197    cost_fn: F,
198) -> Option<Vec<(i32, i32)>>
199where
200    F: Fn(i32, i32) -> u32,
201{
202    use std::collections::BTreeMap;
203
204    if start_x == goal_x && start_y == goal_y {
205        return Some(Vec::new());
206    }
207
208    // Encode (x, y) as a single i64 key for map lookups (no alloc per node).
209    let key = |x: i32, y: i32| -> i64 { (x as i64) << 32 | (y as i64 & 0xFFFFFFFF) };
210
211    // g_score: best known cost to reach each node.
212    let mut g_score: BTreeMap<i64, u32> = BTreeMap::new();
213    // came_from: parent link for path reconstruction.
214    let mut came_from: BTreeMap<i64, i64> = BTreeMap::new();
215
216    // Open set as sorted Vec (poor man's priority queue — bounded by max_steps).
217    // Each entry: (f_score, g_score, x, y).
218    let mut open: Vec<AStarNode> = Vec::with_capacity(max_steps as usize);
219    let start_h = heuristic(start_x, start_y, goal_x, goal_y);
220    open.push(AStarNode {
221        x: start_x,
222        y: start_y,
223        g: 0,
224        f: start_h,
225    });
226    g_score.insert(key(start_x, start_y), 0);
227
228    let mut iterations: u32 = 0;
229
230    while let Some(best_idx) = find_min_f(&open) {
231        iterations += 1;
232        if iterations > max_steps {
233            return None; // Bounded work exceeded.
234        }
235
236        let current = open.swap_remove(best_idx);
237
238        if current.x == goal_x && current.y == goal_y {
239            // Reconstruct path.
240            let mut path = Vec::new();
241            let mut ck = key(goal_x, goal_y);
242            let sk = key(start_x, start_y);
243            while ck != sk {
244                let cx = (ck >> 32) as i32;
245                let cy = ck as i32;
246                path.push((cx, cy));
247                ck = match came_from.get(&ck) {
248                    Some(&parent) => parent,
249                    None => break,
250                };
251            }
252            path.reverse();
253            return Some(path);
254        }
255
256        for &(dx, dy) in &DIRS {
257            let nx = current.x + dx;
258            let ny = current.y + dy;
259            let move_cost = cost_fn(nx, ny);
260            if move_cost >= 999 {
261                continue;
262            } // Impassable.
263
264            let tentative_g = current.g + move_cost;
265            let nk = key(nx, ny);
266
267            if let Some(&existing_g) = g_score.get(&nk) {
268                if tentative_g >= existing_g {
269                    continue;
270                }
271            }
272
273            g_score.insert(nk, tentative_g);
274            came_from.insert(nk, key(current.x, current.y));
275            let h = heuristic(nx, ny, goal_x, goal_y);
276            open.push(AStarNode {
277                x: nx,
278                y: ny,
279                g: tentative_g,
280                f: tentative_g + h,
281            });
282        }
283    }
284
285    None // No path found.
286}
287
288// =============================================================================
289// SpatialGrid — O(1) per-tick entity index for area-of-interest queries.
290// =============================================================================
291//
292// Built ephemerally at the start of each `tick_advance` and discarded after.
293// Provides O(1) amortized cell lookup and O(cells) neighbor scan (9 cells max).
294// Not persisted — all writes are to a local HashMap in the current tick context.
295
296/// Key into the spatial grid: (cell_x, cell_y).
297type CellKey = (i32, i32);
298
299/// In-memory spatial grid mapping cell coordinates to entity ID lists.
300///
301/// Intended lifetime: one tick. Build with `SpatialGrid::new()`, populate
302/// with `insert`, query with `query_cell`/`query_neighbors`, then drop.
303#[derive(Default)]
304pub struct SpatialGrid {
305    cells: std::collections::HashMap<CellKey, Vec<String>>,
306}
307
308impl SpatialGrid {
309    /// Create an empty spatial grid.
310    #[inline]
311    pub fn new() -> Self {
312        Self {
313            cells: std::collections::HashMap::new(),
314        }
315    }
316
317    /// Create a grid pre-allocated for at least `capacity` entities.
318    #[inline]
319    pub fn with_capacity(capacity: usize) -> Self {
320        Self {
321            cells: std::collections::HashMap::with_capacity(capacity),
322        }
323    }
324
325    /// Insert an entity at world position (x, y).
326    ///
327    /// Computes the cell coordinate and appends `entity_id` to that cell's list.
328    /// An entity may appear in multiple calls (e.g. once per owned component),
329    /// but callers are responsible for deduplication if required.
330    #[inline]
331    pub fn insert(&mut self, entity_id: &str, x: i32, y: i32) {
332        let key = (cell_coord(x), cell_coord(y));
333        self.cells.entry(key).or_default().push(entity_id.to_string());
334    }
335
336    /// Remove an entity from its current cell at world position (x, y).
337    ///
338    /// Removes the first occurrence of `entity_id` in the cell.
339    /// No-ops if the entity is not present.
340    #[inline]
341    pub fn remove(&mut self, entity_id: &str, x: i32, y: i32) {
342        let key = (cell_coord(x), cell_coord(y));
343        if let Some(list) = self.cells.get_mut(&key) {
344            if let Some(pos) = list.iter().position(|id| id == entity_id) {
345                list.swap_remove(pos);
346            }
347        }
348    }
349
350    /// Return all entity IDs in the cell containing world position (x, y).
351    ///
352    /// Returns an empty slice if the cell is unpopulated.
353    #[inline]
354    pub fn query_cell(&self, x: i32, y: i32) -> &[String] {
355        let key = (cell_coord(x), cell_coord(y));
356        match self.cells.get(&key) {
357            Some(list) => list.as_slice(),
358            None => &[],
359        }
360    }
361
362    /// Return all entity IDs in the 3x3 neighborhood of cell (cx, cy).
363    ///
364    /// Results are unsorted and may contain duplicates if an entity spans cells.
365    /// Allocates a Vec bounded by 9 * max_entities_per_cell.
366    pub fn query_neighbors(&self, cx: i32, cy: i32) -> Vec<&str> {
367        let mut result = Vec::new();
368        for (ncx, ncy) in action_horizon_cells(cx, cy) {
369            if let Some(list) = self.cells.get(&(ncx, ncy)) {
370                for id in list {
371                    result.push(id.as_str());
372                }
373            }
374        }
375        result
376    }
377
378    /// Return all entity IDs within `range` of world position (x, y).
379    ///
380    /// Queries the 3x3 cell neighborhood and excludes cells whose closest
381    /// point to (x, y) exceeds `range`. Because the grid stores cell
382    /// assignment — not exact entity positions — this is cell-granularity
383    /// filtering. Callers with exact entity coordinates should apply a
384    /// final `distance_squared` check for precise results.
385    ///
386    /// Each returned tuple is `(entity_id, cell_origin_x, cell_origin_y)`.
387    pub fn query_radius(&self, x: i32, y: i32, range: i32) -> Vec<(&str, i32, i32)> {
388        let cx = cell_coord(x);
389        let cy = cell_coord(y);
390        let range_sq = (range as i64) * (range as i64);
391
392        let mut result = Vec::new();
393        for (ncx, ncy) in action_horizon_cells(cx, cy) {
394            // Compute the closest point on this cell's bounding box to (x, y).
395            let cell_min_x = ncx * CELL_SIZE;
396            let cell_max_x = cell_min_x + CELL_SIZE - 1;
397            let cell_min_y = ncy * CELL_SIZE;
398            let cell_max_y = cell_min_y + CELL_SIZE - 1;
399
400            let nearest_x = x.clamp(cell_min_x, cell_max_x);
401            let nearest_y = y.clamp(cell_min_y, cell_max_y);
402
403            if distance_squared(x, y, nearest_x, nearest_y) > range_sq {
404                continue;
405            }
406
407            if let Some(list) = self.cells.get(&(ncx, ncy)) {
408                for id in list {
409                    result.push((id.as_str(), cell_min_x, cell_min_y));
410                }
411            }
412        }
413        result
414    }
415
416    /// Return the number of occupied cells.
417    #[inline]
418    pub fn cell_count(&self) -> usize {
419        self.cells.len()
420    }
421
422    /// Return the total number of entity references across all cells.
423    #[inline]
424    pub fn entity_count(&self) -> usize {
425        self.cells.values().map(|v| v.len()).sum()
426    }
427
428    /// Clear all cells. Retains allocated capacity.
429    #[inline]
430    pub fn clear(&mut self) {
431        self.cells.clear();
432    }
433}
434
435// =============================================================================
436// SpatialHashGrid — Dense spatial grid for O(1) entity lookup by position.
437// =============================================================================
438//
439// Uses u64 entity IDs (numeric handles) instead of String IDs.
440// Supports configurable cell size and variable-radius queries beyond 3x3.
441// Designed for GPU-side entity systems, sub-area queries, and scale scenarios
442// where entity counts exceed the 3x3 neighborhood scan of SpatialGrid.
443
444/// Dense spatial grid for O(1) entity lookup by position.
445/// Grid cells are `cell_size` x `cell_size` world units.
446/// Uses u64 entity handles — zero-copy, no per-entity String allocation.
447pub struct SpatialHashGrid {
448    cells: std::collections::HashMap<(i32, i32), Vec<u64>>,
449    cell_size: i32,
450}
451
452impl SpatialHashGrid {
453    /// Create a new spatial hash grid with the given cell size.
454    pub fn new(cell_size: i32) -> Self {
455        Self {
456            cells: std::collections::HashMap::with_capacity(256),
457            cell_size,
458        }
459    }
460
461    /// Compute the cell key for a world-space position.
462    #[inline]
463    pub fn cell_key(&self, x: i32, y: i32) -> (i32, i32) {
464        (x.div_euclid(self.cell_size), y.div_euclid(self.cell_size))
465    }
466
467    /// Insert an entity at world position (x, y).
468    pub fn insert(&mut self, entity_id: u64, x: i32, y: i32) {
469        let key = self.cell_key(x, y);
470        self.cells
471            .entry(key)
472            .or_insert_with(|| Vec::with_capacity(16))
473            .push(entity_id);
474    }
475
476    /// Remove an entity from its cell at world position (x, y).
477    /// Removes the first occurrence. No-ops if not present.
478    pub fn remove(&mut self, entity_id: u64, x: i32, y: i32) {
479        let key = self.cell_key(x, y);
480        if let Some(cell) = self.cells.get_mut(&key) {
481            cell.retain(|&id| id != entity_id);
482        }
483    }
484
485    /// Query all entities within `radius` world units of (cx, cy).
486    /// Scans `O(radius^2 / cell_size^2)` cells.
487    pub fn query_radius(&self, cx: i32, cy: i32, radius: i32) -> Vec<u64> {
488        let mut result = Vec::new();
489        let cell_radius = (radius / self.cell_size) + 1;
490        let center_key = self.cell_key(cx, cy);
491        for dx in -cell_radius..=cell_radius {
492            for dy in -cell_radius..=cell_radius {
493                let key = (center_key.0 + dx, center_key.1 + dy);
494                if let Some(cell) = self.cells.get(&key) {
495                    result.extend_from_slice(cell);
496                }
497            }
498        }
499        result
500    }
501
502    /// Return all entity IDs in the cell containing world position (x, y).
503    pub fn query_cell(&self, x: i32, y: i32) -> &[u64] {
504        let key = self.cell_key(x, y);
505        match self.cells.get(&key) {
506            Some(list) => list.as_slice(),
507            None => &[],
508        }
509    }
510
511    /// Clear all cells. Retains allocated capacity.
512    pub fn clear(&mut self) {
513        self.cells.clear();
514    }
515
516    /// Return the total number of entity references across all cells.
517    pub fn entity_count(&self) -> usize {
518        self.cells.values().map(|c| c.len()).sum()
519    }
520
521    /// Return the number of occupied cells.
522    pub fn cell_count(&self) -> usize {
523        self.cells.len()
524    }
525
526    /// Return the cell size.
527    pub fn cell_size(&self) -> i32 {
528        self.cell_size
529    }
530}
531
532#[cfg(test)]
533mod hash_grid_tests {
534    use super::*;
535
536    #[test]
537    fn insert_and_query_finds_entity() {
538        let mut grid = SpatialHashGrid::new(128);
539        grid.insert(42, 100, 100);
540        let found = grid.query_cell(100, 100);
541        assert_eq!(found, &[42]);
542    }
543
544    #[test]
545    fn remove_and_query_does_not_find_entity() {
546        let mut grid = SpatialHashGrid::new(128);
547        grid.insert(42, 100, 100);
548        grid.remove(42, 100, 100);
549        let found = grid.query_cell(100, 100);
550        assert!(found.is_empty());
551    }
552
553    #[test]
554    fn query_radius_returns_only_nearby() {
555        let mut grid = SpatialHashGrid::new(128);
556        // Entity at origin
557        grid.insert(1, 0, 0);
558        // Entity far away (cell 10,10 = world 1280,1280)
559        grid.insert(2, 1280, 1280);
560        // Query radius 200 from origin — should only find entity 1
561        let found = grid.query_radius(0, 0, 200);
562        assert!(found.contains(&1));
563        assert!(!found.contains(&2));
564    }
565
566    #[test]
567    fn empty_grid_returns_empty() {
568        let grid = SpatialHashGrid::new(128);
569        assert!(grid.query_cell(0, 0).is_empty());
570        assert!(grid.query_radius(0, 0, 500).is_empty());
571        assert_eq!(grid.entity_count(), 0);
572        assert_eq!(grid.cell_count(), 0);
573    }
574
575    #[test]
576    fn multiple_entities_same_cell() {
577        let mut grid = SpatialHashGrid::new(128);
578        grid.insert(1, 10, 10);
579        grid.insert(2, 50, 50);
580        grid.insert(3, 100, 100);
581        // All in cell (0, 0)
582        let found = grid.query_cell(0, 0);
583        assert_eq!(found.len(), 3);
584        assert_eq!(grid.entity_count(), 3);
585        assert_eq!(grid.cell_count(), 1);
586    }
587
588    #[test]
589    fn negative_coordinates() {
590        let mut grid = SpatialHashGrid::new(128);
591        grid.insert(99, -200, -300);
592        let found = grid.query_cell(-200, -300);
593        assert_eq!(found, &[99]);
594        // Should not appear in positive cell
595        assert!(grid.query_cell(200, 300).is_empty());
596    }
597
598    #[test]
599    fn clear_resets_all() {
600        let mut grid = SpatialHashGrid::new(128);
601        grid.insert(1, 0, 0);
602        grid.insert(2, 128, 0);
603        grid.clear();
604        assert_eq!(grid.entity_count(), 0);
605        assert_eq!(grid.cell_count(), 0);
606    }
607
608    #[test]
609    fn custom_cell_size() {
610        let mut grid = SpatialHashGrid::new(64);
611        grid.insert(1, 0, 0); // cell (0, 0)
612        grid.insert(2, 64, 0); // cell (1, 0)
613        grid.insert(3, 63, 0); // still cell (0, 0)
614        assert_eq!(grid.query_cell(0, 0).len(), 2); // entities 1 and 3
615        assert_eq!(grid.query_cell(64, 0).len(), 1); // entity 2
616        assert_eq!(grid.cell_size(), 64);
617    }
618
619    #[test]
620    fn query_radius_covers_adjacent_cells() {
621        let mut grid = SpatialHashGrid::new(128);
622        // Entity at (130, 0) = cell (1, 0)
623        grid.insert(10, 130, 0);
624        // Query from origin with radius 200 — should reach cell (1, 0)
625        let found = grid.query_radius(0, 0, 200);
626        assert!(found.contains(&10));
627    }
628}
629
630#[cfg(test)]
631mod grid_tests {
632    use super::*;
633
634    #[test]
635    fn insert_and_query_same_cell() {
636        let mut grid = SpatialGrid::new();
637        grid.insert("e1", 0, 0);
638        grid.insert("e2", 50, 50); // same cell as (0,0): cell (0,0)
639        let found = grid.query_cell(0, 0);
640        assert_eq!(found.len(), 2);
641    }
642
643    #[test]
644    fn insert_different_cells() {
645        let mut grid = SpatialGrid::new();
646        grid.insert("e1", 0, 0); // cell (0,0)
647        grid.insert("e2", 128, 0); // cell (1,0)
648        assert_eq!(grid.query_cell(0, 0).len(), 1);
649        assert_eq!(grid.query_cell(128, 0).len(), 1);
650        assert_eq!(grid.cell_count(), 2);
651    }
652
653    #[test]
654    fn query_empty_cell_returns_empty_slice() {
655        let grid = SpatialGrid::new();
656        assert!(grid.query_cell(999, 999).is_empty());
657    }
658
659    #[test]
660    fn remove_entity() {
661        let mut grid = SpatialGrid::new();
662        grid.insert("e1", 0, 0);
663        grid.insert("e2", 0, 0);
664        grid.remove("e1", 0, 0);
665        let found = grid.query_cell(0, 0);
666        assert_eq!(found.len(), 1);
667        assert_eq!(found[0], "e2");
668    }
669
670    #[test]
671    fn remove_nonexistent_is_noop() {
672        let mut grid = SpatialGrid::new();
673        grid.insert("e1", 0, 0);
674        grid.remove("e2", 0, 0); // e2 not in this cell
675        assert_eq!(grid.query_cell(0, 0).len(), 1);
676    }
677
678    #[test]
679    fn query_neighbors_returns_all_9_cells() {
680        let mut grid = SpatialGrid::new();
681        // Place one entity in each of the 9 neighbor cells around (0,0).
682        grid.insert("c", 64, 64); // cell (0,0)
683        grid.insert("n", 64, -64); // cell (0,-1)
684        grid.insert("s", 64, 192); // cell (0,1)
685        grid.insert("w", -64, 64); // cell (-1,0)
686        grid.insert("e", 192, 64); // cell (1,0)
687        grid.insert("nw", -64, -64); // cell (-1,-1)
688        grid.insert("ne", 192, -64); // cell (1,-1)
689        grid.insert("sw", -64, 192); // cell (-1,1)
690        grid.insert("se", 192, 192); // cell (1,1)
691        let neighbors = grid.query_neighbors(0, 0);
692        assert_eq!(neighbors.len(), 9);
693    }
694
695    #[test]
696    fn entity_count_tracks_insertions() {
697        let mut grid = SpatialGrid::new();
698        grid.insert("a", 0, 0);
699        grid.insert("b", 0, 0);
700        grid.insert("c", 128, 0);
701        assert_eq!(grid.entity_count(), 3);
702    }
703
704    #[test]
705    fn clear_resets_grid() {
706        let mut grid = SpatialGrid::new();
707        grid.insert("a", 0, 0);
708        grid.clear();
709        assert_eq!(grid.entity_count(), 0);
710        assert_eq!(grid.cell_count(), 0);
711    }
712
713    #[test]
714    fn negative_coords_map_correctly() {
715        let mut grid = SpatialGrid::new();
716        grid.insert("neg", -1, -1); // cell (-1,-1)
717        assert_eq!(grid.query_cell(-1, -1).len(), 1);
718        assert_eq!(grid.query_cell(0, 0).len(), 0);
719    }
720
721    #[test]
722    fn with_capacity_works() {
723        let mut grid = SpatialGrid::with_capacity(1000);
724        grid.insert("e", 0, 0);
725        assert_eq!(grid.entity_count(), 1);
726    }
727
728    // -- Negative coordinate tests (E12) --------------------------------------
729
730    #[test]
731    fn cell_coord_negative_one() {
732        // -1 should map to cell -1 (floor division).
733        assert_eq!(cell_coord(-1), -1);
734    }
735
736    #[test]
737    fn cell_coord_negative_128() {
738        // -128 is exactly -1 * CELL_SIZE, so should map to cell -1.
739        assert_eq!(cell_coord(-128), -1);
740    }
741
742    #[test]
743    fn cell_coord_negative_129() {
744        // -129 should map to cell -2 (one past the -1 cell boundary).
745        assert_eq!(cell_coord(-129), -2);
746    }
747
748    #[test]
749    fn cell_coord_i32_min() {
750        // i32::MIN should not panic and should produce a valid cell coord.
751        let result = cell_coord(i32::MIN);
752        // i32::MIN = -2147483648; -2147483648 / 128 = -16777216
753        assert_eq!(result, -16777216);
754    }
755
756    #[test]
757    fn grid_negative_coordinates() {
758        let mut grid = SpatialGrid::new();
759        grid.insert("neg_entity", -200, -300);
760        // -200 / 128 -> cell -2, -300 / 128 -> cell -3
761        let found = grid.query_cell(-200, -300);
762        assert_eq!(found.len(), 1);
763        assert_eq!(found[0], "neg_entity");
764
765        // Positive coords should not find it.
766        assert!(grid.query_cell(200, 300).is_empty());
767    }
768
769    #[test]
770    fn grid_negative_positive_boundary() {
771        let mut grid = SpatialGrid::new();
772        // Entity at -1 (cell -1) should not appear in cell 0.
773        grid.insert("boundary", -1, -1);
774        assert_eq!(grid.query_cell(-1, -1).len(), 1);
775        assert_eq!(grid.query_cell(0, 0).len(), 0);
776    }
777
778    #[test]
779    fn cell_coord_negative_exact_boundary() {
780        // -256 = -2 * CELL_SIZE -> cell -2
781        assert_eq!(cell_coord(-256), -2);
782        // -257 -> cell -3
783        assert_eq!(cell_coord(-257), -3);
784    }
785}
786
787#[cfg(test)]
788mod visibility_tests {
789    use super::*;
790
791    #[test]
792    fn visibility_mask_all_visible() {
793        let fov = vec![(0, 0), (1, 0), (0, 1)];
794        let meshlets = vec![(0, 0), (1, 0)];
795        let mask = compute_observer_visibility_mask(&fov, &meshlets);
796        assert_eq!(mask, vec![1, 1]);
797    }
798
799    #[test]
800    fn visibility_mask_none_visible() {
801        let fov = vec![(10, 10)];
802        let meshlets = vec![(0, 0), (1, 1)];
803        let mask = compute_observer_visibility_mask(&fov, &meshlets);
804        assert_eq!(mask, vec![0, 0]);
805    }
806
807    #[test]
808    fn visibility_mask_partial() {
809        let fov = vec![(0, 0), (1, 0)];
810        let meshlets = vec![(0, 0), (5, 5), (1, 0)];
811        let mask = compute_observer_visibility_mask(&fov, &meshlets);
812        assert_eq!(mask, vec![1, 0, 1]);
813    }
814
815    #[test]
816    fn fill_visibility_mask_zero_alloc() {
817        let fov = vec![(0, 0), (2, 2)];
818        let meshlets = vec![(0, 0), (1, 1), (2, 2)];
819        let mut out = vec![0u32; 3];
820        fill_observer_visibility_mask(&fov, &meshlets, &mut out);
821        assert_eq!(out, vec![1, 0, 1]);
822    }
823
824    #[test]
825    fn visibility_mask_empty_fov() {
826        let fov: Vec<(i32, i32)> = vec![];
827        let meshlets = vec![(0, 0)];
828        let mask = compute_observer_visibility_mask(&fov, &meshlets);
829        assert_eq!(mask, vec![0]);
830    }
831}
832
833/// Find the index of the node with minimum f-score in the open list.
834fn find_min_f(open: &[AStarNode]) -> Option<usize> {
835    if open.is_empty() {
836        return None;
837    }
838    let mut min_idx = 0;
839    let mut min_f = open[0].f;
840    for (i, node) in open.iter().enumerate().skip(1) {
841        if node.f < min_f {
842            min_f = node.f;
843            min_idx = i;
844        }
845    }
846    Some(min_idx)
847}