diff --git a/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs index 4c11c18..b4f8613 100644 --- a/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs +++ b/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs @@ -45,6 +45,7 @@ use cosmobiologia_engine::{ OUTER_RING_MODULES, }; use cosmobiologia_model::{ChartId, ContactId, GroupId}; +use cosmobiologia_render::{compose_sphere, DrawCommand, Palette, SphereOpts, SphereView}; use cosmobiologia_theme::{AspectKind as TAspectKind, AstroPalette, Element, Planet}; use nahual_theme::Theme; @@ -125,6 +126,15 @@ struct PanDragState { pan_y_start: f32, } +/// Drag activo de rotación de la esfera 3D. El delta del cursor desde +/// `start` se suma a `yaw`/`pitch` de partida — arrastrar = orbitar. +#[derive(Clone, Copy, Debug)] +struct SphereDragState { + start: Point, + yaw_start: f32, + pitch_start: f32, +} + #[derive(Clone, Debug)] pub struct CanvasState { pub mode: CanvasMode, @@ -156,8 +166,14 @@ pub struct CanvasState { /// El canvas dibuja su perfil como una curva en el footer; el valle /// marca la hora de nacimiento que mejor explica los eventos. pub rectificacion: Option, + /// `true` cuando la carta se muestra como esfera celeste 3D en vez + /// de la rueda 2D. Solo aplica al modo `Wheel`. + pub sphere_3d: bool, + /// Orientación de la esfera 3D — la muta el drag. + pub sphere_view: SphereView, drag_jog: Option, drag_pan: Option, + drag_sphere: Option, } /// Límites del zoom — bajo 0.5 los glyphs se vuelven ilegibles; sobre @@ -240,8 +256,11 @@ impl Default for CanvasState { show_coords: true, hover: None, rectificacion: None, + sphere_3d: false, + sphere_view: SphereView::default(), drag_jog: None, drag_pan: None, + drag_sphere: None, } } } @@ -357,6 +376,13 @@ impl AstrologyCanvas { cx.notify(); } + /// Alterna entre la rueda 2D y la esfera celeste 3D. Solo tiene + /// efecto visible cuando hay una carta cargada (`CanvasMode::Wheel`). + pub fn toggle_sphere(&mut self, cx: &mut Context<'_, Self>) { + self.state.sphere_3d = !self.state.sphere_3d; + cx.notify(); + } + /// Resetea zoom y pan a sus defaults (1.0 y 0,0). No toca rotation /// ni time offset — esos son ortogonales y tienen su propio reset. pub fn reset_view(&mut self, cx: &mut Context<'_, Self>) { @@ -694,6 +720,35 @@ impl AstrologyCanvas { } } + // ----- Internos: rotación de la esfera 3D ----- + + fn on_sphere_down(&mut self, position: Point) { + self.state.drag_sphere = Some(SphereDragState { + start: position, + yaw_start: self.state.sphere_view.yaw_deg, + pitch_start: self.state.sphere_view.pitch_deg, + }); + } + + fn on_sphere_move(&mut self, position: Point, cx: &mut Context<'_, Self>) { + let Some(drag) = self.state.drag_sphere else { + return; + }; + let dx: f32 = (position.x - drag.start.x).into(); + let dy: f32 = (position.y - drag.start.y).into(); + // 0.4°/px da una rotación cómoda. El pitch se clampea para no + // dar vuelta la esfera de adentro hacia afuera. + self.state.sphere_view.yaw_deg = (drag.yaw_start + dx * 0.4).rem_euclid(360.0); + self.state.sphere_view.pitch_deg = (drag.pitch_start + dy * 0.4).clamp(-89.0, 89.0); + cx.notify(); + } + + fn on_sphere_up(&mut self, cx: &mut Context<'_, Self>) { + if self.state.drag_sphere.take().is_some() { + cx.notify(); + } + } + fn on_scroll( &mut self, event: &ScrollWheelEvent, @@ -762,6 +817,10 @@ impl AstrologyCanvas { cx.emit(CanvasEvent::ExportSvgRequested); return; } + "v" | "V" => { + self.toggle_sphere(cx); + return; + } _ => return, }; self.toggle_layer(kind, cx); @@ -851,6 +910,15 @@ impl Render for AstrologyCanvas { let body = match &self.state.mode { CanvasMode::Empty => render_empty(&theme), + CanvasMode::Wheel { render } if self.state.sphere_3d => render_sphere( + &theme, + render, + self.state.sphere_view, + self.state.view_scale, + self.state.view_pan_x, + self.state.view_pan_y, + entity, + ), CanvasMode::Wheel { render } => render_wheel( &theme, &palette, @@ -869,6 +937,34 @@ impl Render for AstrologyCanvas { CanvasMode::Thumbnails { items, .. } => render_thumbnails(&theme, items), }; + // Botón flotante 2D ⇄ 3D — visible solo con una carta cargada. + // Muestra el modo al que se cambiará, no el activo. + let sphere_toggle = matches!(self.state.mode, CanvasMode::Wheel { .. }).then(|| { + let label = if self.state.sphere_3d { + "Rueda 2D" + } else { + "Esfera 3D" + }; + div() + .absolute() + .top(px(12.0)) + .right(px(12.0)) + .px(px(11.0)) + .py(px(5.0)) + .rounded(px(6.0)) + .bg(theme.bg_panel_alt.clone()) + .border_1() + .border_color(theme.border) + .text_size(px(11.0)) + .text_color(theme.fg_text) + .cursor_pointer() + .child(label) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _w, cx| this.toggle_sphere(cx)), + ) + }); + // Depth field: capa absoluta detrás del body, ocupa todo el // canvas. Vignette radial — el centro queda claro y los // bordes se oscurecen, dando profundidad sin "ruido" de @@ -908,6 +1004,7 @@ impl Render for AstrologyCanvas { .justify_center() .child(body), ) + .children(sphere_toggle) } } @@ -969,6 +1066,169 @@ fn render_thumbnails(theme: &Theme, items: &[ThumbnailItem]) -> gpui::Div { row } +// ===================================================================== +// Esfera celeste 3D +// ===================================================================== + +/// Render del modo esfera 3D. Compone la escena agnóstica con +/// `compose_sphere` (en `cosmobiologia-render`) y traduce sus +/// `DrawCommand`s a primitivas GPUI: las líneas y discos se pintan en +/// el `canvas`; los glifos van como hijos DOM sobre las coordenadas ya +/// proyectadas. El drag rota la esfera. +#[allow(clippy::too_many_arguments)] +fn render_sphere( + theme: &Theme, + render: &RenderModel, + view: SphereView, + view_scale: f32, + view_pan_x: f32, + view_pan_y: f32, + entity: gpui::Entity, +) -> gpui::Div { + let sphere_size = WHEEL_SIZE * view_scale; + let opts = SphereOpts { + size: sphere_size, + palette: if theme.is_dark { + Palette::dark() + } else { + Palette::light() + }, + ..Default::default() + }; + let commands = compose_sphere(render, &view, &opts); + // Las líneas y círculos se pintan en el canvas; el texto va al DOM. + let paint_cmds: Vec = commands + .iter() + .filter(|c| !matches!(c, DrawCommand::Text { .. })) + .cloned() + .collect(); + + let entity_for_canvas = entity.clone(); + let canvas_element = canvas( + move |_b: Bounds, _w, _cx| (), + move |bounds: Bounds, _, window, _| { + let ox: f32 = bounds.origin.x.into(); + let oy: f32 = bounds.origin.y.into(); + for cmd in &paint_cmds { + match cmd { + DrawCommand::Line { x1, y1, x2, y2, color, width, dash } => { + paint_segment( + window, + ox + *x1, + oy + *y1, + ox + *x2, + oy + *y2, + rgba_to_hsla(*color), + *dash, + *width, + ); + } + DrawCommand::Circle { cx, cy, r, stroke, fill, stroke_w } => { + if let Some(f) = fill { + fill_circle(window, ox + *cx, oy + *cy, *r, rgba_to_hsla(*f)); + } + if let Some(s) = stroke { + stroke_circle( + window, + ox + *cx, + oy + *cy, + *r, + *stroke_w, + rgba_to_hsla(*s), + ); + } + } + DrawCommand::Text { .. } => {} + } + } + + // Drag para orbitar la esfera. + let ent = entity_for_canvas.clone(); + window.on_mouse_event(move |ev: &MouseDownEvent, _, _w, cx| { + if ev.button == MouseButton::Left && bounds.contains(&ev.position) { + ent.update(cx, |this, _cx| this.on_sphere_down(ev.position)); + } + }); + let ent = entity_for_canvas.clone(); + window.on_mouse_event(move |ev: &MouseMoveEvent, _, _w, cx| { + if ev.dragging() { + ent.update(cx, |this, cx| this.on_sphere_move(ev.position, cx)); + } + }); + let ent = entity_for_canvas.clone(); + window.on_mouse_event(move |_: &MouseUpEvent, _, _w, cx| { + ent.update(cx, |this, cx| this.on_sphere_up(cx)); + }); + }, + ) + .absolute() + .w(px(sphere_size)) + .h(px(sphere_size)); + + let mut sphere = div() + .relative() + .w(px(sphere_size)) + .h(px(sphere_size)) + .ml(px(view_pan_x)) + .mt(px(view_pan_y)) + .child(canvas_element); + + // Glifos (signos, ángulos, cuerpos) como hijos DOM, ubicados sobre + // las coordenadas que ya proyectó `compose_sphere`. + for cmd in &commands { + if let DrawCommand::Text { x, y, content, color, size, .. } = cmd { + sphere = sphere.child(centered_glyph( + *x, + *y, + size * 1.9, + *size, + content.clone().into(), + rgba_to_hsla(*color), + )); + } + } + + // Pista de interacción al pie. + sphere.child( + div() + .absolute() + .bottom(px(6.0)) + .left(px(0.0)) + .w(px(sphere_size)) + .flex() + .justify_center() + .text_size(px(10.0)) + .text_color(theme.fg_disabled) + .child("Arrastrá para rotar la esfera"), + ) +} + +/// Convierte un `Rgba` agnóstico (`[0,1]^4`) al `Hsla` de gpui. +fn rgba_to_hsla(c: cosmobiologia_render::Rgba) -> Hsla { + let (r, g, b) = (c.r, c.g, c.b); + let max = r.max(g).max(b); + let min = r.min(g).min(b); + let l = (max + min) / 2.0; + let d = max - min; + if d < 1e-6 { + return hsla(0.0, 0.0, l.clamp(0.0, 1.0), c.a.clamp(0.0, 1.0)); + } + let s = d / (1.0 - (2.0 * l - 1.0).abs()); + let h = (if max == r { + ((g - b) / d).rem_euclid(6.0) + } else if max == g { + (b - r) / d + 2.0 + } else { + (r - g) / d + 4.0 + }) * 60.0; + hsla( + h.rem_euclid(360.0) / 360.0, + s.clamp(0.0, 1.0), + l.clamp(0.0, 1.0), + c.a.clamp(0.0, 1.0), + ) +} + // ===================================================================== // Wheel // ===================================================================== diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/draw.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/draw.rs index 5218d74..99e655a 100644 --- a/crates/modules/cosmobiologia/cosmobiologia-render/src/draw.rs +++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/draw.rs @@ -582,7 +582,7 @@ fn svg_escape(s: &str) -> String { .replace('"', """) } -fn sign_unicode(name: &str) -> &'static str { +pub(crate) fn sign_unicode(name: &str) -> &'static str { match name { "aries" => "♈", "taurus" => "♉", @@ -622,7 +622,7 @@ fn planet_unicode(name: &str) -> &'static str { /// Glyph del cuerpo con sufijo "℞" si está retrógrado — concatenación /// directa en el text para no agregar más comandos por planeta. -fn planet_unicode_with_retro(name: &str, retrograde: bool) -> String { +pub(crate) fn planet_unicode_with_retro(name: &str, retrograde: bool) -> String { if retrograde { format!("{}℞", planet_unicode(name)) } else { diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs index b976048..8aa7b53 100644 --- a/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs +++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs @@ -35,6 +35,7 @@ pub mod gr; pub mod harmonic; pub mod math; pub mod palette; +pub mod sphere3d; pub use draw::{ compose_wheel, draw_commands_to_svg, CompositionOpts, DrawCommand, Rgba, TextAnchor, @@ -45,6 +46,7 @@ pub use math::{ find_clusters, format_coord_compact, polar_to_screen, spread_angles, Radii, }; pub use palette::Palette; +pub use sphere3d::{compose_sphere, SphereOpts, SphereView, OBLICUIDAD_DEG}; // ===================================================================== // RenderModel — lo que el client renderea diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/sphere3d.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/sphere3d.rs new file mode 100644 index 0000000..f20ae0f --- /dev/null +++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/sphere3d.rs @@ -0,0 +1,545 @@ +//! `sphere3d` — la esfera celeste en 3D, proyectada a primitivas 2D. +//! +//! GPUI no es un motor 3D y empotrar wgpu sería frágil. La estrategia +//! es otra: la esfera celeste es un objeto de **alambre** —círculos +//! máximos y puntos—, y eso se proyecta a software con trigonometría +//! pura. Cada superficie (canvas gpui nativo, SVG del cliente web) ya +//! sabe traducir un [`DrawCommand`] (línea, círculo, texto); este +//! módulo solo decide DÓNDE cae cada trazo. Resultado: una esfera +//! celeste real, rotable, sin una sola línea de GPU. +//! +//! ## Marco de coordenadas — la eclíptica como plano de referencia +//! +//! El plano de la eclíptica es el plano `z = 0`. El eje `x` apunta al +//! 0° de Aries (el punto vernal). Una longitud eclíptica `λ` —con +//! latitud eclíptica ≈ 0, lo cual vale para los planetas— es el punto +//! unitario `(cos λ, sin λ, 0)`. El polo norte de la eclíptica es +//! `(0, 0, 1)`. +//! +//! El **ecuador celeste** es ese mismo círculo inclinado por la +//! oblicuidad ε ≈ 23.44° alrededor del eje `x`: los dos se cruzan en +//! los equinoccios, exactamente como en el cielo. Ver esa inclinación +//! —imposible en la rueda 2D— es el corazón de esta vista. +//! +//! ## Lo que esta primera entrega NO hace todavía +//! +//! El **horizonte local** (y con él el día/noche: qué planetas están +//! sobre el horizonte) necesita la latitud geográfica del lugar, que +//! hoy no viaja en el [`RenderModel`]. Queda para una segunda capa. + +use serde::{Deserialize, Serialize}; + +use crate::draw::{planet_unicode_with_retro, sign_unicode, DrawCommand, Rgba, TextAnchor}; +use crate::palette::Palette; +use crate::{LayerKind, RenderModel}; + +/// Oblicuidad media de la eclíptica, en grados. Varía ~0.013°/siglo — +/// despreciable para una vista de alambre, así que se fija. +pub const OBLICUIDAD_DEG: f32 = 23.4393; + +const SIGN_NAMES: [&str; 12] = [ + "aries", "taurus", "gemini", "cancer", "leo", "virgo", "libra", + "scorpio", "sagittarius", "capricorn", "aquarius", "pisces", +]; + +// ===================================================================== +// Cámara — cómo se orienta la esfera frente al observador +// ===================================================================== + +/// Orientación de la esfera frente a la cámara. El usuario la muta +/// arrastrando: `yaw` gira alrededor del eje polar de la eclíptica, +/// `pitch` inclina la cámara hacia arriba o abajo. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct SphereView { + /// Giro alrededor del eje polar de la eclíptica, en grados. + pub yaw_deg: f32, + /// Inclinación de la cámara, en grados. Un `pitch` negativo mira la + /// esfera desde el norte hacia abajo, dejando la eclíptica como una + /// elipse abierta en vez de una raya de canto. + pub pitch_deg: f32, +} + +impl Default for SphereView { + fn default() -> Self { + // Tres-cuartos desde arriba: la eclíptica se ve como un aro + // ancho y la inclinación del ecuador se lee de inmediato. + Self { yaw_deg: 26.0, pitch_deg: -64.0 } + } +} + +/// Opciones de composición de la esfera. +#[derive(Debug, Clone)] +pub struct SphereOpts { + /// Lado en px del cuadrado contenedor. + pub size: f32, + pub palette: Palette, + /// Oblicuidad de la eclíptica en grados (ver [`OBLICUIDAD_DEG`]). + pub obliquity_deg: f32, + /// Rejilla de meridianos y paralelos eclípticos. + pub show_grid: bool, + /// El ecuador celeste y el eje de la Tierra. + pub show_equator: bool, + /// Los cuerpos natales sobre la eclíptica. + pub show_bodies: bool, + /// Los glifos y divisiones de los signos. + pub show_signs: bool, +} + +impl Default for SphereOpts { + fn default() -> Self { + Self { + size: 600.0, + palette: Palette::dark(), + obliquity_deg: OBLICUIDAD_DEG, + show_grid: true, + show_equator: true, + show_bodies: true, + show_signs: true, + } + } +} + +// ===================================================================== +// Vector 3D y proyección +// ===================================================================== + +#[derive(Debug, Clone, Copy)] +struct Vec3 { + x: f32, + y: f32, + z: f32, +} + +impl Vec3 { + fn new(x: f32, y: f32, z: f32) -> Self { + Self { x, y, z } + } + fn scale(self, k: f32) -> Self { + Self::new(self.x * k, self.y * k, self.z * k) + } +} + +/// Un punto ya proyectado a la pantalla, con su profundidad conservada +/// para ordenar de atrás hacia adelante y atenuar el hemisferio lejano. +#[derive(Debug, Clone, Copy)] +struct Projected { + x: f32, + y: f32, + /// `+` hacia el observador (frente), `−` lejos (fondo). + depth: f32, +} + +/// Proyector ortográfico: gira un punto por la cámara (`yaw` alrededor +/// del eje polar, `pitch` alrededor del eje horizontal de pantalla) y +/// lo aplana a coordenadas de pantalla. +struct Projector { + ys: f32, + yc: f32, + ps: f32, + pc: f32, + ox: f32, + oy: f32, + rad: f32, +} + +impl Projector { + fn new(view: &SphereView, ox: f32, oy: f32, rad: f32) -> Self { + let (ys, yc) = view.yaw_deg.to_radians().sin_cos(); + let (ps, pc) = view.pitch_deg.to_radians().sin_cos(); + Self { ys, yc, ps, pc, ox, oy, rad } + } + + fn project(&self, p: Vec3) -> Projected { + // 1) yaw alrededor del eje Z (polar de la eclíptica). + let x1 = p.x * self.yc - p.y * self.ys; + let y1 = p.x * self.ys + p.y * self.yc; + let z1 = p.z; + // 2) pitch alrededor del eje X (horizontal de pantalla). + let x2 = x1; + let y2 = y1 * self.pc - z1 * self.ps; + let z2 = y1 * self.ps + z1 * self.pc; + // 3) ortográfica: la pantalla tiene la Y hacia abajo. + Projected { + x: self.ox + self.rad * x2, + y: self.oy - self.rad * y2, + depth: z2, + } + } +} + +/// Punto unitario sobre la eclíptica a la longitud `deg`. +fn eclip(deg: f32) -> Vec3 { + let (s, c) = deg.to_radians().sin_cos(); + Vec3::new(c, s, 0.0) +} + +/// Rota `p` alrededor del eje X (la línea de los equinoccios). +fn rot_x(p: Vec3, ang_rad: f32) -> Vec3 { + let (s, c) = ang_rad.sin_cos(); + Vec3::new(p.x, p.y * c - p.z * s, p.y * s + p.z * c) +} + +/// Atenuación por profundidad: el frente brilla pleno, el fondo se +/// apaga hasta ~0.30 para que el ojo lea el volumen de la esfera. +fn depth_alpha(depth: f32) -> f32 { + 0.30 + 0.70 * ((depth + 1.0) * 0.5).clamp(0.0, 1.0) +} + +/// `color` con su alpha modulada por la profundidad. +fn dim(color: Rgba, depth: f32) -> Rgba { + color.with_alpha(color.a * depth_alpha(depth)) +} + +// ===================================================================== +// Generadores de círculos +// ===================================================================== + +/// El círculo de la eclíptica (z = 0), `n` puntos. +fn ring_points(n: usize) -> Vec { + (0..n) + .map(|i| eclip((i as f32) / (n as f32) * 360.0)) + .collect() +} + +/// Un meridiano eclíptico: círculo máximo por ambos polos a la +/// longitud `lon0`. +fn meridian_points(lon0: f32, n: usize) -> Vec { + let (ls, lc) = lon0.to_radians().sin_cos(); + (0..n) + .map(|i| { + let a = (i as f32) / (n as f32) * std::f32::consts::TAU; + let (asin, acos) = a.sin_cos(); + Vec3::new(acos * lc, acos * ls, asin) + }) + .collect() +} + +/// Un paralelo eclíptico: círculo menor a la latitud `beta`. +fn parallel_points(beta: f32, n: usize) -> Vec { + let (bs, bc) = beta.to_radians().sin_cos(); + (0..n) + .map(|i| { + let lon = (i as f32) / (n as f32) * 360.0; + let (ls, lc) = lon.to_radians().sin_cos(); + Vec3::new(bc * lc, bc * ls, bs) + }) + .collect() +} + +/// Proyecta una polilínea cerrada y empuja un `Line` por segmento, con +/// la profundidad como clave de orden y la atenuación ya aplicada. +fn add_loop( + items: &mut Vec<(f32, DrawCommand)>, + proj: &Projector, + pts: &[Vec3], + color: Rgba, + width: f32, +) { + let n = pts.len(); + for i in 0..n { + let a = proj.project(pts[i]); + let b = proj.project(pts[(i + 1) % n]); + let d = (a.depth + b.depth) * 0.5; + items.push(( + d, + DrawCommand::Line { + x1: a.x, + y1: a.y, + x2: b.x, + y2: b.y, + color: dim(color, d), + width, + dash: None, + }, + )); + } +} + +// ===================================================================== +// Composición +// ===================================================================== + +/// Compone la esfera celeste como una lista de [`DrawCommand`]s, ya +/// ordenada de atrás hacia adelante (algoritmo del pintor). El canvas +/// nativo y el cliente web la consumen igual que la rueda 2D. +pub fn compose_sphere( + model: &RenderModel, + view: &SphereView, + opts: &SphereOpts, +) -> Vec { + let pal = &opts.palette; + let size = opts.size; + let center = size * 0.5; + let rad = size * 0.36; + let proj = Projector::new(view, center, center, rad); + let eps = opts.obliquity_deg.to_radians(); + + // (profundidad, comando) — se ordena al final. + let mut items: Vec<(f32, DrawCommand)> = Vec::new(); + + // --- Limbo: disco tenue de fondo + contorno (siempre al fondo) --- + items.push(( + -100.0, + DrawCommand::Circle { + cx: center, + cy: center, + r: rad, + stroke: Some(pal.fg_muted.with_alpha(0.22)), + fill: Some(pal.water.with_alpha(if pal.is_dark { 0.07 } else { 0.05 })), + stroke_w: 1.0, + }, + )); + + // --- Rejilla: meridianos + paralelos de la eclíptica --- + if opts.show_grid { + let grid = pal.fg_muted.with_alpha(0.16); + for k in 0..6 { + add_loop(&mut items, &proj, &meridian_points((k as f32) * 30.0, 64), grid, 0.5); + } + for &beta in &[-60.0_f32, -30.0, 30.0, 60.0] { + add_loop(&mut items, &proj, ¶llel_points(beta, 64), grid, 0.5); + } + } + + // --- Ecuador celeste + eje de la Tierra --- + if opts.show_equator { + let equator: Vec = ring_points(96).iter().map(|p| rot_x(*p, eps)).collect(); + add_loop(&mut items, &proj, &equator, pal.uranus.with_alpha(0.85), 1.3); + let n = proj.project(rot_x(Vec3::new(0.0, 0.0, 1.0), eps)); + let s = proj.project(rot_x(Vec3::new(0.0, 0.0, -1.0), eps)); + items.push(( + (n.depth + s.depth) * 0.5, + DrawCommand::Line { + x1: s.x, + y1: s.y, + x2: n.x, + y2: n.y, + color: pal.uranus.with_alpha(0.45), + width: 0.8, + dash: Some((4.0, 4.0)), + }, + )); + } + + // --- Eclíptica: el camino del zodíaco, el aro prominente --- + add_loop(&mut items, &proj, &ring_points(96), pal.dial_ring, 2.0); + { + // Eje polar de la eclíptica, tenue. + let n = proj.project(Vec3::new(0.0, 0.0, 1.0)); + let s = proj.project(Vec3::new(0.0, 0.0, -1.0)); + items.push(( + (n.depth + s.depth) * 0.5, + DrawCommand::Line { + x1: s.x, + y1: s.y, + x2: n.x, + y2: n.y, + color: pal.fg_muted.with_alpha(0.30), + width: 0.6, + dash: None, + }, + )); + } + + // --- Signos: espolón en cada borde + glifo en el centro --- + if opts.show_signs { + for i in 0..12 { + let boundary = (i as f32) * 30.0; + let a = proj.project(eclip(boundary)); + let b = proj.project(eclip(boundary).scale(1.09)); + let d = (a.depth + b.depth) * 0.5; + items.push(( + d, + DrawCommand::Line { + x1: a.x, + y1: a.y, + x2: b.x, + y2: b.y, + color: dim(pal.dial_ring, d), + width: 1.0, + dash: None, + }, + )); + let mid = boundary + 15.0; + let g = proj.project(eclip(mid).scale(1.17)); + let name = SIGN_NAMES[i]; + items.push(( + g.depth + 0.002, + DrawCommand::Text { + x: g.x, + y: g.y, + content: sign_unicode(name).into(), + color: dim(pal.sign(name), g.depth), + size: size * 0.030, + anchor: TextAnchor::Middle, + }, + )); + } + } + + // --- Ángulos ASC / MC / DSC / IC --- + for (deg, label) in [ + (model.ascendant_deg, "Asc"), + (model.midheaven_deg, "MC"), + (model.descendant_deg, "Dsc"), + (model.imum_coeli_deg, "IC"), + ] { + let a = proj.project(eclip(deg)); + let b = proj.project(eclip(deg).scale(1.14)); + let d = (a.depth + b.depth) * 0.5; + items.push(( + d, + DrawCommand::Line { + x1: a.x, + y1: a.y, + x2: b.x, + y2: b.y, + color: dim(pal.angle_highlight, d), + width: 1.6, + dash: None, + }, + )); + let lbl = proj.project(eclip(deg).scale(1.30)); + items.push(( + lbl.depth + 0.002, + DrawCommand::Text { + x: lbl.x, + y: lbl.y, + content: label.into(), + color: dim(pal.angle_highlight, lbl.depth), + size: size * 0.021, + anchor: TextAnchor::Middle, + }, + )); + } + + // --- Cuerpos natales sobre la eclíptica --- + if opts.show_bodies { + for layer in &model.layers { + if !matches!(layer.kind, LayerKind::Bodies) || layer.module_id != "natal" { + continue; + } + let halo = if pal.is_dark { + pal.bg_panel.with_alpha(0.92) + } else { + Rgba::opaque(1.0, 1.0, 1.0).with_alpha(0.92) + }; + for g in &layer.glyphs { + let p = proj.project(eclip(g.deg)); + let color = pal.planet(&g.symbol); + items.push(( + p.depth, + DrawCommand::Circle { + cx: p.x, + cy: p.y, + r: size * 0.020, + stroke: Some(dim(color, p.depth)), + fill: Some(halo), + stroke_w: 1.3, + }, + )); + items.push(( + p.depth + 0.003, + DrawCommand::Text { + x: p.x, + y: p.y, + content: planet_unicode_with_retro(&g.symbol, g.retrograde), + color: dim(color, p.depth), + size: size * 0.026, + anchor: TextAnchor::Middle, + }, + )); + } + } + } + + // Algoritmo del pintor: de la profundidad menor (fondo) a la mayor. + items.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(core::cmp::Ordering::Equal)); + items.into_iter().map(|(_, cmd)| cmd).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ChartId, ChartKind, Geometry, Glyph, Layer}; + + #[test] + fn vernal_point_y_cuadratura_sobre_la_eclyptica() { + let v = eclip(0.0); + assert!((v.x - 1.0).abs() < 1e-5 && v.y.abs() < 1e-5 && v.z.abs() < 1e-5); + let q = eclip(90.0); + assert!(q.x.abs() < 1e-5 && (q.y - 1.0).abs() < 1e-5 && q.z.abs() < 1e-5); + } + + #[test] + fn la_oblicuidad_inclina_el_polo_celeste() { + // El polo norte celeste = polo eclíptico rotado por ε. El + // ángulo entre ambos debe ser exactamente ε. + let ncp = rot_x(Vec3::new(0.0, 0.0, 1.0), OBLICUIDAD_DEG.to_radians()); + let cos_ang = ncp.z; // producto punto con (0,0,1). + let ang = cos_ang.acos().to_degrees(); + assert!((ang - OBLICUIDAD_DEG).abs() < 1e-3, "ángulo {ang}"); + } + + #[test] + fn la_proyeccion_no_se_sale_del_cuadro() { + let view = SphereView::default(); + let proj = Projector::new(&view, 300.0, 300.0, 108.0); + for i in 0..360 { + let p = proj.project(eclip(i as f32)); + assert!(p.x >= 300.0 - 109.0 && p.x <= 300.0 + 109.0); + assert!(p.y >= 300.0 - 109.0 && p.y <= 300.0 + 109.0); + } + } + + fn modelo_demo() -> RenderModel { + RenderModel { + chart_id: ChartId::default(), + chart_kind: ChartKind::Natal, + title: "demo".into(), + subtitle: None, + compute_ms: 0, + ascendant_deg: 100.0, + midheaven_deg: 10.0, + descendant_deg: 280.0, + imum_coeli_deg: 190.0, + layers: vec![Layer { + module_id: "natal".into(), + kind: LayerKind::Bodies, + ring: 0.0, + z: 0, + geometry: Geometry::GlyphsOnly, + glyphs: vec![ + Glyph { deg: 12.0, symbol: "sun".into(), ..Default::default() }, + Glyph { deg: 200.0, symbol: "moon".into(), ..Default::default() }, + ], + }], + overlays: vec![], + aspect_summary: vec![], + uranian_groups: vec![], + gr_triggers: vec![], + harmonic: 1, + harmonic_spectrum: vec![], + } + } + + #[test] + fn compose_sphere_emite_esqueleto_y_cuerpos() { + let cmds = compose_sphere(&modelo_demo(), &SphereView::default(), &SphereOpts::default()); + assert!(!cmds.is_empty(), "la esfera produce comandos"); + let lineas = cmds.iter().filter(|c| matches!(c, DrawCommand::Line { .. })).count(); + let textos = cmds.iter().filter(|c| matches!(c, DrawCommand::Text { .. })).count(); + assert!(lineas > 100, "círculos máximos como polilíneas: {lineas}"); + // 12 glifos de signo + 4 etiquetas de ángulo + 2 cuerpos. + assert_eq!(textos, 18, "glifos de signos, ángulos y cuerpos: {textos}"); + } + + #[test] + fn el_primer_comando_es_el_limbo_de_fondo() { + let cmds = compose_sphere(&modelo_demo(), &SphereView::default(), &SphereOpts::default()); + assert!( + matches!(cmds.first(), Some(DrawCommand::Circle { .. })), + "el limbo (profundidad −100) se pinta primero" + ); + } +}