From 370a593ad8cdc8173ef0e94d0fe7e119c50fdb92 Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 20 May 2026 14:14:37 +0000 Subject: [PATCH] =?UTF-8?q?feat(pineal):=20cierra=20stub=20polar=20?= =?UTF-8?q?=E2=80=94=20pie/donut=20+=20radar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fase F: tercer stub de pineal cerrado. - pie — paint_pie: pie y donut (inner_radius > 0). Porciones desde las 12 en punto, horario; valores negativos → 0. Cada cuña se tesela en un triangle strip [in,out,in,out,…] con segmentos de arco escalados al ángulo. - radar — paint_radar: M ejes equiespaciados, valores proyectados a distancia proporcional; relleno (fan) + contorno (polilínea cerrada). Painters 100% agnósticos (trait Canvas). 5 tests verdes. cargo check --workspace verde. Co-Authored-By: Claude Opus 4.7 --- crates/modules/pineal/polar/src/lib.rs | 19 ++-- crates/modules/pineal/polar/src/pie.rs | 124 +++++++++++++++++++++++ crates/modules/pineal/polar/src/radar.rs | 97 ++++++++++++++++++ 3 files changed, 230 insertions(+), 10 deletions(-) create mode 100644 crates/modules/pineal/polar/src/pie.rs create mode 100644 crates/modules/pineal/polar/src/radar.rs diff --git a/crates/modules/pineal/polar/src/lib.rs b/crates/modules/pineal/polar/src/lib.rs index 0a66dfb..a844502 100644 --- a/crates/modules/pineal/polar/src/lib.rs +++ b/crates/modules/pineal/polar/src/lib.rs @@ -1,16 +1,15 @@ //! `pineal-polar` — gráficos en coordenadas polares. //! -//! - **`pie`** — pie / donut chart. -//! - **`radar`** — radar (spider) chart. -//! - **`element`** — `Element` GPUI. +//! Painters agnósticos (hablan contra `Canvas`): el `Canvas` no tiene +//! primitiva de arco, así que cada forma se tesela en triangle strips. //! -//! No comparte mucho con cartesian; viewport y gestures van -//! ad-hoc. El picture-cache de cartesian no aplica acá (las -//! rotaciones lo invalidan). +//! - [`pie`] — pie / donut chart. +//! - [`radar`] — radar (spider) chart. #![forbid(unsafe_code)] -#![allow(dead_code)] -pub mod pie {} -pub mod radar {} -pub mod element {} +pub mod pie; +pub mod radar; + +pub use pie::{paint_pie, Slice}; +pub use radar::paint_radar; diff --git a/crates/modules/pineal/polar/src/pie.rs b/crates/modules/pineal/polar/src/pie.rs new file mode 100644 index 0000000..19626fe --- /dev/null +++ b/crates/modules/pineal/polar/src/pie.rs @@ -0,0 +1,124 @@ +//! Pie / donut chart. +//! +//! Las porciones arrancan a las 12 en punto (-90°) y avanzan en sentido +//! horario. Como el `Canvas` no tiene primitiva de arco, cada cuña se +//! tesela en un triangle strip; la calidad del arco escala con el ángulo. + +use pineal_render::{Canvas, Color, Point}; +use std::f32::consts::{FRAC_PI_2, TAU}; + +/// Una porción del pie: un valor (peso) y su color. +#[derive(Debug, Clone, Copy)] +pub struct Slice { + pub value: f32, + pub color: Color, +} + +impl Slice { + pub fn new(value: f32, color: Color) -> Self { + Self { value, color } + } +} + +/// Segmentos de arco por vuelta completa — controla la suavidad. +const ARC_SEGMENTS_PER_TURN: f32 = 96.0; + +/// Dibuja un pie centrado en `center`. Si `inner_radius > 0` es un donut. +/// Los valores negativos se tratan como 0. +pub fn paint_pie( + slices: &[Slice], + center: Point, + radius: f32, + inner_radius: f32, + canvas: &mut dyn Canvas, +) { + let total: f32 = slices.iter().map(|s| s.value.max(0.0)).sum(); + if total <= 0.0 || radius <= 0.0 { + return; + } + let mut angle = -FRAC_PI_2; + for s in slices { + let sweep = (s.value.max(0.0) / total) * TAU; + if sweep > 0.0 { + paint_wedge(center, radius, inner_radius.max(0.0), angle, angle + sweep, s.color, canvas); + } + angle += sweep; + } +} + +fn arc_point(center: Point, r: f32, angle: f32) -> Point { + Point::new(center.x + r * angle.cos(), center.y + r * angle.sin()) +} + +fn paint_wedge( + center: Point, + r_out: f32, + r_in: f32, + a0: f32, + a1: f32, + color: Color, + canvas: &mut dyn Canvas, +) { + let segs = ((a1 - a0).abs() / TAU * ARC_SEGMENTS_PER_TURN).ceil() as usize; + let segs = segs.max(1); + let mut coords = Vec::with_capacity((segs + 1) * 4); + let mut colors = Vec::with_capacity((segs + 1) * 2); + for i in 0..=segs { + let t = a0 + (a1 - a0) * (i as f32 / segs as f32); + // Borde interno: el centro (pie) o el arco interno (donut). + let inner = if r_in <= 0.0 { + center + } else { + arc_point(center, r_in, t) + }; + let outer = arc_point(center, r_out, t); + coords.push(inner.x); + coords.push(inner.y); + coords.push(outer.x); + coords.push(outer.y); + colors.push(color); + colors.push(color); + } + // Strip [in0,out0,in1,out1,…]: cada par de triángulos cubre un segmento. + canvas.fill_triangle_strip(&coords, &colors); +} + +#[cfg(test)] +mod tests { + use super::*; + use pineal_render::{PlanRecorder, RenderCmd}; + + fn count_strips(cmds: &[RenderCmd]) -> usize { + cmds.iter() + .filter(|c| matches!(c, RenderCmd::FillTriangleStrip { .. })) + .count() + } + + #[test] + fn one_strip_per_nonzero_slice() { + let slices = [ + Slice::new(1.0, Color::WHITE), + Slice::new(1.0, Color::BLACK), + Slice::new(2.0, Color::from_hex(0xff0000)), + ]; + let mut rec = PlanRecorder::new(); + paint_pie(&slices, Point::new(50.0, 50.0), 40.0, 0.0, &mut rec); + assert_eq!(count_strips(&rec.into_plan().cmds), 3); + } + + #[test] + fn zero_total_draws_nothing() { + let slices = [Slice::new(0.0, Color::WHITE)]; + let mut rec = PlanRecorder::new(); + paint_pie(&slices, Point::new(50.0, 50.0), 40.0, 0.0, &mut rec); + assert!(rec.into_plan().cmds.is_empty()); + } + + #[test] + fn donut_also_emits_strips() { + let slices = [Slice::new(1.0, Color::WHITE), Slice::new(1.0, Color::BLACK)]; + let mut rec = PlanRecorder::new(); + paint_pie(&slices, Point::new(50.0, 50.0), 40.0, 20.0, &mut rec); + assert_eq!(count_strips(&rec.into_plan().cmds), 2); + } +} diff --git a/crates/modules/pineal/polar/src/radar.rs b/crates/modules/pineal/polar/src/radar.rs new file mode 100644 index 0000000..e303b15 --- /dev/null +++ b/crates/modules/pineal/polar/src/radar.rs @@ -0,0 +1,97 @@ +//! Radar (spider) chart. +//! +//! `M` ejes equiespaciados desde las 12 en punto. Cada valor se proyecta +//! a una distancia del centro proporcional a `value / max_value`. El +//! polígono resultante se rellena (triangle fan) y se contornea. + +use pineal_render::{Canvas, Color, Point, StrokeStyle}; +use std::f32::consts::{FRAC_PI_2, TAU}; + +/// Dibuja un radar de `values.len()` ejes. `max_value` define el borde. +/// Rellena con `fill` y contornea con `stroke`. +pub fn paint_radar( + values: &[f32], + max_value: f32, + center: Point, + radius: f32, + fill: Color, + stroke: StrokeStyle, + canvas: &mut dyn Canvas, +) { + let m = values.len(); + if m < 3 || max_value <= 0.0 || radius <= 0.0 { + return; + } + + // Punto de cada eje, en orden. + let verts: Vec = values + .iter() + .enumerate() + .map(|(i, &v)| { + let angle = -FRAC_PI_2 + (i as f32 / m as f32) * TAU; + let dist = (v / max_value).clamp(0.0, 1.0) * radius; + Point::new(center.x + dist * angle.cos(), center.y + dist * angle.sin()) + }) + .collect(); + + // Relleno: fan como strip [c, v0, c, v1, …, c, v0] (cierra el polígono). + let mut coords = Vec::with_capacity((m + 1) * 4); + let mut colors = Vec::with_capacity((m + 1) * 2); + for i in 0..=m { + let v = verts[i % m]; + coords.push(center.x); + coords.push(center.y); + coords.push(v.x); + coords.push(v.y); + colors.push(fill); + colors.push(fill); + } + canvas.fill_triangle_strip(&coords, &colors); + + // Contorno: polilínea cerrada. + let mut outline = Vec::with_capacity((m + 1) * 2); + for i in 0..=m { + let v = verts[i % m]; + outline.push(v.x); + outline.push(v.y); + } + canvas.stroke_polyline(&outline, stroke); +} + +#[cfg(test)] +mod tests { + use super::*; + use pineal_render::{PlanRecorder, RenderCmd}; + + #[test] + fn emits_fill_strip_and_outline() { + let mut rec = PlanRecorder::new(); + paint_radar( + &[1.0, 2.0, 3.0, 2.0, 1.0], + 3.0, + Point::new(50.0, 50.0), + 40.0, + Color::WHITE, + StrokeStyle::new(1.5, Color::BLACK), + &mut rec, + ); + let cmds = rec.into_plan().cmds; + assert!(cmds.iter().any(|c| matches!(c, RenderCmd::FillTriangleStrip { .. }))); + assert!(cmds.iter().any(|c| matches!(c, RenderCmd::StrokePolyline { .. }))); + } + + #[test] + fn too_few_axes_draws_nothing() { + let mut rec = PlanRecorder::new(); + paint_radar( + &[1.0, 2.0], + 3.0, + Point::new(0.0, 0.0), + 10.0, + Color::WHITE, + StrokeStyle::new(1.0, Color::BLACK), + &mut rec, + ); + assert!(rec.into_plan().cmds.is_empty()); + } +}