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