Files
brahman/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
T
sergio 1a3bc55016 feat(tahuantinsuyu): fase 9 — Solar Arc como segundo overlay
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>
2026-05-17 10:59:01 +00:00

335 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `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));
}
}