//! `tahuantinsuyu-engine` — bridge entre el modelo agnóstico y //! `eternal-astrology`. //! //! Recibe un `Chart` del modelo + un `ChartKind` y devuelve un //! [`RenderModel`] que describe la geometría a pintar **sin** acoplar //! el canvas a tipos de la librería astronómica. El canvas habla //! grados decimales, radios normalizados y kinds simbólicos. //! //! ## Por qué un RenderModel intermedio //! //! 1. El canvas no debería caer si cambia el shape de `NatalChart` //! upstream. //! 2. Tests del canvas: podemos generar `RenderModel`s sintéticos sin //! arrancar eternal. //! 3. Cada `ChartKind` produce el mismo shape genérico → el render //! coordina N módulos sin saber qué calcularon. //! //! ## Feature `eternal-bridge` //! //! - **on** (default): [`compute`] abre una `EphemerisSession` VSOP2013 //! compartida y corre la pipeline real. //! - **off**: [`compute`] cae a [`compute_mock`] — útil para tests + //! builds sin eternal checked out. #![forbid(unsafe_code)] #![warn(rust_2018_idioms)] use serde::{Deserialize, Serialize}; use thiserror::Error; pub use tahuantinsuyu_model::{Chart, ChartId, ChartKind}; // `Chart` reexportado arriba es lo que `PipelineRequest::Synastry` // transporta — el caller (shell) lee del Store y pasa el Chart entero // para que el bridge construya su NatalChart en eternal. #[cfg(feature = "eternal-bridge")] mod bridge; // ===================================================================== // RenderModel — lo que el canvas necesita pintar // ===================================================================== /// Resultado agnóstico de un cómputo astrológico, listo para renderizar. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RenderModel { pub chart_id: ChartId, pub chart_kind: ChartKind, pub title: String, #[serde(default)] pub subtitle: Option, pub compute_ms: u64, // ─── Ángulos del chart (grados eclípticos, 0..360) ─────────────── /// Ascendente — punto fijo de rotación del lienzo. La rueda se gira /// de modo que el Asc cae a las 9 (lado izquierdo). pub ascendant_deg: f32, pub midheaven_deg: f32, pub descendant_deg: f32, pub imum_coeli_deg: f32, /// Capas a pintar. Orden = z-order ascendente. pub layers: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Layer { pub module_id: String, pub kind: LayerKind, /// Radio normalizado [0, 1] sobre el lienzo — el canvas lo convierte /// a píxeles. Permite stack de anillos. pub ring: f32, #[serde(default)] pub z: i32, pub geometry: Geometry, #[serde(default)] pub glyphs: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum LayerKind { SignDial, Houses, Bodies, Aspects, Lots, FixedStars, Midpoints, Outer, Custom, } #[derive(Debug, Clone, Serialize, Deserialize)] pub enum Geometry { GlyphsOnly, /// Anillo dividido en sectores. `cusps_deg` son los grados /// zodiacales donde van las divisiones radiales. Ring { cusps_deg: Vec }, Lines(Vec), Points(Vec), } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LineSeg { /// Grados zodiacales del extremo "a". pub from_deg: f32, /// Grados zodiacales del extremo "b". pub to_deg: f32, /// Categoría simbólica (`"conjunction"`, `"trine"`, …) — el theme la /// resuelve a color. pub kind: String, pub opacity: f32, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PointMark { pub deg: f32, pub label: String, pub tag: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Glyph { /// Grado eclíptico [0, 360). pub deg: f32, /// Glyph simbólico — el theme/canvas lo mapea a unicode o imagen. /// Ej: `"sun"`, `"moon"`, `"aries"`, `"asc"`, `"mc"`. pub symbol: String, #[serde(default)] pub annotation: Option, #[serde(default)] pub retrograde: bool, #[serde(default)] pub house: Option, } // ===================================================================== // Errores // ===================================================================== #[derive(Debug, Error)] pub enum EngineError { #[error("bridge a eternal-astrology no disponible (recompilá con feature `eternal-bridge`)")] BridgeDisabled, #[error("model: {0}")] Model(#[from] tahuantinsuyu_model::ModelError), #[error("eternal: {0}")] Eternal(String), #[error("kind {0:?} todavía no implementado")] UnsupportedKind(ChartKind), } // ===================================================================== // API pública // ===================================================================== /// Pedidos que el host (Shell) eleva a la engine para componer un /// `RenderModel`. La capa natal **siempre** se computa; estos requests /// son **overlays adicionales**. /// /// Cada variante mapea 1-a-1 con un Module declarado en /// `tahuantinsuyu-modules` por id string. Esto deja la engine como /// dueña única del cómputo (no depende del trait Module — los módulos /// son sólo metadata + UI controls). #[derive(Debug, Clone)] pub enum PipelineRequest { /// `module_id = "transit"` — anillo externo con planetas al /// instante actual (reloj de pared) + cross aspects natal × transit. Transit, /// `module_id = "progression"` — anillo interno con los planetas /// progresados (método secundario "día por año") a la edad pedida /// + cross aspects natal × progresada. SecondaryProgression { /// Edad simbólica en años a la que avanzar la carta. Para "la /// edad de hoy", el shell la calcula a partir de `birth_data` + /// `SystemTime::now`. target_age_years: f64, }, /// `module_id = "solar_arc"` — Solar Arc dirigido (default = "true /// progressed Sun"): cada cuerpo y cada cusp natal se desplazan por /// el mismo arco ≈ 1° por año de vida. Anillo interno bien adentro /// + cross aspects natal × dirigida. SolarArc { target_age_years: f64, }, /// `module_id = "synastry"` — bi-wheel: la natal en el centro, la /// carta del partner en el anillo externo (compartido con Transit /// — mutuamente excluyentes), cross aspects natal × partner. /// El partner viene como `Chart` completo del shell. Synastry { partner_chart: Box, }, } /// Composición canónica: carta natal + todos los overlays pedidos. /// Es la única función que el Shell necesita llamar — `compute_at_offset` /// y `compute_with_transits_at_now` quedan como atajos retrocompatibles. pub fn compose( chart: &Chart, offset_minutes: i64, requests: &[PipelineRequest], ) -> Result { #[cfg(feature = "eternal-bridge")] { bridge::compose(chart, offset_minutes, requests) } #[cfg(not(feature = "eternal-bridge"))] { let _ = (offset_minutes, requests); Ok(compute_mock(chart)) } } /// Atajo: natal sin overlays. Equivalente a `compose(chart, 0, &[])`. pub fn compute(chart: &Chart) -> Result { compose(chart, 0, &[]) } /// Atajo: natal con time-scrubbing pero sin overlays. pub fn compute_at_offset(chart: &Chart, offset_minutes: i64) -> Result { compose(chart, offset_minutes, &[]) } /// Atajo: natal + overlay de tránsitos al instante actual. pub fn compute_with_transits_at_now( chart: &Chart, offset_minutes: i64, ) -> Result { compose(chart, offset_minutes, &[PipelineRequest::Transit]) } /// Stub determinista — útil para tests + para la UI sin eternal. pub fn compute_mock(chart: &Chart) -> RenderModel { use std::time::Instant; let t0 = Instant::now(); let sign_dial = Layer { module_id: "natal".into(), kind: LayerKind::SignDial, ring: 1.0, z: 0, geometry: Geometry::Ring { cusps_deg: (0..12).map(|i| (i as f32) * 30.0).collect(), }, glyphs: (0..12) .map(|i| Glyph { deg: (i as f32) * 30.0 + 15.0, symbol: ZODIAC_GLYPHS[i].into(), annotation: None, retrograde: false, house: None, }) .collect(), }; RenderModel { chart_id: chart.id, chart_kind: chart.kind, title: chart.label.clone(), subtitle: chart.birth_data.birthplace_label.clone(), compute_ms: t0.elapsed().as_millis() as u64, ascendant_deg: 0.0, midheaven_deg: 270.0, descendant_deg: 180.0, imum_coeli_deg: 90.0, layers: vec![sign_dial], } } const ZODIAC_GLYPHS: [&str; 12] = [ "aries", "taurus", "gemini", "cancer", "leo", "virgo", "libra", "scorpio", "sagittarius", "capricorn", "aquarius", "pisces", ]; // ===================================================================== // Tests // ===================================================================== #[cfg(test)] mod tests { use super::*; use tahuantinsuyu_model::{ Chart, ChartKind, ContactId, StoredBirthData, StoredChartConfig, }; fn sample_chart() -> Chart { Chart { id: ChartId::new(), contact_id: ContactId::new(), kind: ChartKind::Natal, label: "test".into(), birth_data: StoredBirthData { year: 1987, month: 3, day: 14, hour: 5, minute: 22, second: 0.0, tz_offset_minutes: -240, latitude_deg: 10.4806, longitude_deg: -66.9036, altitude_m: 900.0, time_certainty: Default::default(), subject_name: None, birthplace_label: None, }, config: StoredChartConfig::default(), related_chart_id: None, created_at_ms: 0, } } #[test] fn mock_emits_sign_dial() { let model = compute_mock(&sample_chart()); assert_eq!(model.layers.len(), 1); assert!(matches!(model.layers[0].kind, LayerKind::SignDial)); assert_eq!(model.layers[0].glyphs.len(), 12); } #[cfg(feature = "eternal-bridge")] #[test] fn real_compute_natal_demo() { let model = compute(&sample_chart()).expect("compute con eternal"); assert!(model.layers.iter().any(|l| matches!(l.kind, LayerKind::SignDial))); assert!(model.layers.iter().any(|l| matches!(l.kind, LayerKind::Houses))); assert!(model.layers.iter().any(|l| matches!(l.kind, LayerKind::Bodies))); // El Asc debe ser un grado válido. assert!(model.ascendant_deg.is_finite()); assert!((0.0..360.0).contains(&model.ascendant_deg)); } }