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