gioser

tawasuyu/brahman

Rust 568TOML 215Markdown 67JSON 198164 Definitions

⎇ a92fa1577700a5cb28c6bb96872300ce99a7e5f8 crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs Rust · 1490 lines · 53.5 KB History Who touched what raw

feat(tahuantinsuyu): anti-solapamiento de glyphs + selector Naibod/Ptolomeo a92fa1577 · 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::{Arc, OnceLock};
9use std::time::Instant;
10
11use eternal_astrology::{
12    all_lots, composite, directed_longitude, find_aspects, find_synastry_aspects, next_return,
13    primary_direction::PrimaryDirection, secondary_progression, solar_arc_true, topocentric_ecliptic,
14    Aspect, AspectKind as EAspectKind, BirthData, BodySet, ChartConfig,
15    DirectionKey as EDirectionKey, HouseSystem as EHouseSystem, Houses as EHouses, NatalChart,
16    OrbTable, Zodiac as EZodiac,
17};
18use eternal_sky::{Ayanamsha, Body, EphemerisSession, Instant as ESInstant, Observer, SessionConfig};
19
20use tahuantinsuyu_model::{Chart, HouseSystem, StoredChartConfig, Zodiac};
21
22use crate::dignity::essential_dignity;
23use crate::{
24    AspectSummary, EngineError, Geometry, Glyph, Layer, LayerKind, LineSeg, OverlayMeta,
25    RenderModel, UranianGroup,
26};
27
28// =====================================================================
29// Sesión global cacheada
30// =====================================================================
31
32static SESSION: OnceLock<EphemerisSession> = OnceLock::new();
33
34fn session() -> Result<&'static EphemerisSession, EngineError> {
35    if let Some(s) = SESSION.get() {
36        return Ok(s);
37    }
38    let opened = EphemerisSession::open(SessionConfig::vsop2013())
39        .map_err(|e| EngineError::Eternal(format!("EphemerisSession::open: {:?}", e)))?;
40    // Si otro thread ya pobló la celda mientras abríamos, el set_once
41    // falla silenciosamente — usamos el que quedó dentro.
42    let _ = SESSION.set(opened);
43    Ok(SESSION.get().expect("session was just set"))
44}
45
46// =====================================================================
47// Traducciones Stored* → eternal
48// =====================================================================
49
50fn map_house_system(h: HouseSystem) -> EHouseSystem {
51    match h {
52        HouseSystem::Placidus => EHouseSystem::Placidus,
53        HouseSystem::Koch => EHouseSystem::Koch,
54        HouseSystem::Regiomontanus => EHouseSystem::Regiomontanus,
55        HouseSystem::Campanus => EHouseSystem::Campanus,
56        HouseSystem::Porphyry => EHouseSystem::Porphyry,
57        HouseSystem::Equal => EHouseSystem::Equal,
58        HouseSystem::WholeSign => EHouseSystem::WholeSign,
59    }
60}
61
62fn map_zodiac(z: Zodiac, ayanamsha_hint: Option<&str>) -> EZodiac {
63    match z {
64        Zodiac::Tropical => EZodiac::Tropical,
65        Zodiac::Sidereal => {
66            let mode = match ayanamsha_hint.unwrap_or("lahiri").to_ascii_lowercase().as_str() {
67                "fagan_bradley" | "fagan-bradley" | "faganbradley" => Ayanamsha::FaganBradley,
68                "raman" => Ayanamsha::Raman,
69                "krishnamurti" => Ayanamsha::Krishnamurti,
70                "de_luce" | "deluce" => Ayanamsha::DeLuce,
71                "djwhal_khul" | "djwhalkhul" => Ayanamsha::DjwhalKhul,
72                "ushashashi" => Ayanamsha::Ushashashi,
73                "yukteshwar" => Ayanamsha::Yukteshwar,
74                _ => Ayanamsha::Lahiri,
75            };
76            EZodiac::Sidereal(mode)
77        }
78        // Dracónico aún no soportado en eternal — caemos a tropical por
79        // ahora; cuando eternal lo agregue, lo cableamos acá.
80        Zodiac::Draconic => EZodiac::Tropical,
81    }
82}
83
84fn map_body_set(cfg: &StoredChartConfig) -> BodySet {
85    let mut bodies: Vec<Body> = Vec::new();
86    for name in &cfg.bodies {
87        if let Some(b) = map_body(name) {
88            bodies.push(b);
89        }
90    }
91    if bodies.is_empty() {
92        // Default razonable si el config vino vacío.
93        return BodySet::classical_modern();
94    }
95    let mut set = BodySet {
96        bodies,
97        include_south_node: cfg.include_south_node,
98    };
99    if cfg.include_lilith {
100        set = set.with_lilith();
101    }
102    if cfg.include_main_belt_asteroids {
103        set = set.with_main_belt_asteroids();
104    }
105    set
106}
107
108fn map_body(name: &str) -> Option<Body> {
109    Some(match name.to_ascii_lowercase().as_str() {
110        "sun" => Body::Sun,
111        "moon" => Body::Moon,
112        "mercury" => Body::Mercury,
113        "venus" => Body::Venus,
114        "mars" => Body::Mars,
115        "jupiter" => Body::Jupiter,
116        "saturn" => Body::Saturn,
117        "uranus" => Body::Uranus,
118        "neptune" => Body::Neptune,
119        "pluto" => Body::Pluto,
120        "mean_node" | "meannode" => Body::MeanNode,
121        "true_node" | "truenode" => Body::TrueNode,
122        "mean_lilith" | "lilith" => Body::MeanLilith,
123        "true_lilith" => Body::TrueLilith,
124        "ceres" => Body::Ceres,
125        "pallas" => Body::Pallas,
126        "juno" => Body::Juno,
127        "vesta" => Body::Vesta,
128        _ => return None,
129    })
130}
131
132fn body_symbol(b: Body) -> &'static str {
133    match b {
134        Body::Sun => "sun",
135        Body::Moon => "moon",
136        Body::Mercury => "mercury",
137        Body::Venus => "venus",
138        Body::Mars => "mars",
139        Body::Jupiter => "jupiter",
140        Body::Saturn => "saturn",
141        Body::Uranus => "uranus",
142        Body::Neptune => "neptune",
143        Body::Pluto => "pluto",
144        Body::MeanNode => "north_node",
145        Body::TrueNode => "north_node",
146        Body::MeanLilith => "lilith",
147        Body::TrueLilith => "lilith",
148        Body::Ceres => "ceres",
149        Body::Pallas => "pallas",
150        Body::Juno => "juno",
151        Body::Vesta => "vesta",
152        Body::Chiron => "chiron",
153        Body::Pholus => "chiron",
154        Body::Eris => "chiron",
155        Body::Sedna => "chiron",
156        // `Body` es `#[non_exhaustive]` — cualquier cuerpo nuevo
157        // upstream cae al símbolo de fallback hasta que lo cableemos.
158        _ => "custom",
159    }
160}
161
162fn aspect_kind_id(k: EAspectKind) -> &'static str {
163    match k {
164        EAspectKind::Conjunction => "conjunction",
165        EAspectKind::Opposition => "opposition",
166        EAspectKind::Trine => "trine",
167        EAspectKind::Square => "square",
168        EAspectKind::Sextile => "sextile",
169        EAspectKind::Quincunx => "quincunx",
170        EAspectKind::SemiSextile => "semi_sextile",
171        EAspectKind::SemiSquare => "semi_square",
172        EAspectKind::Sesquiquadrate => "sesquiquadrate",
173        EAspectKind::Quintile => "quintile",
174        EAspectKind::BiQuintile => "biquintile",
175        EAspectKind::Septile => "septile",
176    }
177}
178
179// =====================================================================
180// compute()
181// =====================================================================
182
183/// Construye los tipos eternales (`BirthData`, `ChartConfig`) desde el
184/// `Chart` agnóstico, aplicando el offset temporal. Devuelve también el
185/// `Observer` y la `ChartConfig` para reusar en pipelines extendidas
186/// (transits, sinastría) sin re-traducir.
187fn build_eternal_inputs(
188    chart: &Chart,
189    offset_minutes: i64,
190) -> Result<(BirthData, ChartConfig, Observer), EngineError> {
191    chart.validate()?;
192    let bd = &chart.birth_data;
193    let base_instant = ESInstant::from_civil_local(
194        bd.year,
195        u8::try_from(bd.month).map_err(|_| {
196            EngineError::Eternal(format!("mes fuera de u8: {}", bd.month))
197        })?,
198        u8::try_from(bd.day).map_err(|_| {
199            EngineError::Eternal(format!("día fuera de u8: {}", bd.day))
200        })?,
201        u8::try_from(bd.hour).map_err(|_| {
202            EngineError::Eternal(format!("hora fuera de u8: {}", bd.hour))
203        })?,
204        u8::try_from(bd.minute).map_err(|_| {
205            EngineError::Eternal(format!("minuto fuera de u8: {}", bd.minute))
206        })?,
207        bd.second,
208        bd.tz_offset_minutes,
209    )
210    .map_err(|e| EngineError::Eternal(format!("Instant::from_civil_local: {:?}", e)))?;
211
212    let instant = if offset_minutes == 0 {
213        base_instant
214    } else {
215        let shifted_utc = base_instant.utc().add_seconds((offset_minutes as f64) * 60.0);
216        ESInstant::from_utc(shifted_utc)
217    };
218
219    let observer = Observer::from_degrees(bd.latitude_deg, bd.longitude_deg, bd.altitude_m);
220    let mut birth_e = BirthData::new(instant, observer);
221    if let Some(name) = &bd.subject_name {
222        birth_e = birth_e.with_name(name.clone());
223    }
224    let config_e = ChartConfig {
225        house_system: map_house_system(chart.config.house_system),
226        zodiac: map_zodiac(chart.config.zodiac, chart.config.ayanamsha.as_deref()),
227        bodies: map_body_set(&chart.config),
228        include_horizon: false,
229    };
230    Ok((birth_e, config_e, observer))
231}
232
233/// Computa la `NatalChart` consultando primero el LRU cache global.
234/// Útil para pipelines compuestas (transits, sinastría, composite) que
235/// computan la misma carta natal del partner en cada render — bajo
236/// drag de sliders se llama decenas de veces seguidas con inputs
237/// idénticos.
238///
239/// La clave incluye todos los campos de `StoredBirthData` y
240/// `StoredChartConfig` que afectan el cómputo; editar la carta invalida
241/// automáticamente la entrada.
242fn compute_natal_chart(
243    chart: &Chart,
244    offset_minutes: i64,
245) -> Result<(Arc<NatalChart>, ChartConfig, Observer), EngineError> {
246    let (birth_e, config_e, observer) = build_eternal_inputs(chart, offset_minutes)?;
247    let key = crate::natal_cache::key_for(&chart.birth_data, &chart.config, offset_minutes);
248    if let Some(cached) = crate::natal_cache::get(key) {
249        return Ok((cached, config_e, observer));
250    }
251    let session = session()?;
252    let natal = NatalChart::compute(&birth_e, &config_e, session)
253        .map_err(|e| EngineError::Eternal(format!("NatalChart::compute: {:?}", e)))?;
254    let arc = Arc::new(natal);
255    crate::natal_cache::insert(key, arc.clone());
256    Ok((arc, config_e, observer))
257}
258
259/// Composición principal: natal + overlays pedidos. Es la función que
260/// `lib::compose` delega cuando el feature `eternal-bridge` está activo.
261pub fn compose(
262    chart: &Chart,
263    offset_minutes: i64,
264    requests: &[crate::PipelineRequest],
265    natal_options: &crate::NatalOptions,
266) -> Result<RenderModel, EngineError> {
267    let t0 = Instant::now();
268    let (natal, config_e, observer) = compute_natal_chart(chart, offset_minutes)?;
269    let orb_table = build_orb_table(natal_options.orb_multiplier);
270    let all_aspects = find_aspects(&natal, &orb_table);
271    let aspects: Vec<Aspect> = all_aspects
272        .into_iter()
273        .filter(|a| {
274            let is_major = EAspectKind::MAJORS.contains(&a.kind);
275            (is_major && natal_options.show_majors)
276                || (!is_major && natal_options.show_minors)
277        })
278        .collect();
279    let mut render = build_render_model(chart, &natal, &aspects, t0);
280    if natal_options.show_dignities {
281        annotate_dignities(&natal, &mut render);
282    }
283    populate_natal_aspect_summary(&aspects, &mut render);
284
285    for req in requests {
286        match req {
287            crate::PipelineRequest::Transit => {
288                build_transit_overlay(&natal, &config_e, observer, ESInstant::now(), &mut render)?;
289                push_overlay_meta(&mut render, "transit", "Tránsito ahora".into());
290            }
291            crate::PipelineRequest::SecondaryProgression { target_age_years } => {
292                build_progression_overlay(&natal, *target_age_years, &mut render)?;
293                push_overlay_meta(
294                    &mut render,
295                    "progression",
296                    format!("Progresión {:.1}a", target_age_years),
297                );
298            }
299            crate::PipelineRequest::SolarArc { target_age_years } => {
300                build_solar_arc_overlay(&natal, *target_age_years, &mut render)?;
301                push_overlay_meta(
302                    &mut render,
303                    "solar_arc",
304                    format!("Solar Arc {:.1}a", target_age_years),
305                );
306            }
307            crate::PipelineRequest::Synastry { partner_chart } => {
308                let partner_label = partner_chart.label.clone();
309                build_synastry_overlay(&natal, partner_chart, &mut render)?;
310                push_overlay_meta(
311                    &mut render,
312                    "synastry",
313                    format!("Sinastría · {}", partner_label),
314                );
315            }
316            crate::PipelineRequest::Midpoints => {
317                build_midpoints_overlay(&natal, &mut render);
318                push_overlay_meta(&mut render, "midpoints", "Midpoints ☉/☽".into());
319            }
320            crate::PipelineRequest::PlanetaryReturn {
321                body,
322                target_age_years,
323                shift_days,
324            } => {
325                let body_e = map_body(body).ok_or_else(|| {
326                    EngineError::Eternal(format!(
327                        "body desconocido para planetary return: {}",
328                        body
329                    ))
330                })?;
331                build_planetary_return_overlay(
332                    &natal,
333                    &config_e,
334                    observer,
335                    body_e,
336                    *target_age_years,
337                    *shift_days,
338                    &mut render,
339                )?;
340                let shift_label = if *shift_days == 0 {
341                    String::new()
342                } else {
343                    format!(" {:+}d", shift_days)
344                };
345                push_overlay_meta(
346                    &mut render,
347                    "planetary_return",
348                    format!("{} return {:.0}a{}", body_e.name(), target_age_years, shift_label),
349                );
350            }
351            crate::PipelineRequest::Composite { partner_chart } => {
352                let partner_label = partner_chart.label.clone();
353                build_composite_overlay(&natal, partner_chart, &mut render)?;
354                push_overlay_meta(
355                    &mut render,
356                    "composite",
357                    format!("Composite · {}", partner_label),
358                );
359            }
360            crate::PipelineRequest::Uranian => {
361                build_uranian_groups(&natal, &mut render);
362                let n = render.uranian_groups.len();
363                push_overlay_meta(
364                    &mut render,
365                    "uranian",
366                    if n == 0 {
367                        "Uraniano · sin ejes".into()
368                    } else {
369                        format!("Uraniano · {} ejes", n)
370                    },
371                );
372            }
373            crate::PipelineRequest::Lots => {
374                let count = build_lots_overlay(&natal, &mut render)?;
375                push_overlay_meta(&mut render, "lots", format!("Lots · {}", count));
376            }
377            crate::PipelineRequest::FixedStars => {
378                let count = build_fixed_stars_overlay(chart, &mut render);
379                push_overlay_meta(
380                    &mut render,
381                    "fixed_stars",
382                    format!("Estrellas fijas · {}", count),
383                );
384            }
385            crate::PipelineRequest::Topocentric => {
386                build_topocentric_overlay(&natal, &mut render)?;
387                push_overlay_meta(
388                    &mut render,
389                    "topocentric",
390                    "Topocéntrico (Polich-Page)".into(),
391                );
392            }
393            crate::PipelineRequest::PrimaryDirections {
394                target_age_years,
395                key,
396            } => {
397                let dkey = match key.as_str() {
398                    "ptolemy" => EDirectionKey::Ptolemy,
399                    _ => EDirectionKey::Naibod,
400                };
401                build_primary_directions_overlay(
402                    &natal,
403                    *target_age_years,
404                    dkey,
405                    &mut render,
406                );
407                push_overlay_meta(
408                    &mut render,
409                    "primary_directions",
410                    format!(
411                        "GR Direcciones · {:.1}a · {}",
412                        target_age_years,
413                        match dkey {
414                            EDirectionKey::Naibod => "Naibod",
415                            EDirectionKey::Ptolemy => "Ptolomeo",
416                        }
417                    ),
418                );
419            }
420        }
421    }
422
423    render.compute_ms = t0.elapsed().as_millis() as u64;
424    Ok(render)
425}
426
427/// Helper: agrega al `RenderModel` las dos capas del overlay de
428/// tránsitos (Outer + cross Aspects).
429fn build_transit_overlay(
430    natal: &NatalChart,
431    config_e: &ChartConfig,
432    observer: Observer,
433    transit_at: ESInstant,
434    render: &mut RenderModel,
435) -> Result<(), EngineError> {
436    let transit_birth = BirthData::new(transit_at, observer);
437    let session = session()?;
438    let transit = NatalChart::compute(&transit_birth, config_e, session).map_err(|e| {
439        EngineError::Eternal(format!("NatalChart::compute (transit): {:?}", e))
440    })?;
441
442    let outer_glyphs: Vec<Glyph> = transit
443        .placements
444        .iter()
445        .map(|p| Glyph {
446            deg: p.longitude.longitude_deg() as f32,
447            symbol: body_symbol(p.body).into(),
448            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
449            retrograde: p.longitude_rate_rad_per_day < 0.0,
450            house: None,
451            dignity_marker: None,
452        })
453        .collect();
454    render.layers.push(Layer {
455        module_id: "transit".into(),
456        kind: LayerKind::Outer,
457        ring: 0.82,
458        z: 4,
459        geometry: Geometry::GlyphsOnly,
460        glyphs: outer_glyphs,
461    });
462
463    let cross = find_synastry_aspects(
464        natal,
465        &transit,
466        &OrbTable::modern_western(),
467        EAspectKind::MAJORS,
468    );
469    let cross_lines: Vec<LineSeg> = cross
470        .iter()
471        .filter_map(|a| {
472            let natal_p = natal.placement(a.person_a_body)?;
473            let transit_p = transit.placement(a.person_b_body)?;
474            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
475            Some(LineSeg {
476                from_deg: natal_p.longitude.longitude_deg() as f32,
477                to_deg: transit_p.longitude.longitude_deg() as f32,
478                kind: aspect_kind_id(a.kind).into(),
479                opacity: opacity * 0.75,
480                from_body: body_symbol(a.person_a_body).into(),
481                to_body: body_symbol(a.person_b_body).into(),
482                orb_deg: a.orb_abs_deg() as f32,
483            })
484        })
485        .collect();
486    render.layers.push(Layer {
487        module_id: "transit".into(),
488        kind: LayerKind::Aspects,
489        ring: 0.0,
490        z: 5,
491        geometry: Geometry::Lines(cross_lines),
492        glyphs: Vec::new(),
493    });
494    populate_cross_aspect_summary(&cross, "transit", render);
495    Ok(())
496}
497
498/// Helper: agrega al `RenderModel` las capas del overlay de progresión
499/// secundaria. La carta progresada se computa con el mismo observer y
500/// config que la natal pero al instante natal+(age_years/period_years)
501/// días.
502/// Overlay topocéntrico: re-proyecta cada placement natal a longitud
503/// topocéntrica (con paralaje horizontal) y recalcula las casas con
504/// Polich-Page. Los dos quedan emparentados al mismo `module_id =
505/// "topocentric"` para que el canvas los pinte con un visual
506/// consistente. La capa convive con la natal geocéntrica — ambas se
507/// ven simultáneamente.
508fn build_topocentric_overlay(
509    natal: &NatalChart,
510    render: &mut RenderModel,
511) -> Result<(), EngineError> {
512    const KM_PER_AU: f64 = 149_597_870.7;
513    let lst = natal.local_apparent_sidereal_time_rad;
514    let eps = natal.obliquity_rad;
515    let obs_lat = natal.birth.observer.lat_rad;
516
517    // 1) Planetas topocéntricos. Para puntos sin distancia (nodos,
518    // Lilith calculada) `topocentric_ecliptic` retorna la entrada sin
519    // cambios — geocéntrico y topocéntrico coinciden ahí.
520    let body_glyphs: Vec<Glyph> = natal
521        .placements
522        .iter()
523        .map(|p| {
524            let dist_au = p.distance_km / KM_PER_AU;
525            let (lon_topo, _) = topocentric_ecliptic(
526                p.longitude.longitude_rad(),
527                p.latitude_rad,
528                dist_au,
529                obs_lat,
530                lst,
531                eps,
532            );
533            let lon_topo_deg = lon_topo.to_degrees() as f32;
534            Glyph {
535                deg: lon_topo_deg,
536                symbol: body_symbol(p.body).into(),
537                annotation: Some(format!("{:.2}° topo", lon_topo_deg)),
538                retrograde: p.longitude_rate_rad_per_day < 0.0,
539                house: None,
540                dignity_marker: None,
541            }
542        })
543        .collect();
544
545    render.layers.push(Layer {
546        module_id: "topocentric".into(),
547        kind: LayerKind::Bodies,
548        ring: 0.50,
549        z: 8,
550        geometry: Geometry::GlyphsOnly,
551        glyphs: body_glyphs,
552    });
553
554    // 2) Casas Polich-Page. Si la latitud cae en el círculo polar el
555    // sistema diverge — devolvemos un error parcial pero conservamos
556    // la capa de planetas topocéntricos (que sí es válida).
557    match EHouses::compute(EHouseSystem::PolichPage, lst, obs_lat, eps) {
558        Ok(houses_pp) => {
559            let cusps_deg: Vec<f32> =
560                houses_pp.cusps.iter().map(|c| c.to_degrees() as f32).collect();
561            let house_glyphs: Vec<Glyph> = (0..12)
562                .map(|i| Glyph {
563                    deg: cusps_deg[i] + 4.0,
564                    symbol: format!("h{}", i + 1),
565                    annotation: None,
566                    retrograde: false,
567                    house: Some((i as u8) + 1),
568                    dignity_marker: None,
569                })
570                .collect();
571            render.layers.push(Layer {
572                module_id: "topocentric".into(),
573                kind: LayerKind::Houses,
574                ring: 0.78,
575                z: 9,
576                geometry: Geometry::Ring { cusps_deg },
577                glyphs: house_glyphs,
578            });
579        }
580        Err(e) => {
581            // Polo: el visual se queda solo con planetas topocéntricos.
582            eprintln!("[bridge] PolichPage no disponible en lat polar: {:?}", e);
583        }
584    }
585
586    Ok(())
587}
588
589/// GR dual-ring de Direcciones Primarias: a la edad pedida, cada
590/// cuerpo natal se proyecta dos veces — directa (rotación diurna
591/// forward, anillo afuera) y conversa (rotación inversa, anillo
592/// dentro). En rectificación, los dos rings se ven simultáneamente
593/// y si un evento real cayó cerca de un ángulo, debe aparecer
594/// "cruzado" con ambos arcos coincidentes — eso valida la hora.
595///
596/// Usa el key Naibod (0°59'08″/año) como default — convención GR.
597fn build_primary_directions_overlay(
598    natal: &NatalChart,
599    target_age_years: f64,
600    key: EDirectionKey,
601    render: &mut RenderModel,
602) {
603    let eps = natal.obliquity_rad;
604
605    let project = |dir: PrimaryDirection| -> Vec<Glyph> {
606        natal
607            .placements
608            .iter()
609            .map(|p| {
610                let new_lon_rad = directed_longitude(
611                    p.right_ascension_rad,
612                    p.declination_rad,
613                    target_age_years,
614                    dir,
615                    key,
616                    eps,
617                );
618                let new_lon_deg = new_lon_rad.to_degrees() as f32;
619                Glyph {
620                    deg: new_lon_deg,
621                    symbol: body_symbol(p.body).into(),
622                    annotation: Some(format!("{:.2}°", new_lon_deg)),
623                    retrograde: p.longitude_rate_rad_per_day < 0.0,
624                    house: None,
625                    dignity_marker: None,
626                }
627            })
628            .collect()
629    };
630
631    let direct_glyphs = project(PrimaryDirection::Direct);
632    let converse_glyphs = project(PrimaryDirection::Converse);
633
634    render.layers.push(Layer {
635        module_id: "pd_direct".into(),
636        kind: LayerKind::Bodies,
637        ring: 0.0,
638        z: 10,
639        geometry: Geometry::GlyphsOnly,
640        glyphs: direct_glyphs,
641    });
642    render.layers.push(Layer {
643        module_id: "pd_converse".into(),
644        kind: LayerKind::Bodies,
645        ring: 0.0,
646        z: 11,
647        geometry: Geometry::GlyphsOnly,
648        glyphs: converse_glyphs,
649    });
650}
651
652fn build_progression_overlay(
653    natal: &NatalChart,
654    target_age_years: f64,
655    render: &mut RenderModel,
656) -> Result<(), EngineError> {
657    let session = session()?;
658    let prog = secondary_progression(natal, session, target_age_years)
659        .map_err(|e| EngineError::Eternal(format!("secondary_progression: {:?}", e)))?;
660    let progressed = &prog.progressed;
661
662    // Glifos de los cuerpos progresados — anillo interno (radio 0.48).
663    let glyphs: Vec<Glyph> = progressed
664        .placements
665        .iter()
666        .map(|p| Glyph {
667            deg: p.longitude.longitude_deg() as f32,
668            symbol: body_symbol(p.body).into(),
669            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
670            retrograde: p.longitude_rate_rad_per_day < 0.0,
671            house: Some(p.house_number),
672            dignity_marker: None,
673        })
674        .collect();
675    render.layers.push(Layer {
676        module_id: "progression".into(),
677        kind: LayerKind::Bodies,
678        ring: 0.48,
679        z: 6,
680        geometry: Geometry::GlyphsOnly,
681        glyphs,
682    });
683
684    // Cross aspects natal × progresada (sólo mayores).
685    let cross = find_synastry_aspects(
686        natal,
687        progressed,
688        &OrbTable::modern_western(),
689        EAspectKind::MAJORS,
690    );
691    let cross_lines: Vec<LineSeg> = cross
692        .iter()
693        .filter_map(|a| {
694            let natal_p = natal.placement(a.person_a_body)?;
695            let prog_p = progressed.placement(a.person_b_body)?;
696            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
697            Some(LineSeg {
698                from_deg: natal_p.longitude.longitude_deg() as f32,
699                to_deg: prog_p.longitude.longitude_deg() as f32,
700                kind: aspect_kind_id(a.kind).into(),
701                opacity: opacity * 0.7,
702                from_body: body_symbol(a.person_a_body).into(),
703                to_body: body_symbol(a.person_b_body).into(),
704                orb_deg: a.orb_abs_deg() as f32,
705            })
706        })
707        .collect();
708    render.layers.push(Layer {
709        module_id: "progression".into(),
710        kind: LayerKind::Aspects,
711        ring: 0.0,
712        z: 7,
713        geometry: Geometry::Lines(cross_lines),
714        glyphs: Vec::new(),
715    });
716    populate_cross_aspect_summary(&cross, "progression", render);
717    Ok(())
718}
719
720/// Helper: detecta "ejes" del dial uraniano de 90° — cuerpos natales
721/// cuya longitud módulo 90 cae dentro de una tolerancia ε (2° por
722/// default). Llena `render.uranian_groups` con los grupos detectados.
723fn build_uranian_groups(natal: &NatalChart, render: &mut RenderModel) {
724    const EPSILON: f64 = 2.0;
725    let mut entries: Vec<(String, f64)> = natal
726        .placements
727        .iter()
728        .map(|p| {
729            let lon = p.longitude.longitude_deg();
730            let mod90 = lon.rem_euclid(90.0);
731            (body_symbol(p.body).to_string(), mod90)
732        })
733        .collect();
734    entries.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
735
736    let mut groups: Vec<UranianGroup> = Vec::new();
737    let mut current: Vec<(String, f64)> = Vec::new();
738    let mut anchor_mod90 = 0.0_f64;
739    for entry in entries {
740        if current.is_empty() {
741            anchor_mod90 = entry.1;
742            current.push(entry);
743            continue;
744        }
745        // Distancia circular módulo 90 entre el entry y el anchor.
746        let mut diff = (entry.1 - anchor_mod90).abs();
747        if diff > 45.0 {
748            diff = 90.0 - diff;
749        }
750        if diff <= EPSILON {
751            current.push(entry);
752        } else {
753            if current.len() >= 2 {
754                let center = current.iter().map(|(_, m)| *m).sum::<f64>() / current.len() as f64;
755                groups.push(UranianGroup {
756                    bodies: current.iter().map(|(s, _)| s.clone()).collect(),
757                    mod90_deg: center,
758                });
759            }
760            anchor_mod90 = entry.1;
761            current = vec![entry];
762        }
763    }
764    if current.len() >= 2 {
765        let center = current.iter().map(|(_, m)| *m).sum::<f64>() / current.len() as f64;
766        groups.push(UranianGroup {
767            bodies: current.iter().map(|(s, _)| s.clone()).collect(),
768            mod90_deg: center,
769        });
770    }
771    // Wrap-around check: el primer y último grupo podrían ser el mismo
772    // (si span >88° abarcando el wrap en 90/0). Si los anchors están a
773    // ≤EPSILON modulo 90, mergeamos.
774    if groups.len() >= 2 {
775        let first_mod = groups[0].mod90_deg;
776        let last_mod = groups[groups.len() - 1].mod90_deg;
777        let mut diff = (first_mod - last_mod).abs();
778        if diff > 45.0 {
779            diff = 90.0 - diff;
780        }
781        if diff <= EPSILON {
782            let last = groups.pop().unwrap();
783            groups[0].bodies.extend(last.bodies);
784        }
785    }
786    render.uranian_groups = groups;
787}
788
789/// Helper: agrega al `RenderModel` la carta compuesta (midpoint
790/// composite, Davison 1958) entre la natal del sujeto y la carta del
791/// partner. Cada planeta compuesto es el angular midpoint entre los
792/// dos correspondientes. Se renderea en `radii.composite` (ring 0.36).
793fn build_composite_overlay(
794    natal: &NatalChart,
795    partner_chart: &Chart,
796    render: &mut RenderModel,
797) -> Result<(), EngineError> {
798    let (partner_natal, _config, _observer) = compute_natal_chart(partner_chart, 0)?;
799    let comp = composite(natal, &partner_natal).map_err(|e| {
800        EngineError::Eternal(format!("composite: {:?}", e))
801    })?;
802
803    let glyphs: Vec<Glyph> = comp
804        .placements
805        .iter()
806        .map(|p| Glyph {
807            deg: p.longitude.longitude_deg() as f32,
808            symbol: body_symbol(p.body).into(),
809            annotation: Some(format!("composite {}", p.body.name())),
810            retrograde: false,
811            house: Some(p.house_number),
812            dignity_marker: None,
813        })
814        .collect();
815    render.layers.push(Layer {
816        module_id: "composite".into(),
817        kind: LayerKind::Bodies,
818        ring: 0.36,
819        z: 15,
820        geometry: Geometry::GlyphsOnly,
821        glyphs,
822    });
823    Ok(())
824}
825
826/// Helper: agrega al `RenderModel` los midpoints entre pares de
827/// cuerpos natales. Filtra para mostrar solo los que involucran al
828/// Sol o a la Luna (~10 puntos) — son los más significativos
829/// astrológicamente y mantiene la rueda legible.
830///
831/// El midpoint de dos longitudes es la menor distancia angular entre
832/// ellas. Si `|a - b| > 180`, hay que sumar 180 al promedio para
833/// obtener el midpoint "corto".
834fn build_midpoints_overlay(natal: &NatalChart, render: &mut RenderModel) {
835    let mut glyphs: Vec<Glyph> = Vec::new();
836    let placements = &natal.placements;
837
838    for i in 0..placements.len() {
839        for j in (i + 1)..placements.len() {
840            let pa = &placements[i];
841            let pb = &placements[j];
842            // Solo midpoints que involucren Sol o Luna.
843            let involves_luminary = matches!(pa.body, Body::Sun | Body::Moon)
844                || matches!(pb.body, Body::Sun | Body::Moon);
845            if !involves_luminary {
846                continue;
847            }
848            let a = pa.longitude.longitude_deg() as f32;
849            let b = pb.longitude.longitude_deg() as f32;
850            let diff = (a - b).abs();
851            let mid = if diff > 180.0 {
852                ((a + b) / 2.0 + 180.0).rem_euclid(360.0)
853            } else {
854                ((a + b) / 2.0).rem_euclid(360.0)
855            };
856            glyphs.push(Glyph {
857                deg: mid,
858                symbol: format!("{}/{}", body_symbol(pa.body), body_symbol(pb.body)),
859                annotation: Some(format!("{}/{}", pa.body.name(), pb.body.name())),
860                retrograde: false,
861                house: None,
862                dignity_marker: None,
863            });
864        }
865    }
866
867    render.layers.push(Layer {
868        module_id: "midpoints".into(),
869        kind: LayerKind::Midpoints,
870        ring: 0.62,
871        z: 14,
872        geometry: Geometry::GlyphsOnly,
873        glyphs,
874    });
875}
876
877/// Helper: agrega al `RenderModel` las capas del overlay de Solar Arc
878/// (método true-progressed-Sun por default). Cada cuerpo natal se
879/// desplaza por el mismo arco — preserva las relaciones angulares y
880/// las posiciones relativas en casas se mantienen.
881fn build_solar_arc_overlay(
882    natal: &NatalChart,
883    target_age_years: f64,
884    render: &mut RenderModel,
885) -> Result<(), EngineError> {
886    let session = session()?;
887    let arc = solar_arc_true(natal, session, target_age_years)
888        .map_err(|e| EngineError::Eternal(format!("solar_arc_true: {:?}", e)))?;
889    let directed = &arc.directed;
890
891    let glyphs: Vec<Glyph> = directed
892        .placements
893        .iter()
894        .map(|p| Glyph {
895            deg: p.longitude.longitude_deg() as f32,
896            symbol: body_symbol(p.body).into(),
897            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
898            retrograde: p.longitude_rate_rad_per_day < 0.0,
899            house: Some(p.house_number),
900            dignity_marker: None,
901        })
902        .collect();
903    render.layers.push(Layer {
904        module_id: "solar_arc".into(),
905        kind: LayerKind::Bodies,
906        ring: 0.43,
907        z: 8,
908        geometry: Geometry::GlyphsOnly,
909        glyphs,
910    });
911
912    let cross = find_synastry_aspects(
913        natal,
914        directed,
915        &OrbTable::modern_western(),
916        EAspectKind::MAJORS,
917    );
918    let cross_lines: Vec<LineSeg> = cross
919        .iter()
920        .filter_map(|a| {
921            let natal_p = natal.placement(a.person_a_body)?;
922            let dir_p = directed.placement(a.person_b_body)?;
923            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
924            Some(LineSeg {
925                from_deg: natal_p.longitude.longitude_deg() as f32,
926                to_deg: dir_p.longitude.longitude_deg() as f32,
927                kind: aspect_kind_id(a.kind).into(),
928                opacity: opacity * 0.7,
929                from_body: body_symbol(a.person_a_body).into(),
930                to_body: body_symbol(a.person_b_body).into(),
931                orb_deg: a.orb_abs_deg() as f32,
932            })
933        })
934        .collect();
935    render.layers.push(Layer {
936        module_id: "solar_arc".into(),
937        kind: LayerKind::Aspects,
938        ring: 0.0,
939        z: 9,
940        geometry: Geometry::Lines(cross_lines),
941        glyphs: Vec::new(),
942    });
943    populate_cross_aspect_summary(&cross, "solar_arc", render);
944    Ok(())
945}
946
947/// Helper: agrega al `RenderModel` las capas del overlay de sinastría
948/// con otra carta natal completa. La carta partner se computa con su
949/// propio observer/config (no comparte con la natal). El outer ring
950/// se comparte con Transit — mutuamente excluyentes a nivel de Shell.
951fn build_synastry_overlay(
952    natal: &NatalChart,
953    partner_chart: &Chart,
954    render: &mut RenderModel,
955) -> Result<(), EngineError> {
956    let (partner, _config, _observer) = compute_natal_chart(partner_chart, 0)?;
957
958    let glyphs: Vec<Glyph> = partner
959        .placements
960        .iter()
961        .map(|p| Glyph {
962            deg: p.longitude.longitude_deg() as f32,
963            symbol: body_symbol(p.body).into(),
964            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
965            retrograde: p.longitude_rate_rad_per_day < 0.0,
966            house: Some(p.house_number),
967            dignity_marker: None,
968        })
969        .collect();
970    render.layers.push(Layer {
971        module_id: "synastry".into(),
972        kind: LayerKind::Outer,
973        ring: 0.82,
974        z: 10,
975        geometry: Geometry::GlyphsOnly,
976        glyphs,
977    });
978
979    let cross = find_synastry_aspects(
980        natal,
981        &partner,
982        &OrbTable::modern_western(),
983        EAspectKind::MAJORS,
984    );
985    let cross_lines: Vec<LineSeg> = cross
986        .iter()
987        .filter_map(|a| {
988            let natal_p = natal.placement(a.person_a_body)?;
989            let partner_p = partner.placement(a.person_b_body)?;
990            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
991            Some(LineSeg {
992                from_deg: natal_p.longitude.longitude_deg() as f32,
993                to_deg: partner_p.longitude.longitude_deg() as f32,
994                kind: aspect_kind_id(a.kind).into(),
995                opacity: opacity * 0.85,
996                from_body: body_symbol(a.person_a_body).into(),
997                to_body: body_symbol(a.person_b_body).into(),
998                orb_deg: a.orb_abs_deg() as f32,
999            })
1000        })
1001        .collect();
1002    render.layers.push(Layer {
1003        module_id: "synastry".into(),
1004        kind: LayerKind::Aspects,
1005        ring: 0.0,
1006        z: 11,
1007        geometry: Geometry::Lines(cross_lines),
1008        glyphs: Vec::new(),
1009    });
1010    populate_cross_aspect_summary(&cross, "synastry", render);
1011    Ok(())
1012}
1013
1014/// Helper: agrega al `RenderModel` las capas del overlay de retorno
1015/// planetario — la carta natal completa computada al instante en que
1016/// el `body` vuelve a su posición natal cerca de la edad pedida.
1017/// Sun = retorno solar anual, Moon = mensual, Júpiter/Saturno =
1018/// generacionales. Esa nueva carta va en el anillo externo (compartido
1019/// con Transit/Synastry, mutuamente excluyentes a nivel de Shell).
1020/// Cross aspects natal × return.
1021fn build_planetary_return_overlay(
1022    natal: &NatalChart,
1023    config_e: &ChartConfig,
1024    observer: Observer,
1025    body: Body,
1026    target_age_years: f64,
1027    shift_days: i64,
1028    render: &mut RenderModel,
1029) -> Result<(), EngineError> {
1030    let session = session()?;
1031    let natal_p = natal.placement(body).ok_or_else(|| {
1032        EngineError::Eternal(format!(
1033            "natal chart sin {} — return imposible",
1034            body.name()
1035        ))
1036    })?;
1037    let natal_lon = natal_p.longitude.longitude_rad();
1038
1039    // El offset desde el cumpleaños depende del período sinódico del
1040    // cuerpo: para Sun/planet lentos, ~30 días antes garantiza captar
1041    // el return; para Moon, ~15 días. Tomamos un margen amplio que
1042    // sirve para todos.
1043    const TROPICAL_YEAR_SECS: f64 = 365.242190 * 86400.0;
1044    // shift_days permite saltar de un retorno mensual al siguiente
1045    // cuando body=Moon, o ajustar finamente el año en Solar return.
1046    let after_seconds =
1047        (target_age_years * 365.242190 - 30.0 + shift_days as f64) * 86400.0;
1048    let after_utc = natal
1049        .birth
1050        .instant
1051        .utc()
1052        .add_seconds(after_seconds.max(-TROPICAL_YEAR_SECS * 2.0));
1053    let after = ESInstant::from_utc(after_utc);
1054
1055    let return_instant = next_return(session, body, natal_lon, after, None).map_err(|e| {
1056        EngineError::Eternal(format!("next_return {}: {:?}", body.name(), e))
1057    })?;
1058
1059    // La carta del retorno se computa al return_instant con el mismo
1060    // observer y config natales (convención clásica: return tropical
1061    // en la ciudad de nacimiento).
1062    let return_birth = BirthData::new(return_instant, observer);
1063    let return_chart = NatalChart::compute(&return_birth, config_e, session).map_err(|e| {
1064        EngineError::Eternal(format!(
1065            "NatalChart::compute ({} return): {:?}",
1066            body.name(),
1067            e
1068        ))
1069    })?;
1070
1071    let glyphs: Vec<Glyph> = return_chart
1072        .placements
1073        .iter()
1074        .map(|p| Glyph {
1075            deg: p.longitude.longitude_deg() as f32,
1076            symbol: body_symbol(p.body).into(),
1077            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
1078            retrograde: p.longitude_rate_rad_per_day < 0.0,
1079            house: Some(p.house_number),
1080            dignity_marker: None,
1081        })
1082        .collect();
1083    render.layers.push(Layer {
1084        module_id: "planetary_return".into(),
1085        kind: LayerKind::Outer,
1086        ring: 0.82,
1087        z: 12,
1088        geometry: Geometry::GlyphsOnly,
1089        glyphs,
1090    });
1091
1092    let cross = find_synastry_aspects(
1093        natal,
1094        &return_chart,
1095        &OrbTable::modern_western(),
1096        EAspectKind::MAJORS,
1097    );
1098    let cross_lines: Vec<LineSeg> = cross
1099        .iter()
1100        .filter_map(|a| {
1101            let n_p = natal.placement(a.person_a_body)?;
1102            let r_p = return_chart.placement(a.person_b_body)?;
1103            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
1104            Some(LineSeg {
1105                from_deg: n_p.longitude.longitude_deg() as f32,
1106                to_deg: r_p.longitude.longitude_deg() as f32,
1107                kind: aspect_kind_id(a.kind).into(),
1108                opacity: opacity * 0.8,
1109                from_body: body_symbol(a.person_a_body).into(),
1110                to_body: body_symbol(a.person_b_body).into(),
1111                orb_deg: a.orb_abs_deg() as f32,
1112            })
1113        })
1114        .collect();
1115    render.layers.push(Layer {
1116        module_id: "planetary_return".into(),
1117        kind: LayerKind::Aspects,
1118        ring: 0.0,
1119        z: 13,
1120        geometry: Geometry::Lines(cross_lines),
1121        glyphs: Vec::new(),
1122    });
1123    populate_cross_aspect_summary(&cross, "planetary_return", render);
1124    Ok(())
1125}
1126
1127// =====================================================================
1128// NatalChart → RenderModel
1129// =====================================================================
1130
1131fn build_render_model(
1132    chart: &Chart,
1133    natal: &NatalChart,
1134    aspects: &[Aspect],
1135    started: Instant,
1136) -> RenderModel {
1137    let ascendant_deg = natal.ascendant().longitude_deg() as f32;
1138    let midheaven_deg = natal.midheaven().longitude_deg() as f32;
1139    let descendant_deg = natal.descendant().longitude_deg() as f32;
1140    let imum_coeli_deg = natal.imum_coeli().longitude_deg() as f32;
1141
1142    // ─── Capa 0: Sign Dial ────────────────────────────────────────────
1143    let sign_dial = Layer {
1144        module_id: "natal".into(),
1145        kind: LayerKind::SignDial,
1146        ring: 1.0,
1147        z: 0,
1148        geometry: Geometry::Ring {
1149            cusps_deg: (0..12).map(|i| (i as f32) * 30.0).collect(),
1150        },
1151        glyphs: (0..12)
1152            .map(|i| Glyph {
1153                deg: (i as f32) * 30.0 + 15.0,
1154                symbol: ZODIAC_SYMBOLS[i].into(),
1155                annotation: None,
1156                retrograde: false,
1157                house: None,
1158            dignity_marker: None,
1159            })
1160            .collect(),
1161    };
1162
1163    // ─── Capa 1: Houses ───────────────────────────────────────────────
1164    let cusps_deg: Vec<f32> = natal
1165        .houses
1166        .cusps
1167        .iter()
1168        .map(|c| c.to_degrees() as f32)
1169        .collect();
1170    let houses = Layer {
1171        module_id: "natal".into(),
1172        kind: LayerKind::Houses,
1173        ring: 0.86,
1174        z: 1,
1175        geometry: Geometry::Ring {
1176            cusps_deg: cusps_deg.clone(),
1177        },
1178        glyphs: cusps_deg
1179            .iter()
1180            .enumerate()
1181            .map(|(i, c)| Glyph {
1182                deg: *c + 4.0,
1183                symbol: format!("h{}", i + 1),
1184                annotation: None,
1185                retrograde: false,
1186                house: Some((i as u8) + 1),
1187                dignity_marker: None,
1188            })
1189            .collect(),
1190    };
1191
1192    // ─── Capa 2: Bodies ───────────────────────────────────────────────
1193    let body_glyphs: Vec<Glyph> = natal
1194        .placements
1195        .iter()
1196        .map(|p| Glyph {
1197            deg: p.longitude.longitude_deg() as f32,
1198            symbol: body_symbol(p.body).into(),
1199            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
1200            // `BodyPlacement` cambió entre versiones de eternal entre
1201            // `pub fn is_retrograde(&self) -> bool` y `pub
1202            // is_retrograde: bool` — leemos el campo crudo
1203            // `longitude_rate_rad_per_day` (estable en ambas) para no
1204            // depender del wrapper.
1205            retrograde: p.longitude_rate_rad_per_day < 0.0,
1206            house: Some(p.house_number),
1207            dignity_marker: None,
1208        })
1209        .collect();
1210    let bodies = Layer {
1211        module_id: "natal".into(),
1212        kind: LayerKind::Bodies,
1213        ring: 0.72,
1214        z: 2,
1215        geometry: Geometry::Points(
1216            natal
1217                .placements
1218                .iter()
1219                .map(|p| crate::PointMark {
1220                    deg: p.longitude.longitude_deg() as f32,
1221                    label: p.body.name().into(),
1222                    tag: body_symbol(p.body).into(),
1223                })
1224                .collect(),
1225        ),
1226        glyphs: body_glyphs,
1227    };
1228
1229    // ─── Capa 3: Aspects ──────────────────────────────────────────────
1230    // Los aspects ya vienen filtrados por NatalOptions (majors / minors)
1231    // desde compose(). Acá solo mapeamos a LineSeg.
1232    let mut aspect_lines: Vec<LineSeg> = Vec::with_capacity(aspects.len());
1233    for a in aspects {
1234        let pa = natal.placement(a.a);
1235        let pb = natal.placement(a.b);
1236        if let (Some(pa), Some(pb)) = (pa, pb) {
1237            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
1238            aspect_lines.push(LineSeg {
1239                from_deg: pa.longitude.longitude_deg() as f32,
1240                to_deg: pb.longitude.longitude_deg() as f32,
1241                kind: aspect_kind_id(a.kind).into(),
1242                opacity,
1243                from_body: body_symbol(a.a).into(),
1244                to_body: body_symbol(a.b).into(),
1245                orb_deg: a.orb_abs_deg() as f32,
1246            });
1247        }
1248    }
1249    let aspects_layer = Layer {
1250        module_id: "natal".into(),
1251        kind: LayerKind::Aspects,
1252        ring: 0.58,
1253        z: 3,
1254        geometry: Geometry::Lines(aspect_lines),
1255        glyphs: Vec::new(),
1256    };
1257
1258    let subtitle = chart
1259        .birth_data
1260        .birthplace_label
1261        .clone()
1262        .or_else(|| {
1263            Some(format!(
1264                "{:04}-{:02}-{:02} · lat {:+.2}° · lon {:+.2}°",
1265                chart.birth_data.year,
1266                chart.birth_data.month,
1267                chart.birth_data.day,
1268                chart.birth_data.latitude_deg,
1269                chart.birth_data.longitude_deg,
1270            ))
1271        });
1272
1273    RenderModel {
1274        chart_id: chart.id,
1275        chart_kind: chart.kind,
1276        title: chart.label.clone(),
1277        subtitle,
1278        compute_ms: started.elapsed().as_millis() as u64,
1279        ascendant_deg,
1280        midheaven_deg,
1281        descendant_deg,
1282        imum_coeli_deg,
1283        layers: vec![sign_dial, houses, bodies, aspects_layer],
1284        overlays: Vec::new(),
1285        aspect_summary: Vec::new(),
1286        uranian_groups: Vec::new(),
1287    }
1288}
1289
1290/// Construye una `OrbTable` con los orbes default de `modern_western`
1291/// escalados por `multiplier`. Necesario porque eternal expone
1292/// `set_orb` pero no permite iterar los base orbs internos.
1293fn build_orb_table(multiplier: f64) -> OrbTable {
1294    let mut t = OrbTable::modern_western();
1295    let m = multiplier.max(0.0);
1296    t.set_orb(EAspectKind::Conjunction, 8.0 * m);
1297    t.set_orb(EAspectKind::Opposition, 8.0 * m);
1298    t.set_orb(EAspectKind::Trine, 7.0 * m);
1299    t.set_orb(EAspectKind::Square, 7.0 * m);
1300    t.set_orb(EAspectKind::Sextile, 5.0 * m);
1301    t.set_orb(EAspectKind::Quincunx, 2.5 * m);
1302    t.set_orb(EAspectKind::SemiSextile, 2.0 * m);
1303    t.set_orb(EAspectKind::SemiSquare, 2.0 * m);
1304    t.set_orb(EAspectKind::Sesquiquadrate, 2.0 * m);
1305    t.set_orb(EAspectKind::Quintile, 1.5 * m);
1306    t.set_orb(EAspectKind::BiQuintile, 1.5 * m);
1307    t.set_orb(EAspectKind::Septile, 1.5 * m);
1308    t
1309}
1310
1311fn push_overlay_meta(render: &mut RenderModel, module_id: &str, label: String) {
1312    render.overlays.push(OverlayMeta {
1313        module_id: module_id.to_string(),
1314        label,
1315    });
1316}
1317
1318/// Helper: agrega al `RenderModel` los Lots arábigos clásicos
1319/// (helenísticos) — Fortune, Spirit, Eros, Necessity, Courage, Victory,
1320/// Nemesis. Cada uno se renderea como un glifo `lot:Fo` en el anillo
1321/// `0.54` (entre midpoints y cuerpos progresados). Retorna la cantidad
1322/// de lots renderizados.
1323fn build_lots_overlay(
1324    natal: &NatalChart,
1325    render: &mut RenderModel,
1326) -> Result<usize, EngineError> {
1327    let lots = all_lots(natal)
1328        .map_err(|e| EngineError::Eternal(format!("all_lots: {:?}", e)))?;
1329    let glyphs: Vec<Glyph> = lots
1330        .iter()
1331        .map(|l| {
1332            let name = l.name.map(|n| n.label()).unwrap_or("Lot");
1333            // Tres-letras compactas para no recargar la rueda.
1334            let abbrev: String = name.chars().take(2).collect();
1335            Glyph {
1336                deg: l.longitude.longitude_deg() as f32,
1337                symbol: format!("lot:{}", abbrev),
1338                annotation: Some(name.to_string()),
1339                retrograde: false,
1340                house: Some(l.house_number),
1341                dignity_marker: None,
1342            }
1343        })
1344        .collect();
1345    let count = glyphs.len();
1346    render.layers.push(Layer {
1347        module_id: "lots".into(),
1348        kind: LayerKind::Lots,
1349        ring: 0.54,
1350        z: 13,
1351        geometry: Geometry::GlyphsOnly,
1352        glyphs,
1353    });
1354    Ok(count)
1355}
1356
1357/// Helper: agrega al `RenderModel` 9 estrellas fijas notables. Las
1358/// longitudes están en J2000 ecliptica tropical; aplicamos precesión
1359/// general de 50.29″/año hacia adelante hasta el año natal — basta
1360/// para el orbe de conjunción de ±1.5° con que se interpretan.
1361fn build_fixed_stars_overlay(chart: &Chart, render: &mut RenderModel) -> usize {
1362    // (símbolo, nombre, longitud tropical J2000 en grados)
1363    const STARS: &[(&str, &str, f64)] = &[
1364        ("✦Ald", "Aldebaran", 69.79),     // 09°47′ Gem
1365        ("✦Reg", "Regulus", 149.83),      // 29°50′ Leo
1366        ("✦Ant", "Antares", 249.77),      // 09°46′ Sag
1367        ("✦Fom", "Fomalhaut", 333.87),    // 03°52′ Pis
1368        ("✦Spi", "Spica", 203.84),        // 23°50′ Lib
1369        ("✦Sir", "Sirius", 104.10),       // 14°06′ Can
1370        ("✦Alg", "Algol", 56.18),         // 26°10′ Tau
1371        ("✦Veg", "Vega", 285.31),         // 15°19′ Cap
1372        ("✦Pol", "Pollux", 113.27),       // 23°16′ Can
1373    ];
1374    let years_from_j2000 = (chart.birth_data.year - 2000) as f64;
1375    // 50.29″/año ≈ 0.01397°/año de precesión en longitud eclíptica.
1376    let precession_deg = years_from_j2000 * (50.29 / 3600.0);
1377    let glyphs: Vec<Glyph> = STARS
1378        .iter()
1379        .map(|(sym, name, j2000_deg)| {
1380            let lon = (j2000_deg + precession_deg).rem_euclid(360.0) as f32;
1381            Glyph {
1382                deg: lon,
1383                symbol: (*sym).to_string(),
1384                annotation: Some((*name).to_string()),
1385                retrograde: false,
1386                house: None,
1387                dignity_marker: None,
1388            }
1389        })
1390        .collect();
1391    let count = glyphs.len();
1392    render.layers.push(Layer {
1393        module_id: "fixed_stars".into(),
1394        kind: LayerKind::FixedStars,
1395        ring: 1.04,
1396        z: 16,
1397        geometry: Geometry::GlyphsOnly,
1398        glyphs,
1399    });
1400    count
1401}
1402
1403/// Decora cada Glyph de Bodies (module_id="natal") con su dignity
1404/// marker en `glyph.dignity_marker`. Usa `essential_dignity(body, sign)`
1405/// — los cuerpos modernos quedan sin marker.
1406fn annotate_dignities(natal: &NatalChart, render: &mut RenderModel) {
1407    use std::collections::HashMap;
1408    let mut by_symbol: HashMap<&'static str, &'static str> = HashMap::new();
1409    for p in &natal.placements {
1410        let sign_idx = (p.longitude.longitude_deg() / 30.0).floor() as u8 % 12;
1411        if let Some(d) = essential_dignity(p.body, sign_idx) {
1412            by_symbol.insert(body_symbol(p.body), d.marker());
1413        }
1414    }
1415    for layer in render.layers.iter_mut() {
1416        if matches!(layer.kind, LayerKind::Bodies) && layer.module_id == "natal" {
1417            for g in layer.glyphs.iter_mut() {
1418                if let Some(marker) = by_symbol.get(g.symbol.as_str()) {
1419                    g.dignity_marker = Some((*marker).to_string());
1420                }
1421            }
1422        }
1423    }
1424}
1425
1426fn populate_natal_aspect_summary(aspects: &[Aspect], render: &mut RenderModel) {
1427    for a in aspects {
1428        render.aspect_summary.push(AspectSummary {
1429            module_id: "natal".into(),
1430            from_body: body_symbol(a.a).into(),
1431            to_body: body_symbol(a.b).into(),
1432            kind: aspect_kind_id(a.kind).into(),
1433            orb_deg: a.orb_abs_deg(),
1434            applying: Some(a.applying),
1435        });
1436    }
1437    sort_aspect_summary(render);
1438}
1439
1440fn populate_cross_aspect_summary(
1441    cross: &[eternal_astrology::SynastryAspect],
1442    module_id: &str,
1443    render: &mut RenderModel,
1444) {
1445    for a in cross {
1446        render.aspect_summary.push(AspectSummary {
1447            module_id: module_id.to_string(),
1448            from_body: body_symbol(a.person_a_body).into(),
1449            to_body: body_symbol(a.person_b_body).into(),
1450            kind: aspect_kind_id(a.kind).into(),
1451            orb_deg: a.orb_abs_deg(),
1452            applying: None,
1453        });
1454    }
1455    sort_aspect_summary(render);
1456}
1457
1458fn sort_aspect_summary(render: &mut RenderModel) {
1459    render
1460        .aspect_summary
1461        .sort_by(|x, y| x.orb_deg.partial_cmp(&y.orb_deg).unwrap_or(std::cmp::Ordering::Equal));
1462}
1463
1464/// Mapea el orb absoluto a una opacidad — los aspectos más exactos se
1465/// pintan más fuerte, los flojos casi se desvanecen.
1466fn orb_to_opacity(orb_deg: f64, kind: EAspectKind) -> f32 {
1467    let max = match kind {
1468        EAspectKind::Conjunction | EAspectKind::Opposition => 8.0,
1469        EAspectKind::Trine | EAspectKind::Square => 7.0,
1470        EAspectKind::Sextile => 5.0,
1471        _ => 3.0,
1472    };
1473    let t = (1.0 - (orb_deg / max).min(1.0)).max(0.25);
1474    t as f32
1475}
1476
1477const ZODIAC_SYMBOLS: [&str; 12] = [
1478    "aries",
1479    "taurus",
1480    "gemini",
1481    "cancer",
1482    "leo",
1483    "virgo",
1484    "libra",
1485    "scorpio",
1486    "sagittarius",
1487    "capricorn",
1488    "aquarius",
1489    "pisces",
1490];