Files
brahman/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
T
sergio 4d14a4495f feat(tahuantinsuyu): fase 5 — overlay de tránsitos (bi-wheel natal × ahora)
Activá el toggle "Tránsitos (ahora)" en el panel (o hotkey [T] sobre
el wheel): la engine computa una segunda NatalChart al instante
SystemTime::now() con el mismo observer y dibuja un anillo externo de
planet glyphs encima del natal, más las cross-aspects entre ambos
charts (sólo mayores). Las líneas cross van del ring de cuerpos
natales al ring externo de tránsitos, con stroke más fino y opacidad
más baja para no taparle el ojo a las aspectos natal-natal.

- engine/bridge.rs: extraídas build_eternal_inputs y
  compute_natal_chart como helpers reutilizables. Nueva
  compute_with_transits(chart, offset, transit_at) que llama
  find_synastry_aspects entre natal y transit (AspectKind::MAJORS).
  Atajo compute_with_transits_at_now usa ESInstant::now(). Las capas
  extra van con module_id = "transit" y LayerKind::Outer /
  LayerKind::Aspects para que el canvas las distinga.
- engine/lib.rs: re-export de compute_with_transits_at_now con el
  mismo fallback al mock cuando feature `eternal-bridge` está off.
- canvas: nueva Radii::transits = 0.82, layout del wheel re-balanceado
  (houses_outer 0.78, houses_inner 0.66, bodies 0.58, aspects 0.50)
  para hacer lugar al anillo externo sin colisiones. paint_wheel:
  detecta layers de transit por module_id, pinta dots + glifos en el
  anillo nuevo + anillos guía sutiles. paint_cross_aspect_line con
  stroke 0.7 entre los dos radios. Glyph overlay para Outer ring con
  alpha 0.9 y font_size más chico que el natal. Hotkey [T] en
  on_key_down toggle LayerKind::Outer.
- modules: NatalModule.controls() agrega toggle show_transits con
  hotkey [T] (default false — no recomputar transits si nadie pidió).
- shell: nuevo show_transits flag. render_current despacha entre
  compute_at_offset y compute_with_transits_at_now según el flag.
  on_panel_event traduce ControlChanged show_transits a flip + redraw.
  on_canvas_event: el toggle de LayerKind::Outer dispara show_transits
  flip + render (no es un visibility toggle puro).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 10:24:36 +00:00

525 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Bridge real: `tahuantinsuyu_model::Chart` → eternal_astrology → [`RenderModel`].
//!
//! La sesión de efemérides VSOP2013 es **compartida globalmente** vía
//! `OnceLock` — abrirla cuesta unos cuantos ms (carga de las series en
//! memoria), y como es read-only se puede leer en paralelo desde varios
//! cómputos.
use std::sync::OnceLock;
use std::time::Instant;
use eternal_astrology::{
find_aspects, find_synastry_aspects, Aspect, AspectKind as EAspectKind, BirthData, BodySet,
ChartConfig, HouseSystem as EHouseSystem, NatalChart, OrbTable, Zodiac as EZodiac,
};
use eternal_sky::{Ayanamsha, Body, EphemerisSession, Instant as ESInstant, Observer, SessionConfig};
use tahuantinsuyu_model::{Chart, HouseSystem, StoredChartConfig, Zodiac};
use crate::{EngineError, Geometry, Glyph, Layer, LayerKind, LineSeg, RenderModel};
// =====================================================================
// Sesión global cacheada
// =====================================================================
static SESSION: OnceLock<EphemerisSession> = OnceLock::new();
fn session() -> Result<&'static EphemerisSession, EngineError> {
if let Some(s) = SESSION.get() {
return Ok(s);
}
let opened = EphemerisSession::open(SessionConfig::vsop2013())
.map_err(|e| EngineError::Eternal(format!("EphemerisSession::open: {:?}", e)))?;
// Si otro thread ya pobló la celda mientras abríamos, el set_once
// falla silenciosamente — usamos el que quedó dentro.
let _ = SESSION.set(opened);
Ok(SESSION.get().expect("session was just set"))
}
// =====================================================================
// Traducciones Stored* → eternal
// =====================================================================
fn map_house_system(h: HouseSystem) -> EHouseSystem {
match h {
HouseSystem::Placidus => EHouseSystem::Placidus,
HouseSystem::Koch => EHouseSystem::Koch,
HouseSystem::Regiomontanus => EHouseSystem::Regiomontanus,
HouseSystem::Campanus => EHouseSystem::Campanus,
HouseSystem::Porphyry => EHouseSystem::Porphyry,
HouseSystem::Equal => EHouseSystem::Equal,
HouseSystem::WholeSign => EHouseSystem::WholeSign,
}
}
fn map_zodiac(z: Zodiac, ayanamsha_hint: Option<&str>) -> EZodiac {
match z {
Zodiac::Tropical => EZodiac::Tropical,
Zodiac::Sidereal => {
let mode = match ayanamsha_hint.unwrap_or("lahiri").to_ascii_lowercase().as_str() {
"fagan_bradley" | "fagan-bradley" | "faganbradley" => Ayanamsha::FaganBradley,
"raman" => Ayanamsha::Raman,
"krishnamurti" => Ayanamsha::Krishnamurti,
"de_luce" | "deluce" => Ayanamsha::DeLuce,
"djwhal_khul" | "djwhalkhul" => Ayanamsha::DjwhalKhul,
"ushashashi" => Ayanamsha::Ushashashi,
"yukteshwar" => Ayanamsha::Yukteshwar,
_ => Ayanamsha::Lahiri,
};
EZodiac::Sidereal(mode)
}
// Dracónico aún no soportado en eternal — caemos a tropical por
// ahora; cuando eternal lo agregue, lo cableamos acá.
Zodiac::Draconic => EZodiac::Tropical,
}
}
fn map_body_set(cfg: &StoredChartConfig) -> BodySet {
let mut bodies: Vec<Body> = Vec::new();
for name in &cfg.bodies {
if let Some(b) = map_body(name) {
bodies.push(b);
}
}
if bodies.is_empty() {
// Default razonable si el config vino vacío.
return BodySet::classical_modern();
}
let mut set = BodySet {
bodies,
include_south_node: cfg.include_south_node,
};
if cfg.include_lilith {
set = set.with_lilith();
}
if cfg.include_main_belt_asteroids {
set = set.with_main_belt_asteroids();
}
set
}
fn map_body(name: &str) -> Option<Body> {
Some(match name.to_ascii_lowercase().as_str() {
"sun" => Body::Sun,
"moon" => Body::Moon,
"mercury" => Body::Mercury,
"venus" => Body::Venus,
"mars" => Body::Mars,
"jupiter" => Body::Jupiter,
"saturn" => Body::Saturn,
"uranus" => Body::Uranus,
"neptune" => Body::Neptune,
"pluto" => Body::Pluto,
"mean_node" | "meannode" => Body::MeanNode,
"true_node" | "truenode" => Body::TrueNode,
"mean_lilith" | "lilith" => Body::MeanLilith,
"true_lilith" => Body::TrueLilith,
"ceres" => Body::Ceres,
"pallas" => Body::Pallas,
"juno" => Body::Juno,
"vesta" => Body::Vesta,
_ => return None,
})
}
fn body_symbol(b: Body) -> &'static str {
match b {
Body::Sun => "sun",
Body::Moon => "moon",
Body::Mercury => "mercury",
Body::Venus => "venus",
Body::Mars => "mars",
Body::Jupiter => "jupiter",
Body::Saturn => "saturn",
Body::Uranus => "uranus",
Body::Neptune => "neptune",
Body::Pluto => "pluto",
Body::MeanNode => "north_node",
Body::TrueNode => "north_node",
Body::MeanLilith => "lilith",
Body::TrueLilith => "lilith",
Body::Ceres => "ceres",
Body::Pallas => "pallas",
Body::Juno => "juno",
Body::Vesta => "vesta",
Body::Chiron => "chiron",
Body::Pholus => "chiron",
Body::Eris => "chiron",
Body::Sedna => "chiron",
// `Body` es `#[non_exhaustive]` — cualquier cuerpo nuevo
// upstream cae al símbolo de fallback hasta que lo cableemos.
_ => "custom",
}
}
fn aspect_kind_id(k: EAspectKind) -> &'static str {
match k {
EAspectKind::Conjunction => "conjunction",
EAspectKind::Opposition => "opposition",
EAspectKind::Trine => "trine",
EAspectKind::Square => "square",
EAspectKind::Sextile => "sextile",
EAspectKind::Quincunx => "quincunx",
EAspectKind::SemiSextile => "semi_sextile",
EAspectKind::SemiSquare => "semi_square",
EAspectKind::Sesquiquadrate => "sesquiquadrate",
EAspectKind::Quintile => "quintile",
EAspectKind::BiQuintile => "biquintile",
EAspectKind::Septile => "septile",
}
}
// =====================================================================
// compute()
// =====================================================================
/// Construye los tipos eternales (`BirthData`, `ChartConfig`) desde el
/// `Chart` agnóstico, aplicando el offset temporal. Devuelve también el
/// `Observer` y la `ChartConfig` para reusar en pipelines extendidas
/// (transits, sinastría) sin re-traducir.
fn build_eternal_inputs(
chart: &Chart,
offset_minutes: i64,
) -> Result<(BirthData, ChartConfig, Observer), EngineError> {
chart.validate()?;
let bd = &chart.birth_data;
let base_instant = ESInstant::from_civil_local(
bd.year,
u8::try_from(bd.month).map_err(|_| {
EngineError::Eternal(format!("mes fuera de u8: {}", bd.month))
})?,
u8::try_from(bd.day).map_err(|_| {
EngineError::Eternal(format!("día fuera de u8: {}", bd.day))
})?,
u8::try_from(bd.hour).map_err(|_| {
EngineError::Eternal(format!("hora fuera de u8: {}", bd.hour))
})?,
u8::try_from(bd.minute).map_err(|_| {
EngineError::Eternal(format!("minuto fuera de u8: {}", bd.minute))
})?,
bd.second,
bd.tz_offset_minutes,
)
.map_err(|e| EngineError::Eternal(format!("Instant::from_civil_local: {:?}", e)))?;
let instant = if offset_minutes == 0 {
base_instant
} else {
let shifted_utc = base_instant.utc().add_seconds((offset_minutes as f64) * 60.0);
ESInstant::from_utc(shifted_utc)
};
let observer = Observer::from_degrees(bd.latitude_deg, bd.longitude_deg, bd.altitude_m);
let mut birth_e = BirthData::new(instant, observer);
if let Some(name) = &bd.subject_name {
birth_e = birth_e.with_name(name.clone());
}
let config_e = ChartConfig {
house_system: map_house_system(chart.config.house_system),
zodiac: map_zodiac(chart.config.zodiac, chart.config.ayanamsha.as_deref()),
bodies: map_body_set(&chart.config),
include_horizon: false,
};
Ok((birth_e, config_e, observer))
}
/// Computa solo la `NatalChart` (sin construir RenderModel). Útil para
/// pipelines compuestas (transits, sinastría) que necesitan el natal
/// crudo para correr `find_synastry_aspects`.
fn compute_natal_chart(
chart: &Chart,
offset_minutes: i64,
) -> Result<(NatalChart, ChartConfig, Observer), EngineError> {
let (birth_e, config_e, observer) = build_eternal_inputs(chart, offset_minutes)?;
let session = session()?;
let natal = NatalChart::compute(&birth_e, &config_e, session)
.map_err(|e| EngineError::Eternal(format!("NatalChart::compute: {:?}", e)))?;
Ok((natal, config_e, observer))
}
pub fn compute_at_offset(chart: &Chart, offset_minutes: i64) -> Result<RenderModel, EngineError> {
let t0 = Instant::now();
let (natal, _, _) = compute_natal_chart(chart, offset_minutes)?;
let aspects = find_aspects(&natal, &OrbTable::modern_western());
Ok(build_render_model(chart, &natal, &aspects, t0))
}
/// Pipeline natal + overlay de tránsitos. Computa la carta natal
/// (eventualmente con un `offset_minutes` aplicado) **y además** una
/// segunda `NatalChart` con el mismo observer pero al instante
/// `transit_at` (usualmente `Instant::now()`). Devuelve un `RenderModel`
/// con dos capas extra:
///
/// - `LayerKind::Outer` con `module_id = "transit"` — glifos
/// planetarios del cielo actual, pintados en un anillo externo.
/// - `LayerKind::Aspects` con `module_id = "transit"` — aspectos cross
/// natal × transit (sólo mayores). Convención: `LineSeg.from_deg` =
/// longitud natal, `LineSeg.to_deg` = longitud transit.
pub fn compute_with_transits(
chart: &Chart,
offset_minutes: i64,
transit_at: ESInstant,
) -> Result<RenderModel, EngineError> {
let t0 = Instant::now();
let (natal, config_e, observer) = compute_natal_chart(chart, offset_minutes)?;
let aspects = find_aspects(&natal, &OrbTable::modern_western());
let mut render = build_render_model(chart, &natal, &aspects, t0);
// Carta de tránsito: mismo observer, mismo config, instante "ahora".
let transit_birth = BirthData::new(transit_at, observer);
let session = session()?;
let transit = NatalChart::compute(&transit_birth, &config_e, session).map_err(|e| {
EngineError::Eternal(format!("NatalChart::compute (transit): {:?}", e))
})?;
// Outer ring de glifos: planetas del cielo actual.
let outer_glyphs: Vec<Glyph> = transit
.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: None,
})
.collect();
render.layers.push(Layer {
module_id: "transit".into(),
kind: LayerKind::Outer,
ring: 0.82,
z: 4,
geometry: Geometry::GlyphsOnly,
glyphs: outer_glyphs,
});
// Cross aspects natal × transit. find_synastry_aspects toma una lista
// de `AspectKind`s — usamos solo mayores para no saturar.
let cross = find_synastry_aspects(
&natal,
&transit,
&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 transit_p = transit.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: transit_p.longitude.longitude_deg() as f32,
kind: aspect_kind_id(a.kind).into(),
// Apagamos un poco más los cross para distinguirlos del
// tejido natal-natal.
opacity: opacity * 0.75,
})
})
.collect();
render.layers.push(Layer {
module_id: "transit".into(),
kind: LayerKind::Aspects,
ring: 0.0,
z: 5,
geometry: Geometry::Lines(cross_lines),
glyphs: Vec::new(),
});
render.compute_ms = t0.elapsed().as_millis() as u64;
Ok(render)
}
/// Atajo: tránsitos al instante actual del reloj.
pub fn compute_with_transits_at_now(
chart: &Chart,
offset_minutes: i64,
) -> Result<RenderModel, EngineError> {
compute_with_transits(chart, offset_minutes, ESInstant::now())
}
// =====================================================================
// NatalChart → RenderModel
// =====================================================================
fn build_render_model(
chart: &Chart,
natal: &NatalChart,
aspects: &[Aspect],
started: Instant,
) -> RenderModel {
let ascendant_deg = natal.ascendant().longitude_deg() as f32;
let midheaven_deg = natal.midheaven().longitude_deg() as f32;
let descendant_deg = natal.descendant().longitude_deg() as f32;
let imum_coeli_deg = natal.imum_coeli().longitude_deg() as f32;
// ─── Capa 0: Sign Dial ────────────────────────────────────────────
let sign_dial = Layer {
module_id: "natal".into(),
kind: LayerKind::SignDial,
ring: 1.0,
z: 0,
geometry: Geometry::Ring {
cusps_deg: (0..12).map(|i| (i as f32) * 30.0).collect(),
},
glyphs: (0..12)
.map(|i| Glyph {
deg: (i as f32) * 30.0 + 15.0,
symbol: ZODIAC_SYMBOLS[i].into(),
annotation: None,
retrograde: false,
house: None,
})
.collect(),
};
// ─── Capa 1: Houses ───────────────────────────────────────────────
let cusps_deg: Vec<f32> = natal
.houses
.cusps
.iter()
.map(|c| c.to_degrees() as f32)
.collect();
let houses = Layer {
module_id: "natal".into(),
kind: LayerKind::Houses,
ring: 0.86,
z: 1,
geometry: Geometry::Ring {
cusps_deg: cusps_deg.clone(),
},
glyphs: cusps_deg
.iter()
.enumerate()
.map(|(i, c)| Glyph {
deg: *c + 4.0,
symbol: format!("h{}", i + 1),
annotation: None,
retrograde: false,
house: Some((i as u8) + 1),
})
.collect(),
};
// ─── Capa 2: Bodies ───────────────────────────────────────────────
let body_glyphs: Vec<Glyph> = natal
.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())),
// `BodyPlacement` cambió entre versiones de eternal entre
// `pub fn is_retrograde(&self) -> bool` y `pub
// is_retrograde: bool` — leemos el campo crudo
// `longitude_rate_rad_per_day` (estable en ambas) para no
// depender del wrapper.
retrograde: p.longitude_rate_rad_per_day < 0.0,
house: Some(p.house_number),
})
.collect();
let bodies = Layer {
module_id: "natal".into(),
kind: LayerKind::Bodies,
ring: 0.72,
z: 2,
geometry: Geometry::Points(
natal
.placements
.iter()
.map(|p| crate::PointMark {
deg: p.longitude.longitude_deg() as f32,
label: p.body.name().into(),
tag: body_symbol(p.body).into(),
})
.collect(),
),
glyphs: body_glyphs,
};
// ─── Capa 3: Aspects ──────────────────────────────────────────────
let mut aspect_lines: Vec<LineSeg> = Vec::with_capacity(aspects.len());
for a in aspects {
// Solo los aspectos mayores se pintan en este pase — los menores
// saturan visualmente. Fase 4 pondrá un toggle para mostrarlos.
if !EAspectKind::MAJORS.contains(&a.kind) {
continue;
}
let pa = natal.placement(a.a);
let pb = natal.placement(a.b);
if let (Some(pa), Some(pb)) = (pa, pb) {
let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
aspect_lines.push(LineSeg {
from_deg: pa.longitude.longitude_deg() as f32,
to_deg: pb.longitude.longitude_deg() as f32,
kind: aspect_kind_id(a.kind).into(),
opacity,
});
}
}
let aspects_layer = Layer {
module_id: "natal".into(),
kind: LayerKind::Aspects,
ring: 0.58,
z: 3,
geometry: Geometry::Lines(aspect_lines),
glyphs: Vec::new(),
};
let subtitle = chart
.birth_data
.birthplace_label
.clone()
.or_else(|| {
Some(format!(
"{:04}-{:02}-{:02} · lat {:+.2}° · lon {:+.2}°",
chart.birth_data.year,
chart.birth_data.month,
chart.birth_data.day,
chart.birth_data.latitude_deg,
chart.birth_data.longitude_deg,
))
});
RenderModel {
chart_id: chart.id,
chart_kind: chart.kind,
title: chart.label.clone(),
subtitle,
compute_ms: started.elapsed().as_millis() as u64,
ascendant_deg,
midheaven_deg,
descendant_deg,
imum_coeli_deg,
layers: vec![sign_dial, houses, bodies, aspects_layer],
}
}
/// 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 {
let max = match kind {
EAspectKind::Conjunction | EAspectKind::Opposition => 8.0,
EAspectKind::Trine | EAspectKind::Square => 7.0,
EAspectKind::Sextile => 5.0,
_ => 3.0,
};
let t = (1.0 - (orb_deg / max).min(1.0)).max(0.25);
t as f32
}
const ZODIAC_SYMBOLS: [&str; 12] = [
"aries",
"taurus",
"gemini",
"cancer",
"leo",
"virgo",
"libra",
"scorpio",
"sagittarius",
"capricorn",
"aquarius",
"pisces",
];