feat(tahuantinsuyu): fase 9 — Solar Arc como segundo overlay

Confirma que la arquitectura de fase 6 escala: tres overlays simultáneos
(transit + progression + solar_arc) sin acoplamiento entre módulos, y
sin tocar el flujo del Shell salvo registrar el nuevo branch.

Tres puntos de extensión por overlay nuevo (exactamente los predichos):
1. variante en PipelineRequest
2. helper build_*_overlay en bridge + match arm en compose
3. módulo declarativo en modules/ + registro

- engine: PipelineRequest::SolarArc { target_age_years: f64 } +
  build_solar_arc_overlay que llama solar_arc_true(natal, session, age)
  → desplaza uniformemente cada placement y cusp por el arco solar
  (default ≈1°/año, vía true progressed Sun). Cross aspects natal ×
  dirigida vía find_synastry_aspects(majors). Layers con
  module_id="solar_arc" y z=8/9 (sobre todos los demás).
- modules: solar_arc::SolarArcModule con id="solar_arc", toggle
  "Activar" + slider target_age_years 0..120. Mismo shape que
  ProgressionModule. Registry.with_builtins lo registra. Test pasó a
  4 módulos aplicables a ChartKind::Natal.
- canvas: Radii.solar_arc = 0.40 (entre progression 0.48 y aspects),
  aspects shrunk a 0.32 para hacer lugar. Helpers Radii::body_ring()
  y Radii::aspect_endpoints() ahora reconocen "solar_arc". paint_wheel
  itera ambos overlays (progression + solar_arc) para dibujar dots,
  glyph overlays y anillos guía sutiles. Loop común `for (id, ring) in
  [..]` evita duplicación de código.
- shell: build_requests detecta solar_arc.enabled, agrega request con
  edad. apply_selection inicializa target_age_years para ambos
  overlays (progression + solar_arc) en current_age + sincroniza los
  sliders del panel. Helper module_age_or_current(id) factoriza la
  lectura de edad con fallback.

Activando los tres overlays al mismo tiempo el canvas se convierte en
una rueda de cinco anillos: zodíaco (1.00), tránsito (0.82), natal
(0.66-0.78), bodies natal (0.58), progression (0.48), solar arc (0.40),
con líneas de aspectos cross convergiendo desde el ring natal hacia
cada overlay simultáneamente.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-17 10:59:01 +00:00
parent e0c5c02b8e
commit 1a3bc55016
5 changed files with 232 additions and 65 deletions
+30 -8
View File
@@ -118,20 +118,23 @@ impl Shell {
let age = current_age_years(&chart.birth_data); let age = current_age_years(&chart.birth_data);
self.current_chart = Some(chart.clone()); self.current_chart = Some(chart.clone());
self.current_offset_minutes = 0; self.current_offset_minutes = 0;
// Inicializar la edad objetivo del módulo de progresión // Inicializar la edad objetivo de los módulos basados
// con la edad actual del sujeto, así el slider arranca // en edad con la edad actual del sujeto, así sus
// "donde corresponde" si el usuario lo activa. // sliders arrancan donde corresponde al activarse.
let prog_entry = self for module_id in ["progression", "solar_arc"] {
let entry = self
.module_configs .module_configs
.entry("progression".into()) .entry(module_id.into())
.or_insert_with(|| serde_json::json!({})); .or_insert_with(|| serde_json::json!({}));
if let serde_json::Value::Object(map) = prog_entry { if let serde_json::Value::Object(map) = entry {
map.insert("target_age_years".into(), serde_json::json!(age)); map.insert("target_age_years".into(), serde_json::json!(age));
} }
}
self.render_current(cx); self.render_current(cx);
self.panel.update(cx, |p, cx| { self.panel.update(cx, |p, cx| {
p.set_active_kind(Some(chart.kind), cx); p.set_active_kind(Some(chart.kind), cx);
p.set_slider("progression", "target_age_years", age, cx); p.set_slider("progression", "target_age_years", age, cx);
p.set_slider("solar_arc", "target_age_years", age, cx);
}); });
} }
TreeSelection::Contact(id) => { TreeSelection::Contact(id) => {
@@ -192,16 +195,35 @@ impl Shell {
requests.push(PipelineRequest::Transit); requests.push(PipelineRequest::Transit);
} }
if module_enabled(&self.module_configs, "progression") { if module_enabled(&self.module_configs, "progression") {
if let Some(chart) = self.current_chart.as_ref() { let age = self.module_age_or_current("progression");
let age = current_age_years(&chart.birth_data);
requests.push(PipelineRequest::SecondaryProgression { requests.push(PipelineRequest::SecondaryProgression {
target_age_years: age, target_age_years: age,
}); });
} }
if module_enabled(&self.module_configs, "solar_arc") {
let age = self.module_age_or_current("solar_arc");
requests.push(PipelineRequest::SolarArc {
target_age_years: age,
});
} }
requests requests
} }
/// Lee `target_age_years` del módulo o cae a la edad actual del
/// sujeto (calculada desde la fecha de nacimiento y el reloj).
fn module_age_or_current(&self, module_id: &str) -> f64 {
self.module_configs
.get(module_id)
.and_then(|c| c.get("target_age_years"))
.and_then(|v| v.as_f64())
.unwrap_or_else(|| {
self.current_chart
.as_ref()
.map(|c| current_age_years(&c.birth_data))
.unwrap_or(0.0)
})
}
fn render_current(&mut self, cx: &mut Context<Self>) { fn render_current(&mut self, cx: &mut Context<Self>) {
let Some(chart) = self.current_chart.as_ref() else { let Some(chart) = self.current_chart.as_ref() else {
return; return;
@@ -535,16 +535,17 @@ fn render_wheel(
} }
} }
// Planet glyphs: natal (en `bodies`) y progresada (en `progression`, // Planet glyphs: natal (en `bodies`) + overlays en sus rings
// con alpha + tamaño un poco distintos para diferenciarlos). // (progression, solar_arc) con alpha + tamaño más chico para
// diferenciarse visualmente del natal.
if visible.get(&LayerKind::Bodies).copied().unwrap_or(true) { if visible.get(&LayerKind::Bodies).copied().unwrap_or(true) {
for layer in &render.layers { for layer in &render.layers {
if matches!(layer.kind, LayerKind::Bodies) { if matches!(layer.kind, LayerKind::Bodies) {
let is_prog = layer.module_id == "progression"; let is_natal = layer.module_id == "natal";
let ring = if is_prog { radii.progression } else { radii.bodies }; let ring = radii.body_ring(&layer.module_id);
let alpha = if is_prog { 0.85 } else { 1.0 }; let alpha = if is_natal { 1.0 } else { 0.85 };
let font_size = if is_prog { 14.0 } else { 18.0 }; let font_size = if is_natal { 18.0 } else { 14.0 };
let box_size = if is_prog { 20.0 } else { 24.0 }; let box_size = if is_natal { 24.0 } else { 20.0 };
for g in &layer.glyphs { for g in &layer.glyphs {
let (x, y) = polar_to_screen(g.deg, asc, rot_offset, ring); let (x, y) = polar_to_screen(g.deg, asc, rot_offset, ring);
let color = with_alpha(planet_color(palette, &g.symbol), alpha); let color = with_alpha(planet_color(palette, &g.symbol), alpha);
@@ -683,15 +684,17 @@ struct Radii {
sign_outer: f32, sign_outer: f32,
sign_inner: f32, sign_inner: f32,
/// Anillo de glifos de tránsito (cuando el overlay está activo). /// Anillo de glifos de tránsito (cuando el overlay está activo).
/// Vive entre `sign_inner` y `houses_outer`; queda vacío cuando no
/// hay capa Outer con module_id = "transit".
transits: f32, transits: f32,
houses_outer: f32, houses_outer: f32,
houses_inner: f32, houses_inner: f32,
bodies: f32, bodies: f32,
/// Anillo interno con cuerpos progresados (cuando hay overlay de /// Anillo interno con cuerpos progresados (overlay opcional).
/// progresión secundaria). Queda vacío sino.
progression: f32, progression: f32,
/// Anillo más interno con cuerpos dirigidos por Solar Arc (overlay
/// opcional). Si tanto progression como solar_arc están activos,
/// progression va afuera (más cerca de bodies natales) y solar_arc
/// adentro (entre progression y aspects).
solar_arc: f32,
aspects: f32, aspects: f32,
} }
@@ -705,18 +708,28 @@ impl Radii {
houses_inner: r * 0.66, houses_inner: r * 0.66,
bodies: r * 0.58, bodies: r * 0.58,
progression: r * 0.48, progression: r * 0.48,
aspects: r * 0.38, solar_arc: r * 0.40,
aspects: r * 0.32,
}
}
/// Radio del ring de cuerpos según el `module_id` del Layer.
fn body_ring(&self, module_id: &str) -> f32 {
match module_id {
"progression" => self.progression,
"solar_arc" => self.solar_arc,
_ => self.bodies,
} }
} }
/// Resuelve qué radios corresponden a una capa de aspectos según el /// Resuelve qué radios corresponden a una capa de aspectos según el
/// `module_id`: natal-natal en `aspects`, cross-aspects con /// `module_id`: natal-natal en `aspects`, cross con cada overlay
/// tránsito desde `bodies` a `transits`, cross con progresión desde /// desde `bodies` (extremo natal) al ring del módulo.
/// `bodies` a `progression`.
fn aspect_endpoints(&self, module_id: &str) -> (f32, f32) { fn aspect_endpoints(&self, module_id: &str) -> (f32, f32) {
match module_id { match module_id {
"transit" => (self.bodies, self.transits), "transit" => (self.bodies, self.transits),
"progression" => (self.bodies, self.progression), "progression" => (self.bodies, self.progression),
"solar_arc" => (self.bodies, self.solar_arc),
_ => (self.aspects, self.aspects), _ => (self.aspects, self.aspects),
} }
} }
@@ -876,16 +889,14 @@ fn paint_wheel(
} }
} }
// 4. Dots de cuerpos: natal en `bodies`, progresada en `progression`. // 4. Dots de cuerpos: natal en `bodies`, overlays en sus rings
// específicos (progression, solar_arc).
if show(LayerKind::Bodies) { if show(LayerKind::Bodies) {
let dot_r = (radii.sign_outer * 0.018).max(2.0); let dot_r = (radii.sign_outer * 0.018).max(2.0);
for layer in layers { for layer in layers {
if matches!(layer.kind, LayerKind::Bodies) { if matches!(layer.kind, LayerKind::Bodies) {
let ring = match layer.module_id.as_str() { let ring = radii.body_ring(&layer.module_id);
"progression" => radii.progression, let alpha = if layer.module_id == "natal" { 1.0 } else { 0.85 };
_ => radii.bodies,
};
let alpha = if layer.module_id == "progression" { 0.85 } else { 1.0 };
for g in &layer.glyphs { for g in &layer.glyphs {
let color = with_alpha(planet_color(palette, &g.symbol), alpha); let color = with_alpha(planet_color(palette, &g.symbol), alpha);
let (x, y) = polar_to_screen(g.deg, ascendant_deg, rot_offset_deg, ring); let (x, y) = polar_to_screen(g.deg, ascendant_deg, rot_offset_deg, ring);
@@ -895,16 +906,21 @@ fn paint_wheel(
} }
} }
// Si hay progresión, dibujar anillo guía sutil delimitando su slot. // Anillos guía para los overlays internos (progression, solar_arc).
let prog_active = layers let guide_inset = radii.sign_outer * 0.03;
for (module_id, ring) in [
("progression", radii.progression),
("solar_arc", radii.solar_arc),
] {
let active = layers
.iter() .iter()
.any(|l| matches!(l.kind, LayerKind::Bodies) && l.module_id == "progression"); .any(|l| matches!(l.kind, LayerKind::Bodies) && l.module_id == module_id);
if prog_active { if active {
stroke_circle( stroke_circle(
window, window,
cx, cx,
cy, cy,
radii.progression + radii.sign_outer * 0.03, ring + guide_inset,
0.5, 0.5,
with_alpha(palette.house_cusp, 0.35), with_alpha(palette.house_cusp, 0.35),
); );
@@ -912,11 +928,12 @@ fn paint_wheel(
window, window,
cx, cx,
cy, cy,
radii.progression - radii.sign_outer * 0.03, ring - guide_inset,
0.5, 0.5,
with_alpha(palette.house_cusp, 0.35), with_alpha(palette.house_cusp, 0.35),
); );
} }
}
// 5. Outer ring (transit overlay): anillo guía + dots de transit. // 5. Outer ring (transit overlay): anillo guía + dots de transit.
let transit_active = layers let transit_active = layers
@@ -9,9 +9,9 @@ use std::sync::OnceLock;
use std::time::Instant; use std::time::Instant;
use eternal_astrology::{ use eternal_astrology::{
find_aspects, find_synastry_aspects, secondary_progression, Aspect, AspectKind as EAspectKind, find_aspects, find_synastry_aspects, secondary_progression, solar_arc_true, Aspect,
BirthData, BodySet, ChartConfig, HouseSystem as EHouseSystem, NatalChart, OrbTable, AspectKind as EAspectKind, BirthData, BodySet, ChartConfig, HouseSystem as EHouseSystem,
Zodiac as EZodiac, NatalChart, OrbTable, Zodiac as EZodiac,
}; };
use eternal_sky::{Ayanamsha, Body, EphemerisSession, Instant as ESInstant, Observer, SessionConfig}; use eternal_sky::{Ayanamsha, Body, EphemerisSession, Instant as ESInstant, Observer, SessionConfig};
@@ -258,6 +258,9 @@ pub fn compose(
crate::PipelineRequest::SecondaryProgression { target_age_years } => { crate::PipelineRequest::SecondaryProgression { target_age_years } => {
build_progression_overlay(&natal, *target_age_years, &mut render)?; build_progression_overlay(&natal, *target_age_years, &mut render)?;
} }
crate::PipelineRequest::SolarArc { target_age_years } => {
build_solar_arc_overlay(&natal, *target_age_years, &mut render)?;
}
} }
} }
@@ -398,6 +401,71 @@ fn build_progression_overlay(
Ok(()) Ok(())
} }
/// Helper: agrega al `RenderModel` las capas del overlay de Solar Arc
/// (método true-progressed-Sun por default). Cada cuerpo natal se
/// desplaza por el mismo arco — preserva las relaciones angulares y
/// las posiciones relativas en casas se mantienen.
fn build_solar_arc_overlay(
natal: &NatalChart,
target_age_years: f64,
render: &mut RenderModel,
) -> Result<(), EngineError> {
let session = session()?;
let arc = solar_arc_true(natal, session, target_age_years)
.map_err(|e| EngineError::Eternal(format!("solar_arc_true: {:?}", e)))?;
let directed = &arc.directed;
let glyphs: Vec<Glyph> = directed
.placements
.iter()
.map(|p| Glyph {
deg: p.longitude.longitude_deg() as f32,
symbol: body_symbol(p.body).into(),
annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
retrograde: p.longitude_rate_rad_per_day < 0.0,
house: Some(p.house_number),
})
.collect();
render.layers.push(Layer {
module_id: "solar_arc".into(),
kind: LayerKind::Bodies,
ring: 0.43,
z: 8,
geometry: Geometry::GlyphsOnly,
glyphs,
});
let cross = find_synastry_aspects(
natal,
directed,
&OrbTable::modern_western(),
EAspectKind::MAJORS,
);
let cross_lines: Vec<LineSeg> = cross
.iter()
.filter_map(|a| {
let natal_p = natal.placement(a.person_a_body)?;
let dir_p = directed.placement(a.person_b_body)?;
let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
Some(LineSeg {
from_deg: natal_p.longitude.longitude_deg() as f32,
to_deg: dir_p.longitude.longitude_deg() as f32,
kind: aspect_kind_id(a.kind).into(),
opacity: opacity * 0.7,
})
})
.collect();
render.layers.push(Layer {
module_id: "solar_arc".into(),
kind: LayerKind::Aspects,
ring: 0.0,
z: 9,
geometry: Geometry::Lines(cross_lines),
glyphs: Vec::new(),
});
Ok(())
}
// ===================================================================== // =====================================================================
// NatalChart → RenderModel // NatalChart → RenderModel
// ===================================================================== // =====================================================================
@@ -173,8 +173,14 @@ pub enum PipelineRequest {
/// `SystemTime::now`. /// `SystemTime::now`.
target_age_years: f64, target_age_years: f64,
}, },
// ── Fase 8 ────────────────────────────────────────────────────── /// `module_id = "solar_arc"` — Solar Arc dirigido (default = "true
// SolarArc { target_year: i32 }, /// progressed Sun"): cada cuerpo y cada cusp natal se desplazan por
/// el mismo arco ≈ 1° por año de vida. Anillo interno bien adentro
/// + cross aspects natal × dirigida.
SolarArc {
target_age_years: f64,
},
// ── Fase 10 ─────────────────────────────────────────────────────
// Synastry { partner: tahuantinsuyu_model::ChartId }, // Synastry { partner: tahuantinsuyu_model::ChartId },
} }
@@ -125,6 +125,7 @@ impl Registry {
r.register(Box::new(natal::NatalModule)); r.register(Box::new(natal::NatalModule));
r.register(Box::new(transit::TransitModule)); r.register(Box::new(transit::TransitModule));
r.register(Box::new(progression::ProgressionModule)); r.register(Box::new(progression::ProgressionModule));
r.register(Box::new(solar_arc::SolarArcModule));
r r
} }
@@ -332,6 +333,59 @@ pub mod progression {
} }
} }
// =====================================================================
// SolarArcModule — Solar Arc dirigido (true progressed Sun)
// =====================================================================
pub mod solar_arc {
use super::*;
/// Cada planeta y cusp natal se desplaza por el mismo arco
/// (≈ 1° por año de vida, calculado como el delta del Sol
/// progresado secundario). Anillo interno bien adentro + cross
/// aspects natal × dirigida.
pub struct SolarArcModule;
impl Module for SolarArcModule {
fn id(&self) -> &'static str {
"solar_arc"
}
fn label(&self) -> &'static str {
"Solar Arc"
}
fn description(&self) -> &'static str {
"Dirección por arco solar — uniforme, ≈1°/año."
}
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 objetivo (años)".into(),
min: 0.0,
max: 120.0,
step: 0.25,
default: 30.0,
},
]
}
fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
Vec::new()
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -342,9 +396,9 @@ mod tests {
assert!(r.find("natal").is_some()); assert!(r.find("natal").is_some());
assert!(r.find("transit").is_some()); assert!(r.find("transit").is_some());
assert!(r.find("progression").is_some()); assert!(r.find("progression").is_some());
// Natal kind tiene 3 módulos aplicables: natal + transit + progression. assert!(r.find("solar_arc").is_some());
assert_eq!(r.for_kind(ChartKind::Natal).len(), 3); // Natal kind tiene 4 módulos: natal + transit + progression + solar_arc.
// Synastry kind no tiene módulos hoy. assert_eq!(r.for_kind(ChartKind::Natal).len(), 4);
assert!(r.for_kind(ChartKind::Synastry).is_empty()); assert!(r.for_kind(ChartKind::Synastry).is_empty());
} }
} }