tawasuyu

tawasuyu/brahman

Rust 570TOML 215Markdown 68JSON 198261 Definiciones

⎇ 1232e3939739e6bbf8885d687561796666611eae crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs Rust · 870 líneas · 30.3 KB Historia Quién tocó qué crudo

feat(tahuantinsuyu): fase 15 — badges de overlays activos en el footer 1232e3939 · sergio ·

1//! Bridge real: `tahuantinsuyu_model::Chart` → eternal_astrology → [`RenderModel`].
2//!
3//! La sesión de efemérides VSOP2013 es **compartida globalmente** vía
4//! `OnceLock` — abrirla cuesta unos cuantos ms (carga de las series en
5//! memoria), y como es read-only se puede leer en paralelo desde varios
6//! cómputos.
7
8use std::sync::OnceLock;
9use std::time::Instant;
10
11use eternal_astrology::{
12    find_aspects, find_synastry_aspects, next_return, secondary_progression, solar_arc_true,
13    Aspect, AspectKind as EAspectKind, BirthData, BodySet, ChartConfig, HouseSystem as EHouseSystem,
14    NatalChart, OrbTable, Zodiac as EZodiac,
15};
16use eternal_sky::{Ayanamsha, Body, EphemerisSession, Instant as ESInstant, Observer, SessionConfig};
17
18use tahuantinsuyu_model::{Chart, HouseSystem, StoredChartConfig, Zodiac};
19
20use crate::{EngineError, Geometry, Glyph, Layer, LayerKind, LineSeg, OverlayMeta, RenderModel};
21
22// =====================================================================
23// Sesión global cacheada
24// =====================================================================
25
26static SESSION: OnceLock<EphemerisSession> = OnceLock::new();
27
28fn session() -> Result<&'static EphemerisSession, EngineError> {
29    if let Some(s) = SESSION.get() {
30        return Ok(s);
31    }
32    let opened = EphemerisSession::open(SessionConfig::vsop2013())
33        .map_err(|e| EngineError::Eternal(format!("EphemerisSession::open: {:?}", e)))?;
34    // Si otro thread ya pobló la celda mientras abríamos, el set_once
35    // falla silenciosamente — usamos el que quedó dentro.
36    let _ = SESSION.set(opened);
37    Ok(SESSION.get().expect("session was just set"))
38}
39
40// =====================================================================
41// Traducciones Stored* → eternal
42// =====================================================================
43
44fn map_house_system(h: HouseSystem) -> EHouseSystem {
45    match h {
46        HouseSystem::Placidus => EHouseSystem::Placidus,
47        HouseSystem::Koch => EHouseSystem::Koch,
48        HouseSystem::Regiomontanus => EHouseSystem::Regiomontanus,
49        HouseSystem::Campanus => EHouseSystem::Campanus,
50        HouseSystem::Porphyry => EHouseSystem::Porphyry,
51        HouseSystem::Equal => EHouseSystem::Equal,
52        HouseSystem::WholeSign => EHouseSystem::WholeSign,
53    }
54}
55
56fn map_zodiac(z: Zodiac, ayanamsha_hint: Option<&str>) -> EZodiac {
57    match z {
58        Zodiac::Tropical => EZodiac::Tropical,
59        Zodiac::Sidereal => {
60            let mode = match ayanamsha_hint.unwrap_or("lahiri").to_ascii_lowercase().as_str() {
61                "fagan_bradley" | "fagan-bradley" | "faganbradley" => Ayanamsha::FaganBradley,
62                "raman" => Ayanamsha::Raman,
63                "krishnamurti" => Ayanamsha::Krishnamurti,
64                "de_luce" | "deluce" => Ayanamsha::DeLuce,
65                "djwhal_khul" | "djwhalkhul" => Ayanamsha::DjwhalKhul,
66                "ushashashi" => Ayanamsha::Ushashashi,
67                "yukteshwar" => Ayanamsha::Yukteshwar,
68                _ => Ayanamsha::Lahiri,
69            };
70            EZodiac::Sidereal(mode)
71        }
72        // Dracónico aún no soportado en eternal — caemos a tropical por
73        // ahora; cuando eternal lo agregue, lo cableamos acá.
74        Zodiac::Draconic => EZodiac::Tropical,
75    }
76}
77
78fn map_body_set(cfg: &StoredChartConfig) -> BodySet {
79    let mut bodies: Vec<Body> = Vec::new();
80    for name in &cfg.bodies {
81        if let Some(b) = map_body(name) {
82            bodies.push(b);
83        }
84    }
85    if bodies.is_empty() {
86        // Default razonable si el config vino vacío.
87        return BodySet::classical_modern();
88    }
89    let mut set = BodySet {
90        bodies,
91        include_south_node: cfg.include_south_node,
92    };
93    if cfg.include_lilith {
94        set = set.with_lilith();
95    }
96    if cfg.include_main_belt_asteroids {
97        set = set.with_main_belt_asteroids();
98    }
99    set
100}
101
102fn map_body(name: &str) -> Option<Body> {
103    Some(match name.to_ascii_lowercase().as_str() {
104        "sun" => Body::Sun,
105        "moon" => Body::Moon,
106        "mercury" => Body::Mercury,
107        "venus" => Body::Venus,
108        "mars" => Body::Mars,
109        "jupiter" => Body::Jupiter,
110        "saturn" => Body::Saturn,
111        "uranus" => Body::Uranus,
112        "neptune" => Body::Neptune,
113        "pluto" => Body::Pluto,
114        "mean_node" | "meannode" => Body::MeanNode,
115        "true_node" | "truenode" => Body::TrueNode,
116        "mean_lilith" | "lilith" => Body::MeanLilith,
117        "true_lilith" => Body::TrueLilith,
118        "ceres" => Body::Ceres,
119        "pallas" => Body::Pallas,
120        "juno" => Body::Juno,
121        "vesta" => Body::Vesta,
122        _ => return None,
123    })
124}
125
126fn body_symbol(b: Body) -> &'static str {
127    match b {
128        Body::Sun => "sun",
129        Body::Moon => "moon",
130        Body::Mercury => "mercury",
131        Body::Venus => "venus",
132        Body::Mars => "mars",
133        Body::Jupiter => "jupiter",
134        Body::Saturn => "saturn",
135        Body::Uranus => "uranus",
136        Body::Neptune => "neptune",
137        Body::Pluto => "pluto",
138        Body::MeanNode => "north_node",
139        Body::TrueNode => "north_node",
140        Body::MeanLilith => "lilith",
141        Body::TrueLilith => "lilith",
142        Body::Ceres => "ceres",
143        Body::Pallas => "pallas",
144        Body::Juno => "juno",
145        Body::Vesta => "vesta",
146        Body::Chiron => "chiron",
147        Body::Pholus => "chiron",
148        Body::Eris => "chiron",
149        Body::Sedna => "chiron",
150        // `Body` es `#[non_exhaustive]` — cualquier cuerpo nuevo
151        // upstream cae al símbolo de fallback hasta que lo cableemos.
152        _ => "custom",
153    }
154}
155
156fn aspect_kind_id(k: EAspectKind) -> &'static str {
157    match k {
158        EAspectKind::Conjunction => "conjunction",
159        EAspectKind::Opposition => "opposition",
160        EAspectKind::Trine => "trine",
161        EAspectKind::Square => "square",
162        EAspectKind::Sextile => "sextile",
163        EAspectKind::Quincunx => "quincunx",
164        EAspectKind::SemiSextile => "semi_sextile",
165        EAspectKind::SemiSquare => "semi_square",
166        EAspectKind::Sesquiquadrate => "sesquiquadrate",
167        EAspectKind::Quintile => "quintile",
168        EAspectKind::BiQuintile => "biquintile",
169        EAspectKind::Septile => "septile",
170    }
171}
172
173// =====================================================================
174// compute()
175// =====================================================================
176
177/// Construye los tipos eternales (`BirthData`, `ChartConfig`) desde el
178/// `Chart` agnóstico, aplicando el offset temporal. Devuelve también el
179/// `Observer` y la `ChartConfig` para reusar en pipelines extendidas
180/// (transits, sinastría) sin re-traducir.
181fn build_eternal_inputs(
182    chart: &Chart,
183    offset_minutes: i64,
184) -> Result<(BirthData, ChartConfig, Observer), EngineError> {
185    chart.validate()?;
186    let bd = &chart.birth_data;
187    let base_instant = ESInstant::from_civil_local(
188        bd.year,
189        u8::try_from(bd.month).map_err(|_| {
190            EngineError::Eternal(format!("mes fuera de u8: {}", bd.month))
191        })?,
192        u8::try_from(bd.day).map_err(|_| {
193            EngineError::Eternal(format!("día fuera de u8: {}", bd.day))
194        })?,
195        u8::try_from(bd.hour).map_err(|_| {
196            EngineError::Eternal(format!("hora fuera de u8: {}", bd.hour))
197        })?,
198        u8::try_from(bd.minute).map_err(|_| {
199            EngineError::Eternal(format!("minuto fuera de u8: {}", bd.minute))
200        })?,
201        bd.second,
202        bd.tz_offset_minutes,
203    )
204    .map_err(|e| EngineError::Eternal(format!("Instant::from_civil_local: {:?}", e)))?;
205
206    let instant = if offset_minutes == 0 {
207        base_instant
208    } else {
209        let shifted_utc = base_instant.utc().add_seconds((offset_minutes as f64) * 60.0);
210        ESInstant::from_utc(shifted_utc)
211    };
212
213    let observer = Observer::from_degrees(bd.latitude_deg, bd.longitude_deg, bd.altitude_m);
214    let mut birth_e = BirthData::new(instant, observer);
215    if let Some(name) = &bd.subject_name {
216        birth_e = birth_e.with_name(name.clone());
217    }
218    let config_e = ChartConfig {
219        house_system: map_house_system(chart.config.house_system),
220        zodiac: map_zodiac(chart.config.zodiac, chart.config.ayanamsha.as_deref()),
221        bodies: map_body_set(&chart.config),
222        include_horizon: false,
223    };
224    Ok((birth_e, config_e, observer))
225}
226
227/// Computa solo la `NatalChart` (sin construir RenderModel). Útil para
228/// pipelines compuestas (transits, sinastría) que necesitan el natal
229/// crudo para correr `find_synastry_aspects`.
230fn compute_natal_chart(
231    chart: &Chart,
232    offset_minutes: i64,
233) -> Result<(NatalChart, ChartConfig, Observer), EngineError> {
234    let (birth_e, config_e, observer) = build_eternal_inputs(chart, offset_minutes)?;
235    let session = session()?;
236    let natal = NatalChart::compute(&birth_e, &config_e, session)
237        .map_err(|e| EngineError::Eternal(format!("NatalChart::compute: {:?}", e)))?;
238    Ok((natal, config_e, observer))
239}
240
241/// Composición principal: natal + overlays pedidos. Es la función que
242/// `lib::compose` delega cuando el feature `eternal-bridge` está activo.
243pub fn compose(
244    chart: &Chart,
245    offset_minutes: i64,
246    requests: &[crate::PipelineRequest],
247) -> Result<RenderModel, EngineError> {
248    let t0 = Instant::now();
249    let (natal, config_e, observer) = compute_natal_chart(chart, offset_minutes)?;
250    let aspects = find_aspects(&natal, &OrbTable::modern_western());
251    let mut render = build_render_model(chart, &natal, &aspects, t0);
252
253    for req in requests {
254        match req {
255            crate::PipelineRequest::Transit => {
256                build_transit_overlay(&natal, &config_e, observer, ESInstant::now(), &mut render)?;
257                push_overlay_meta(&mut render, "transit", "Tránsito ahora".into());
258            }
259            crate::PipelineRequest::SecondaryProgression { target_age_years } => {
260                build_progression_overlay(&natal, *target_age_years, &mut render)?;
261                push_overlay_meta(
262                    &mut render,
263                    "progression",
264                    format!("Progresión {:.1}a", target_age_years),
265                );
266            }
267            crate::PipelineRequest::SolarArc { target_age_years } => {
268                build_solar_arc_overlay(&natal, *target_age_years, &mut render)?;
269                push_overlay_meta(
270                    &mut render,
271                    "solar_arc",
272                    format!("Solar Arc {:.1}a", target_age_years),
273                );
274            }
275            crate::PipelineRequest::Synastry { partner_chart } => {
276                let partner_label = partner_chart.label.clone();
277                build_synastry_overlay(&natal, partner_chart, &mut render)?;
278                push_overlay_meta(
279                    &mut render,
280                    "synastry",
281                    format!("Sinastría · {}", partner_label),
282                );
283            }
284            crate::PipelineRequest::PlanetaryReturn {
285                body,
286                target_age_years,
287            } => {
288                let body_e = map_body(body).ok_or_else(|| {
289                    EngineError::Eternal(format!(
290                        "body desconocido para planetary return: {}",
291                        body
292                    ))
293                })?;
294                build_planetary_return_overlay(
295                    &natal,
296                    &config_e,
297                    observer,
298                    body_e,
299                    *target_age_years,
300                    &mut render,
301                )?;
302                push_overlay_meta(
303                    &mut render,
304                    "planetary_return",
305                    format!("{} return {:.0}a", body_e.name(), target_age_years),
306                );
307            }
308        }
309    }
310
311    render.compute_ms = t0.elapsed().as_millis() as u64;
312    Ok(render)
313}
314
315/// Helper: agrega al `RenderModel` las dos capas del overlay de
316/// tránsitos (Outer + cross Aspects).
317fn build_transit_overlay(
318    natal: &NatalChart,
319    config_e: &ChartConfig,
320    observer: Observer,
321    transit_at: ESInstant,
322    render: &mut RenderModel,
323) -> Result<(), EngineError> {
324    let transit_birth = BirthData::new(transit_at, observer);
325    let session = session()?;
326    let transit = NatalChart::compute(&transit_birth, config_e, session).map_err(|e| {
327        EngineError::Eternal(format!("NatalChart::compute (transit): {:?}", e))
328    })?;
329
330    let outer_glyphs: Vec<Glyph> = transit
331        .placements
332        .iter()
333        .map(|p| Glyph {
334            deg: p.longitude.longitude_deg() as f32,
335            symbol: body_symbol(p.body).into(),
336            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
337            retrograde: p.longitude_rate_rad_per_day < 0.0,
338            house: None,
339        })
340        .collect();
341    render.layers.push(Layer {
342        module_id: "transit".into(),
343        kind: LayerKind::Outer,
344        ring: 0.82,
345        z: 4,
346        geometry: Geometry::GlyphsOnly,
347        glyphs: outer_glyphs,
348    });
349
350    let cross = find_synastry_aspects(
351        natal,
352        &transit,
353        &OrbTable::modern_western(),
354        EAspectKind::MAJORS,
355    );
356    let cross_lines: Vec<LineSeg> = cross
357        .iter()
358        .filter_map(|a| {
359            let natal_p = natal.placement(a.person_a_body)?;
360            let transit_p = transit.placement(a.person_b_body)?;
361            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
362            Some(LineSeg {
363                from_deg: natal_p.longitude.longitude_deg() as f32,
364                to_deg: transit_p.longitude.longitude_deg() as f32,
365                kind: aspect_kind_id(a.kind).into(),
366                opacity: opacity * 0.75,
367            })
368        })
369        .collect();
370    render.layers.push(Layer {
371        module_id: "transit".into(),
372        kind: LayerKind::Aspects,
373        ring: 0.0,
374        z: 5,
375        geometry: Geometry::Lines(cross_lines),
376        glyphs: Vec::new(),
377    });
378    Ok(())
379}
380
381/// Helper: agrega al `RenderModel` las capas del overlay de progresión
382/// secundaria. La carta progresada se computa con el mismo observer y
383/// config que la natal pero al instante natal+(age_years/period_years)
384/// días.
385fn build_progression_overlay(
386    natal: &NatalChart,
387    target_age_years: f64,
388    render: &mut RenderModel,
389) -> Result<(), EngineError> {
390    let session = session()?;
391    let prog = secondary_progression(natal, session, target_age_years)
392        .map_err(|e| EngineError::Eternal(format!("secondary_progression: {:?}", e)))?;
393    let progressed = &prog.progressed;
394
395    // Glifos de los cuerpos progresados — anillo interno (radio 0.48).
396    let glyphs: Vec<Glyph> = progressed
397        .placements
398        .iter()
399        .map(|p| Glyph {
400            deg: p.longitude.longitude_deg() as f32,
401            symbol: body_symbol(p.body).into(),
402            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
403            retrograde: p.longitude_rate_rad_per_day < 0.0,
404            house: Some(p.house_number),
405        })
406        .collect();
407    render.layers.push(Layer {
408        module_id: "progression".into(),
409        kind: LayerKind::Bodies,
410        ring: 0.48,
411        z: 6,
412        geometry: Geometry::GlyphsOnly,
413        glyphs,
414    });
415
416    // Cross aspects natal × progresada (sólo mayores).
417    let cross = find_synastry_aspects(
418        natal,
419        progressed,
420        &OrbTable::modern_western(),
421        EAspectKind::MAJORS,
422    );
423    let cross_lines: Vec<LineSeg> = cross
424        .iter()
425        .filter_map(|a| {
426            let natal_p = natal.placement(a.person_a_body)?;
427            let prog_p = progressed.placement(a.person_b_body)?;
428            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
429            Some(LineSeg {
430                from_deg: natal_p.longitude.longitude_deg() as f32,
431                to_deg: prog_p.longitude.longitude_deg() as f32,
432                kind: aspect_kind_id(a.kind).into(),
433                opacity: opacity * 0.7,
434            })
435        })
436        .collect();
437    render.layers.push(Layer {
438        module_id: "progression".into(),
439        kind: LayerKind::Aspects,
440        ring: 0.0,
441        z: 7,
442        geometry: Geometry::Lines(cross_lines),
443        glyphs: Vec::new(),
444    });
445    Ok(())
446}
447
448/// Helper: agrega al `RenderModel` las capas del overlay de Solar Arc
449/// (método true-progressed-Sun por default). Cada cuerpo natal se
450/// desplaza por el mismo arco — preserva las relaciones angulares y
451/// las posiciones relativas en casas se mantienen.
452fn build_solar_arc_overlay(
453    natal: &NatalChart,
454    target_age_years: f64,
455    render: &mut RenderModel,
456) -> Result<(), EngineError> {
457    let session = session()?;
458    let arc = solar_arc_true(natal, session, target_age_years)
459        .map_err(|e| EngineError::Eternal(format!("solar_arc_true: {:?}", e)))?;
460    let directed = &arc.directed;
461
462    let glyphs: Vec<Glyph> = directed
463        .placements
464        .iter()
465        .map(|p| Glyph {
466            deg: p.longitude.longitude_deg() as f32,
467            symbol: body_symbol(p.body).into(),
468            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
469            retrograde: p.longitude_rate_rad_per_day < 0.0,
470            house: Some(p.house_number),
471        })
472        .collect();
473    render.layers.push(Layer {
474        module_id: "solar_arc".into(),
475        kind: LayerKind::Bodies,
476        ring: 0.43,
477        z: 8,
478        geometry: Geometry::GlyphsOnly,
479        glyphs,
480    });
481
482    let cross = find_synastry_aspects(
483        natal,
484        directed,
485        &OrbTable::modern_western(),
486        EAspectKind::MAJORS,
487    );
488    let cross_lines: Vec<LineSeg> = cross
489        .iter()
490        .filter_map(|a| {
491            let natal_p = natal.placement(a.person_a_body)?;
492            let dir_p = directed.placement(a.person_b_body)?;
493            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
494            Some(LineSeg {
495                from_deg: natal_p.longitude.longitude_deg() as f32,
496                to_deg: dir_p.longitude.longitude_deg() as f32,
497                kind: aspect_kind_id(a.kind).into(),
498                opacity: opacity * 0.7,
499            })
500        })
501        .collect();
502    render.layers.push(Layer {
503        module_id: "solar_arc".into(),
504        kind: LayerKind::Aspects,
505        ring: 0.0,
506        z: 9,
507        geometry: Geometry::Lines(cross_lines),
508        glyphs: Vec::new(),
509    });
510    Ok(())
511}
512
513/// Helper: agrega al `RenderModel` las capas del overlay de sinastría
514/// con otra carta natal completa. La carta partner se computa con su
515/// propio observer/config (no comparte con la natal). El outer ring
516/// se comparte con Transit — mutuamente excluyentes a nivel de Shell.
517fn build_synastry_overlay(
518    natal: &NatalChart,
519    partner_chart: &Chart,
520    render: &mut RenderModel,
521) -> Result<(), EngineError> {
522    let (partner, _config, _observer) = compute_natal_chart(partner_chart, 0)?;
523
524    let glyphs: Vec<Glyph> = partner
525        .placements
526        .iter()
527        .map(|p| Glyph {
528            deg: p.longitude.longitude_deg() as f32,
529            symbol: body_symbol(p.body).into(),
530            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
531            retrograde: p.longitude_rate_rad_per_day < 0.0,
532            house: Some(p.house_number),
533        })
534        .collect();
535    render.layers.push(Layer {
536        module_id: "synastry".into(),
537        kind: LayerKind::Outer,
538        ring: 0.82,
539        z: 10,
540        geometry: Geometry::GlyphsOnly,
541        glyphs,
542    });
543
544    let cross = find_synastry_aspects(
545        natal,
546        &partner,
547        &OrbTable::modern_western(),
548        EAspectKind::MAJORS,
549    );
550    let cross_lines: Vec<LineSeg> = cross
551        .iter()
552        .filter_map(|a| {
553            let natal_p = natal.placement(a.person_a_body)?;
554            let partner_p = partner.placement(a.person_b_body)?;
555            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
556            Some(LineSeg {
557                from_deg: natal_p.longitude.longitude_deg() as f32,
558                to_deg: partner_p.longitude.longitude_deg() as f32,
559                kind: aspect_kind_id(a.kind).into(),
560                opacity: opacity * 0.85,
561            })
562        })
563        .collect();
564    render.layers.push(Layer {
565        module_id: "synastry".into(),
566        kind: LayerKind::Aspects,
567        ring: 0.0,
568        z: 11,
569        geometry: Geometry::Lines(cross_lines),
570        glyphs: Vec::new(),
571    });
572    Ok(())
573}
574
575/// Helper: agrega al `RenderModel` las capas del overlay de retorno
576/// planetario — la carta natal completa computada al instante en que
577/// el `body` vuelve a su posición natal cerca de la edad pedida.
578/// Sun = retorno solar anual, Moon = mensual, Júpiter/Saturno =
579/// generacionales. Esa nueva carta va en el anillo externo (compartido
580/// con Transit/Synastry, mutuamente excluyentes a nivel de Shell).
581/// Cross aspects natal × return.
582fn build_planetary_return_overlay(
583    natal: &NatalChart,
584    config_e: &ChartConfig,
585    observer: Observer,
586    body: Body,
587    target_age_years: f64,
588    render: &mut RenderModel,
589) -> Result<(), EngineError> {
590    let session = session()?;
591    let natal_p = natal.placement(body).ok_or_else(|| {
592        EngineError::Eternal(format!(
593            "natal chart sin {} — return imposible",
594            body.name()
595        ))
596    })?;
597    let natal_lon = natal_p.longitude.longitude_rad();
598
599    // El offset desde el cumpleaños depende del período sinódico del
600    // cuerpo: para Sun/planet lentos, ~30 días antes garantiza captar
601    // el return; para Moon, ~15 días. Tomamos un margen amplio que
602    // sirve para todos.
603    const TROPICAL_YEAR_SECS: f64 = 365.242190 * 86400.0;
604    let after_seconds = (target_age_years * 365.242190 - 30.0) * 86400.0;
605    let after_utc = natal
606        .birth
607        .instant
608        .utc()
609        .add_seconds(after_seconds.max(-TROPICAL_YEAR_SECS * 2.0));
610    let after = ESInstant::from_utc(after_utc);
611
612    let return_instant = next_return(session, body, natal_lon, after, None).map_err(|e| {
613        EngineError::Eternal(format!("next_return {}: {:?}", body.name(), e))
614    })?;
615
616    // La carta del retorno se computa al return_instant con el mismo
617    // observer y config natales (convención clásica: return tropical
618    // en la ciudad de nacimiento).
619    let return_birth = BirthData::new(return_instant, observer);
620    let return_chart = NatalChart::compute(&return_birth, config_e, session).map_err(|e| {
621        EngineError::Eternal(format!(
622            "NatalChart::compute ({} return): {:?}",
623            body.name(),
624            e
625        ))
626    })?;
627
628    let glyphs: Vec<Glyph> = return_chart
629        .placements
630        .iter()
631        .map(|p| Glyph {
632            deg: p.longitude.longitude_deg() as f32,
633            symbol: body_symbol(p.body).into(),
634            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
635            retrograde: p.longitude_rate_rad_per_day < 0.0,
636            house: Some(p.house_number),
637        })
638        .collect();
639    render.layers.push(Layer {
640        module_id: "planetary_return".into(),
641        kind: LayerKind::Outer,
642        ring: 0.82,
643        z: 12,
644        geometry: Geometry::GlyphsOnly,
645        glyphs,
646    });
647
648    let cross = find_synastry_aspects(
649        natal,
650        &return_chart,
651        &OrbTable::modern_western(),
652        EAspectKind::MAJORS,
653    );
654    let cross_lines: Vec<LineSeg> = cross
655        .iter()
656        .filter_map(|a| {
657            let n_p = natal.placement(a.person_a_body)?;
658            let r_p = return_chart.placement(a.person_b_body)?;
659            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
660            Some(LineSeg {
661                from_deg: n_p.longitude.longitude_deg() as f32,
662                to_deg: r_p.longitude.longitude_deg() as f32,
663                kind: aspect_kind_id(a.kind).into(),
664                opacity: opacity * 0.8,
665            })
666        })
667        .collect();
668    render.layers.push(Layer {
669        module_id: "planetary_return".into(),
670        kind: LayerKind::Aspects,
671        ring: 0.0,
672        z: 13,
673        geometry: Geometry::Lines(cross_lines),
674        glyphs: Vec::new(),
675    });
676    Ok(())
677}
678
679// =====================================================================
680// NatalChart → RenderModel
681// =====================================================================
682
683fn build_render_model(
684    chart: &Chart,
685    natal: &NatalChart,
686    aspects: &[Aspect],
687    started: Instant,
688) -> RenderModel {
689    let ascendant_deg = natal.ascendant().longitude_deg() as f32;
690    let midheaven_deg = natal.midheaven().longitude_deg() as f32;
691    let descendant_deg = natal.descendant().longitude_deg() as f32;
692    let imum_coeli_deg = natal.imum_coeli().longitude_deg() as f32;
693
694    // ─── Capa 0: Sign Dial ────────────────────────────────────────────
695    let sign_dial = Layer {
696        module_id: "natal".into(),
697        kind: LayerKind::SignDial,
698        ring: 1.0,
699        z: 0,
700        geometry: Geometry::Ring {
701            cusps_deg: (0..12).map(|i| (i as f32) * 30.0).collect(),
702        },
703        glyphs: (0..12)
704            .map(|i| Glyph {
705                deg: (i as f32) * 30.0 + 15.0,
706                symbol: ZODIAC_SYMBOLS[i].into(),
707                annotation: None,
708                retrograde: false,
709                house: None,
710            })
711            .collect(),
712    };
713
714    // ─── Capa 1: Houses ───────────────────────────────────────────────
715    let cusps_deg: Vec<f32> = natal
716        .houses
717        .cusps
718        .iter()
719        .map(|c| c.to_degrees() as f32)
720        .collect();
721    let houses = Layer {
722        module_id: "natal".into(),
723        kind: LayerKind::Houses,
724        ring: 0.86,
725        z: 1,
726        geometry: Geometry::Ring {
727            cusps_deg: cusps_deg.clone(),
728        },
729        glyphs: cusps_deg
730            .iter()
731            .enumerate()
732            .map(|(i, c)| Glyph {
733                deg: *c + 4.0,
734                symbol: format!("h{}", i + 1),
735                annotation: None,
736                retrograde: false,
737                house: Some((i as u8) + 1),
738            })
739            .collect(),
740    };
741
742    // ─── Capa 2: Bodies ───────────────────────────────────────────────
743    let body_glyphs: Vec<Glyph> = natal
744        .placements
745        .iter()
746        .map(|p| Glyph {
747            deg: p.longitude.longitude_deg() as f32,
748            symbol: body_symbol(p.body).into(),
749            annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
750            // `BodyPlacement` cambió entre versiones de eternal entre
751            // `pub fn is_retrograde(&self) -> bool` y `pub
752            // is_retrograde: bool` — leemos el campo crudo
753            // `longitude_rate_rad_per_day` (estable en ambas) para no
754            // depender del wrapper.
755            retrograde: p.longitude_rate_rad_per_day < 0.0,
756            house: Some(p.house_number),
757        })
758        .collect();
759    let bodies = Layer {
760        module_id: "natal".into(),
761        kind: LayerKind::Bodies,
762        ring: 0.72,
763        z: 2,
764        geometry: Geometry::Points(
765            natal
766                .placements
767                .iter()
768                .map(|p| crate::PointMark {
769                    deg: p.longitude.longitude_deg() as f32,
770                    label: p.body.name().into(),
771                    tag: body_symbol(p.body).into(),
772                })
773                .collect(),
774        ),
775        glyphs: body_glyphs,
776    };
777
778    // ─── Capa 3: Aspects ──────────────────────────────────────────────
779    let mut aspect_lines: Vec<LineSeg> = Vec::with_capacity(aspects.len());
780    for a in aspects {
781        // Solo los aspectos mayores se pintan en este pase — los menores
782        // saturan visualmente. Fase 4 pondrá un toggle para mostrarlos.
783        if !EAspectKind::MAJORS.contains(&a.kind) {
784            continue;
785        }
786        let pa = natal.placement(a.a);
787        let pb = natal.placement(a.b);
788        if let (Some(pa), Some(pb)) = (pa, pb) {
789            let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind);
790            aspect_lines.push(LineSeg {
791                from_deg: pa.longitude.longitude_deg() as f32,
792                to_deg: pb.longitude.longitude_deg() as f32,
793                kind: aspect_kind_id(a.kind).into(),
794                opacity,
795            });
796        }
797    }
798    let aspects_layer = Layer {
799        module_id: "natal".into(),
800        kind: LayerKind::Aspects,
801        ring: 0.58,
802        z: 3,
803        geometry: Geometry::Lines(aspect_lines),
804        glyphs: Vec::new(),
805    };
806
807    let subtitle = chart
808        .birth_data
809        .birthplace_label
810        .clone()
811        .or_else(|| {
812            Some(format!(
813                "{:04}-{:02}-{:02} · lat {:+.2}° · lon {:+.2}°",
814                chart.birth_data.year,
815                chart.birth_data.month,
816                chart.birth_data.day,
817                chart.birth_data.latitude_deg,
818                chart.birth_data.longitude_deg,
819            ))
820        });
821
822    RenderModel {
823        chart_id: chart.id,
824        chart_kind: chart.kind,
825        title: chart.label.clone(),
826        subtitle,
827        compute_ms: started.elapsed().as_millis() as u64,
828        ascendant_deg,
829        midheaven_deg,
830        descendant_deg,
831        imum_coeli_deg,
832        layers: vec![sign_dial, houses, bodies, aspects_layer],
833        overlays: Vec::new(),
834    }
835}
836
837fn push_overlay_meta(render: &mut RenderModel, module_id: &str, label: String) {
838    render.overlays.push(OverlayMeta {
839        module_id: module_id.to_string(),
840        label,
841    });
842}
843
844/// Mapea el orb absoluto a una opacidad — los aspectos más exactos se
845/// pintan más fuerte, los flojos casi se desvanecen.
846fn orb_to_opacity(orb_deg: f64, kind: EAspectKind) -> f32 {
847    let max = match kind {
848        EAspectKind::Conjunction | EAspectKind::Opposition => 8.0,
849        EAspectKind::Trine | EAspectKind::Square => 7.0,
850        EAspectKind::Sextile => 5.0,
851        _ => 3.0,
852    };
853    let t = (1.0 - (orb_deg / max).min(1.0)).max(0.25);
854    t as f32
855}
856
857const ZODIAC_SYMBOLS: [&str; 12] = [
858    "aries",
859    "taurus",
860    "gemini",
861    "cancer",
862    "leo",
863    "virgo",
864    "libra",
865    "scorpio",
866    "sagittarius",
867    "capricorn",
868    "aquarius",
869    "pisces",
870];