4d14a4495f
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>
314 lines
9.6 KiB
Rust
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));
|
|
}
|
|
}
|