Skip to main content

symbios_tensor/
spatial.rs

1//! Spatial hash grid for fast proximity and intersection queries during tracing.
2//!
3//! [`SpatialHash`] partitions world space into a uniform grid of cells, each
4//! storing references to nearby nodes and edges. [`resolve_trace_step`]
5//! evaluates a proposed trace step against the graph, detecting node snaps,
6//! edge crossings, and T-junction proximity — all in O(1) expected time per
7//! query.
8
9use glam::Vec2;
10
11use crate::geometry::{closest_point_on_segment, segment_intersection};
12use crate::graph::{EdgeId, NodeId, RoadGraph};
13
14#[derive(Clone, Default)]
15struct HashCell {
16    nodes: Vec<NodeId>,
17    edges: Vec<EdgeId>,
18}
19
20/// A flat 2D spatial hash grid for O(1) proximity queries against road nodes and edges.
21pub struct SpatialHash {
22    cell_size: f32,
23    cols: usize,
24    rows: usize,
25    cells: Vec<HashCell>,
26}
27
28impl SpatialHash {
29    /// Maximum number of cells the grid will allocate. If the requested
30    /// `cell_size` would exceed this, it is automatically enlarged.
31    const MAX_CELLS: usize = 4_000_000;
32
33    /// Creates a new spatial hash covering a `world_width` × `world_depth`
34    /// area with cells of the given size. If the resulting grid would exceed
35    /// `MAX_CELLS`, `cell_size` is automatically increased to fit.
36    pub fn new(world_width: f32, world_depth: f32, cell_size: f32) -> Self {
37        assert!(
38            world_width.is_finite() && world_depth.is_finite() && cell_size.is_finite(),
39            "SpatialHash dimensions must be finite (got {world_width} x {world_depth}, cell {cell_size})"
40        );
41        assert!(
42            world_width > 0.0 && world_depth > 0.0 && cell_size > 0.0,
43            "SpatialHash dimensions must be positive (got {world_width} x {world_depth}, cell {cell_size})"
44        );
45        let mut cs = cell_size;
46        loop {
47            let cols = (world_width / cs).ceil() as usize;
48            let rows = (world_depth / cs).ceil() as usize;
49            if cols.saturating_mul(rows) <= Self::MAX_CELLS {
50                return Self {
51                    cell_size: cs,
52                    cols,
53                    rows,
54                    cells: vec![HashCell::default(); cols * rows],
55                };
56            }
57            cs *= 2.0;
58        }
59    }
60
61    fn coords_to_index(&self, pos: Vec2) -> Option<usize> {
62        let col = (pos.x / self.cell_size).floor() as isize;
63        let row = (pos.y / self.cell_size).floor() as isize;
64
65        if col >= 0 && col < self.cols as isize && row >= 0 && row < self.rows as isize {
66            Some(row as usize * self.cols + col as usize)
67        } else {
68            None
69        }
70    }
71
72    /// Registers a node in the cell containing `pos`.
73    pub fn insert_node(&mut self, id: NodeId, pos: Vec2) {
74        if let Some(idx) = self.coords_to_index(pos) {
75            self.cells[idx].nodes.push(id);
76        }
77    }
78
79    /// Removes an edge from every cell it was registered in.
80    pub fn remove_edge(&mut self, id: EdgeId, start: Vec2, end: Vec2) {
81        let min_col = ((start.x.min(end.x) / self.cell_size).floor() as isize).max(0);
82        let max_col =
83            ((start.x.max(end.x) / self.cell_size).floor() as isize).min(self.cols as isize - 1);
84        let min_row = ((start.y.min(end.y) / self.cell_size).floor() as isize).max(0);
85        let max_row =
86            ((start.y.max(end.y) / self.cell_size).floor() as isize).min(self.rows as isize - 1);
87
88        for r in min_row..=max_row {
89            for c in min_col..=max_col {
90                let idx = r as usize * self.cols + c as usize;
91                self.cells[idx].edges.retain(|&e| e != id);
92            }
93        }
94    }
95
96    /// Registers an edge in every cell its axis-aligned bounding box overlaps.
97    pub fn insert_edge(&mut self, id: EdgeId, start: Vec2, end: Vec2) {
98        let min_col = ((start.x.min(end.x) / self.cell_size).floor() as isize).max(0);
99        let max_col =
100            ((start.x.max(end.x) / self.cell_size).floor() as isize).min(self.cols as isize - 1);
101        let min_row = ((start.y.min(end.y) / self.cell_size).floor() as isize).max(0);
102        let max_row =
103            ((start.y.max(end.y) / self.cell_size).floor() as isize).min(self.rows as isize - 1);
104
105        for r in min_row..=max_row {
106            for c in min_col..=max_col {
107                let idx = r as usize * self.cols + c as usize;
108                self.cells[idx].edges.push(id);
109            }
110        }
111    }
112
113    /// Returns deduplicated edge IDs from all cells overlapping the region.
114    pub(crate) fn edges_in_region(&self, start: Vec2, end: Vec2, padding: f32) -> Vec<EdgeId> {
115        let cells = self.cells_for_region(start, end, padding);
116        let mut ids = Vec::new();
117        for cell in &cells {
118            for &eid in &cell.edges {
119                if !ids.contains(&eid) {
120                    ids.push(eid);
121                }
122            }
123        }
124        ids
125    }
126
127    /// Collects all unique cells whose bounding box overlaps `[start, end]` expanded by `padding`.
128    fn cells_for_region(&self, start: Vec2, end: Vec2, padding: f32) -> Vec<&HashCell> {
129        let min_col = (((start.x.min(end.x) - padding) / self.cell_size).floor() as isize).max(0);
130        let max_col = (((start.x.max(end.x) + padding) / self.cell_size).floor() as isize)
131            .min(self.cols as isize - 1);
132        let min_row = (((start.y.min(end.y) - padding) / self.cell_size).floor() as isize).max(0);
133        let max_row = (((start.y.max(end.y) + padding) / self.cell_size).floor() as isize)
134            .min(self.rows as isize - 1);
135
136        let mut result = Vec::new();
137        for r in min_row..=max_row {
138            for c in min_col..=max_col {
139                result.push(&self.cells[r as usize * self.cols + c as usize]);
140            }
141        }
142        result
143    }
144}
145
146/// Result of evaluating a proposed trace step against the existing graph.
147pub enum TraceResult {
148    /// Path is clear — create a new node at this position.
149    Clear(Vec2),
150    /// Landed near an existing intersection — snap to it.
151    SnappedToNode(NodeId),
152    /// Hit or landed near an existing edge — split it to form an intersection.
153    SnappedToEdge {
154        edge_id: EdgeId,
155        intersection_pos: Vec2,
156    },
157}
158
159/// Evaluates a single trace step `start_pos` → `proposed_pos` against the graph.
160///
161/// Resolution order:
162/// 1. **Edge crossing** — did our path physically cross an existing road?
163/// 2. **Node snap** — did we land close to an existing intersection?
164/// 3. **Edge proximity (T-junction)** — did we land near the side of a road?
165pub fn resolve_trace_step(
166    graph: &RoadGraph,
167    spatial: &SpatialHash,
168    start_pos: Vec2,
169    proposed_pos: Vec2,
170    snap_radius: f32,
171    current_node_id: NodeId,
172) -> TraceResult {
173    let cells = spatial.cells_for_region(start_pos, proposed_pos, snap_radius);
174    let snap_sq = snap_radius * snap_radius;
175
176    // Collect direct neighbours of the current node so we don't snap back
177    // into the immediate adjacency of where we just came from.
178    let current_neighbours: Vec<NodeId> = graph.nodes[current_node_id as usize]
179        .edges
180        .iter()
181        .filter(|&&eid| graph.edges[eid as usize].active)
182        .map(|&eid| graph.opposite(eid, current_node_id))
183        .collect();
184
185    // --- CHECK 1: EDGE CROSSING (physical intersection) ---
186    // Must be checked BEFORE node snaps: if the trace segment physically
187    // crosses an existing road, that crossing must be detected even if the
188    // proposed position happens to land near a node on the far side.
189    let mut closest_crossing: Option<(EdgeId, Vec2)> = None;
190    let mut closest_crossing_dist = f32::MAX;
191
192    for cell in &cells {
193        for &e_id in &cell.edges {
194            let edge = &graph.edges[e_id as usize];
195            if !edge.active {
196                continue;
197            }
198            if edge.start == current_node_id || edge.end == current_node_id {
199                continue;
200            }
201
202            let e_start = graph.nodes[edge.start as usize].position;
203            let e_end = graph.nodes[edge.end as usize].position;
204
205            if let Some(intersect) = segment_intersection(start_pos, proposed_pos, e_start, e_end) {
206                let dist = start_pos.distance_squared(intersect);
207                if dist < closest_crossing_dist {
208                    closest_crossing_dist = dist;
209                    closest_crossing = Some((e_id, intersect));
210                }
211            }
212        }
213    }
214
215    if let Some((edge_id, intersection_pos)) = closest_crossing {
216        let edge = &graph.edges[edge_id as usize];
217        let e_start = graph.nodes[edge.start as usize].position;
218        let e_end = graph.nodes[edge.end as usize].position;
219
220        let dist_to_start = intersection_pos.distance_squared(e_start);
221        let dist_to_end = intersection_pos.distance_squared(e_end);
222
223        if dist_to_start < snap_sq && dist_to_start <= dist_to_end {
224            let nid = edge.start;
225            if nid != current_node_id {
226                return TraceResult::SnappedToNode(nid);
227            }
228            return TraceResult::SnappedToEdge {
229                edge_id,
230                intersection_pos,
231            };
232        } else if dist_to_end < snap_sq {
233            let nid = edge.end;
234            if nid != current_node_id {
235                return TraceResult::SnappedToNode(nid);
236            }
237            return TraceResult::SnappedToEdge {
238                edge_id,
239                intersection_pos,
240            };
241        } else {
242            return TraceResult::SnappedToEdge {
243                edge_id,
244                intersection_pos,
245            };
246        }
247    }
248
249    // --- CHECK 2: NODE SNAPPING ---
250    let mut closest_node: Option<NodeId> = None;
251    let mut closest_node_dist = f32::MAX;
252
253    for cell in &cells {
254        for &n_id in &cell.nodes {
255            if n_id == current_node_id || current_neighbours.contains(&n_id) {
256                continue;
257            }
258            let dist_sq = graph.nodes[n_id as usize]
259                .position
260                .distance_squared(proposed_pos);
261            if dist_sq < snap_sq && dist_sq < closest_node_dist {
262                closest_node_dist = dist_sq;
263                closest_node = Some(n_id);
264            }
265        }
266    }
267
268    if let Some(n_id) = closest_node {
269        return TraceResult::SnappedToNode(n_id);
270    }
271
272    // --- CHECK 3: EDGE PROXIMITY (T-junction) ---
273    let mut closest_edge_hit: Option<(EdgeId, Vec2)> = None;
274    let mut closest_edge_dist = f32::MAX;
275
276    for cell in &cells {
277        for &e_id in &cell.edges {
278            let edge = &graph.edges[e_id as usize];
279            if !edge.active {
280                continue;
281            }
282            if edge.start == current_node_id || edge.end == current_node_id {
283                continue;
284            }
285            if current_neighbours.contains(&edge.start) || current_neighbours.contains(&edge.end) {
286                continue;
287            }
288
289            let e_start = graph.nodes[edge.start as usize].position;
290            let e_end = graph.nodes[edge.end as usize].position;
291
292            let proj = closest_point_on_segment(proposed_pos, e_start, e_end);
293            let dist_to_edge_sq = proposed_pos.distance_squared(proj);
294
295            if dist_to_edge_sq < snap_sq && dist_to_edge_sq < closest_edge_dist {
296                closest_edge_dist = dist_to_edge_sq;
297                closest_edge_hit = Some((e_id, proj));
298            }
299        }
300    }
301
302    if let Some((edge_id, intersection_pos)) = closest_edge_hit {
303        let edge = &graph.edges[edge_id as usize];
304        let e_start = graph.nodes[edge.start as usize].position;
305        let e_end = graph.nodes[edge.end as usize].position;
306
307        let dist_to_start = intersection_pos.distance_squared(e_start);
308        let dist_to_end = intersection_pos.distance_squared(e_end);
309
310        if dist_to_start < snap_sq && dist_to_start <= dist_to_end {
311            let nid = edge.start;
312            if nid != current_node_id {
313                return TraceResult::SnappedToNode(nid);
314            }
315            return TraceResult::SnappedToEdge {
316                edge_id,
317                intersection_pos,
318            };
319        } else if dist_to_end < snap_sq {
320            let nid = edge.end;
321            if nid != current_node_id {
322                return TraceResult::SnappedToNode(nid);
323            }
324            return TraceResult::SnappedToEdge {
325                edge_id,
326                intersection_pos,
327            };
328        } else {
329            return TraceResult::SnappedToEdge {
330                edge_id,
331                intersection_pos,
332            };
333        }
334    }
335
336    TraceResult::Clear(proposed_pos)
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use crate::graph::RoadType;
343
344    #[test]
345    fn insert_and_find_node() {
346        let mut graph = RoadGraph::default();
347        let origin = graph.add_node(Vec2::new(0.0, 0.0)); // tracer's current node
348        let target = graph.add_node(Vec2::new(5.0, 5.0));
349
350        let mut sh = SpatialHash::new(100.0, 100.0, 10.0);
351        sh.insert_node(origin, Vec2::new(0.0, 0.0));
352        sh.insert_node(target, Vec2::new(5.0, 5.0));
353
354        let result = resolve_trace_step(
355            &graph,
356            &sh,
357            Vec2::new(0.0, 0.0),
358            Vec2::new(5.5, 5.5),
359            2.0,
360            origin,
361        );
362        assert!(matches!(result, TraceResult::SnappedToNode(1)));
363    }
364
365    #[test]
366    fn crossing_edge_detected() {
367        let mut graph = RoadGraph::default();
368        let origin = graph.add_node(Vec2::new(5.0, 0.0)); // tracer's current node
369        let a = graph.add_node(Vec2::new(0.0, 5.0));
370        let b = graph.add_node(Vec2::new(10.0, 5.0));
371        let e = graph.add_edge(a, b, RoadType::Major);
372
373        let mut sh = SpatialHash::new(100.0, 100.0, 10.0);
374        sh.insert_node(origin, graph.node_pos(origin));
375        sh.insert_node(a, graph.node_pos(a));
376        sh.insert_node(b, graph.node_pos(b));
377        sh.insert_edge(e, graph.node_pos(a), graph.node_pos(b));
378
379        let result = resolve_trace_step(
380            &graph,
381            &sh,
382            Vec2::new(5.0, 0.0),
383            Vec2::new(5.0, 10.0),
384            1.0,
385            origin,
386        );
387        match result {
388            TraceResult::SnappedToEdge {
389                edge_id,
390                intersection_pos,
391            } => {
392                assert_eq!(edge_id, e);
393                assert!((intersection_pos - Vec2::new(5.0, 5.0)).length() < 1e-4);
394            }
395            _ => panic!("expected SnappedToEdge"),
396        }
397    }
398
399    #[test]
400    fn clear_when_no_obstacles() {
401        let mut graph = RoadGraph::default();
402        let origin = graph.add_node(Vec2::new(40.0, 40.0));
403        let sh = SpatialHash::new(100.0, 100.0, 10.0);
404
405        let target = Vec2::new(50.0, 50.0);
406        let result = resolve_trace_step(&graph, &sh, Vec2::new(40.0, 40.0), target, 1.0, origin);
407        match result {
408            TraceResult::Clear(pos) => assert!((pos - target).length() < 1e-5),
409            _ => panic!("expected Clear"),
410        }
411    }
412}