dc8554d123
Fase F: sexto stub de pineal cerrado (6/6). mesh resultó ser un módulo de viz de grafos, no un triangle-mesh. Núcleo implementado: - buffers — NodeBuffer (stride 3: x,y,radius) + EdgeBuffer (stride 2), Vec planos contiguos, raw() para subir a GPU. - spatial_hash — uniform grid; rebuild + query (nodo bajo un punto, revisa celda + 8 vecinas). - force — layout force-directed Fruchterman-Reingold naïve O(n²): repulsión todo-par + atracción por arista + cooling. Jitter determinista para nodos coincidentes. - tree — layout de árbol por ancho de subárbol (post-order, padres centrados sobre hijos), soporta bosque, ciclos sin colgar. - camera — pan/zoom con zoom anclado al cursor (anchor-preserving). 13 tests verdes. cargo check --workspace verde. Pendiente (follow-up): hierarchical (Sugiyama) + Barnes-Hut para escalar el force-directed a grafos masivos. Pineal: 6/6 stubs cerrados. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
81 lines
2.5 KiB
Rust
81 lines
2.5 KiB
Rust
//! Uniform grid para hit-test de nodos móviles.
|
||
|
||
use crate::buffers::NodeBuffer;
|
||
use std::collections::HashMap;
|
||
|
||
/// Grid de celdas cuadradas. `rebuild` lo repuebla; `query` busca el
|
||
/// nodo cuyo radio cubre un punto.
|
||
pub struct SpatialHash {
|
||
cell: f32,
|
||
map: HashMap<(i32, i32), Vec<usize>>,
|
||
}
|
||
|
||
impl SpatialHash {
|
||
/// `cell_size` conviene ~2× el radio típico de nodo.
|
||
pub fn new(cell_size: f32) -> Self {
|
||
Self { cell: cell_size.max(1.0), map: HashMap::new() }
|
||
}
|
||
|
||
fn cell_of(&self, x: f32, y: f32) -> (i32, i32) {
|
||
((x / self.cell).floor() as i32, (y / self.cell).floor() as i32)
|
||
}
|
||
|
||
/// Repuebla el grid con las posiciones actuales de los nodos.
|
||
pub fn rebuild(&mut self, nodes: &NodeBuffer) {
|
||
self.map.clear();
|
||
for i in 0..nodes.len() {
|
||
let (x, y) = nodes.pos(i);
|
||
self.map.entry(self.cell_of(x, y)).or_default().push(i);
|
||
}
|
||
}
|
||
|
||
/// Devuelve el nodo más cercano a `(x,y)` cuyo radio lo cubre, o
|
||
/// `None`. Revisa la celda del punto y sus 8 vecinas.
|
||
pub fn query(&self, nodes: &NodeBuffer, x: f32, y: f32) -> Option<usize> {
|
||
let (cx, cy) = self.cell_of(x, y);
|
||
let mut best: Option<(usize, f32)> = None;
|
||
for dy in -1..=1 {
|
||
for dx in -1..=1 {
|
||
if let Some(bucket) = self.map.get(&(cx + dx, cy + dy)) {
|
||
for &i in bucket {
|
||
let (nx, ny) = nodes.pos(i);
|
||
let r = nodes.radius(i);
|
||
let d2 = (nx - x).powi(2) + (ny - y).powi(2);
|
||
if d2 <= r * r && best.map(|(_, bd)| d2 < bd).unwrap_or(true) {
|
||
best = Some((i, d2));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
best.map(|(i, _)| i)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn query_hits_node_under_point() {
|
||
let mut nb = NodeBuffer::new();
|
||
nb.push(10.0, 10.0, 5.0);
|
||
nb.push(100.0, 100.0, 8.0);
|
||
let mut sh = SpatialHash::new(20.0);
|
||
sh.rebuild(&nb);
|
||
assert_eq!(sh.query(&nb, 12.0, 11.0), Some(0));
|
||
assert_eq!(sh.query(&nb, 103.0, 98.0), Some(1));
|
||
}
|
||
|
||
#[test]
|
||
fn query_misses_empty_space() {
|
||
let mut nb = NodeBuffer::new();
|
||
nb.push(10.0, 10.0, 5.0);
|
||
let mut sh = SpatialHash::new(20.0);
|
||
sh.rebuild(&nb);
|
||
assert_eq!(sh.query(&nb, 500.0, 500.0), None);
|
||
// fuera del radio pero misma celda
|
||
assert_eq!(sh.query(&nb, 18.0, 18.0), None);
|
||
}
|
||
}
|