gioser

tawasuyu/brahman

Rust 528TOML 203Markdown 58JSON 197560 Definiciones

feat(tahuantinsuyu): fase 18 — aspect list + hover tooltips + dignidades + export SVG

Fase grande con 4 features que aprovechan toda la infraestructura ya
construida. Engine ganó 2 módulos nuevos (dignity table data-only +
svg_export), el RenderModel se enriqueció con AspectSummary y los
glyphs con dignity_marker, y el canvas trae hit-test pasivo + lista
textual + botón de export.

## C — Lista textual de aspectos

- engine: nuevo `AspectSummary { module_id, from_body, to_body, kind,
  orb_deg, applying }` + campo `aspect_summary: Vec<AspectSummary>`
  en RenderModel. populate_natal_aspect_summary y
  populate_cross_aspect_summary se llaman desde compose por cada
  pasada (natal + 4 overlays). Ordenado por orb_deg asc (los más
  exactos primero).
- canvas: nuevo aspect_unicode helper (☌ ☍ △ □ ⚹ ⚻ ⚺ ∠ ⚼ Q bQ).
  Footer agrega un grid flex-wrap con las top 12 entries del summary,
  cada una formateada como "[module_id] ☉ △ ☾  ·  2.3° A" coloreado
  por palette.aspect(kind).

## A — Tooltips al hover

- canvas: nuevo HoverInfo { module_id, symbol, deg, house, retrograde,
  dignity_marker, annotation, local_x, local_y } + state.hover.
  on_hover_check ejecuta hit-test sobre todos los glyphs Bodies +
  Outer (threshold 14px); se llama desde el handler MouseMoveEvent
  cuando NO está dragging (handler reescrito para soportar drag y
  hover en mismo callback). Cuando mouse sale del wheel, hover=None.
- Tooltip absoluto: "☉ Tauro · 23.4° · Casa 5 · ℞" con border
  angle_highlight. Posición offset arriba-derecha del planeta,
  clampada al wheel para no salirse.

## B — Dignidades esenciales clásicas

- engine: nuevo mod `dignity` con `Dignity { Rulership/Exaltation/
  Detriment/Fall }` + tabla rules_classical (7 planetas tradicionales)
  + exalts_at table. `essential_dignity(body, sign_index) -> Option`.
  4 tests cubren rulership/detriment/exaltation/fall + edge case
  modernos (Urano/Nept/Plutón sin dignidad clásica). 4 markers:
  + (domicilio), · (exaltación), − (exilio), * (caída).
- engine: Glyph gana campo `dignity_marker: Option<String>`. Default
  derive en Glyph para no romper N construction sites. bridge::
  annotate_dignities mutua RenderModel post-build agregando markers
  a glyphs natales según el signo de cada placement.
- NatalOptions agrega show_dignities. NatalModule.controls() agrega
  Toggle "Dignidades esenciales (+ · − *)" default false.
- canvas: glyph render append dignity_marker al texto después del ᴿ.

## D — Export SVG

- engine: nuevo `pub mod svg_export` con `render_to_svg(&RenderModel)
  -> String`. Reproduce la geometría del canvas en un SVG standalone
  800×800 escalable: anillos zodiacales, cusps, planetas con
  retrograde+dignity markers, aspectos coloreados por kind (cross
  conocen rings de origen/destino vía aspect_radii), labels ASC/MC/
  DESC/IC. Sin dependencias nuevas — write! sobre String. Test
  asserts well-formed XML.
- canvas: CanvasEvent::ExportSvgRequested + botón pequeño "⬇ SVG"
  en el header del wheel (al lado del title).
- shell: on_canvas_event ExportSvgRequested → export_current_to_svg
  recompose actual + svg_export::render_to_svg + write a
  $XDG_DATA_HOME/tahuantinsuyu/exports/<label>_<short_id>.svg.
  Ruta logueada a stderr para que el usuario encuentre el archivo.

`cargo check` y `cargo test` verdes con 8 tests en engine
(2 existentes + 4 dignity + 1 svg + 1 mock).

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

2cd34c82da7e745a2f9cfac90715f745f4487036 · por sergio · (hace 5 meses) · padre 0ae622550 · ver el árbol en este commit

Delta semántico · 65

crates/apps/tahuantinsuyu/src/shell.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/dignity.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/svg_export.rs

crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs

7 archivos · +906 −10

diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs
index a85f42d..a0fc6b2 100644
--- a/crates/apps/tahuantinsuyu/src/shell.rs
+++ b/crates/apps/tahuantinsuyu/src/shell.rs
@@ -33,6 +33,7 @@ use tahuantinsuyu_canvas::{
 };
 use tahuantinsuyu_engine::{
     LayerKind, NatalOptions, OUTER_RING_MODULES, PipelineRequest, compose_with_options,
+    svg_export,
 };
 use tahuantinsuyu_model::{Chart, ChartId, ModuleState, TreeSelection};
 use tahuantinsuyu_panel::{ChartOption, ControlPanel, PanelEvent};
@@ -308,6 +309,7 @@ impl Shell {
             show_majors: read_bool("aspect_majors", true),
             show_minors: read_bool("aspect_minors", false),
             orb_multiplier: read_f64("orb_multiplier", 1.0),
+            show_dignities: read_bool("show_dignities", false),
         }
     }
 
@@ -495,6 +497,53 @@ impl Shell {
             CanvasEvent::ChartRequested(_) => {
                 // Fase 7: doble click sobre un thumbnail abre la carta.
             }
+            CanvasEvent::ExportSvgRequested => {
+                self.export_current_to_svg();
+            }
+        }
+    }
+
+    /// Recompone la carta actual + escribe el SVG a un archivo en
+    /// `$XDG_DATA_HOME/tahuantinsuyu/exports/<label>_<short_id>.svg`.
+    /// Logea la ruta a stderr — futuro: file save dialog GPUI.
+    fn export_current_to_svg(&self) {
+        let Some(chart) = self.current_chart.as_ref() else {
+            eprintln!("[shell] export svg: sin carta activa");
+            return;
+        };
+        let requests = self.build_requests();
+        let natal_options = self.build_natal_options();
+        let render = match compose_with_options(
+            chart,
+            self.current_offset_minutes,
+            &requests,
+            &natal_options,
+        ) {
+            Ok(r) => r,
+            Err(e) => {
+                eprintln!("[shell] export svg compose: {}", e);
+                return;
+            }
+        };
+        let svg = svg_export::render_to_svg(&render);
+        let dir = directories::ProjectDirs::from("net", "gioser", "tahuantinsuyu")
+            .map(|d| d.data_dir().join("exports"))
+            .unwrap_or_else(|| std::path::PathBuf::from("."));
+        if let Err(e) = std::fs::create_dir_all(&dir) {
+            eprintln!("[shell] mkdir {:?}: {}", dir, e);
+            return;
+        }
+        let safe_label: String = chart
+            .label
+            .chars()
+            .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
+            .collect();
+        let short = format!("{}", chart.id).chars().take(8).collect::<String>();
+        let path = dir.join(format!("{}_{}.svg", safe_label, short));
+        if let Err(e) = std::fs::write(&path, svg) {
+            eprintln!("[shell] write {:?}: {}", path, e);
+        } else {
+            eprintln!("[shell] SVG exportado → {}", path.display());
         }
     }
 
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
index 83341ff..21e0de5 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
@@ -59,6 +59,9 @@ pub enum CanvasEvent {
     /// El usuario togggleó una capa via hotkey — el panel debería
     /// reflejarlo si quisiera mantenerse en sync.
     LayerVisibilityChanged { kind: LayerKind, visible: bool },
+    /// El usuario pidió exportar el render actual como SVG. El shell
+    /// se encarga de escribir el archivo (la engine genera el string).
+    ExportSvgRequested,
 }
 
 // =====================================================================
@@ -110,9 +113,28 @@ pub struct CanvasState {
     pub time_offset_minutes: i64,
     /// Por-LayerKind: `true` = visible. Default = todo visible.
     pub layer_visibility: HashMap<LayerKind, bool>,
+    /// Planeta hovered actualmente (para tooltip). `None` cuando el
+    /// mouse no está sobre ningún cuerpo.
+    pub hover: Option<HoverInfo>,
     drag_jog: Option<JogDragState>,
 }
 
+/// Info del cuerpo bajo el cursor — usado por el render para mostrar
+/// un tooltip flotante con detalles.
+#[derive(Clone, Debug)]
+pub struct HoverInfo {
+    pub module_id: String,
+    pub symbol: String,
+    pub deg: f32,
+    pub house: Option<u8>,
+    pub retrograde: bool,
+    pub dignity_marker: Option<String>,
+    pub annotation: Option<String>,
+    /// Posición relativa al wheel (en píxeles desde su top-left).
+    pub local_x: f32,
+    pub local_y: f32,
+}
+
 impl Default for CanvasState {
     fn default() -> Self {
         Self {
@@ -120,6 +142,7 @@ impl Default for CanvasState {
             view_rotation_deg: 0.0,
             time_offset_minutes: 0,
             layer_visibility: HashMap::new(),
+            hover: None,
             drag_jog: None,
         }
     }
@@ -250,6 +273,81 @@ impl AstrologyCanvas {
         cx.notify();
     }
 
+    /// Hit-test sobre los body glyphs activos. Recibe la posición del
+    /// mouse en window coords y los bounds del canvas (wheel). Para
+    /// cada Glyph con LayerKind == Bodies u Outer en el RenderModel
+    /// actual, calcula su posición pintada y mide distancia. Si está
+    /// dentro de `threshold_px`, actualiza `state.hover`.
+    fn on_hover_check(
+        &mut self,
+        position: Point<Pixels>,
+        bounds: Bounds<Pixels>,
+        cx: &mut Context<Self>,
+    ) {
+        let CanvasMode::Wheel { render } = &self.state.mode else {
+            if self.state.hover.take().is_some() {
+                cx.notify();
+            }
+            return;
+        };
+        let (cx_px, cy_px) = bounds_center(bounds);
+        let mx: f32 = position.x.into();
+        let my: f32 = position.y.into();
+        let r_outer = (WHEEL_SIZE - WHEEL_MARGIN * 2.0) / 2.0;
+        let radii = Radii::from_outer(r_outer);
+        let asc = render.ascendant_deg;
+        let rot = self.state.view_rotation_deg;
+        let threshold = 14.0_f32;
+
+        let mut best: Option<(f32, HoverInfo)> = None;
+        for layer in &render.layers {
+            let ring = match layer.kind {
+                LayerKind::Bodies => radii.body_ring(&layer.module_id),
+                LayerKind::Outer if OUTER_RING_MODULES.contains(&layer.module_id.as_str()) => {
+                    radii.transits
+                }
+                _ => continue,
+            };
+            for g in &layer.glyphs {
+                let (gx, gy) = polar_to_screen(g.deg, asc, rot, ring);
+                let dx = mx - (cx_px + gx);
+                let dy = my - (cy_px + gy);
+                let dist = (dx * dx + dy * dy).sqrt();
+                if dist > threshold {
+                    continue;
+                }
+                if best.as_ref().map(|(d, _)| dist < *d).unwrap_or(true) {
+                    let ox: f32 = bounds.origin.x.into();
+                    let oy: f32 = bounds.origin.y.into();
+                    best = Some((
+                        dist,
+                        HoverInfo {
+                            module_id: layer.module_id.clone(),
+                            symbol: g.symbol.clone(),
+                            deg: g.deg,
+                            house: g.house,
+                            retrograde: g.retrograde,
+                            dignity_marker: g.dignity_marker.clone(),
+                            annotation: g.annotation.clone(),
+                            local_x: cx_px + gx - ox,
+                            local_y: cy_px + gy - oy,
+                        },
+                    ));
+                }
+            }
+        }
+        let new_hover = best.map(|(_, h)| h);
+        let changed = match (&self.state.hover, &new_hover) {
+            (Some(a), Some(b)) => a.symbol != b.symbol || a.module_id != b.module_id,
+            (None, None) => false,
+            _ => true,
+        };
+        if changed {
+            self.state.hover = new_hover;
+            cx.notify();
+        }
+    }
+
     fn on_jog_up(&mut self, cx: &mut Context<Self>) {
         let Some(jog) = self.state.drag_jog.take() else {
             return;
@@ -325,6 +423,7 @@ impl Render for AstrologyCanvas {
                 self.state.view_rotation_deg,
                 self.state.time_offset_minutes,
                 &self.state.layer_visibility,
+                self.state.hover.as_ref(),
                 entity,
             ),
             CanvasMode::Thumbnails { items, .. } => render_thumbnails(&theme, items),
@@ -421,6 +520,7 @@ fn render_wheel(
     view_rotation_deg: f32,
     time_offset_minutes: i64,
     layer_visibility: &HashMap<LayerKind, bool>,
+    hover: Option<&HoverInfo>,
     entity: gpui::Entity<AstrologyCanvas>,
 ) -> gpui::Div {
     let asc = render.ascendant_deg;
@@ -471,10 +571,19 @@ fn render_wheel(
             });
             let entity_m = entity_for_canvas.clone();
             window.on_mouse_event(move |ev: &MouseMoveEvent, _, _w, cx| {
-                if !ev.dragging() {
-                    return;
+                if ev.dragging() {
+                    entity_m.update(cx, |this, cx| this.on_jog_move(ev.position, bounds, cx));
+                } else if bounds.contains(&ev.position) {
+                    // Mouse hover sin drag: hit-test sobre los body
+                    // glyphs para el tooltip.
+                    entity_m.update(cx, |this, cx| this.on_hover_check(ev.position, bounds, cx));
+                } else {
+                    entity_m.update(cx, |this, cx| {
+                        if this.state.hover.take().is_some() {
+                            cx.notify();
+                        }
+                    });
                 }
-                entity_m.update(cx, |this, cx| this.on_jog_move(ev.position, bounds, cx));
             });
             let entity_u = entity_for_canvas.clone();
             window.on_mouse_event(move |_: &MouseUpEvent, _, _w, cx| {
@@ -561,11 +670,13 @@ fn render_wheel(
                 for g in &layer.glyphs {
                     let (x, y) = polar_to_screen(g.deg, asc, rot_offset, ring);
                     let color = with_alpha(planet_color(palette, &g.symbol), alpha);
-                    let glyph_text = if g.retrograde {
-                        format!("{}ᴿ", planet_unicode(&g.symbol))
-                    } else {
-                        planet_unicode(&g.symbol).into()
-                    };
+                    let mut glyph_text = planet_unicode(&g.symbol).to_string();
+                    if g.retrograde {
+                        glyph_text.push('ᴿ');
+                    }
+                    if let Some(marker) = &g.dignity_marker {
+                        glyph_text.push_str(marker);
+                    }
                     wheel = wheel.child(centered_glyph(
                         cx_center + x,
                         cy_center + y,
@@ -607,6 +718,50 @@ fn render_wheel(
         }
     }
 
+    // Tooltip absoluto sobre el cuerpo hovered. Aparece arriba-derecha
+    // del planeta, con offset pequeño para no taparlo. Texto:
+    // "<unicode> <signo grado>° · Casa N · módulo · retrógrado?"
+    if let Some(hov) = hover {
+        let sign_idx = ((hov.deg / 30.0).floor() as usize) % 12;
+        let sign_name = SIGN_NAMES_ES[sign_idx];
+        let deg_in_sign = hov.deg - (sign_idx as f32) * 30.0;
+        let mut text = format!(
+            "{} {} · {:.1}°",
+            planet_unicode(&hov.symbol),
+            sign_name,
+            deg_in_sign,
+        );
+        if let Some(h) = hov.house {
+            text.push_str(&format!(" · Casa {}", h));
+        }
+        if hov.retrograde {
+            text.push_str(" · ℞");
+        }
+        if let Some(m) = &hov.dignity_marker {
+            text.push_str(&format!(" · {}", m));
+        }
+        if hov.module_id != "natal" {
+            text.push_str(&format!(" · {}", hov.module_id));
+        }
+        let tip_x = (hov.local_x + 14.0).min(WHEEL_SIZE - 220.0).max(8.0);
+        let tip_y = (hov.local_y - 28.0).max(8.0);
+        wheel = wheel.child(
+            div()
+                .absolute()
+                .left(px(tip_x))
+                .top(px(tip_y))
+                .px(px(8.0))
+                .py(px(4.0))
+                .rounded(px(6.0))
+                .bg(theme.bg_panel_alt.clone())
+                .border_1()
+                .border_color(palette.angle_highlight)
+                .text_size(px(11.0))
+                .text_color(theme.fg_text)
+                .child(SharedString::from(text)),
+        );
+    }
+
     // Labels ASC/MC/DESC/IC en el perímetro. Texto pequeño en el
     // margen exterior (radius * 1.05) para que no se monte con los
     // glifos de los signos. Color angle_highlight para que el ojo los
@@ -652,6 +807,34 @@ fn render_wheel(
     } else {
         header
     };
+    // Botón export SVG — pequeño, alineado a la derecha del title.
+    let export_btn = div()
+        .id("tts-canvas-export-svg")
+        .px(px(10.0))
+        .py(px(3.0))
+        .rounded(px(4.0))
+        .bg(theme.bg_button())
+        .hover(|s| s.bg(theme.bg_button_hover()))
+        .border_1()
+        .border_color(theme.border)
+        .text_size(px(10.0))
+        .text_color(theme.fg_text)
+        .child("⬇ SVG")
+        .on_click({
+            let entity_e = entity.clone();
+            move |_: &gpui::ClickEvent, _w, cx: &mut gpui::App| {
+                entity_e.update(cx, |_this, cx| {
+                    cx.emit(CanvasEvent::ExportSvgRequested);
+                });
+            }
+        });
+    let header = div()
+        .flex()
+        .flex_row()
+        .items_center()
+        .gap(px(12.0))
+        .child(header)
+        .child(export_btn);
 
     let offset_label = format_offset(time_offset_minutes);
     let offset_color = if time_offset_minutes == 0 {
@@ -706,6 +889,48 @@ fn render_wheel(
         footer = footer.child(b);
     }
 
+    // Lista textual de aspectos (top 12 por orb). Compacta, en grid
+    // de 3 columnas, fonts pequeños. Solo aparece cuando hay aspectos
+    // computados.
+    if !render.aspect_summary.is_empty() {
+        let mut grid = div()
+            .flex()
+            .flex_row()
+            .flex_wrap()
+            .gap(px(10.0))
+            .max_w(px(WHEEL_SIZE + 80.0))
+            .justify_center();
+        for ap in render.aspect_summary.iter().take(12) {
+            let kind_sym = aspect_unicode(&ap.kind);
+            let line = format!(
+                "{} {} {}  ·  {:.1}°{}",
+                planet_unicode(&ap.from_body),
+                kind_sym,
+                planet_unicode(&ap.to_body),
+                ap.orb_deg,
+                match ap.applying {
+                    Some(true) => " A",
+                    Some(false) => " S",
+                    None => "",
+                }
+            );
+            let prefix = if ap.module_id == "natal" {
+                String::new()
+            } else {
+                format!("[{}] ", ap.module_id)
+            };
+            grid = grid.child(
+                div()
+                    .px(px(6.0))
+                    .py(px(2.0))
+                    .text_size(px(11.0))
+                    .text_color(aspect_color(palette, &ap.kind))
+                    .child(SharedString::from(format!("{}{}", prefix, line))),
+            );
+        }
+        footer = footer.child(grid);
+    }
+
     div()
         .flex()
         .flex_col()
@@ -1289,6 +1514,38 @@ fn sign_unicode(name: &str) -> &'static str {
     }
 }
 
+const SIGN_NAMES_ES: [&str; 12] = [
+    "Aries",
+    "Tauro",
+    "Géminis",
+    "Cáncer",
+    "Leo",
+    "Virgo",
+    "Libra",
+    "Escorpio",
+    "Sagitario",
+    "Capricornio",
+    "Acuario",
+    "Piscis",
+];
+
+fn aspect_unicode(kind: &str) -> &'static str {
+    match kind {
+        "conjunction" => "☌",
+        "opposition" => "☍",
+        "trine" => "△",
+        "square" => "□",
+        "sextile" => "⚹",
+        "quincunx" => "⚻",
+        "semi_sextile" => "⚺",
+        "semi_square" => "∠",
+        "sesquiquadrate" => "⚼",
+        "quintile" => "Q",
+        "biquintile" => "bQ",
+        _ => "·",
+    }
+}
+
 fn planet_unicode(name: &str) -> &'static str {
     match name {
         "sun" => "☉",
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
index ca29fe2..9e5a5a9 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
@@ -17,7 +17,11 @@ use eternal_sky::{Ayanamsha, Body, EphemerisSession, Instant as ESInstant, Obser
 
 use tahuantinsuyu_model::{Chart, HouseSystem, StoredChartConfig, Zodiac};
 
-use crate::{EngineError, Geometry, Glyph, Layer, LayerKind, LineSeg, OverlayMeta, RenderModel};
+use crate::dignity::essential_dignity;
+use crate::{
+    AspectSummary, EngineError, Geometry, Glyph, Layer, LayerKind, LineSeg, OverlayMeta,
+    RenderModel,
+};
 
 // =====================================================================
 // Sesión global cacheada
@@ -259,6 +263,10 @@ pub fn compose(
         })
         .collect();
     let mut render = build_render_model(chart, &natal, &aspects, t0);
+    if natal_options.show_dignities {
+        annotate_dignities(&natal, &mut render);
+    }
+    populate_natal_aspect_summary(&aspects, &mut render);
 
     for req in requests {
         match req {
@@ -346,6 +354,7 @@ fn build_transit_overlay(
             annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
             retrograde: p.longitude_rate_rad_per_day < 0.0,
             house: None,
+            dignity_marker: None,
         })
         .collect();
     render.layers.push(Layer {
@@ -385,6 +394,7 @@ fn build_transit_overlay(
         geometry: Geometry::Lines(cross_lines),
         glyphs: Vec::new(),
     });
+    populate_cross_aspect_summary(&cross, "transit", render);
     Ok(())
 }
 
@@ -412,6 +422,7 @@ fn build_progression_overlay(
             annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
             retrograde: p.longitude_rate_rad_per_day < 0.0,
             house: Some(p.house_number),
+            dignity_marker: None,
         })
         .collect();
     render.layers.push(Layer {
@@ -452,6 +463,7 @@ fn build_progression_overlay(
         geometry: Geometry::Lines(cross_lines),
         glyphs: Vec::new(),
     });
+    populate_cross_aspect_summary(&cross, "progression", render);
     Ok(())
 }
 
@@ -478,6 +490,7 @@ fn build_solar_arc_overlay(
             annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
             retrograde: p.longitude_rate_rad_per_day < 0.0,
             house: Some(p.house_number),
+            dignity_marker: None,
         })
         .collect();
     render.layers.push(Layer {
@@ -517,6 +530,7 @@ fn build_solar_arc_overlay(
         geometry: Geometry::Lines(cross_lines),
         glyphs: Vec::new(),
     });
+    populate_cross_aspect_summary(&cross, "solar_arc", render);
     Ok(())
 }
 
@@ -540,6 +554,7 @@ fn build_synastry_overlay(
             annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
             retrograde: p.longitude_rate_rad_per_day < 0.0,
             house: Some(p.house_number),
+            dignity_marker: None,
         })
         .collect();
     render.layers.push(Layer {
@@ -579,6 +594,7 @@ fn build_synastry_overlay(
         geometry: Geometry::Lines(cross_lines),
         glyphs: Vec::new(),
     });
+    populate_cross_aspect_summary(&cross, "synastry", render);
     Ok(())
 }
 
@@ -644,6 +660,7 @@ fn build_planetary_return_overlay(
             annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
             retrograde: p.longitude_rate_rad_per_day < 0.0,
             house: Some(p.house_number),
+            dignity_marker: None,
         })
         .collect();
     render.layers.push(Layer {
@@ -683,6 +700,7 @@ fn build_planetary_return_overlay(
         geometry: Geometry::Lines(cross_lines),
         glyphs: Vec::new(),
     });
+    populate_cross_aspect_summary(&cross, "planetary_return", render);
     Ok(())
 }
 
@@ -717,6 +735,7 @@ fn build_render_model(
                 annotation: None,
                 retrograde: false,
                 house: None,
+            dignity_marker: None,
             })
             .collect(),
     };
@@ -745,6 +764,7 @@ fn build_render_model(
                 annotation: None,
                 retrograde: false,
                 house: Some((i as u8) + 1),
+                dignity_marker: None,
             })
             .collect(),
     };
@@ -764,6 +784,7 @@ fn build_render_model(
             // depender del wrapper.
             retrograde: p.longitude_rate_rad_per_day < 0.0,
             house: Some(p.house_number),
+            dignity_marker: None,
         })
         .collect();
     let bodies = Layer {
@@ -838,6 +859,7 @@ fn build_render_model(
         imum_coeli_deg,
         layers: vec![sign_dial, houses, bodies, aspects_layer],
         overlays: Vec::new(),
+        aspect_summary: Vec::new(),
     }
 }
 
@@ -869,6 +891,67 @@ fn push_overlay_meta(render: &mut RenderModel, module_id: &str, label: String) {
     });
 }
 
+/// Decora cada Glyph de Bodies (module_id="natal") con su dignity
+/// marker en `glyph.dignity_marker`. Usa `essential_dignity(body, sign)`
+/// — los cuerpos modernos quedan sin marker.
+fn annotate_dignities(natal: &NatalChart, render: &mut RenderModel) {
+    use std::collections::HashMap;
+    let mut by_symbol: HashMap<&'static str, &'static str> = HashMap::new();
+    for p in &natal.placements {
+        let sign_idx = (p.longitude.longitude_deg() / 30.0).floor() as u8 % 12;
+        if let Some(d) = essential_dignity(p.body, sign_idx) {
+            by_symbol.insert(body_symbol(p.body), d.marker());
+        }
+    }
+    for layer in render.layers.iter_mut() {
+        if matches!(layer.kind, LayerKind::Bodies) && layer.module_id == "natal" {
+            for g in layer.glyphs.iter_mut() {
+                if let Some(marker) = by_symbol.get(g.symbol.as_str()) {
+                    g.dignity_marker = Some((*marker).to_string());
+                }
+            }
+        }
+    }
+}
+
+fn populate_natal_aspect_summary(aspects: &[Aspect], render: &mut RenderModel) {
+    for a in aspects {
+        render.aspect_summary.push(AspectSummary {
+            module_id: "natal".into(),
+            from_body: body_symbol(a.a).into(),
+            to_body: body_symbol(a.b).into(),
+            kind: aspect_kind_id(a.kind).into(),
+            orb_deg: a.orb_abs_deg(),
+            applying: Some(a.applying),
+        });
+    }
+    sort_aspect_summary(render);
+}
+
+fn populate_cross_aspect_summary(
+    cross: &[eternal_astrology::SynastryAspect],
+    module_id: &str,
+    render: &mut RenderModel,
+) {
+    for a in cross {
+        render.aspect_summary.push(AspectSummary {
+            module_id: module_id.to_string(),
+            from_body: body_symbol(a.person_a_body).into(),
+            to_body: body_symbol(a.person_b_body).into(),
+            kind: aspect_kind_id(a.kind).into(),
+            orb_deg: a.orb_abs_deg(),
+            applying: None,
+        });
+    }
+    sort_aspect_summary(render);
+}
+
+fn sort_aspect_summary(render: &mut RenderModel) {
+    render
+        .aspect_summary
+        .sort_by(|x, y| x.orb_deg.partial_cmp(&y.orb_deg).unwrap_or(std::cmp::Ordering::Equal));
+}
+
 /// Mapea el orb absoluto a una opacidad — los aspectos más exactos se
 /// pintan más fuerte, los flojos casi se desvanecen.
 fn orb_to_opacity(orb_deg: f64, kind: EAspectKind) -> f32 {
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/dignity.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/dignity.rs
new file mode 100644
index 0000000..066d4c3
--- /dev/null
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/dignity.rs
@@ -0,0 +1,141 @@
+//! Dignidades esenciales clásicas — tabla data-only.
+//!
+//! Cada planeta tradicional tiene cuatro estatus posibles según el
+//! signo en el que cae:
+//!
+//! - **Domicilio** (rulership) — el signo del que es regente.
+//! - **Exaltación** — un signo "huésped" que le da fuerza extra.
+//! - **Exilio** (detriment) — opuesto al domicilio, debilita.
+//! - **Caída** (fall) — opuesto a la exaltación, debilita.
+//!
+//! Esta tabla usa las regencias **clásicas** (Aries=Marte, Escorpio=
+//! Marte, Acuario=Saturno, Piscis=Júpiter) — los planetas modernos
+//! (Urano/Neptuno/Plutón) no tienen regencia clásica por convención.
+//! En una fase futura podemos exponer un toggle "regencias modernas"
+//! que mapee Escorpio→Plutón, Acuario→Urano, Piscis→Neptuno.
+
+use eternal_sky::Body;
+
+/// Status de dignidad esencial de un cuerpo en un signo dado.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Dignity {
+    /// Domicilio. Marker `"+"`.
+    Rulership,
+    /// Exaltación. Marker `"·"`.
+    Exaltation,
+    /// Exilio. Marker `"−"`.
+    Detriment,
+    /// Caída. Marker `"*"`.
+    Fall,
+}
+
+impl Dignity {
+    pub fn marker(self) -> &'static str {
+        match self {
+            Dignity::Rulership => "+",
+            Dignity::Exaltation => "·",
+            Dignity::Detriment => "−",
+            Dignity::Fall => "*",
+        }
+    }
+}
+
+/// Devuelve el status de dignidad de `body` en `sign_index` (0..12,
+/// Aries=0) o `None` si no aplica (sin dignidad / cuerpo moderno sin
+/// regencia clásica).
+pub fn essential_dignity(body: Body, sign_index: u8) -> Option<Dignity> {
+    let sign = sign_index % 12;
+    let opposite = (sign + 6) % 12;
+
+    // Rulership clásico — el "regente" del signo.
+    if rules_classical(body, sign) {
+        return Some(Dignity::Rulership);
+    }
+    // Detriment = el cuerpo gobierna el signo opuesto.
+    if rules_classical(body, opposite) {
+        return Some(Dignity::Detriment);
+    }
+    // Exaltación tabular.
+    if exalts_at(body) == Some(sign) {
+        return Some(Dignity::Exaltation);
+    }
+    // Caída = opuesto a la exaltación.
+    if exalts_at(body) == Some(opposite) {
+        return Some(Dignity::Fall);
+    }
+    None
+}
+
+/// Devuelve true si `body` gobierna `sign` (0=Aries..11=Pisces) en el
+/// esquema clásico de 7 planetas.
+fn rules_classical(body: Body, sign: u8) -> bool {
+    match (body, sign) {
+        // Sol: Leo (4)
+        (Body::Sun, 4) => true,
+        // Luna: Cancer (3)
+        (Body::Moon, 3) => true,
+        // Mercurio: Gemini (2), Virgo (5)
+        (Body::Mercury, 2) | (Body::Mercury, 5) => true,
+        // Venus: Taurus (1), Libra (6)
+        (Body::Venus, 1) | (Body::Venus, 6) => true,
+        // Marte: Aries (0), Scorpio (7)
+        (Body::Mars, 0) | (Body::Mars, 7) => true,
+        // Júpiter: Sagittarius (8), Pisces (11)
+        (Body::Jupiter, 8) | (Body::Jupiter, 11) => true,
+        // Saturno: Capricorn (9), Aquarius (10)
+        (Body::Saturn, 9) | (Body::Saturn, 10) => true,
+        _ => false,
+    }
+}
+
+/// Devuelve el signo (0..12) donde el cuerpo exalta, o `None` si no
+/// tiene exaltación clásica documentada.
+fn exalts_at(body: Body) -> Option<u8> {
+    Some(match body {
+        Body::Sun => 0,       // Aries
+        Body::Moon => 1,      // Taurus
+        Body::Mercury => 5,   // Virgo (algunas tradiciones la ponen acá)
+        Body::Venus => 11,    // Pisces
+        Body::Mars => 9,      // Capricorn
+        Body::Jupiter => 3,   // Cancer
+        Body::Saturn => 6,    // Libra
+        _ => return None,
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn rulership_examples() {
+        assert_eq!(essential_dignity(Body::Sun, 4), Some(Dignity::Rulership)); // Sol en Leo
+        assert_eq!(essential_dignity(Body::Moon, 3), Some(Dignity::Rulership)); // Luna en Cancer
+        assert_eq!(essential_dignity(Body::Mars, 7), Some(Dignity::Rulership)); // Marte en Scorpio
+    }
+
+    #[test]
+    fn detriment_examples() {
+        assert_eq!(essential_dignity(Body::Sun, 10), Some(Dignity::Detriment)); // Sol en Acuario
+        assert_eq!(essential_dignity(Body::Moon, 9), Some(Dignity::Detriment)); // Luna en Capricornio
+    }
+
+    #[test]
+    fn exaltation_examples() {
+        assert_eq!(essential_dignity(Body::Sun, 0), Some(Dignity::Exaltation)); // Sol en Aries
+        assert_eq!(essential_dignity(Body::Saturn, 6), Some(Dignity::Exaltation)); // Saturno en Libra
+    }
+
+    #[test]
+    fn fall_examples() {
+        assert_eq!(essential_dignity(Body::Sun, 6), Some(Dignity::Fall)); // Sol en Libra
+        assert_eq!(essential_dignity(Body::Saturn, 0), Some(Dignity::Fall)); // Saturno en Aries
+    }
+
+    #[test]
+    fn modern_planets_no_classical_dignity() {
+        assert_eq!(essential_dignity(Body::Uranus, 10), None);
+        assert_eq!(essential_dignity(Body::Neptune, 11), None);
+        assert_eq!(essential_dignity(Body::Pluto, 7), None);
+    }
+}
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
index e47fdc0..823127f 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
@@ -36,6 +36,10 @@ pub use tahuantinsuyu_model::{Chart, ChartId, ChartKind};
 
 #[cfg(feature = "eternal-bridge")]
 mod bridge;
+#[cfg(feature = "eternal-bridge")]
+mod dignity;
+#[cfg(feature = "eternal-bridge")]
+pub mod svg_export;
 
 // =====================================================================
 // RenderModel — lo que el canvas necesita pintar
@@ -66,6 +70,11 @@ pub struct RenderModel {
     /// la pinta como badges en el footer.
     #[serde(default)]
     pub overlays: Vec<OverlayMeta>,
+    /// Lista paralela a las LineSeg de aspectos — uno por aspecto
+    /// natal o cross. Ordenado por `orb_deg` ascendente (los más
+    /// cerrados primero). La UI lo usa para la lista textual.
+    #[serde(default)]
+    pub aspect_summary: Vec<AspectSummary>,
 }
 
 /// Etiqueta legible de un overlay para el footer del canvas. La engine
@@ -78,6 +87,26 @@ pub struct OverlayMeta {
     pub label: String,
 }
 
+/// Resumen textual de un aspecto para listas legibles. La engine lo
+/// emite en paralelo con las `LineSeg` de la capa de aspectos, así
+/// el canvas no tiene que re-derivar nombres de cuerpos desde grados.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct AspectSummary {
+    /// Module al que pertenece — "natal", "transit", "synastry",
+    /// "progression", "solar_arc", "planetary_return".
+    pub module_id: String,
+    /// Identificador agnóstico del cuerpo "a" — "sun", "moon", etc.
+    pub from_body: String,
+    pub to_body: String,
+    /// Identificador del aspecto — "conjunction", "trine", etc.
+    pub kind: String,
+    pub orb_deg: f64,
+    /// `Some(true)` = applying, `Some(false)` = separating. `None` para
+    /// cross-aspects (sinastría/return) donde no se computa.
+    #[serde(default)]
+    pub applying: Option<bool>,
+}
+
 #[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct Layer {
     pub module_id: String,
@@ -135,7 +164,7 @@ pub struct PointMark {
     pub tag: String,
 }
 
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
 pub struct Glyph {
     /// Grado eclíptico [0, 360).
     pub deg: f32,
@@ -148,6 +177,11 @@ pub struct Glyph {
     pub retrograde: bool,
     #[serde(default)]
     pub house: Option<u8>,
+    /// Marker de dignidad esencial, set solo cuando
+    /// `NatalOptions::show_dignities` está activo: `"+"` (domicilio),
+    /// `"·"` (exaltación), `"−"` (exilio), `"*"` (caída).
+    #[serde(default)]
+    pub dignity_marker: Option<String>,
 }
 
 // =====================================================================
@@ -237,6 +271,10 @@ pub struct NatalOptions {
     /// Multiplicador uniforme sobre los orbes default. `1.0` = orbes
     /// modern_western; `0.5` = tight; `2.0` = wide.
     pub orb_multiplier: f64,
+    /// Si `true`, anota cada cuerpo natal con su dignidad esencial
+    /// (domicilio +, exaltación ·, exilio −, caída *). El canvas lo
+    /// renderea como sufijo del glifo.
+    pub show_dignities: bool,
 }
 
 impl Default for NatalOptions {
@@ -245,6 +283,7 @@ impl Default for NatalOptions {
             show_majors: true,
             show_minors: false,
             orb_multiplier: 1.0,
+            show_dignities: false,
         }
     }
 }
@@ -316,6 +355,7 @@ pub fn compute_mock(chart: &Chart) -> RenderModel {
                 annotation: None,
                 retrograde: false,
                 house: None,
+            dignity_marker: None,
             })
             .collect(),
     };
@@ -332,6 +372,7 @@ pub fn compute_mock(chart: &Chart) -> RenderModel {
         imum_coeli_deg: 90.0,
         layers: vec![sign_dial],
         overlays: Vec::new(),
+        aspect_summary: Vec::new(),
     }
 }
 
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/svg_export.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/svg_export.rs
new file mode 100644
index 0000000..1cc49fa
--- /dev/null
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/svg_export.rs
@@ -0,0 +1,319 @@
+//! Export del `RenderModel` a SVG.
+//!
+//! Genera un documento SVG standalone con la misma geometría que pinta
+//! el canvas: anillos zodiacales, cusps, planetas, aspectos. El
+//! resultado es escalable (imprimible a cualquier tamaño) y no requiere
+//! la app GPUI para verse — cualquier visor de SVG sirve.
+//!
+//! Convención de coordenadas idéntica al canvas:
+//! `screen_angle_deg = 180 - (longitude - ascendant)` con +y para abajo.
+
+use std::f64::consts::PI;
+use std::fmt::Write;
+
+use crate::{Geometry, LayerKind, RenderModel};
+
+/// Dimensiones default del viewport. Aspect ratio cuadrada.
+const VIEWBOX: f64 = 800.0;
+const MARGIN: f64 = 40.0;
+
+/// Radios normalizados — espejan los de `tahuantinsuyu-canvas`.
+const R_SIGN_OUTER: f64 = 1.00;
+const R_SIGN_INNER: f64 = 0.88;
+const R_TRANSITS: f64 = 0.82;
+const R_HOUSES_OUTER: f64 = 0.78;
+const R_HOUSES_INNER: f64 = 0.66;
+const R_BODIES: f64 = 0.58;
+const R_PROGRESSION: f64 = 0.48;
+const R_SOLAR_ARC: f64 = 0.40;
+const R_ASPECTS: f64 = 0.32;
+
+/// Convierte el `RenderModel` a un documento SVG completo.
+pub fn render_to_svg(render: &RenderModel) -> String {
+    let mut out = String::with_capacity(8192);
+    let r_outer = (VIEWBOX - MARGIN * 2.0) / 2.0;
+    let cx = VIEWBOX / 2.0;
+    let cy = VIEWBOX / 2.0;
+    let asc = render.ascendant_deg as f64;
+
+    writeln!(
+        out,
+        r#"<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {0} {1}" width="{0}" height="{1}" font-family="serif" text-anchor="middle" dominant-baseline="central">"#,
+        VIEWBOX, VIEWBOX
+    )
+    .unwrap();
+
+    // Fondo + título.
+    writeln!(
+        out,
+        r##"  <rect x="0" y="0" width="{0}" height="{0}" fill="#fdfaf3"/>
+  <text x="{cx}" y="20" font-size="14" fill="#2a2620">{title}</text>"##,
+        VIEWBOX,
+        cx = cx,
+        title = escape_xml(&render.title)
+    )
+    .unwrap();
+
+    // Anillos base.
+    for r in [R_SIGN_OUTER, R_SIGN_INNER, R_HOUSES_OUTER, R_HOUSES_INNER] {
+        writeln!(
+            out,
+            r##"  <circle cx="{cx}" cy="{cy}" r="{r}" fill="none" stroke="#a89572" stroke-width="0.6"/>"##,
+            r = r * r_outer
+        )
+        .unwrap();
+    }
+
+    // Cusps del zodíaco cada 30°.
+    for i in 0..12 {
+        let lon = (i as f64) * 30.0;
+        let (x1, y1) = polar(lon, asc, R_SIGN_INNER * r_outer, cx, cy);
+        let (x2, y2) = polar(lon, asc, R_SIGN_OUTER * r_outer, cx, cy);
+        writeln!(
+            out,
+            r##"  <line x1="{x1:.2}" y1="{y1:.2}" x2="{x2:.2}" y2="{y2:.2}" stroke="#a89572" stroke-width="0.5"/>"##,
+        )
+        .unwrap();
+    }
+
+    // Glifos de signos a media-altura del dial.
+    let sign_mid = (R_SIGN_OUTER + R_SIGN_INNER) / 2.0;
+    for layer in &render.layers {
+        if matches!(layer.kind, LayerKind::SignDial) {
+            for g in &layer.glyphs {
+                let (x, y) = polar(g.deg as f64, asc, sign_mid * r_outer, cx, cy);
+                writeln!(
+                    out,
+                    r##"  <text x="{x:.2}" y="{y:.2}" font-size="16" fill="#5a4830">{}</text>"##,
+                    sign_unicode(&g.symbol)
+                )
+                .unwrap();
+            }
+        }
+    }
+
+    // Cusps de casas + énfasis Asc/IC/Desc/MC.
+    for layer in &render.layers {
+        if matches!(layer.kind, LayerKind::Houses) {
+            if let 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, w) = if is_angle {
+                        ("#b8862e", 1.6)
+                    } else {
+                        ("#9b8460", 0.5)
+                    };
+                    let (x1, y1) =
+                        polar(*c as f64, asc, R_HOUSES_INNER * r_outer, cx, cy);
+                    let (x2, y2) =
+                        polar(*c as f64, asc, R_HOUSES_OUTER * r_outer, cx, cy);
+                    writeln!(
+                        out,
+                        r##"  <line x1="{x1:.2}" y1="{y1:.2}" x2="{x2:.2}" y2="{y2:.2}" stroke="{color}" stroke-width="{w}"/>"##,
+                    )
+                    .unwrap();
+                }
+            }
+        }
+    }
+
+    // Líneas de aspectos. Para natal usamos un solo ring; para
+    // cross-aspects (transit/synastry/progression/solar_arc/...) los
+    // extremos van en rings distintos según el `module_id`.
+    for layer in &render.layers {
+        if !matches!(layer.kind, LayerKind::Aspects) {
+            continue;
+        }
+        if let Geometry::Lines(segs) = &layer.geometry {
+            let (r_from, r_to) = aspect_radii(&layer.module_id);
+            for seg in segs {
+                let color = aspect_color_hex(&seg.kind);
+                let (x1, y1) = polar(seg.from_deg as f64, asc, r_from * r_outer, cx, cy);
+                let (x2, y2) = polar(seg.to_deg as f64, asc, r_to * r_outer, cx, cy);
+                writeln!(
+                    out,
+                    r##"  <line x1="{x1:.2}" y1="{y1:.2}" x2="{x2:.2}" y2="{y2:.2}" stroke="{color}" stroke-width="0.6" stroke-opacity="{op:.2}"/>"##,
+                    op = seg.opacity
+                )
+                .unwrap();
+            }
+        }
+    }
+
+    // Glifos planetarios (natal + overlays). Cada uno en su ring.
+    for layer in &render.layers {
+        if !matches!(layer.kind, LayerKind::Bodies | LayerKind::Outer) {
+            continue;
+        }
+        let ring = body_ring_radius(&layer.module_id);
+        let size = if layer.module_id == "natal" { 18 } else { 14 };
+        for g in &layer.glyphs {
+            let (x, y) = polar(g.deg as f64, asc, ring * r_outer, cx, cy);
+            let glyph = planet_unicode(&g.symbol);
+            let suffix = match (g.retrograde, g.dignity_marker.as_deref()) {
+                (true, Some(m)) => format!("ᴿ{}", m),
+                (true, None) => "ᴿ".into(),
+                (false, Some(m)) => m.to_string(),
+                (false, None) => String::new(),
+            };
+            writeln!(
+                out,
+                r##"  <text x="{x:.2}" y="{y:.2}" font-size="{size}" fill="#1f1812">{glyph}{suffix}</text>"##
+            )
+            .unwrap();
+        }
+    }
+
+    // Etiquetas ASC / MC / DESC / IC en el perímetro.
+    for (deg, label) in [
+        (asc, "ASC"),
+        (render.midheaven_deg as f64, "MC"),
+        (render.descendant_deg as f64, "DESC"),
+        (render.imum_coeli_deg as f64, "IC"),
+    ] {
+        let (x, y) = polar(deg, asc, 1.06 * r_outer, cx, cy);
+        writeln!(
+            out,
+            r##"  <text x="{x:.2}" y="{y:.2}" font-size="10" fill="#b8862e">{label}</text>"##
+        )
+        .unwrap();
+    }
+
+    writeln!(out, "</svg>").unwrap();
+    out
+}
+
+fn polar(longitude_deg: f64, ascendant_deg: f64, radius: f64, cx: f64, cy: f64) -> (f64, f64) {
+    let deg = 180.0 - (longitude_deg - ascendant_deg);
+    let rad = deg * PI / 180.0;
+    (cx + radius * rad.cos(), cy + radius * rad.sin())
+}
+
+fn aspect_radii(module_id: &str) -> (f64, f64) {
+    if crate::OUTER_RING_MODULES.contains(&module_id) {
+        return (R_BODIES, R_TRANSITS);
+    }
+    match module_id {
+        "progression" => (R_BODIES, R_PROGRESSION),
+        "solar_arc" => (R_BODIES, R_SOLAR_ARC),
+        _ => (R_ASPECTS, R_ASPECTS),
+    }
+}
+
+fn body_ring_radius(module_id: &str) -> f64 {
+    if crate::OUTER_RING_MODULES.contains(&module_id) {
+        return R_TRANSITS;
+    }
+    match module_id {
+        "progression" => R_PROGRESSION,
+        "solar_arc" => R_SOLAR_ARC,
+        _ => R_BODIES,
+    }
+}
+
+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" => "⚸",
+        "ceres" => "⚳",
+        "pallas" => "⚴",
+        "juno" => "⚵",
+        "vesta" => "⚶",
+        _ => "•",
+    }
+}
+
+fn aspect_color_hex(kind: &str) -> &'static str {
+    match kind {
+        "conjunction" => "#b8862e",
+        "opposition" => "#a64a8a",
+        "trine" => "#3f7d57",
+        "square" => "#c64b2a",
+        "sextile" => "#3a6db5",
+        _ => "#8a7660",
+    }
+}
+
+fn escape_xml(s: &str) -> String {
+    s.replace('&', "&amp;")
+        .replace('<', "&lt;")
+        .replace('>', "&gt;")
+        .replace('"', "&quot;")
+        .replace('\'', "&apos;")
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::{compute_mock, ChartKind};
+    use tahuantinsuyu_model::{Chart, ContactId, StoredBirthData, StoredChartConfig};
+
+    fn sample_chart() -> Chart {
+        Chart {
+            id: tahuantinsuyu_model::ChartId::new(),
+            contact_id: ContactId::new(),
+            kind: ChartKind::Natal,
+            label: "Test".into(),
+            birth_data: StoredBirthData {
+                year: 1987,
+                month: 3,
+                day: 14,
+                hour: 5,
+                minute: 22,
+                second: 0.0,
+                tz_offset_minutes: -240,
+                latitude_deg: 10.0,
+                longitude_deg: -66.0,
+                altitude_m: 0.0,
+                time_certainty: Default::default(),
+                subject_name: None,
+                birthplace_label: None,
+            },
+            config: StoredChartConfig::default(),
+            related_chart_id: None,
+            created_at_ms: 0,
+        }
+    }
+
+    #[test]
+    fn svg_well_formed_minimal() {
+        let render = compute_mock(&sample_chart());
+        let svg = render_to_svg(&render);
+        assert!(svg.starts_with("<?xml"));
+        assert!(svg.contains("<svg"));
+        assert!(svg.ends_with("</svg>\n"));
+        // Debe traer al menos un círculo de los rings base.
+        assert!(svg.contains("<circle "));
+    }
+}
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
index 9a8320f..04050b3 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
@@ -239,6 +239,12 @@ pub mod natal {
                     step: 0.25,
                     default: 1.0,
                 },
+                Control::Toggle {
+                    key: "show_dignities".into(),
+                    label: "Dignidades esenciales (+ · − *)".into(),
+                    default: false,
+                    hotkey: None,
+                },
                 Control::Slider {
                     key: "harmonic".into(),
                     label: "Armónico".into(),