From 82fa370877bc24e088852763087b0a51a4c0a680 Mon Sep 17 00:00:00 2001 From: sergio Date: Sat, 16 May 2026 01:43:11 +0000 Subject: [PATCH] =?UTF-8?q?feat(tahuantinsuyu):=20fase=203=20=E2=80=94=20e?= =?UTF-8?q?ngine=20real=20contra=20eternal=20+=20rueda=20pintada=20en=20GP?= =?UTF-8?q?UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridge a eternal-astrology prendido por default. `engine::compute(chart)` abre una EphemerisSession VSOP2013 (cacheada vía OnceLock global), traduce los Stored* del modelo a BirthData/ChartConfig de eternal, corre NatalChart::compute + find_aspects(modern_western) y devuelve un RenderModel con cuatro capas: SignDial, Houses, Bodies, Aspects. - tahuantinsuyu-engine: bridge.rs nuevo con map_house_system, map_zodiac (incl. 8 ayanamshas), map_body_set, body_symbol, aspect_kind_id. compute_mock se mantiene como fallback sin feature. Errores tipados (EngineError::Eternal). Test real verde con datos natales de demo. - tahuantinsuyu-canvas: rewrite con gpui::canvas() + PathBuilder. Pinta: sectores zodiacales coloreados por elemento (Fire/Earth/Air/ Water), anillos de sign-dial/houses/aspects, cusps zodiacales, cusps de casas (con énfasis para Asc/MC/Desc/IC), líneas radiales hasta el centro para los ejes, líneas de aspectos coloreadas por kind con opacidad por orb, dots de cuerpos. Glifos unicode (♈-♓ signos, ☉-♇ planetas, ☊☋⚷⚸ puntos) como divs absolutos sobre el canvas. Marcador ᴿ cuando retrógrado. Rotación canónica: Asc a las 9, casas crecen contrarreloj. - shell: ahora llama engine::compute() real y reporta errores por stderr sin caer la app. Datos sintetizados: ascendente, MC, descendente, IC; 12 cusps de casa según el sistema configurado; placements de los cuerpos del BodySet con sus longitudes zodiacales, casa y flag retrógrado; aspectos mayores con opacidad proporcional al orb. `cargo check` y `cargo test --features eternal-bridge` verdes. La fase 4 traerá el panel interactivo (jog-dial, toggles, sliders, atajos teclado). Co-Authored-By: Claude Opus 4.7 --- crates/apps/tahuantinsuyu/src/shell.rs | 10 +- .../tahuantinsuyu-canvas/src/lib.rs | 746 ++++++++++++++++-- .../tahuantinsuyu-engine/Cargo.toml | 9 +- .../tahuantinsuyu-engine/src/bridge.rs | 402 ++++++++++ .../tahuantinsuyu-engine/src/lib.rs | 135 ++-- 5 files changed, 1152 insertions(+), 150 deletions(-) create mode 100644 crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs index 4df2918..f574683 100644 --- a/crates/apps/tahuantinsuyu/src/shell.rs +++ b/crates/apps/tahuantinsuyu/src/shell.rs @@ -28,7 +28,7 @@ use gpui::{ use tahuantinsuyu_canvas::{ AstrologyCanvas, CanvasMode, ThumbnailItem, ThumbnailScope, }; -use tahuantinsuyu_engine::compute_mock; +use tahuantinsuyu_engine::compute; use tahuantinsuyu_model::TreeSelection; use tahuantinsuyu_panel::{ControlPanel, PanelEvent}; use tahuantinsuyu_store::Store; @@ -105,7 +105,13 @@ impl Shell { } }; let kind = chart.kind; - let render = compute_mock(&chart); + let render = match compute(&chart) { + Ok(r) => r, + Err(e) => { + eprintln!("[shell] compute {}: {}", id, e); + return; + } + }; self.canvas.update(cx, |c, cx| { c.set_mode( CanvasMode::Wheel { diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs index 88b504f..0807a05 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs @@ -6,30 +6,34 @@ //! (drag, hotkeys, toggles) mutan el estado; el render lee la última //! `RenderModel` y la deriva al frame. //! -//! ## Modos +//! ## Convención de rotación //! -//! - [`CanvasMode::Wheel`] — pinta una carta única (la rueda). -//! - [`CanvasMode::Thumbnails`] — pinta una grilla de mini-cartas -//! cuando el item activo del tree es un Group o Contact. -//! - [`CanvasMode::Empty`] — sin selección. +//! El Ascendente cae a las 9 del reloj (lado izquierdo). Las casas +//! crecen contrarreloj visualmente. Para una longitud eclíptica `L` y +//! un ascendente `asc`: //! -//! ## Fase 1 +//! ```text +//! screen_angle_rad = π - (L - asc) · π/180 (más view_rotation) +//! point = (cx + r·cos(θ), cy + r·sin(θ)) +//! ``` //! -//! Este crate trae el esqueleto: tipos, estado, render placeholder -//! (caja cuadrada con título centrado, eje cardinal y un anillo -//! perfilado). Las interacciones del jog-dial, el árbol Uraniano y la -//! pintura de cada `Layer` vienen en fases siguientes. +//! El `+y` de canvas apunta para abajo, así que `+sin` lleva al sur del +//! lienzo → la convención coincide con el chart estándar (IC abajo, +//! MC arriba). #![forbid(unsafe_code)] #![warn(rust_2018_idioms)] +use std::f32::consts::PI; + use gpui::{ - Context, EventEmitter, IntoElement, Render, SharedString, Window, div, prelude::*, px, + Bounds, Context, EventEmitter, Hsla, IntoElement, ParentElement, PathBuilder, Pixels, Render, + SharedString, Styled, Window, canvas, div, hsla, point, prelude::*, px, }; -use tahuantinsuyu_engine::RenderModel; +use tahuantinsuyu_engine::{Geometry, Layer, LayerKind, RenderModel}; use tahuantinsuyu_model::{ChartId, ContactId, GroupId}; -use tahuantinsuyu_theme::AstroPalette; +use tahuantinsuyu_theme::{AspectKind as TAspectKind, AstroPalette, Element, Planet}; use yahweh_theme::Theme; // ===================================================================== @@ -38,10 +42,7 @@ use yahweh_theme::Theme; #[derive(Clone, Debug)] pub enum CanvasEvent { - /// El usuario hizo doble click sobre un thumbnail o pidió abrir la - /// carta activa. El host (la app) decide si emitir al AppBus. ChartRequested(ChartId), - /// El usuario rotó la rueda de tiempo: minutos de offset acumulados. TimeOffsetChanged(i64), } @@ -49,14 +50,11 @@ pub enum CanvasEvent { // Estado // ===================================================================== -/// Modo de visualización del canvas. #[derive(Clone, Debug, Default)] pub enum CanvasMode { #[default] Empty, - /// Single chart wheel. Wheel { render: Box }, - /// Grilla de thumbnails para un Group o Contact con varias cartas. Thumbnails { scope: ThumbnailScope, items: Vec, @@ -74,25 +72,15 @@ pub struct ThumbnailItem { pub chart_id: ChartId, pub label: SharedString, pub subtitle: Option, - /// `Some` si ya hay un render-mock disponible. `None` = lazy. pub preview: Option, } -/// Estado unificado del canvas. Inspirado en la conversación de Sergio -/// con el agente — todo lo que controla qué se pinta vive acá. #[derive(Clone, Debug, Default)] pub struct CanvasState { pub mode: CanvasMode, - - /// Rotación manual del lienzo en grados. `0.0` = Aries al este. + /// Rotación adicional manual en grados. `0.0` = el Asc cae a las 9. pub view_rotation_deg: f32, - - /// Offset acumulado del time-scrubbing (jog-dial perimetral) en - /// minutos. La engine recalcula la `RenderModel` cuando esto cambia. pub time_offset_minutes: i64, - - /// Capas activas por `module_id`. Si una capa del `RenderModel` - /// pertenece a un módulo no presente aquí, no se pinta. pub active_modules: std::collections::HashSet, } @@ -118,7 +106,6 @@ impl AstrologyCanvas { &self.state } - /// Reemplaza el modo de visualización (lo que se pinta). pub fn set_mode(&mut self, mode: CanvasMode, cx: &mut Context) { self.state.mode = mode; cx.notify(); @@ -141,6 +128,12 @@ impl AstrologyCanvas { // Render // ===================================================================== +/// Tamaño del cuadrado de la rueda en píxeles. Fijo para que glifos y +/// geometría coincidan sin un round-trip de bounds. La rueda se centra +/// en el panel del canvas; el resto del espacio queda como margen. +const WHEEL_SIZE: f32 = 580.0; +const WHEEL_MARGIN: f32 = 28.0; + impl Render for AstrologyCanvas { fn render(&mut self, _w: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = Theme::global(cx).clone(); @@ -148,10 +141,10 @@ impl Render for AstrologyCanvas { let body = match &self.state.mode { CanvasMode::Empty => render_empty(&theme), - CanvasMode::Wheel { render } => render_wheel(&theme, &palette, render), - CanvasMode::Thumbnails { scope: _, items } => { - render_thumbnails(&theme, &palette, items) + CanvasMode::Wheel { render } => { + render_wheel(&theme, &palette, render, self.state.view_rotation_deg) } + CanvasMode::Thumbnails { items, .. } => render_thumbnails(&theme, items), }; div() @@ -165,6 +158,10 @@ impl Render for AstrologyCanvas { } } +// ===================================================================== +// Modos: empty / thumbnails / wheel +// ===================================================================== + fn render_empty(theme: &Theme) -> gpui::Div { div() .flex() @@ -186,57 +183,13 @@ fn render_empty(theme: &Theme) -> gpui::Div { ) } -fn render_wheel(theme: &Theme, palette: &AstroPalette, render: &RenderModel) -> gpui::Div { - // Fase 1: placeholder visual. Una caja cuadrada con el título y un - // contador de capas. El pintado real de los Layer vendrá con - // `gpui::canvas` + matrices en la fase 3. - let _ = palette; // silencia warning hasta la fase 3. - div() - .flex() - .flex_col() - .items_center() - .justify_center() - .gap(px(10.0)) - .child( - div() - .text_size(px(16.0)) - .text_color(theme.fg_text) - .child(SharedString::from(render.title.clone())), - ) - .child( - div() - .text_size(px(11.0)) - .text_color(theme.fg_muted) - .child(SharedString::from(format!( - "{} capa(s) · {} ms", - render.layers.len(), - render.compute_ms - ))), - ) - .child( - // Marco cuadrado provisional — el render real lo ocupará. - div() - .size(px(480.0)) - .rounded(px(8.0)) - .border_1() - .border_color(theme.border_strong) - .bg(theme.bg_panel_alt.clone()), - ) -} - -fn render_thumbnails( - theme: &Theme, - _palette: &AstroPalette, - items: &[ThumbnailItem], -) -> gpui::Div { +fn render_thumbnails(theme: &Theme, items: &[ThumbnailItem]) -> gpui::Div { if items.is_empty() { return div() .text_size(px(12.0)) .text_color(theme.fg_muted) .child("Sin cartas en este grupo todavía."); } - // Grid simple en flex-wrap. La fase 3 lo reemplaza por miniaturas - // pintadas con la rueda en miniatura. let mut row = div().flex().flex_row().flex_wrap().gap(px(12.0)); for it in items { row = row.child( @@ -262,3 +215,638 @@ fn render_thumbnails( } row } + +// ===================================================================== +// Wheel +// ===================================================================== + +fn render_wheel( + theme: &Theme, + palette: &AstroPalette, + render: &RenderModel, + view_rotation_deg: f32, +) -> gpui::Div { + let asc = render.ascendant_deg; + let rot_offset = view_rotation_deg; + let cx_center = WHEEL_SIZE / 2.0; + let cy_center = WHEEL_SIZE / 2.0; + let r_outer = (WHEEL_SIZE - WHEEL_MARGIN * 2.0) / 2.0; + + let radii = Radii::from_outer(r_outer); + + // --- Canvas element con todo el trazo --- + let palette_paint = palette.clone(); + let theme_paint = theme.clone(); + let layers_paint: Vec = render.layers.clone(); + let asc_for_paint = asc; + let mc_for_paint = render.midheaven_deg; + let canvas_element = canvas( + move |_b: Bounds, _w, _cx| (), + move |bounds: Bounds, _, window, _| { + paint_wheel( + bounds, + window, + &theme_paint, + &palette_paint, + &layers_paint, + asc_for_paint, + mc_for_paint, + rot_offset, + radii, + ); + }, + ) + .absolute() + .w(px(WHEEL_SIZE)) + .h(px(WHEEL_SIZE)); + + // --- Glyphs como divs absolutos (text-rendering nativo) --- + let mut wheel = div() + .relative() + .w(px(WHEEL_SIZE)) + .h(px(WHEEL_SIZE)) + .child(canvas_element); + + // Sign glyphs en el centro de cada sector zodiacal. + let sign_ring_mid = (radii.sign_outer + radii.sign_inner) / 2.0; + for layer in &render.layers { + if matches!(layer.kind, LayerKind::SignDial) { + for g in &layer.glyphs { + let (x, y) = polar_to_screen(g.deg, asc, rot_offset, sign_ring_mid); + let color = element_color_for_sign(palette, &g.symbol); + wheel = wheel.child(centered_glyph( + cx_center + x, + cy_center + y, + 20.0, + 18.0, + sign_unicode(&g.symbol).into(), + color, + )); + } + } + } + + // House numbers cerca de cada cusp. + let house_label_r = (radii.houses_outer + radii.houses_inner) / 2.0; + for layer in &render.layers { + if matches!(layer.kind, LayerKind::Houses) { + for g in &layer.glyphs { + let (x, y) = polar_to_screen(g.deg, asc, rot_offset, house_label_r); + if let Some(h) = g.house { + wheel = wheel.child(centered_glyph( + cx_center + x, + cy_center + y, + 16.0, + 10.0, + format!("{}", h).into(), + palette.house_cusp, + )); + } + } + } + } + + // Planet glyphs sobre el ring de cuerpos. + for layer in &render.layers { + if matches!(layer.kind, LayerKind::Bodies) { + for g in &layer.glyphs { + let (x, y) = polar_to_screen(g.deg, asc, rot_offset, radii.bodies); + let color = planet_color(palette, &g.symbol); + let glyph_text = if g.retrograde { + format!("{}ᴿ", planet_unicode(&g.symbol)) + } else { + planet_unicode(&g.symbol).into() + }; + wheel = wheel.child(centered_glyph( + cx_center + x, + cy_center + y, + 24.0, + 18.0, + glyph_text.into(), + color, + )); + } + } + } + + // --- Composición final con título arriba --- + let header = div() + .flex() + .flex_col() + .items_center() + .gap(px(2.0)) + .child( + div() + .text_size(px(16.0)) + .text_color(theme.fg_text) + .child(SharedString::from(render.title.clone())), + ); + let header = if let Some(sub) = &render.subtitle { + header.child( + div() + .text_size(px(11.0)) + .text_color(theme.fg_muted) + .child(SharedString::from(sub.clone())), + ) + } else { + header + }; + let footer = div() + .text_size(px(10.0)) + .text_color(theme.fg_disabled) + .child(SharedString::from(format!( + "Asc {:.1}° MC {:.1}° · {} capas · {} ms", + render.ascendant_deg, + render.midheaven_deg, + render.layers.len(), + render.compute_ms, + ))); + + div() + .flex() + .flex_col() + .items_center() + .gap(px(8.0)) + .child(header) + .child(wheel) + .child(footer) +} + +// ===================================================================== +// Painting +// ===================================================================== + +#[derive(Clone, Copy)] +struct Radii { + sign_outer: f32, + sign_inner: f32, + houses_outer: f32, + houses_inner: f32, + bodies: f32, + aspects: f32, +} + +impl Radii { + fn from_outer(r: f32) -> Self { + Self { + sign_outer: r, + sign_inner: r * 0.88, + houses_outer: r * 0.86, + houses_inner: r * 0.72, + bodies: r * 0.65, + aspects: r * 0.58, + } + } +} + +#[allow(clippy::too_many_arguments)] +fn paint_wheel( + bounds: Bounds, + window: &mut Window, + theme: &Theme, + palette: &AstroPalette, + layers: &[Layer], + ascendant_deg: f32, + midheaven_deg: f32, + rot_offset_deg: f32, + radii: Radii, +) { + let ox: f32 = bounds.origin.x.into(); + let oy: f32 = bounds.origin.y.into(); + let bw: f32 = bounds.size.width.into(); + let bh: f32 = bounds.size.height.into(); + let cx = ox + bw / 2.0; + let cy = oy + bh / 2.0; + + // 1. Sectores del zodíaco coloreados por elemento. + paint_sign_sectors(window, cx, cy, &radii, palette, ascendant_deg, rot_offset_deg); + + // 2. Anillos (outer + inner del sign dial, houses outer, body outer). + stroke_circle(window, cx, cy, radii.sign_outer, 1.5, palette.dial_ring); + stroke_circle(window, cx, cy, radii.sign_inner, 1.0, palette.dial_ring); + stroke_circle( + window, + cx, + cy, + radii.houses_inner, + 0.8, + with_alpha(palette.house_cusp, 0.6), + ); + + // 3. Líneas de cusp del zodíaco (cada 30° desde Aries 0°). + for i in 0..12 { + let lon = (i as f32) * 30.0; + let color = if i == 0 { + palette.angle_highlight + } else { + palette.dial_ring + }; + paint_radial_line( + window, + cx, + cy, + lon, + ascendant_deg, + rot_offset_deg, + radii.sign_inner, + radii.sign_outer, + color, + 1.0, + ); + } + + // 4. Casas: cusps radiales + énfasis Asc / MC. + for layer in layers { + if matches!(layer.kind, LayerKind::Houses) { + if let Geometry::Ring { cusps_deg } = &layer.geometry { + for (i, c) in cusps_deg.iter().enumerate() { + let is_angle = i == 0 || i == 3 || i == 6 || i == 9; + let color = if is_angle { + palette.angle_highlight + } else { + with_alpha(palette.house_cusp, 0.7) + }; + let width = if is_angle { 2.0 } else { 0.8 }; + paint_radial_line( + window, + cx, + cy, + *c, + ascendant_deg, + rot_offset_deg, + radii.houses_inner, + radii.houses_outer, + color, + width, + ); + } + } + } + } + + // 5. Énfasis Asc + MC extendido hasta el centro (línea fina). + paint_radial_line( + window, + cx, + cy, + ascendant_deg, + ascendant_deg, + rot_offset_deg, + 0.0, + radii.houses_outer, + with_alpha(palette.angle_highlight, 0.35), + 1.0, + ); + paint_radial_line( + window, + cx, + cy, + midheaven_deg, + ascendant_deg, + rot_offset_deg, + 0.0, + radii.houses_outer, + with_alpha(palette.angle_highlight, 0.35), + 1.0, + ); + + // 6. Aspectos. + for layer in layers { + if matches!(layer.kind, LayerKind::Aspects) { + if let Geometry::Lines(segs) = &layer.geometry { + for seg in segs { + let color = aspect_color(palette, &seg.kind); + let color = with_alpha(color, color.a * seg.opacity); + paint_aspect_line( + window, + cx, + cy, + seg.from_deg, + seg.to_deg, + ascendant_deg, + rot_offset_deg, + radii.aspects, + color, + ); + } + } + } + } + + // 7. Cuerpos: pequeño dot detrás del glifo. + let dot_r = (radii.sign_outer * 0.018).max(2.0); + for layer in layers { + if matches!(layer.kind, LayerKind::Bodies) { + for g in &layer.glyphs { + let color = planet_color(palette, &g.symbol); + let (x, y) = polar_to_screen(g.deg, ascendant_deg, rot_offset_deg, radii.bodies); + fill_circle(window, cx + x, cy + y, dot_r, color); + } + } + } + + // 8. Marco exterior del lienzo (sutil). + let _ = theme; +} + +fn paint_sign_sectors( + window: &mut Window, + cx: f32, + cy: f32, + radii: &Radii, + palette: &AstroPalette, + ascendant_deg: f32, + rot_offset_deg: f32, +) { + // Cada sector cubre 30° de longitud zodiacal entre `sign_inner` y + // `sign_outer`. Lo aproximamos con polígonos para no depender de + // `arc_to` (que requiere `Vector`; los polígonos son + // suficientemente suaves a este radio). + const SUBDIVISIONS: usize = 18; + for i in 0..12 { + let lon_start = (i as f32) * 30.0; + let lon_end = lon_start + 30.0; + let element = sign_element_by_index(i); + let color = with_alpha(palette.element(element), 0.10); + + let mut builder = PathBuilder::fill(); + let (x0, y0) = polar_to_screen(lon_start, ascendant_deg, rot_offset_deg, radii.sign_inner); + builder.move_to(point(px(cx + x0), px(cy + y0))); + + // Borde interno (lon_start → lon_end), N subdivisiones. + for k in 1..=SUBDIVISIONS { + let t = lon_start + (lon_end - lon_start) * (k as f32) / (SUBDIVISIONS as f32); + let (x, y) = polar_to_screen(t, ascendant_deg, rot_offset_deg, radii.sign_inner); + builder.line_to(point(px(cx + x), px(cy + y))); + } + // Salto al borde externo en lon_end. + let (xe, ye) = polar_to_screen(lon_end, ascendant_deg, rot_offset_deg, radii.sign_outer); + builder.line_to(point(px(cx + xe), px(cy + ye))); + + // Borde externo de lon_end → lon_start (al revés). + for k in (0..SUBDIVISIONS).rev() { + let t = lon_start + (lon_end - lon_start) * (k as f32) / (SUBDIVISIONS as f32); + let (x, y) = polar_to_screen(t, ascendant_deg, rot_offset_deg, radii.sign_outer); + builder.line_to(point(px(cx + x), px(cy + y))); + } + builder.close(); + if let Ok(path) = builder.build() { + window.paint_path(path, color); + } + } +} + +fn stroke_circle( + window: &mut Window, + cx: f32, + cy: f32, + r: f32, + width: f32, + color: Hsla, +) { + const SEGMENTS: usize = 96; + let mut builder = PathBuilder::stroke(px(width)); + for i in 0..=SEGMENTS { + let t = (i as f32) / (SEGMENTS as f32) * (2.0 * PI); + let x = cx + r * t.cos(); + let y = cy + r * t.sin(); + if i == 0 { + builder.move_to(point(px(x), px(y))); + } else { + builder.line_to(point(px(x), px(y))); + } + } + if let Ok(path) = builder.build() { + window.paint_path(path, color); + } +} + +fn fill_circle(window: &mut Window, cx: f32, cy: f32, r: f32, color: Hsla) { + const SEGMENTS: usize = 32; + let mut builder = PathBuilder::fill(); + builder.move_to(point(px(cx + r), px(cy))); + for i in 1..=SEGMENTS { + let t = (i as f32) / (SEGMENTS as f32) * (2.0 * PI); + let x = cx + r * t.cos(); + let y = cy + r * t.sin(); + builder.line_to(point(px(x), px(y))); + } + builder.close(); + if let Ok(path) = builder.build() { + window.paint_path(path, color); + } +} + +#[allow(clippy::too_many_arguments)] +fn paint_radial_line( + window: &mut Window, + cx: f32, + cy: f32, + longitude_deg: f32, + ascendant_deg: f32, + rot_offset_deg: f32, + r_inner: f32, + r_outer: f32, + color: Hsla, + width: f32, +) { + let (xi, yi) = polar_to_screen(longitude_deg, ascendant_deg, rot_offset_deg, r_inner); + let (xo, yo) = polar_to_screen(longitude_deg, ascendant_deg, rot_offset_deg, r_outer); + let mut builder = PathBuilder::stroke(px(width)); + builder.move_to(point(px(cx + xi), px(cy + yi))); + builder.line_to(point(px(cx + xo), px(cy + yo))); + if let Ok(path) = builder.build() { + window.paint_path(path, color); + } +} + +#[allow(clippy::too_many_arguments)] +fn paint_aspect_line( + window: &mut Window, + cx: f32, + cy: f32, + a_deg: f32, + b_deg: f32, + ascendant_deg: f32, + rot_offset_deg: f32, + r: f32, + color: Hsla, +) { + let (xa, ya) = polar_to_screen(a_deg, ascendant_deg, rot_offset_deg, r); + let (xb, yb) = polar_to_screen(b_deg, ascendant_deg, rot_offset_deg, r); + let mut builder = PathBuilder::stroke(px(1.0)); + builder.move_to(point(px(cx + xa), px(cy + ya))); + builder.line_to(point(px(cx + xb), px(cy + yb))); + if let Ok(path) = builder.build() { + window.paint_path(path, color); + } +} + +// ===================================================================== +// Geometry helpers +// ===================================================================== + +/// Mapea una longitud eclíptica + ascendente + rotación adicional → (x, y) +/// **relativos al centro del lienzo** (positivo hacia derecha/abajo). +fn polar_to_screen( + longitude_deg: f32, + ascendant_deg: f32, + rot_offset_deg: f32, + radius: f32, +) -> (f32, f32) { + // Convención: el Asc cae a las 9 (θ=π). A más longitud, más + // contrarreloj visual → θ decrece. + let deg = 180.0 - (longitude_deg - ascendant_deg + rot_offset_deg); + let rad = deg * PI / 180.0; + (radius * rad.cos(), radius * rad.sin()) +} + +fn centered_glyph( + x: f32, + y: f32, + box_size: f32, + font_size: f32, + text: SharedString, + color: Hsla, +) -> gpui::Div { + div() + .absolute() + .left(px(x - box_size / 2.0)) + .top(px(y - box_size / 2.0)) + .w(px(box_size)) + .h(px(box_size)) + .flex() + .items_center() + .justify_center() + .text_size(px(font_size)) + .text_color(color) + .child(text) +} + +fn with_alpha(c: Hsla, a: f32) -> Hsla { + hsla(c.h, c.s, c.l, a.clamp(0.0, 1.0)) +} + +// ===================================================================== +// Symbol → unicode / theme +// ===================================================================== + +fn sign_unicode(name: &str) -> &'static str { + match name { + "aries" => "♈", + "taurus" => "♉", + "gemini" => "♊", + "cancer" => "♋", + "leo" => "♌", + "virgo" => "♍", + "libra" => "♎", + "scorpio" => "♏", + "sagittarius" => "♐", + "capricorn" => "♑", + "aquarius" => "♒", + "pisces" => "♓", + _ => "?", + } +} + +fn planet_unicode(name: &str) -> &'static str { + match name { + "sun" => "☉", + "moon" => "☽", + "mercury" => "☿", + "venus" => "♀", + "mars" => "♂", + "jupiter" => "♃", + "saturn" => "♄", + "uranus" => "♅", + "neptune" => "♆", + "pluto" => "♇", + "north_node" => "☊", + "south_node" => "☋", + "chiron" => "⚷", + "lilith" => "⚸", + "ceres" => "⚳", + "pallas" => "⚴", + "juno" => "⚵", + "vesta" => "⚶", + _ => "•", + } +} + +fn planet_color(p: &AstroPalette, name: &str) -> Hsla { + let planet = match name { + "sun" => Planet::Sun, + "moon" => Planet::Moon, + "mercury" => Planet::Mercury, + "venus" => Planet::Venus, + "mars" => Planet::Mars, + "jupiter" => Planet::Jupiter, + "saturn" => Planet::Saturn, + "uranus" => Planet::Uranus, + "neptune" => Planet::Neptune, + "pluto" => Planet::Pluto, + "chiron" => Planet::Chiron, + "north_node" => Planet::NorthNode, + "south_node" => Planet::SouthNode, + "lilith" => Planet::Lilith, + _ => return p.fg_text_fallback(), + }; + p.planet(planet) +} + +fn sign_element_by_index(i: usize) -> Element { + match i % 4 { + 0 => Element::Fire, + 1 => Element::Earth, + 2 => Element::Air, + _ => Element::Water, + } +} + +fn element_color_for_sign(p: &AstroPalette, name: &str) -> Hsla { + let elem = match name { + "aries" | "leo" | "sagittarius" => Element::Fire, + "taurus" | "virgo" | "capricorn" => Element::Earth, + "gemini" | "libra" | "aquarius" => Element::Air, + "cancer" | "scorpio" | "pisces" => Element::Water, + _ => return p.fg_text_fallback(), + }; + p.element(elem) +} + +fn aspect_color(p: &AstroPalette, kind: &str) -> Hsla { + let k = match kind { + "conjunction" => TAspectKind::Conjunction, + "opposition" => TAspectKind::Opposition, + "trine" => TAspectKind::Trine, + "square" => TAspectKind::Square, + "sextile" => TAspectKind::Sextile, + "quincunx" => TAspectKind::Quincunx, + "semi_sextile" => TAspectKind::Semisextile, + "semi_square" => TAspectKind::Semisquare, + "sesquiquadrate" => TAspectKind::Sesquisquare, + "quintile" => TAspectKind::Quintile, + "biquintile" => TAspectKind::Biquintile, + _ => return p.minor_aspect, + }; + p.aspect(k) +} + +// ===================================================================== +// Adendum: fallback color cuando la paleta no tiene match +// ===================================================================== + +impl AstroPaletteExt for AstroPalette { + fn fg_text_fallback(&self) -> Hsla { + if self.is_dark { + hsla(0.0, 0.0, 0.85, 1.0) + } else { + hsla(0.0, 0.0, 0.25, 1.0) + } + } +} + +trait AstroPaletteExt { + fn fg_text_fallback(&self) -> Hsla; +} + diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/Cargo.toml b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/Cargo.toml index 4940e95..0690997 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/Cargo.toml +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/Cargo.toml @@ -23,8 +23,9 @@ path = "../../../../../eternal/eternal-sky" optional = true [features] -default = [] -# Activa el bridge real contra eternal-astrology. Sin este feature, la -# engine sólo expone el RenderModel y mocks — útil para tests y para -# compilar la UI antes de que eternal esté disponible. +# El bridge real contra eternal-astrology está prendido por default +# porque la app sin eternal no muestra cartas reales. Si necesitás +# compilar sin eternal checked out (CI, builds aisladas), `--no-default-features` +# lo apaga y `compute()` cae a `compute_mock()`. +default = ["eternal-bridge"] eternal-bridge = ["dep:eternal-astrology", "dep:eternal-sky"] diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs new file mode 100644 index 0000000..2ac3c93 --- /dev/null +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs @@ -0,0 +1,402 @@ +//! Bridge real: `tahuantinsuyu_model::Chart` → eternal_astrology → [`RenderModel`]. +//! +//! La sesión de efemérides VSOP2013 es **compartida globalmente** vía +//! `OnceLock` — abrirla cuesta unos cuantos ms (carga de las series en +//! memoria), y como es read-only se puede leer en paralelo desde varios +//! cómputos. + +use std::sync::OnceLock; +use std::time::Instant; + +use eternal_astrology::{ + find_aspects, Aspect, AspectKind as EAspectKind, BirthData, BodySet, ChartConfig, + HouseSystem as EHouseSystem, NatalChart, OrbTable, Zodiac as EZodiac, +}; +use eternal_sky::{Ayanamsha, Body, EphemerisSession, Instant as ESInstant, Observer, SessionConfig}; + +use tahuantinsuyu_model::{Chart, HouseSystem, StoredChartConfig, Zodiac}; + +use crate::{EngineError, Geometry, Glyph, Layer, LayerKind, LineSeg, RenderModel}; + +// ===================================================================== +// Sesión global cacheada +// ===================================================================== + +static SESSION: OnceLock = OnceLock::new(); + +fn session() -> Result<&'static EphemerisSession, EngineError> { + if let Some(s) = SESSION.get() { + return Ok(s); + } + let opened = EphemerisSession::open(SessionConfig::vsop2013()) + .map_err(|e| EngineError::Eternal(format!("EphemerisSession::open: {:?}", e)))?; + // Si otro thread ya pobló la celda mientras abríamos, el set_once + // falla silenciosamente — usamos el que quedó dentro. + let _ = SESSION.set(opened); + Ok(SESSION.get().expect("session was just set")) +} + +// ===================================================================== +// Traducciones Stored* → eternal +// ===================================================================== + +fn map_house_system(h: HouseSystem) -> EHouseSystem { + match h { + HouseSystem::Placidus => EHouseSystem::Placidus, + HouseSystem::Koch => EHouseSystem::Koch, + HouseSystem::Regiomontanus => EHouseSystem::Regiomontanus, + HouseSystem::Campanus => EHouseSystem::Campanus, + HouseSystem::Porphyry => EHouseSystem::Porphyry, + HouseSystem::Equal => EHouseSystem::Equal, + HouseSystem::WholeSign => EHouseSystem::WholeSign, + } +} + +fn map_zodiac(z: Zodiac, ayanamsha_hint: Option<&str>) -> EZodiac { + match z { + Zodiac::Tropical => EZodiac::Tropical, + Zodiac::Sidereal => { + let mode = match ayanamsha_hint.unwrap_or("lahiri").to_ascii_lowercase().as_str() { + "fagan_bradley" | "fagan-bradley" | "faganbradley" => Ayanamsha::FaganBradley, + "raman" => Ayanamsha::Raman, + "krishnamurti" => Ayanamsha::Krishnamurti, + "de_luce" | "deluce" => Ayanamsha::DeLuce, + "djwhal_khul" | "djwhalkhul" => Ayanamsha::DjwhalKhul, + "ushashashi" => Ayanamsha::Ushashashi, + "yukteshwar" => Ayanamsha::Yukteshwar, + _ => Ayanamsha::Lahiri, + }; + EZodiac::Sidereal(mode) + } + // Dracónico aún no soportado en eternal — caemos a tropical por + // ahora; cuando eternal lo agregue, lo cableamos acá. + Zodiac::Draconic => EZodiac::Tropical, + } +} + +fn map_body_set(cfg: &StoredChartConfig) -> BodySet { + let mut bodies: Vec = Vec::new(); + for name in &cfg.bodies { + if let Some(b) = map_body(name) { + bodies.push(b); + } + } + if bodies.is_empty() { + // Default razonable si el config vino vacío. + return BodySet::classical_modern(); + } + let mut set = BodySet { + bodies, + include_south_node: cfg.include_south_node, + }; + if cfg.include_lilith { + set = set.with_lilith(); + } + if cfg.include_main_belt_asteroids { + set = set.with_main_belt_asteroids(); + } + set +} + +fn map_body(name: &str) -> Option { + Some(match name.to_ascii_lowercase().as_str() { + "sun" => Body::Sun, + "moon" => Body::Moon, + "mercury" => Body::Mercury, + "venus" => Body::Venus, + "mars" => Body::Mars, + "jupiter" => Body::Jupiter, + "saturn" => Body::Saturn, + "uranus" => Body::Uranus, + "neptune" => Body::Neptune, + "pluto" => Body::Pluto, + "mean_node" | "meannode" => Body::MeanNode, + "true_node" | "truenode" => Body::TrueNode, + "mean_lilith" | "lilith" => Body::MeanLilith, + "true_lilith" => Body::TrueLilith, + "ceres" => Body::Ceres, + "pallas" => Body::Pallas, + "juno" => Body::Juno, + "vesta" => Body::Vesta, + _ => return None, + }) +} + +fn body_symbol(b: Body) -> &'static str { + match b { + Body::Sun => "sun", + Body::Moon => "moon", + Body::Mercury => "mercury", + Body::Venus => "venus", + Body::Mars => "mars", + Body::Jupiter => "jupiter", + Body::Saturn => "saturn", + Body::Uranus => "uranus", + Body::Neptune => "neptune", + Body::Pluto => "pluto", + Body::MeanNode => "north_node", + Body::TrueNode => "north_node", + Body::MeanLilith => "lilith", + Body::TrueLilith => "lilith", + Body::Ceres => "ceres", + Body::Pallas => "pallas", + Body::Juno => "juno", + Body::Vesta => "vesta", + Body::Chiron => "chiron", + Body::Pholus => "chiron", + Body::Eris => "chiron", + Body::Sedna => "chiron", + // `Body` es `#[non_exhaustive]` — cualquier cuerpo nuevo + // upstream cae al símbolo de fallback hasta que lo cableemos. + _ => "custom", + } +} + +fn aspect_kind_id(k: EAspectKind) -> &'static str { + match k { + EAspectKind::Conjunction => "conjunction", + EAspectKind::Opposition => "opposition", + EAspectKind::Trine => "trine", + EAspectKind::Square => "square", + EAspectKind::Sextile => "sextile", + EAspectKind::Quincunx => "quincunx", + EAspectKind::SemiSextile => "semi_sextile", + EAspectKind::SemiSquare => "semi_square", + EAspectKind::Sesquiquadrate => "sesquiquadrate", + EAspectKind::Quintile => "quintile", + EAspectKind::BiQuintile => "biquintile", + EAspectKind::Septile => "septile", + } +} + +// ===================================================================== +// compute() +// ===================================================================== + +pub fn compute(chart: &Chart) -> Result { + let t0 = Instant::now(); + chart.validate()?; + + let bd = &chart.birth_data; + let instant = ESInstant::from_civil_local( + bd.year, + u8::try_from(bd.month).map_err(|_| { + EngineError::Eternal(format!("mes fuera de u8: {}", bd.month)) + })?, + u8::try_from(bd.day).map_err(|_| { + EngineError::Eternal(format!("día fuera de u8: {}", bd.day)) + })?, + u8::try_from(bd.hour).map_err(|_| { + EngineError::Eternal(format!("hora fuera de u8: {}", bd.hour)) + })?, + u8::try_from(bd.minute).map_err(|_| { + EngineError::Eternal(format!("minuto fuera de u8: {}", bd.minute)) + })?, + bd.second, + bd.tz_offset_minutes, + ) + .map_err(|e| EngineError::Eternal(format!("Instant::from_civil_local: {:?}", e)))?; + + let observer = Observer::from_degrees(bd.latitude_deg, bd.longitude_deg, bd.altitude_m); + + let mut birth_e = BirthData::new(instant, observer); + if let Some(name) = &bd.subject_name { + birth_e = birth_e.with_name(name.clone()); + } + + let config_e = ChartConfig { + house_system: map_house_system(chart.config.house_system), + zodiac: map_zodiac(chart.config.zodiac, chart.config.ayanamsha.as_deref()), + bodies: map_body_set(&chart.config), + include_horizon: false, + }; + + let session = session()?; + let natal = NatalChart::compute(&birth_e, &config_e, session) + .map_err(|e| EngineError::Eternal(format!("NatalChart::compute: {:?}", e)))?; + + let aspects = find_aspects(&natal, &OrbTable::modern_western()); + + let render = build_render_model(chart, &natal, &aspects, t0); + Ok(render) +} + +// ===================================================================== +// NatalChart → RenderModel +// ===================================================================== + +fn build_render_model( + chart: &Chart, + natal: &NatalChart, + aspects: &[Aspect], + started: Instant, +) -> RenderModel { + let ascendant_deg = natal.ascendant().longitude_deg() as f32; + let midheaven_deg = natal.midheaven().longitude_deg() as f32; + let descendant_deg = natal.descendant().longitude_deg() as f32; + let imum_coeli_deg = natal.imum_coeli().longitude_deg() as f32; + + // ─── Capa 0: Sign Dial ──────────────────────────────────────────── + 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_SYMBOLS[i].into(), + annotation: None, + retrograde: false, + house: None, + }) + .collect(), + }; + + // ─── Capa 1: Houses ─────────────────────────────────────────────── + let cusps_deg: Vec = natal + .houses + .cusps + .iter() + .map(|c| c.to_degrees() as f32) + .collect(); + let houses = Layer { + module_id: "natal".into(), + kind: LayerKind::Houses, + ring: 0.86, + z: 1, + geometry: Geometry::Ring { + cusps_deg: cusps_deg.clone(), + }, + glyphs: cusps_deg + .iter() + .enumerate() + .map(|(i, c)| Glyph { + deg: *c + 4.0, + symbol: format!("h{}", i + 1), + annotation: None, + retrograde: false, + house: Some((i as u8) + 1), + }) + .collect(), + }; + + // ─── Capa 2: Bodies ─────────────────────────────────────────────── + let body_glyphs: Vec = natal + .placements + .iter() + .map(|p| Glyph { + deg: p.longitude.longitude_deg() as f32, + symbol: body_symbol(p.body).into(), + annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())), + retrograde: p.is_retrograde(), + house: Some(p.house_number), + }) + .collect(); + let bodies = Layer { + module_id: "natal".into(), + kind: LayerKind::Bodies, + ring: 0.72, + z: 2, + geometry: Geometry::Points( + natal + .placements + .iter() + .map(|p| crate::PointMark { + deg: p.longitude.longitude_deg() as f32, + label: p.body.name().into(), + tag: body_symbol(p.body).into(), + }) + .collect(), + ), + glyphs: body_glyphs, + }; + + // ─── Capa 3: Aspects ────────────────────────────────────────────── + let mut aspect_lines: Vec = Vec::with_capacity(aspects.len()); + for a in aspects { + // Solo los aspectos mayores se pintan en este pase — los menores + // saturan visualmente. Fase 4 pondrá un toggle para mostrarlos. + if !EAspectKind::MAJORS.contains(&a.kind) { + continue; + } + let pa = natal.placement(a.a); + let pb = natal.placement(a.b); + if let (Some(pa), Some(pb)) = (pa, pb) { + let opacity = orb_to_opacity(a.orb_abs_deg(), a.kind); + aspect_lines.push(LineSeg { + from_deg: pa.longitude.longitude_deg() as f32, + to_deg: pb.longitude.longitude_deg() as f32, + kind: aspect_kind_id(a.kind).into(), + opacity, + }); + } + } + let aspects_layer = Layer { + module_id: "natal".into(), + kind: LayerKind::Aspects, + ring: 0.58, + z: 3, + geometry: Geometry::Lines(aspect_lines), + glyphs: Vec::new(), + }; + + let subtitle = chart + .birth_data + .birthplace_label + .clone() + .or_else(|| { + Some(format!( + "{:04}-{:02}-{:02} · lat {:+.2}° · lon {:+.2}°", + chart.birth_data.year, + chart.birth_data.month, + chart.birth_data.day, + chart.birth_data.latitude_deg, + chart.birth_data.longitude_deg, + )) + }); + + RenderModel { + chart_id: chart.id, + chart_kind: chart.kind, + title: chart.label.clone(), + subtitle, + compute_ms: started.elapsed().as_millis() as u64, + ascendant_deg, + midheaven_deg, + descendant_deg, + imum_coeli_deg, + layers: vec![sign_dial, houses, bodies, aspects_layer], + } +} + +/// Mapea el orb absoluto a una opacidad — los aspectos más exactos se +/// pintan más fuerte, los flojos casi se desvanecen. +fn orb_to_opacity(orb_deg: f64, kind: EAspectKind) -> f32 { + let max = match kind { + EAspectKind::Conjunction | EAspectKind::Opposition => 8.0, + EAspectKind::Trine | EAspectKind::Square => 7.0, + EAspectKind::Sextile => 5.0, + _ => 3.0, + }; + let t = (1.0 - (orb_deg / max).min(1.0)).max(0.25); + t as f32 +} + +const ZODIAC_SYMBOLS: [&str; 12] = [ + "aries", + "taurus", + "gemini", + "cancer", + "leo", + "virgo", + "libra", + "scorpio", + "sagittarius", + "capricorn", + "aquarius", + "pisces", +]; diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs index 51522a7..19be94f 100644 --- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs +++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs @@ -17,11 +17,10 @@ //! //! ## Feature `eternal-bridge` //! -//! - **off** (default): la engine sólo expone los tipos `RenderModel`, -//! `Layer`, `Glyph`, etc. y un `compute_mock()` con un disco de -//! prueba. Útil para la UI antes de que `eternal-astrology` compile. -//! - **on**: agrega `compute(chart) -> RenderModel` con la pipeline -//! real. +//! - **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)] @@ -31,41 +30,45 @@ use thiserror::Error; pub use tahuantinsuyu_model::{Chart, ChartId, ChartKind}; +#[cfg(feature = "eternal-bridge")] +mod bridge; + // ===================================================================== -// RenderModel — lo que el canvas necesita pintar una capa +// RenderModel — lo que el canvas necesita pintar // ===================================================================== /// Resultado agnóstico de un cómputo astrológico, listo para renderizar. -/// Cada `Layer` es independiente — el canvas las apila por z-order. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RenderModel { - /// Identidad estable de la carta a la que pertenece este render. pub chart_id: ChartId, - /// Kind original — el canvas lo usa para títulos y ornamentos. pub chart_kind: ChartKind, + pub title: String, + #[serde(default)] + pub subtitle: Option, + 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, - /// Texto humano-legible breve. Ej. "Sergio · 14 mar 1987 · Caracas". - pub title: String, - /// Tiempo de cómputo en ms — métrica para diagnóstico. - pub compute_ms: u64, } -/// Una capa visual. Cada módulo de astrología publica una o varias. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Layer { - /// Identidad estable del módulo emisor ("natal", "transit", "uranian"). pub module_id: String, - /// Tipo de capa — controla cómo se compone con vecinas. pub kind: LayerKind, - /// Radio normalizado [0, 1] sobre el lienzo. Permite stack de anillos. + /// Radio normalizado [0, 1] sobre el lienzo — el canvas lo convierte + /// a píxeles. Permite stack de anillos. pub ring: f32, - /// Z-order absoluto (más alto = encima). Default 0. #[serde(default)] pub z: i32, - /// Geometría: puntos, arcos, líneas. pub geometry: Geometry, - /// Glifos simbólicos sobre la geometría (planetas, signos, casas). #[serde(default)] pub glyphs: Vec, } @@ -73,51 +76,36 @@ pub struct Layer { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum LayerKind { - /// El anillo zodiacal de fondo (12 signos). SignDial, - /// Las 12 cusps de casas + cuadrantes. Houses, - /// Los planetas / cuerpos en sus posiciones. Bodies, - /// Líneas de aspecto entre cuerpos. Aspects, - /// Puntos arábigos / lots. Lots, - /// Estrellas fijas como overlay. FixedStars, - /// Puntos medios y simetría Uraniana. Midpoints, - /// Anillo externo de tránsitos / progresiones / direcciones. Outer, - /// Geometría libre — usa cuando una capa no encaja en las otras. Custom, } -/// Geometría primitiva, agnóstica del renderer. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum Geometry { - /// Sólo glifos posicionados — sin trazo de fondo. GlyphsOnly, - /// Anillo dividido en sectores (zodíaco, casas). - Ring { - /// Divisiones en grados zodiacales [0, 360). El canvas pinta - /// líneas radiales en cada uno. - cusps_deg: Vec, - }, - /// Conjunto de líneas (aspectos). Cada par = `(from_deg, to_deg)`. + /// Anillo dividido en sectores. `cusps_deg` son los grados + /// zodiacales donde van las divisiones radiales. + Ring { cusps_deg: Vec }, Lines(Vec), - /// Puntos sueltos con marcadores (lots, fixed stars). Points(Vec), } #[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, square…) — el theme - /// resuelve el color. + /// Categoría simbólica (`"conjunction"`, `"trine"`, …) — el theme la + /// resuelve a color. pub kind: String, - /// Opacidad sugerida [0, 1]. pub opacity: f32, } @@ -125,25 +113,20 @@ pub struct LineSeg { pub struct PointMark { pub deg: f32, pub label: String, - /// Tag simbólico para que el theme elija color/glifo. pub tag: String, } -/// Glifo dibujable sobre una capa. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Glyph { - /// Posición zodiacal en grados [0, 360). + /// Grado eclíptico [0, 360). pub deg: f32, - /// Glyph simbólico ("sun","moon","aries",…). El theme lo mapea a - /// imagen o codepoint. + /// Glyph simbólico — el theme/canvas lo mapea a unicode o imagen. + /// Ej: `"sun"`, `"moon"`, `"aries"`, `"asc"`, `"mc"`. pub symbol: String, - /// Texto secundario (ej. el grado dentro del signo). #[serde(default)] pub annotation: Option, - /// `true` si el cuerpo está retrógrado. #[serde(default)] pub retrograde: bool, - /// Casa en la que cae (1..=12), si aplica. #[serde(default)] pub house: Option, } @@ -158,29 +141,30 @@ pub enum EngineError { BridgeDisabled, #[error("model: {0}")] Model(#[from] tahuantinsuyu_model::ModelError), - #[cfg(feature = "eternal-bridge")] #[error("eternal: {0}")] Eternal(String), + #[error("kind {0:?} todavía no implementado")] + UnsupportedKind(ChartKind), } // ===================================================================== // API pública // ===================================================================== -/// Computa el RenderModel real contra `eternal-astrology`. Requiere -/// el feature `eternal-bridge`. -#[cfg(feature = "eternal-bridge")] -pub fn compute(_chart: &Chart) -> Result { - // TODO: pipeline real — abrir `EphemerisSession`, traducir - // `StoredBirthData → BirthData`, `StoredChartConfig → ChartConfig`, - // correr `NatalChart::compute`, mapear a `Layer`s. Se cablea en la - // fase 3 del plan. - Err(EngineError::Eternal("pendiente fase 3".into())) +/// Computa el RenderModel real contra eternal-astrology si el feature +/// está prendido; sino cae al mock. +pub fn compute(chart: &Chart) -> Result { + #[cfg(feature = "eternal-bridge")] + { + bridge::compute(chart) + } + #[cfg(not(feature = "eternal-bridge"))] + { + Ok(compute_mock(chart)) + } } -/// Stub que devuelve un disco vacío de placeholder — sirve a la UI -/// mientras la pipeline real no esté cableada. Usar en demos y -/// desarrollo. +/// 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(); @@ -188,7 +172,7 @@ pub fn compute_mock(chart: &Chart) -> RenderModel { let sign_dial = Layer { module_id: "natal".into(), kind: LayerKind::SignDial, - ring: 0.95, + ring: 1.0, z: 0, geometry: Geometry::Ring { cusps_deg: (0..12).map(|i| (i as f32) * 30.0).collect(), @@ -207,9 +191,14 @@ pub fn compute_mock(chart: &Chart) -> RenderModel { RenderModel { chart_id: chart.id, chart_kind: chart.kind, - layers: vec![sign_dial], 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], } } @@ -228,6 +217,10 @@ const ZODIAC_GLYPHS: [&str; 12] = [ "pisces", ]; +// ===================================================================== +// Tests +// ===================================================================== + #[cfg(test)] mod tests { use super::*; @@ -269,4 +262,16 @@ mod tests { 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)); + } }