feat(pineal): cierra stub mesh — viz de grafos (núcleo)
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>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user