Delta semántico · 149
crates/modules/ui_engine/libs/lapaloma-core/src/buffer.rs
crates/modules/ui_engine/libs/lapaloma-core/src/lib.rs
crates/modules/ui_engine/libs/lapaloma-core/src/lttb.rs
crates/modules/ui_engine/libs/lapaloma-core/src/ring.rs
crates/modules/ui_engine/libs/lapaloma-core/src/scale.rs
crates/modules/ui_engine/libs/lapaloma-core/src/spatial.rs
crates/modules/ui_engine/widgets/lapaloma-cartesian/src/lib.rs
crates/modules/ui_engine/widgets/lapaloma-export/src/lib.rs
- nueva mod pdf pub
- nueva mod svg pub
crates/modules/ui_engine/widgets/lapaloma-financial/src/lib.rs
crates/modules/ui_engine/widgets/lapaloma-flow/src/lib.rs
crates/modules/ui_engine/widgets/lapaloma-heatmap/src/lib.rs
crates/modules/ui_engine/widgets/lapaloma-mesh/src/lib.rs
crates/modules/ui_engine/widgets/lapaloma-phosphor/src/lib.rs
crates/modules/ui_engine/widgets/lapaloma-polar/src/lib.rs
crates/modules/ui_engine/widgets/lapaloma-render/src/canvas.rs
crates/modules/ui_engine/widgets/lapaloma-render/src/color.rs
crates/modules/ui_engine/widgets/lapaloma-render/src/geom.rs
crates/modules/ui_engine/widgets/lapaloma-render/src/lib.rs
crates/modules/ui_engine/widgets/lapaloma-render/src/plan.rs
crates/modules/ui_engine/widgets/lapaloma-stream/src/lib.rs
crates/modules/ui_engine/widgets/lapaloma-treemap/src/lib.rs
diff --git a/Cargo.lock b/Cargo.lock
index 4cb9aed..b1adef1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -5362,6 +5362,126 @@ dependencies = [
"rustversion",
]
+[[package]]
+name = "lapaloma"
+version = "0.1.0"
+dependencies = [
+ "lapaloma-cartesian",
+ "lapaloma-core",
+ "lapaloma-export",
+ "lapaloma-financial",
+ "lapaloma-flow",
+ "lapaloma-heatmap",
+ "lapaloma-mesh",
+ "lapaloma-phosphor",
+ "lapaloma-polar",
+ "lapaloma-render",
+ "lapaloma-stream",
+ "lapaloma-treemap",
+]
+
+[[package]]
+name = "lapaloma-cartesian"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+ "lapaloma-core",
+ "lapaloma-render",
+]
+
+[[package]]
+name = "lapaloma-core"
+version = "0.1.0"
+
+[[package]]
+name = "lapaloma-export"
+version = "0.1.0"
+dependencies = [
+ "lapaloma-core",
+ "lapaloma-render",
+]
+
+[[package]]
+name = "lapaloma-financial"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+ "lapaloma-cartesian",
+ "lapaloma-core",
+ "lapaloma-render",
+]
+
+[[package]]
+name = "lapaloma-flow"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+ "lapaloma-core",
+ "lapaloma-render",
+]
+
+[[package]]
+name = "lapaloma-heatmap"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+ "lapaloma-core",
+ "lapaloma-render",
+]
+
+[[package]]
+name = "lapaloma-mesh"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+ "lapaloma-core",
+ "lapaloma-render",
+]
+
+[[package]]
+name = "lapaloma-phosphor"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+ "lapaloma-core",
+ "lapaloma-render",
+ "lapaloma-stream",
+]
+
+[[package]]
+name = "lapaloma-polar"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+ "lapaloma-core",
+ "lapaloma-render",
+]
+
+[[package]]
+name = "lapaloma-render"
+version = "0.1.0"
+dependencies = [
+ "lapaloma-core",
+]
+
+[[package]]
+name = "lapaloma-stream"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+ "lapaloma-core",
+ "lapaloma-render",
+]
+
+[[package]]
+name = "lapaloma-treemap"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+ "lapaloma-core",
+ "lapaloma-render",
+]
+
[[package]]
name = "lazy_static"
version = "1.5.0"
diff --git a/Cargo.toml b/Cargo.toml
index b85535f..0e444ad 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -71,6 +71,21 @@ members = [
"crates/modules/ui_engine/widgets/app-header",
"crates/modules/ui_engine/widgets/theme-switcher",
+ # --- lapaloma: módulo de gráficos data-viz (ver ARCHITECTURE.md fuente) ---
+ "crates/modules/ui_engine/libs/lapaloma-core",
+ "crates/modules/ui_engine/widgets/lapaloma-render",
+ "crates/modules/ui_engine/widgets/lapaloma-cartesian",
+ "crates/modules/ui_engine/widgets/lapaloma-stream",
+ "crates/modules/ui_engine/widgets/lapaloma-mesh",
+ "crates/modules/ui_engine/widgets/lapaloma-financial",
+ "crates/modules/ui_engine/widgets/lapaloma-polar",
+ "crates/modules/ui_engine/widgets/lapaloma-heatmap",
+ "crates/modules/ui_engine/widgets/lapaloma-treemap",
+ "crates/modules/ui_engine/widgets/lapaloma-flow",
+ "crates/modules/ui_engine/widgets/lapaloma-phosphor",
+ "crates/modules/ui_engine/widgets/lapaloma-export",
+ "crates/modules/ui_engine/widgets/lapaloma",
+
# ============================================================
# modules/nakui/ — ERP matemático (nakui absorbido)
# ============================================================
@@ -235,6 +250,23 @@ yahweh-database-explorer = { path = "crates/apps/database_explorer" }
yahweh-text-viewer = { path = "crates/apps/text_viewer" }
yahweh-image-viewer = { path = "crates/apps/image_viewer" }
+# ============================================================
+# Intra-workspace deps de lapaloma (módulo de gráficos)
+# ============================================================
+lapaloma-core = { path = "crates/modules/ui_engine/libs/lapaloma-core" }
+lapaloma-render = { path = "crates/modules/ui_engine/widgets/lapaloma-render" }
+lapaloma-cartesian = { path = "crates/modules/ui_engine/widgets/lapaloma-cartesian" }
+lapaloma-stream = { path = "crates/modules/ui_engine/widgets/lapaloma-stream" }
+lapaloma-mesh = { path = "crates/modules/ui_engine/widgets/lapaloma-mesh" }
+lapaloma-financial = { path = "crates/modules/ui_engine/widgets/lapaloma-financial" }
+lapaloma-polar = { path = "crates/modules/ui_engine/widgets/lapaloma-polar" }
+lapaloma-heatmap = { path = "crates/modules/ui_engine/widgets/lapaloma-heatmap" }
+lapaloma-treemap = { path = "crates/modules/ui_engine/widgets/lapaloma-treemap" }
+lapaloma-flow = { path = "crates/modules/ui_engine/widgets/lapaloma-flow" }
+lapaloma-phosphor = { path = "crates/modules/ui_engine/widgets/lapaloma-phosphor" }
+lapaloma-export = { path = "crates/modules/ui_engine/widgets/lapaloma-export" }
+lapaloma = { path = "crates/modules/ui_engine/widgets/lapaloma" }
+
[profile.release]
lto = "thin"
codegen-units = 1
diff --git a/crates/modules/ui_engine/libs/lapaloma-core/Cargo.toml b/crates/modules/ui_engine/libs/lapaloma-core/Cargo.toml
new file mode 100644
index 0000000..2496550
--- /dev/null
+++ b/crates/modules/ui_engine/libs/lapaloma-core/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "lapaloma-core"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+authors = { workspace = true }
+publish = { workspace = true }
+description = "Lapaloma — primitivas agnósticas: DataBuffer interleaved, RingBuffer streaming, SpatialIndex, LTTB, escalas. Cero gpui, cero alloc en hot path."
+
+[dependencies]
+
+[dev-dependencies]
diff --git a/crates/modules/ui_engine/libs/lapaloma-core/src/buffer.rs b/crates/modules/ui_engine/libs/lapaloma-core/src/buffer.rs
new file mode 100644
index 0000000..a80469a
--- /dev/null
+++ b/crates/modules/ui_engine/libs/lapaloma-core/src/buffer.rs
@@ -0,0 +1,128 @@
+//! `DataBuffer` — buffer interleaved `[x0, y0, x1, y1, ...]` con
+//! revision counter para invalidación de cachés.
+//!
+//! Es la primitiva universal de Lapaloma: todo serie cartesiana,
+//! todo grafo de nodos, todo OHLC vive en uno de estos (o en una
+//! variante con stride distinto). El layout `f32` x `f32` es lo
+//! que el GPU consume sin transformación.
+
+/// Buffer de coordenadas planas `[x, y]` empacadas.
+///
+/// La longitud lógica (número de puntos) es `coords.len() / 2`.
+/// Mutar in-place (`set_xy`, `push`) bumpea `revision` — los
+/// painters comparan su `last_seen_revision` para decidir si
+/// rebuilear su caché.
+#[derive(Debug, Clone, Default)]
+pub struct DataBuffer {
+ coords: Vec<f32>,
+ revision: u64,
+}
+
+impl DataBuffer {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Reserva espacio para `n` puntos sin agregarlos. Usalo al
+ /// montar el widget para que `push` no realloque después.
+ pub fn with_capacity(n: usize) -> Self {
+ Self {
+ coords: Vec::with_capacity(n * 2),
+ revision: 0,
+ }
+ }
+
+ /// Construye a partir de coords interleaved ya armadas.
+ /// Útil en tests y carga inicial.
+ pub fn from_interleaved(coords: Vec<f32>) -> Self {
+ assert!(coords.len() % 2 == 0, "interleaved coords deben ser pares");
+ Self {
+ coords,
+ revision: 0,
+ }
+ }
+
+ pub fn push(&mut self, x: f32, y: f32) {
+ self.coords.push(x);
+ self.coords.push(y);
+ self.revision = self.revision.wrapping_add(1);
+ }
+
+ /// Sobrescribe un punto existente. `i` es el índice de punto
+ /// (no de float), 0-based.
+ pub fn set_xy(&mut self, i: usize, x: f32, y: f32) {
+ self.coords[i * 2] = x;
+ self.coords[i * 2 + 1] = y;
+ self.revision = self.revision.wrapping_add(1);
+ }
+
+ /// Pisa el contenido completo con la nueva slice.
+ /// Útil para hidratar el buffer en un solo memcpy.
+ pub fn replace_from(&mut self, src: &[f32]) {
+ assert!(src.len() % 2 == 0);
+ self.coords.clear();
+ self.coords.extend_from_slice(src);
+ self.revision = self.revision.wrapping_add(1);
+ }
+
+ pub fn clear(&mut self) {
+ self.coords.clear();
+ self.revision = self.revision.wrapping_add(1);
+ }
+
+ pub fn len(&self) -> usize {
+ self.coords.len() / 2
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.coords.is_empty()
+ }
+
+ pub fn xy(&self, i: usize) -> (f32, f32) {
+ (self.coords[i * 2], self.coords[i * 2 + 1])
+ }
+
+ /// Slice plana lista para `drawRawPoints` / `wgpu::Buffer`
+ /// / `<polyline points>`. No realiza copia.
+ pub fn coords(&self) -> &[f32] {
+ &self.coords
+ }
+
+ pub fn revision(&self) -> u64 {
+ self.revision
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn push_y_len() {
+ let mut b = DataBuffer::with_capacity(4);
+ b.push(0.0, 1.0);
+ b.push(1.0, 2.0);
+ assert_eq!(b.len(), 2);
+ assert_eq!(b.xy(1), (1.0, 2.0));
+ }
+
+ #[test]
+ fn revision_bumps() {
+ let mut b = DataBuffer::new();
+ let r0 = b.revision();
+ b.push(0.0, 0.0);
+ let r1 = b.revision();
+ b.set_xy(0, 1.0, 1.0);
+ let r2 = b.revision();
+ assert_ne!(r0, r1);
+ assert_ne!(r1, r2);
+ }
+
+ #[test]
+ fn coords_slice_is_zero_copy() {
+ let raw = vec![0.0, 0.0, 1.0, 1.0, 2.0, 2.0];
+ let b = DataBuffer::from_interleaved(raw);
+ assert_eq!(b.coords(), &[0.0, 0.0, 1.0, 1.0, 2.0, 2.0]);
+ assert_eq!(b.len(), 3);
+ }
+}
diff --git a/crates/modules/ui_engine/libs/lapaloma-core/src/lib.rs b/crates/modules/ui_engine/libs/lapaloma-core/src/lib.rs
new file mode 100644
index 0000000..2607e7a
--- /dev/null
+++ b/crates/modules/ui_engine/libs/lapaloma-core/src/lib.rs
@@ -0,0 +1,55 @@
+//! `lapaloma-core` — primitivas agnósticas de Lapaloma.
+//!
+//! Cero `gpui`, cero `wgpu`, cero I/O. Todo lo que vive acá puede
+//! correr en un test unitario, en un worker thread o en un export
+//! a SVG. Las tres reglas del documento de arquitectura aplican:
+//!
+//! - **P1 Zero boxing.** Los datos viven en `Vec<f32>` planos
+//! indexados, nunca como `Vec<Point2D>`. Cache L1 caliente y el
+//! compilador puede SIMD-loopearlo.
+//! - **P2 Zero alloc en hot path.** Buffers se reservan al construir,
+//! se mutan in-place para siempre. Helpers escriben a `&mut Vec`
+//! provistos por el caller, no devuelven `Vec` nuevos.
+//! - **P3 Una draw call por capa.** Acá no se dibuja; pero los
+//! tipos exponen slices contiguos listos para mandar al GPU
+//! sin copia.
+//!
+//! Convención de coordenadas: el buffer canónico es interleaved
+//! `[x0, y0, x1, y1, ...]`. Esto es el formato que `drawRawPoints`,
+//! `Vertices.raw`, `wgpu` vertex buffers y `<polyline points>` SVG
+//! consumen sin transformación.
+
+#![forbid(unsafe_code)]
+
+pub mod buffer;
+pub mod ring;
+pub mod spatial;
+pub mod lttb;
+pub mod scale;
+
+// Algoritmos de layout — quedan como placeholders hasta que cada
+// módulo de visualización (mesh, treemap, flow) los demande.
+
+/// Barnes-Hut quadtree para layouts force-directed.
+///
+/// Cuando se implemente: el quadtree es un `Vec<f32>` plano de
+/// stride 7 (cm_x, cm_y, mass, half_size, center_x, center_y,
+/// child_base), no un árbol de objetos. Rebuild O(n) por frame
+/// sin allocations.
+pub mod barnes_hut {}
+
+/// Sugiyama-lite jerárquico: cycle-removal por DFS + Kahn layering
+/// + barycenter ordering con inversion-count crossings.
+pub mod sugiyama {}
+
+/// Squarified treemap (Bruls / d3-hierarchy). Worst-aspect formula
+/// usa el lado *corto* del rectángulo restante.
+pub mod squarify {}
+
+/// Subtree-width tree layout: BFS spanning + bottom-up width
+/// measurement + top-down placement. Simpler que Reingold-Tilford.
+pub mod tree_layout {}
+
+/// Force-Directed Edge Bundling (FDEB-lite, single quadratic-bezier
+/// control point por edge).
+pub mod fdeb {}
diff --git a/crates/modules/ui_engine/libs/lapaloma-core/src/lttb.rs b/crates/modules/ui_engine/libs/lapaloma-core/src/lttb.rs
new file mode 100644
index 0000000..31466c4
--- /dev/null
+++ b/crates/modules/ui_engine/libs/lapaloma-core/src/lttb.rs
@@ -0,0 +1,177 @@
+//! LTTB (Largest-Triangle-Three-Buckets) — downsampling preservador
+//! de silueta para series cartesianas.
+//!
+//! Algoritmo: dividir `n` puntos en `k-2` buckets (los extremos se
+//! mantienen siempre). Por cada bucket, elegir el punto que forma
+//! el triángulo de área máxima con el último punto elegido y el
+//! centroide del bucket siguiente. Costo total O(n). Output ≤ k.
+//!
+//! Knob práctico: `target ≈ width_px × 3`. Tres vértices por pixel,
+//! el anti-aliasing rellena el resto.
+
+/// Reduce `coords` (interleaved `[x,y,x,y,…]`) a a lo sumo `target`
+/// puntos, escribiendo los **índices originales** seleccionados en
+/// `out` (sin clearearlo: el caller decide).
+///
+/// Si `n <= target` o `target < 3`, devuelve todos los índices
+/// `[0..n)`.
+pub fn lttb_indices(coords: &[f32], target: usize, out: &mut Vec<usize>) {
+ let n = coords.len() / 2;
+ if n == 0 {
+ return;
+ }
+ if n <= target || target < 3 {
+ out.extend(0..n);
+ return;
+ }
+ lttb_in_range_indices(coords, 0, n, target, out);
+}
+
+/// Variante que opera sobre el rango `[start, end)` de un buffer
+/// más grande. Los índices devueltos son **absolutos** (relativos
+/// al `coords` original), no al sub-rango — esto le ahorra al caller
+/// la corrección de offset después de un `SpatialIndex::range`.
+pub fn lttb_in_range_indices(
+ coords: &[f32],
+ start: usize,
+ end: usize,
+ target: usize,
+ out: &mut Vec<usize>,
+) {
+ debug_assert!(coords.len() % 2 == 0);
+ debug_assert!(start <= end && end <= coords.len() / 2);
+
+ let len = end - start;
+ if len == 0 {
+ return;
+ }
+ if len <= target || target < 3 {
+ out.extend(start..end);
+ return;
+ }
+
+ // Primero el extremo izquierdo.
+ out.push(start);
+
+ let bucket_size = (len - 2) as f64 / (target - 2) as f64;
+ let mut a = start; // último punto elegido
+
+ for i in 0..target - 2 {
+ // Bucket actual y siguiente, en índices absolutos.
+ let cur_lo = start + 1 + (i as f64 * bucket_size).floor() as usize;
+ let cur_hi = start + 1 + ((i + 1) as f64 * bucket_size).floor() as usize;
+ let next_lo = cur_hi.min(end);
+ let next_hi = (start + 1 + ((i + 2) as f64 * bucket_size).floor() as usize).min(end);
+
+ // Centroide del bucket siguiente. Si está vacío, fallback
+ // al último punto.
+ let (avg_x, avg_y) = if next_hi > next_lo {
+ let span = (next_hi - next_lo) as f32;
+ let mut sx = 0.0f32;
+ let mut sy = 0.0f32;
+ for j in next_lo..next_hi {
+ sx += coords[j * 2];
+ sy += coords[j * 2 + 1];
+ }
+ (sx / span, sy / span)
+ } else {
+ (coords[(end - 1) * 2], coords[(end - 1) * 2 + 1])
+ };
+
+ let ax = coords[a * 2];
+ let ay = coords[a * 2 + 1];
+
+ let mut max_area = -1.0f32;
+ let mut max_idx = cur_lo;
+ for j in cur_lo..cur_hi.min(end) {
+ let bx = coords[j * 2];
+ let by = coords[j * 2 + 1];
+ // Área del triángulo (sin /2 porque comparamos relativos).
+ let area = ((ax - avg_x) * (by - ay) - (ax - bx) * (avg_y - ay)).abs();
+ if area > max_area {
+ max_area = area;
+ max_idx = j;
+ }
+ }
+ out.push(max_idx);
+ a = max_idx;
+ }
+
+ // Extremo derecho.
+ out.push(end - 1);
+}
+
+/// Variante que materializa coords decimadas directamente — útil
+/// cuando el painter sólo quiere un slice listo para `drawRawPoints`
+/// y no necesita los índices.
+pub fn lttb_coords(coords: &[f32], target: usize, out: &mut Vec<f32>) {
+ let mut idx_buf: Vec<usize> = Vec::with_capacity(target);
+ lttb_indices(coords, target, &mut idx_buf);
+ for i in idx_buf {
+ out.push(coords[i * 2]);
+ out.push(coords[i * 2 + 1]);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn no_decimate_si_n_menor_que_target() {
+ let coords: Vec<f32> = (0..5).flat_map(|i| [i as f32, (i * i) as f32]).collect();
+ let mut out = Vec::new();
+ lttb_indices(&coords, 10, &mut out);
+ assert_eq!(out, vec![0, 1, 2, 3, 4]);
+ }
+
+ #[test]
+ fn extremos_preservados() {
+ let n = 100;
+ let coords: Vec<f32> = (0..n).flat_map(|i| [i as f32, (i as f32).sin()]).collect();
+ let mut out = Vec::new();
+ lttb_indices(&coords, 10, &mut out);
+ assert_eq!(out.first(), Some(&0));
+ assert_eq!(out.last(), Some(&(n - 1)));
+ assert!(out.len() <= 10);
+ }
+
+ #[test]
+ fn indices_sorted_y_unicos() {
+ let coords: Vec<f32> = (0..1000)
+ .flat_map(|i| [i as f32, (i as f32 * 0.01).sin()])
+ .collect();
+ let mut out = Vec::new();
+ lttb_indices(&coords, 50, &mut out);
+ for w in out.windows(2) {
+ assert!(w[0] < w[1], "indices deben ser estrictamente crecientes");
+ }
+ }
+
+ #[test]
+ fn in_range_indices_son_absolutos() {
+ let n = 100;
+ let coords: Vec<f32> = (0..n).flat_map(|i| [i as f32, i as f32]).collect();
+ let mut out = Vec::new();
+ lttb_in_range_indices(&coords, 20, 80, 10, &mut out);
+ assert_eq!(out.first(), Some(&20));
+ assert_eq!(out.last(), Some(&79));
+ // ningún índice fuera del rango pedido
+ for &i in &out {
+ assert!(i >= 20 && i < 80);
+ }
+ }
+
+ #[test]
+ fn preserva_picos_extremos() {
+ // Señal plana con un pico al medio: LTTB debe agarrar el pico.
+ let mut coords: Vec<f32> = Vec::new();
+ for i in 0..200 {
+ coords.push(i as f32);
+ coords.push(if i == 100 { 10.0 } else { 0.0 });
+ }
+ let mut out = Vec::new();
+ lttb_indices(&coords, 20, &mut out);
+ assert!(out.contains(&100), "pico debe sobrevivir el downsample");
+ }
+}
diff --git a/crates/modules/ui_engine/libs/lapaloma-core/src/ring.rs b/crates/modules/ui_engine/libs/lapaloma-core/src/ring.rs
new file mode 100644
index 0000000..2f5fa55
--- /dev/null
+++ b/crates/modules/ui_engine/libs/lapaloma-core/src/ring.rs
@@ -0,0 +1,216 @@
+//! `RingBuffer` — buffer circular de samples para streaming tipo
+//! osciloscopio.
+//!
+//! Capacidad fija. `push(v)` hace dos writes (uno a `values`, uno
+//! a `coords[head*2+1]`) y un increment de head + revision. El
+//! buffer **nunca se reasigna**; el painter consume slices del
+//! mismo backing memory frame tras frame.
+//!
+//! Convención: `x_norm` se pre-computa una vez en construcción
+//! (modo sweep). El painter aplica el escalado a píxeles via su
+//! propio transform — el buffer no rota X entre frames.
+//!
+//! ## Trampa del pre-fill (1.0.2 fix del Flutter)
+//!
+//! Antes que `count >= capacity`, los slots `[head, capacity)`
+//! contienen ceros iniciales. Si el painter dibuja toda la
+//! ringa, aparece una línea plana sobre la mitad derecha. La
+//! API expone [`RingBuffer::filled_len`] que devuelve `head` en
+//! ese caso, y `capacity` después — el painter clipea a eso.
+
+/// Ring buffer en modo sweep (x_norm de cada slot es fijo).
+///
+/// Para modo scroll el painter aplica un translate adicional por
+/// frame; la estructura de datos es la misma.
+#[derive(Debug, Clone)]
+pub struct RingBuffer {
+ /// Sample raw por slot.
+ values: Vec<f32>,
+ /// `[x_norm, y_value]` por slot. `x_norm = slot / (cap - 1)`,
+ /// fijo. `y_value` = `values[slot]`.
+ coords: Vec<f32>,
+ capacity: usize,
+ /// Próximo slot a escribir.
+ head: usize,
+ /// Monotonic, sobrevive wraparound. Útil para anclar
+ /// anotaciones por sample index absoluto.
+ count: u64,
+ revision: u64,
+}
+
+impl RingBuffer {
+ /// Asume `capacity >= 2` para que `x_norm` no divida por cero.
+ pub fn new(capacity: usize) -> Self {
+ assert!(capacity >= 2, "RingBuffer requiere capacity >= 2");
+ let mut coords = vec![0.0; capacity * 2];
+ let denom = (capacity - 1) as f32;
+ for slot in 0..capacity {
+ coords[slot * 2] = slot as f32 / denom;
+ }
+ Self {
+ values: vec![0.0; capacity],
+ coords,
+ capacity,
+ head: 0,
+ count: 0,
+ revision: 0,
+ }
+ }
+
+ pub fn push(&mut self, v: f32) {
+ self.values[self.head] = v;
+ self.coords[self.head * 2 + 1] = v;
+ self.head = (self.head + 1) % self.capacity;
+ self.count = self.count.wrapping_add(1);
+ self.revision = self.revision.wrapping_add(1);
+ }
+
+ /// Inserción en batch con dos memcpys (cola + wrap-around).
+ /// Para batches > capacity se queda con los últimos `capacity`
+ /// samples (los anteriores se sobreescribirían igual).
+ pub fn push_all(&mut self, batch: &[f32]) {
+ if batch.is_empty() {
+ return;
+ }
+
+ let cap = self.capacity;
+ let src = if batch.len() > cap {
+ &batch[batch.len() - cap..]
+ } else {
+ batch
+ };
+
+ let tail = cap - self.head;
+ if src.len() <= tail {
+ self.values[self.head..self.head + src.len()].copy_from_slice(src);
+ for (i, v) in src.iter().enumerate() {
+ self.coords[(self.head + i) * 2 + 1] = *v;
+ }
+ self.head = (self.head + src.len()) % cap;
+ } else {
+ let (a, b) = src.split_at(tail);
+ self.values[self.head..].copy_from_slice(a);
+ for (i, v) in a.iter().enumerate() {
+ self.coords[(self.head + i) * 2 + 1] = *v;
+ }
+ self.values[..b.len()].copy_from_slice(b);
+ for (i, v) in b.iter().enumerate() {
+ self.coords[i * 2 + 1] = *v;
+ }
+ self.head = b.len();
+ }
+
+ self.count = self.count.wrapping_add(src.len() as u64);
+ self.revision = self.revision.wrapping_add(1);
+ }
+
+ pub fn capacity(&self) -> usize {
+ self.capacity
+ }
+
+ pub fn head(&self) -> usize {
+ self.head
+ }
+
+ pub fn count(&self) -> u64 {
+ self.count
+ }
+
+ pub fn revision(&self) -> u64 {
+ self.revision
+ }
+
+ pub fn is_full(&self) -> bool {
+ self.count >= self.capacity as u64
+ }
+
+ /// Cantidad de slots con datos reales. Antes del fill es
+ /// `head`; después es `capacity`. El painter clipea a este
+ /// valor para evitar el flicker del pre-fill.
+ pub fn filled_len(&self) -> usize {
+ if self.is_full() {
+ self.capacity
+ } else {
+ self.head
+ }
+ }
+
+ /// Slice interleaved de `[x_norm, y]`. Para render en dos
+ /// segmentos: `&coords()[..head*2]` y `&coords()[head*2..]`
+ /// (cuando is_full).
+ pub fn coords(&self) -> &[f32] {
+ &self.coords
+ }
+
+ /// Slice plana de samples raw — útil para downsample envelope
+ /// min/max sin pasar por coords.
+ pub fn values(&self) -> &[f32] {
+ &self.values
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn x_norm_precomputado() {
+ let r = RingBuffer::new(4);
+ // x_norm en slots 0, 1, 2, 3 = 0.0, 1/3, 2/3, 1.0
+ assert!((r.coords()[0] - 0.0).abs() < 1e-6);
+ assert!((r.coords()[2] - 1.0 / 3.0).abs() < 1e-6);
+ assert!((r.coords()[6] - 1.0).abs() < 1e-6);
+ }
+
+ #[test]
+ fn push_actualiza_y_no_x() {
+ let mut r = RingBuffer::new(4);
+ r.push(5.0);
+ r.push(7.0);
+ // slot 0 → y=5, slot 1 → y=7, x quedó igual
+ assert_eq!(r.coords()[1], 5.0);
+ assert_eq!(r.coords()[3], 7.0);
+ assert!((r.coords()[2] - 1.0 / 3.0).abs() < 1e-6);
+ assert_eq!(r.head(), 2);
+ assert_eq!(r.count(), 2);
+ }
+
+ #[test]
+ fn filled_len_bloquea_prefill() {
+ let mut r = RingBuffer::new(4);
+ assert_eq!(r.filled_len(), 0);
+ r.push(1.0);
+ r.push(2.0);
+ assert_eq!(r.filled_len(), 2);
+ r.push(3.0);
+ r.push(4.0);
+ assert_eq!(r.filled_len(), 4);
+ r.push(5.0); // wrap
+ assert_eq!(r.filled_len(), 4);
+ assert!(r.is_full());
+ }
+
+ #[test]
+ fn push_all_wrap_around() {
+ let mut r = RingBuffer::new(4);
+ r.push_all(&[1.0, 2.0, 3.0]); // head=3
+ r.push_all(&[4.0, 5.0, 6.0]); // wrap: 4 en slot 3, 5 en slot 0, 6 en slot 1
+ assert_eq!(r.values()[3], 4.0);
+ assert_eq!(r.values()[0], 5.0);
+ assert_eq!(r.values()[1], 6.0);
+ assert_eq!(r.head(), 2);
+ assert_eq!(r.count(), 6);
+ }
+
+ #[test]
+ fn push_all_oversized_se_queda_con_la_cola() {
+ let mut r = RingBuffer::new(4);
+ r.push_all(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]);
+ // Sólo los últimos 4 importan: [6,7,8,9]
+ assert_eq!(r.values()[0], 6.0);
+ assert_eq!(r.values()[1], 7.0);
+ assert_eq!(r.values()[2], 8.0);
+ assert_eq!(r.values()[3], 9.0);
+ assert!(r.is_full());
+ }
+}
diff --git a/crates/modules/ui_engine/libs/lapaloma-core/src/scale.rs b/crates/modules/ui_engine/libs/lapaloma-core/src/scale.rs
new file mode 100644
index 0000000..da18d4f
--- /dev/null
+++ b/crates/modules/ui_engine/libs/lapaloma-core/src/scale.rs
@@ -0,0 +1,153 @@
+//! Escalas value→pixel para series cartesianas.
+//!
+//! La proyección no se aplica sobre los datos (eso rompería el
+//! P2 zero-alloc — habría que reescribir todo el buffer por frame).
+//! Las escalas devuelven el `(scale_x, scale_y, translate_x,
+//! translate_y)` que el painter mete en un transform GPU. Los
+//! datos quedan intactos.
+
+/// Trait común a Linear / Log / Time. Cada implementación traduce
+/// un valor de dominio a posición normalizada `[0, 1]` (que luego
+/// el painter mapea al pixel range del plot).
+pub trait Scale {
+ fn to_norm(&self, value: f64) -> f64;
+ fn from_norm(&self, norm: f64) -> f64;
+ fn domain(&self) -> (f64, f64);
+}
+
+#[derive(Debug, Clone, Copy)]
+pub struct LinearScale {
+ min: f64,
+ max: f64,
+}
+
+impl LinearScale {
+ pub fn new(min: f64, max: f64) -> Self {
+ debug_assert!(max > min, "LinearScale: max debe ser > min");
+ Self { min, max }
+ }
+}
+
+impl Scale for LinearScale {
+ fn to_norm(&self, v: f64) -> f64 {
+ (v - self.min) / (self.max - self.min)
+ }
+ fn from_norm(&self, n: f64) -> f64 {
+ self.min + n * (self.max - self.min)
+ }
+ fn domain(&self) -> (f64, f64) {
+ (self.min, self.max)
+ }
+}
+
+/// Escala logarítmica base e. `min` y `max` deben ser positivos.
+#[derive(Debug, Clone, Copy)]
+pub struct LogScale {
+ log_min: f64,
+ log_max: f64,
+ min: f64,
+ max: f64,
+}
+
+impl LogScale {
+ pub fn new(min: f64, max: f64) -> Self {
+ debug_assert!(min > 0.0 && max > min, "LogScale: 0 < min < max");
+ Self {
+ log_min: min.ln(),
+ log_max: max.ln(),
+ min,
+ max,
+ }
+ }
+}
+
+impl Scale for LogScale {
+ fn to_norm(&self, v: f64) -> f64 {
+ (v.ln() - self.log_min) / (self.log_max - self.log_min)
+ }
+ fn from_norm(&self, n: f64) -> f64 {
+ (self.log_min + n * (self.log_max - self.log_min)).exp()
+ }
+ fn domain(&self) -> (f64, f64) {
+ (self.min, self.max)
+ }
+}
+
+/// Escala temporal sobre epoch ms. Internamente lineal.
+#[derive(Debug, Clone, Copy)]
+pub struct TimeScale {
+ inner: LinearScale,
+}
+
+impl TimeScale {
+ pub fn new(min_epoch_ms: f64, max_epoch_ms: f64) -> Self {
+ Self {
+ inner: LinearScale::new(min_epoch_ms, max_epoch_ms),
+ }
+ }
+}
+
+impl Scale for TimeScale {
+ fn to_norm(&self, v: f64) -> f64 {
+ self.inner.to_norm(v)
+ }
+ fn from_norm(&self, n: f64) -> f64 {
+ self.inner.from_norm(n)
+ }
+ fn domain(&self) -> (f64, f64) {
+ self.inner.domain()
+ }
+}
+
+/// Wilkinson "nice numbers" — devuelve el step ideal en `{1, 2, 5} × 10^k`
+/// para que un rango `[min, max]` tenga ~`target_ticks` divisiones.
+pub fn nice_step(min: f64, max: f64, target_ticks: usize) -> f64 {
+ debug_assert!(max > min && target_ticks > 0);
+ let raw = (max - min) / target_ticks as f64;
+ let mag = 10f64.powf(raw.log10().floor());
+ let norm = raw / mag;
+ let nice = if norm < 1.5 {
+ 1.0
+ } else if norm < 3.0 {
+ 2.0
+ } else if norm < 7.0 {
+ 5.0
+ } else {
+ 10.0
+ };
+ nice * mag
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn linear_roundtrip() {
+ let s = LinearScale::new(10.0, 20.0);
+ assert!((s.to_norm(15.0) - 0.5).abs() < 1e-9);
+ assert!((s.from_norm(0.5) - 15.0).abs() < 1e-9);
+ }
+
+ #[test]
+ fn log_roundtrip() {
+ let s = LogScale::new(1.0, 1000.0);
+ // 10 está a 1/3 del camino en log10. ln(10)/ln(1000) = 1/3.
+ assert!((s.to_norm(10.0) - 1.0 / 3.0).abs() < 1e-9);
+ assert!((s.from_norm(2.0 / 3.0) - 100.0).abs() < 1e-9);
+ }
+
+ #[test]
+ fn nice_step_es_potencia() {
+ // 100/5 = 20 — exact match para el branch nice=2.0 · mag=10.
+ assert!((nice_step(0.0, 100.0, 5) - 20.0).abs() < 1e-9);
+ // 1.0/10 = 0.1 — branch nice=1.0 · mag=0.1.
+ assert!((nice_step(0.0, 1.0, 10) - 0.1).abs() < 1e-9);
+ // 14/5 = 2.8 — branch nice=2.0 (1.5 ≤ norm < 3) · mag=1.
+ assert!((nice_step(0.0, 14.0, 5) - 2.0).abs() < 1e-9);
+ // 7/5 = 1.4 — cae bajo 1.5 → snap a 1.0 · mag=1 = 1.0.
+ assert!((nice_step(0.0, 7.0, 5) - 1.0).abs() < 1e-9);
+ // 50/5 = 10 — branch nice=10 · mag=1 = 10. (Equivalente a 1·10.)
+ assert!((nice_step(0.0, 50.0, 5) - 10.0).abs() < 1e-9);
+ }
+}
diff --git a/crates/modules/ui_engine/libs/lapaloma-core/src/spatial.rs b/crates/modules/ui_engine/libs/lapaloma-core/src/spatial.rs
new file mode 100644
index 0000000..4b1daa3
--- /dev/null
+++ b/crates/modules/ui_engine/libs/lapaloma-core/src/spatial.rs
@@ -0,0 +1,149 @@
+//! `SpatialIndex` — hit-testing sobre coords interleaved sorted-by-X.
+//!
+//! Cuando los puntos vienen ordenados por X (caso típico de series
+//! temporales) un binary search basta y es O(log n) sin estructuras
+//! auxiliares. Para nodos que se mueven cada frame (mesh graph)
+//! corresponde un spatial hash uniforme — ese va en `lapaloma-mesh`,
+//! no acá.
+
+/// View sobre un buffer interleaved `[x0,y0,x1,y1,…]` sorted-asc por X.
+///
+/// El binary search asume invariante de ordenamiento. Si tu pipeline
+/// puede generar coords desordenadas, sortealas antes de construir
+/// el índice (no hay debug-assert porque sería O(n) en hot path).
+#[derive(Debug, Clone, Copy)]
+pub struct SpatialIndex<'a> {
+ coords: &'a [f32],
+}
+
+impl<'a> SpatialIndex<'a> {
+ pub fn new(coords: &'a [f32]) -> Self {
+ debug_assert!(coords.len() % 2 == 0);
+ Self { coords }
+ }
+
+ pub fn len(&self) -> usize {
+ self.coords.len() / 2
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.coords.is_empty()
+ }
+
+ /// Índice del punto cuya X está más cerca de `target_x`.
+ /// `None` si el buffer está vacío.
+ pub fn nearest(&self, target_x: f32) -> Option<usize> {
+ let n = self.len();
+ if n == 0 {
+ return None;
+ }
+ // Binary search sobre la columna X.
+ let mut lo = 0usize;
+ let mut hi = n;
+ while lo < hi {
+ let mid = (lo + hi) / 2;
+ if self.coords[mid * 2] < target_x {
+ lo = mid + 1;
+ } else {
+ hi = mid;
+ }
+ }
+ // `lo` es la primera X >= target_x. El más cercano es lo o lo-1.
+ if lo == 0 {
+ Some(0)
+ } else if lo >= n {
+ Some(n - 1)
+ } else {
+ let prev = lo - 1;
+ let dx_prev = target_x - self.coords[prev * 2];
+ let dx_next = self.coords[lo * 2] - target_x;
+ if dx_prev <= dx_next {
+ Some(prev)
+ } else {
+ Some(lo)
+ }
+ }
+ }
+
+ /// Rango `[start, end)` de puntos con X en `[x_min, x_max]`.
+ /// Útil para clip-to-viewport antes de LTTB.
+ pub fn range(&self, x_min: f32, x_max: f32) -> (usize, usize) {
+ let n = self.len();
+ if n == 0 {
+ return (0, 0);
+ }
+ // lower bound: primer i con coords[i*2] >= x_min
+ let start = {
+ let mut lo = 0usize;
+ let mut hi = n;
+ while lo < hi {
+ let mid = (lo + hi) / 2;
+ if self.coords[mid * 2] < x_min {
+ lo = mid + 1;
+ } else {
+ hi = mid;
+ }
+ }
+ lo
+ };
+ // upper bound: primer i con coords[i*2] > x_max
+ let end = {
+ let mut lo = start;
+ let mut hi = n;
+ while lo < hi {
+ let mid = (lo + hi) / 2;
+ if self.coords[mid * 2] <= x_max {
+ lo = mid + 1;
+ } else {
+ hi = mid;
+ }
+ }
+ lo
+ };
+ (start, end)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn fixture() -> Vec<f32> {
+ // x: 0, 1, 3, 5, 8 — y irrelevante.
+ vec![0.0, 0.0, 1.0, 0.0, 3.0, 0.0, 5.0, 0.0, 8.0, 0.0]
+ }
+
+ #[test]
+ fn nearest_dentro() {
+ let c = fixture();
+ let s = SpatialIndex::new(&c);
+ assert_eq!(s.nearest(0.0), Some(0));
+ assert_eq!(s.nearest(2.0), Some(1)); // 1 está más cerca que 3
+ assert_eq!(s.nearest(2.5), Some(2)); // 3 está más cerca que 1
+ assert_eq!(s.nearest(8.0), Some(4));
+ }
+
+ #[test]
+ fn nearest_fuera_clamp() {
+ let c = fixture();
+ let s = SpatialIndex::new(&c);
+ assert_eq!(s.nearest(-10.0), Some(0));
+ assert_eq!(s.nearest(99.0), Some(4));
+ }
+
+ #[test]
+ fn nearest_empty() {
+ let empty: [f32; 0] = [];
+ assert_eq!(SpatialIndex::new(&empty).nearest(0.0), None);
+ }
+
+ #[test]
+ fn range_clip() {
+ let c = fixture();
+ let s = SpatialIndex::new(&c);
+ assert_eq!(s.range(1.0, 5.0), (1, 4)); // incluye x=1,3,5
+ assert_eq!(s.range(2.0, 4.0), (2, 3)); // sólo x=3
+ assert_eq!(s.range(-1.0, 100.0), (0, 5));
+ assert_eq!(s.range(10.0, 20.0), (5, 5));
+ }
+}
diff --git a/crates/modules/ui_engine/widgets/lapaloma-cartesian/Cargo.toml b/crates/modules/ui_engine/widgets/lapaloma-cartesian/Cargo.toml
new file mode 100644
index 0000000..09892e9
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-cartesian/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "lapaloma-cartesian"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+authors = { workspace = true }
+publish = { workspace = true }
+description = "Lapaloma — gráficos cartesianos: LineSeries / BarSeries / AreaSeries, viewport con pan/zoom, picture cache, ejes con decimación, tooltips."
+
+[dependencies]
+lapaloma-core = { path = "../../libs/lapaloma-core" }
+lapaloma-render = { path = "../lapaloma-render" }
+gpui = { workspace = true }
diff --git a/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/lib.rs b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/lib.rs
new file mode 100644
index 0000000..0f2baa2
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-cartesian/src/lib.rs
@@ -0,0 +1,31 @@
+//! `lapaloma-cartesian` — gráficos cartesianos.
+//!
+//! Este crate trae:
+//!
+//! - **`viewport`** — `ChartViewport` con `(x_min, x_max, y_min, y_max)`
+//! y helpers de pan/zoom anchor-preserving.
+//! - **`coord_system`** — proyecta valores de dominio → pixeles del
+//! plot usando las escalas de `lapaloma-core::scale`.
+//! - **`series`** — trait `Series` + impls `LineSeries`, `BarSeries`,
+//! `AreaSeries`. Cada serie decide LTTB vs raw según densidad.
+//! - **`axis`** — ejes con nice-ticks (Wilkinson) y decimación de
+//! etiquetas que no overlappean.
+//! - **`picture_cache`** — translate-only pan-blit con hash de
+//! invalidación. Clipea el outer canvas antes del translate
+//! (bug 0.3.0 del Flutter).
+//! - **`element`** — el `Element` GPUI que envuelve todo lo de
+//! arriba y se inserta en un layout yahweh.
+//!
+//! Hoy todos los módulos están como placeholders; la primera
+//! impl real va a ser `LineSeries` + `element` end-to-end para
+//! validar la cadena `core → render → cartesian → gpui`.
+
+#![forbid(unsafe_code)]
+#![allow(dead_code)]
+
+pub mod viewport {}
+pub mod coord_system {}
+pub mod series {}
+pub mod axis {}
+pub mod picture_cache {}
+pub mod element {}
diff --git a/crates/modules/ui_engine/widgets/lapaloma-export/Cargo.toml b/crates/modules/ui_engine/widgets/lapaloma-export/Cargo.toml
new file mode 100644
index 0000000..ffd4571
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-export/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "lapaloma-export"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+authors = { workspace = true }
+publish = { workspace = true }
+description = "Lapaloma — exporters. SVG primero, PDF después. Decimación contextual por DPI: target = width_inches × dpi × vertices_per_pixel."
+
+[dependencies]
+lapaloma-core = { path = "../../libs/lapaloma-core" }
+lapaloma-render = { path = "../lapaloma-render" }
diff --git a/crates/modules/ui_engine/widgets/lapaloma-export/src/lib.rs b/crates/modules/ui_engine/widgets/lapaloma-export/src/lib.rs
new file mode 100644
index 0000000..b55dd0d
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-export/src/lib.rs
@@ -0,0 +1,23 @@
+//! `lapaloma-export` — exporters.
+//!
+//! Estrategia: implementar `lapaloma_render::Canvas` con un
+//! adapter que emite elementos SVG (o instrucciones PDF). El mismo
+//! painter que dibuja en pantalla escribe en el exporter — un sólo
+//! camino de código.
+//!
+//! Decimación contextual:
+//! ```text
+//! target = width_inches × dpi × vertices_per_pixel
+//! ```
+//! Print (300 dpi) saca ~3× más vértices que screen (96 dpi) del
+//! mismo source data (sección 3.10).
+//!
+//! - **`svg`** — exporter SVG.
+//! - **`pdf`** — placeholder; cuando se implemente, vía `printpdf`
+//! sobre el mismo `RenderPlan` que el SVG.
+
+#![forbid(unsafe_code)]
+#![allow(dead_code)]
+
+pub mod svg {}
+pub mod pdf {}
diff --git a/crates/modules/ui_engine/widgets/lapaloma-financial/Cargo.toml b/crates/modules/ui_engine/widgets/lapaloma-financial/Cargo.toml
new file mode 100644
index 0000000..72029e0
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-financial/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "lapaloma-financial"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+authors = { workspace = true }
+publish = { workspace = true }
+description = "Lapaloma — gráficos financieros. OHLC / candlesticks con agregación que preserva volatilidad (no LTTB, time-bucketing con max/min de wicks)."
+
+[dependencies]
+lapaloma-core = { path = "../../libs/lapaloma-core" }
+lapaloma-render = { path = "../lapaloma-render" }
+lapaloma-cartesian = { path = "../lapaloma-cartesian" }
+gpui = { workspace = true }
diff --git a/crates/modules/ui_engine/widgets/lapaloma-financial/src/lib.rs b/crates/modules/ui_engine/widgets/lapaloma-financial/src/lib.rs
new file mode 100644
index 0000000..2c1d7d1
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-financial/src/lib.rs
@@ -0,0 +1,16 @@
+//! `lapaloma-financial` — OHLC y candlesticks.
+//!
+//! Buffer: 6 floats por bar `[t, o, h, l, c, v]`. Agregación
+//! preserva volatilidad (max(h)/min(l), no LTTB — ver sección 3.2):
+//! time-bucketing con fallback a index-bucketing cuando todos los
+//! timestamps colapsan.
+//!
+//! Re-usa `lapaloma-cartesian` para viewport, ejes y gestures;
+//! sólo aporta el `CandlestickSeries` y la lógica de aggregación.
+
+#![forbid(unsafe_code)]
+#![allow(dead_code)]
+
+pub mod ohlc_buffer {}
+pub mod aggregate {}
+pub mod candlestick {}
diff --git a/crates/modules/ui_engine/widgets/lapaloma-flow/Cargo.toml b/crates/modules/ui_engine/widgets/lapaloma-flow/Cargo.toml
new file mode 100644
index 0000000..4dd8da4
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-flow/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "lapaloma-flow"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+authors = { workspace = true }
+publish = { workspace = true }
+description = "Lapaloma — diagramas de flujo Sankey: columnas topológicas + barycenter ordering + ribbons como triangle strips de béziers."
+
+[dependencies]
+lapaloma-core = { path = "../../libs/lapaloma-core" }
+lapaloma-render = { path = "../lapaloma-render" }
+gpui = { workspace = true }
diff --git a/crates/modules/ui_engine/widgets/lapaloma-flow/src/lib.rs b/crates/modules/ui_engine/widgets/lapaloma-flow/src/lib.rs
new file mode 100644
index 0000000..9e5196e
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-flow/src/lib.rs
@@ -0,0 +1,16 @@
+//! `lapaloma-flow` — diagramas Sankey.
+//!
+//! Pipeline (sección 3.7 del ARCHITECTURE.md):
+//! 1. Columnas via longest-path en el DAG (back-edges drop).
+//! 2. Flow por nodo = max(in_value, out_value).
+//! 3. Barycenter ordering con inversion-count crossings.
+//! 4. Stripes por edge dentro de cada lado del nodo.
+//! 5. Ribbons como triangle-strip de béziers, un draw call por
+//! ribbon, color por vértice.
+
+#![forbid(unsafe_code)]
+#![allow(dead_code)]
+
+pub mod layout {}
+pub mod ribbon {}
+pub mod element {}
diff --git a/crates/modules/ui_engine/widgets/lapaloma-heatmap/Cargo.toml b/crates/modules/ui_engine/widgets/lapaloma-heatmap/Cargo.toml
new file mode 100644
index 0000000..29bc7c9
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-heatmap/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "lapaloma-heatmap"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+authors = { workspace = true }
+publish = { workspace = true }
+description = "Lapaloma — heatmap. Matriz [width × height] de f32 → imagen pre-encodeada que se rendea con un sólo drawImageRect."
+
+[dependencies]
+lapaloma-core = { path = "../../libs/lapaloma-core" }
+lapaloma-render = { path = "../lapaloma-render" }
+gpui = { workspace = true }
diff --git a/crates/modules/ui_engine/widgets/lapaloma-heatmap/src/lib.rs b/crates/modules/ui_engine/widgets/lapaloma-heatmap/src/lib.rs
new file mode 100644
index 0000000..07d7611
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-heatmap/src/lib.rs
@@ -0,0 +1,21 @@
+//! `lapaloma-heatmap` — matriz `[width × height]` de `f32` → imagen.
+//!
+//! Para matrices grandes (4096² = 67 MB de pixels), encodear la
+//! imagen una vez al cambiar la data y renderear con un solo
+//! `drawImageRect` (o equivalente GPUI). Eso convierte el coste
+//! de cada frame en "blit de una textura", sub-millisecond.
+//!
+//! - **`matrix`** — `HeatmapMatrix { data: Vec<f32>, width, height,
+//! revision }`.
+//! - **`palette`** — color ramps (viridis, plasma, gray…).
+//! - **`encoder`** — convierte la matrix a un buffer ARGB para
+//! subir como textura.
+//! - **`element`** — `Element` GPUI.
+
+#![forbid(unsafe_code)]
+#![allow(dead_code)]
+
+pub mod matrix {}
+pub mod palette {}
+pub mod encoder {}
+pub mod element {}
diff --git a/crates/modules/ui_engine/widgets/lapaloma-mesh/Cargo.toml b/crates/modules/ui_engine/widgets/lapaloma-mesh/Cargo.toml
new file mode 100644
index 0000000..80e1644
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-mesh/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "lapaloma-mesh"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+authors = { workspace = true }
+publish = { workspace = true }
+description = "Lapaloma — grafos. NodeBuffer / EdgeBuffer + layouts (force-directed con Barnes-Hut, Sugiyama-lite jerárquico, subtree-width)."
+
+[dependencies]
+lapaloma-core = { path = "../../libs/lapaloma-core" }
+lapaloma-render = { path = "../lapaloma-render" }
+gpui = { workspace = true }
diff --git a/crates/modules/ui_engine/widgets/lapaloma-mesh/src/lib.rs b/crates/modules/ui_engine/widgets/lapaloma-mesh/src/lib.rs
new file mode 100644
index 0000000..461d993
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-mesh/src/lib.rs
@@ -0,0 +1,28 @@
+//! `lapaloma-mesh` — visualización de grafos.
+//!
+//! Módulos:
+//! - **`node_buffer`** / **`edge_buffer`** — `Vec<f32>` 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
+//! `lapaloma_core::barnes_hut` (cuando se implemente).
+//! - **`hierarchical`** — Sugiyama-lite, delegado a
+//! `lapaloma_core::sugiyama`.
+//! - **`tree`** — subtree-width layout, delegado a
+//! `lapaloma_core::tree_layout`.
+//! - **`camera`** — pan/zoom con anchor-preserving zoom de la
+//! sección 5.3.
+//! - **`element`** — `Element` GPUI.
+
+#![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 {}
diff --git a/crates/modules/ui_engine/widgets/lapaloma-phosphor/Cargo.toml b/crates/modules/ui_engine/widgets/lapaloma-phosphor/Cargo.toml
new file mode 100644
index 0000000..f29e906
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-phosphor/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "lapaloma-phosphor"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+authors = { workspace = true }
+publish = { workspace = true }
+description = "Lapaloma — decoración CRT sobre lapaloma-stream: trail con alpha decay por edad, ghost, anotaciones magnéticas ancladas a sample index."
+
+[dependencies]
+lapaloma-core = { path = "../../libs/lapaloma-core" }
+lapaloma-render = { path = "../lapaloma-render" }
+lapaloma-stream = { path = "../lapaloma-stream" }
+gpui = { workspace = true }
diff --git a/crates/modules/ui_engine/widgets/lapaloma-phosphor/src/lib.rs b/crates/modules/ui_engine/widgets/lapaloma-phosphor/src/lib.rs
new file mode 100644
index 0000000..1865107
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-phosphor/src/lib.rs
@@ -0,0 +1,15 @@
+//! `lapaloma-phosphor` — decoración CRT sobre `lapaloma-stream`.
+//!
+//! Three pieces (sección 4.3):
+//! - **`trail`** — cada sample como 2 vértices ±half_width, triangle
+//! strip con color por vértice; alpha = 1 - age/trail_samples.
+//! - **`ghost`** — render con offset/blur del trail anterior.
+//! - **`magnetic_anchor`** — anotaciones ancladas a sample index
+//! absoluto, no a screen pos (sección 5.5).
+
+#![forbid(unsafe_code)]
+#![allow(dead_code)]
+
+pub mod trail {}
+pub mod ghost {}
+pub mod magnetic_anchor {}
diff --git a/crates/modules/ui_engine/widgets/lapaloma-polar/Cargo.toml b/crates/modules/ui_engine/widgets/lapaloma-polar/Cargo.toml
new file mode 100644
index 0000000..2205af4
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-polar/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "lapaloma-polar"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+authors = { workspace = true }
+publish = { workspace = true }
+description = "Lapaloma — gráficos polares: pie chart, donut, radar."
+
+[dependencies]
+lapaloma-core = { path = "../../libs/lapaloma-core" }
+lapaloma-render = { path = "../lapaloma-render" }
+gpui = { workspace = true }
diff --git a/crates/modules/ui_engine/widgets/lapaloma-polar/src/lib.rs b/crates/modules/ui_engine/widgets/lapaloma-polar/src/lib.rs
new file mode 100644
index 0000000..7afc06e
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-polar/src/lib.rs
@@ -0,0 +1,16 @@
+//! `lapaloma-polar` — gráficos en coordenadas polares.
+//!
+//! - **`pie`** — pie / donut chart.
+//! - **`radar`** — radar (spider) chart.
+//! - **`element`** — `Element` GPUI.
+//!
+//! No comparte mucho con cartesian; viewport y gestures van
+//! ad-hoc. El picture-cache de cartesian no aplica acá (las
+//! rotaciones lo invalidan).
+
+#![forbid(unsafe_code)]
+#![allow(dead_code)]
+
+pub mod pie {}
+pub mod radar {}
+pub mod element {}
diff --git a/crates/modules/ui_engine/widgets/lapaloma-render/Cargo.toml b/crates/modules/ui_engine/widgets/lapaloma-render/Cargo.toml
new file mode 100644
index 0000000..4eee5e0
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-render/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "lapaloma-render"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+authors = { workspace = true }
+publish = { workspace = true }
+description = "Lapaloma — abstracción de painter: trait Canvas + RenderPlan + color helpers. Habilita backend CPU (gpui hoy) y GPU (wgpu mañana) sin tocar a los painters."
+
+[dependencies]
+lapaloma-core = { path = "../../libs/lapaloma-core" }
diff --git a/crates/modules/ui_engine/widgets/lapaloma-render/src/canvas.rs b/crates/modules/ui_engine/widgets/lapaloma-render/src/canvas.rs
new file mode 100644
index 0000000..e5a494f
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-render/src/canvas.rs
@@ -0,0 +1,53 @@
+//! El trait `Canvas` que todos los painters consumen.
+//!
+//! Mantenemos el set mínimo: line / polyline / rect (fill+stroke) /
+//! triangle strip. Cualquier visualización compleja (curvas
+//! bezier, gradients) se descompone en estos primitivos por el
+//! painter — el backend no necesita entender la semántica.
+//!
+//! Convención: coordenadas en píxeles del viewport, origen
+//! arriba-izquierda, +Y hacia abajo. La proyección de datos→pixel
+//! la hace el painter via las escalas de `lapaloma-core`.
+
+use crate::{Color, Point, Rect};
+
+#[derive(Debug, Clone, Copy)]
+pub struct StrokeStyle {
+ pub width: f32,
+ pub color: Color,
+}
+
+impl StrokeStyle {
+ pub const fn new(width: f32, color: Color) -> Self {
+ Self { width, color }
+ }
+}
+
+pub trait Canvas {
+ /// Clip subsiguiente al rect dado. Stack-discipline:
+ /// `push_clip` + draw + `pop_clip`.
+ fn push_clip(&mut self, rect: Rect);
+ fn pop_clip(&mut self);
+
+ /// Rectángulo relleno (sin stroke).
+ fn fill_rect(&mut self, rect: Rect, color: Color);
+
+ /// Rectángulo sólo stroke (sin fill).
+ fn stroke_rect(&mut self, rect: Rect, stroke: StrokeStyle);
+
+ /// Línea de a→b.
+ fn stroke_line(&mut self, a: Point, b: Point, stroke: StrokeStyle);
+
+ /// Polilínea sobre coords interleaved `[x0,y0,x1,y1,…]`.
+ /// El backend la rendea como un solo draw call cuando puede.
+ fn stroke_polyline(&mut self, coords: &[f32], stroke: StrokeStyle);
+
+ /// Triangle strip rellenado, con un color por vértice
+ /// (longitudes deben coincidir: `coords.len()/2 == colors.len()`).
+ /// Es lo que usa el phosphor trail y los ribbons Sankey.
+ fn fill_triangle_strip(&mut self, coords: &[f32], colors: &[Color]);
+
+ /// Glyph de texto sencillo. El layout va a un text-cache
+ /// dentro del backend; por ahora un trazo simple.
+ fn draw_text(&mut self, p: Point, text: &str, color: Color, size_px: f32);
+}
diff --git a/crates/modules/ui_engine/widgets/lapaloma-render/src/color.rs b/crates/modules/ui_engine/widgets/lapaloma-render/src/color.rs
new file mode 100644
index 0000000..255daab
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-render/src/color.rs
@@ -0,0 +1,35 @@
+//! Color RGBA en f32, agnóstico de backend.
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Color {
+ pub r: f32,
+ pub g: f32,
+ pub b: f32,
+ pub a: f32,
+}
+
+impl Color {
+ pub const TRANSPARENT: Self = Self::rgba(0.0, 0.0, 0.0, 0.0);
+ pub const BLACK: Self = Self::rgb(0.0, 0.0, 0.0);
+ pub const WHITE: Self = Self::rgb(1.0, 1.0, 1.0);
+
+ pub const fn rgb(r: f32, g: f32, b: f32) -> Self {
+ Self { r, g, b, a: 1.0 }
+ }
+ pub const fn rgba(r: f32, g: f32, b: f32, a: f32) -> Self {
+ Self { r, g, b, a }
+ }
+
+ /// Construye desde 0xRRGGBB hex literal.
+ pub fn from_hex(rgb: u32) -> Self {
+ let r = ((rgb >> 16) & 0xff) as f32 / 255.0;
+ let g = ((rgb >> 8) & 0xff) as f32 / 255.0;
+ let b = (rgb & 0xff) as f32 / 255.0;
+ Self::rgb(r, g, b)
+ }
+
+ /// Multiplica el canal alpha — útil para fade del phosphor trail.
+ pub fn with_alpha(self, a: f32) -> Self {
+ Self { a, ..self }
+ }
+}
diff --git a/crates/modules/ui_engine/widgets/lapaloma-render/src/geom.rs b/crates/modules/ui_engine/widgets/lapaloma-render/src/geom.rs
new file mode 100644
index 0000000..8af2d2b
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-render/src/geom.rs
@@ -0,0 +1,36 @@
+//! Tipos geométricos mínimos en `f32`.
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Point {
+ pub x: f32,
+ pub y: f32,
+}
+
+impl Point {
+ pub const fn new(x: f32, y: f32) -> Self {
+ Self { x, y }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Rect {
+ pub x: f32,
+ pub y: f32,
+ pub w: f32,
+ pub h: f32,
+}
+
+impl Rect {
+ pub const fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
+ Self { x, y, w, h }
+ }
+ pub fn right(&self) -> f32 {
+ self.x + self.w
+ }
+ pub fn bottom(&self) -> f32 {
+ self.y + self.h
+ }
+ pub fn contains(&self, p: Point) -> bool {
+ p.x >= self.x && p.x <= self.right() && p.y >= self.y && p.y <= self.bottom()
+ }
+}
diff --git a/crates/modules/ui_engine/widgets/lapaloma-render/src/lib.rs b/crates/modules/ui_engine/widgets/lapaloma-render/src/lib.rs
new file mode 100644
index 0000000..44f05ae
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-render/src/lib.rs
@@ -0,0 +1,30 @@
+//! `lapaloma-render` — abstracción de painter.
+//!
+//! Los crates de visualización (cartesian, mesh, polar…) no
+//! conocen `gpui` ni `wgpu`. Hablan contra el trait [`Canvas`]
+//! definido acá. Eso permite:
+//!
+//! - **Backend CPU sobre gpui** — implementación por defecto;
+//! sirve para series de hasta ~50 k vértices a 60 FPS sin
+//! sudar.
+//! - **Backend GPU sobre wgpu** — placeholder hoy; cuando un
+//! módulo le pegue al wall (millones de puntos, force-sim
+//! pesada), se enchufa sin tocar la lógica de los painters.
+//! - **Backend SVG** — `lapaloma-export` implementa el mismo
+//! trait emitiendo elementos `<path>`, `<polyline>`, etc.
+//!
+//! Tipos primitivos (`Color`, `Point`, `Rect`) viven acá para
+//! no atarlos a `gpui::Rgba`/`gpui::Point` — los backends
+//! traducen al tipo nativo del runtime que les toca.
+
+#![forbid(unsafe_code)]
+
+pub mod color;
+pub mod geom;
+pub mod canvas;
+pub mod plan;
+
+pub use color::Color;
+pub use geom::{Point, Rect};
+pub use canvas::{Canvas, StrokeStyle};
+pub use plan::{RenderCmd, RenderPlan};
diff --git a/crates/modules/ui_engine/widgets/lapaloma-render/src/plan.rs b/crates/modules/ui_engine/widgets/lapaloma-render/src/plan.rs
new file mode 100644
index 0000000..825fa9e
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-render/src/plan.rs
@@ -0,0 +1,35 @@
+//! `RenderPlan` — comandos materializados para backends que no
+//! reciben llamadas en vivo (SVG export, snapshot testing).
+//!
+//! Un painter que escribe contra [`crate::Canvas`] puede ser
+//! capturado en un `RenderPlan` usando un `Canvas` adapter que
+//! empuja `RenderCmd`s en lugar de dibujar. El exporter consume
+//! el plan y emite `<polyline>` / `<rect>` / etc.
+
+use crate::{Color, Point, Rect, StrokeStyle};
+
+#[derive(Debug, Clone)]
+pub enum RenderCmd {
+ PushClip(Rect),
+ PopClip,
+ FillRect { rect: Rect, color: Color },
+ StrokeRect { rect: Rect, stroke: StrokeStyle },
+ StrokeLine { a: Point, b: Point, stroke: StrokeStyle },
+ StrokePolyline { coords: Vec<f32>, stroke: StrokeStyle },
+ FillTriangleStrip { coords: Vec<f32>, colors: Vec<Color> },
+ DrawText { p: Point, text: String, color: Color, size_px: f32 },
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct RenderPlan {
+ pub cmds: Vec<RenderCmd>,
+}
+
+impl RenderPlan {
+ pub fn new() -> Self {
+ Self::default()
+ }
+ pub fn push(&mut self, cmd: RenderCmd) {
+ self.cmds.push(cmd);
+ }
+}
diff --git a/crates/modules/ui_engine/widgets/lapaloma-stream/Cargo.toml b/crates/modules/ui_engine/widgets/lapaloma-stream/Cargo.toml
new file mode 100644
index 0000000..706e66d
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-stream/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "lapaloma-stream"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+authors = { workspace = true }
+publish = { workspace = true }
+description = "Lapaloma — widget de telemetría tipo osciloscopio. Ring buffer + envelope min/max por columna + render en dos segmentos (split at head)."
+
+[dependencies]
+lapaloma-core = { path = "../../libs/lapaloma-core" }
+lapaloma-render = { path = "../lapaloma-render" }
+gpui = { workspace = true }
diff --git a/crates/modules/ui_engine/widgets/lapaloma-stream/src/lib.rs b/crates/modules/ui_engine/widgets/lapaloma-stream/src/lib.rs
new file mode 100644
index 0000000..d6c3dca
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-stream/src/lib.rs
@@ -0,0 +1,19 @@
+//! `lapaloma-stream` — telemetría streaming tipo osciloscopio.
+//!
+//! Núcleo: `lapaloma_core::ring::RingBuffer` + render en dos
+//! segmentos split-at-head (sweep) o con translate por frame
+//! (scroll).
+//!
+//! Módulos:
+//! - **`envelope`** — downsample min/max por columna de pixel.
+//! Incremental para sweep, single bounded pass para scroll
+//! (ver sección 3.3 del ARCHITECTURE.md).
+//! - **`element`** — `Element` GPUI con `Model<RingBuffer>`
+//! observable. El push viene de otro thread; el Element se
+//! redibuja sólo cuando `revision` cambió.
+
+#![forbid(unsafe_code)]
+#![allow(dead_code)]
+
+pub mod envelope {}
+pub mod element {}
diff --git a/crates/modules/ui_engine/widgets/lapaloma-treemap/Cargo.toml b/crates/modules/ui_engine/widgets/lapaloma-treemap/Cargo.toml
new file mode 100644
index 0000000..3f2eed8
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-treemap/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "lapaloma-treemap"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+authors = { workspace = true }
+publish = { workspace = true }
+description = "Lapaloma — treemap con algoritmo squarified (Bruls / d3-hierarchy formulation)."
+
+[dependencies]
+lapaloma-core = { path = "../../libs/lapaloma-core" }
+lapaloma-render = { path = "../lapaloma-render" }
+gpui = { workspace = true }
diff --git a/crates/modules/ui_engine/widgets/lapaloma-treemap/src/lib.rs b/crates/modules/ui_engine/widgets/lapaloma-treemap/src/lib.rs
new file mode 100644
index 0000000..fe460b1
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma-treemap/src/lib.rs
@@ -0,0 +1,12 @@
+//! `lapaloma-treemap` — treemap squarified.
+//!
+//! Algoritmo en `lapaloma_core::squarify` (placeholder); el `Element`
+//! sólo se encarga de iterar las tiles resultantes y dibujarlas.
+//! Pre-scaling de valores al area total del rect es clave para
+//! estabilidad numérica con rangos amplios.
+
+#![forbid(unsafe_code)]
+#![allow(dead_code)]
+
+pub mod tile {}
+pub mod element {}
diff --git a/crates/modules/ui_engine/widgets/lapaloma/Cargo.toml b/crates/modules/ui_engine/widgets/lapaloma/Cargo.toml
new file mode 100644
index 0000000..2eefcfd
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma/Cargo.toml
@@ -0,0 +1,41 @@
+[package]
+name = "lapaloma"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+authors = { workspace = true }
+publish = { workspace = true }
+description = "Lapaloma — paraguas: re-exporta los módulos para prototipos. En producción importar los crates hoja directamente para que tree-shaking descarte lo no usado."
+
+[dependencies]
+lapaloma-core = { path = "../../libs/lapaloma-core", optional = true }
+lapaloma-render = { path = "../lapaloma-render", optional = true }
+lapaloma-cartesian = { path = "../lapaloma-cartesian", optional = true }
+lapaloma-stream = { path = "../lapaloma-stream", optional = true }
+lapaloma-mesh = { path = "../lapaloma-mesh", optional = true }
+lapaloma-financial = { path = "../lapaloma-financial", optional = true }
+lapaloma-polar = { path = "../lapaloma-polar", optional = true }
+lapaloma-heatmap = { path = "../lapaloma-heatmap", optional = true }
+lapaloma-treemap = { path = "../lapaloma-treemap", optional = true }
+lapaloma-flow = { path = "../lapaloma-flow", optional = true }
+lapaloma-phosphor = { path = "../lapaloma-phosphor", optional = true }
+lapaloma-export = { path = "../lapaloma-export", optional = true }
+
+[features]
+default = ["full"]
+full = [
+ "core", "render", "cartesian", "stream", "mesh", "financial",
+ "polar", "heatmap", "treemap", "flow", "phosphor", "export",
+]
+core = ["dep:lapaloma-core"]
+render = ["dep:lapaloma-render", "core"]
+cartesian = ["dep:lapaloma-cartesian", "render"]
+stream = ["dep:lapaloma-stream", "render"]
+mesh = ["dep:lapaloma-mesh", "render"]
+financial = ["dep:lapaloma-financial", "cartesian"]
+polar = ["dep:lapaloma-polar", "render"]
+heatmap = ["dep:lapaloma-heatmap", "render"]
+treemap = ["dep:lapaloma-treemap", "render"]
+flow = ["dep:lapaloma-flow", "render"]
+phosphor = ["dep:lapaloma-phosphor", "stream"]
+export = ["dep:lapaloma-export", "render"]
diff --git a/crates/modules/ui_engine/widgets/lapaloma/src/lib.rs b/crates/modules/ui_engine/widgets/lapaloma/src/lib.rs
new file mode 100644
index 0000000..5a0a5a9
--- /dev/null
+++ b/crates/modules/ui_engine/widgets/lapaloma/src/lib.rs
@@ -0,0 +1,53 @@
+//! `lapaloma` — paraguas re-export.
+//!
+//! Para **prototipos** que quieren probar varios módulos a la vez
+//! sin agregar 8 dependencias a `Cargo.toml`. En producción
+//! preferir importar directamente los crates hoja (`lapaloma-core`,
+//! `lapaloma-cartesian`, …) para que el linker descarte lo no
+//! usado y los tiempos de compilación bajen.
+//!
+//! Las features mapean 1:1 a cada sub-crate:
+//!
+//! ```toml
+//! [dependencies]
+//! lapaloma = { workspace = true, default-features = false,
+//! features = ["cartesian", "stream"] }
+//! ```
+
+#![forbid(unsafe_code)]
+
+#[cfg(feature = "core")]
+pub use lapaloma_core as core;
+
+#[cfg(feature = "render")]
+pub use lapaloma_render as render;
+
+#[cfg(feature = "cartesian")]
+pub use lapaloma_cartesian as cartesian;
+
+#[cfg(feature = "stream")]
+pub use lapaloma_stream as stream;
+
+#[cfg(feature = "mesh")]
+pub use lapaloma_mesh as mesh;
+
+#[cfg(feature = "financial")]
+pub use lapaloma_financial as financial;
+
+#[cfg(feature = "polar")]
+pub use lapaloma_polar as polar;
+
+#[cfg(feature = "heatmap")]
+pub use lapaloma_heatmap as heatmap;
+
+#[cfg(feature = "treemap")]
+pub use lapaloma_treemap as treemap;
+
+#[cfg(feature = "flow")]
+pub use lapaloma_flow as flow;
+
+#[cfg(feature = "phosphor")]
+pub use lapaloma_phosphor as phosphor;
+
+#[cfg(feature = "export")]
+pub use lapaloma_export as export;