gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198635 Sutichaykuna

feat(tahuantinsuyu): GR dual-ring + topo ascensional pegado al dial + coords

Dos cambios mayores que cierran el sistema GR/ascensional:

1. Reordenamiento radial — la capa ascensional (topocéntrico
   Polich-Page) se ubica AHORA pegada al sign dial, y la
   geocéntrica clásica queda más adentro. Layout outer→inner:
   - sign_dial (1.00 → 0.88)
   - topo_houses_outer (0.875) / topo_houses_inner (0.79)  ← P-P pegadas al zodiaco
   - topocentric (0.755)                                    ← planetas topo con coords
   - transits (0.71)
   - houses_outer (0.66) / houses_inner (0.54)             ← Placidus geo
   - midpoints (0.50) / bodies (0.47) / bodies_inner (0.44) ← natal geo con coords
   - pd_direct (0.495) / pd_converse (0.425)               ← dual-ring GR
   - aspects (0.41) / progression (0.36) / solar_arc (0.30)

   Topocéntrico default ON (era OFF en la fase previa).
   Coord labels ahora se pintan también en planetas topocéntricos
   (label hacia adentro, no afuera, para no chocar con casas P-P).

2. Sistema GR Direcciones Primarias (dual-ring):
   - Nuevo `PipelineRequest::PrimaryDirections { target_age_years }`.
   - `build_primary_directions_overlay` proyecta cada cuerpo natal
     con `directed_longitude` (key Naibod) en dos direcciones —
     directa y conversa — y emite dos Layer Bodies con
     `module_id` "pd_direct" / "pd_converse".
   - Canvas: nuevos `pd_direct` y `pd_converse` en Radii; en el
     render de Bodies disco más chico y alpha 0.80. Los dos anillos
     se marcan con punteado fino que "abraza" el cinturón natal
     por afuera y por adentro — el natal queda en el centro.
   - Nuevo `PrimaryDirectionsModule` con toggle + slider de edad
     (0..120, step 0.05a). Activable desde el panel.

Tests: 6 shell + 5 coord siguen verdes; el motor matemático
(eternal-astrology directed_longitude) y house system Polich-Page
están testeados desde el commit `e385ab2` en eternal.

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

7eb620aa170f825b5dec6ff454baac07c48bd9f1 · ruwaq sergio · (5 killa ñawpaq) · tayta 1d49b9ff8 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 20

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/lib.rs

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

5 qillqakuna · +323 −84

diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs
index ca47d5f..4f79ab1 100644
--- a/crates/apps/tahuantinsuyu/src/shell.rs
+++ b/crates/apps/tahuantinsuyu/src/shell.rs
@@ -550,6 +550,12 @@ impl Shell {
         if module_enabled(&self.module_configs, "topocentric") {
             requests.push(PipelineRequest::Topocentric);
         }
+        if module_enabled(&self.module_configs, "primary_directions") {
+            let age = self.module_age_or_current("primary_directions");
+            requests.push(PipelineRequest::PrimaryDirections {
+                target_age_years: age,
+            });
+        }
         if module_enabled(&self.module_configs, "composite") {
             if let Some(partner) = self.resolve_composite_partner() {
                 requests.push(PipelineRequest::Composite {
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
index e1c3f54..e61d532 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
@@ -1115,22 +1115,25 @@ fn render_wheel(
             if matches!(layer.kind, LayerKind::Bodies) {
                 let is_natal = layer.module_id == "natal";
                 let is_topo = layer.module_id == "topocentric";
+                let is_pd_direct = layer.module_id == "pd_direct";
+                let is_pd_converse = layer.module_id == "pd_converse";
+                let is_pd = is_pd_direct || is_pd_converse;
                 let ring = radii.body_ring(&layer.module_id);
                 let alpha = if is_natal {
                     1.0
                 } else if is_topo {
                     0.75
+                } else if is_pd {
+                    0.80
                 } else {
                     0.88
                 };
-                // Topocéntrico va con disco un poco más chico que el
-                // natal, y con desaturación implícita en `alpha`. El
-                // shift respecto al natal es lo que el ojo lee, no el
-                // tamaño individual.
                 let font_size = (if is_natal {
                     18.0
                 } else if is_topo {
                     15.0
+                } else if is_pd {
+                    13.0
                 } else {
                     14.0
                 }) * s;
@@ -1138,6 +1141,8 @@ fn render_wheel(
                     26.0
                 } else if is_topo {
                     22.0
+                } else if is_pd {
+                    20.0
                 } else {
                     22.0
                 }) * s;
@@ -1163,23 +1168,31 @@ fn render_wheel(
                     ));
 
                     // Coord label: grado dentro del signo + glyph del
-                    // signo, pintado justo afuera del disco del
-                    // planeta (radialmente). Sólo en natal (los
-                    // overlays ya cargan info en su badge / tooltip).
-                    if show_coords && is_natal {
+                    // signo, pintado afuera del disco del planeta
+                    // (radialmente). Se pinta para el natal (afuera)
+                    // y para el topocéntrico (más afuera aún, hacia
+                    // el sign dial) — los dos sistemas conviven con
+                    // sus coords. Otros overlays (progression, solar
+                    // arc) usan badges en el footer.
+                    if show_coords && (is_natal || is_topo) {
                         let coord = format_coord_compact(g.deg);
-                        let label_r = ring + disk_size * 0.7;
+                        // Topo: label hacia ADENTRO (entre planeta y
+                        // casas P-P arriba); natal: label hacia
+                        // AFUERA (entre planeta y casas Placidus).
+                        let label_r = if is_topo {
+                            ring - disk_size * 0.7
+                        } else {
+                            ring + disk_size * 0.7
+                        };
                         let (lx, ly) = polar_to_screen(g.deg, asc, rot_offset, label_r);
-                        wheel = wheel.child(
-                            coord_label(
-                                cx_center + lx,
-                                cy_center + ly,
-                                coord.into(),
-                                theme.fg_muted,
-                                halo_bg,
-                                9.5 * s,
-                            ),
-                        );
+                        wheel = wheel.child(coord_label(
+                            cx_center + lx,
+                            cy_center + ly,
+                            coord.into(),
+                            theme.fg_muted,
+                            halo_bg,
+                            9.5 * s,
+                        ));
                     }
                 }
             }
@@ -1604,27 +1617,40 @@ fn format_offset(minutes: i64) -> String {
 
 #[derive(Clone, Copy)]
 struct Radii {
+    // Layout outer→inner. La capa **ascensional** (sistema topocéntrico
+    // Polich-Page) va pegada al sign dial — su lógica nace del eje
+    // ASC/MC y de la ascensión recta del observador, conceptualmente
+    // hermana del zodiaco. La capa **geocéntrica** clásica
+    // (casas Placidus + planetas tropicales sin paralaje) vive más
+    // adentro, alrededor del cinturón de planetas natales.
     sign_outer: f32,
     sign_inner: f32,
+    /// Anillo exterior de las casas Polich-Page (topocéntricas), pegado
+    /// al sign dial. Los cusps llegan hasta el `sign_inner`.
+    topo_houses_outer: f32,
+    topo_houses_inner: f32,
+    /// Carril de planetas topocéntricos — apenas dentro del anillo
+    /// de casas P-P. Lleva sus propios coord labels.
+    topocentric: f32,
     /// Anillo de glifos de tránsito (cuando el overlay está activo).
     transits: f32,
+    /// Casas geocéntricas (Placidus default) — más adentro que las
+    /// topocéntricas, claramente diferenciables.
     houses_outer: f32,
     houses_inner: f32,
-    /// Anillo de midpoints — entre bodies natales y houses_inner.
+    /// Anillo de midpoints — entre cuerpos geocéntricos y `houses_inner`.
     midpoints: f32,
-    /// Anillo principal de cuerpos natales — donde se posan los
-    /// glyphs. Junto con `bodies_inner` forman el "cinturón" de los
-    /// planetas (doble línea visual).
+    /// Anillo principal de cuerpos natales geocéntricos.
     bodies: f32,
-    /// Borde interior del cinturón de planetas. Marca dónde "termina"
-    /// la zona de cuerpos y empieza la zona de aspectos.
+    /// Borde interior del cinturón de planetas geocéntricos.
     bodies_inner: f32,
-    /// Cuerpos topocéntricos (capa "ascensional") — un poco hacia
-    /// adentro de `bodies` para que un mismo planeta se vea como
-    /// "doble glyph": natal afuera, topocéntrico justo dentro. En
-    /// Luna la separación angular es visible (~1°); en exteriores
-    /// los dos glyphs se superponen casi exactamente.
-    topocentric: f32,
+    /// Direcciones Primarias DIRECTAS (Sistema GR): ring exterior del
+    /// "abrazo" GR — justo afuera del cinturón natal.
+    pd_direct: f32,
+    /// Direcciones Primarias CONVERSAS (Sistema GR): ring interior —
+    /// el cinturón natal queda entre `pd_direct` (afuera) y
+    /// `pd_converse` (dentro), formando el dual-ring de rectificación.
+    pd_converse: f32,
     /// Anillo interno con cuerpos progresados (overlay opcional).
     progression: f32,
     /// Anillo más interno con cuerpos dirigidos por Solar Arc.
@@ -1632,9 +1658,7 @@ struct Radii {
     /// Anillo de carta compuesta (midpoint Davison) con un partner.
     composite: f32,
     /// Círculo donde anclan las líneas de aspecto entre cuerpos
-    /// natales. Justo dentro del cinturón de planetas, no en el
-    /// centro — así las líneas conectan cuerpos cercanos al ring
-    /// donde se ven, no atraviesan toda la rueda.
+    /// natales. Justo dentro del cinturón de planetas.
     aspects: f32,
 }
 
@@ -1643,28 +1667,36 @@ impl Radii {
         Self {
             sign_outer: r,
             sign_inner: r * 0.88,
-            transits: r * 0.82,
-            houses_outer: r * 0.78,
-            houses_inner: r * 0.66,
-            midpoints: r * 0.62,
-            bodies: r * 0.60,
-            // bodies_inner cerca de bodies — los dos anillos juntos
-            // forman un "carril" estrecho que delimita la franja de
-            // planetas, no dos líneas separadas que confunden.
-            bodies_inner: r * 0.57,
-            // Topocéntrico justo bajo el carril natal: los dos
-            // glyphs comparten ancho visual, el shift relativo
-            // (Luna en particular) se lee como "el natal apunta a
-            // este grado, el topo a este otro".
-            topocentric: r * 0.555,
-            // aspects justo bajo el carril de cuerpos. Las líneas
-            // de aspecto entran a este radio, pero el círculo en sí
-            // no se pinta — son las líneas las que importan, no
-            // un anillo extra que sume ruido.
-            aspects: r * 0.54,
-            progression: r * 0.46,
-            solar_arc: r * 0.38,
-            composite: r * 0.30,
+            // Ascensional (Polich-Page): pegado al sign dial.
+            topo_houses_outer: r * 0.875,
+            topo_houses_inner: r * 0.79,
+            // Carril topocéntrico de planetas: justo dentro de las
+            // casas P-P. Lleva sus coord labels al borde interior.
+            topocentric: r * 0.755,
+            // Tránsitos: ring intermedio entre las dos coronas (topo y
+            // geo), apenas debajo del topocéntrico.
+            transits: r * 0.71,
+            // Capa geocéntrica clásica más adentro — claramente
+            // separada del bloque ascensional.
+            houses_outer: r * 0.66,
+            houses_inner: r * 0.54,
+            midpoints: r * 0.50,
+            bodies: r * 0.47,
+            bodies_inner: r * 0.44,
+            // Dual-ring GR — abraza el cinturón natal por afuera y por
+            // adentro. Para Saturno los dos rings caen casi en la misma
+            // posición angular (poca rotación diurna afecta su lon en
+            // años humanos); para luminarias los rings divergen
+            // visiblemente y leen "directa rumbo a este grado, conversa
+            // hacia este otro".
+            pd_direct: r * 0.495,
+            pd_converse: r * 0.425,
+            // aspects justo bajo el carril natal — las líneas entran
+            // a este radio sin pintar el círculo (sería ruido extra).
+            aspects: r * 0.41,
+            progression: r * 0.36,
+            solar_arc: r * 0.30,
+            composite: r * 0.24,
         }
     }
 
@@ -1676,6 +1708,8 @@ impl Radii {
             "composite" => self.composite,
             "midpoints" => self.midpoints,
             "topocentric" => self.topocentric,
+            "pd_direct" => self.pd_direct,
+            "pd_converse" => self.pd_converse,
             _ => self.bodies,
         }
     }
@@ -1762,15 +1796,31 @@ fn paint_wheel(
         let house_color = with_alpha(house_base, 0.85);
         stroke_circle_3d(window, cx, cy, radii.houses_outer, 1.1, house_color, theme);
         stroke_circle_3d(window, cx, cy, radii.houses_inner, 1.1, house_color, theme);
+        // Si hay capa topocéntrica activa, pintar también sus dos
+        // anillos (con stroke más sutil que el geocéntrico, para que
+        // se lea como "sistema ascensional" sin competir).
+        if layers
+            .iter()
+            .any(|l| matches!(l.kind, LayerKind::Houses) && l.module_id == "topocentric")
+        {
+            let topo_color = with_alpha(house_base, 0.55);
+            stroke_circle(window, cx, cy, radii.topo_houses_outer, 0.8, topo_color);
+            stroke_circle(window, cx, cy, radii.topo_houses_inner, 0.8, topo_color);
+        }
 
         for layer in layers {
             if matches!(layer.kind, LayerKind::Houses) {
                 let is_topo = layer.module_id == "topocentric";
+                let (r_in, r_out) = if is_topo {
+                    (radii.topo_houses_inner, radii.topo_houses_outer)
+                } else {
+                    (radii.houses_inner, radii.houses_outer)
+                };
                 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 = if is_topo {
-                            with_alpha(house_base, 0.55)
+                            with_alpha(house_base, 0.60)
                         } else if is_angle {
                             palette.angle_highlight
                         } else {
@@ -1779,27 +1829,44 @@ fn paint_wheel(
                         let width = if is_angle && !is_topo { 2.0 } else { 0.8 };
                         if is_topo {
                             // Topocéntrico: cusp como línea punteada
-                            // en su propio anillo (un poco más
-                            // adentro que las casas geocéntricas) →
-                            // se distingue como sistema alternativo.
-                            let (xi, yi) = polar_to_screen(
-                                *c,
-                                ascendant_deg,
-                                rot_offset_deg,
-                                radii.houses_inner - 4.0,
-                            );
-                            let (xo, yo) = polar_to_screen(
-                                *c,
-                                ascendant_deg,
-                                rot_offset_deg,
-                                radii.houses_inner - 28.0,
-                            );
+                            // en su propio anillo cercano al sign
+                            // dial — se distingue del Placidus
+                            // geocéntrico por el dash pattern y la
+                            // ubicación más exterior.
                             paint_segment(
                                 window,
-                                cx + xi,
-                                cy + yi,
-                                cx + xo,
-                                cy + yo,
+                                cx
+                                    + polar_to_screen(
+                                        *c,
+                                        ascendant_deg,
+                                        rot_offset_deg,
+                                        r_in,
+                                    )
+                                    .0,
+                                cy
+                                    + polar_to_screen(
+                                        *c,
+                                        ascendant_deg,
+                                        rot_offset_deg,
+                                        r_in,
+                                    )
+                                    .1,
+                                cx
+                                    + polar_to_screen(
+                                        *c,
+                                        ascendant_deg,
+                                        rot_offset_deg,
+                                        r_out,
+                                    )
+                                    .0,
+                                cy
+                                    + polar_to_screen(
+                                        *c,
+                                        ascendant_deg,
+                                        rot_offset_deg,
+                                        r_out,
+                                    )
+                                    .1,
                                 color,
                                 Some((3.0, 2.5)),
                                 1.0,
@@ -1812,8 +1879,8 @@ fn paint_wheel(
                                 *c,
                                 ascendant_deg,
                                 rot_offset_deg,
-                                radii.houses_inner,
-                                radii.houses_outer,
+                                r_in,
+                                r_out,
                                 color,
                                 width,
                             );
@@ -1866,6 +1933,35 @@ fn paint_wheel(
             0.6,
             with_alpha(palette.dial_ring, 0.35),
         );
+        // GR dual-ring: si las capas de direcciones primarias están
+        // presentes, marcar sus anillos para que el visual lea como
+        // "abrazo" del cinturón natal. La directa va punteada,
+        // la conversa también — la diferencia entre las dos es la
+        // ubicación radial (afuera vs adentro del cinturón natal).
+        let has_pd = layers.iter().any(|l| {
+            matches!(l.kind, LayerKind::Bodies)
+                && (l.module_id == "pd_direct" || l.module_id == "pd_converse")
+        });
+        if has_pd {
+            let pd_color = with_alpha(palette.angle_highlight, 0.50);
+            for r in [radii.pd_direct, radii.pd_converse] {
+                // Pintamos el anillo como tramo punteado fino: 24
+                // segmentos cortos a lo largo del círculo.
+                let steps = 96;
+                for i in 0..steps {
+                    if i % 2 != 0 {
+                        continue;
+                    }
+                    let a0 = (i as f32) / (steps as f32) * std::f32::consts::TAU;
+                    let a1 = ((i + 1) as f32) / (steps as f32) * std::f32::consts::TAU;
+                    let x0 = cx + r * a0.cos();
+                    let y0 = cy + r * a0.sin();
+                    let x1 = cx + r * a1.cos();
+                    let y1 = cy + r * a1.sin();
+                    paint_segment(window, x0, y0, x1, y1, pd_color, None, 0.6);
+                }
+            }
+        }
     }
 
     // 3. Aspectos. Cada module_id usa su par de radios — natal-natal
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
index ee53387..acde26f 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
@@ -9,10 +9,11 @@ use std::sync::{Arc, OnceLock};
 use std::time::Instant;
 
 use eternal_astrology::{
-    all_lots, composite, find_aspects, find_synastry_aspects, next_return, secondary_progression,
-    solar_arc_true, topocentric_ecliptic, Aspect, AspectKind as EAspectKind, BirthData, BodySet,
-    ChartConfig, HouseSystem as EHouseSystem, Houses as EHouses, NatalChart, OrbTable,
-    Zodiac as EZodiac,
+    all_lots, composite, directed_longitude, find_aspects, find_synastry_aspects, next_return,
+    primary_direction::PrimaryDirection, secondary_progression, solar_arc_true, topocentric_ecliptic,
+    Aspect, AspectKind as EAspectKind, BirthData, BodySet, ChartConfig,
+    DirectionKey as EDirectionKey, HouseSystem as EHouseSystem, Houses as EHouses, NatalChart,
+    OrbTable, Zodiac as EZodiac,
 };
 use eternal_sky::{Ayanamsha, Body, EphemerisSession, Instant as ESInstant, Observer, SessionConfig};
 
@@ -389,6 +390,14 @@ pub fn compose(
                     "Topocéntrico (Polich-Page)".into(),
                 );
             }
+            crate::PipelineRequest::PrimaryDirections { target_age_years } => {
+                build_primary_directions_overlay(&natal, *target_age_years, &mut render);
+                push_overlay_meta(
+                    &mut render,
+                    "primary_directions",
+                    format!("GR Direcciones · {:.1}a", target_age_years),
+                );
+            }
         }
     }
 
@@ -558,6 +567,69 @@ fn build_topocentric_overlay(
     Ok(())
 }
 
+/// GR dual-ring de Direcciones Primarias: a la edad pedida, cada
+/// cuerpo natal se proyecta dos veces — directa (rotación diurna
+/// forward, anillo afuera) y conversa (rotación inversa, anillo
+/// dentro). En rectificación, los dos rings se ven simultáneamente
+/// y si un evento real cayó cerca de un ángulo, debe aparecer
+/// "cruzado" con ambos arcos coincidentes — eso valida la hora.
+///
+/// Usa el key Naibod (0°59'08″/año) como default — convención GR.
+fn build_primary_directions_overlay(
+    natal: &NatalChart,
+    target_age_years: f64,
+    render: &mut RenderModel,
+) {
+    let key = EDirectionKey::Naibod;
+    let eps = natal.obliquity_rad;
+
+    let project = |dir: PrimaryDirection| -> Vec<Glyph> {
+        natal
+            .placements
+            .iter()
+            .map(|p| {
+                let new_lon_rad = directed_longitude(
+                    p.right_ascension_rad,
+                    p.declination_rad,
+                    target_age_years,
+                    dir,
+                    key,
+                    eps,
+                );
+                let new_lon_deg = new_lon_rad.to_degrees() as f32;
+                Glyph {
+                    deg: new_lon_deg,
+                    symbol: body_symbol(p.body).into(),
+                    annotation: Some(format!("{:.2}°", new_lon_deg)),
+                    retrograde: p.longitude_rate_rad_per_day < 0.0,
+                    house: None,
+                    dignity_marker: None,
+                }
+            })
+            .collect()
+    };
+
+    let direct_glyphs = project(PrimaryDirection::Direct);
+    let converse_glyphs = project(PrimaryDirection::Converse);
+
+    render.layers.push(Layer {
+        module_id: "pd_direct".into(),
+        kind: LayerKind::Bodies,
+        ring: 0.0,
+        z: 10,
+        geometry: Geometry::GlyphsOnly,
+        glyphs: direct_glyphs,
+    });
+    render.layers.push(Layer {
+        module_id: "pd_converse".into(),
+        kind: LayerKind::Bodies,
+        ring: 0.0,
+        z: 11,
+        geometry: Geometry::GlyphsOnly,
+        glyphs: converse_glyphs,
+    });
+}
+
 fn build_progression_overlay(
     natal: &NatalChart,
     target_age_years: f64,
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
index 6f7ca5b..64f14e5 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
@@ -333,6 +333,14 @@ pub enum PipelineRequest {
     /// (~1° de shift); imperceptible en planetas exteriores. La capa
     /// convive con la natal geocéntrica como overlay comparativo.
     Topocentric,
+    /// `module_id = "pd_direct"` + `"pd_converse"` — Direcciones
+    /// Primarias del Sistema GR (García Rosas). Cada cuerpo natal se
+    /// proyecta dos veces: hacia adelante en el tiempo diurno
+    /// (direct) y hacia atrás (converse). Los dos resultados a la
+    /// edad pedida pintan un dual-ring para rectificación en vivo.
+    PrimaryDirections {
+        target_age_years: f64,
+    },
 }
 
 /// Opciones que afectan la pasada natal (qué aspectos pintar, qué
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
index 3f9fa8b..228d248 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
@@ -143,6 +143,7 @@ impl Registry {
         r.register(Box::new(lots::LotsModule));
         r.register(Box::new(fixed_stars::FixedStarsModule));
         r.register(Box::new(topocentric::TopocentricModule));
+        r.register(Box::new(primary_directions::PrimaryDirectionsModule));
         r
     }
 
@@ -840,13 +841,13 @@ pub mod topocentric {
             matches!(kind, ChartKind::Natal)
         }
         fn enabled_by_default(&self) -> bool {
-            false
+            true
         }
         fn controls(&self) -> Vec<Control> {
             vec![Control::Toggle {
                 key: "enabled".into(),
                 label: "Activar".into(),
-                default: false,
+                default: true,
                 hotkey: None,
             }]
         }
@@ -855,3 +856,59 @@ pub mod topocentric {
         }
     }
 }
+
+// =====================================================================
+// PrimaryDirectionsModule — GR dual-ring (Direct + Converse)
+// =====================================================================
+
+pub mod primary_directions {
+    use super::*;
+
+    /// Direcciones Primarias del Sistema GR (García Rosas): cada
+    /// cuerpo natal se proyecta en dos rings — directa (rotación
+    /// diurna forward) y conversa (rotación inversa). El usuario
+    /// scrubea `target_age_years` para ver el movimiento en vivo.
+    /// Útil para rectificación: un evento real debe coincidir con
+    /// arcos directos y conversos consistentes si la hora natal es
+    /// correcta.
+    pub struct PrimaryDirectionsModule;
+
+    impl Module for PrimaryDirectionsModule {
+        fn id(&self) -> &'static str {
+            "primary_directions"
+        }
+        fn label(&self) -> &'static str {
+            "Direcciones primarias (GR)"
+        }
+        fn description(&self) -> &'static str {
+            "Dual-ring directas + conversas para rectificación en vivo."
+        }
+        fn applies_to(&self, kind: ChartKind) -> bool {
+            matches!(kind, ChartKind::Natal)
+        }
+        fn enabled_by_default(&self) -> bool {
+            false
+        }
+        fn controls(&self) -> Vec<Control> {
+            vec![
+                Control::Toggle {
+                    key: "enabled".into(),
+                    label: "Activar".into(),
+                    default: false,
+                    hotkey: None,
+                },
+                Control::Slider {
+                    key: "target_age_years".into(),
+                    label: "Edad (años)".into(),
+                    min: 0.0,
+                    max: 120.0,
+                    step: 0.05,
+                    default: 30.0,
+                },
+            ]
+        }
+        fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
+            Vec::new()
+        }
+    }
+}