tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198630 Definiciones

feat(cosmobiologia): cliente web demo SSR + DrawCommand agnóstico (fase 3a)

Fase 3a — render web operativo sin WASM. Demo funcional inmediata
con server-side rendering del SVG; el cliente WASM puro se hace en
fase 3b cuando wasm-pack / wasm-bindgen-cli esté instalado.

cosmobiologia-render — nuevo módulo `draw`:
- `Rgba { r, g, b, a }` color agnóstico (no Hsla, no hex CSS).
- `DrawCommand` enum tagged-serde: `Circle`, `Line`, `Text`. Listo
  para WASM o nativo — solo primitivas.
- `CompositionOpts { size, rot_offset_deg, include_bodies }`.
- `compose_wheel(model, opts) -> Vec<DrawCommand>` primera versión:
  anillo zodiacal (A+B), 12 cusps cada 30°, glyphs de signos,
  corona de casas (C+D), cusps de casas (Asc/IC/Desc/MC con peso
  doble), house numbers, anillo de aspectos (E), líneas de
  aspectos coloreadas por kind, glyphs de cuerpos natales con
  disco halo.
- `draw_commands_to_svg(cmds, size) -> String` serializa la lista
  a SVG inline. SVG-escape, `text-anchor` configurable, `dominant
  -baseline=central` para centrar verticalmente.

Pendiente en `compose_wheel` (extender en commits siguientes,
copiando lo del canvas gpui): spread anti-solapamiento, clusters
compartidos, coord labels, dial 3D bevel, vignette, themes
PrintColor/PrintBW. Por ahora es un MVP suficiente para verificar
end-to-end y para que el usuario tenga algo visible YA.

cosmobiologia-server:
- Nuevos endpoints:
  * `GET /`                     → HTML del cliente (single-page)
  * `GET /api/sky.svg`          → SVG agnóstico del "cielo ahora"
  * `GET /api/charts/:id/wheel.svg` → SVG agnóstico de carta con
                                     overlays via query (offset,
                                     transit, prog, sa, pd)
- Página HTML embebida (`include_str!` de `static/index.html`):
  * Sidebar con tree (groups → contacts → charts), click selecciona
  * "⏱ Cielo ahora" siempre disponible como botón rápido
  * Toolbar con input offset minutos + checkbox tránsito + botón
    refresh + botón download SVG
  * Botones "Nuevo grupo / Nuevo contacto" con prompt + POST
  * Wheel renderizado en SVG inline, info row con título/asc/mc/ms

Smoke test:
  cargo run -p cosmobiologia-server -- --port 18787
  curl /                       → HTML (página completa)
  curl /api/sky.svg            → 12 KB SVG con 17 circles +
                                 51 lines + 36 texts
  curl /api/tree               → árbol JSON
  curl POST /api/groups        → crea grupo
  Browser http://127.0.0.1:8787 → wheel visible

Próximo (fase 3b): cliente cdylib WASM `cosmobiologia-web` que
reemplace el SSR — recibe RenderModel JSON, llama compose_wheel +
draw_commands_to_svg en WASM, monta SVG via DOM. Trade-off: el
SSR de hoy es 12 KB transferidos por click (sólido); WASM
descarga ~150 KB una sola vez y luego compone localmente
(scrubbing instantáneo, sin round-trip al server).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

eac8c58974d78d4fab51ce62f6bb1a12da0b7426 · por sergio · (hace 5 meses) · padre d341004f5 · ver el árbol en este commit

Delta semántico · 23

crates/apps/cosmobiologia-server/src/main.rs

crates/modules/cosmobiologia/cosmobiologia-render/src/draw.rs

crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs

  • nueva mod draw pub

4 archivos · +742 −0

diff --git a/crates/apps/cosmobiologia-server/src/main.rs b/crates/apps/cosmobiologia-server/src/main.rs
index 1c4933c..844b3cf 100644
--- a/crates/apps/cosmobiologia-server/src/main.rs
+++ b/crates/apps/cosmobiologia-server/src/main.rs
@@ -45,6 +45,7 @@ use clap::Parser;
 use cosmobiologia_engine::{
     compose_with_options, svg_export, EngineError, NatalOptions, PipelineRequest, RenderModel,
 };
+use cosmobiologia_render::{compose_wheel, draw_commands_to_svg, CompositionOpts};
 use cosmobiologia_model::{
     Chart, ChartId, ChartKind, Contact, ContactId, Group, GroupId, StoredBirthData,
     StoredChartConfig,
@@ -118,9 +119,17 @@ fn default_db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
 
 fn router() -> Router<AppState> {
     Router::new()
+        .route("/", get(get_index))
         .route("/api/health", get(health))
         .route("/api/tree", get(get_tree))
         .route("/api/sky", get(get_sky))
+        // El render SVG agnóstico (via `cosmobiologia-render::compose_wheel`
+        // + `draw_commands_to_svg`) sirve a la fase 3 inicial: el
+        // cliente recibe SVG ya compuesto, sin necesidad de WASM.
+        // Cuando agreguemos el cliente WASM real, este endpoint se
+        // mantiene como fallback "ver SVG sin JS".
+        .route("/api/sky.svg", get(get_sky_svg))
+        .route("/api/charts/:id/wheel.svg", get(get_chart_wheel_svg))
         .route("/api/groups", post(post_group))
         .route("/api/groups/:id", patch(patch_group).delete(delete_group))
         .route("/api/contacts", post(post_contact))
@@ -139,6 +148,55 @@ fn router() -> Router<AppState> {
         .layer(TraceLayer::new_for_http())
 }
 
+// =====================================================================
+// Página HTML inicial
+// =====================================================================
+
+const INDEX_HTML: &str = include_str!("../static/index.html");
+
+async fn get_index() -> Response {
+    (
+        [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
+        INDEX_HTML.to_string(),
+    )
+        .into_response()
+}
+
+// SVG render agnóstico (no es el del engine — este viene de
+// `cosmobiologia-render::compose_wheel` que es lo que mañana el
+// cliente WASM también va a usar). Útil para demos sin WASM.
+async fn get_sky_svg() -> Result<Response, ApiError> {
+    let chart = build_present_sky_chart();
+    let model = compose_with_options(&chart, 0, &[], &NatalOptions::default())?;
+    let cmds = compose_wheel(&model, &CompositionOpts::default());
+    let svg = draw_commands_to_svg(&cmds, 600.0);
+    Ok((
+        [(axum::http::header::CONTENT_TYPE, "image/svg+xml")],
+        svg,
+    )
+        .into_response())
+}
+
+async fn get_chart_wheel_svg(
+    State(s): State<AppState>,
+    Path(id): Path<ChartId>,
+    Query(q): Query<RenderQuery>,
+) -> Result<Response, ApiError> {
+    let chart = s
+        .store
+        .get_chart(id)
+        .map_err(|_| ApiError::NotFound(format!("chart {}", id)))?;
+    let model =
+        compose_with_options(&chart, q.offset_min, &build_requests(&q), &NatalOptions::default())?;
+    let cmds = compose_wheel(&model, &CompositionOpts::default());
+    let svg = draw_commands_to_svg(&cmds, 600.0);
+    Ok((
+        [(axum::http::header::CONTENT_TYPE, "image/svg+xml")],
+        svg,
+    )
+        .into_response())
+}
+
 // =====================================================================
 // Error
 // =====================================================================
diff --git a/crates/apps/cosmobiologia-server/static/index.html b/crates/apps/cosmobiologia-server/static/index.html
new file mode 100644
index 0000000..5e355d5
--- /dev/null
+++ b/crates/apps/cosmobiologia-server/static/index.html
@@ -0,0 +1,245 @@
+<!doctype html>
+<html lang="es">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>Cosmobiología</title>
+  <style>
+    :root {
+      --bg: #0f1115;
+      --bg-panel: #171a21;
+      --fg: #e8e6df;
+      --fg-muted: #9aa0a8;
+      --accent: #c79a4d;
+      --border: #2a2e38;
+    }
+    * { box-sizing: border-box; }
+    body {
+      margin: 0;
+      background: var(--bg);
+      color: var(--fg);
+      font-family: -apple-system, system-ui, sans-serif;
+      display: flex;
+      min-height: 100vh;
+    }
+    aside {
+      width: 280px;
+      background: var(--bg-panel);
+      border-right: 1px solid var(--border);
+      padding: 16px;
+      overflow-y: auto;
+    }
+    main {
+      flex: 1;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      padding: 24px;
+    }
+    h1 {
+      margin: 0 0 4px;
+      font-weight: 500;
+      letter-spacing: .02em;
+    }
+    h2 {
+      margin: 24px 0 8px;
+      font-size: 12px;
+      color: var(--fg-muted);
+      text-transform: uppercase;
+      letter-spacing: .08em;
+    }
+    .tree-node {
+      cursor: pointer;
+      padding: 4px 6px;
+      border-radius: 4px;
+      font-size: 13px;
+    }
+    .tree-node:hover { background: rgba(255,255,255,.05); }
+    .tree-node.active { background: rgba(199,154,77,.15); color: var(--accent); }
+    .tree-node .icon { margin-right: 6px; opacity: .7; }
+    .indent-1 { padding-left: 22px; }
+    .indent-2 { padding-left: 38px; }
+    #wheel-container {
+      width: 600px;
+      height: 600px;
+      background: var(--bg-panel);
+      border: 1px solid var(--border);
+      border-radius: 8px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+    }
+    #info {
+      margin-top: 12px;
+      font-size: 12px;
+      color: var(--fg-muted);
+    }
+    #info b { color: var(--fg); font-weight: 500; }
+    .toolbar {
+      width: 600px;
+      display: flex;
+      gap: 8px;
+      margin-bottom: 12px;
+      align-items: center;
+    }
+    .toolbar button {
+      background: var(--bg-panel);
+      color: var(--fg);
+      border: 1px solid var(--border);
+      padding: 6px 12px;
+      border-radius: 4px;
+      font-size: 12px;
+      cursor: pointer;
+    }
+    .toolbar button:hover { border-color: var(--accent); color: var(--accent); }
+    .toolbar label { font-size: 12px; color: var(--fg-muted); }
+    .toolbar input { width: 60px; padding: 4px; border: 1px solid var(--border);
+      background: var(--bg); color: var(--fg); border-radius: 4px; font-size: 12px; }
+  </style>
+</head>
+<body>
+  <aside>
+    <h1>Cosmobiología</h1>
+    <div style="font-size:11px;color:var(--fg-muted);">cliente web demo</div>
+
+    <h2>Cartas</h2>
+    <div id="tree"></div>
+
+    <h2>Acciones rápidas</h2>
+    <div class="tree-node" onclick="loadSky()">⏱ Cielo ahora</div>
+    <div class="tree-node" onclick="newGroup()">+ Nuevo grupo</div>
+    <div class="tree-node" onclick="newContact()">+ Nuevo contacto</div>
+  </aside>
+
+  <main>
+    <div class="toolbar">
+      <button onclick="refreshSelected()">↻ Refrescar</button>
+      <label>Offset (min):
+        <input type="number" id="offset" value="0" step="60">
+      </label>
+      <label>
+        <input type="checkbox" id="transit"> Tránsito
+      </label>
+      <button onclick="downloadSvg()">⬇ SVG</button>
+    </div>
+    <div id="wheel-container">
+      <div style="color:var(--fg-muted)">Seleccioná una carta o "Cielo ahora"</div>
+    </div>
+    <div id="info"></div>
+  </main>
+
+  <script>
+    let selectedChartId = null;
+    let mode = 'sky'; // 'sky' | 'chart'
+
+    async function loadTree() {
+      const tree = await fetch('/api/tree').then(r => r.json());
+      const el = document.getElementById('tree');
+      el.innerHTML = '';
+      renderNodes(tree, el, 0);
+    }
+
+    function renderNodes(nodes, container, depth) {
+      for (const n of nodes) {
+        const div = document.createElement('div');
+        div.className = `tree-node indent-${depth}`;
+        const icon = n.kind === 'group' ? '◇' : n.kind === 'contact' ? '◯' : '✦';
+        div.innerHTML = `<span class="icon">${icon}</span>${escapeHtml(n.label)}`;
+        if (n.kind === 'chart') {
+          const id = n.id.replace(/^h:/, '');
+          div.onclick = () => selectChart(id);
+        }
+        container.appendChild(div);
+        if (n.children && n.children.length) {
+          renderNodes(n.children, container, depth + 1);
+        }
+      }
+    }
+
+    async function selectChart(id) {
+      selectedChartId = id;
+      mode = 'chart';
+      document.querySelectorAll('.tree-node').forEach(el =>
+        el.classList.toggle('active', el.textContent.endsWith(' (active)'))
+      );
+      await refreshSelected();
+    }
+
+    async function loadSky() {
+      mode = 'sky';
+      selectedChartId = null;
+      await refreshSelected();
+    }
+
+    async function refreshSelected() {
+      const offset = document.getElementById('offset').value || 0;
+      const transit = document.getElementById('transit').checked ? '1' : '0';
+      const params = new URLSearchParams({ offset_min: offset, transit });
+      let svgUrl, infoUrl;
+      if (mode === 'sky') {
+        svgUrl = `/api/sky.svg`;
+        infoUrl = `/api/sky`;
+      } else {
+        svgUrl = `/api/charts/${selectedChartId}/wheel.svg?${params}`;
+        infoUrl = `/api/charts/${selectedChartId}/render?${params}`;
+      }
+      const [svg, render] = await Promise.all([
+        fetch(svgUrl).then(r => r.text()),
+        fetch(infoUrl).then(r => r.json()).catch(() => null),
+      ]);
+      document.getElementById('wheel-container').innerHTML = svg;
+      const info = document.getElementById('info');
+      if (render) {
+        info.innerHTML =
+          `<b>${escapeHtml(render.title)}</b> · ` +
+          `Asc ${render.ascendant_deg.toFixed(2)}° · ` +
+          `MC ${render.midheaven_deg.toFixed(2)}° · ` +
+          `${render.compute_ms} ms`;
+      } else {
+        info.textContent = '';
+      }
+    }
+
+    function downloadSvg() {
+      const offset = document.getElementById('offset').value || 0;
+      const transit = document.getElementById('transit').checked ? '1' : '0';
+      const params = new URLSearchParams({ offset_min: offset, transit });
+      const url = mode === 'sky'
+        ? `/api/sky.svg`
+        : `/api/charts/${selectedChartId}/wheel.svg?${params}`;
+      window.location.href = url;
+    }
+
+    async function newGroup() {
+      const name = prompt('Nombre del grupo:');
+      if (!name) return;
+      await fetch('/api/groups', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ name })
+      });
+      loadTree();
+    }
+
+    async function newContact() {
+      const name = prompt('Nombre del contacto:');
+      if (!name) return;
+      await fetch('/api/contacts', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ name })
+      });
+      loadTree();
+    }
+
+    function escapeHtml(s) {
+      return s.replace(/[&<>"']/g, c => ({
+        '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
+      }[c]));
+    }
+
+    loadTree();
+    loadSky();
+  </script>
+</body>
+</html>
diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/draw.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/draw.rs
new file mode 100644
index 0000000..88ab27c
--- /dev/null
+++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/draw.rs
@@ -0,0 +1,435 @@
+//! Primitivas agnósticas de pintura — el `DrawCommand` que cada
+//! surface (gpui canvas o SVG/Canvas2D del WASM) traduce a su API.
+
+use serde::{Deserialize, Serialize};
+
+/// Color RGBA en `[0.0, 1.0]^4`. Independiente del color-space del
+/// surface (no es Hsla de gpui ni hex de CSS). El traductor de surface
+/// hace la conversión final.
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
+pub struct Rgba {
+    pub r: f32,
+    pub g: f32,
+    pub b: f32,
+    pub a: f32,
+}
+
+impl Rgba {
+    pub const TRANSPARENT: Rgba = Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.0 };
+    pub fn opaque(r: f32, g: f32, b: f32) -> Self {
+        Self { r, g, b, a: 1.0 }
+    }
+    pub fn with_alpha(mut self, a: f32) -> Self {
+        self.a = a;
+        self
+    }
+    /// Helper para serializar como CSS rgba(...).
+    pub fn to_css(&self) -> String {
+        format!(
+            "rgba({},{},{},{})",
+            (self.r * 255.0).round() as u8,
+            (self.g * 255.0).round() as u8,
+            (self.b * 255.0).round() as u8,
+            self.a
+        )
+    }
+}
+
+/// Anchor horizontal del texto. Vertical siempre es `middle` para
+/// que el texto se centre verticalmente en `(x, y)`.
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum TextAnchor {
+    Start,
+    Middle,
+    End,
+}
+
+/// Primitiva de pintura agnóstica. La lista de comandos describe
+/// **qué** dibujar, no **cómo** — cada surface traduce a su API.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum DrawCommand {
+    /// Círculo (stroke + fill opcional).
+    Circle {
+        cx: f32,
+        cy: f32,
+        r: f32,
+        #[serde(default)]
+        stroke: Option<Rgba>,
+        #[serde(default)]
+        fill: Option<Rgba>,
+        #[serde(default = "default_stroke_width")]
+        stroke_w: f32,
+    },
+    /// Segmento de línea con dash opcional.
+    Line {
+        x1: f32,
+        y1: f32,
+        x2: f32,
+        y2: f32,
+        color: Rgba,
+        #[serde(default = "default_stroke_width")]
+        width: f32,
+        /// `Some((on, off))` para dash. None = sólido.
+        #[serde(default)]
+        dash: Option<(f32, f32)>,
+    },
+    /// Texto en `(x, y)`, anchor horizontal configurable.
+    Text {
+        x: f32,
+        y: f32,
+        content: String,
+        color: Rgba,
+        size: f32,
+        #[serde(default = "default_anchor")]
+        anchor: TextAnchor,
+    },
+}
+
+fn default_stroke_width() -> f32 {
+    1.0
+}
+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.
+#[derive(Debug, Clone)]
+pub struct CompositionOpts {
+    /// Tamaño total del wheel en px (lado del cuadrado contenedor).
+    pub size: f32,
+    /// Rotación adicional visual (para jog-dial / transformaciones).
+    pub rot_offset_deg: f32,
+    /// Si `false`, la lista no incluye los glyphs de cuerpos (útil
+    /// para previews compactos).
+    pub include_bodies: bool,
+}
+
+impl Default for CompositionOpts {
+    fn default() -> Self {
+        Self {
+            size: 600.0,
+            rot_offset_deg: 0.0,
+            include_bodies: 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).
+pub fn compose_wheel(
+    model: &crate::RenderModel,
+    opts: &CompositionOpts,
+) -> Vec<DrawCommand> {
+    use crate::math::{polar_to_screen, Radii};
+    let mut out = Vec::new();
+
+    let cx = opts.size / 2.0;
+    let cy = opts.size / 2.0;
+    let margin = opts.size * 0.05;
+    let r_outer = (opts.size / 2.0) - margin;
+    let radii = Radii::from_outer(r_outer);
+
+    let asc = model.ascendant_deg;
+    let rot = opts.rot_offset_deg;
+
+    // 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);
+
+    // === Aro A (externo zodiaco) + B (interno) ===
+    out.push(DrawCommand::Circle {
+        cx,
+        cy,
+        r: radii.sign_outer,
+        stroke: Some(ink_strong),
+        fill: None,
+        stroke_w: 1.5,
+    });
+    out.push(DrawCommand::Circle {
+        cx,
+        cy,
+        r: radii.sign_inner,
+        stroke: Some(ink_mid),
+        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;
+        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 {
+            x1: cx + xi,
+            y1: cy + yi,
+            x2: cx + xo,
+            y2: cy + yo,
+            color: ink_mid,
+            width: 1.0,
+            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 ===
+    let sign_ring_mid = (radii.sign_outer + radii.sign_inner) / 2.0;
+    for layer in &model.layers {
+        if !matches!(layer.kind, crate::LayerKind::SignDial) {
+            continue;
+        }
+        for g in &layer.glyphs {
+            let (gx, gy) = polar_to_screen(g.deg, asc, rot, sign_ring_mid);
+            out.push(DrawCommand::Text {
+                x: cx + gx,
+                y: cy + gy,
+                content: sign_unicode(&g.symbol).into(),
+                color: ink_strong,
+                size: opts.size * 0.03,
+                anchor: TextAnchor::Middle,
+            });
+        }
+    }
+
+    // === Cuerpos natales (sin spread/cluster — minimal) ===
+    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.bodies;
+            for g in &layer.glyphs {
+                let (gx, gy) = polar_to_screen(g.deg, asc, rot, ring);
+                // Disco halo
+                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,
+                });
+                // Glyph del cuerpo
+                out.push(DrawCommand::Text {
+                    x: cx + gx,
+                    y: cy + gy,
+                    content: planet_unicode(&g.symbol).into(),
+                    color: ink_strong,
+                    size: opts.size * 0.028,
+                    anchor: TextAnchor::Middle,
+                });
+            }
+        }
+    }
+
+    // === Anillo de aspectos + líneas ===
+    out.push(DrawCommand::Circle {
+        cx,
+        cy,
+        r: radii.aspects,
+        stroke: Some(ink_soft),
+        fill: None,
+        stroke_w: 0.7,
+    });
+    for layer in &model.layers {
+        if !matches!(layer.kind, crate::LayerKind::Aspects) {
+            continue;
+        }
+        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);
+                let alpha = (seg.opacity).clamp(0.0, 1.0);
+                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,
+                    dash: None,
+                });
+            }
+        }
+    }
+
+    out
+}
+
+/// Sirve los `DrawCommand`s como un documento SVG completo.
+/// Devuelve un `String` listo para `innerHTML = ...` o file.
+pub fn draw_commands_to_svg(commands: &[DrawCommand], size: f32) -> String {
+    let mut s = String::with_capacity(8192);
+    s.push_str(&format!(
+        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{0}\" height=\"{0}\" viewBox=\"0 0 {0} {0}\">",
+        size as i32
+    ));
+    for cmd in commands {
+        match cmd {
+            DrawCommand::Circle { cx, cy, r, stroke, fill, stroke_w } => {
+                let stroke_attr = stroke
+                    .map(|c| format!(" stroke=\"{}\" stroke-width=\"{}\"", c.to_css(), stroke_w))
+                    .unwrap_or_default();
+                let fill_attr = match fill {
+                    Some(c) => format!(" fill=\"{}\"", c.to_css()),
+                    None => " fill=\"none\"".into(),
+                };
+                s.push_str(&format!(
+                    "<circle cx=\"{:.2}\" cy=\"{:.2}\" r=\"{:.2}\"{}{}/>",
+                    cx, cy, r, stroke_attr, fill_attr
+                ));
+            }
+            DrawCommand::Line { x1, y1, x2, y2, color, width, dash } => {
+                let dash_attr = match dash {
+                    Some((on, off)) => format!(" stroke-dasharray=\"{},{}\"", on, off),
+                    None => String::new(),
+                };
+                s.push_str(&format!(
+                    "<line x1=\"{:.2}\" y1=\"{:.2}\" x2=\"{:.2}\" y2=\"{:.2}\" stroke=\"{}\" stroke-width=\"{}\"{}/>",
+                    x1, y1, x2, y2, color.to_css(), width, dash_attr
+                ));
+            }
+            DrawCommand::Text { x, y, content, color, size: sz, anchor } => {
+                let anchor_attr = match anchor {
+                    TextAnchor::Start => "start",
+                    TextAnchor::Middle => "middle",
+                    TextAnchor::End => "end",
+                };
+                let escaped = svg_escape(content);
+                s.push_str(&format!(
+                    "<text x=\"{:.2}\" y=\"{:.2}\" font-size=\"{:.2}\" fill=\"{}\" text-anchor=\"{}\" dominant-baseline=\"central\">{}</text>",
+                    x, y, sz, color.to_css(), anchor_attr, escaped
+                ));
+            }
+        }
+    }
+    s.push_str("</svg>");
+    s
+}
+
+fn svg_escape(s: &str) -> String {
+    s.replace('&', "&amp;")
+        .replace('<', "&lt;")
+        .replace('>', "&gt;")
+        .replace('"', "&quot;")
+}
+
+fn sign_unicode(name: &str) -> &'static str {
+    match name {
+        "aries" => "♈",
+        "taurus" => "♉",
+        "gemini" => "♊",
+        "cancer" => "♋",
+        "leo" => "♌",
+        "virgo" => "♍",
+        "libra" => "♎",
+        "scorpio" => "♏",
+        "sagittarius" => "♐",
+        "capricorn" => "♑",
+        "aquarius" => "♒",
+        "pisces" => "♓",
+        _ => "?",
+    }
+}
+
+fn planet_unicode(name: &str) -> &'static str {
+    match name {
+        "sun" => "☉",
+        "moon" => "☽",
+        "mercury" => "☿",
+        "venus" => "♀",
+        "mars" => "♂",
+        "jupiter" => "♃",
+        "saturn" => "♄",
+        "uranus" => "♅",
+        "neptune" => "♆",
+        "pluto" => "♇",
+        "north_node" => "☊",
+        "south_node" => "☋",
+        "chiron" => "⚷",
+        "lilith" => "⚸",
+        _ => "•",
+    }
+}
+
+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),
+    }
+}
diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs
index e37f7fb..819abb5 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs
@@ -30,8 +30,12 @@ use serde::{Deserialize, Serialize};
 
 pub use cosmobiologia_model::{Chart, ChartId, ChartKind};
 
+pub mod draw;
 pub mod math;
 
+pub use draw::{
+    compose_wheel, draw_commands_to_svg, CompositionOpts, DrawCommand, Rgba, TextAnchor,
+};
 pub use math::{
     find_clusters, format_coord_compact, polar_to_screen, spread_angles, Radii,
 };