tawasuyu

tawasuyu/brahman

Rust 492TOML 194Markdown 53JSON 176939 Definitions

⎇ 7eb620aa170f825b5dec6ff454baac07c48bd9f1 crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs Rust · 1471 lines · 52.9 KB History Who touched what raw

feat(tahuantinsuyu): GR dual-ring + topo ascensional pegado al dial + coords 7eb620aa1 · 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 { target_age_years } => {
394                build_primary_directions_overlay(&natal, *target_age_years, &mut render);
395                push_overlay_meta(
396                    &mut render,
397                    "primary_directions",
398                    format!("GR Direcciones · {:.1}a", target_age_years),
399                );
400            }
401        }
402    }
403
404    render.compute_ms = t0.elapsed().as_millis() as u64;
405    Ok(render)
406}
407
408/// Helper: agrega al `RenderModel` las dos capas del overlay de
409/// tránsitos (Outer + cross Aspects).
410fn build_transit_overlay(
411    natal: &NatalChart,
412    config_e: &ChartConfig,
413    observer: Observer,
414    transit_at: ESInstant,
415    render: &mut RenderModel,
416) -> Result<(), EngineError> {
417    let transit_birth = BirthData::new(transit_at, observer);
418    let session = session()?;
419    let transit = NatalChart::compute(&transit_birth, config_e, session).map_err(|e| {
420        EngineError::Eternal(format!("NatalChart::compute (transit): {:?}", e))
421    })?;
422
423    let outer_glyphs: Vec<Glyph> = transit
424        .placements
425        .iter()
426        .map(|p| Glyph {
427            deg: p.longitude.longitude_deg() as f32,
428            symbol: body_symbol(p.body).into(),
429            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
430            retrograde: p.longitude_rate_rad_per_day < 0.0,
431            house: None,
432            dignity_marker: None,
433        })
434        .collect();
435    render.layers.push(Layer {
436        module_id: "transit".into(),
437        kind: LayerKind::Outer,
438        ring: 0.82,
439        z: 4,
440        geometry: Geometry::GlyphsOnly,
441        glyphs: outer_glyphs,
442    });
443
444    let cross = find_synastry_aspects(
445        natal,
446        &transit,
447        &OrbTable::modern_western(),
448        EAspectKind::MAJORS,
449    );
450    let cross_lines: Vec<LineSeg> = cross
451        .iter()
452        .filter_map(|a| {
453            let natal_p = natal.placement(a.person_a_body)?;
454            let transit_p = transit.placement(a.person_b_body)?;
455            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
456            Some(LineSeg {
457                from_deg: natal_p.longitude.longitude_deg() as f32,
458                to_deg: transit_p.longitude.longitude_deg() as f32,
459                kind: aspect_kind_id(a.kind).into(),
460                opacity: opacity * 0.75,
461                from_body: body_symbol(a.person_a_body).into(),
462                to_body: body_symbol(a.person_b_body).into(),
463                orb_deg: a.orb_abs_deg() as f32,
464            })
465        })
466        .collect();
467    render.layers.push(Layer {
468        module_id: "transit".into(),
469        kind: LayerKind::Aspects,
470        ring: 0.0,
471        z: 5,
472        geometry: Geometry::Lines(cross_lines),
473        glyphs: Vec::new(),
474    });
475    populate_cross_aspect_summary(&cross, "transit", render);
476    Ok(())
477}
478
479/// Helper: agrega al `RenderModel` las capas del overlay de progresión
480/// secundaria. La carta progresada se computa con el mismo observer y
481/// config que la natal pero al instante natal+(age_years/period_years)
482/// días.
483/// Overlay topocéntrico: re-proyecta cada placement natal a longitud
484/// topocéntrica (con paralaje horizontal) y recalcula las casas con
485/// Polich-Page. Los dos quedan emparentados al mismo `module_id =
486/// "topocentric"` para que el canvas los pinte con un visual
487/// consistente. La capa convive con la natal geocéntrica — ambas se
488/// ven simultáneamente.
489fn build_topocentric_overlay(
490    natal: &NatalChart,
491    render: &mut RenderModel,
492) -> Result<(), EngineError> {
493    const KM_PER_AU: f64 = 149_597_870.7;
494    let lst = natal.local_apparent_sidereal_time_rad;
495    let eps = natal.obliquity_rad;
496    let obs_lat = natal.birth.observer.lat_rad;
497
498    // 1) Planetas topocéntricos. Para puntos sin distancia (nodos,
499    // Lilith calculada) `topocentric_ecliptic` retorna la entrada sin
500    // cambios — geocéntrico y topocéntrico coinciden ahí.
501    let body_glyphs: Vec<Glyph> = natal
502        .placements
503        .iter()
504        .map(|p| {
505            let dist_au = p.distance_km / KM_PER_AU;
506            let (lon_topo, _) = topocentric_ecliptic(
507                p.longitude.longitude_rad(),
508                p.latitude_rad,
509                dist_au,
510                obs_lat,
511                lst,
512                eps,
513            );
514            let lon_topo_deg = lon_topo.to_degrees() as f32;
515            Glyph {
516                deg: lon_topo_deg,
517                symbol: body_symbol(p.body).into(),
518                annotation: Some(format!("{:.2}° topo", lon_topo_deg)),
519                retrograde: p.longitude_rate_rad_per_day < 0.0,
520                house: None,
521                dignity_marker: None,
522            }
523        })
524        .collect();
525
526    render.layers.push(Layer {
527        module_id: "topocentric".into(),
528        kind: LayerKind::Bodies,
529        ring: 0.50,
530        z: 8,
531        geometry: Geometry::GlyphsOnly,
532        glyphs: body_glyphs,
533    });
534
535    // 2) Casas Polich-Page. Si la latitud cae en el círculo polar el
536    // sistema diverge — devolvemos un error parcial pero conservamos
537    // la capa de planetas topocéntricos (que sí es válida).
538    match EHouses::compute(EHouseSystem::PolichPage, lst, obs_lat, eps) {
539        Ok(houses_pp) => {
540            let cusps_deg: Vec<f32> =
541                houses_pp.cusps.iter().map(|c| c.to_degrees() as f32).collect();
542            let house_glyphs: Vec<Glyph> = (0..12)
543                .map(|i| Glyph {
544                    deg: cusps_deg[i] + 4.0,
545                    symbol: format!("h{}", i + 1),
546                    annotation: None,
547                    retrograde: false,
548                    house: Some((i as u8) + 1),
549                    dignity_marker: None,
550                })
551                .collect();
552            render.layers.push(Layer {
553                module_id: "topocentric".into(),
554                kind: LayerKind::Houses,
555                ring: 0.78,
556                z: 9,
557                geometry: Geometry::Ring { cusps_deg },
558                glyphs: house_glyphs,
559            });
560        }
561        Err(e) => {
562            // Polo: el visual se queda solo con planetas topocéntricos.
563            eprintln!("[bridge] PolichPage no disponible en lat polar: {:?}", e);
564        }
565    }
566
567    Ok(())
568}
569
570/// GR dual-ring de Direcciones Primarias: a la edad pedida, cada
571/// cuerpo natal se proyecta dos veces — directa (rotación diurna
572/// forward, anillo afuera) y conversa (rotación inversa, anillo
573/// dentro). En rectificación, los dos rings se ven simultáneamente
574/// y si un evento real cayó cerca de un ángulo, debe aparecer
575/// "cruzado" con ambos arcos coincidentes — eso valida la hora.
576///
577/// Usa el key Naibod (0°59'08″/año) como default — convención GR.
578fn build_primary_directions_overlay(
579    natal: &NatalChart,
580    target_age_years: f64,
581    render: &mut RenderModel,
582) {
583    let key = EDirectionKey::Naibod;
584    let eps = natal.obliquity_rad;
585
586    let project = |dir: PrimaryDirection| -> Vec<Glyph> {
587        natal
588            .placements
589            .iter()
590            .map(|p| {
591                let new_lon_rad = directed_longitude(
592                    p.right_ascension_rad,
593                    p.declination_rad,
594                    target_age_years,
595                    dir,
596                    key,
597                    eps,
598                );
599                let new_lon_deg = new_lon_rad.to_degrees() as f32;
600                Glyph {
601                    deg: new_lon_deg,
602                    symbol: body_symbol(p.body).into(),
603                    annotation: Some(format!("{:.2}°", new_lon_deg)),
604                    retrograde: p.longitude_rate_rad_per_day < 0.0,
605                    house: None,
606                    dignity_marker: None,
607                }
608            })
609            .collect()
610    };
611
612    let direct_glyphs = project(PrimaryDirection::Direct);
613    let converse_glyphs = project(PrimaryDirection::Converse);
614
615    render.layers.push(Layer {
616        module_id: "pd_direct".into(),
617        kind: LayerKind::Bodies,
618        ring: 0.0,
619        z: 10,
620        geometry: Geometry::GlyphsOnly,
621        glyphs: direct_glyphs,
622    });
623    render.layers.push(Layer {
624        module_id: "pd_converse".into(),
625        kind: LayerKind::Bodies,
626        ring: 0.0,
627        z: 11,
628        geometry: Geometry::GlyphsOnly,
629        glyphs: converse_glyphs,
630    });
631}
632
633fn build_progression_overlay(
634    natal: &NatalChart,
635    target_age_years: f64,
636    render: &mut RenderModel,
637) -> Result<(), EngineError> {
638    let session = session()?;
639    let prog = secondary_progression(natal, session, target_age_years)
640        .map_err(|e| EngineError::Eternal(format!("secondary_progression: {:?}", e)))?;
641    let progressed = &prog.progressed;
642
643    // Glifos de los cuerpos progresados — anillo interno (radio 0.48).
644    let glyphs: Vec<Glyph> = progressed
645        .placements
646        .iter()
647        .map(|p| Glyph {
648            deg: p.longitude.longitude_deg() as f32,
649            symbol: body_symbol(p.body).into(),
650            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
651            retrograde: p.longitude_rate_rad_per_day < 0.0,
652            house: Some(p.house_number),
653            dignity_marker: None,
654        })
655        .collect();
656    render.layers.push(Layer {
657        module_id: "progression".into(),
658        kind: LayerKind::Bodies,
659        ring: 0.48,
660        z: 6,
661        geometry: Geometry::GlyphsOnly,
662        glyphs,
663    });
664
665    // Cross aspects natal × progresada (sólo mayores).
666    let cross = find_synastry_aspects(
667        natal,
668        progressed,
669        &OrbTable::modern_western(),
670        EAspectKind::MAJORS,
671    );
672    let cross_lines: Vec<LineSeg> = cross
673        .iter()
674        .filter_map(|a| {
675            let natal_p = natal.placement(a.person_a_body)?;
676            let prog_p = progressed.placement(a.person_b_body)?;
677            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
678            Some(LineSeg {
679                from_deg: natal_p.longitude.longitude_deg() as f32,
680                to_deg: prog_p.longitude.longitude_deg() as f32,
681                kind: aspect_kind_id(a.kind).into(),
682                opacity: opacity * 0.7,
683                from_body: body_symbol(a.person_a_body).into(),
684                to_body: body_symbol(a.person_b_body).into(),
685                orb_deg: a.orb_abs_deg() as f32,
686            })
687        })
688        .collect();
689    render.layers.push(Layer {
690        module_id: "progression".into(),
691        kind: LayerKind::Aspects,
692        ring: 0.0,
693        z: 7,
694        geometry: Geometry::Lines(cross_lines),
695        glyphs: Vec::new(),
696    });
697    populate_cross_aspect_summary(&cross, "progression", render);
698    Ok(())
699}
700
701/// Helper: detecta "ejes" del dial uraniano de 90° — cuerpos natales
702/// cuya longitud módulo 90 cae dentro de una tolerancia ε (2° por
703/// default). Llena `render.uranian_groups` con los grupos detectados.
704fn build_uranian_groups(natal: &NatalChart, render: &mut RenderModel) {
705    const EPSILON: f64 = 2.0;
706    let mut entries: Vec<(String, f64)> = natal
707        .placements
708        .iter()
709        .map(|p| {
710            let lon = p.longitude.longitude_deg();
711            let mod90 = lon.rem_euclid(90.0);
712            (body_symbol(p.body).to_string(), mod90)
713        })
714        .collect();
715    entries.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
716
717    let mut groups: Vec<UranianGroup> = Vec::new();
718    let mut current: Vec<(String, f64)> = Vec::new();
719    let mut anchor_mod90 = 0.0_f64;
720    for entry in entries {
721        if current.is_empty() {
722            anchor_mod90 = entry.1;
723            current.push(entry);
724            continue;
725        }
726        // Distancia circular módulo 90 entre el entry y el anchor.
727        let mut diff = (entry.1 - anchor_mod90).abs();
728        if diff > 45.0 {
729            diff = 90.0 - diff;
730        }
731        if diff <= EPSILON {
732            current.push(entry);
733        } else {
734            if current.len() >= 2 {
735                let center = current.iter().map(|(_, m)| *m).sum::<f64>() / current.len() as f64;
736                groups.push(UranianGroup {
737                    bodies: current.iter().map(|(s, _)| s.clone()).collect(),
738                    mod90_deg: center,
739                });
740            }
741            anchor_mod90 = entry.1;
742            current = vec![entry];
743        }
744    }
745    if current.len() >= 2 {
746        let center = current.iter().map(|(_, m)| *m).sum::<f64>() / current.len() as f64;
747        groups.push(UranianGroup {
748            bodies: current.iter().map(|(s, _)| s.clone()).collect(),
749            mod90_deg: center,
750        });
751    }
752    // Wrap-around check: el primer y último grupo podrían ser el mismo
753    // (si span >88° abarcando el wrap en 90/0). Si los anchors están a
754    // ≤EPSILON modulo 90, mergeamos.
755    if groups.len() >= 2 {
756        let first_mod = groups[0].mod90_deg;
757        let last_mod = groups[groups.len() - 1].mod90_deg;
758        let mut diff = (first_mod - last_mod).abs();
759        if diff > 45.0 {
760            diff = 90.0 - diff;
761        }
762        if diff <= EPSILON {
763            let last = groups.pop().unwrap();
764            groups[0].bodies.extend(last.bodies);
765        }
766    }
767    render.uranian_groups = groups;
768}
769
770/// Helper: agrega al `RenderModel` la carta compuesta (midpoint
771/// composite, Davison 1958) entre la natal del sujeto y la carta del
772/// partner. Cada planeta compuesto es el angular midpoint entre los
773/// dos correspondientes. Se renderea en `radii.composite` (ring 0.36).
774fn build_composite_overlay(
775    natal: &NatalChart,
776    partner_chart: &Chart,
777    render: &mut RenderModel,
778) -> Result<(), EngineError> {
779    let (partner_natal, _config, _observer) = compute_natal_chart(partner_chart, 0)?;
780    let comp = composite(natal, &partner_natal).map_err(|e| {
781        EngineError::Eternal(format!("composite: {:?}", e))
782    })?;
783
784    let glyphs: Vec<Glyph> = comp
785        .placements
786        .iter()
787        .map(|p| Glyph {
788            deg: p.longitude.longitude_deg() as f32,
789            symbol: body_symbol(p.body).into(),
790            annotation: Some(format!("composite {}", p.body.name())),
791            retrograde: false,
792            house: Some(p.house_number),
793            dignity_marker: None,
794        })
795        .collect();
796    render.layers.push(Layer {
797        module_id: "composite".into(),
798        kind: LayerKind::Bodies,
799        ring: 0.36,
800        z: 15,
801        geometry: Geometry::GlyphsOnly,
802        glyphs,
803    });
804    Ok(())
805}
806
807/// Helper: agrega al `RenderModel` los midpoints entre pares de
808/// cuerpos natales. Filtra para mostrar solo los que involucran al
809/// Sol o a la Luna (~10 puntos) — son los más significativos
810/// astrológicamente y mantiene la rueda legible.
811///
812/// El midpoint de dos longitudes es la menor distancia angular entre
813/// ellas. Si `|a - b| > 180`, hay que sumar 180 al promedio para
814/// obtener el midpoint "corto".
815fn build_midpoints_overlay(natal: &NatalChart, render: &mut RenderModel) {
816    let mut glyphs: Vec<Glyph> = Vec::new();
817    let placements = &natal.placements;
818
819    for i in 0..placements.len() {
820        for j in (i + 1)..placements.len() {
821            let pa = &placements[i];
822            let pb = &placements[j];
823            // Solo midpoints que involucren Sol o Luna.
824            let involves_luminary = matches!(pa.body, Body::Sun | Body::Moon)
825                || matches!(pb.body, Body::Sun | Body::Moon);
826            if !involves_luminary {
827                continue;
828            }
829            let a = pa.longitude.longitude_deg() as f32;
830            let b = pb.longitude.longitude_deg() as f32;
831            let diff = (a - b).abs();
832            let mid = if diff > 180.0 {
833                ((a + b) / 2.0 + 180.0).rem_euclid(360.0)
834            } else {
835                ((a + b) / 2.0).rem_euclid(360.0)
836            };
837            glyphs.push(Glyph {
838                deg: mid,
839                symbol: format!("{}/{}", body_symbol(pa.body), body_symbol(pb.body)),
840                annotation: Some(format!("{}/{}", pa.body.name(), pb.body.name())),
841                retrograde: false,
842                house: None,
843                dignity_marker: None,
844            });
845        }
846    }
847
848    render.layers.push(Layer {
849        module_id: "midpoints".into(),
850        kind: LayerKind::Midpoints,
851        ring: 0.62,
852        z: 14,
853        geometry: Geometry::GlyphsOnly,
854        glyphs,
855    });
856}
857
858/// Helper: agrega al `RenderModel` las capas del overlay de Solar Arc
859/// (método true-progressed-Sun por default). Cada cuerpo natal se
860/// desplaza por el mismo arco — preserva las relaciones angulares y
861/// las posiciones relativas en casas se mantienen.
862fn build_solar_arc_overlay(
863    natal: &NatalChart,
864    target_age_years: f64,
865    render: &mut RenderModel,
866) -> Result<(), EngineError> {
867    let session = session()?;
868    let arc = solar_arc_true(natal, session, target_age_years)
869        .map_err(|e| EngineError::Eternal(format!("solar_arc_true: {:?}", e)))?;
870    let directed = &arc.directed;
871
872    let glyphs: Vec<Glyph> = directed
873        .placements
874        .iter()
875        .map(|p| Glyph {
876            deg: p.longitude.longitude_deg() as f32,
877            symbol: body_symbol(p.body).into(),
878            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
879            retrograde: p.longitude_rate_rad_per_day < 0.0,
880            house: Some(p.house_number),
881            dignity_marker: None,
882        })
883        .collect();
884    render.layers.push(Layer {
885        module_id: "solar_arc".into(),
886        kind: LayerKind::Bodies,
887        ring: 0.43,
888        z: 8,
889        geometry: Geometry::GlyphsOnly,
890        glyphs,
891    });
892
893    let cross = find_synastry_aspects(
894        natal,
895        directed,
896        &OrbTable::modern_western(),
897        EAspectKind::MAJORS,
898    );
899    let cross_lines: Vec<LineSeg> = cross
900        .iter()
901        .filter_map(|a| {
902            let natal_p = natal.placement(a.person_a_body)?;
903            let dir_p = directed.placement(a.person_b_body)?;
904            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
905            Some(LineSeg {
906                from_deg: natal_p.longitude.longitude_deg() as f32,
907                to_deg: dir_p.longitude.longitude_deg() as f32,
908                kind: aspect_kind_id(a.kind).into(),
909                opacity: opacity * 0.7,
910                from_body: body_symbol(a.person_a_body).into(),
911                to_body: body_symbol(a.person_b_body).into(),
912                orb_deg: a.orb_abs_deg() as f32,
913            })
914        })
915        .collect();
916    render.layers.push(Layer {
917        module_id: "solar_arc".into(),
918        kind: LayerKind::Aspects,
919        ring: 0.0,
920        z: 9,
921        geometry: Geometry::Lines(cross_lines),
922        glyphs: Vec::new(),
923    });
924    populate_cross_aspect_summary(&cross, "solar_arc", render);
925    Ok(())
926}
927
928/// Helper: agrega al `RenderModel` las capas del overlay de sinastría
929/// con otra carta natal completa. La carta partner se computa con su
930/// propio observer/config (no comparte con la natal). El outer ring
931/// se comparte con Transit — mutuamente excluyentes a nivel de Shell.
932fn build_synastry_overlay(
933    natal: &NatalChart,
934    partner_chart: &Chart,
935    render: &mut RenderModel,
936) -> Result<(), EngineError> {
937    let (partner, _config, _observer) = compute_natal_chart(partner_chart, 0)?;
938
939    let glyphs: Vec<Glyph> = partner
940        .placements
941        .iter()
942        .map(|p| Glyph {
943            deg: p.longitude.longitude_deg() as f32,
944            symbol: body_symbol(p.body).into(),
945            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
946            retrograde: p.longitude_rate_rad_per_day < 0.0,
947            house: Some(p.house_number),
948            dignity_marker: None,
949        })
950        .collect();
951    render.layers.push(Layer {
952        module_id: "synastry".into(),
953        kind: LayerKind::Outer,
954        ring: 0.82,
955        z: 10,
956        geometry: Geometry::GlyphsOnly,
957        glyphs,
958    });
959
960    let cross = find_synastry_aspects(
961        natal,
962        &partner,
963        &OrbTable::modern_western(),
964        EAspectKind::MAJORS,
965    );
966    let cross_lines: Vec<LineSeg> = cross
967        .iter()
968        .filter_map(|a| {
969            let natal_p = natal.placement(a.person_a_body)?;
970            let partner_p = partner.placement(a.person_b_body)?;
971            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
972            Some(LineSeg {
973                from_deg: natal_p.longitude.longitude_deg() as f32,
974                to_deg: partner_p.longitude.longitude_deg() as f32,
975                kind: aspect_kind_id(a.kind).into(),
976                opacity: opacity * 0.85,
977                from_body: body_symbol(a.person_a_body).into(),
978                to_body: body_symbol(a.person_b_body).into(),
979                orb_deg: a.orb_abs_deg() as f32,
980            })
981        })
982        .collect();
983    render.layers.push(Layer {
984        module_id: "synastry".into(),
985        kind: LayerKind::Aspects,
986        ring: 0.0,
987        z: 11,
988        geometry: Geometry::Lines(cross_lines),
989        glyphs: Vec::new(),
990    });
991    populate_cross_aspect_summary(&cross, "synastry", render);
992    Ok(())
993}
994
995/// Helper: agrega al `RenderModel` las capas del overlay de retorno
996/// planetario — la carta natal completa computada al instante en que
997/// el `body` vuelve a su posición natal cerca de la edad pedida.
998/// Sun = retorno solar anual, Moon = mensual, Júpiter/Saturno =
999/// generacionales. Esa nueva carta va en el anillo externo (compartido
1000/// con Transit/Synastry, mutuamente excluyentes a nivel de Shell).
1001/// Cross aspects natal × return.
1002fn build_planetary_return_overlay(
1003    natal: &NatalChart,
1004    config_e: &ChartConfig,
1005    observer: Observer,
1006    body: Body,
1007    target_age_years: f64,
1008    shift_days: i64,
1009    render: &mut RenderModel,
1010) -> Result<(), EngineError> {
1011    let session = session()?;
1012    let natal_p = natal.placement(body).ok_or_else(|| {
1013        EngineError::Eternal(format!(
1014            "natal chart sin {} — return imposible",
1015            body.name()
1016        ))
1017    })?;
1018    let natal_lon = natal_p.longitude.longitude_rad();
1019
1020    // El offset desde el cumpleaños depende del período sinódico del
1021    // cuerpo: para Sun/planet lentos, ~30 días antes garantiza captar
1022    // el return; para Moon, ~15 días. Tomamos un margen amplio que
1023    // sirve para todos.
1024    const TROPICAL_YEAR_SECS: f64 = 365.242190 * 86400.0;
1025    // shift_days permite saltar de un retorno mensual al siguiente
1026    // cuando body=Moon, o ajustar finamente el año en Solar return.
1027    let after_seconds =
1028        (target_age_years * 365.242190 - 30.0 + shift_days as f64) * 86400.0;
1029    let after_utc = natal
1030        .birth
1031        .instant
1032        .utc()
1033        .add_seconds(after_seconds.max(-TROPICAL_YEAR_SECS * 2.0));
1034    let after = ESInstant::from_utc(after_utc);
1035
1036    let return_instant = next_return(session, body, natal_lon, after, None).map_err(|e| {
1037        EngineError::Eternal(format!("next_return {}: {:?}", body.name(), e))
1038    })?;
1039
1040    // La carta del retorno se computa al return_instant con el mismo
1041    // observer y config natales (convención clásica: return tropical
1042    // en la ciudad de nacimiento).
1043    let return_birth = BirthData::new(return_instant, observer);
1044    let return_chart = NatalChart::compute(&return_birth, config_e, session).map_err(|e| {
1045        EngineError::Eternal(format!(
1046            "NatalChart::compute ({} return): {:?}",
1047            body.name(),
1048            e
1049        ))
1050    })?;
1051
1052    let glyphs: Vec<Glyph> = return_chart
1053        .placements
1054        .iter()
1055        .map(|p| Glyph {
1056            deg: p.longitude.longitude_deg() as f32,
1057            symbol: body_symbol(p.body).into(),
1058            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
1059            retrograde: p.longitude_rate_rad_per_day < 0.0,
1060            house: Some(p.house_number),
1061            dignity_marker: None,
1062        })
1063        .collect();
1064    render.layers.push(Layer {
1065        module_id: "planetary_return".into(),
1066        kind: LayerKind::Outer,
1067        ring: 0.82,
1068        z: 12,
1069        geometry: Geometry::GlyphsOnly,
1070        glyphs,
1071    });
1072
1073    let cross = find_synastry_aspects(
1074        natal,
1075        &return_chart,
1076        &OrbTable::modern_western(),
1077        EAspectKind::MAJORS,
1078    );
1079    let cross_lines: Vec<LineSeg> = cross
1080        .iter()
1081        .filter_map(|a| {
1082            let n_p = natal.placement(a.person_a_body)?;
1083            let r_p = return_chart.placement(a.person_b_body)?;
1084            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
1085            Some(LineSeg {
1086                from_deg: n_p.longitude.longitude_deg() as f32,
1087                to_deg: r_p.longitude.longitude_deg() as f32,
1088                kind: aspect_kind_id(a.kind).into(),
1089                opacity: opacity * 0.8,
1090                from_body: body_symbol(a.person_a_body).into(),
1091                to_body: body_symbol(a.person_b_body).into(),
1092                orb_deg: a.orb_abs_deg() as f32,
1093            })
1094        })
1095        .collect();
1096    render.layers.push(Layer {
1097        module_id: "planetary_return".into(),
1098        kind: LayerKind::Aspects,
1099        ring: 0.0,
1100        z: 13,
1101        geometry: Geometry::Lines(cross_lines),
1102        glyphs: Vec::new(),
1103    });
1104    populate_cross_aspect_summary(&cross, "planetary_return", render);
1105    Ok(())
1106}
1107
1108// =====================================================================
1109// NatalChart → RenderModel
1110// =====================================================================
1111
1112fn build_render_model(
1113    chart: &Chart,
1114    natal: &NatalChart,
1115    aspects: &[Aspect],
1116    started: Instant,
1117) -> RenderModel {
1118    let ascendant_deg = natal.ascendant().longitude_deg() as f32;
1119    let midheaven_deg = natal.midheaven().longitude_deg() as f32;
1120    let descendant_deg = natal.descendant().longitude_deg() as f32;
1121    let imum_coeli_deg = natal.imum_coeli().longitude_deg() as f32;
1122
1123    // ─── Capa 0: Sign Dial ────────────────────────────────────────────
1124    let sign_dial = Layer {
1125        module_id: "natal".into(),
1126        kind: LayerKind::SignDial,
1127        ring: 1.0,
1128        z: 0,
1129        geometry: Geometry::Ring {
1130            cusps_deg: (0..12).map(|i| (i as f32) * 30.0).collect(),
1131        },
1132        glyphs: (0..12)
1133            .map(|i| Glyph {
1134                deg: (i as f32) * 30.0 + 15.0,
1135                symbol: ZODIAC_SYMBOLS[i].into(),
1136                annotation: None,
1137                retrograde: false,
1138                house: None,
1139            dignity_marker: None,
1140            })
1141            .collect(),
1142    };
1143
1144    // ─── Capa 1: Houses ───────────────────────────────────────────────
1145    let cusps_deg: Vec<f32> = natal
1146        .houses
1147        .cusps
1148        .iter()
1149        .map(|c| c.to_degrees() as f32)
1150        .collect();
1151    let houses = Layer {
1152        module_id: "natal".into(),
1153        kind: LayerKind::Houses,
1154        ring: 0.86,
1155        z: 1,
1156        geometry: Geometry::Ring {
1157            cusps_deg: cusps_deg.clone(),
1158        },
1159        glyphs: cusps_deg
1160            .iter()
1161            .enumerate()
1162            .map(|(i, c)| Glyph {
1163                deg: *c + 4.0,
1164                symbol: format!("h{}", i + 1),
1165                annotation: None,
1166                retrograde: false,
1167                house: Some((i as u8) + 1),
1168                dignity_marker: None,
1169            })
1170            .collect(),
1171    };
1172
1173    // ─── Capa 2: Bodies ───────────────────────────────────────────────
1174    let body_glyphs: Vec<Glyph> = natal
1175        .placements
1176        .iter()
1177        .map(|p| Glyph {
1178            deg: p.longitude.longitude_deg() as f32,
1179            symbol: body_symbol(p.body).into(),
1180            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
1181            // `BodyPlacement` cambió entre versiones de eternal entre
1182            // `pub fn is_retrograde(&self) -> bool` y `pub
1183            // is_retrograde: bool` — leemos el campo crudo
1184            // `longitude_rate_rad_per_day` (estable en ambas) para no
1185            // depender del wrapper.
1186            retrograde: p.longitude_rate_rad_per_day < 0.0,
1187            house: Some(p.house_number),
1188            dignity_marker: None,
1189        })
1190        .collect();
1191    let bodies = Layer {
1192        module_id: "natal".into(),
1193        kind: LayerKind::Bodies,
1194        ring: 0.72,
1195        z: 2,
1196        geometry: Geometry::Points(
1197            natal
1198                .placements
1199                .iter()
1200                .map(|p| crate::PointMark {
1201                    deg: p.longitude.longitude_deg() as f32,
1202                    label: p.body.name().into(),
1203                    tag: body_symbol(p.body).into(),
1204                })
1205                .collect(),
1206        ),
1207        glyphs: body_glyphs,
1208    };
1209
1210    // ─── Capa 3: Aspects ──────────────────────────────────────────────
1211    // Los aspects ya vienen filtrados por NatalOptions (majors / minors)
1212    // desde compose(). Acá solo mapeamos a LineSeg.
1213    let mut aspect_lines: Vec<LineSeg> = Vec::with_capacity(aspects.len());
1214    for a in aspects {
1215        let pa = natal.placement(a.a);
1216        let pb = natal.placement(a.b);
1217        if let (Some(pa), Some(pb)) = (pa, pb) {
1218            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
1219            aspect_lines.push(LineSeg {
1220                from_deg: pa.longitude.longitude_deg() as f32,
1221                to_deg: pb.longitude.longitude_deg() as f32,
1222                kind: aspect_kind_id(a.kind).into(),
1223                opacity,
1224                from_body: body_symbol(a.a).into(),
1225                to_body: body_symbol(a.b).into(),
1226                orb_deg: a.orb_abs_deg() as f32,
1227            });
1228        }
1229    }
1230    let aspects_layer = Layer {
1231        module_id: "natal".into(),
1232        kind: LayerKind::Aspects,
1233        ring: 0.58,
1234        z: 3,
1235        geometry: Geometry::Lines(aspect_lines),
1236        glyphs: Vec::new(),
1237    };
1238
1239    let subtitle = chart
1240        .birth_data
1241        .birthplace_label
1242        .clone()
1243        .or_else(|| {
1244            Some(format!(
1245                "{:04}-{:02}-{:02} · lat {:+.2}° · lon {:+.2}°",
1246                chart.birth_data.year,
1247                chart.birth_data.month,
1248                chart.birth_data.day,
1249                chart.birth_data.latitude_deg,
1250                chart.birth_data.longitude_deg,
1251            ))
1252        });
1253
1254    RenderModel {
1255        chart_id: chart.id,
1256        chart_kind: chart.kind,
1257        title: chart.label.clone(),
1258        subtitle,
1259        compute_ms: started.elapsed().as_millis() as u64,
1260        ascendant_deg,
1261        midheaven_deg,
1262        descendant_deg,
1263        imum_coeli_deg,
1264        layers: vec![sign_dial, houses, bodies, aspects_layer],
1265        overlays: Vec::new(),
1266        aspect_summary: Vec::new(),
1267        uranian_groups: Vec::new(),
1268    }
1269}
1270
1271/// Construye una `OrbTable` con los orbes default de `modern_western`
1272/// escalados por `multiplier`. Necesario porque eternal expone
1273/// `set_orb` pero no permite iterar los base orbs internos.
1274fn build_orb_table(multiplier: f64) -> OrbTable {
1275    let mut t = OrbTable::modern_western();
1276    let m = multiplier.max(0.0);
1277    t.set_orb(EAspectKind::Conjunction, 8.0 * m);
1278    t.set_orb(EAspectKind::Opposition, 8.0 * m);
1279    t.set_orb(EAspectKind::Trine, 7.0 * m);
1280    t.set_orb(EAspectKind::Square, 7.0 * m);
1281    t.set_orb(EAspectKind::Sextile, 5.0 * m);
1282    t.set_orb(EAspectKind::Quincunx, 2.5 * m);
1283    t.set_orb(EAspectKind::SemiSextile, 2.0 * m);
1284    t.set_orb(EAspectKind::SemiSquare, 2.0 * m);
1285    t.set_orb(EAspectKind::Sesquiquadrate, 2.0 * m);
1286    t.set_orb(EAspectKind::Quintile, 1.5 * m);
1287    t.set_orb(EAspectKind::BiQuintile, 1.5 * m);
1288    t.set_orb(EAspectKind::Septile, 1.5 * m);
1289    t
1290}
1291
1292fn push_overlay_meta(render: &mut RenderModel, module_id: &str, label: String) {
1293    render.overlays.push(OverlayMeta {
1294        module_id: module_id.to_string(),
1295        label,
1296    });
1297}
1298
1299/// Helper: agrega al `RenderModel` los Lots arábigos clásicos
1300/// (helenísticos) — Fortune, Spirit, Eros, Necessity, Courage, Victory,
1301/// Nemesis. Cada uno se renderea como un glifo `lot:Fo` en el anillo
1302/// `0.54` (entre midpoints y cuerpos progresados). Retorna la cantidad
1303/// de lots renderizados.
1304fn build_lots_overlay(
1305    natal: &NatalChart,
1306    render: &mut RenderModel,
1307) -> Result<usize, EngineError> {
1308    let lots = all_lots(natal)
1309        .map_err(|e| EngineError::Eternal(format!("all_lots: {:?}", e)))?;
1310    let glyphs: Vec<Glyph> = lots
1311        .iter()
1312        .map(|l| {
1313            let name = l.name.map(|n| n.label()).unwrap_or("Lot");
1314            // Tres-letras compactas para no recargar la rueda.
1315            let abbrev: String = name.chars().take(2).collect();
1316            Glyph {
1317                deg: l.longitude.longitude_deg() as f32,
1318                symbol: format!("lot:{}", abbrev),
1319                annotation: Some(name.to_string()),
1320                retrograde: false,
1321                house: Some(l.house_number),
1322                dignity_marker: None,
1323            }
1324        })
1325        .collect();
1326    let count = glyphs.len();
1327    render.layers.push(Layer {
1328        module_id: "lots".into(),
1329        kind: LayerKind::Lots,
1330        ring: 0.54,
1331        z: 13,
1332        geometry: Geometry::GlyphsOnly,
1333        glyphs,
1334    });
1335    Ok(count)
1336}
1337
1338/// Helper: agrega al `RenderModel` 9 estrellas fijas notables. Las
1339/// longitudes están en J2000 ecliptica tropical; aplicamos precesión
1340/// general de 50.29″/año hacia adelante hasta el año natal — basta
1341/// para el orbe de conjunción de ±1.5° con que se interpretan.
1342fn build_fixed_stars_overlay(chart: &Chart, render: &mut RenderModel) -> usize {
1343    // (símbolo, nombre, longitud tropical J2000 en grados)
1344    const STARS: &[(&str, &str, f64)] = &[
1345        ("✦Ald", "Aldebaran", 69.79),     // 09°47′ Gem
1346        ("✦Reg", "Regulus", 149.83),      // 29°50′ Leo
1347        ("✦Ant", "Antares", 249.77),      // 09°46′ Sag
1348        ("✦Fom", "Fomalhaut", 333.87),    // 03°52′ Pis
1349        ("✦Spi", "Spica", 203.84),        // 23°50′ Lib
1350        ("✦Sir", "Sirius", 104.10),       // 14°06′ Can
1351        ("✦Alg", "Algol", 56.18),         // 26°10′ Tau
1352        ("✦Veg", "Vega", 285.31),         // 15°19′ Cap
1353        ("✦Pol", "Pollux", 113.27),       // 23°16′ Can
1354    ];
1355    let years_from_j2000 = (chart.birth_data.year - 2000) as f64;
1356    // 50.29″/año ≈ 0.01397°/año de precesión en longitud eclíptica.
1357    let precession_deg = years_from_j2000 * (50.29 / 3600.0);
1358    let glyphs: Vec<Glyph> = STARS
1359        .iter()
1360        .map(|(sym, name, j2000_deg)| {
1361            let lon = (j2000_deg + precession_deg).rem_euclid(360.0) as f32;
1362            Glyph {
1363                deg: lon,
1364                symbol: (*sym).to_string(),
1365                annotation: Some((*name).to_string()),
1366                retrograde: false,
1367                house: None,
1368                dignity_marker: None,
1369            }
1370        })
1371        .collect();
1372    let count = glyphs.len();
1373    render.layers.push(Layer {
1374        module_id: "fixed_stars".into(),
1375        kind: LayerKind::FixedStars,
1376        ring: 1.04,
1377        z: 16,
1378        geometry: Geometry::GlyphsOnly,
1379        glyphs,
1380    });
1381    count
1382}
1383
1384/// Decora cada Glyph de Bodies (module_id="natal") con su dignity
1385/// marker en `glyph.dignity_marker`. Usa `essential_dignity(body, sign)`
1386/// — los cuerpos modernos quedan sin marker.
1387fn annotate_dignities(natal: &NatalChart, render: &mut RenderModel) {
1388    use std::collections::HashMap;
1389    let mut by_symbol: HashMap<&'static str, &'static str> = HashMap::new();
1390    for p in &natal.placements {
1391        let sign_idx = (p.longitude.longitude_deg() / 30.0).floor() as u8 % 12;
1392        if let Some(d) = essential_dignity(p.body, sign_idx) {
1393            by_symbol.insert(body_symbol(p.body), d.marker());
1394        }
1395    }
1396    for layer in render.layers.iter_mut() {
1397        if matches!(layer.kind, LayerKind::Bodies) && layer.module_id == "natal" {
1398            for g in layer.glyphs.iter_mut() {
1399                if let Some(marker) = by_symbol.get(g.symbol.as_str()) {
1400                    g.dignity_marker = Some((*marker).to_string());
1401                }
1402            }
1403        }
1404    }
1405}
1406
1407fn populate_natal_aspect_summary(aspects: &[Aspect], render: &mut RenderModel) {
1408    for a in aspects {
1409        render.aspect_summary.push(AspectSummary {
1410            module_id: "natal".into(),
1411            from_body: body_symbol(a.a).into(),
1412            to_body: body_symbol(a.b).into(),
1413            kind: aspect_kind_id(a.kind).into(),
1414            orb_deg: a.orb_abs_deg(),
1415            applying: Some(a.applying),
1416        });
1417    }
1418    sort_aspect_summary(render);
1419}
1420
1421fn populate_cross_aspect_summary(
1422    cross: &[eternal_astrology::SynastryAspect],
1423    module_id: &str,
1424    render: &mut RenderModel,
1425) {
1426    for a in cross {
1427        render.aspect_summary.push(AspectSummary {
1428            module_id: module_id.to_string(),
1429            from_body: body_symbol(a.person_a_body).into(),
1430            to_body: body_symbol(a.person_b_body).into(),
1431            kind: aspect_kind_id(a.kind).into(),
1432            orb_deg: a.orb_abs_deg(),
1433            applying: None,
1434        });
1435    }
1436    sort_aspect_summary(render);
1437}
1438
1439fn sort_aspect_summary(render: &mut RenderModel) {
1440    render
1441        .aspect_summary
1442        .sort_by(|x, y| x.orb_deg.partial_cmp(&y.orb_deg).unwrap_or(std::cmp::Ordering::Equal));
1443}
1444
1445/// Mapea el orb absoluto a una opacidad — los aspectos más exactos se
1446/// pintan más fuerte, los flojos casi se desvanecen.
1447fn orb_to_opacity(orb_deg: f64, kind: EAspectKind) -> f32 {
1448    let max = match kind {
1449        EAspectKind::Conjunction | EAspectKind::Opposition => 8.0,
1450        EAspectKind::Trine | EAspectKind::Square => 7.0,
1451        EAspectKind::Sextile => 5.0,
1452        _ => 3.0,
1453    };
1454    let t = (1.0 - (orb_deg / max).min(1.0)).max(0.25);
1455    t as f32
1456}
1457
1458const ZODIAC_SYMBOLS: [&str; 12] = [
1459    "aries",
1460    "taurus",
1461    "gemini",
1462    "cancer",
1463    "leo",
1464    "virgo",
1465    "libra",
1466    "scorpio",
1467    "sagittarius",
1468    "capricorn",
1469    "aquarius",
1470    "pisces",
1471];