//! `cosmobiologia-modules` — registry de módulos astrológicos. //! //! Cada tipo de astrología (natal, tránsito, progresión, sinastría, //! Uraniano, …) es un **módulo** que declara: //! //! - Qué `Layer`s aporta al `RenderModel`. //! - Qué `Control`s expone al panel inferior (toggles, sliders, selects). //! - Hotkeys opcionales. //! - Si su cómputo es lazy (sólo cuando se activa) o eager. //! //! El registry es un `Vec<&dyn Module>` estático: el canvas consulta //! "para esta `ChartKind`, ¿qué módulos están disponibles?" y el panel //! pinta sus controles. Activar / desactivar persiste en //! `ModuleState` (en la store). //! //! Esta fase 1 trae el trait + un módulo `NatalModule` de placeholder. //! En fases posteriores agregamos Transit, Progression, Synastry, //! Composite, SolarArc, Uranian, FixedStars, Dignities, Lots… #![forbid(unsafe_code)] #![warn(rust_2018_idioms)] use serde::{Deserialize, Serialize}; use cosmobiologia_engine::Layer; use cosmobiologia_model::{Chart, ChartKind}; // ===================================================================== // Trait Module // ===================================================================== /// Una capa de astrología enchufable. /// /// `Send + Sync` para que el registry sea estático y se pueda consultar /// desde cualquier thread (el cómputo pesado va a un background executor). pub trait Module: Send + Sync { /// Identidad estable del módulo. Coincide con `ModuleState.module_id` /// en la store. fn id(&self) -> &'static str; /// Etiqueta amigable para el panel. fn label(&self) -> &'static str; /// Breve descripción para tooltip. fn description(&self) -> &'static str; /// Para qué tipos de carta tiene sentido este módulo. El panel filtra /// con esto al armar la lista de toggles disponibles. fn applies_to(&self, kind: ChartKind) -> bool; /// Si el módulo está activado por default al crear una carta. fn enabled_by_default(&self) -> bool { false } /// Controles que aporta al panel inferior. fn controls(&self) -> Vec { Vec::new() } /// Computa las capas que este módulo aporta al RenderModel de /// `chart`. La engine la llama solo si el módulo está activado /// para esa carta. /// /// Devuelve `Vec` (no Option) — un módulo puede no aportar capas /// si su config interna lo apaga (ej. "Uranian: mostrar simetría /// = false"); en ese caso retorna `Vec::new()`. fn compute_layers(&self, chart: &Chart, config: &serde_json::Value) -> Vec; } // ===================================================================== // Controls expuestos al panel // ===================================================================== #[derive(Debug, Clone, Serialize, Deserialize)] pub enum Control { Toggle { key: String, label: String, default: bool, hotkey: Option, }, Slider { key: String, label: String, min: f64, max: f64, step: f64, default: f64, }, Select { key: String, label: String, options: Vec, default: String, }, /// Texto libre — útil para etiquetas, comentarios. TextInput { key: String, label: String, default: String, }, /// Picker dinámico de una carta de la DB. Las opciones las inyecta /// el host (Shell) en el panel — el módulo solo declara la /// existencia del control. Valor emitido en `ControlChanged` = /// `Value::String(chart_id)` cuando se selecciona, `Value::Null` /// cuando se vuelve a "automático". ChartPicker { key: String, label: String, }, /// Botón sin estado — el click dispara un `PanelEvent::Action` /// con `key`. El panel lo pinta como pill clickeable. Útil para /// "Guardar como carta libre" en los módulos overlay con /// transformación (RS, progresión, solar arc, GR). Action { key: String, label: String, }, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SelectOption { pub value: String, pub label: String, } // ===================================================================== // Registry // ===================================================================== /// Lista estática de módulos disponibles. La app los registra al boot. pub struct Registry { modules: Vec>, } impl Registry { /// Registry con todos los módulos built-in. La app llama esto al /// boot y luego usa `find()` / `for_kind()` para consultar. pub fn with_builtins() -> Self { let mut r = Self { modules: Vec::new() }; r.register(Box::new(natal::NatalModule)); r.register(Box::new(transit::TransitModule)); r.register(Box::new(progression::ProgressionModule)); r.register(Box::new(solar_arc::SolarArcModule)); r.register(Box::new(synastry::SynastryModule)); r.register(Box::new(planetary_return::PlanetaryReturnModule)); r.register(Box::new(midpoints::MidpointsModule)); r.register(Box::new(composite::CompositeModule)); r.register(Box::new(uranian::UranianModule)); r.register(Box::new(lots::LotsModule)); r.register(Box::new(fixed_stars::FixedStarsModule)); r.register(Box::new(topocentric::TopocentricModule)); r.register(Box::new(primary_directions::PrimaryDirectionsModule)); r } pub fn register(&mut self, m: Box) { self.modules.push(m); } pub fn all(&self) -> &[Box] { &self.modules } pub fn find(&self, id: &str) -> Option<&dyn Module> { self.modules .iter() .find(|m| m.id() == id) .map(|m| m.as_ref()) } pub fn for_kind(&self, kind: ChartKind) -> Vec<&dyn Module> { self.modules .iter() .filter(|m| m.applies_to(kind)) .map(|m| m.as_ref()) .collect() } } // ===================================================================== // NatalModule — placeholder fase 1 // ===================================================================== pub mod natal { use super::*; use cosmobiologia_engine::compute_mock; pub struct NatalModule; impl Module for NatalModule { fn id(&self) -> &'static str { "natal" } fn label(&self) -> &'static str { "Carta natal" } fn description(&self) -> &'static str { "Posiciones natales, casas y aspectos." } fn applies_to(&self, kind: ChartKind) -> bool { matches!(kind, ChartKind::Natal) } fn enabled_by_default(&self) -> bool { true } fn controls(&self) -> Vec { vec![ Control::Toggle { key: "show_sign_dial".into(), label: "Dial zodiacal".into(), default: true, hotkey: Some("D".into()), }, Control::Toggle { key: "show_houses".into(), label: "Casas".into(), default: true, hotkey: Some("H".into()), }, Control::Toggle { key: "show_aspects".into(), label: "Aspectos".into(), default: true, hotkey: Some("X".into()), }, Control::Toggle { key: "show_bodies".into(), label: "Cuerpos".into(), default: true, hotkey: Some("P".into()), }, Control::Toggle { key: "show_coords".into(), label: "Coordenadas (grado°min')".into(), default: true, hotkey: Some("C".into()), }, // Filtros de aspectos: cambian QUÉ se computa, no QUÉ // se pinta del render. Recompose al togglear. Control::Toggle { key: "aspect_majors".into(), label: "Mayores (☌ ☍ △ □ ⚹)".into(), default: true, hotkey: None, }, Control::Toggle { key: "aspect_minors".into(), label: "Menores (quincunx, semi-…)".into(), default: false, hotkey: None, }, Control::Slider { key: "orb_multiplier".into(), label: "Multiplicador de orbe".into(), min: 0.25, max: 2.5, step: 0.25, default: 1.0, }, Control::Toggle { key: "show_dignities".into(), label: "Dignidades esenciales (+ · − *)".into(), default: false, hotkey: None, }, Control::Slider { key: "harmonic".into(), label: "Armónico".into(), min: 1.0, // 1-32: el rango del espectro de fuerza armónica. max: 32.0, step: 1.0, default: 1.0, }, ] } fn compute_layers(&self, chart: &Chart, _cfg: &serde_json::Value) -> Vec { // Fase 1: delega al mock de la engine para que la UI tenga // algo que pintar. Fase 3 reemplaza con `engine::compute` // contra `eternal-astrology`. compute_mock(chart).layers } } } // ===================================================================== // TransitModule — overlay del cielo del momento sobre la carta natal // ===================================================================== pub mod transit { use super::*; /// Anillo externo con las posiciones planetarias del **instante /// actual** (reloj de pared) sobre el sujeto natal, más las /// cross-aspects natal × transit. La engine despacha al pipeline /// `PipelineRequest::Transit` cuando este módulo está activo en el /// `module_configs` del shell. pub struct TransitModule; impl Module for TransitModule { fn id(&self) -> &'static str { "transit" } fn label(&self) -> &'static str { "Tránsitos" } fn description(&self) -> &'static str { "Cielo del momento sobre la natal + cross aspects." } fn applies_to(&self, kind: ChartKind) -> bool { // Por ahora solo overlay sobre cartas natales — más adelante // podríamos overlayar tránsitos sobre Progresiones, etc. matches!(kind, ChartKind::Natal) } fn enabled_by_default(&self) -> bool { false } fn controls(&self) -> Vec { vec![ Control::Toggle { key: "enabled".into(), label: "Activar".into(), default: false, hotkey: Some("T".into()), }, Control::Action { key: "save_as_free".into(), label: "💾 Guardar tránsito como carta libre".into(), }, ] } fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec { // Las capas de tránsito se construyen en la engine vía // `PipelineRequest::Transit` porque necesitan acceso a la // NatalChart cruda + EphemerisSession. Este método queda // como no-op — el módulo es puramente declarativo. Vec::new() } } } // ===================================================================== // ProgressionModule — progresión secundaria (día por año) // ===================================================================== pub mod progression { use super::*; /// Anillo interno con la carta progresada (método secundario, /// "un día de efemérides = un año de vida") + cross aspects natal × /// progresada. La engine lo despacha vía /// `PipelineRequest::SecondaryProgression { target_age_years }`. pub struct ProgressionModule; impl Module for ProgressionModule { fn id(&self) -> &'static str { "progression" } fn label(&self) -> &'static str { "Progresión secundaria" } fn description(&self) -> &'static str { "Día-por-año: avanza la carta a la edad actual." } fn applies_to(&self, kind: ChartKind) -> bool { matches!(kind, ChartKind::Natal) } fn enabled_by_default(&self) -> bool { false } fn controls(&self) -> Vec { vec![ Control::Toggle { key: "enabled".into(), label: "Activar".into(), default: false, hotkey: None, }, // El default (30.0) es un placeholder — el shell empuja // la edad actual del sujeto al cargar una carta vía // panel.set_slider("progression", "target_age_years", // current_age). Control::Slider { key: "target_age_years".into(), label: "Edad objetivo (años)".into(), min: 0.0, max: 120.0, step: 0.25, default: 30.0, }, Control::Action { key: "save_as_free".into(), label: "💾 Guardar progresada como carta libre".into(), }, ] } fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec { Vec::new() } } } // ===================================================================== // SynastryModule — bi-wheel con otra carta hermana del contacto actual // ===================================================================== pub mod synastry { use super::*; /// Pone la carta del partner en el anillo externo (compartido con /// Transit — mutuamente excluyentes) y dibuja las cross aspects /// natal × partner. El shell elige el partner: la primera carta /// hermana del mismo contacto. Si no hay hermana, el request se /// salta silenciosamente. pub struct SynastryModule; impl Module for SynastryModule { fn id(&self) -> &'static str { "synastry" } fn label(&self) -> &'static str { "Sinastría" } fn description(&self) -> &'static str { "Bi-wheel con la primera carta hermana del contacto." } fn applies_to(&self, kind: ChartKind) -> bool { matches!(kind, ChartKind::Natal) } fn enabled_by_default(&self) -> bool { false } fn controls(&self) -> Vec { vec![ Control::Toggle { key: "enabled".into(), label: "Activar".into(), default: false, hotkey: None, }, Control::ChartPicker { key: "partner_chart_id".into(), label: "Partner".into(), }, ] } fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec { Vec::new() } } } // ===================================================================== // PlanetaryReturnModule — retornos de cualquier cuerpo a su pos natal // ===================================================================== pub mod planetary_return { use super::*; /// Computa la carta natal completa al instante del próximo retorno /// del cuerpo elegido. Sun = anual (cumpleaños), Moon = mensual, /// Júpiter/Saturno = generacionales. Comparte el outer ring con /// Transit y Synastry — mutuamente excluyentes a nivel de Shell. pub struct PlanetaryReturnModule; impl Module for PlanetaryReturnModule { fn id(&self) -> &'static str { "planetary_return" } fn label(&self) -> &'static str { "Retornos planetarios" } fn description(&self) -> &'static str { "Carta del próximo retorno (Sol, Luna, Júpiter, Saturno…)." } fn applies_to(&self, kind: ChartKind) -> bool { matches!(kind, ChartKind::Natal) } fn enabled_by_default(&self) -> bool { false } fn controls(&self) -> Vec { vec![ Control::Toggle { key: "enabled".into(), label: "Activar".into(), default: false, hotkey: None, }, Control::Select { key: "body".into(), label: "Cuerpo".into(), default: "sun".into(), options: vec![ SelectOption { value: "sun".into(), label: "Sol".into() }, SelectOption { value: "moon".into(), label: "Luna".into() }, SelectOption { value: "mercury".into(), label: "Mercurio".into() }, SelectOption { value: "venus".into(), label: "Venus".into() }, SelectOption { value: "mars".into(), label: "Marte".into() }, SelectOption { value: "jupiter".into(), label: "Júpiter".into() }, SelectOption { value: "saturn".into(), label: "Saturno".into() }, SelectOption { value: "uranus".into(), label: "Urano".into() }, SelectOption { value: "neptune".into(), label: "Neptuno".into() }, SelectOption { value: "pluto".into(), label: "Plutón".into() }, ], }, Control::Slider { key: "target_age_years".into(), label: "Edad del retorno".into(), min: 0.0, max: 120.0, step: 1.0, default: 30.0, }, // Offset adicional para Moon return (saltar ~28d entre // retornos lunares) o ajuste fino del Solar return. Control::Slider { key: "shift_days".into(), label: "Shift días (lunar nav)".into(), min: -180.0, max: 180.0, step: 1.0, default: 0.0, }, // Botón: captura la carta del retorno actual (cuerpo + // edad) como FreeChart con label `{contacto} rs-{N}` // (o `lunar-{N}` etc. según el cuerpo). El usuario // luego decide si guardarla en un contacto. Control::Action { key: "save_as_free".into(), label: "💾 Guardar retorno como carta libre".into(), }, ] } fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec { Vec::new() } } } // ===================================================================== // CompositeModule — carta compuesta (midpoint Davison) con un partner // ===================================================================== pub mod composite { use super::*; /// Carta compuesta entre la natal y otra carta — cada placement es /// el midpoint angular del par. Mismo ChartPicker que sinastría /// para elegir el partner. pub struct CompositeModule; impl Module for CompositeModule { fn id(&self) -> &'static str { "composite" } fn label(&self) -> &'static str { "Composite" } fn description(&self) -> &'static str { "Carta compuesta con otro sujeto (midpoint Davison)." } fn applies_to(&self, kind: ChartKind) -> bool { matches!(kind, ChartKind::Natal) } fn enabled_by_default(&self) -> bool { false } fn controls(&self) -> Vec { vec![ Control::Toggle { key: "enabled".into(), label: "Activar".into(), default: false, hotkey: None, }, Control::ChartPicker { key: "partner_chart_id".into(), label: "Partner".into(), }, ] } fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec { Vec::new() } } } // ===================================================================== // SolarArcModule — Solar Arc dirigido (true progressed Sun) // ===================================================================== pub mod solar_arc { use super::*; /// Cada planeta y cusp natal se desplaza por el mismo arco /// (≈ 1° por año de vida, calculado como el delta del Sol /// progresado secundario). Anillo interno bien adentro + cross /// aspects natal × dirigida. pub struct SolarArcModule; impl Module for SolarArcModule { fn id(&self) -> &'static str { "solar_arc" } fn label(&self) -> &'static str { "Solar Arc" } fn description(&self) -> &'static str { "Dirección por arco solar — uniforme, ≈1°/año." } fn applies_to(&self, kind: ChartKind) -> bool { matches!(kind, ChartKind::Natal) } fn enabled_by_default(&self) -> bool { false } fn controls(&self) -> Vec { vec![ Control::Toggle { key: "enabled".into(), label: "Activar".into(), default: false, hotkey: None, }, Control::Slider { key: "target_age_years".into(), label: "Edad objetivo (años)".into(), min: 0.0, max: 120.0, step: 0.25, default: 30.0, }, ] } fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec { Vec::new() } } } // ===================================================================== // MidpointsModule — puntos medios entre cuerpos natales (Sol/Luna) // ===================================================================== pub mod midpoints { use super::*; /// Computa midpoints entre los cuerpos natales (filtrado a los que /// involucran Sol o Luna, ~10 puntos) y los renderea como pequeños /// puntos en un anillo interior. Hovering muestra los dos cuerpos /// que originan el midpoint. pub struct MidpointsModule; impl Module for MidpointsModule { fn id(&self) -> &'static str { "midpoints" } fn label(&self) -> &'static str { "Midpoints" } fn description(&self) -> &'static str { "Puntos medios que involucran al Sol o a la Luna." } fn applies_to(&self, kind: ChartKind) -> bool { matches!(kind, ChartKind::Natal) } fn enabled_by_default(&self) -> bool { false } fn controls(&self) -> Vec { vec![Control::Toggle { key: "enabled".into(), label: "Activar".into(), default: false, hotkey: None, }] } fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec { Vec::new() } } } #[cfg(test)] mod tests { use super::*; #[test] fn registry_finds_builtins() { let r = Registry::with_builtins(); assert!(r.find("natal").is_some()); assert!(r.find("transit").is_some()); assert!(r.find("progression").is_some()); assert!(r.find("solar_arc").is_some()); assert!(r.find("synastry").is_some()); assert!(r.find("planetary_return").is_some()); assert!(r.find("midpoints").is_some()); assert!(r.find("composite").is_some()); assert!(r.find("uranian").is_some()); assert!(r.find("lots").is_some()); assert!(r.find("fixed_stars").is_some()); // Natal kind tiene 11 módulos aplicables. assert_eq!(r.for_kind(ChartKind::Natal).len(), 11); assert!(r.for_kind(ChartKind::Synastry).is_empty()); } } // ===================================================================== // LotsModule — Lots helenísticos (Fortune, Spirit, Eros, …) // ===================================================================== pub mod lots { use super::*; /// Calcula los 7 Lots arábigos clásicos via eternal-astrology y /// los renderea como pequeños labels en un ring justo debajo de /// los cuerpos natales. Hover muestra el nombre completo. pub struct LotsModule; impl Module for LotsModule { fn id(&self) -> &'static str { "lots" } fn label(&self) -> &'static str { "Lots (helenísticos)" } fn description(&self) -> &'static str { "Fortune, Spirit, Eros, Necessity, Courage, Victory, Nemesis." } fn applies_to(&self, kind: ChartKind) -> bool { matches!(kind, ChartKind::Natal) } fn enabled_by_default(&self) -> bool { false } fn controls(&self) -> Vec { vec![Control::Toggle { key: "enabled".into(), label: "Activar".into(), default: false, hotkey: None, }] } fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec { Vec::new() } } } // ===================================================================== // FixedStarsModule — 9 estrellas astrológicamente notables // ===================================================================== pub mod fixed_stars { use super::*; /// 9 estrellas fijas (Aldebaran, Regulus, Antares, Fomalhaut, /// Spica, Sirius, Algol, Vega, Pollux) con posición tropical /// aproximada (J2000 + precesión simple). Marcadores chicos en el /// margen exterior del sign dial. pub struct FixedStarsModule; impl Module for FixedStarsModule { fn id(&self) -> &'static str { "fixed_stars" } fn label(&self) -> &'static str { "Estrellas fijas" } fn description(&self) -> &'static str { "9 estrellas notables — conjunciones con planetas natales." } fn applies_to(&self, kind: ChartKind) -> bool { matches!(kind, ChartKind::Natal) } fn enabled_by_default(&self) -> bool { false } fn controls(&self) -> Vec { vec![Control::Toggle { key: "enabled".into(), label: "Activar".into(), default: false, hotkey: None, }] } fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec { Vec::new() } } } // ===================================================================== // UranianModule — ejes del dial uraniano de 90° (versión textual) // ===================================================================== pub mod uranian { use super::*; /// Detecta "ejes" del dial uraniano: grupos de cuerpos natales cuya /// longitud módulo 90 cae dentro de una tolerancia. Los grupos /// resultantes se listan en el footer del canvas. La visualización /// geométrica del dial completo de 90° queda para una fase futura. pub struct UranianModule; impl Module for UranianModule { fn id(&self) -> &'static str { "uranian" } fn label(&self) -> &'static str { "Uraniano (90°)" } fn description(&self) -> &'static str { "Ejes del dial uraniano — cuerpos en la misma posición mod 90." } fn applies_to(&self, kind: ChartKind) -> bool { matches!(kind, ChartKind::Natal) } fn enabled_by_default(&self) -> bool { false } fn controls(&self) -> Vec { vec![Control::Toggle { key: "enabled".into(), label: "Activar".into(), default: false, hotkey: None, }] } fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec { Vec::new() } } } // ===================================================================== // TopocentricModule — capa "ascensional" (paralaje + Polich-Page) // ===================================================================== pub mod topocentric { use super::*; /// Capa topocéntrica que convive con la natal geocéntrica: cada /// planeta se re-proyecta a longitud eclíptica topocéntrica (con /// paralaje horizontal por cuerpo) y las casas se calculan con el /// sistema Polich-Page. El shift es visible en la Luna (~1°), /// modesto en interiores cerca de oposición, e imperceptible en /// exteriores. La engine despacha al pipeline /// `PipelineRequest::Topocentric` cuando este módulo está activo. pub struct TopocentricModule; impl Module for TopocentricModule { fn id(&self) -> &'static str { "topocentric" } fn label(&self) -> &'static str { "Topocéntrico (ascensional)" } fn description(&self) -> &'static str { "Paralaje horizontal por cuerpo + casas Polich-Page." } fn applies_to(&self, kind: ChartKind) -> bool { matches!(kind, ChartKind::Natal) } fn enabled_by_default(&self) -> bool { true } fn controls(&self) -> Vec { vec![Control::Toggle { key: "enabled".into(), label: "Activar".into(), default: true, hotkey: None, }] } fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec { Vec::new() } } } // ===================================================================== // PrimaryDirectionsModule — GR dual-ring (Direct + Converse) // ===================================================================== pub mod primary_directions { use super::*; /// Direcciones Primarias del Sistema GR (García Rosas): cada /// cuerpo natal se proyecta en dos rings — directa (rotación /// diurna forward) y conversa (rotación inversa). El usuario /// scrubea `target_age_years` para ver el movimiento en vivo. /// Útil para rectificación: un evento real debe coincidir con /// arcos directos y conversos consistentes si la hora natal es /// correcta. pub struct PrimaryDirectionsModule; impl Module for PrimaryDirectionsModule { fn id(&self) -> &'static str { "primary_directions" } fn label(&self) -> &'static str { "Direcciones primarias (GR)" } fn description(&self) -> &'static str { "Dual-ring directas + conversas para rectificación en vivo." } fn applies_to(&self, kind: ChartKind) -> bool { matches!(kind, ChartKind::Natal) } fn enabled_by_default(&self) -> bool { false } fn controls(&self) -> Vec { vec![ Control::Toggle { key: "enabled".into(), label: "Activar".into(), default: false, hotkey: None, }, Control::Slider { key: "target_age_years".into(), label: "Edad (años)".into(), min: 0.0, max: 120.0, step: 0.05, default: 30.0, }, Control::Select { key: "key".into(), label: "Clave (arco/año)".into(), default: "naibod".into(), options: vec![ SelectOption { value: "naibod".into(), label: "Naibod (0°59'08\"/año)".into(), }, SelectOption { value: "ptolemy".into(), label: "Ptolomeo (1°/año)".into(), }, ], }, ] } fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec { Vec::new() } } }