tawasuyu

tawasuyu/pineal

Backend-agnostic GPU visualization — a catalog of painters over one Canvas trait, in Rust.

Rust 82Markdown 69TOML 331099 Sutichaykuna

⎇ 514f17ba57e75d7e5db3a54d703d47377ca10fe6 00_unanchay/pineal/pineal-hexbin/src/lib.rs Rust · 249 siq'ikuna · 7.7 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat: pineal standalone — catálogo de painters GPU sobre Llimphi (front-door liviano) 514f17ba5 · Sergio ·

1//! `pineal-hexbin` — bineado hexagonal de scatter densos.
2//!
3//! Cuando un scatter tiene tantos puntos que la nube tapa el patrón,
4//! binear sobre una rejilla hexagonal pointy-top y colorear por
5//! densidad es la forma estándar de revelar la distribución. La
6//! topología hexagonal evita los "estrías" rectangulares de un binning
7//! cuadrado y respeta mejor la isotropía de la nube.
8//!
9//! - [`HexGrid`] — calcula el bin de cada `(x, y)` con la rejilla
10//!   pointy-top (hex con dos vértices arriba/abajo). Cell size = radio
11//!   del circumcírculo.
12//! - [`paint_hexbin`] — pinta los bins no vacíos como hexágonos
13//!   rellenos coloreados por densidad vía `pineal_heatmap::Ramp`.
14
15#![forbid(unsafe_code)]
16
17use pineal_heatmap::Ramp;
18use pineal_render::{Canvas, Color, Rect};
19use std::collections::HashMap;
20
21/// Rejilla hexagonal pointy-top con bines indexados por `(col, row)`
22/// según el offset-coord clásico ("odd-r" en la nomenclatura de Red
23/// Blob Games). El bineo es determinista y O(n) sobre los puntos.
24#[derive(Debug, Clone)]
25pub struct HexGrid {
26    radius: f32,
27    counts: HashMap<(i32, i32), u32>,
28}
29
30impl HexGrid {
31    /// Construye una rejilla vacía con celda de `radius` pixels.
32    pub fn new(radius: f32) -> Self {
33        Self { radius: radius.max(1e-3), counts: HashMap::new() }
34    }
35
36    /// Agrega un punto. Incrementa el bin correspondiente.
37    pub fn push(&mut self, x: f32, y: f32) {
38        let cell = pixel_to_oddr(x, y, self.radius);
39        *self.counts.entry(cell).or_insert(0) += 1;
40    }
41
42    /// Construye y popula a partir de un slice interleaved `[x0,y0,x1,y1…]`.
43    pub fn from_xy(radius: f32, xy: &[f32]) -> Self {
44        let mut g = Self::new(radius);
45        for chunk in xy.chunks_exact(2) {
46            g.push(chunk[0], chunk[1]);
47        }
48        g
49    }
50
51    pub fn radius(&self) -> f32 {
52        self.radius
53    }
54
55    pub fn cells(&self) -> impl Iterator<Item = ((i32, i32), u32)> + '_ {
56        self.counts.iter().map(|(k, v)| (*k, *v))
57    }
58
59    pub fn is_empty(&self) -> bool {
60        self.counts.is_empty()
61    }
62
63    /// `(min, max)` de cuentas. `(0, 0)` si está vacía.
64    pub fn min_max(&self) -> (u32, u32) {
65        let mut it = self.counts.values().copied();
66        let Some(first) = it.next() else { return (0, 0) };
67        it.fold((first, first), |(lo, hi), v| (lo.min(v), hi.max(v)))
68    }
69}
70
71/// Convierte pixel `(x, y)` a coords offset `(col, row)` odd-r.
72/// Algoritmo: pasar a axial (q, r) por pixel→axial pointy-top, luego
73/// redondear vía cube-coordinate-rounding, luego axial→oddr.
74fn pixel_to_oddr(x: f32, y: f32, radius: f32) -> (i32, i32) {
75    let sqrt3 = 3.0_f32.sqrt();
76    let q = (sqrt3 / 3.0 * x - y / 3.0) / radius;
77    let r = (2.0 / 3.0 * y) / radius;
78    let (qr, rr) = cube_round(q, r);
79    let col = qr + (rr - (rr & 1)) / 2;
80    (col, rr)
81}
82
83fn cube_round(q: f32, r: f32) -> (i32, i32) {
84    let x = q;
85    let z = r;
86    let y = -x - z;
87    let mut rx = x.round();
88    let ry = y.round();
89    let mut rz = z.round();
90    let dx = (rx - x).abs();
91    let dy = (ry - y).abs();
92    let dz = (rz - z).abs();
93    if dx > dy && dx > dz {
94        rx = -ry - rz;
95    } else if dz > dy {
96        rz = -rx - ry;
97    }
98    // En el caso restante (dy es el peor), el output sólo usa rx y rz —
99    // no hace falta ajustar ry. La invariante rx+ry+rz=0 no nos importa
100    // porque ry no se devuelve.
101    (rx as i32, rz as i32)
102}
103
104/// Convierte offset `(col, row)` odd-r a centro pixel.
105pub fn oddr_to_pixel(col: i32, row: i32, radius: f32) -> (f32, f32) {
106    let sqrt3 = 3.0_f32.sqrt();
107    let x = radius * sqrt3 * (col as f32 + 0.5 * (row & 1) as f32);
108    let y = radius * 3.0 / 2.0 * row as f32;
109    (x, y)
110}
111
112/// Dibuja todos los bines de `grid` sobre `canvas`, mapeando densidad a
113/// color con `ramp`. `offset` desplaza la grilla (se suma al centro de
114/// cada hex). El alto y ancho del rect destino los decide el caller —
115/// los hexes se dibujan en sus coords absolutas, no se reescalan.
116pub fn paint_hexbin(
117    grid: &HexGrid,
118    ramp: Ramp,
119    offset: (f32, f32),
120    canvas: &mut dyn Canvas,
121) {
122    if grid.is_empty() {
123        return;
124    }
125    let (min, max) = grid.min_max();
126    let span = (max - min) as f32;
127    let r = grid.radius();
128
129    for ((col, row), count) in grid.cells() {
130        let (cx, cy) = oddr_to_pixel(col, row, r);
131        let cx = cx + offset.0;
132        let cy = cy + offset.1;
133        let t = if span > 0.0 {
134            (count - min) as f32 / span
135        } else {
136            0.0
137        };
138        let color = ramp.sample(t);
139        paint_hex(cx, cy, r, color, canvas);
140    }
141}
142
143/// Hexágono pointy-top centrado en `(cx, cy)` con circumcircle `r`.
144/// Se tesselan los 6 triángulos del fan compartiendo el centro.
145fn paint_hex(cx: f32, cy: f32, r: f32, color: Color, canvas: &mut dyn Canvas) {
146    use std::f32::consts::{FRAC_PI_3, FRAC_PI_2};
147    // Fan: [center, v0, center, v1, ..., center, v0].
148    let mut coords = Vec::with_capacity(14 * 2);
149    let mut colors = Vec::with_capacity(14);
150    for i in 0..=6 {
151        let a = -FRAC_PI_2 + i as f32 * FRAC_PI_3;
152        coords.push(cx);
153        coords.push(cy);
154        coords.push(cx + r * a.cos());
155        coords.push(cy + r * a.sin());
156        colors.push(color);
157        colors.push(color);
158    }
159    canvas.fill_triangle_strip(&coords, &colors);
160}
161
162/// `Rect` con todos los hexes — útil para que el caller dimensione el
163/// viewport antes de pintar.
164pub fn bounds(grid: &HexGrid) -> Option<Rect> {
165    if grid.is_empty() {
166        return None;
167    }
168    let r = grid.radius();
169    let mut min_x = f32::MAX;
170    let mut min_y = f32::MAX;
171    let mut max_x = f32::MIN;
172    let mut max_y = f32::MIN;
173    for ((col, row), _) in grid.cells() {
174        let (cx, cy) = oddr_to_pixel(col, row, r);
175        min_x = min_x.min(cx - r);
176        max_x = max_x.max(cx + r);
177        min_y = min_y.min(cy - r);
178        max_y = max_y.max(cy + r);
179    }
180    Some(Rect::new(min_x, min_y, max_x - min_x, max_y - min_y))
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use pineal_render::{PlanRecorder, RenderCmd};
187
188    #[test]
189    fn empty_grid_is_noop() {
190        let g = HexGrid::new(10.0);
191        let mut rec = PlanRecorder::new();
192        paint_hexbin(&g, Ramp::Viridis, (0.0, 0.0), &mut rec);
193        assert!(rec.into_plan().cmds.is_empty());
194    }
195
196    #[test]
197    fn coincident_points_bin_together() {
198        let mut g = HexGrid::new(5.0);
199        g.push(10.0, 10.0);
200        g.push(10.0, 10.0);
201        g.push(10.0, 10.0);
202        assert_eq!(g.cells().count(), 1);
203        let (_, count) = g.cells().next().unwrap();
204        assert_eq!(count, 3);
205    }
206
207    #[test]
208    fn far_points_bin_separately() {
209        let mut g = HexGrid::new(5.0);
210        g.push(0.0, 0.0);
211        g.push(1000.0, 1000.0);
212        assert_eq!(g.cells().count(), 2);
213    }
214
215    #[test]
216    fn paint_emits_one_strip_per_cell() {
217        let mut g = HexGrid::new(8.0);
218        g.push(0.0, 0.0);
219        g.push(100.0, 100.0);
220        g.push(50.0, 50.0);
221        let mut rec = PlanRecorder::new();
222        paint_hexbin(&g, Ramp::Viridis, (0.0, 0.0), &mut rec);
223        let strips = rec
224            .into_plan()
225            .cmds
226            .iter()
227            .filter(|c| matches!(c, RenderCmd::FillTriangleStrip { .. }))
228            .count();
229        assert_eq!(strips, 3);
230    }
231
232    #[test]
233    fn from_xy_populates_correctly() {
234        let xy = [0.0, 0.0, 100.0, 100.0, 100.0, 100.0];
235        let g = HexGrid::from_xy(5.0, &xy);
236        assert_eq!(g.cells().count(), 2);
237        let (_, max) = g.min_max();
238        assert_eq!(max, 2);
239    }
240
241    #[test]
242    fn bounds_covers_extremes() {
243        let mut g = HexGrid::new(5.0);
244        g.push(0.0, 0.0);
245        g.push(100.0, 50.0);
246        let b = bounds(&g).unwrap();
247        assert!(b.w > 0.0 && b.h > 0.0);
248    }
249}