feat(pineal): cierra stub heatmap — matrix + viridis + encoder + paint

Fase F: segundo stub de pineal cerrado.

- matrix — HeatmapMatrix densa width×height de f32, con revision para
  invalidación de textura; get/set/min_max/replace_data.
- palette — Ramp::{Viridis, Grayscale}; Viridis por interpolación
  lineal de 5 control points perceptualmente uniformes.
- encoder — encode_argb: normaliza por min/max + rampa + pack 0xAARRGGBB
  para subir como textura (camino de matrices grandes).
- paint — painter agnóstico: un fill_rect por celda contra un Canvas
  (camino de matrices chicas + export SVG).

12 tests verdes. cargo check --workspace verde.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-20 14:13:10 +00:00
parent b75e22fa91
commit 4528e08e04
5 changed files with 323 additions and 16 deletions
@@ -0,0 +1,57 @@
//! Encoder: `HeatmapMatrix` → buffer ARGB para subir como textura.
use crate::matrix::HeatmapMatrix;
use crate::palette::Ramp;
/// Normaliza cada celda a `[0,1]` por min/max, la mapea por la rampa y
/// empaqueta el resultado como `u32` ARGB (0xAARRGGBB), fila por fila.
///
/// El backend GPUI sube este buffer como una textura y la rendea con un
/// solo `drawImageRect`, en vez de N draw calls.
pub fn encode_argb(matrix: &HeatmapMatrix, ramp: Ramp) -> Vec<u32> {
let (min, max) = matrix.min_max();
let span = max - min;
let mut out = Vec::with_capacity(matrix.width() * matrix.height());
for &v in matrix.data() {
let t = if span > 0.0 { (v - min) / span } else { 0.0 };
let c = ramp.sample(t);
out.push(pack_argb(c.a, c.r, c.g, c.b));
}
out
}
/// Empaqueta 4 canales `f32` `[0,1]` en `0xAARRGGBB`.
fn pack_argb(a: f32, r: f32, g: f32, b: f32) -> u32 {
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u32;
(q(a) << 24) | (q(r) << 16) | (q(g) << 8) | q(b)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encodes_one_pixel_per_cell() {
let m = HeatmapMatrix::from_data(vec![0.0, 1.0, 2.0, 3.0], 2, 2).unwrap();
let buf = encode_argb(&m, Ramp::Grayscale);
assert_eq!(buf.len(), 4);
}
#[test]
fn normalizes_min_to_ramp_start() {
// Grayscale: min → negro (0x000000), max → blanco (0xffffff).
let m = HeatmapMatrix::from_data(vec![10.0, 20.0], 2, 1).unwrap();
let buf = encode_argb(&m, Ramp::Grayscale);
assert_eq!(buf[0] & 0x00ff_ffff, 0x0000_0000); // min
assert_eq!(buf[1] & 0x00ff_ffff, 0x00ff_ffff); // max
assert_eq!(buf[0] >> 24, 0xff); // alpha opaco
}
#[test]
fn flat_matrix_does_not_divide_by_zero() {
let m = HeatmapMatrix::from_data(vec![5.0; 4], 2, 2).unwrap();
let buf = encode_argb(&m, Ramp::Viridis);
assert_eq!(buf.len(), 4);
assert!(buf.iter().all(|&p| p == buf[0]));
}
}