Files
brahman/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
T
sergio 4d14a4495f feat(tahuantinsuyu): fase 5 — overlay de tránsitos (bi-wheel natal × ahora)
Activá el toggle "Tránsitos (ahora)" en el panel (o hotkey [T] sobre
el wheel): la engine computa una segunda NatalChart al instante
SystemTime::now() con el mismo observer y dibuja un anillo externo de
planet glyphs encima del natal, más las cross-aspects entre ambos
charts (sólo mayores). Las líneas cross van del ring de cuerpos
natales al ring externo de tránsitos, con stroke más fino y opacidad
más baja para no taparle el ojo a las aspectos natal-natal.

- engine/bridge.rs: extraídas build_eternal_inputs y
  compute_natal_chart como helpers reutilizables. Nueva
  compute_with_transits(chart, offset, transit_at) que llama
  find_synastry_aspects entre natal y transit (AspectKind::MAJORS).
  Atajo compute_with_transits_at_now usa ESInstant::now(). Las capas
  extra van con module_id = "transit" y LayerKind::Outer /
  LayerKind::Aspects para que el canvas las distinga.
- engine/lib.rs: re-export de compute_with_transits_at_now con el
  mismo fallback al mock cuando feature `eternal-bridge` está off.
- canvas: nueva Radii::transits = 0.82, layout del wheel re-balanceado
  (houses_outer 0.78, houses_inner 0.66, bodies 0.58, aspects 0.50)
  para hacer lugar al anillo externo sin colisiones. paint_wheel:
  detecta layers de transit por module_id, pinta dots + glifos en el
  anillo nuevo + anillos guía sutiles. paint_cross_aspect_line con
  stroke 0.7 entre los dos radios. Glyph overlay para Outer ring con
  alpha 0.9 y font_size más chico que el natal. Hotkey [T] en
  on_key_down toggle LayerKind::Outer.
- modules: NatalModule.controls() agrega toggle show_transits con
  hotkey [T] (default false — no recomputar transits si nadie pidió).
- shell: nuevo show_transits flag. render_current despacha entre
  compute_at_offset y compute_with_transits_at_now según el flag.
  on_panel_event traduce ControlChanged show_transits a flip + redraw.
  on_canvas_event: el toggle de LayerKind::Outer dispara show_transits
  flip + render (no es un visibility toggle puro).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 10:24:36 +00:00

314 lines
9.6 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
// =====================================================================
/// Computa el RenderModel real contra eternal-astrology si el feature
/// está prendido; sino cae al mock.
pub fn compute(chart: &Chart) -> Result<RenderModel, EngineError> {
compute_at_offset(chart, 0)
}
/// Variante con offset temporal en minutos sobre el instante del chart.
/// Útil para time-scrubbing: el jog-dial del canvas pasa el offset
/// acumulado y la engine recompone toda la pipeline (Asc, casas,
/// posiciones planetarias, aspectos) para ese instante desplazado.
pub fn compute_at_offset(chart: &Chart, offset_minutes: i64) -> Result<RenderModel, EngineError> {
#[cfg(feature = "eternal-bridge")]
{
bridge::compute_at_offset(chart, offset_minutes)
}
#[cfg(not(feature = "eternal-bridge"))]
{
let _ = offset_minutes;
Ok(compute_mock(chart))
}
}
/// Variante con overlay de tránsitos al **instante actual** (reloj de
/// pared). Computa la carta natal igual que [`compute_at_offset`] y le
/// suma dos capas extras:
///
/// - `LayerKind::Outer` con `module_id = "transit"` — glifos
/// planetarios del cielo del momento, sobre un anillo externo.
/// - `LayerKind::Aspects` con `module_id = "transit"` — líneas natal ↔
/// transit (sólo aspectos mayores). Por convención, en cada
/// `LineSeg` el `from_deg` es la longitud natal y el `to_deg` la
/// longitud del planeta de tránsito.
///
/// Sin el feature `eternal-bridge` cae al mock (sin overlay).
pub fn compute_with_transits_at_now(
chart: &Chart,
offset_minutes: i64,
) -> Result<RenderModel, EngineError> {
#[cfg(feature = "eternal-bridge")]
{
bridge::compute_with_transits_at_now(chart, offset_minutes)
}
#[cfg(not(feature = "eternal-bridge"))]
{
let _ = offset_minutes;
Ok(compute_mock(chart))
}
}
/// 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));
}
}