diff --git a/crates/modules/pineal/mesh/src/buffers.rs b/crates/modules/pineal/mesh/src/buffers.rs new file mode 100644 index 0000000..251a652 --- /dev/null +++ b/crates/modules/pineal/mesh/src/buffers.rs @@ -0,0 +1,111 @@ +//! Buffers planos de nodos y aristas — `Vec` contiguos con stride fijo. + +/// Nodos: stride 3 = `[x, y, radius]` por nodo. +#[derive(Debug, Clone, Default)] +pub struct NodeBuffer { + data: Vec, +} + +impl NodeBuffer { + pub fn new() -> Self { + Self::default() + } + + pub fn with_capacity(n: usize) -> Self { + Self { data: Vec::with_capacity(n * 3) } + } + + /// Agrega un nodo y devuelve su índice. + pub fn push(&mut self, x: f32, y: f32, radius: f32) -> usize { + let idx = self.len(); + self.data.extend_from_slice(&[x, y, radius]); + idx + } + + pub fn len(&self) -> usize { + self.data.len() / 3 + } + + pub fn is_empty(&self) -> bool { + self.data.is_empty() + } + + pub fn pos(&self, i: usize) -> (f32, f32) { + (self.data[i * 3], self.data[i * 3 + 1]) + } + + pub fn radius(&self, i: usize) -> f32 { + self.data[i * 3 + 2] + } + + pub fn set_pos(&mut self, i: usize, x: f32, y: f32) { + self.data[i * 3] = x; + self.data[i * 3 + 1] = y; + } + + /// Acceso crudo al `Vec` interleaved — para subir como buffer GPU. + pub fn raw(&self) -> &[f32] { + &self.data + } +} + +/// Aristas: stride 2 = `[from, to]` (índices de nodo). +#[derive(Debug, Clone, Default)] +pub struct EdgeBuffer { + data: Vec, +} + +impl EdgeBuffer { + pub fn new() -> Self { + Self::default() + } + + pub fn push(&mut self, from: usize, to: usize) { + self.data.push(from as u32); + self.data.push(to as u32); + } + + pub fn len(&self) -> usize { + self.data.len() / 2 + } + + pub fn is_empty(&self) -> bool { + self.data.is_empty() + } + + pub fn edge(&self, i: usize) -> (usize, usize) { + (self.data[i * 2] as usize, self.data[i * 2 + 1] as usize) + } + + pub fn iter(&self) -> impl Iterator + '_ { + (0..self.len()).map(move |i| self.edge(i)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn node_buffer_push_and_access() { + let mut nb = NodeBuffer::new(); + let a = nb.push(1.0, 2.0, 5.0); + let b = nb.push(3.0, 4.0, 6.0); + assert_eq!((a, b), (0, 1)); + assert_eq!(nb.len(), 2); + assert_eq!(nb.pos(1), (3.0, 4.0)); + assert_eq!(nb.radius(0), 5.0); + nb.set_pos(0, 9.0, 9.0); + assert_eq!(nb.pos(0), (9.0, 9.0)); + } + + #[test] + fn edge_buffer_roundtrip() { + let mut eb = EdgeBuffer::new(); + eb.push(0, 1); + eb.push(1, 2); + assert_eq!(eb.len(), 2); + assert_eq!(eb.edge(1), (1, 2)); + assert_eq!(eb.iter().collect::>(), vec![(0, 1), (1, 2)]); + } +} diff --git a/crates/modules/pineal/mesh/src/camera.rs b/crates/modules/pineal/mesh/src/camera.rs new file mode 100644 index 0000000..0f890f6 --- /dev/null +++ b/crates/modules/pineal/mesh/src/camera.rs @@ -0,0 +1,72 @@ +//! Cámara 2D: pan + zoom con zoom anclado a un punto de pantalla. + +/// Transformación world↔screen. `screen = (world - pan) * zoom`. +#[derive(Debug, Clone, Copy)] +pub struct Camera { + pub pan: (f32, f32), + pub zoom: f32, +} + +impl Default for Camera { + fn default() -> Self { + Self { pan: (0.0, 0.0), zoom: 1.0 } + } +} + +impl Camera { + pub fn new() -> Self { + Self::default() + } + + pub fn world_to_screen(&self, w: (f32, f32)) -> (f32, f32) { + ((w.0 - self.pan.0) * self.zoom, (w.1 - self.pan.1) * self.zoom) + } + + pub fn screen_to_world(&self, s: (f32, f32)) -> (f32, f32) { + (s.0 / self.zoom + self.pan.0, s.1 / self.zoom + self.pan.1) + } + + /// Desplaza la cámara `delta` pixels de pantalla. + pub fn pan_by(&mut self, dx: f32, dy: f32) { + self.pan.0 -= dx / self.zoom; + self.pan.1 -= dy / self.zoom; + } + + /// Zoom multiplicando por `factor`, manteniendo fijo el punto de + /// pantalla `anchor` (el world-point bajo el cursor no se mueve). + pub fn zoom_at(&mut self, anchor: (f32, f32), factor: f32) { + let before = self.screen_to_world(anchor); + self.zoom = (self.zoom * factor).clamp(0.01, 1000.0); + let after = self.screen_to_world(anchor); + self.pan.0 += before.0 - after.0; + self.pan.1 += before.1 - after.1; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn close(a: (f32, f32), b: (f32, f32)) -> bool { + (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3 + } + + #[test] + fn world_screen_roundtrip() { + let mut cam = Camera::new(); + cam.pan = (10.0, 20.0); + cam.zoom = 2.0; + let w = (33.0, 44.0); + assert!(close(cam.screen_to_world(cam.world_to_screen(w)), w)); + } + + #[test] + fn zoom_at_keeps_anchor_world_point_fixed() { + let mut cam = Camera::new(); + let anchor = (100.0, 80.0); + let before = cam.screen_to_world(anchor); + cam.zoom_at(anchor, 2.5); + let after = cam.screen_to_world(anchor); + assert!(close(before, after), "el punto bajo el cursor no se mueve"); + } +} diff --git a/crates/modules/pineal/mesh/src/force.rs b/crates/modules/pineal/mesh/src/force.rs new file mode 100644 index 0000000..918045d --- /dev/null +++ b/crates/modules/pineal/mesh/src/force.rs @@ -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); + } +} diff --git a/crates/modules/pineal/mesh/src/lib.rs b/crates/modules/pineal/mesh/src/lib.rs index d243c0e..633f78e 100644 --- a/crates/modules/pineal/mesh/src/lib.rs +++ b/crates/modules/pineal/mesh/src/lib.rs @@ -1,28 +1,25 @@ //! `pineal-mesh` — visualización de grafos. //! -//! Módulos: -//! - **`node_buffer`** / **`edge_buffer`** — `Vec` planos con -//! stride fijo (3 floats por nodo: `[x, y, radius]`). -//! - **`spatial_hash`** — uniform grid para hit-test de nodos -//! móviles (sección 5.1). -//! - **`force_directed`** — layout con Barnes-Hut delegado a -//! `pineal_core::barnes_hut` (cuando se implemente). -//! - **`hierarchical`** — Sugiyama-lite, delegado a -//! `pineal_core::sugiyama`. -//! - **`tree`** — subtree-width layout, delegado a -//! `pineal_core::tree_layout`. -//! - **`camera`** — pan/zoom con anchor-preserving zoom de la -//! sección 5.3. -//! - **`element`** — `Element` GPUI. +//! - [`buffers`] — `NodeBuffer` / `EdgeBuffer`: `Vec` planos con stride +//! fijo (3 floats por nodo `[x,y,radius]`, 2 por arista). +//! - [`spatial_hash`] — uniform grid para hit-test de nodos móviles. +//! - [`force`] — layout force-directed (Fruchterman-Reingold naïve). +//! - [`tree`] — layout de árbol por ancho de subárbol. +//! - [`camera`] — pan/zoom con zoom anclado al cursor. +//! +//! Pendiente: `hierarchical` (Sugiyama, layered graph drawing) y la +//! optimización Barnes-Hut del force-directed para grafos masivos. #![forbid(unsafe_code)] -#![allow(dead_code)] -pub mod node_buffer {} -pub mod edge_buffer {} -pub mod spatial_hash {} -pub mod force_directed {} -pub mod hierarchical {} -pub mod tree {} -pub mod camera {} -pub mod element {} +pub mod buffers; +pub mod camera; +pub mod force; +pub mod spatial_hash; +pub mod tree; + +pub use buffers::{EdgeBuffer, NodeBuffer}; +pub use camera::Camera; +pub use force::{ForceLayout, ForceParams}; +pub use spatial_hash::SpatialHash; +pub use tree::tree_layout; diff --git a/crates/modules/pineal/mesh/src/spatial_hash.rs b/crates/modules/pineal/mesh/src/spatial_hash.rs new file mode 100644 index 0000000..aade27e --- /dev/null +++ b/crates/modules/pineal/mesh/src/spatial_hash.rs @@ -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>, +} + +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 { + 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); + } +} diff --git a/crates/modules/pineal/mesh/src/tree.rs b/crates/modules/pineal/mesh/src/tree.rs new file mode 100644 index 0000000..4241b29 --- /dev/null +++ b/crates/modules/pineal/mesh/src/tree.rs @@ -0,0 +1,105 @@ +//! Layout de árbol por ancho de subárbol. +//! +//! Post-order: las hojas se ubican en columnas consecutivas; cada nodo +//! interno se centra sobre sus hijos. `y` es la profundidad. Soporta +//! bosque (múltiples raíces). Ciclos en los punteros `parent` se ignoran +//! con gracia (esos nodos quedan en el origen). + +/// Calcula `(x, y)` por nodo. `parent[i] = None` marca una raíz. +pub fn tree_layout(parent: &[Option], x_gap: f32, y_gap: f32) -> Vec<(f32, f32)> { + let n = parent.len(); + let mut pos = vec![(0.0f32, 0.0f32); n]; + if n == 0 { + return pos; + } + + let mut children: Vec> = vec![Vec::new(); n]; + let mut roots: Vec = Vec::new(); + for i in 0..n { + match parent[i] { + Some(p) if p < n && p != i => children[p].push(i), + _ => roots.push(i), + } + } + + // Profundidad por BFS desde las raíces. + let mut depth = vec![0usize; n]; + let mut visited = vec![false; n]; + let mut queue: Vec = roots.clone(); + for &r in &roots { + visited[r] = true; + } + let mut head = 0; + while head < queue.len() { + let u = queue[head]; + head += 1; + for &c in &children[u] { + if !visited[c] { + visited[c] = true; + depth[c] = depth[u] + 1; + queue.push(c); + } + } + } + + // Asignación de `x` por post-order iterativo, raíz por raíz. + let mut next_leaf = 0.0f32; + for &root in &roots { + let mut stack: Vec<(usize, usize)> = vec![(root, 0)]; + while let Some(&mut (u, ref mut ci)) = stack.last_mut() { + if *ci < children[u].len() { + let c = children[u][*ci]; + *ci += 1; + stack.push((c, 0)); + } else { + if children[u].is_empty() { + pos[u].0 = next_leaf; + next_leaf += x_gap; + } else { + let sum: f32 = children[u].iter().map(|&c| pos[c].0).sum(); + pos[u].0 = sum / children[u].len() as f32; + } + pos[u].1 = depth[u] as f32 * y_gap; + stack.pop(); + } + } + } + pos +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn single_node_at_origin() { + let pos = tree_layout(&[None], 10.0, 20.0); + assert_eq!(pos, vec![(0.0, 0.0)]); + } + + #[test] + fn parent_centered_over_two_children() { + // 0 raíz; 1 y 2 hijos. + let pos = tree_layout(&[None, Some(0), Some(0)], 10.0, 20.0); + assert_eq!(pos[1], (0.0, 20.0)); + assert_eq!(pos[2], (10.0, 20.0)); + // padre centrado en x = (0+10)/2 = 5, depth 0. + assert_eq!(pos[0], (5.0, 0.0)); + } + + #[test] + fn depth_increases_down_the_tree() { + // cadena 0 → 1 → 2 + let pos = tree_layout(&[None, Some(0), Some(1)], 10.0, 20.0); + assert_eq!(pos[0].1, 0.0); + assert_eq!(pos[1].1, 20.0); + assert_eq!(pos[2].1, 40.0); + } + + #[test] + fn cycle_in_parents_does_not_hang() { + // 0 ↔ 1 sin raíz: no debe colgar. + let pos = tree_layout(&[Some(1), Some(0)], 10.0, 20.0); + assert_eq!(pos.len(), 2); + } +}