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 { + 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 { 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 { + 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 { + Vec::new() + } + } +}