//! 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, next_return, secondary_progression, solar_arc_true, 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::dignity::essential_dignity; use crate::{ AspectSummary, EngineError, Geometry, Glyph, Layer, LayerKind, LineSeg, OverlayMeta, RenderModel, }; // ===================================================================== // Sesión global cacheada // ===================================================================== static SESSION: OnceLock = 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 = 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 { 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)) } /// Composición principal: natal + overlays pedidos. Es la función que /// `lib::compose` delega cuando el feature `eternal-bridge` está activo. pub fn compose( chart: &Chart, offset_minutes: i64, requests: &[crate::PipelineRequest], natal_options: &crate::NatalOptions, ) -> Result { let t0 = Instant::now(); let (natal, config_e, observer) = compute_natal_chart(chart, offset_minutes)?; let orb_table = build_orb_table(natal_options.orb_multiplier); let all_aspects = find_aspects(&natal, &orb_table); let aspects: Vec = all_aspects .into_iter() .filter(|a| { let is_major = EAspectKind::MAJORS.contains(&a.kind); (is_major && natal_options.show_majors) || (!is_major && natal_options.show_minors) }) .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 { crate::PipelineRequest::Transit => { build_transit_overlay(&natal, &config_e, observer, ESInstant::now(), &mut render)?; push_overlay_meta(&mut render, "transit", "Tránsito ahora".into()); } crate::PipelineRequest::SecondaryProgression { target_age_years } => { build_progression_overlay(&natal, *target_age_years, &mut render)?; push_overlay_meta( &mut render, "progression", format!("Progresión {:.1}a", target_age_years), ); } crate::PipelineRequest::SolarArc { target_age_years } => { build_solar_arc_overlay(&natal, *target_age_years, &mut render)?; push_overlay_meta( &mut render, "solar_arc", format!("Solar Arc {:.1}a", target_age_years), ); } crate::PipelineRequest::Synastry { partner_chart } => { let partner_label = partner_chart.label.clone(); build_synastry_overlay(&natal, partner_chart, &mut render)?; push_overlay_meta( &mut render, "synastry", format!("Sinastría · {}", partner_label), ); } crate::PipelineRequest::Midpoints => { build_midpoints_overlay(&natal, &mut render); push_overlay_meta(&mut render, "midpoints", "Midpoints ☉/☽".into()); } crate::PipelineRequest::PlanetaryReturn { body, target_age_years, } => { let body_e = map_body(body).ok_or_else(|| { EngineError::Eternal(format!( "body desconocido para planetary return: {}", body )) })?; build_planetary_return_overlay( &natal, &config_e, observer, body_e, *target_age_years, &mut render, )?; push_overlay_meta( &mut render, "planetary_return", format!("{} return {:.0}a", body_e.name(), target_age_years), ); } } } render.compute_ms = t0.elapsed().as_millis() as u64; Ok(render) } /// Helper: agrega al `RenderModel` las dos capas del overlay de /// tránsitos (Outer + cross Aspects). fn build_transit_overlay( natal: &NatalChart, config_e: &ChartConfig, observer: Observer, transit_at: ESInstant, render: &mut RenderModel, ) -> Result<(), EngineError> { 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)) })?; let outer_glyphs: Vec = 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, dignity_marker: None, }) .collect(); render.layers.push(Layer { module_id: "transit".into(), kind: LayerKind::Outer, ring: 0.82, z: 4, geometry: Geometry::GlyphsOnly, glyphs: outer_glyphs, }); let cross = find_synastry_aspects( natal, &transit, &OrbTable::modern_western(), EAspectKind::MAJORS, ); let cross_lines: Vec = 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(), 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(), }); populate_cross_aspect_summary(&cross, "transit", render); Ok(()) } /// Helper: agrega al `RenderModel` las capas del overlay de progresión /// secundaria. La carta progresada se computa con el mismo observer y /// config que la natal pero al instante natal+(age_years/period_years) /// días. fn build_progression_overlay( natal: &NatalChart, target_age_years: f64, render: &mut RenderModel, ) -> Result<(), EngineError> { let session = session()?; let prog = secondary_progression(natal, session, target_age_years) .map_err(|e| EngineError::Eternal(format!("secondary_progression: {:?}", e)))?; let progressed = &prog.progressed; // Glifos de los cuerpos progresados — anillo interno (radio 0.48). let glyphs: Vec = progressed .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), dignity_marker: None, }) .collect(); render.layers.push(Layer { module_id: "progression".into(), kind: LayerKind::Bodies, ring: 0.48, z: 6, geometry: Geometry::GlyphsOnly, glyphs, }); // Cross aspects natal × progresada (sólo mayores). let cross = find_synastry_aspects( natal, progressed, &OrbTable::modern_western(), EAspectKind::MAJORS, ); let cross_lines: Vec = cross .iter() .filter_map(|a| { let natal_p = natal.placement(a.person_a_body)?; let prog_p = progressed.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: prog_p.longitude.longitude_deg() as f32, kind: aspect_kind_id(a.kind).into(), opacity: opacity * 0.7, }) }) .collect(); render.layers.push(Layer { module_id: "progression".into(), kind: LayerKind::Aspects, ring: 0.0, z: 7, geometry: Geometry::Lines(cross_lines), glyphs: Vec::new(), }); populate_cross_aspect_summary(&cross, "progression", render); Ok(()) } /// Helper: agrega al `RenderModel` los midpoints entre pares de /// cuerpos natales. Filtra para mostrar solo los que involucran al /// Sol o a la Luna (~10 puntos) — son los más significativos /// astrológicamente y mantiene la rueda legible. /// /// El midpoint de dos longitudes es la menor distancia angular entre /// ellas. Si `|a - b| > 180`, hay que sumar 180 al promedio para /// obtener el midpoint "corto". fn build_midpoints_overlay(natal: &NatalChart, render: &mut RenderModel) { let mut glyphs: Vec = Vec::new(); let placements = &natal.placements; for i in 0..placements.len() { for j in (i + 1)..placements.len() { let pa = &placements[i]; let pb = &placements[j]; // Solo midpoints que involucren Sol o Luna. let involves_luminary = matches!(pa.body, Body::Sun | Body::Moon) || matches!(pb.body, Body::Sun | Body::Moon); if !involves_luminary { continue; } let a = pa.longitude.longitude_deg() as f32; let b = pb.longitude.longitude_deg() as f32; let diff = (a - b).abs(); let mid = if diff > 180.0 { ((a + b) / 2.0 + 180.0).rem_euclid(360.0) } else { ((a + b) / 2.0).rem_euclid(360.0) }; glyphs.push(Glyph { deg: mid, symbol: format!("{}/{}", body_symbol(pa.body), body_symbol(pb.body)), annotation: Some(format!("{}/{}", pa.body.name(), pb.body.name())), retrograde: false, house: None, dignity_marker: None, }); } } render.layers.push(Layer { module_id: "midpoints".into(), kind: LayerKind::Midpoints, ring: 0.62, z: 14, geometry: Geometry::GlyphsOnly, glyphs, }); } /// 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 = 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), dignity_marker: None, }) .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 = 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(), }); populate_cross_aspect_summary(&cross, "solar_arc", render); Ok(()) } /// Helper: agrega al `RenderModel` las capas del overlay de sinastría /// con otra carta natal completa. La carta partner se computa con su /// propio observer/config (no comparte con la natal). El outer ring /// se comparte con Transit — mutuamente excluyentes a nivel de Shell. fn build_synastry_overlay( natal: &NatalChart, partner_chart: &Chart, render: &mut RenderModel, ) -> Result<(), EngineError> { let (partner, _config, _observer) = compute_natal_chart(partner_chart, 0)?; let glyphs: Vec = partner .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), dignity_marker: None, }) .collect(); render.layers.push(Layer { module_id: "synastry".into(), kind: LayerKind::Outer, ring: 0.82, z: 10, geometry: Geometry::GlyphsOnly, glyphs, }); let cross = find_synastry_aspects( natal, &partner, &OrbTable::modern_western(), EAspectKind::MAJORS, ); let cross_lines: Vec = cross .iter() .filter_map(|a| { let natal_p = natal.placement(a.person_a_body)?; let partner_p = partner.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: partner_p.longitude.longitude_deg() as f32, kind: aspect_kind_id(a.kind).into(), opacity: opacity * 0.85, }) }) .collect(); render.layers.push(Layer { module_id: "synastry".into(), kind: LayerKind::Aspects, ring: 0.0, z: 11, geometry: Geometry::Lines(cross_lines), glyphs: Vec::new(), }); populate_cross_aspect_summary(&cross, "synastry", render); Ok(()) } /// Helper: agrega al `RenderModel` las capas del overlay de retorno /// planetario — la carta natal completa computada al instante en que /// el `body` vuelve a su posición natal cerca de la edad pedida. /// Sun = retorno solar anual, Moon = mensual, Júpiter/Saturno = /// generacionales. Esa nueva carta va en el anillo externo (compartido /// con Transit/Synastry, mutuamente excluyentes a nivel de Shell). /// Cross aspects natal × return. fn build_planetary_return_overlay( natal: &NatalChart, config_e: &ChartConfig, observer: Observer, body: Body, target_age_years: f64, render: &mut RenderModel, ) -> Result<(), EngineError> { let session = session()?; let natal_p = natal.placement(body).ok_or_else(|| { EngineError::Eternal(format!( "natal chart sin {} — return imposible", body.name() )) })?; let natal_lon = natal_p.longitude.longitude_rad(); // El offset desde el cumpleaños depende del período sinódico del // cuerpo: para Sun/planet lentos, ~30 días antes garantiza captar // el return; para Moon, ~15 días. Tomamos un margen amplio que // sirve para todos. const TROPICAL_YEAR_SECS: f64 = 365.242190 * 86400.0; let after_seconds = (target_age_years * 365.242190 - 30.0) * 86400.0; let after_utc = natal .birth .instant .utc() .add_seconds(after_seconds.max(-TROPICAL_YEAR_SECS * 2.0)); let after = ESInstant::from_utc(after_utc); let return_instant = next_return(session, body, natal_lon, after, None).map_err(|e| { EngineError::Eternal(format!("next_return {}: {:?}", body.name(), e)) })?; // La carta del retorno se computa al return_instant con el mismo // observer y config natales (convención clásica: return tropical // en la ciudad de nacimiento). let return_birth = BirthData::new(return_instant, observer); let return_chart = NatalChart::compute(&return_birth, config_e, session).map_err(|e| { EngineError::Eternal(format!( "NatalChart::compute ({} return): {:?}", body.name(), e )) })?; let glyphs: Vec = return_chart .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), dignity_marker: None, }) .collect(); render.layers.push(Layer { module_id: "planetary_return".into(), kind: LayerKind::Outer, ring: 0.82, z: 12, geometry: Geometry::GlyphsOnly, glyphs, }); let cross = find_synastry_aspects( natal, &return_chart, &OrbTable::modern_western(), EAspectKind::MAJORS, ); let cross_lines: Vec = cross .iter() .filter_map(|a| { let n_p = natal.placement(a.person_a_body)?; let r_p = return_chart.placement(a.person_b_body)?; let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind); Some(LineSeg { from_deg: n_p.longitude.longitude_deg() as f32, to_deg: r_p.longitude.longitude_deg() as f32, kind: aspect_kind_id(a.kind).into(), opacity: opacity * 0.8, }) }) .collect(); render.layers.push(Layer { module_id: "planetary_return".into(), kind: LayerKind::Aspects, ring: 0.0, z: 13, geometry: Geometry::Lines(cross_lines), glyphs: Vec::new(), }); populate_cross_aspect_summary(&cross, "planetary_return", render); Ok(()) } // ===================================================================== // 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, dignity_marker: None, }) .collect(), }; // ─── Capa 1: Houses ─────────────────────────────────────────────── let cusps_deg: Vec = 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), dignity_marker: None, }) .collect(), }; // ─── Capa 2: Bodies ─────────────────────────────────────────────── let body_glyphs: Vec = 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), dignity_marker: None, }) .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 ────────────────────────────────────────────── // Los aspects ya vienen filtrados por NatalOptions (majors / minors) // desde compose(). Acá solo mapeamos a LineSeg. let mut aspect_lines: Vec = Vec::with_capacity(aspects.len()); for a in aspects { 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], overlays: Vec::new(), aspect_summary: Vec::new(), } } /// Construye una `OrbTable` con los orbes default de `modern_western` /// escalados por `multiplier`. Necesario porque eternal expone /// `set_orb` pero no permite iterar los base orbs internos. fn build_orb_table(multiplier: f64) -> OrbTable { let mut t = OrbTable::modern_western(); let m = multiplier.max(0.0); t.set_orb(EAspectKind::Conjunction, 8.0 * m); t.set_orb(EAspectKind::Opposition, 8.0 * m); t.set_orb(EAspectKind::Trine, 7.0 * m); t.set_orb(EAspectKind::Square, 7.0 * m); t.set_orb(EAspectKind::Sextile, 5.0 * m); t.set_orb(EAspectKind::Quincunx, 2.5 * m); t.set_orb(EAspectKind::SemiSextile, 2.0 * m); t.set_orb(EAspectKind::SemiSquare, 2.0 * m); t.set_orb(EAspectKind::Sesquiquadrate, 2.0 * m); t.set_orb(EAspectKind::Quintile, 1.5 * m); t.set_orb(EAspectKind::BiQuintile, 1.5 * m); t.set_orb(EAspectKind::Septile, 1.5 * m); t } fn push_overlay_meta(render: &mut RenderModel, module_id: &str, label: String) { render.overlays.push(OverlayMeta { module_id: module_id.to_string(), label, }); } /// 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 { 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", ];