tawasuyu

tawasuyu/brahman

Rust 230TOML 69JSON 14HTML 12901 Definitions

⎇ 7eb620aa170f825b5dec6ff454baac07c48bd9f1 crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs Rust · 914 lines · 31.3 KB History Who touched what raw

feat(tahuantinsuyu): GR dual-ring + topo ascensional pegado al dial + coords 7eb620aa1 · sergio ·

1//! `tahuantinsuyu-modules` — registry de módulos astrológicos.
2//!
3//! Cada tipo de astrología (natal, tránsito, progresión, sinastría,
4//! Uraniano, …) es un **módulo** que declara:
5//!
6//! - Qué `Layer`s aporta al `RenderModel`.
7//! - Qué `Control`s expone al panel inferior (toggles, sliders, selects).
8//! - Hotkeys opcionales.
9//! - Si su cómputo es lazy (sólo cuando se activa) o eager.
10//!
11//! El registry es un `Vec<&dyn Module>` estático: el canvas consulta
12//! "para esta `ChartKind`, ¿qué módulos están disponibles?" y el panel
13//! pinta sus controles. Activar / desactivar persiste en
14//! `ModuleState` (en la store).
15//!
16//! Esta fase 1 trae el trait + un módulo `NatalModule` de placeholder.
17//! En fases posteriores agregamos Transit, Progression, Synastry,
18//! Composite, SolarArc, Uranian, FixedStars, Dignities, Lots…
19
20#![forbid(unsafe_code)]
21#![warn(rust_2018_idioms)]
22
23use serde::{Deserialize, Serialize};
24
25use tahuantinsuyu_engine::Layer;
26use tahuantinsuyu_model::{Chart, ChartKind};
27
28// =====================================================================
29// Trait Module
30// =====================================================================
31
32/// Una capa de astrología enchufable.
33///
34/// `Send + Sync` para que el registry sea estático y se pueda consultar
35/// desde cualquier thread (el cómputo pesado va a un background executor).
36pub trait Module: Send + Sync {
37    /// Identidad estable del módulo. Coincide con `ModuleState.module_id`
38    /// en la store.
39    fn id(&self) -> &'static str;
40
41    /// Etiqueta amigable para el panel.
42    fn label(&self) -> &'static str;
43
44    /// Breve descripción para tooltip.
45    fn description(&self) -> &'static str;
46
47    /// Para qué tipos de carta tiene sentido este módulo. El panel filtra
48    /// con esto al armar la lista de toggles disponibles.
49    fn applies_to(&self, kind: ChartKind) -> bool;
50
51    /// Si el módulo está activado por default al crear una carta.
52    fn enabled_by_default(&self) -> bool {
53        false
54    }
55
56    /// Controles que aporta al panel inferior.
57    fn controls(&self) -> Vec<Control> {
58        Vec::new()
59    }
60
61    /// Computa las capas que este módulo aporta al RenderModel de
62    /// `chart`. La engine la llama solo si el módulo está activado
63    /// para esa carta.
64    ///
65    /// Devuelve `Vec` (no Option) — un módulo puede no aportar capas
66    /// si su config interna lo apaga (ej. "Uranian: mostrar simetría
67    /// = false"); en ese caso retorna `Vec::new()`.
68    fn compute_layers(&self, chart: &Chart, config: &serde_json::Value) -> Vec<Layer>;
69}
70
71// =====================================================================
72// Controls expuestos al panel
73// =====================================================================
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub enum Control {
77    Toggle {
78        key: String,
79        label: String,
80        default: bool,
81        hotkey: Option<String>,
82    },
83    Slider {
84        key: String,
85        label: String,
86        min: f64,
87        max: f64,
88        step: f64,
89        default: f64,
90    },
91    Select {
92        key: String,
93        label: String,
94        options: Vec<SelectOption>,
95        default: String,
96    },
97    /// Texto libre — útil para etiquetas, comentarios.
98    TextInput {
99        key: String,
100        label: String,
101        default: String,
102    },
103    /// Picker dinámico de una carta de la DB. Las opciones las inyecta
104    /// el host (Shell) en el panel — el módulo solo declara la
105    /// existencia del control. Valor emitido en `ControlChanged` =
106    /// `Value::String(chart_id)` cuando se selecciona, `Value::Null`
107    /// cuando se vuelve a "automático".
108    ChartPicker {
109        key: String,
110        label: String,
111    },
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct SelectOption {
116    pub value: String,
117    pub label: String,
118}
119
120// =====================================================================
121// Registry
122// =====================================================================
123
124/// Lista estática de módulos disponibles. La app los registra al boot.
125pub struct Registry {
126    modules: Vec<Box<dyn Module>>,
127}
128
129impl Registry {
130    /// Registry con todos los módulos built-in. La app llama esto al
131    /// boot y luego usa `find()` / `for_kind()` para consultar.
132    pub fn with_builtins() -> Self {
133        let mut r = Self { modules: Vec::new() };
134        r.register(Box::new(natal::NatalModule));
135        r.register(Box::new(transit::TransitModule));
136        r.register(Box::new(progression::ProgressionModule));
137        r.register(Box::new(solar_arc::SolarArcModule));
138        r.register(Box::new(synastry::SynastryModule));
139        r.register(Box::new(planetary_return::PlanetaryReturnModule));
140        r.register(Box::new(midpoints::MidpointsModule));
141        r.register(Box::new(composite::CompositeModule));
142        r.register(Box::new(uranian::UranianModule));
143        r.register(Box::new(lots::LotsModule));
144        r.register(Box::new(fixed_stars::FixedStarsModule));
145        r.register(Box::new(topocentric::TopocentricModule));
146        r.register(Box::new(primary_directions::PrimaryDirectionsModule));
147        r
148    }
149
150    pub fn register(&mut self, m: Box<dyn Module>) {
151        self.modules.push(m);
152    }
153
154    pub fn all(&self) -> &[Box<dyn Module>] {
155        &self.modules
156    }
157
158    pub fn find(&self, id: &str) -> Option<&dyn Module> {
159        self.modules
160            .iter()
161            .find(|m| m.id() == id)
162            .map(|m| m.as_ref())
163    }
164
165    pub fn for_kind(&self, kind: ChartKind) -> Vec<&dyn Module> {
166        self.modules
167            .iter()
168            .filter(|m| m.applies_to(kind))
169            .map(|m| m.as_ref())
170            .collect()
171    }
172}
173
174// =====================================================================
175// NatalModule — placeholder fase 1
176// =====================================================================
177
178pub mod natal {
179    use super::*;
180    use tahuantinsuyu_engine::compute_mock;
181
182    pub struct NatalModule;
183
184    impl Module for NatalModule {
185        fn id(&self) -> &'static str {
186            "natal"
187        }
188        fn label(&self) -> &'static str {
189            "Carta natal"
190        }
191        fn description(&self) -> &'static str {
192            "Posiciones natales, casas y aspectos."
193        }
194        fn applies_to(&self, kind: ChartKind) -> bool {
195            matches!(kind, ChartKind::Natal)
196        }
197        fn enabled_by_default(&self) -> bool {
198            true
199        }
200
201        fn controls(&self) -> Vec<Control> {
202            vec![
203                Control::Toggle {
204                    key: "show_sign_dial".into(),
205                    label: "Dial zodiacal".into(),
206                    default: true,
207                    hotkey: Some("D".into()),
208                },
209                Control::Toggle {
210                    key: "show_houses".into(),
211                    label: "Casas".into(),
212                    default: true,
213                    hotkey: Some("H".into()),
214                },
215                Control::Toggle {
216                    key: "show_aspects".into(),
217                    label: "Aspectos".into(),
218                    default: true,
219                    hotkey: Some("X".into()),
220                },
221                Control::Toggle {
222                    key: "show_bodies".into(),
223                    label: "Cuerpos".into(),
224                    default: true,
225                    hotkey: Some("P".into()),
226                },
227                Control::Toggle {
228                    key: "show_coords".into(),
229                    label: "Coordenadas (grado°min')".into(),
230                    default: true,
231                    hotkey: Some("C".into()),
232                },
233                // Filtros de aspectos: cambian QUÉ se computa, no QUÉ
234                // se pinta del render. Recompose al togglear.
235                Control::Toggle {
236                    key: "aspect_majors".into(),
237                    label: "Mayores (☌ ☍ △ □ ⚹)".into(),
238                    default: true,
239                    hotkey: None,
240                },
241                Control::Toggle {
242                    key: "aspect_minors".into(),
243                    label: "Menores (quincunx, semi-…)".into(),
244                    default: false,
245                    hotkey: None,
246                },
247                Control::Slider {
248                    key: "orb_multiplier".into(),
249                    label: "Multiplicador de orbe".into(),
250                    min: 0.25,
251                    max: 2.5,
252                    step: 0.25,
253                    default: 1.0,
254                },
255                Control::Toggle {
256                    key: "show_dignities".into(),
257                    label: "Dignidades esenciales (+ · − *)".into(),
258                    default: false,
259                    hotkey: None,
260                },
261                Control::Slider {
262                    key: "harmonic".into(),
263                    label: "Armónico".into(),
264                    min: 1.0,
265                    max: 20.0,
266                    step: 1.0,
267                    default: 1.0,
268                },
269            ]
270        }
271
272        fn compute_layers(&self, chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
273            // Fase 1: delega al mock de la engine para que la UI tenga
274            // algo que pintar. Fase 3 reemplaza con `engine::compute`
275            // contra `eternal-astrology`.
276            compute_mock(chart).layers
277        }
278    }
279}
280
281// =====================================================================
282// TransitModule — overlay del cielo del momento sobre la carta natal
283// =====================================================================
284
285pub mod transit {
286    use super::*;
287
288    /// Anillo externo con las posiciones planetarias del **instante
289    /// actual** (reloj de pared) sobre el sujeto natal, más las
290    /// cross-aspects natal × transit. La engine despacha al pipeline
291    /// `PipelineRequest::Transit` cuando este módulo está activo en el
292    /// `module_configs` del shell.
293    pub struct TransitModule;
294
295    impl Module for TransitModule {
296        fn id(&self) -> &'static str {
297            "transit"
298        }
299        fn label(&self) -> &'static str {
300            "Tránsitos"
301        }
302        fn description(&self) -> &'static str {
303            "Cielo del momento sobre la natal + cross aspects."
304        }
305        fn applies_to(&self, kind: ChartKind) -> bool {
306            // Por ahora solo overlay sobre cartas natales — más adelante
307            // podríamos overlayar tránsitos sobre Progresiones, etc.
308            matches!(kind, ChartKind::Natal)
309        }
310        fn enabled_by_default(&self) -> bool {
311            false
312        }
313        fn controls(&self) -> Vec<Control> {
314            vec![Control::Toggle {
315                key: "enabled".into(),
316                label: "Activar".into(),
317                default: false,
318                hotkey: Some("T".into()),
319            }]
320        }
321        fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
322            // Las capas de tránsito se construyen en la engine vía
323            // `PipelineRequest::Transit` porque necesitan acceso a la
324            // NatalChart cruda + EphemerisSession. Este método queda
325            // como no-op — el módulo es puramente declarativo.
326            Vec::new()
327        }
328    }
329}
330
331// =====================================================================
332// ProgressionModule — progresión secundaria (día por año)
333// =====================================================================
334
335pub mod progression {
336    use super::*;
337
338    /// Anillo interno con la carta progresada (método secundario,
339    /// "un día de efemérides = un año de vida") + cross aspects natal ×
340    /// progresada. La engine lo despacha vía
341    /// `PipelineRequest::SecondaryProgression { target_age_years }`.
342    pub struct ProgressionModule;
343
344    impl Module for ProgressionModule {
345        fn id(&self) -> &'static str {
346            "progression"
347        }
348        fn label(&self) -> &'static str {
349            "Progresión secundaria"
350        }
351        fn description(&self) -> &'static str {
352            "Día-por-año: avanza la carta a la edad actual."
353        }
354        fn applies_to(&self, kind: ChartKind) -> bool {
355            matches!(kind, ChartKind::Natal)
356        }
357        fn enabled_by_default(&self) -> bool {
358            false
359        }
360        fn controls(&self) -> Vec<Control> {
361            vec![
362                Control::Toggle {
363                    key: "enabled".into(),
364                    label: "Activar".into(),
365                    default: false,
366                    hotkey: None,
367                },
368                // El default (30.0) es un placeholder — el shell empuja
369                // la edad actual del sujeto al cargar una carta vía
370                // panel.set_slider("progression", "target_age_years",
371                // current_age).
372                Control::Slider {
373                    key: "target_age_years".into(),
374                    label: "Edad objetivo (años)".into(),
375                    min: 0.0,
376                    max: 120.0,
377                    step: 0.25,
378                    default: 30.0,
379                },
380            ]
381        }
382        fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
383            Vec::new()
384        }
385    }
386}
387
388// =====================================================================
389// SynastryModule — bi-wheel con otra carta hermana del contacto actual
390// =====================================================================
391
392pub mod synastry {
393    use super::*;
394
395    /// Pone la carta del partner en el anillo externo (compartido con
396    /// Transit — mutuamente excluyentes) y dibuja las cross aspects
397    /// natal × partner. El shell elige el partner: la primera carta
398    /// hermana del mismo contacto. Si no hay hermana, el request se
399    /// salta silenciosamente.
400    pub struct SynastryModule;
401
402    impl Module for SynastryModule {
403        fn id(&self) -> &'static str {
404            "synastry"
405        }
406        fn label(&self) -> &'static str {
407            "Sinastría"
408        }
409        fn description(&self) -> &'static str {
410            "Bi-wheel con la primera carta hermana del contacto."
411        }
412        fn applies_to(&self, kind: ChartKind) -> bool {
413            matches!(kind, ChartKind::Natal)
414        }
415        fn enabled_by_default(&self) -> bool {
416            false
417        }
418        fn controls(&self) -> Vec<Control> {
419            vec![
420                Control::Toggle {
421                    key: "enabled".into(),
422                    label: "Activar".into(),
423                    default: false,
424                    hotkey: None,
425                },
426                Control::ChartPicker {
427                    key: "partner_chart_id".into(),
428                    label: "Partner".into(),
429                },
430            ]
431        }
432        fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
433            Vec::new()
434        }
435    }
436}
437
438// =====================================================================
439// PlanetaryReturnModule — retornos de cualquier cuerpo a su pos natal
440// =====================================================================
441
442pub mod planetary_return {
443    use super::*;
444
445    /// Computa la carta natal completa al instante del próximo retorno
446    /// del cuerpo elegido. Sun = anual (cumpleaños), Moon = mensual,
447    /// Júpiter/Saturno = generacionales. Comparte el outer ring con
448    /// Transit y Synastry — mutuamente excluyentes a nivel de Shell.
449    pub struct PlanetaryReturnModule;
450
451    impl Module for PlanetaryReturnModule {
452        fn id(&self) -> &'static str {
453            "planetary_return"
454        }
455        fn label(&self) -> &'static str {
456            "Retornos planetarios"
457        }
458        fn description(&self) -> &'static str {
459            "Carta del próximo retorno (Sol, Luna, Júpiter, Saturno…)."
460        }
461        fn applies_to(&self, kind: ChartKind) -> bool {
462            matches!(kind, ChartKind::Natal)
463        }
464        fn enabled_by_default(&self) -> bool {
465            false
466        }
467        fn controls(&self) -> Vec<Control> {
468            vec![
469                Control::Toggle {
470                    key: "enabled".into(),
471                    label: "Activar".into(),
472                    default: false,
473                    hotkey: None,
474                },
475                Control::Select {
476                    key: "body".into(),
477                    label: "Cuerpo".into(),
478                    default: "sun".into(),
479                    options: vec![
480                        SelectOption { value: "sun".into(), label: "Sol".into() },
481                        SelectOption { value: "moon".into(), label: "Luna".into() },
482                        SelectOption { value: "mercury".into(), label: "Mercurio".into() },
483                        SelectOption { value: "venus".into(), label: "Venus".into() },
484                        SelectOption { value: "mars".into(), label: "Marte".into() },
485                        SelectOption { value: "jupiter".into(), label: "Júpiter".into() },
486                        SelectOption { value: "saturn".into(), label: "Saturno".into() },
487                        SelectOption { value: "uranus".into(), label: "Urano".into() },
488                        SelectOption { value: "neptune".into(), label: "Neptuno".into() },
489                        SelectOption { value: "pluto".into(), label: "Plutón".into() },
490                    ],
491                },
492                Control::Slider {
493                    key: "target_age_years".into(),
494                    label: "Edad del retorno".into(),
495                    min: 0.0,
496                    max: 120.0,
497                    step: 1.0,
498                    default: 30.0,
499                },
500                // Offset adicional para Moon return (saltar ~28d entre
501                // retornos lunares) o ajuste fino del Solar return.
502                Control::Slider {
503                    key: "shift_days".into(),
504                    label: "Shift días (lunar nav)".into(),
505                    min: -180.0,
506                    max: 180.0,
507                    step: 1.0,
508                    default: 0.0,
509                },
510            ]
511        }
512        fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
513            Vec::new()
514        }
515    }
516}
517
518// =====================================================================
519// CompositeModule — carta compuesta (midpoint Davison) con un partner
520// =====================================================================
521
522pub mod composite {
523    use super::*;
524
525    /// Carta compuesta entre la natal y otra carta — cada placement es
526    /// el midpoint angular del par. Mismo ChartPicker que sinastría
527    /// para elegir el partner.
528    pub struct CompositeModule;
529
530    impl Module for CompositeModule {
531        fn id(&self) -> &'static str {
532            "composite"
533        }
534        fn label(&self) -> &'static str {
535            "Composite"
536        }
537        fn description(&self) -> &'static str {
538            "Carta compuesta con otro sujeto (midpoint Davison)."
539        }
540        fn applies_to(&self, kind: ChartKind) -> bool {
541            matches!(kind, ChartKind::Natal)
542        }
543        fn enabled_by_default(&self) -> bool {
544            false
545        }
546        fn controls(&self) -> Vec<Control> {
547            vec![
548                Control::Toggle {
549                    key: "enabled".into(),
550                    label: "Activar".into(),
551                    default: false,
552                    hotkey: None,
553                },
554                Control::ChartPicker {
555                    key: "partner_chart_id".into(),
556                    label: "Partner".into(),
557                },
558            ]
559        }
560        fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
561            Vec::new()
562        }
563    }
564}
565
566// =====================================================================
567// SolarArcModule — Solar Arc dirigido (true progressed Sun)
568// =====================================================================
569
570pub mod solar_arc {
571    use super::*;
572
573    /// Cada planeta y cusp natal se desplaza por el mismo arco
574    /// (≈ 1° por año de vida, calculado como el delta del Sol
575    /// progresado secundario). Anillo interno bien adentro + cross
576    /// aspects natal × dirigida.
577    pub struct SolarArcModule;
578
579    impl Module for SolarArcModule {
580        fn id(&self) -> &'static str {
581            "solar_arc"
582        }
583        fn label(&self) -> &'static str {
584            "Solar Arc"
585        }
586        fn description(&self) -> &'static str {
587            "Dirección por arco solar — uniforme, ≈1°/año."
588        }
589        fn applies_to(&self, kind: ChartKind) -> bool {
590            matches!(kind, ChartKind::Natal)
591        }
592        fn enabled_by_default(&self) -> bool {
593            false
594        }
595        fn controls(&self) -> Vec<Control> {
596            vec![
597                Control::Toggle {
598                    key: "enabled".into(),
599                    label: "Activar".into(),
600                    default: false,
601                    hotkey: None,
602                },
603                Control::Slider {
604                    key: "target_age_years".into(),
605                    label: "Edad objetivo (años)".into(),
606                    min: 0.0,
607                    max: 120.0,
608                    step: 0.25,
609                    default: 30.0,
610                },
611            ]
612        }
613        fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
614            Vec::new()
615        }
616    }
617}
618
619// =====================================================================
620// MidpointsModule — puntos medios entre cuerpos natales (Sol/Luna)
621// =====================================================================
622
623pub mod midpoints {
624    use super::*;
625
626    /// Computa midpoints entre los cuerpos natales (filtrado a los que
627    /// involucran Sol o Luna, ~10 puntos) y los renderea como pequeños
628    /// puntos en un anillo interior. Hovering muestra los dos cuerpos
629    /// que originan el midpoint.
630    pub struct MidpointsModule;
631
632    impl Module for MidpointsModule {
633        fn id(&self) -> &'static str {
634            "midpoints"
635        }
636        fn label(&self) -> &'static str {
637            "Midpoints"
638        }
639        fn description(&self) -> &'static str {
640            "Puntos medios que involucran al Sol o a la Luna."
641        }
642        fn applies_to(&self, kind: ChartKind) -> bool {
643            matches!(kind, ChartKind::Natal)
644        }
645        fn enabled_by_default(&self) -> bool {
646            false
647        }
648        fn controls(&self) -> Vec<Control> {
649            vec![Control::Toggle {
650                key: "enabled".into(),
651                label: "Activar".into(),
652                default: false,
653                hotkey: None,
654            }]
655        }
656        fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
657            Vec::new()
658        }
659    }
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665
666    #[test]
667    fn registry_finds_builtins() {
668        let r = Registry::with_builtins();
669        assert!(r.find("natal").is_some());
670        assert!(r.find("transit").is_some());
671        assert!(r.find("progression").is_some());
672        assert!(r.find("solar_arc").is_some());
673        assert!(r.find("synastry").is_some());
674        assert!(r.find("planetary_return").is_some());
675        assert!(r.find("midpoints").is_some());
676        assert!(r.find("composite").is_some());
677        assert!(r.find("uranian").is_some());
678        assert!(r.find("lots").is_some());
679        assert!(r.find("fixed_stars").is_some());
680        // Natal kind tiene 11 módulos aplicables.
681        assert_eq!(r.for_kind(ChartKind::Natal).len(), 11);
682        assert!(r.for_kind(ChartKind::Synastry).is_empty());
683    }
684}
685
686// =====================================================================
687// LotsModule — Lots helenísticos (Fortune, Spirit, Eros, …)
688// =====================================================================
689
690pub mod lots {
691    use super::*;
692
693    /// Calcula los 7 Lots arábigos clásicos via eternal-astrology y
694    /// los renderea como pequeños labels en un ring justo debajo de
695    /// los cuerpos natales. Hover muestra el nombre completo.
696    pub struct LotsModule;
697
698    impl Module for LotsModule {
699        fn id(&self) -> &'static str {
700            "lots"
701        }
702        fn label(&self) -> &'static str {
703            "Lots (helenísticos)"
704        }
705        fn description(&self) -> &'static str {
706            "Fortune, Spirit, Eros, Necessity, Courage, Victory, Nemesis."
707        }
708        fn applies_to(&self, kind: ChartKind) -> bool {
709            matches!(kind, ChartKind::Natal)
710        }
711        fn enabled_by_default(&self) -> bool {
712            false
713        }
714        fn controls(&self) -> Vec<Control> {
715            vec![Control::Toggle {
716                key: "enabled".into(),
717                label: "Activar".into(),
718                default: false,
719                hotkey: None,
720            }]
721        }
722        fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
723            Vec::new()
724        }
725    }
726}
727
728// =====================================================================
729// FixedStarsModule — 9 estrellas astrológicamente notables
730// =====================================================================
731
732pub mod fixed_stars {
733    use super::*;
734
735    /// 9 estrellas fijas (Aldebaran, Regulus, Antares, Fomalhaut,
736    /// Spica, Sirius, Algol, Vega, Pollux) con posición tropical
737    /// aproximada (J2000 + precesión simple). Marcadores chicos en el
738    /// margen exterior del sign dial.
739    pub struct FixedStarsModule;
740
741    impl Module for FixedStarsModule {
742        fn id(&self) -> &'static str {
743            "fixed_stars"
744        }
745        fn label(&self) -> &'static str {
746            "Estrellas fijas"
747        }
748        fn description(&self) -> &'static str {
749            "9 estrellas notables — conjunciones con planetas natales."
750        }
751        fn applies_to(&self, kind: ChartKind) -> bool {
752            matches!(kind, ChartKind::Natal)
753        }
754        fn enabled_by_default(&self) -> bool {
755            false
756        }
757        fn controls(&self) -> Vec<Control> {
758            vec![Control::Toggle {
759                key: "enabled".into(),
760                label: "Activar".into(),
761                default: false,
762                hotkey: None,
763            }]
764        }
765        fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
766            Vec::new()
767        }
768    }
769}
770
771// =====================================================================
772// UranianModule — ejes del dial uraniano de 90° (versión textual)
773// =====================================================================
774
775pub mod uranian {
776    use super::*;
777
778    /// Detecta "ejes" del dial uraniano: grupos de cuerpos natales cuya
779    /// longitud módulo 90 cae dentro de una tolerancia. Los grupos
780    /// resultantes se listan en el footer del canvas. La visualización
781    /// geométrica del dial completo de 90° queda para una fase futura.
782    pub struct UranianModule;
783
784    impl Module for UranianModule {
785        fn id(&self) -> &'static str {
786            "uranian"
787        }
788        fn label(&self) -> &'static str {
789            "Uraniano (90°)"
790        }
791        fn description(&self) -> &'static str {
792            "Ejes del dial uraniano — cuerpos en la misma posición mod 90."
793        }
794        fn applies_to(&self, kind: ChartKind) -> bool {
795            matches!(kind, ChartKind::Natal)
796        }
797        fn enabled_by_default(&self) -> bool {
798            false
799        }
800        fn controls(&self) -> Vec<Control> {
801            vec![Control::Toggle {
802                key: "enabled".into(),
803                label: "Activar".into(),
804                default: false,
805                hotkey: None,
806            }]
807        }
808        fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
809            Vec::new()
810        }
811    }
812}
813
814// =====================================================================
815// TopocentricModule — capa "ascensional" (paralaje + Polich-Page)
816// =====================================================================
817
818pub mod topocentric {
819    use super::*;
820
821    /// Capa topocéntrica que convive con la natal geocéntrica: cada
822    /// planeta se re-proyecta a longitud eclíptica topocéntrica (con
823    /// paralaje horizontal por cuerpo) y las casas se calculan con el
824    /// sistema Polich-Page. El shift es visible en la Luna (~1°),
825    /// modesto en interiores cerca de oposición, e imperceptible en
826    /// exteriores. La engine despacha al pipeline
827    /// `PipelineRequest::Topocentric` cuando este módulo está activo.
828    pub struct TopocentricModule;
829
830    impl Module for TopocentricModule {
831        fn id(&self) -> &'static str {
832            "topocentric"
833        }
834        fn label(&self) -> &'static str {
835            "Topocéntrico (ascensional)"
836        }
837        fn description(&self) -> &'static str {
838            "Paralaje horizontal por cuerpo + casas Polich-Page."
839        }
840        fn applies_to(&self, kind: ChartKind) -> bool {
841            matches!(kind, ChartKind::Natal)
842        }
843        fn enabled_by_default(&self) -> bool {
844            true
845        }
846        fn controls(&self) -> Vec<Control> {
847            vec![Control::Toggle {
848                key: "enabled".into(),
849                label: "Activar".into(),
850                default: true,
851                hotkey: None,
852            }]
853        }
854        fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
855            Vec::new()
856        }
857    }
858}
859
860// =====================================================================
861// PrimaryDirectionsModule — GR dual-ring (Direct + Converse)
862// =====================================================================
863
864pub mod primary_directions {
865    use super::*;
866
867    /// Direcciones Primarias del Sistema GR (García Rosas): cada
868    /// cuerpo natal se proyecta en dos rings — directa (rotación
869    /// diurna forward) y conversa (rotación inversa). El usuario
870    /// scrubea `target_age_years` para ver el movimiento en vivo.
871    /// Útil para rectificación: un evento real debe coincidir con
872    /// arcos directos y conversos consistentes si la hora natal es
873    /// correcta.
874    pub struct PrimaryDirectionsModule;
875
876    impl Module for PrimaryDirectionsModule {
877        fn id(&self) -> &'static str {
878            "primary_directions"
879        }
880        fn label(&self) -> &'static str {
881            "Direcciones primarias (GR)"
882        }
883        fn description(&self) -> &'static str {
884            "Dual-ring directas + conversas para rectificación en vivo."
885        }
886        fn applies_to(&self, kind: ChartKind) -> bool {
887            matches!(kind, ChartKind::Natal)
888        }
889        fn enabled_by_default(&self) -> bool {
890            false
891        }
892        fn controls(&self) -> Vec<Control> {
893            vec![
894                Control::Toggle {
895                    key: "enabled".into(),
896                    label: "Activar".into(),
897                    default: false,
898                    hotkey: None,
899                },
900                Control::Slider {
901                    key: "target_age_years".into(),
902                    label: "Edad (años)".into(),
903                    min: 0.0,
904                    max: 120.0,
905                    step: 0.05,
906                    default: 30.0,
907                },
908            ]
909        }
910        fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
911            Vec::new()
912        }
913    }
914}