97a6aab883
Quinto módulo overlay. Cuando hay otra carta hermana del mismo
contacto, la sinastría pone las posiciones del partner en el outer
ring + dibuja cross aspects entre las dos personas. Mismo molde que
los overlays anteriores; única novedad: el PipelineRequest transporta
una `Chart` completa porque el partner no es derivable de la natal.
- engine: PipelineRequest::Synastry { partner_chart: Box<Chart> }.
build_synastry_overlay(natal, partner_chart, render) llama
compute_natal_chart sobre el partner y find_synastry_aspects entre
los dos NatalCharts (sólo majors). Layers con module_id="synastry"
y z=10/11. Reusa la helper compute_natal_chart de fase 5.
- modules: synastry::SynastryModule (id "synastry", toggle "Activar"
sin hotkey por ahora). Registry agrega el quinto built-in. Test
pasó a 5 módulos aplicables a ChartKind::Natal.
- shell: build_requests detecta synastry.enabled y llama
find_synastry_partner — busca la primera carta hermana del contacto
actual (mismo contact_id, distinto chart_id). Si no hay hermana,
skip silencioso. Mutual exclusion: al prender transit o synastry
se apaga el otro automáticamente (comparten outer ring) — sincroniza
el toggle del panel + el layer_visibility del canvas.
- canvas: Radii::aspect_endpoints("synastry") devuelve (bodies,
transits) — same slot que transit. Loops del outer ring aceptan
module_id "transit" OR "synastry" (paint_wheel + glyph overlay).
Sin radii nuevo — visualmente comparten el ring 0.82 con transit.
Para probarlo: creá dos cartas en el mismo contacto (ej. el sujeto +
su pareja). Abrí la primera y activá "Sinastría" en el panel. Verás
los planetas del partner en el outer ring + líneas que cruzan al
centro mostrando los aspectos entre las dos personas. Si tenés
transit prendido cuando lo activás, se apaga; al revés también.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
344 lines
11 KiB
Rust
344 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};
|
||
|
||
// `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<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,
|
||
},
|
||
/// `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<Chart>,
|
||
},
|
||
}
|
||
|
||
/// 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));
|
||
}
|
||
}
|