tawasuyu

tawasuyu/brahman

Rust 201TOML 61JSON 7HTML 12439 Definiciones

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

feat(tahuantinsuyu): fase 20 — accordion + lunar shift + CompositeModule + 90 ciudades d3649bfd1 · 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,
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        }
347    }
348
349    render.compute_ms = t0.elapsed().as_millis() as u64;
350    Ok(render)
351}
352
353/// Helper: agrega al `RenderModel` las dos capas del overlay de
354/// tránsitos (Outer + cross Aspects).
355fn build_transit_overlay(
356    natal: &NatalChart,
357    config_e: &ChartConfig,
358    observer: Observer,
359    transit_at: ESInstant,
360    render: &mut RenderModel,
361) -> Result<(), EngineError> {
362    let transit_birth = BirthData::new(transit_at, observer);
363    let session = session()?;
364    let transit = NatalChart::compute(&transit_birth, config_e, session).map_err(|e| {
365        EngineError::Eternal(format!("NatalChart::compute (transit): {:?}", e))
366    })?;
367
368    let outer_glyphs: Vec<Glyph> = transit
369        .placements
370        .iter()
371        .map(|p| Glyph {
372            deg: p.longitude.longitude_deg() as f32,
373            symbol: body_symbol(p.body).into(),
374            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
375            retrograde: p.longitude_rate_rad_per_day < 0.0,
376            house: None,
377            dignity_marker: None,
378        })
379        .collect();
380    render.layers.push(Layer {
381        module_id: "transit".into(),
382        kind: LayerKind::Outer,
383        ring: 0.82,
384        z: 4,
385        geometry: Geometry::GlyphsOnly,
386        glyphs: outer_glyphs,
387    });
388
389    let cross = find_synastry_aspects(
390        natal,
391        &transit,
392        &OrbTable::modern_western(),
393        EAspectKind::MAJORS,
394    );
395    let cross_lines: Vec<LineSeg> = cross
396        .iter()
397        .filter_map(|a| {
398            let natal_p = natal.placement(a.person_a_body)?;
399            let transit_p = transit.placement(a.person_b_body)?;
400            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
401            Some(LineSeg {
402                from_deg: natal_p.longitude.longitude_deg() as f32,
403                to_deg: transit_p.longitude.longitude_deg() as f32,
404                kind: aspect_kind_id(a.kind).into(),
405                opacity: opacity * 0.75,
406            })
407        })
408        .collect();
409    render.layers.push(Layer {
410        module_id: "transit".into(),
411        kind: LayerKind::Aspects,
412        ring: 0.0,
413        z: 5,
414        geometry: Geometry::Lines(cross_lines),
415        glyphs: Vec::new(),
416    });
417    populate_cross_aspect_summary(&cross, "transit", render);
418    Ok(())
419}
420
421/// Helper: agrega al `RenderModel` las capas del overlay de progresión
422/// secundaria. La carta progresada se computa con el mismo observer y
423/// config que la natal pero al instante natal+(age_years/period_years)
424/// días.
425fn build_progression_overlay(
426    natal: &NatalChart,
427    target_age_years: f64,
428    render: &mut RenderModel,
429) -> Result<(), EngineError> {
430    let session = session()?;
431    let prog = secondary_progression(natal, session, target_age_years)
432        .map_err(|e| EngineError::Eternal(format!("secondary_progression: {:?}", e)))?;
433    let progressed = &prog.progressed;
434
435    // Glifos de los cuerpos progresados — anillo interno (radio 0.48).
436    let glyphs: Vec<Glyph> = progressed
437        .placements
438        .iter()
439        .map(|p| Glyph {
440            deg: p.longitude.longitude_deg() as f32,
441            symbol: body_symbol(p.body).into(),
442            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
443            retrograde: p.longitude_rate_rad_per_day < 0.0,
444            house: Some(p.house_number),
445            dignity_marker: None,
446        })
447        .collect();
448    render.layers.push(Layer {
449        module_id: "progression".into(),
450        kind: LayerKind::Bodies,
451        ring: 0.48,
452        z: 6,
453        geometry: Geometry::GlyphsOnly,
454        glyphs,
455    });
456
457    // Cross aspects natal × progresada (sólo mayores).
458    let cross = find_synastry_aspects(
459        natal,
460        progressed,
461        &OrbTable::modern_western(),
462        EAspectKind::MAJORS,
463    );
464    let cross_lines: Vec<LineSeg> = cross
465        .iter()
466        .filter_map(|a| {
467            let natal_p = natal.placement(a.person_a_body)?;
468            let prog_p = progressed.placement(a.person_b_body)?;
469            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
470            Some(LineSeg {
471                from_deg: natal_p.longitude.longitude_deg() as f32,
472                to_deg: prog_p.longitude.longitude_deg() as f32,
473                kind: aspect_kind_id(a.kind).into(),
474                opacity: opacity * 0.7,
475            })
476        })
477        .collect();
478    render.layers.push(Layer {
479        module_id: "progression".into(),
480        kind: LayerKind::Aspects,
481        ring: 0.0,
482        z: 7,
483        geometry: Geometry::Lines(cross_lines),
484        glyphs: Vec::new(),
485    });
486    populate_cross_aspect_summary(&cross, "progression", render);
487    Ok(())
488}
489
490/// Helper: agrega al `RenderModel` la carta compuesta (midpoint
491/// composite, Davison 1958) entre la natal del sujeto y la carta del
492/// partner. Cada planeta compuesto es el angular midpoint entre los
493/// dos correspondientes. Se renderea en `radii.composite` (ring 0.36).
494fn build_composite_overlay(
495    natal: &NatalChart,
496    partner_chart: &Chart,
497    render: &mut RenderModel,
498) -> Result<(), EngineError> {
499    let (partner_natal, _config, _observer) = compute_natal_chart(partner_chart, 0)?;
500    let comp = composite(natal, &partner_natal).map_err(|e| {
501        EngineError::Eternal(format!("composite: {:?}", e))
502    })?;
503
504    let glyphs: Vec<Glyph> = comp
505        .placements
506        .iter()
507        .map(|p| Glyph {
508            deg: p.longitude.longitude_deg() as f32,
509            symbol: body_symbol(p.body).into(),
510            annotation: Some(format!("composite {}", p.body.name())),
511            retrograde: false,
512            house: Some(p.house_number),
513            dignity_marker: None,
514        })
515        .collect();
516    render.layers.push(Layer {
517        module_id: "composite".into(),
518        kind: LayerKind::Bodies,
519        ring: 0.36,
520        z: 15,
521        geometry: Geometry::GlyphsOnly,
522        glyphs,
523    });
524    Ok(())
525}
526
527/// Helper: agrega al `RenderModel` los midpoints entre pares de
528/// cuerpos natales. Filtra para mostrar solo los que involucran al
529/// Sol o a la Luna (~10 puntos) — son los más significativos
530/// astrológicamente y mantiene la rueda legible.
531///
532/// El midpoint de dos longitudes es la menor distancia angular entre
533/// ellas. Si `|a - b| > 180`, hay que sumar 180 al promedio para
534/// obtener el midpoint "corto".
535fn build_midpoints_overlay(natal: &NatalChart, render: &mut RenderModel) {
536    let mut glyphs: Vec<Glyph> = Vec::new();
537    let placements = &natal.placements;
538
539    for i in 0..placements.len() {
540        for j in (i + 1)..placements.len() {
541            let pa = &placements[i];
542            let pb = &placements[j];
543            // Solo midpoints que involucren Sol o Luna.
544            let involves_luminary = matches!(pa.body, Body::Sun | Body::Moon)
545                || matches!(pb.body, Body::Sun | Body::Moon);
546            if !involves_luminary {
547                continue;
548            }
549            let a = pa.longitude.longitude_deg() as f32;
550            let b = pb.longitude.longitude_deg() as f32;
551            let diff = (a - b).abs();
552            let mid = if diff > 180.0 {
553                ((a + b) / 2.0 + 180.0).rem_euclid(360.0)
554            } else {
555                ((a + b) / 2.0).rem_euclid(360.0)
556            };
557            glyphs.push(Glyph {
558                deg: mid,
559                symbol: format!("{}/{}", body_symbol(pa.body), body_symbol(pb.body)),
560                annotation: Some(format!("{}/{}", pa.body.name(), pb.body.name())),
561                retrograde: false,
562                house: None,
563                dignity_marker: None,
564            });
565        }
566    }
567
568    render.layers.push(Layer {
569        module_id: "midpoints".into(),
570        kind: LayerKind::Midpoints,
571        ring: 0.62,
572        z: 14,
573        geometry: Geometry::GlyphsOnly,
574        glyphs,
575    });
576}
577
578/// Helper: agrega al `RenderModel` las capas del overlay de Solar Arc
579/// (método true-progressed-Sun por default). Cada cuerpo natal se
580/// desplaza por el mismo arco — preserva las relaciones angulares y
581/// las posiciones relativas en casas se mantienen.
582fn build_solar_arc_overlay(
583    natal: &NatalChart,
584    target_age_years: f64,
585    render: &mut RenderModel,
586) -> Result<(), EngineError> {
587    let session = session()?;
588    let arc = solar_arc_true(natal, session, target_age_years)
589        .map_err(|e| EngineError::Eternal(format!("solar_arc_true: {:?}", e)))?;
590    let directed = &arc.directed;
591
592    let glyphs: Vec<Glyph> = directed
593        .placements
594        .iter()
595        .map(|p| Glyph {
596            deg: p.longitude.longitude_deg() as f32,
597            symbol: body_symbol(p.body).into(),
598            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
599            retrograde: p.longitude_rate_rad_per_day < 0.0,
600            house: Some(p.house_number),
601            dignity_marker: None,
602        })
603        .collect();
604    render.layers.push(Layer {
605        module_id: "solar_arc".into(),
606        kind: LayerKind::Bodies,
607        ring: 0.43,
608        z: 8,
609        geometry: Geometry::GlyphsOnly,
610        glyphs,
611    });
612
613    let cross = find_synastry_aspects(
614        natal,
615        directed,
616        &OrbTable::modern_western(),
617        EAspectKind::MAJORS,
618    );
619    let cross_lines: Vec<LineSeg> = cross
620        .iter()
621        .filter_map(|a| {
622            let natal_p = natal.placement(a.person_a_body)?;
623            let dir_p = directed.placement(a.person_b_body)?;
624            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
625            Some(LineSeg {
626                from_deg: natal_p.longitude.longitude_deg() as f32,
627                to_deg: dir_p.longitude.longitude_deg() as f32,
628                kind: aspect_kind_id(a.kind).into(),
629                opacity: opacity * 0.7,
630            })
631        })
632        .collect();
633    render.layers.push(Layer {
634        module_id: "solar_arc".into(),
635        kind: LayerKind::Aspects,
636        ring: 0.0,
637        z: 9,
638        geometry: Geometry::Lines(cross_lines),
639        glyphs: Vec::new(),
640    });
641    populate_cross_aspect_summary(&cross, "solar_arc", render);
642    Ok(())
643}
644
645/// Helper: agrega al `RenderModel` las capas del overlay de sinastría
646/// con otra carta natal completa. La carta partner se computa con su
647/// propio observer/config (no comparte con la natal). El outer ring
648/// se comparte con Transit — mutuamente excluyentes a nivel de Shell.
649fn build_synastry_overlay(
650    natal: &NatalChart,
651    partner_chart: &Chart,
652    render: &mut RenderModel,
653) -> Result<(), EngineError> {
654    let (partner, _config, _observer) = compute_natal_chart(partner_chart, 0)?;
655
656    let glyphs: Vec<Glyph> = partner
657        .placements
658        .iter()
659        .map(|p| Glyph {
660            deg: p.longitude.longitude_deg() as f32,
661            symbol: body_symbol(p.body).into(),
662            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
663            retrograde: p.longitude_rate_rad_per_day < 0.0,
664            house: Some(p.house_number),
665            dignity_marker: None,
666        })
667        .collect();
668    render.layers.push(Layer {
669        module_id: "synastry".into(),
670        kind: LayerKind::Outer,
671        ring: 0.82,
672        z: 10,
673        geometry: Geometry::GlyphsOnly,
674        glyphs,
675    });
676
677    let cross = find_synastry_aspects(
678        natal,
679        &partner,
680        &OrbTable::modern_western(),
681        EAspectKind::MAJORS,
682    );
683    let cross_lines: Vec<LineSeg> = cross
684        .iter()
685        .filter_map(|a| {
686            let natal_p = natal.placement(a.person_a_body)?;
687            let partner_p = partner.placement(a.person_b_body)?;
688            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
689            Some(LineSeg {
690                from_deg: natal_p.longitude.longitude_deg() as f32,
691                to_deg: partner_p.longitude.longitude_deg() as f32,
692                kind: aspect_kind_id(a.kind).into(),
693                opacity: opacity * 0.85,
694            })
695        })
696        .collect();
697    render.layers.push(Layer {
698        module_id: "synastry".into(),
699        kind: LayerKind::Aspects,
700        ring: 0.0,
701        z: 11,
702        geometry: Geometry::Lines(cross_lines),
703        glyphs: Vec::new(),
704    });
705    populate_cross_aspect_summary(&cross, "synastry", render);
706    Ok(())
707}
708
709/// Helper: agrega al `RenderModel` las capas del overlay de retorno
710/// planetario — la carta natal completa computada al instante en que
711/// el `body` vuelve a su posición natal cerca de la edad pedida.
712/// Sun = retorno solar anual, Moon = mensual, Júpiter/Saturno =
713/// generacionales. Esa nueva carta va en el anillo externo (compartido
714/// con Transit/Synastry, mutuamente excluyentes a nivel de Shell).
715/// Cross aspects natal × return.
716fn build_planetary_return_overlay(
717    natal: &NatalChart,
718    config_e: &ChartConfig,
719    observer: Observer,
720    body: Body,
721    target_age_years: f64,
722    shift_days: i64,
723    render: &mut RenderModel,
724) -> Result<(), EngineError> {
725    let session = session()?;
726    let natal_p = natal.placement(body).ok_or_else(|| {
727        EngineError::Eternal(format!(
728            "natal chart sin {} — return imposible",
729            body.name()
730        ))
731    })?;
732    let natal_lon = natal_p.longitude.longitude_rad();
733
734    // El offset desde el cumpleaños depende del período sinódico del
735    // cuerpo: para Sun/planet lentos, ~30 días antes garantiza captar
736    // el return; para Moon, ~15 días. Tomamos un margen amplio que
737    // sirve para todos.
738    const TROPICAL_YEAR_SECS: f64 = 365.242190 * 86400.0;
739    // shift_days permite saltar de un retorno mensual al siguiente
740    // cuando body=Moon, o ajustar finamente el año en Solar return.
741    let after_seconds =
742        (target_age_years * 365.242190 - 30.0 + shift_days as f64) * 86400.0;
743    let after_utc = natal
744        .birth
745        .instant
746        .utc()
747        .add_seconds(after_seconds.max(-TROPICAL_YEAR_SECS * 2.0));
748    let after = ESInstant::from_utc(after_utc);
749
750    let return_instant = next_return(session, body, natal_lon, after, None).map_err(|e| {
751        EngineError::Eternal(format!("next_return {}: {:?}", body.name(), e))
752    })?;
753
754    // La carta del retorno se computa al return_instant con el mismo
755    // observer y config natales (convención clásica: return tropical
756    // en la ciudad de nacimiento).
757    let return_birth = BirthData::new(return_instant, observer);
758    let return_chart = NatalChart::compute(&return_birth, config_e, session).map_err(|e| {
759        EngineError::Eternal(format!(
760            "NatalChart::compute ({} return): {:?}",
761            body.name(),
762            e
763        ))
764    })?;
765
766    let glyphs: Vec<Glyph> = return_chart
767        .placements
768        .iter()
769        .map(|p| Glyph {
770            deg: p.longitude.longitude_deg() as f32,
771            symbol: body_symbol(p.body).into(),
772            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
773            retrograde: p.longitude_rate_rad_per_day < 0.0,
774            house: Some(p.house_number),
775            dignity_marker: None,
776        })
777        .collect();
778    render.layers.push(Layer {
779        module_id: "planetary_return".into(),
780        kind: LayerKind::Outer,
781        ring: 0.82,
782        z: 12,
783        geometry: Geometry::GlyphsOnly,
784        glyphs,
785    });
786
787    let cross = find_synastry_aspects(
788        natal,
789        &return_chart,
790        &OrbTable::modern_western(),
791        EAspectKind::MAJORS,
792    );
793    let cross_lines: Vec<LineSeg> = cross
794        .iter()
795        .filter_map(|a| {
796            let n_p = natal.placement(a.person_a_body)?;
797            let r_p = return_chart.placement(a.person_b_body)?;
798            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
799            Some(LineSeg {
800                from_deg: n_p.longitude.longitude_deg() as f32,
801                to_deg: r_p.longitude.longitude_deg() as f32,
802                kind: aspect_kind_id(a.kind).into(),
803                opacity: opacity * 0.8,
804            })
805        })
806        .collect();
807    render.layers.push(Layer {
808        module_id: "planetary_return".into(),
809        kind: LayerKind::Aspects,
810        ring: 0.0,
811        z: 13,
812        geometry: Geometry::Lines(cross_lines),
813        glyphs: Vec::new(),
814    });
815    populate_cross_aspect_summary(&cross, "planetary_return", render);
816    Ok(())
817}
818
819// =====================================================================
820// NatalChart → RenderModel
821// =====================================================================
822
823fn build_render_model(
824    chart: &Chart,
825    natal: &NatalChart,
826    aspects: &[Aspect],
827    started: Instant,
828) -> RenderModel {
829    let ascendant_deg = natal.ascendant().longitude_deg() as f32;
830    let midheaven_deg = natal.midheaven().longitude_deg() as f32;
831    let descendant_deg = natal.descendant().longitude_deg() as f32;
832    let imum_coeli_deg = natal.imum_coeli().longitude_deg() as f32;
833
834    // ─── Capa 0: Sign Dial ────────────────────────────────────────────
835    let sign_dial = Layer {
836        module_id: "natal".into(),
837        kind: LayerKind::SignDial,
838        ring: 1.0,
839        z: 0,
840        geometry: Geometry::Ring {
841            cusps_deg: (0..12).map(|i| (i as f32) * 30.0).collect(),
842        },
843        glyphs: (0..12)
844            .map(|i| Glyph {
845                deg: (i as f32) * 30.0 + 15.0,
846                symbol: ZODIAC_SYMBOLS[i].into(),
847                annotation: None,
848                retrograde: false,
849                house: None,
850            dignity_marker: None,
851            })
852            .collect(),
853    };
854
855    // ─── Capa 1: Houses ───────────────────────────────────────────────
856    let cusps_deg: Vec<f32> = natal
857        .houses
858        .cusps
859        .iter()
860        .map(|c| c.to_degrees() as f32)
861        .collect();
862    let houses = Layer {
863        module_id: "natal".into(),
864        kind: LayerKind::Houses,
865        ring: 0.86,
866        z: 1,
867        geometry: Geometry::Ring {
868            cusps_deg: cusps_deg.clone(),
869        },
870        glyphs: cusps_deg
871            .iter()
872            .enumerate()
873            .map(|(i, c)| Glyph {
874                deg: *c + 4.0,
875                symbol: format!("h{}", i + 1),
876                annotation: None,
877                retrograde: false,
878                house: Some((i as u8) + 1),
879                dignity_marker: None,
880            })
881            .collect(),
882    };
883
884    // ─── Capa 2: Bodies ───────────────────────────────────────────────
885    let body_glyphs: Vec<Glyph> = natal
886        .placements
887        .iter()
888        .map(|p| Glyph {
889            deg: p.longitude.longitude_deg() as f32,
890            symbol: body_symbol(p.body).into(),
891            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
892            // `BodyPlacement` cambió entre versiones de eternal entre
893            // `pub fn is_retrograde(&self) -> bool` y `pub
894            // is_retrograde: bool` — leemos el campo crudo
895            // `longitude_rate_rad_per_day` (estable en ambas) para no
896            // depender del wrapper.
897            retrograde: p.longitude_rate_rad_per_day < 0.0,
898            house: Some(p.house_number),
899            dignity_marker: None,
900        })
901        .collect();
902    let bodies = Layer {
903        module_id: "natal".into(),
904        kind: LayerKind::Bodies,
905        ring: 0.72,
906        z: 2,
907        geometry: Geometry::Points(
908            natal
909                .placements
910                .iter()
911                .map(|p| crate::PointMark {
912                    deg: p.longitude.longitude_deg() as f32,
913                    label: p.body.name().into(),
914                    tag: body_symbol(p.body).into(),
915                })
916                .collect(),
917        ),
918        glyphs: body_glyphs,
919    };
920
921    // ─── Capa 3: Aspects ──────────────────────────────────────────────
922    // Los aspects ya vienen filtrados por NatalOptions (majors / minors)
923    // desde compose(). Acá solo mapeamos a LineSeg.
924    let mut aspect_lines: Vec<LineSeg> = Vec::with_capacity(aspects.len());
925    for a in aspects {
926        let pa = natal.placement(a.a);
927        let pb = natal.placement(a.b);
928        if let (Some(pa), Some(pb)) = (pa, pb) {
929            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
930            aspect_lines.push(LineSeg {
931                from_deg: pa.longitude.longitude_deg() as f32,
932                to_deg: pb.longitude.longitude_deg() as f32,
933                kind: aspect_kind_id(a.kind).into(),
934                opacity,
935            });
936        }
937    }
938    let aspects_layer = Layer {
939        module_id: "natal".into(),
940        kind: LayerKind::Aspects,
941        ring: 0.58,
942        z: 3,
943        geometry: Geometry::Lines(aspect_lines),
944        glyphs: Vec::new(),
945    };
946
947    let subtitle = chart
948        .birth_data
949        .birthplace_label
950        .clone()
951        .or_else(|| {
952            Some(format!(
953                "{:04}-{:02}-{:02} · lat {:+.2}° · lon {:+.2}°",
954                chart.birth_data.year,
955                chart.birth_data.month,
956                chart.birth_data.day,
957                chart.birth_data.latitude_deg,
958                chart.birth_data.longitude_deg,
959            ))
960        });
961
962    RenderModel {
963        chart_id: chart.id,
964        chart_kind: chart.kind,
965        title: chart.label.clone(),
966        subtitle,
967        compute_ms: started.elapsed().as_millis() as u64,
968        ascendant_deg,
969        midheaven_deg,
970        descendant_deg,
971        imum_coeli_deg,
972        layers: vec![sign_dial, houses, bodies, aspects_layer],
973        overlays: Vec::new(),
974        aspect_summary: Vec::new(),
975    }
976}
977
978/// Construye una `OrbTable` con los orbes default de `modern_western`
979/// escalados por `multiplier`. Necesario porque eternal expone
980/// `set_orb` pero no permite iterar los base orbs internos.
981fn build_orb_table(multiplier: f64) -> OrbTable {
982    let mut t = OrbTable::modern_western();
983    let m = multiplier.max(0.0);
984    t.set_orb(EAspectKind::Conjunction, 8.0 * m);
985    t.set_orb(EAspectKind::Opposition, 8.0 * m);
986    t.set_orb(EAspectKind::Trine, 7.0 * m);
987    t.set_orb(EAspectKind::Square, 7.0 * m);
988    t.set_orb(EAspectKind::Sextile, 5.0 * m);
989    t.set_orb(EAspectKind::Quincunx, 2.5 * m);
990    t.set_orb(EAspectKind::SemiSextile, 2.0 * m);
991    t.set_orb(EAspectKind::SemiSquare, 2.0 * m);
992    t.set_orb(EAspectKind::Sesquiquadrate, 2.0 * m);
993    t.set_orb(EAspectKind::Quintile, 1.5 * m);
994    t.set_orb(EAspectKind::BiQuintile, 1.5 * m);
995    t.set_orb(EAspectKind::Septile, 1.5 * m);
996    t
997}
998
999fn push_overlay_meta(render: &mut RenderModel, module_id: &str, label: String) {
1000    render.overlays.push(OverlayMeta {
1001        module_id: module_id.to_string(),
1002        label,
1003    });
1004}
1005
1006/// Decora cada Glyph de Bodies (module_id="natal") con su dignity
1007/// marker en `glyph.dignity_marker`. Usa `essential_dignity(body, sign)`
1008/// — los cuerpos modernos quedan sin marker.
1009fn annotate_dignities(natal: &NatalChart, render: &mut RenderModel) {
1010    use std::collections::HashMap;
1011    let mut by_symbol: HashMap<&'static str, &'static str> = HashMap::new();
1012    for p in &natal.placements {
1013        let sign_idx = (p.longitude.longitude_deg() / 30.0).floor() as u8 % 12;
1014        if let Some(d) = essential_dignity(p.body, sign_idx) {
1015            by_symbol.insert(body_symbol(p.body), d.marker());
1016        }
1017    }
1018    for layer in render.layers.iter_mut() {
1019        if matches!(layer.kind, LayerKind::Bodies) && layer.module_id == "natal" {
1020            for g in layer.glyphs.iter_mut() {
1021                if let Some(marker) = by_symbol.get(g.symbol.as_str()) {
1022                    g.dignity_marker = Some((*marker).to_string());
1023                }
1024            }
1025        }
1026    }
1027}
1028
1029fn populate_natal_aspect_summary(aspects: &[Aspect], render: &mut RenderModel) {
1030    for a in aspects {
1031        render.aspect_summary.push(AspectSummary {
1032            module_id: "natal".into(),
1033            from_body: body_symbol(a.a).into(),
1034            to_body: body_symbol(a.b).into(),
1035            kind: aspect_kind_id(a.kind).into(),
1036            orb_deg: a.orb_abs_deg(),
1037            applying: Some(a.applying),
1038        });
1039    }
1040    sort_aspect_summary(render);
1041}
1042
1043fn populate_cross_aspect_summary(
1044    cross: &[eternal_astrology::SynastryAspect],
1045    module_id: &str,
1046    render: &mut RenderModel,
1047) {
1048    for a in cross {
1049        render.aspect_summary.push(AspectSummary {
1050            module_id: module_id.to_string(),
1051            from_body: body_symbol(a.person_a_body).into(),
1052            to_body: body_symbol(a.person_b_body).into(),
1053            kind: aspect_kind_id(a.kind).into(),
1054            orb_deg: a.orb_abs_deg(),
1055            applying: None,
1056        });
1057    }
1058    sort_aspect_summary(render);
1059}
1060
1061fn sort_aspect_summary(render: &mut RenderModel) {
1062    render
1063        .aspect_summary
1064        .sort_by(|x, y| x.orb_deg.partial_cmp(&y.orb_deg).unwrap_or(std::cmp::Ordering::Equal));
1065}
1066
1067/// Mapea el orb absoluto a una opacidad — los aspectos más exactos se
1068/// pintan más fuerte, los flojos casi se desvanecen.
1069fn orb_to_opacity(orb_deg: f64, kind: EAspectKind) -> f32 {
1070    let max = match kind {
1071        EAspectKind::Conjunction | EAspectKind::Opposition => 8.0,
1072        EAspectKind::Trine | EAspectKind::Square => 7.0,
1073        EAspectKind::Sextile => 5.0,
1074        _ => 3.0,
1075    };
1076    let t = (1.0 - (orb_deg / max).min(1.0)).max(0.25);
1077    t as f32
1078}
1079
1080const ZODIAC_SYMBOLS: [&str; 12] = [
1081    "aries",
1082    "taurus",
1083    "gemini",
1084    "cancer",
1085    "leo",
1086    "virgo",
1087    "libra",
1088    "scorpio",
1089    "sagittarius",
1090    "capricorn",
1091    "aquarius",
1092    "pisces",
1093];