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