feat(cosmobiologia-render): compose_wheel rico con palette + dial 3D + spread + coord labels

El render agnóstico ya no es un esqueleto — porta al WASM la mayoría
de los detalles visuales que tenía solo el canvas gpui nativo:

- palette.rs: Palette dark/light replicando AstroPalette del theme
  nativo, pero en Rgba (no Hsla de gpui). Métodos planet/aspect/sign
  para resolver color por id simbólico, + house_ring con hue-shift.
- CompositionOpts extendido: palette, dial_3d, draw_ascensional_cross,
  show_coord_labels, show_minor_aspects. Defaults razonables.
- compose_wheel ahora dibuja: background panel, dial 3D bevel (4
  strokes concéntricos con alpha decreciente), subdivisiones cada 10°
  con sign boundaries reforzados, signos con color elemental, casas
  topocéntricas + geocéntricas en sus rings canónicos, cuerpos con
  spread anti-solapamiento + clusters + disco coloreado por planeta,
  coord labels "DD°MM'" en natal, aspectos con width inversa al
  orbe + filtrado opcional de minors, cruz ascensional dashed +
  pills ASC/MC/DESC/IC.
- cosmobiologia-web: nuevo render_model_to_svg_themed(dark: bool)
  para que el cliente JS elija palette según preferencia del UA.

Tests del módulo math siguen verdes (10/10). Smoke test del server:
/api/sky.svg ahora emite 22 circles, 77 lines, 52 texts con paleta
real (vs ~6 circles, 24 lines, 36 texts del esqueleto previo).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-19 01:41:36 +00:00
parent 4619ba3a2b
commit 86fb6ae20b
4 changed files with 562 additions and 117 deletions
@@ -94,9 +94,8 @@ fn default_anchor() -> TextAnchor {
TextAnchor::Middle
}
/// Opciones para `compose_wheel` — el caller decide tamaño total del
/// wheel y rotación visual. Los colores son simples por ahora;
/// extender después con una palette completa.
/// Opciones para `compose_wheel` — el caller decide tamaño total,
/// rotación visual, palette (dark/light) y qué overlays acompañar.
#[derive(Debug, Clone)]
pub struct CompositionOpts {
/// Tamaño total del wheel en px (lado del cuadrado contenedor).
@@ -106,6 +105,19 @@ pub struct CompositionOpts {
/// Si `false`, la lista no incluye los glyphs de cuerpos (útil
/// para previews compactos).
pub include_bodies: bool,
/// Paleta — controla todos los colores del lienzo. Default `dark()`.
pub palette: crate::Palette,
/// Si `true`, dibuja la cruz ascensional (líneas ASC↔DESC e
/// IC↔MC a través del centro) + pills con etiquetas.
pub draw_ascensional_cross: bool,
/// Si `true`, dibuja coord labels ("DD°MM'♈") al lado de cada
/// cuerpo natal.
pub show_coord_labels: bool,
/// Mostrar líneas de aspectos menores (semisextile, quincunx, etc.).
pub show_minor_aspects: bool,
/// Activa relieve 3D del dial (varios strokes concéntricos con
/// alpha decreciente para emular bevel).
pub dial_3d: bool,
}
impl Default for CompositionOpts {
@@ -114,19 +126,31 @@ impl Default for CompositionOpts {
size: 600.0,
rot_offset_deg: 0.0,
include_bodies: true,
palette: crate::Palette::dark(),
draw_ascensional_cross: true,
show_coord_labels: true,
show_minor_aspects: false,
dial_3d: true,
}
}
}
/// Compone una lista de `DrawCommand`s a partir de un `RenderModel`.
/// Versión inicial: anillo de signos + cusps cada 30° + house numbers
/// + cuerpos natales. Sin clusters/spread/aspectos (extiende en
/// commits siguientes).
/// Incluye:
/// - Background panel + dial 3D (bevel via strokes concéntricos)
/// - Anillos A/B/C/D/E con sus cusps
/// - Casas topocéntricas (ring B→C) + geocéntricas (ring C→D) cuando
/// están en el modelo
/// - Glyphs zodiacales con color de su elemento
/// - Cuerpos natales con disco coloreado + spread anti-solapamiento
/// + coord labels en pills
/// - Aspectos: width inversa al orbe, filtrado opcional de minors
/// - Cruz ascensional ASC↔DESC + IC↔MC + pills con etiquetas
pub fn compose_wheel(
model: &crate::RenderModel,
opts: &CompositionOpts,
) -> Vec<DrawCommand> {
use crate::math::{polar_to_screen, Radii};
use crate::math::{find_clusters, format_coord_compact, polar_to_screen, spread_angles, Radii};
let mut out = Vec::new();
let cx = opts.size / 2.0;
@@ -137,35 +161,66 @@ pub fn compose_wheel(
let asc = model.ascendant_deg;
let rot = opts.rot_offset_deg;
let pal = &opts.palette;
// Colores neutros (en fase próxima los reemplazo por palette real)
let ink_strong = Rgba::opaque(0.15, 0.15, 0.20);
let ink_mid = Rgba::opaque(0.45, 0.45, 0.50).with_alpha(0.85);
let ink_soft = Rgba::opaque(0.55, 0.55, 0.60).with_alpha(0.55);
let house_color = Rgba::opaque(0.30, 0.55, 0.50).with_alpha(0.85);
let angle_color = Rgba::opaque(0.85, 0.55, 0.20);
// === Background panel ===
out.push(DrawCommand::Circle {
cx,
cy,
r: radii.sign_outer + opts.size * 0.02,
stroke: None,
fill: Some(pal.bg_panel),
stroke_w: 0.0,
});
// === Dial 3D — relieve via strokes concéntricos cerca de aro A ===
if opts.dial_3d {
let bevel_steps: [(f32, f32, f32); 4] = [
(1.012, 0.18, 0.6), // halo externo
(1.006, 0.32, 0.9),
(0.994, 0.40, 1.0),
(0.988, 0.20, 0.7), // halo interno
];
for (factor, alpha, w) in bevel_steps {
out.push(DrawCommand::Circle {
cx,
cy,
r: radii.sign_outer * factor,
stroke: Some(pal.dial_ring.with_alpha(alpha)),
fill: None,
stroke_w: w,
});
}
}
// === Aro A (externo zodiaco) + B (interno) ===
out.push(DrawCommand::Circle {
cx,
cy,
r: radii.sign_outer,
stroke: Some(ink_strong),
stroke: Some(pal.dial_ring),
fill: None,
stroke_w: 1.5,
stroke_w: 1.6,
});
out.push(DrawCommand::Circle {
cx,
cy,
r: radii.sign_inner,
stroke: Some(ink_mid),
stroke: Some(pal.dial_ring.with_alpha(0.7)),
fill: None,
stroke_w: 1.0,
});
// === Cusps zodiacales (12 radios entre sign_inner y sign_outer) ===
for i in 0..12 {
let lon = (i as f32) * 30.0;
// === Cusps zodiacales cada 30°, sub-divisiones cada 10° (más sutiles) ===
for i in 0..36 {
let lon = (i as f32) * 10.0;
let is_sign_boundary = i % 3 == 0;
let color = if is_sign_boundary {
pal.dial_ring
} else {
pal.dial_ring.with_alpha(0.30)
};
let width = if is_sign_boundary { 1.0 } else { 0.4 };
let (xi, yi) = polar_to_screen(lon, asc, rot, radii.sign_inner);
let (xo, yo) = polar_to_screen(lon, asc, rot, radii.sign_outer);
out.push(DrawCommand::Line {
@@ -173,74 +228,13 @@ pub fn compose_wheel(
y1: cy + yi,
x2: cx + xo,
y2: cy + yo,
color: ink_mid,
width: 1.0,
color,
width,
dash: None,
});
}
// === Casas: aros + cusps + glyph número ===
let house_outer_r = radii.houses_outer;
let house_inner_r = radii.houses_inner;
out.push(DrawCommand::Circle {
cx,
cy,
r: house_outer_r,
stroke: Some(house_color),
fill: None,
stroke_w: 1.0,
});
out.push(DrawCommand::Circle {
cx,
cy,
r: house_inner_r,
stroke: Some(house_color),
fill: None,
stroke_w: 1.0,
});
for layer in &model.layers {
if !matches!(layer.kind, crate::LayerKind::Houses) {
continue;
}
if layer.module_id != "natal" {
continue;
}
if let crate::Geometry::Ring { cusps_deg } = &layer.geometry {
for (i, c) in cusps_deg.iter().enumerate() {
let is_angle = i == 0 || i == 3 || i == 6 || i == 9;
let color = if is_angle { angle_color } else { house_color };
let width = if is_angle { 2.0 } else { 0.8 };
let (xi, yi) = polar_to_screen(*c, asc, rot, house_inner_r);
let (xo, yo) = polar_to_screen(*c, asc, rot, house_outer_r);
out.push(DrawCommand::Line {
x1: cx + xi,
y1: cy + yi,
x2: cx + xo,
y2: cy + yo,
color,
width,
dash: None,
});
}
}
// House numbers
let label_r = (house_outer_r + house_inner_r) / 2.0;
for g in &layer.glyphs {
if let Some(h) = g.house {
let (gx, gy) = polar_to_screen(g.deg, asc, rot, label_r);
out.push(DrawCommand::Text {
x: cx + gx,
y: cy + gy,
content: format!("{}", h),
color: ink_mid,
size: opts.size * 0.018,
anchor: TextAnchor::Middle,
});
}
}
}
// === Glyphs zodiacales ===
// === Glyphs zodiacales con color elemental ===
let sign_ring_mid = (radii.sign_outer + radii.sign_inner) / 2.0;
for layer in &model.layers {
if !matches!(layer.kind, crate::LayerKind::SignDial) {
@@ -252,43 +246,183 @@ pub fn compose_wheel(
x: cx + gx,
y: cy + gy,
content: sign_unicode(&g.symbol).into(),
color: ink_strong,
size: opts.size * 0.03,
color: pal.sign(&g.symbol),
size: opts.size * 0.032,
anchor: TextAnchor::Middle,
});
}
}
// === Cuerpos natales (sin spread/cluster — minimal) ===
// === Casas topocéntricas (ring B→C) — si están en el modelo ===
let topo_outer = radii.topo_houses_outer;
let topo_inner = radii.topo_houses_inner;
let topo_ring_color = pal.house_ring();
let has_topo = model
.layers
.iter()
.any(|l| matches!(l.kind, crate::LayerKind::Houses) && l.module_id == "topocentric");
if has_topo {
out.push(DrawCommand::Circle {
cx,
cy,
r: topo_inner,
stroke: Some(topo_ring_color.with_alpha(0.55)),
fill: None,
stroke_w: 0.8,
});
}
// === Casas geocéntricas (ring C→D) ===
let house_outer_r = radii.houses_outer;
let house_inner_r = radii.houses_inner;
out.push(DrawCommand::Circle {
cx,
cy,
r: house_outer_r,
stroke: Some(pal.house_cusp),
fill: None,
stroke_w: 1.0,
});
out.push(DrawCommand::Circle {
cx,
cy,
r: house_inner_r,
stroke: Some(pal.house_cusp),
fill: None,
stroke_w: 1.0,
});
// Draws cusps + numbers for both house systems (topo + geo) in their respective rings.
for layer in &model.layers {
if !matches!(layer.kind, crate::LayerKind::Houses) {
continue;
}
let (ring_outer, ring_inner, base_color, label_color) = match layer.module_id.as_str() {
"topocentric" => (topo_outer, topo_inner, topo_ring_color, topo_ring_color),
_ => (house_outer_r, house_inner_r, pal.house_cusp, pal.fg_muted),
};
if let crate::Geometry::Ring { cusps_deg } = &layer.geometry {
for (i, c) in cusps_deg.iter().enumerate() {
let is_angle = i == 0 || i == 3 || i == 6 || i == 9;
let color = if is_angle {
pal.angle_highlight
} else {
base_color
};
let width = if is_angle { 1.8 } else { 0.8 };
let (xi, yi) = polar_to_screen(*c, asc, rot, ring_inner);
let (xo, yo) = polar_to_screen(*c, asc, rot, ring_outer);
out.push(DrawCommand::Line {
x1: cx + xi,
y1: cy + yi,
x2: cx + xo,
y2: cy + yo,
color,
width,
dash: None,
});
}
}
// House numbers en el centro del ring
let label_r = (ring_outer + ring_inner) / 2.0;
for g in &layer.glyphs {
if let Some(h) = g.house {
let (gx, gy) = polar_to_screen(g.deg, asc, rot, label_r);
out.push(DrawCommand::Text {
x: cx + gx,
y: cy + gy,
content: format!("{}", h),
color: label_color,
size: opts.size * 0.018,
anchor: TextAnchor::Middle,
});
}
}
}
// === Cuerpos por módulo (natal, topocentric, transit, progresión…) ===
// Spread anti-solapamiento + clusters compartidos + coord labels.
// Cada body-layer se renderea en su ring canónico.
let mut natal_display_by_body: std::collections::HashMap<String, f32> =
std::collections::HashMap::new();
if opts.include_bodies {
for layer in &model.layers {
if !matches!(layer.kind, crate::LayerKind::Bodies) {
continue;
}
if layer.module_id != "natal" {
continue;
let ring = radii.body_ring(&layer.module_id);
let is_natal = layer.module_id == "natal";
// Spread: separación mínima ~10°, shift máximo ~12°.
let raw_degs: Vec<f32> = layer.glyphs.iter().map(|g| g.deg).collect();
let (display_degs, residual) = spread_angles(&raw_degs, 10.0, 12.0);
// Clusters para encoger discos
let clusters = find_clusters(&raw_degs, 9.0);
let mut cluster_size: Vec<usize> = vec![1; layer.glyphs.len()];
for c in &clusters {
for &i in c {
cluster_size[i] = c.len();
}
}
let ring = radii.bodies;
for g in &layer.glyphs {
let (gx, gy) = polar_to_screen(g.deg, asc, rot, ring);
// Disco halo
// Disco base y escala por cluster size
let base_disk = opts.size * 0.022;
let base_font = opts.size * 0.028;
for (i, g) in layer.glyphs.iter().enumerate() {
let disp_deg = display_degs[i];
if is_natal {
natal_display_by_body.insert(g.symbol.clone(), disp_deg);
}
// Encoge un poco si el cluster es denso o quedó presión residual
let dense_factor = if cluster_size[i] >= 3 {
0.78
} else if cluster_size[i] == 2 {
0.88
} else {
1.0
};
let stress_factor = (1.0 - residual * 0.5).max(0.6);
let disk = base_disk * dense_factor * stress_factor;
let font = (base_font * dense_factor * stress_factor).max(opts.size * 0.018);
let (gx, gy) = polar_to_screen(disp_deg, asc, rot, ring);
// Halo del disco — color del planeta, fill oscuro/claro según tema
let body_color = pal.planet(&g.symbol);
let halo_fill = if pal.is_dark {
pal.bg_panel.with_alpha(0.92)
} else {
Rgba::opaque(1.0, 1.0, 1.0).with_alpha(0.92)
};
out.push(DrawCommand::Circle {
cx: cx + gx,
cy: cy + gy,
r: opts.size * 0.022,
stroke: Some(ink_strong),
fill: Some(Rgba::opaque(0.97, 0.97, 0.97).with_alpha(0.92)),
stroke_w: 1.0,
r: disk,
stroke: Some(body_color),
fill: Some(halo_fill),
stroke_w: 1.2,
});
// Glyph del cuerpo
// Glyph
out.push(DrawCommand::Text {
x: cx + gx,
y: cy + gy,
content: planet_unicode(&g.symbol).into(),
color: ink_strong,
size: opts.size * 0.028,
content: planet_unicode_with_retro(&g.symbol, g.retrograde),
color: body_color,
size: font,
anchor: TextAnchor::Middle,
});
// Coord label en pill — solo natal por ahora (sino se ensucia)
if opts.show_coord_labels && is_natal {
let label_ring = ring - disk * 1.6;
let (lx, ly) = polar_to_screen(disp_deg, asc, rot, label_ring);
out.push(DrawCommand::Text {
x: cx + lx,
y: cy + ly,
content: format_coord_compact(g.deg),
color: pal.fg_muted,
size: opts.size * 0.0155,
anchor: TextAnchor::Middle,
});
}
}
}
}
@@ -298,32 +432,95 @@ pub fn compose_wheel(
cx,
cy,
r: radii.aspects,
stroke: Some(ink_soft),
stroke: Some(pal.fg_muted.with_alpha(0.35)),
fill: None,
stroke_w: 0.7,
stroke_w: 0.6,
});
for layer in &model.layers {
if !matches!(layer.kind, crate::LayerKind::Aspects) {
continue;
}
let (ring_a, ring_b) = radii.aspect_endpoints(&layer.module_id);
if let crate::Geometry::Lines(segs) = &layer.geometry {
for seg in segs {
let (ax, ay) = polar_to_screen(seg.from_deg, asc, rot, radii.aspects);
let (bx, by) = polar_to_screen(seg.to_deg, asc, rot, radii.aspects);
// Filtrar menores si opt off
let is_minor = !matches!(
seg.kind.as_str(),
"conjunction" | "sextile" | "square" | "trine" | "opposition"
);
if is_minor && !opts.show_minor_aspects {
continue;
}
// Endpoints: si tenemos display_deg natal, usarlo para que la
// línea apunte al cuerpo "spread", no al "raw". Cae al raw
// si no hay match (overlays sin natal de un lado).
let from_deg = natal_display_by_body
.get(&seg.from_body)
.copied()
.unwrap_or(seg.from_deg);
let to_deg = natal_display_by_body
.get(&seg.to_body)
.copied()
.unwrap_or(seg.to_deg);
let (ax, ay) = polar_to_screen(from_deg, asc, rot, ring_a);
let (bx, by) = polar_to_screen(to_deg, asc, rot, ring_b);
let alpha = (seg.opacity).clamp(0.0, 1.0);
// Width inversa al orbe — orbe 0 → 1.6, orbe 10° → 0.5
let width = (1.6 - seg.orb_deg.abs() * 0.10).clamp(0.45, 1.8);
out.push(DrawCommand::Line {
x1: cx + ax,
y1: cy + ay,
x2: cx + bx,
y2: cy + by,
color: aspect_color(&seg.kind).with_alpha(alpha),
width: 0.9,
color: pal.aspect(&seg.kind).with_alpha(alpha),
width,
dash: None,
});
}
}
}
// === Cruz ascensional + pills ASC/MC/DESC/IC ===
if opts.draw_ascensional_cross {
let cross_r = radii.aspects * 0.96;
let angles: [(f32, &str); 4] = [
(model.ascendant_deg, "Asc"),
(model.descendant_deg, "Desc"),
(model.midheaven_deg, "MC"),
(model.imum_coeli_deg, "IC"),
];
// Asc↔Desc + IC↔MC — dos líneas finas a través del centro
for (a, b) in [
(model.ascendant_deg, model.descendant_deg),
(model.imum_coeli_deg, model.midheaven_deg),
] {
let (ax, ay) = polar_to_screen(a, asc, rot, cross_r);
let (bx, by) = polar_to_screen(b, asc, rot, cross_r);
out.push(DrawCommand::Line {
x1: cx + ax,
y1: cy + ay,
x2: cx + bx,
y2: cy + by,
color: pal.angle_highlight.with_alpha(0.35),
width: 0.8,
dash: Some((4.0, 4.0)),
});
}
// Pills — label justo afuera del sign_outer
let pill_r = radii.sign_outer + opts.size * 0.025;
for (deg, label) in angles {
let (gx, gy) = polar_to_screen(deg, asc, rot, pill_r);
out.push(DrawCommand::Text {
x: cx + gx,
y: cy + gy,
content: label.into(),
color: pal.angle_highlight,
size: opts.size * 0.022,
anchor: TextAnchor::Middle,
});
}
}
out
}
@@ -423,13 +620,12 @@ fn planet_unicode(name: &str) -> &'static str {
}
}
fn aspect_color(kind: &str) -> Rgba {
match kind {
"conjunction" => Rgba::opaque(0.85, 0.65, 0.20),
"sextile" => Rgba::opaque(0.20, 0.55, 0.80),
"square" => Rgba::opaque(0.90, 0.30, 0.30),
"trine" => Rgba::opaque(0.30, 0.70, 0.40),
"opposition" => Rgba::opaque(0.55, 0.30, 0.75),
_ => Rgba::opaque(0.55, 0.55, 0.60).with_alpha(0.55),
/// Glyph del cuerpo con sufijo "℞" si está retrógrado — concatenación
/// directa en el text para no agregar más comandos por planeta.
fn planet_unicode_with_retro(name: &str, retrograde: bool) -> String {
if retrograde {
format!("{}", planet_unicode(name))
} else {
planet_unicode(name).to_string()
}
}