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,154 @@
|
||||
//! Layout force-directed (Fruchterman-Reingold).
|
||||
//!
|
||||
//! Repulsión entre todo par de nodos + atracción a lo largo de las
|
||||
//! aristas, integrado con cooling. Implementación naïve O(n²); Barnes-Hut
|
||||
//! es la optimización de escala (millones de nodos) — pendiente.
|
||||
|
||||
use crate::buffers::{EdgeBuffer, NodeBuffer};
|
||||
|
||||
/// Parámetros de la simulación.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ForceParams {
|
||||
/// Distancia ideal entre nodos conectados.
|
||||
pub k: f32,
|
||||
/// Desplazamiento máximo inicial por paso (se enfría).
|
||||
pub temperature: f32,
|
||||
/// Factor de enfriamiento aplicado cada paso (`0 < cooling < 1`).
|
||||
pub cooling: f32,
|
||||
}
|
||||
|
||||
impl Default for ForceParams {
|
||||
fn default() -> Self {
|
||||
Self { k: 50.0, temperature: 50.0, cooling: 0.95 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Estado de una simulación force-directed.
|
||||
pub struct ForceLayout {
|
||||
params: ForceParams,
|
||||
temp: f32,
|
||||
}
|
||||
|
||||
impl ForceLayout {
|
||||
pub fn new(params: ForceParams) -> Self {
|
||||
let temp = params.temperature;
|
||||
Self { params, temp }
|
||||
}
|
||||
|
||||
/// Temperatura actual (baja con cada paso — útil para detectar fin).
|
||||
pub fn temperature(&self) -> f32 {
|
||||
self.temp
|
||||
}
|
||||
|
||||
/// Un paso de simulación. Muta las posiciones de `nodes`. Devuelve el
|
||||
/// desplazamiento total aplicado (converge hacia 0).
|
||||
pub fn step(&mut self, nodes: &mut NodeBuffer, edges: &EdgeBuffer) -> f32 {
|
||||
let n = nodes.len();
|
||||
if n == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let k = self.params.k.max(1e-3);
|
||||
let mut disp = vec![(0.0f32, 0.0f32); n];
|
||||
|
||||
// Repulsión: todo par. f_r = k² / d.
|
||||
for i in 0..n {
|
||||
let (xi, yi) = nodes.pos(i);
|
||||
for j in (i + 1)..n {
|
||||
let (xj, yj) = nodes.pos(j);
|
||||
let mut dx = xi - xj;
|
||||
let mut dy = yi - yj;
|
||||
let mut dist = (dx * dx + dy * dy).sqrt();
|
||||
if dist < 1e-3 {
|
||||
// Jitter determinista para despegar nodos coincidentes.
|
||||
dx = ((i as f32) - (j as f32)) * 0.01 + 0.01;
|
||||
dy = 0.01;
|
||||
dist = (dx * dx + dy * dy).sqrt();
|
||||
}
|
||||
let f = k * k / dist;
|
||||
let (ux, uy) = (dx / dist, dy / dist);
|
||||
disp[i].0 += ux * f;
|
||||
disp[i].1 += uy * f;
|
||||
disp[j].0 -= ux * f;
|
||||
disp[j].1 -= uy * f;
|
||||
}
|
||||
}
|
||||
|
||||
// Atracción: a lo largo de cada arista. f_a = d² / k.
|
||||
for (u, v) in edges.iter() {
|
||||
if u >= n || v >= n || u == v {
|
||||
continue;
|
||||
}
|
||||
let (xu, yu) = nodes.pos(u);
|
||||
let (xv, yv) = nodes.pos(v);
|
||||
let dx = xu - xv;
|
||||
let dy = yu - yv;
|
||||
let dist = (dx * dx + dy * dy).sqrt().max(1e-3);
|
||||
let f = dist * dist / k;
|
||||
let (ux, uy) = (dx / dist, dy / dist);
|
||||
disp[u].0 -= ux * f;
|
||||
disp[u].1 -= uy * f;
|
||||
disp[v].0 += ux * f;
|
||||
disp[v].1 += uy * f;
|
||||
}
|
||||
|
||||
// Integración con cap de temperatura.
|
||||
let mut total = 0.0f32;
|
||||
for i in 0..n {
|
||||
let (dx, dy) = disp[i];
|
||||
let len = (dx * dx + dy * dy).sqrt();
|
||||
if len < 1e-6 {
|
||||
continue;
|
||||
}
|
||||
let capped = len.min(self.temp);
|
||||
let (mx, my) = (dx / len * capped, dy / len * capped);
|
||||
let (x, y) = nodes.pos(i);
|
||||
nodes.set_pos(i, x + mx, y + my);
|
||||
total += capped;
|
||||
}
|
||||
self.temp *= self.params.cooling;
|
||||
total
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn two_connected_nodes_settle_near_k() {
|
||||
let mut nb = NodeBuffer::new();
|
||||
nb.push(0.0, 0.0, 5.0);
|
||||
nb.push(500.0, 0.0, 5.0); // arrancan muy lejos
|
||||
let mut eb = EdgeBuffer::new();
|
||||
eb.push(0, 1);
|
||||
let mut fl = ForceLayout::new(ForceParams::default());
|
||||
for _ in 0..400 {
|
||||
fl.step(&mut nb, &eb);
|
||||
}
|
||||
let (x0, y0) = nb.pos(0);
|
||||
let (x1, y1) = nb.pos(1);
|
||||
let dist = ((x1 - x0).powi(2) + (y1 - y0).powi(2)).sqrt();
|
||||
// No deberían quedar ni pegados ni a 500 de distancia.
|
||||
assert!(dist > 5.0 && dist < 300.0, "dist tras converger = {dist}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_nodes_do_not_nan() {
|
||||
let mut nb = NodeBuffer::new();
|
||||
nb.push(10.0, 10.0, 5.0);
|
||||
nb.push(10.0, 10.0, 5.0);
|
||||
let eb = EdgeBuffer::new();
|
||||
let mut fl = ForceLayout::new(ForceParams::default());
|
||||
fl.step(&mut nb, &eb);
|
||||
let (x, y) = nb.pos(0);
|
||||
assert!(x.is_finite() && y.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_graph_is_noop() {
|
||||
let mut nb = NodeBuffer::new();
|
||||
let eb = EdgeBuffer::new();
|
||||
let mut fl = ForceLayout::new(ForceParams::default());
|
||||
assert_eq!(fl.step(&mut nb, &eb), 0.0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user