tawasuyu/pineal
Backend-agnostic GPU visualization — a catalog of painters over one Canvas trait, in Rust.
⎇ 514f17ba57e75d7e5db3a54d703d47377ca10fe6
00_unanchay/pineal/pineal-hexbin/src/lib.rs
Historia
Quién tocó qué
crudo
feat: pineal standalone — catálogo de painters GPU sobre Llimphi (front-door liviano)
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}