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,61 @@
//! Painter agnóstico: dibuja una `HeatmapMatrix` contra un `Canvas`.
//!
//! Emite un `fill_rect` por celda. Apto para matrices chicas y para
//! export SVG. Para matrices grandes el backend GPUI usa
//! [`crate::encoder`] + textura en vez de este camino.
use crate::matrix::HeatmapMatrix;
use crate::palette::Ramp;
use pineal_render::{Canvas, Rect};
/// Dibuja `matrix` dentro de `area`, una celda = un `fill_rect`.
/// Los valores se normalizan por min/max de la matriz.
pub fn paint(matrix: &HeatmapMatrix, ramp: Ramp, area: Rect, canvas: &mut dyn Canvas) {
let (w, h) = (matrix.width(), matrix.height());
if w == 0 || h == 0 {
return;
}
let (min, max) = matrix.min_max();
let span = max - min;
let cell_w = area.w / w as f32;
let cell_h = area.h / h as f32;
for y in 0..h {
for x in 0..w {
let v = matrix.get(x, y);
let t = if span > 0.0 { (v - min) / span } else { 0.0 };
let color = ramp.sample(t);
let rect = Rect::new(
area.x + x as f32 * cell_w,
area.y + y as f32 * cell_h,
cell_w,
cell_h,
);
canvas.fill_rect(rect, color);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pineal_render::{PlanRecorder, RenderCmd};
#[test]
fn emits_one_fill_rect_per_cell() {
let m = HeatmapMatrix::from_data(vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0], 3, 2).unwrap();
let mut rec = PlanRecorder::new();
paint(&m, Ramp::Viridis, Rect::new(0.0, 0.0, 300.0, 200.0), &mut rec);
let plan = rec.into_plan();
assert_eq!(plan.cmds.len(), 6);
assert!(plan.cmds.iter().all(|c| matches!(c, RenderCmd::FillRect { .. })));
}
#[test]
fn empty_matrix_emits_nothing() {
let m = HeatmapMatrix::new(0, 0);
let mut rec = PlanRecorder::new();
paint(&m, Ramp::Viridis, Rect::new(0.0, 0.0, 10.0, 10.0), &mut rec);
assert!(rec.into_plan().cmds.is_empty());
}
}