1a3bc55016
Confirma que la arquitectura de fase 6 escala: tres overlays simultáneos
(transit + progression + solar_arc) sin acoplamiento entre módulos, y
sin tocar el flujo del Shell salvo registrar el nuevo branch.
Tres puntos de extensión por overlay nuevo (exactamente los predichos):
1. variante en PipelineRequest
2. helper build_*_overlay en bridge + match arm en compose
3. módulo declarativo en modules/ + registro
- engine: PipelineRequest::SolarArc { target_age_years: f64 } +
build_solar_arc_overlay que llama solar_arc_true(natal, session, age)
→ desplaza uniformemente cada placement y cusp por el arco solar
(default ≈1°/año, vía true progressed Sun). Cross aspects natal ×
dirigida vía find_synastry_aspects(majors). Layers con
module_id="solar_arc" y z=8/9 (sobre todos los demás).
- modules: solar_arc::SolarArcModule con id="solar_arc", toggle
"Activar" + slider target_age_years 0..120. Mismo shape que
ProgressionModule. Registry.with_builtins lo registra. Test pasó a
4 módulos aplicables a ChartKind::Natal.
- canvas: Radii.solar_arc = 0.40 (entre progression 0.48 y aspects),
aspects shrunk a 0.32 para hacer lugar. Helpers Radii::body_ring()
y Radii::aspect_endpoints() ahora reconocen "solar_arc". paint_wheel
itera ambos overlays (progression + solar_arc) para dibujar dots,
glyph overlays y anillos guía sutiles. Loop común `for (id, ring) in
[..]` evita duplicación de código.
- shell: build_requests detecta solar_arc.enabled, agrega request con
edad. apply_selection inicializa target_age_years para ambos
overlays (progression + solar_arc) en current_age + sincroniza los
sliders del panel. Helper module_age_or_current(id) factoriza la
lectura de edad con fallback.
Activando los tres overlays al mismo tiempo el canvas se convierte en
una rueda de cinco anillos: zodíaco (1.00), tránsito (0.82), natal
(0.66-0.78), bodies natal (0.58), progression (0.48), solar arc (0.40),
con líneas de aspectos cross convergiendo desde el ring natal hacia
cada overlay simultáneamente.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
335 lines
11 KiB
Rust
335 lines
11 KiB
Rust
//! `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};
|
||
|
||
#[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<String>,
|
||
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<Layer>,
|
||
}
|
||
|
||
#[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<Glyph>,
|
||
}
|
||
|
||
#[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<f32> },
|
||
Lines(Vec<LineSeg>),
|
||
Points(Vec<PointMark>),
|
||
}
|
||
|
||
#[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<String>,
|
||
#[serde(default)]
|
||
pub retrograde: bool,
|
||
#[serde(default)]
|
||
pub house: Option<u8>,
|
||
}
|
||
|
||
// =====================================================================
|
||
// 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,
|
||
},
|
||
// ── Fase 10 ─────────────────────────────────────────────────────
|
||
// Synastry { partner: tahuantinsuyu_model::ChartId },
|
||
}
|
||
|
||
/// 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<RenderModel, EngineError> {
|
||
#[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<RenderModel, EngineError> {
|
||
compose(chart, 0, &[])
|
||
}
|
||
|
||
/// Atajo: natal con time-scrubbing pero sin overlays.
|
||
pub fn compute_at_offset(chart: &Chart, offset_minutes: i64) -> Result<RenderModel, EngineError> {
|
||
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<RenderModel, EngineError> {
|
||
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));
|
||
}
|
||
}
|