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:
sergio
2026-05-20 15:09:22 +00:00
parent 0042fe3f1f
commit dc8554d123
6 changed files with 542 additions and 23 deletions
@@ -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);
}
}