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