gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198629 Definiciones

⎇ d890bd4b3a7aa8af918e854c1ae6754ca08673cd crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs Rust · 1176 líneas · 41.6 KB Historia Quién tocó qué crudo

feat(tahuantinsuyu): fase 21 — background compute + UranianModule d890bd4b3 · sergio ·

1//! Bridge real: `tahuantinsuyu_model::Chart` → eternal_astrology → [`RenderModel`].
2//!
3//! La sesión de efemérides VSOP2013 es **compartida globalmente** vía
4//! `OnceLock` — abrirla cuesta unos cuantos ms (carga de las series en
5//! memoria), y como es read-only se puede leer en paralelo desde varios
6//! cómputos.
7
8use std::sync::OnceLock;
9use std::time::Instant;
10
11use eternal_astrology::{
12    composite, find_aspects, find_synastry_aspects, next_return, secondary_progression,
13    solar_arc_true, Aspect, AspectKind as EAspectKind, BirthData, BodySet, ChartConfig,
14    HouseSystem as EHouseSystem, NatalChart, OrbTable, Zodiac as EZodiac,
15};
16use eternal_sky::{Ayanamsha, Body, EphemerisSession, Instant as ESInstant, Observer, SessionConfig};
17
18use tahuantinsuyu_model::{Chart, HouseSystem, StoredChartConfig, Zodiac};
19
20use crate::dignity::essential_dignity;
21use crate::{
22    AspectSummary, EngineError, Geometry, Glyph, Layer, LayerKind, LineSeg, OverlayMeta,
23    RenderModel, UranianGroup,
24};
25
26// =====================================================================
27// Sesión global cacheada
28// =====================================================================
29
30static SESSION: OnceLock<EphemerisSession> = OnceLock::new();
31
32fn session() -> Result<&'static EphemerisSession, EngineError> {
33    if let Some(s) = SESSION.get() {
34        return Ok(s);
35    }
36    let opened = EphemerisSession::open(SessionConfig::vsop2013())
37        .map_err(|e| EngineError::Eternal(format!("EphemerisSession::open: {:?}", e)))?;
38    // Si otro thread ya pobló la celda mientras abríamos, el set_once
39    // falla silenciosamente — usamos el que quedó dentro.
40    let _ = SESSION.set(opened);
41    Ok(SESSION.get().expect("session was just set"))
42}
43
44// =====================================================================
45// Traducciones Stored* → eternal
46// =====================================================================
47
48fn map_house_system(h: HouseSystem) -> EHouseSystem {
49    match h {
50        HouseSystem::Placidus => EHouseSystem::Placidus,
51        HouseSystem::Koch => EHouseSystem::Koch,
52        HouseSystem::Regiomontanus => EHouseSystem::Regiomontanus,
53        HouseSystem::Campanus => EHouseSystem::Campanus,
54        HouseSystem::Porphyry => EHouseSystem::Porphyry,
55        HouseSystem::Equal => EHouseSystem::Equal,
56        HouseSystem::WholeSign => EHouseSystem::WholeSign,
57    }
58}
59
60fn map_zodiac(z: Zodiac, ayanamsha_hint: Option<&str>) -> EZodiac {
61    match z {
62        Zodiac::Tropical => EZodiac::Tropical,
63        Zodiac::Sidereal => {
64            let mode = match ayanamsha_hint.unwrap_or("lahiri").to_ascii_lowercase().as_str() {
65                "fagan_bradley" | "fagan-bradley" | "faganbradley" => Ayanamsha::FaganBradley,
66                "raman" => Ayanamsha::Raman,
67                "krishnamurti" => Ayanamsha::Krishnamurti,
68                "de_luce" | "deluce" => Ayanamsha::DeLuce,
69                "djwhal_khul" | "djwhalkhul" => Ayanamsha::DjwhalKhul,
70                "ushashashi" => Ayanamsha::Ushashashi,
71                "yukteshwar" => Ayanamsha::Yukteshwar,
72                _ => Ayanamsha::Lahiri,
73            };
74            EZodiac::Sidereal(mode)
75        }
76        // Dracónico aún no soportado en eternal — caemos a tropical por
77        // ahora; cuando eternal lo agregue, lo cableamos acá.
78        Zodiac::Draconic => EZodiac::Tropical,
79    }
80}
81
82fn map_body_set(cfg: &StoredChartConfig) -> BodySet {
83    let mut bodies: Vec<Body> = Vec::new();
84    for name in &cfg.bodies {
85        if let Some(b) = map_body(name) {
86            bodies.push(b);
87        }
88    }
89    if bodies.is_empty() {
90        // Default razonable si el config vino vacío.
91        return BodySet::classical_modern();
92    }
93    let mut set = BodySet {
94        bodies,
95        include_south_node: cfg.include_south_node,
96    };
97    if cfg.include_lilith {
98        set = set.with_lilith();
99    }
100    if cfg.include_main_belt_asteroids {
101        set = set.with_main_belt_asteroids();
102    }
103    set
104}
105
106fn map_body(name: &str) -> Option<Body> {
107    Some(match name.to_ascii_lowercase().as_str() {
108        "sun" => Body::Sun,
109        "moon" => Body::Moon,
110        "mercury" => Body::Mercury,
111        "venus" => Body::Venus,
112        "mars" => Body::Mars,
113        "jupiter" => Body::Jupiter,
114        "saturn" => Body::Saturn,
115        "uranus" => Body::Uranus,
116        "neptune" => Body::Neptune,
117        "pluto" => Body::Pluto,
118        "mean_node" | "meannode" => Body::MeanNode,
119        "true_node" | "truenode" => Body::TrueNode,
120        "mean_lilith" | "lilith" => Body::MeanLilith,
121        "true_lilith" => Body::TrueLilith,
122        "ceres" => Body::Ceres,
123        "pallas" => Body::Pallas,
124        "juno" => Body::Juno,
125        "vesta" => Body::Vesta,
126        _ => return None,
127    })
128}
129
130fn body_symbol(b: Body) -> &'static str {
131    match b {
132        Body::Sun => "sun",
133        Body::Moon => "moon",
134        Body::Mercury => "mercury",
135        Body::Venus => "venus",
136        Body::Mars => "mars",
137        Body::Jupiter => "jupiter",
138        Body::Saturn => "saturn",
139        Body::Uranus => "uranus",
140        Body::Neptune => "neptune",
141        Body::Pluto => "pluto",
142        Body::MeanNode => "north_node",
143        Body::TrueNode => "north_node",
144        Body::MeanLilith => "lilith",
145        Body::TrueLilith => "lilith",
146        Body::Ceres => "ceres",
147        Body::Pallas => "pallas",
148        Body::Juno => "juno",
149        Body::Vesta => "vesta",
150        Body::Chiron => "chiron",
151        Body::Pholus => "chiron",
152        Body::Eris => "chiron",
153        Body::Sedna => "chiron",
154        // `Body` es `#[non_exhaustive]` — cualquier cuerpo nuevo
155        // upstream cae al símbolo de fallback hasta que lo cableemos.
156        _ => "custom",
157    }
158}
159
160fn aspect_kind_id(k: EAspectKind) -> &'static str {
161    match k {
162        EAspectKind::Conjunction => "conjunction",
163        EAspectKind::Opposition => "opposition",
164        EAspectKind::Trine => "trine",
165        EAspectKind::Square => "square",
166        EAspectKind::Sextile => "sextile",
167        EAspectKind::Quincunx => "quincunx",
168        EAspectKind::SemiSextile => "semi_sextile",
169        EAspectKind::SemiSquare => "semi_square",
170        EAspectKind::Sesquiquadrate => "sesquiquadrate",
171        EAspectKind::Quintile => "quintile",
172        EAspectKind::BiQuintile => "biquintile",
173        EAspectKind::Septile => "septile",
174    }
175}
176
177// =====================================================================
178// compute()
179// =====================================================================
180
181/// Construye los tipos eternales (`BirthData`, `ChartConfig`) desde el
182/// `Chart` agnóstico, aplicando el offset temporal. Devuelve también el
183/// `Observer` y la `ChartConfig` para reusar en pipelines extendidas
184/// (transits, sinastría) sin re-traducir.
185fn build_eternal_inputs(
186    chart: &Chart,
187    offset_minutes: i64,
188) -> Result<(BirthData, ChartConfig, Observer), EngineError> {
189    chart.validate()?;
190    let bd = &chart.birth_data;
191    let base_instant = ESInstant::from_civil_local(
192        bd.year,
193        u8::try_from(bd.month).map_err(|_| {
194            EngineError::Eternal(format!("mes fuera de u8: {}", bd.month))
195        })?,
196        u8::try_from(bd.day).map_err(|_| {
197            EngineError::Eternal(format!("día fuera de u8: {}", bd.day))
198        })?,
199        u8::try_from(bd.hour).map_err(|_| {
200            EngineError::Eternal(format!("hora fuera de u8: {}", bd.hour))
201        })?,
202        u8::try_from(bd.minute).map_err(|_| {
203            EngineError::Eternal(format!("minuto fuera de u8: {}", bd.minute))
204        })?,
205        bd.second,
206        bd.tz_offset_minutes,
207    )
208    .map_err(|e| EngineError::Eternal(format!("Instant::from_civil_local: {:?}", e)))?;
209
210    let instant = if offset_minutes == 0 {
211        base_instant
212    } else {
213        let shifted_utc = base_instant.utc().add_seconds((offset_minutes as f64) * 60.0);
214        ESInstant::from_utc(shifted_utc)
215    };
216
217    let observer = Observer::from_degrees(bd.latitude_deg, bd.longitude_deg, bd.altitude_m);
218    let mut birth_e = BirthData::new(instant, observer);
219    if let Some(name) = &bd.subject_name {
220        birth_e = birth_e.with_name(name.clone());
221    }
222    let config_e = ChartConfig {
223        house_system: map_house_system(chart.config.house_system),
224        zodiac: map_zodiac(chart.config.zodiac, chart.config.ayanamsha.as_deref()),
225        bodies: map_body_set(&chart.config),
226        include_horizon: false,
227    };
228    Ok((birth_e, config_e, observer))
229}
230
231/// Computa solo la `NatalChart` (sin construir RenderModel). Útil para
232/// pipelines compuestas (transits, sinastría) que necesitan el natal
233/// crudo para correr `find_synastry_aspects`.
234fn compute_natal_chart(
235    chart: &Chart,
236    offset_minutes: i64,
237) -> Result<(NatalChart, ChartConfig, Observer), EngineError> {
238    let (birth_e, config_e, observer) = build_eternal_inputs(chart, offset_minutes)?;
239    let session = session()?;
240    let natal = NatalChart::compute(&birth_e, &config_e, session)
241        .map_err(|e| EngineError::Eternal(format!("NatalChart::compute: {:?}", e)))?;
242    Ok((natal, config_e, observer))
243}
244
245/// Composición principal: natal + overlays pedidos. Es la función que
246/// `lib::compose` delega cuando el feature `eternal-bridge` está activo.
247pub fn compose(
248    chart: &Chart,
249    offset_minutes: i64,
250    requests: &[crate::PipelineRequest],
251    natal_options: &crate::NatalOptions,
252) -> Result<RenderModel, EngineError> {
253    let t0 = Instant::now();
254    let (natal, config_e, observer) = compute_natal_chart(chart, offset_minutes)?;
255    let orb_table = build_orb_table(natal_options.orb_multiplier);
256    let all_aspects = find_aspects(&natal, &orb_table);
257    let aspects: Vec<Aspect> = all_aspects
258        .into_iter()
259        .filter(|a| {
260            let is_major = EAspectKind::MAJORS.contains(&a.kind);
261            (is_major && natal_options.show_majors)
262                || (!is_major && natal_options.show_minors)
263        })
264        .collect();
265    let mut render = build_render_model(chart, &natal, &aspects, t0);
266    if natal_options.show_dignities {
267        annotate_dignities(&natal, &mut render);
268    }
269    populate_natal_aspect_summary(&aspects, &mut render);
270
271    for req in requests {
272        match req {
273            crate::PipelineRequest::Transit => {
274                build_transit_overlay(&natal, &config_e, observer, ESInstant::now(), &mut render)?;
275                push_overlay_meta(&mut render, "transit", "Tránsito ahora".into());
276            }
277            crate::PipelineRequest::SecondaryProgression { target_age_years } => {
278                build_progression_overlay(&natal, *target_age_years, &mut render)?;
279                push_overlay_meta(
280                    &mut render,
281                    "progression",
282                    format!("Progresión {:.1}a", target_age_years),
283                );
284            }
285            crate::PipelineRequest::SolarArc { target_age_years } => {
286                build_solar_arc_overlay(&natal, *target_age_years, &mut render)?;
287                push_overlay_meta(
288                    &mut render,
289                    "solar_arc",
290                    format!("Solar Arc {:.1}a", target_age_years),
291                );
292            }
293            crate::PipelineRequest::Synastry { partner_chart } => {
294                let partner_label = partner_chart.label.clone();
295                build_synastry_overlay(&natal, partner_chart, &mut render)?;
296                push_overlay_meta(
297                    &mut render,
298                    "synastry",
299                    format!("Sinastría · {}", partner_label),
300                );
301            }
302            crate::PipelineRequest::Midpoints => {
303                build_midpoints_overlay(&natal, &mut render);
304                push_overlay_meta(&mut render, "midpoints", "Midpoints ☉/☽".into());
305            }
306            crate::PipelineRequest::PlanetaryReturn {
307                body,
308                target_age_years,
309                shift_days,
310            } => {
311                let body_e = map_body(body).ok_or_else(|| {
312                    EngineError::Eternal(format!(
313                        "body desconocido para planetary return: {}",
314                        body
315                    ))
316                })?;
317                build_planetary_return_overlay(
318                    &natal,
319                    &config_e,
320                    observer,
321                    body_e,
322                    *target_age_years,
323                    *shift_days,
324                    &mut render,
325                )?;
326                let shift_label = if *shift_days == 0 {
327                    String::new()
328                } else {
329                    format!(" {:+}d", shift_days)
330                };
331                push_overlay_meta(
332                    &mut render,
333                    "planetary_return",
334                    format!("{} return {:.0}a{}", body_e.name(), target_age_years, shift_label),
335                );
336            }
337            crate::PipelineRequest::Composite { partner_chart } => {
338                let partner_label = partner_chart.label.clone();
339                build_composite_overlay(&natal, partner_chart, &mut render)?;
340                push_overlay_meta(
341                    &mut render,
342                    "composite",
343                    format!("Composite · {}", partner_label),
344                );
345            }
346            crate::PipelineRequest::Uranian => {
347                build_uranian_groups(&natal, &mut render);
348                let n = render.uranian_groups.len();
349                push_overlay_meta(
350                    &mut render,
351                    "uranian",
352                    if n == 0 {
353                        "Uraniano · sin ejes".into()
354                    } else {
355                        format!("Uraniano · {} ejes", n)
356                    },
357                );
358            }
359        }
360    }
361
362    render.compute_ms = t0.elapsed().as_millis() as u64;
363    Ok(render)
364}
365
366/// Helper: agrega al `RenderModel` las dos capas del overlay de
367/// tránsitos (Outer + cross Aspects).
368fn build_transit_overlay(
369    natal: &NatalChart,
370    config_e: &ChartConfig,
371    observer: Observer,
372    transit_at: ESInstant,
373    render: &mut RenderModel,
374) -> Result<(), EngineError> {
375    let transit_birth = BirthData::new(transit_at, observer);
376    let session = session()?;
377    let transit = NatalChart::compute(&transit_birth, config_e, session).map_err(|e| {
378        EngineError::Eternal(format!("NatalChart::compute (transit): {:?}", e))
379    })?;
380
381    let outer_glyphs: Vec<Glyph> = transit
382        .placements
383        .iter()
384        .map(|p| Glyph {
385            deg: p.longitude.longitude_deg() as f32,
386            symbol: body_symbol(p.body).into(),
387            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
388            retrograde: p.longitude_rate_rad_per_day < 0.0,
389            house: None,
390            dignity_marker: None,
391        })
392        .collect();
393    render.layers.push(Layer {
394        module_id: "transit".into(),
395        kind: LayerKind::Outer,
396        ring: 0.82,
397        z: 4,
398        geometry: Geometry::GlyphsOnly,
399        glyphs: outer_glyphs,
400    });
401
402    let cross = find_synastry_aspects(
403        natal,
404        &transit,
405        &OrbTable::modern_western(),
406        EAspectKind::MAJORS,
407    );
408    let cross_lines: Vec<LineSeg> = cross
409        .iter()
410        .filter_map(|a| {
411            let natal_p = natal.placement(a.person_a_body)?;
412            let transit_p = transit.placement(a.person_b_body)?;
413            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
414            Some(LineSeg {
415                from_deg: natal_p.longitude.longitude_deg() as f32,
416                to_deg: transit_p.longitude.longitude_deg() as f32,
417                kind: aspect_kind_id(a.kind).into(),
418                opacity: opacity * 0.75,
419            })
420        })
421        .collect();
422    render.layers.push(Layer {
423        module_id: "transit".into(),
424        kind: LayerKind::Aspects,
425        ring: 0.0,
426        z: 5,
427        geometry: Geometry::Lines(cross_lines),
428        glyphs: Vec::new(),
429    });
430    populate_cross_aspect_summary(&cross, "transit", render);
431    Ok(())
432}
433
434/// Helper: agrega al `RenderModel` las capas del overlay de progresión
435/// secundaria. La carta progresada se computa con el mismo observer y
436/// config que la natal pero al instante natal+(age_years/period_years)
437/// días.
438fn build_progression_overlay(
439    natal: &NatalChart,
440    target_age_years: f64,
441    render: &mut RenderModel,
442) -> Result<(), EngineError> {
443    let session = session()?;
444    let prog = secondary_progression(natal, session, target_age_years)
445        .map_err(|e| EngineError::Eternal(format!("secondary_progression: {:?}", e)))?;
446    let progressed = &prog.progressed;
447
448    // Glifos de los cuerpos progresados — anillo interno (radio 0.48).
449    let glyphs: Vec<Glyph> = progressed
450        .placements
451        .iter()
452        .map(|p| Glyph {
453            deg: p.longitude.longitude_deg() as f32,
454            symbol: body_symbol(p.body).into(),
455            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
456            retrograde: p.longitude_rate_rad_per_day < 0.0,
457            house: Some(p.house_number),
458            dignity_marker: None,
459        })
460        .collect();
461    render.layers.push(Layer {
462        module_id: "progression".into(),
463        kind: LayerKind::Bodies,
464        ring: 0.48,
465        z: 6,
466        geometry: Geometry::GlyphsOnly,
467        glyphs,
468    });
469
470    // Cross aspects natal × progresada (sólo mayores).
471    let cross = find_synastry_aspects(
472        natal,
473        progressed,
474        &OrbTable::modern_western(),
475        EAspectKind::MAJORS,
476    );
477    let cross_lines: Vec<LineSeg> = cross
478        .iter()
479        .filter_map(|a| {
480            let natal_p = natal.placement(a.person_a_body)?;
481            let prog_p = progressed.placement(a.person_b_body)?;
482            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
483            Some(LineSeg {
484                from_deg: natal_p.longitude.longitude_deg() as f32,
485                to_deg: prog_p.longitude.longitude_deg() as f32,
486                kind: aspect_kind_id(a.kind).into(),
487                opacity: opacity * 0.7,
488            })
489        })
490        .collect();
491    render.layers.push(Layer {
492        module_id: "progression".into(),
493        kind: LayerKind::Aspects,
494        ring: 0.0,
495        z: 7,
496        geometry: Geometry::Lines(cross_lines),
497        glyphs: Vec::new(),
498    });
499    populate_cross_aspect_summary(&cross, "progression", render);
500    Ok(())
501}
502
503/// Helper: detecta "ejes" del dial uraniano de 90° — cuerpos natales
504/// cuya longitud módulo 90 cae dentro de una tolerancia ε (2° por
505/// default). Llena `render.uranian_groups` con los grupos detectados.
506fn build_uranian_groups(natal: &NatalChart, render: &mut RenderModel) {
507    const EPSILON: f64 = 2.0;
508    let mut entries: Vec<(String, f64)> = natal
509        .placements
510        .iter()
511        .map(|p| {
512            let lon = p.longitude.longitude_deg();
513            let mod90 = lon.rem_euclid(90.0);
514            (body_symbol(p.body).to_string(), mod90)
515        })
516        .collect();
517    entries.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
518
519    let mut groups: Vec<UranianGroup> = Vec::new();
520    let mut current: Vec<(String, f64)> = Vec::new();
521    let mut anchor_mod90 = 0.0_f64;
522    for entry in entries {
523        if current.is_empty() {
524            anchor_mod90 = entry.1;
525            current.push(entry);
526            continue;
527        }
528        // Distancia circular módulo 90 entre el entry y el anchor.
529        let mut diff = (entry.1 - anchor_mod90).abs();
530        if diff > 45.0 {
531            diff = 90.0 - diff;
532        }
533        if diff <= EPSILON {
534            current.push(entry);
535        } else {
536            if current.len() >= 2 {
537                let center = current.iter().map(|(_, m)| *m).sum::<f64>() / current.len() as f64;
538                groups.push(UranianGroup {
539                    bodies: current.iter().map(|(s, _)| s.clone()).collect(),
540                    mod90_deg: center,
541                });
542            }
543            anchor_mod90 = entry.1;
544            current = vec![entry];
545        }
546    }
547    if current.len() >= 2 {
548        let center = current.iter().map(|(_, m)| *m).sum::<f64>() / current.len() as f64;
549        groups.push(UranianGroup {
550            bodies: current.iter().map(|(s, _)| s.clone()).collect(),
551            mod90_deg: center,
552        });
553    }
554    // Wrap-around check: el primer y último grupo podrían ser el mismo
555    // (si span >88° abarcando el wrap en 90/0). Si los anchors están a
556    // ≤EPSILON modulo 90, mergeamos.
557    if groups.len() >= 2 {
558        let first_mod = groups[0].mod90_deg;
559        let last_mod = groups[groups.len() - 1].mod90_deg;
560        let mut diff = (first_mod - last_mod).abs();
561        if diff > 45.0 {
562            diff = 90.0 - diff;
563        }
564        if diff <= EPSILON {
565            let last = groups.pop().unwrap();
566            groups[0].bodies.extend(last.bodies);
567        }
568    }
569    render.uranian_groups = groups;
570}
571
572/// Helper: agrega al `RenderModel` la carta compuesta (midpoint
573/// composite, Davison 1958) entre la natal del sujeto y la carta del
574/// partner. Cada planeta compuesto es el angular midpoint entre los
575/// dos correspondientes. Se renderea en `radii.composite` (ring 0.36).
576fn build_composite_overlay(
577    natal: &NatalChart,
578    partner_chart: &Chart,
579    render: &mut RenderModel,
580) -> Result<(), EngineError> {
581    let (partner_natal, _config, _observer) = compute_natal_chart(partner_chart, 0)?;
582    let comp = composite(natal, &partner_natal).map_err(|e| {
583        EngineError::Eternal(format!("composite: {:?}", e))
584    })?;
585
586    let glyphs: Vec<Glyph> = comp
587        .placements
588        .iter()
589        .map(|p| Glyph {
590            deg: p.longitude.longitude_deg() as f32,
591            symbol: body_symbol(p.body).into(),
592            annotation: Some(format!("composite {}", p.body.name())),
593            retrograde: false,
594            house: Some(p.house_number),
595            dignity_marker: None,
596        })
597        .collect();
598    render.layers.push(Layer {
599        module_id: "composite".into(),
600        kind: LayerKind::Bodies,
601        ring: 0.36,
602        z: 15,
603        geometry: Geometry::GlyphsOnly,
604        glyphs,
605    });
606    Ok(())
607}
608
609/// Helper: agrega al `RenderModel` los midpoints entre pares de
610/// cuerpos natales. Filtra para mostrar solo los que involucran al
611/// Sol o a la Luna (~10 puntos) — son los más significativos
612/// astrológicamente y mantiene la rueda legible.
613///
614/// El midpoint de dos longitudes es la menor distancia angular entre
615/// ellas. Si `|a - b| > 180`, hay que sumar 180 al promedio para
616/// obtener el midpoint "corto".
617fn build_midpoints_overlay(natal: &NatalChart, render: &mut RenderModel) {
618    let mut glyphs: Vec<Glyph> = Vec::new();
619    let placements = &natal.placements;
620
621    for i in 0..placements.len() {
622        for j in (i + 1)..placements.len() {
623            let pa = &placements[i];
624            let pb = &placements[j];
625            // Solo midpoints que involucren Sol o Luna.
626            let involves_luminary = matches!(pa.body, Body::Sun | Body::Moon)
627                || matches!(pb.body, Body::Sun | Body::Moon);
628            if !involves_luminary {
629                continue;
630            }
631            let a = pa.longitude.longitude_deg() as f32;
632            let b = pb.longitude.longitude_deg() as f32;
633            let diff = (a - b).abs();
634            let mid = if diff > 180.0 {
635                ((a + b) / 2.0 + 180.0).rem_euclid(360.0)
636            } else {
637                ((a + b) / 2.0).rem_euclid(360.0)
638            };
639            glyphs.push(Glyph {
640                deg: mid,
641                symbol: format!("{}/{}", body_symbol(pa.body), body_symbol(pb.body)),
642                annotation: Some(format!("{}/{}", pa.body.name(), pb.body.name())),
643                retrograde: false,
644                house: None,
645                dignity_marker: None,
646            });
647        }
648    }
649
650    render.layers.push(Layer {
651        module_id: "midpoints".into(),
652        kind: LayerKind::Midpoints,
653        ring: 0.62,
654        z: 14,
655        geometry: Geometry::GlyphsOnly,
656        glyphs,
657    });
658}
659
660/// Helper: agrega al `RenderModel` las capas del overlay de Solar Arc
661/// (método true-progressed-Sun por default). Cada cuerpo natal se
662/// desplaza por el mismo arco — preserva las relaciones angulares y
663/// las posiciones relativas en casas se mantienen.
664fn build_solar_arc_overlay(
665    natal: &NatalChart,
666    target_age_years: f64,
667    render: &mut RenderModel,
668) -> Result<(), EngineError> {
669    let session = session()?;
670    let arc = solar_arc_true(natal, session, target_age_years)
671        .map_err(|e| EngineError::Eternal(format!("solar_arc_true: {:?}", e)))?;
672    let directed = &arc.directed;
673
674    let glyphs: Vec<Glyph> = directed
675        .placements
676        .iter()
677        .map(|p| Glyph {
678            deg: p.longitude.longitude_deg() as f32,
679            symbol: body_symbol(p.body).into(),
680            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
681            retrograde: p.longitude_rate_rad_per_day < 0.0,
682            house: Some(p.house_number),
683            dignity_marker: None,
684        })
685        .collect();
686    render.layers.push(Layer {
687        module_id: "solar_arc".into(),
688        kind: LayerKind::Bodies,
689        ring: 0.43,
690        z: 8,
691        geometry: Geometry::GlyphsOnly,
692        glyphs,
693    });
694
695    let cross = find_synastry_aspects(
696        natal,
697        directed,
698        &OrbTable::modern_western(),
699        EAspectKind::MAJORS,
700    );
701    let cross_lines: Vec<LineSeg> = cross
702        .iter()
703        .filter_map(|a| {
704            let natal_p = natal.placement(a.person_a_body)?;
705            let dir_p = directed.placement(a.person_b_body)?;
706            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
707            Some(LineSeg {
708                from_deg: natal_p.longitude.longitude_deg() as f32,
709                to_deg: dir_p.longitude.longitude_deg() as f32,
710                kind: aspect_kind_id(a.kind).into(),
711                opacity: opacity * 0.7,
712            })
713        })
714        .collect();
715    render.layers.push(Layer {
716        module_id: "solar_arc".into(),
717        kind: LayerKind::Aspects,
718        ring: 0.0,
719        z: 9,
720        geometry: Geometry::Lines(cross_lines),
721        glyphs: Vec::new(),
722    });
723    populate_cross_aspect_summary(&cross, "solar_arc", render);
724    Ok(())
725}
726
727/// Helper: agrega al `RenderModel` las capas del overlay de sinastría
728/// con otra carta natal completa. La carta partner se computa con su
729/// propio observer/config (no comparte con la natal). El outer ring
730/// se comparte con Transit — mutuamente excluyentes a nivel de Shell.
731fn build_synastry_overlay(
732    natal: &NatalChart,
733    partner_chart: &Chart,
734    render: &mut RenderModel,
735) -> Result<(), EngineError> {
736    let (partner, _config, _observer) = compute_natal_chart(partner_chart, 0)?;
737
738    let glyphs: Vec<Glyph> = partner
739        .placements
740        .iter()
741        .map(|p| Glyph {
742            deg: p.longitude.longitude_deg() as f32,
743            symbol: body_symbol(p.body).into(),
744            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
745            retrograde: p.longitude_rate_rad_per_day < 0.0,
746            house: Some(p.house_number),
747            dignity_marker: None,
748        })
749        .collect();
750    render.layers.push(Layer {
751        module_id: "synastry".into(),
752        kind: LayerKind::Outer,
753        ring: 0.82,
754        z: 10,
755        geometry: Geometry::GlyphsOnly,
756        glyphs,
757    });
758
759    let cross = find_synastry_aspects(
760        natal,
761        &partner,
762        &OrbTable::modern_western(),
763        EAspectKind::MAJORS,
764    );
765    let cross_lines: Vec<LineSeg> = cross
766        .iter()
767        .filter_map(|a| {
768            let natal_p = natal.placement(a.person_a_body)?;
769            let partner_p = partner.placement(a.person_b_body)?;
770            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
771            Some(LineSeg {
772                from_deg: natal_p.longitude.longitude_deg() as f32,
773                to_deg: partner_p.longitude.longitude_deg() as f32,
774                kind: aspect_kind_id(a.kind).into(),
775                opacity: opacity * 0.85,
776            })
777        })
778        .collect();
779    render.layers.push(Layer {
780        module_id: "synastry".into(),
781        kind: LayerKind::Aspects,
782        ring: 0.0,
783        z: 11,
784        geometry: Geometry::Lines(cross_lines),
785        glyphs: Vec::new(),
786    });
787    populate_cross_aspect_summary(&cross, "synastry", render);
788    Ok(())
789}
790
791/// Helper: agrega al `RenderModel` las capas del overlay de retorno
792/// planetario — la carta natal completa computada al instante en que
793/// el `body` vuelve a su posición natal cerca de la edad pedida.
794/// Sun = retorno solar anual, Moon = mensual, Júpiter/Saturno =
795/// generacionales. Esa nueva carta va en el anillo externo (compartido
796/// con Transit/Synastry, mutuamente excluyentes a nivel de Shell).
797/// Cross aspects natal × return.
798fn build_planetary_return_overlay(
799    natal: &NatalChart,
800    config_e: &ChartConfig,
801    observer: Observer,
802    body: Body,
803    target_age_years: f64,
804    shift_days: i64,
805    render: &mut RenderModel,
806) -> Result<(), EngineError> {
807    let session = session()?;
808    let natal_p = natal.placement(body).ok_or_else(|| {
809        EngineError::Eternal(format!(
810            "natal chart sin {} — return imposible",
811            body.name()
812        ))
813    })?;
814    let natal_lon = natal_p.longitude.longitude_rad();
815
816    // El offset desde el cumpleaños depende del período sinódico del
817    // cuerpo: para Sun/planet lentos, ~30 días antes garantiza captar
818    // el return; para Moon, ~15 días. Tomamos un margen amplio que
819    // sirve para todos.
820    const TROPICAL_YEAR_SECS: f64 = 365.242190 * 86400.0;
821    // shift_days permite saltar de un retorno mensual al siguiente
822    // cuando body=Moon, o ajustar finamente el año en Solar return.
823    let after_seconds =
824        (target_age_years * 365.242190 - 30.0 + shift_days as f64) * 86400.0;
825    let after_utc = natal
826        .birth
827        .instant
828        .utc()
829        .add_seconds(after_seconds.max(-TROPICAL_YEAR_SECS * 2.0));
830    let after = ESInstant::from_utc(after_utc);
831
832    let return_instant = next_return(session, body, natal_lon, after, None).map_err(|e| {
833        EngineError::Eternal(format!("next_return {}: {:?}", body.name(), e))
834    })?;
835
836    // La carta del retorno se computa al return_instant con el mismo
837    // observer y config natales (convención clásica: return tropical
838    // en la ciudad de nacimiento).
839    let return_birth = BirthData::new(return_instant, observer);
840    let return_chart = NatalChart::compute(&return_birth, config_e, session).map_err(|e| {
841        EngineError::Eternal(format!(
842            "NatalChart::compute ({} return): {:?}",
843            body.name(),
844            e
845        ))
846    })?;
847
848    let glyphs: Vec<Glyph> = return_chart
849        .placements
850        .iter()
851        .map(|p| Glyph {
852            deg: p.longitude.longitude_deg() as f32,
853            symbol: body_symbol(p.body).into(),
854            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
855            retrograde: p.longitude_rate_rad_per_day < 0.0,
856            house: Some(p.house_number),
857            dignity_marker: None,
858        })
859        .collect();
860    render.layers.push(Layer {
861        module_id: "planetary_return".into(),
862        kind: LayerKind::Outer,
863        ring: 0.82,
864        z: 12,
865        geometry: Geometry::GlyphsOnly,
866        glyphs,
867    });
868
869    let cross = find_synastry_aspects(
870        natal,
871        &return_chart,
872        &OrbTable::modern_western(),
873        EAspectKind::MAJORS,
874    );
875    let cross_lines: Vec<LineSeg> = cross
876        .iter()
877        .filter_map(|a| {
878            let n_p = natal.placement(a.person_a_body)?;
879            let r_p = return_chart.placement(a.person_b_body)?;
880            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
881            Some(LineSeg {
882                from_deg: n_p.longitude.longitude_deg() as f32,
883                to_deg: r_p.longitude.longitude_deg() as f32,
884                kind: aspect_kind_id(a.kind).into(),
885                opacity: opacity * 0.8,
886            })
887        })
888        .collect();
889    render.layers.push(Layer {
890        module_id: "planetary_return".into(),
891        kind: LayerKind::Aspects,
892        ring: 0.0,
893        z: 13,
894        geometry: Geometry::Lines(cross_lines),
895        glyphs: Vec::new(),
896    });
897    populate_cross_aspect_summary(&cross, "planetary_return", render);
898    Ok(())
899}
900
901// =====================================================================
902// NatalChart → RenderModel
903// =====================================================================
904
905fn build_render_model(
906    chart: &Chart,
907    natal: &NatalChart,
908    aspects: &[Aspect],
909    started: Instant,
910) -> RenderModel {
911    let ascendant_deg = natal.ascendant().longitude_deg() as f32;
912    let midheaven_deg = natal.midheaven().longitude_deg() as f32;
913    let descendant_deg = natal.descendant().longitude_deg() as f32;
914    let imum_coeli_deg = natal.imum_coeli().longitude_deg() as f32;
915
916    // ─── Capa 0: Sign Dial ────────────────────────────────────────────
917    let sign_dial = Layer {
918        module_id: "natal".into(),
919        kind: LayerKind::SignDial,
920        ring: 1.0,
921        z: 0,
922        geometry: Geometry::Ring {
923            cusps_deg: (0..12).map(|i| (i as f32) * 30.0).collect(),
924        },
925        glyphs: (0..12)
926            .map(|i| Glyph {
927                deg: (i as f32) * 30.0 + 15.0,
928                symbol: ZODIAC_SYMBOLS[i].into(),
929                annotation: None,
930                retrograde: false,
931                house: None,
932            dignity_marker: None,
933            })
934            .collect(),
935    };
936
937    // ─── Capa 1: Houses ───────────────────────────────────────────────
938    let cusps_deg: Vec<f32> = natal
939        .houses
940        .cusps
941        .iter()
942        .map(|c| c.to_degrees() as f32)
943        .collect();
944    let houses = Layer {
945        module_id: "natal".into(),
946        kind: LayerKind::Houses,
947        ring: 0.86,
948        z: 1,
949        geometry: Geometry::Ring {
950            cusps_deg: cusps_deg.clone(),
951        },
952        glyphs: cusps_deg
953            .iter()
954            .enumerate()
955            .map(|(i, c)| Glyph {
956                deg: *c + 4.0,
957                symbol: format!("h{}", i + 1),
958                annotation: None,
959                retrograde: false,
960                house: Some((i as u8) + 1),
961                dignity_marker: None,
962            })
963            .collect(),
964    };
965
966    // ─── Capa 2: Bodies ───────────────────────────────────────────────
967    let body_glyphs: Vec<Glyph> = natal
968        .placements
969        .iter()
970        .map(|p| Glyph {
971            deg: p.longitude.longitude_deg() as f32,
972            symbol: body_symbol(p.body).into(),
973            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
974            // `BodyPlacement` cambió entre versiones de eternal entre
975            // `pub fn is_retrograde(&self) -> bool` y `pub
976            // is_retrograde: bool` — leemos el campo crudo
977            // `longitude_rate_rad_per_day` (estable en ambas) para no
978            // depender del wrapper.
979            retrograde: p.longitude_rate_rad_per_day < 0.0,
980            house: Some(p.house_number),
981            dignity_marker: None,
982        })
983        .collect();
984    let bodies = Layer {
985        module_id: "natal".into(),
986        kind: LayerKind::Bodies,
987        ring: 0.72,
988        z: 2,
989        geometry: Geometry::Points(
990            natal
991                .placements
992                .iter()
993                .map(|p| crate::PointMark {
994                    deg: p.longitude.longitude_deg() as f32,
995                    label: p.body.name().into(),
996                    tag: body_symbol(p.body).into(),
997                })
998                .collect(),
999        ),
1000        glyphs: body_glyphs,
1001    };
1002
1003    // ─── Capa 3: Aspects ──────────────────────────────────────────────
1004    // Los aspects ya vienen filtrados por NatalOptions (majors / minors)
1005    // desde compose(). Acá solo mapeamos a LineSeg.
1006    let mut aspect_lines: Vec<LineSeg> = Vec::with_capacity(aspects.len());
1007    for a in aspects {
1008        let pa = natal.placement(a.a);
1009        let pb = natal.placement(a.b);
1010        if let (Some(pa), Some(pb)) = (pa, pb) {
1011            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
1012            aspect_lines.push(LineSeg {
1013                from_deg: pa.longitude.longitude_deg() as f32,
1014                to_deg: pb.longitude.longitude_deg() as f32,
1015                kind: aspect_kind_id(a.kind).into(),
1016                opacity,
1017            });
1018        }
1019    }
1020    let aspects_layer = Layer {
1021        module_id: "natal".into(),
1022        kind: LayerKind::Aspects,
1023        ring: 0.58,
1024        z: 3,
1025        geometry: Geometry::Lines(aspect_lines),
1026        glyphs: Vec::new(),
1027    };
1028
1029    let subtitle = chart
1030        .birth_data
1031        .birthplace_label
1032        .clone()
1033        .or_else(|| {
1034            Some(format!(
1035                "{:04}-{:02}-{:02} · lat {:+.2}° · lon {:+.2}°",
1036                chart.birth_data.year,
1037                chart.birth_data.month,
1038                chart.birth_data.day,
1039                chart.birth_data.latitude_deg,
1040                chart.birth_data.longitude_deg,
1041            ))
1042        });
1043
1044    RenderModel {
1045        chart_id: chart.id,
1046        chart_kind: chart.kind,
1047        title: chart.label.clone(),
1048        subtitle,
1049        compute_ms: started.elapsed().as_millis() as u64,
1050        ascendant_deg,
1051        midheaven_deg,
1052        descendant_deg,
1053        imum_coeli_deg,
1054        layers: vec![sign_dial, houses, bodies, aspects_layer],
1055        overlays: Vec::new(),
1056        aspect_summary: Vec::new(),
1057        uranian_groups: Vec::new(),
1058    }
1059}
1060
1061/// Construye una `OrbTable` con los orbes default de `modern_western`
1062/// escalados por `multiplier`. Necesario porque eternal expone
1063/// `set_orb` pero no permite iterar los base orbs internos.
1064fn build_orb_table(multiplier: f64) -> OrbTable {
1065    let mut t = OrbTable::modern_western();
1066    let m = multiplier.max(0.0);
1067    t.set_orb(EAspectKind::Conjunction, 8.0 * m);
1068    t.set_orb(EAspectKind::Opposition, 8.0 * m);
1069    t.set_orb(EAspectKind::Trine, 7.0 * m);
1070    t.set_orb(EAspectKind::Square, 7.0 * m);
1071    t.set_orb(EAspectKind::Sextile, 5.0 * m);
1072    t.set_orb(EAspectKind::Quincunx, 2.5 * m);
1073    t.set_orb(EAspectKind::SemiSextile, 2.0 * m);
1074    t.set_orb(EAspectKind::SemiSquare, 2.0 * m);
1075    t.set_orb(EAspectKind::Sesquiquadrate, 2.0 * m);
1076    t.set_orb(EAspectKind::Quintile, 1.5 * m);
1077    t.set_orb(EAspectKind::BiQuintile, 1.5 * m);
1078    t.set_orb(EAspectKind::Septile, 1.5 * m);
1079    t
1080}
1081
1082fn push_overlay_meta(render: &mut RenderModel, module_id: &str, label: String) {
1083    render.overlays.push(OverlayMeta {
1084        module_id: module_id.to_string(),
1085        label,
1086    });
1087}
1088
1089/// Decora cada Glyph de Bodies (module_id="natal") con su dignity
1090/// marker en `glyph.dignity_marker`. Usa `essential_dignity(body, sign)`
1091/// — los cuerpos modernos quedan sin marker.
1092fn annotate_dignities(natal: &NatalChart, render: &mut RenderModel) {
1093    use std::collections::HashMap;
1094    let mut by_symbol: HashMap<&'static str, &'static str> = HashMap::new();
1095    for p in &natal.placements {
1096        let sign_idx = (p.longitude.longitude_deg() / 30.0).floor() as u8 % 12;
1097        if let Some(d) = essential_dignity(p.body, sign_idx) {
1098            by_symbol.insert(body_symbol(p.body), d.marker());
1099        }
1100    }
1101    for layer in render.layers.iter_mut() {
1102        if matches!(layer.kind, LayerKind::Bodies) && layer.module_id == "natal" {
1103            for g in layer.glyphs.iter_mut() {
1104                if let Some(marker) = by_symbol.get(g.symbol.as_str()) {
1105                    g.dignity_marker = Some((*marker).to_string());
1106                }
1107            }
1108        }
1109    }
1110}
1111
1112fn populate_natal_aspect_summary(aspects: &[Aspect], render: &mut RenderModel) {
1113    for a in aspects {
1114        render.aspect_summary.push(AspectSummary {
1115            module_id: "natal".into(),
1116            from_body: body_symbol(a.a).into(),
1117            to_body: body_symbol(a.b).into(),
1118            kind: aspect_kind_id(a.kind).into(),
1119            orb_deg: a.orb_abs_deg(),
1120            applying: Some(a.applying),
1121        });
1122    }
1123    sort_aspect_summary(render);
1124}
1125
1126fn populate_cross_aspect_summary(
1127    cross: &[eternal_astrology::SynastryAspect],
1128    module_id: &str,
1129    render: &mut RenderModel,
1130) {
1131    for a in cross {
1132        render.aspect_summary.push(AspectSummary {
1133            module_id: module_id.to_string(),
1134            from_body: body_symbol(a.person_a_body).into(),
1135            to_body: body_symbol(a.person_b_body).into(),
1136            kind: aspect_kind_id(a.kind).into(),
1137            orb_deg: a.orb_abs_deg(),
1138            applying: None,
1139        });
1140    }
1141    sort_aspect_summary(render);
1142}
1143
1144fn sort_aspect_summary(render: &mut RenderModel) {
1145    render
1146        .aspect_summary
1147        .sort_by(|x, y| x.orb_deg.partial_cmp(&y.orb_deg).unwrap_or(std::cmp::Ordering::Equal));
1148}
1149
1150/// Mapea el orb absoluto a una opacidad — los aspectos más exactos se
1151/// pintan más fuerte, los flojos casi se desvanecen.
1152fn orb_to_opacity(orb_deg: f64, kind: EAspectKind) -> f32 {
1153    let max = match kind {
1154        EAspectKind::Conjunction | EAspectKind::Opposition => 8.0,
1155        EAspectKind::Trine | EAspectKind::Square => 7.0,
1156        EAspectKind::Sextile => 5.0,
1157        _ => 3.0,
1158    };
1159    let t = (1.0 - (orb_deg / max).min(1.0)).max(0.25);
1160    t as f32
1161}
1162
1163const ZODIAC_SYMBOLS: [&str; 12] = [
1164    "aries",
1165    "taurus",
1166    "gemini",
1167    "cancer",
1168    "leo",
1169    "virgo",
1170    "libra",
1171    "scorpio",
1172    "sagittarius",
1173    "capricorn",
1174    "aquarius",
1175    "pisces",
1176];