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