6e30dc2d72
- Switch de constelaciones: botón flotante «● Constelaciones» (o tecla B) que las enciende y apaga en la esfera 3D. - La luminosidad se reparte: el brillo especular fijo a la pantalla se bajó mucho (no giraba, se sentía despegado), y en su lugar la Vía Láctea aporta un resplandor difuso a lo largo del plano galáctico — que SÍ gira con la esfera. Más intenso hacia el centro galáctico (Sagitario, como en el cielo real) y atenuado bajo el horizonte local: la franja como se ve desde la Tierra esa noche. 42 tests verdes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1640 lines
56 KiB
Rust
1640 lines
56 KiB
Rust
//! `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,
|
||
/// El horizonte local, el cénit del observador y el meridiano.
|
||
/// Necesita `RenderModel::geo_latitude_deg`.
|
||
pub show_horizon: bool,
|
||
/// El cielo de fondo: campo de estrellas + Vía Láctea. Solo se
|
||
/// dibuja en tema oscuro (en papel rompería la metáfora de imprenta).
|
||
pub show_sky: bool,
|
||
/// La Tierra interior — un globo pequeño, transparente, con los
|
||
/// continentes esquemáticos y el observador marcado en su lugar.
|
||
pub show_earth: bool,
|
||
/// Las figuras de las 88 constelaciones (catálogo d3-celestial).
|
||
pub show_constellations: 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,
|
||
show_horizon: true,
|
||
show_sky: true,
|
||
show_earth: true,
|
||
show_constellations: 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)
|
||
}
|
||
fn dot(self, o: Vec3) -> f32 {
|
||
self.x * o.x + self.y * o.y + self.z * o.z
|
||
}
|
||
fn cross(self, o: Vec3) -> Vec3 {
|
||
Vec3::new(
|
||
self.y * o.z - self.z * o.y,
|
||
self.z * o.x - self.x * o.z,
|
||
self.x * o.y - self.y * o.x,
|
||
)
|
||
}
|
||
fn normalized(self) -> Vec3 {
|
||
let len = (self.x * self.x + self.y * self.y + self.z * self.z).sqrt();
|
||
if len < 1e-9 {
|
||
self
|
||
} else {
|
||
self.scale(1.0 / len)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
/// Punto unitario a longitud y latitud eclípticas (grados) — para los
|
||
/// cuerpos que NO yacen sobre la eclíptica, como las estrellas fijas.
|
||
fn eclip_latlon(lon_deg: f32, lat_deg: f32) -> Vec3 {
|
||
let (sl, cl) = lon_deg.to_radians().sin_cos();
|
||
let (sb, cb) = lat_deg.to_radians().sin_cos();
|
||
Vec3::new(cb * cl, cb * sl, sb)
|
||
}
|
||
|
||
/// 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<Vec3> {
|
||
(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<Vec3> {
|
||
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<Vec3> {
|
||
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()
|
||
}
|
||
|
||
/// Sombreado del cuerpo de la esfera: un disco base sólido, un
|
||
/// degradado que aclara hacia el centro y un brillo especular
|
||
/// desplazado hacia la luz (arriba-izquierda). Da volumen sin
|
||
/// gradientes nativos — solo discos translúcidos que se acumulan.
|
||
fn add_sphere_shading(
|
||
items: &mut Vec<(f32, DrawCommand)>,
|
||
pal: &Palette,
|
||
center: f32,
|
||
rad: f32,
|
||
) {
|
||
let (base, glow, highlight) = if pal.is_dark {
|
||
(
|
||
Rgba::opaque(0.12, 0.14, 0.24),
|
||
Rgba::opaque(0.34, 0.40, 0.60),
|
||
Rgba::opaque(0.62, 0.68, 0.88),
|
||
)
|
||
} else {
|
||
(
|
||
Rgba::opaque(0.82, 0.86, 0.93),
|
||
Rgba::opaque(1.0, 1.0, 1.0),
|
||
Rgba::opaque(1.0, 1.0, 1.0),
|
||
)
|
||
};
|
||
// Disco base — uniforme, le da cuerpo sólido a la esfera.
|
||
items.push((
|
||
-99.0,
|
||
DrawCommand::Circle {
|
||
cx: center,
|
||
cy: center,
|
||
r: rad,
|
||
stroke: None,
|
||
fill: Some(base.with_alpha(0.55)),
|
||
stroke_w: 0.0,
|
||
},
|
||
));
|
||
// Degradado: anillos concéntricos que se acumulan hacia el centro.
|
||
const GLOW: usize = 12;
|
||
for i in 0..GLOW {
|
||
let t = i as f32 / (GLOW - 1) as f32;
|
||
items.push((
|
||
-98.0 + t * 1.5,
|
||
DrawCommand::Circle {
|
||
cx: center,
|
||
cy: center,
|
||
r: rad * (0.95 - 0.95 * t),
|
||
stroke: None,
|
||
fill: Some(glow.with_alpha(0.028)),
|
||
stroke_w: 0.0,
|
||
},
|
||
));
|
||
}
|
||
// Brillo especular desplazado hacia la luz — tenue: la luminosidad
|
||
// viva la reparte la Vía Láctea, que sí gira con la esfera.
|
||
let hx = center - rad * 0.34;
|
||
let hy = center - rad * 0.34;
|
||
const HALO: usize = 6;
|
||
for i in 0..HALO {
|
||
let t = i as f32 / (HALO - 1) as f32;
|
||
items.push((
|
||
-95.0 + t * 0.5,
|
||
DrawCommand::Circle {
|
||
cx: hx,
|
||
cy: hy,
|
||
r: rad * 0.5 * (1.0 - t),
|
||
stroke: None,
|
||
fill: Some(highlight.with_alpha(0.018)),
|
||
stroke_w: 0.0,
|
||
},
|
||
));
|
||
}
|
||
// Contorno nítido del limbo, encima del sombreado.
|
||
items.push((
|
||
-94.0,
|
||
DrawCommand::Circle {
|
||
cx: center,
|
||
cy: center,
|
||
r: rad,
|
||
stroke: Some(pal.fg_muted.with_alpha(0.32)),
|
||
fill: None,
|
||
stroke_w: 1.0,
|
||
},
|
||
));
|
||
}
|
||
|
||
/// 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,
|
||
},
|
||
));
|
||
}
|
||
}
|
||
|
||
/// Proyecta una polilínea ABIERTA y empuja un `Line` por segmento.
|
||
fn add_path(
|
||
items: &mut Vec<(f32, DrawCommand)>,
|
||
proj: &Projector,
|
||
pts: &[Vec3],
|
||
color: Rgba,
|
||
width: f32,
|
||
) {
|
||
for i in 0..pts.len().saturating_sub(1) {
|
||
let a = proj.project(pts[i]);
|
||
let b = proj.project(pts[i + 1]);
|
||
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,
|
||
},
|
||
));
|
||
}
|
||
}
|
||
|
||
/// Dibuja las figuras de las 88 constelaciones: cada trazo une estrellas
|
||
/// reales del catálogo (un punto por vértice), y el nombre va en el
|
||
/// centroide. Capa tenue — referencia, no protagonista.
|
||
fn add_constellations(
|
||
items: &mut Vec<(f32, DrawCommand)>,
|
||
proj: &Projector,
|
||
eps: f32,
|
||
size: f32,
|
||
pal: &Palette,
|
||
) {
|
||
let line_col = pal.fg_muted.with_alpha(0.42);
|
||
let star = Rgba::opaque(0.92, 0.95, 1.0);
|
||
for fig in crate::constellations_data::FIGURAS {
|
||
let (mut sx, mut sy, mut sz, mut n) = (0.0_f32, 0.0_f32, 0.0_f32, 0.0_f32);
|
||
for path in fig.paths {
|
||
let pts: Vec<Vec3> = path
|
||
.iter()
|
||
.map(|&(ra, dec)| rot_x(equatorial_dir(ra, dec), eps))
|
||
.collect();
|
||
add_path(items, proj, &pts, line_col, 0.7);
|
||
for v in &pts {
|
||
sx += v.x;
|
||
sy += v.y;
|
||
sz += v.z;
|
||
n += 1.0;
|
||
let p = proj.project(*v);
|
||
items.push((
|
||
p.depth - 0.01,
|
||
DrawCommand::Circle {
|
||
cx: p.x,
|
||
cy: p.y,
|
||
r: size * 0.0017,
|
||
stroke: None,
|
||
fill: Some(star.with_alpha(0.70 * depth_alpha(p.depth))),
|
||
stroke_w: 0.0,
|
||
},
|
||
));
|
||
}
|
||
}
|
||
if n > 0.0 {
|
||
let c = Vec3::new(sx / n, sy / n, sz / n).normalized();
|
||
let lp = proj.project(c);
|
||
items.push((
|
||
lp.depth + 0.001,
|
||
DrawCommand::Text {
|
||
x: lp.x,
|
||
y: lp.y,
|
||
content: fig.nombre.into(),
|
||
color: pal.fg_muted.with_alpha(0.42 * depth_alpha(lp.depth)),
|
||
size: size * 0.0135,
|
||
anchor: TextAnchor::Middle,
|
||
},
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Los `n` puntos de un círculo máximo perpendicular a `normal`.
|
||
fn great_circle_perp(normal: Vec3, n: usize) -> Vec<Vec3> {
|
||
let z = normal.normalized();
|
||
// Una referencia que no sea casi-paralela a `z`.
|
||
let r = if z.z.abs() < 0.9 {
|
||
Vec3::new(0.0, 0.0, 1.0)
|
||
} else {
|
||
Vec3::new(1.0, 0.0, 0.0)
|
||
};
|
||
let u = z.cross(r).normalized();
|
||
let v = z.cross(u);
|
||
(0..n)
|
||
.map(|i| {
|
||
let t = (i as f32) / (n as f32) * std::f32::consts::TAU;
|
||
let (s, c) = t.sin_cos();
|
||
Vec3::new(u.x * c + v.x * s, u.y * c + v.y * s, u.z * c + v.z * s)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// RAMC — ascensión recta del Medio Cielo, en grados: la AR del punto
|
||
/// eclíptico del MC (latitud eclíptica 0).
|
||
fn ramc_deg(mc_deg: f32, eps_rad: f32) -> f32 {
|
||
let lmc = mc_deg.to_radians();
|
||
(lmc.sin() * eps_rad.cos())
|
||
.atan2(lmc.cos())
|
||
.to_degrees()
|
||
}
|
||
|
||
/// El cénit del observador en el marco eclíptico — el punto del cielo
|
||
/// justo sobre su cabeza. Tiene declinación `φ` (la latitud geográfica)
|
||
/// y AR `RAMC`, y eso se lleva del marco ecuatorial al eclíptico
|
||
/// rotando por la oblicuidad.
|
||
fn zenith_ecliptic(lat_deg: f32, mc_deg: f32, eps_rad: f32) -> Vec3 {
|
||
let phi = lat_deg.to_radians();
|
||
let ramc = ramc_deg(mc_deg, eps_rad).to_radians();
|
||
let (sphi, cphi) = phi.sin_cos();
|
||
let (sr, cr) = ramc.sin_cos();
|
||
rot_x(Vec3::new(cphi * cr, cphi * sr, sphi), eps_rad)
|
||
}
|
||
|
||
/// Marca un punto notable de la esfera: disco + etiqueta, y un anillo
|
||
/// extra si es `prominent`.
|
||
fn add_point_marker(
|
||
items: &mut Vec<(f32, DrawCommand)>,
|
||
proj: &Projector,
|
||
pos: Vec3,
|
||
color: Rgba,
|
||
size: f32,
|
||
label: &str,
|
||
prominent: bool,
|
||
) {
|
||
let p = proj.project(pos);
|
||
let c = dim(color, p.depth);
|
||
let r = if prominent { size * 0.013 } else { size * 0.008 };
|
||
items.push((
|
||
p.depth + 0.001,
|
||
DrawCommand::Circle {
|
||
cx: p.x,
|
||
cy: p.y,
|
||
r,
|
||
stroke: Some(c),
|
||
fill: Some(c.with_alpha(c.a * 0.40)),
|
||
stroke_w: 1.4,
|
||
},
|
||
));
|
||
if prominent {
|
||
items.push((
|
||
p.depth + 0.001,
|
||
DrawCommand::Circle {
|
||
cx: p.x,
|
||
cy: p.y,
|
||
r: r * 1.95,
|
||
stroke: Some(c.with_alpha(c.a * 0.55)),
|
||
fill: None,
|
||
stroke_w: 1.0,
|
||
},
|
||
));
|
||
}
|
||
let lp = proj.project(pos.scale(1.13));
|
||
items.push((
|
||
lp.depth + 0.002,
|
||
DrawCommand::Text {
|
||
x: lp.x,
|
||
y: lp.y,
|
||
content: label.into(),
|
||
color: dim(color, lp.depth),
|
||
size: size * 0.019,
|
||
anchor: TextAnchor::Middle,
|
||
},
|
||
));
|
||
}
|
||
|
||
// --- Cielo de fondo: estrellas decorativas + Vía Láctea --------------
|
||
|
||
/// Polo norte galáctico (J2000): AR 192.859°, Dec +27.128° — constante
|
||
/// estándar IAU que fija el plano de la Vía Láctea.
|
||
const GAL_POLE_RA: f32 = 192.859;
|
||
const GAL_POLE_DEC: f32 = 27.128;
|
||
/// Centro galáctico (Sgr A*, J2000): AR 266.405°, Dec −28.936°. Hacia
|
||
/// ahí la Vía Láctea es más brillante.
|
||
const GAL_CENTER_RA: f32 = 266.405;
|
||
const GAL_CENTER_DEC: f32 = -28.936;
|
||
|
||
/// Hash entero → f32 en [0,1). Determinista (variante de splitmix32):
|
||
/// la misma entrada da siempre el mismo valor, así el campo de
|
||
/// estrellas no titila ni salta entre frames.
|
||
fn hash01(n: u32) -> f32 {
|
||
let mut x = n.wrapping_mul(0x9E37_79B9);
|
||
x ^= x >> 16;
|
||
x = x.wrapping_mul(0x85EB_CA6B);
|
||
x ^= x >> 13;
|
||
x = x.wrapping_mul(0xC2B2_AE35);
|
||
x ^= x >> 16;
|
||
(x as f32) / (u32::MAX as f32)
|
||
}
|
||
|
||
/// Punto uniforme sobre la esfera unidad a partir de dos uniformes.
|
||
fn sphere_point(u1: f32, u2: f32) -> Vec3 {
|
||
let z = 2.0 * u1 - 1.0;
|
||
let rho = (1.0 - z * z).max(0.0).sqrt();
|
||
let theta = std::f32::consts::TAU * u2;
|
||
Vec3::new(rho * theta.cos(), rho * theta.sin(), z)
|
||
}
|
||
|
||
/// Vector unitario de una dirección ecuatorial (AR, Dec en grados).
|
||
fn equatorial_dir(ra_deg: f32, dec_deg: f32) -> Vec3 {
|
||
let (sr, cr) = ra_deg.to_radians().sin_cos();
|
||
let (sd, cd) = dec_deg.to_radians().sin_cos();
|
||
Vec3::new(cd * cr, cd * sr, sd)
|
||
}
|
||
|
||
/// Empuja una estrella: un disco diminuto con brillo y un leve tinte
|
||
/// (azulado o cálido). Va detrás de la rejilla pero delante del
|
||
/// sombreado — un fondo de planetario.
|
||
fn push_star(
|
||
items: &mut Vec<(f32, DrawCommand)>,
|
||
proj: &Projector,
|
||
size: f32,
|
||
pos: Vec3,
|
||
brightness: f32,
|
||
tint: f32,
|
||
) {
|
||
let p = proj.project(pos);
|
||
let bright = brightness * brightness; // sesga hacia las tenues
|
||
let r = size * (0.0011 + 0.0026 * bright);
|
||
let alpha = (0.20 + 0.62 * bright) * depth_alpha(p.depth);
|
||
let col = if tint < 0.22 {
|
||
Rgba { r: 0.74, g: 0.81, b: 1.0, a: alpha }
|
||
} else if tint > 0.86 {
|
||
Rgba { r: 1.0, g: 0.86, b: 0.72, a: alpha }
|
||
} else {
|
||
Rgba { r: 0.95, g: 0.96, b: 1.0, a: alpha }
|
||
};
|
||
items.push((
|
||
p.depth - 3.0,
|
||
DrawCommand::Circle {
|
||
cx: p.x,
|
||
cy: p.y,
|
||
r,
|
||
stroke: None,
|
||
fill: Some(col),
|
||
stroke_w: 0.0,
|
||
},
|
||
));
|
||
}
|
||
|
||
/// El cielo de fondo: un campo de estrellas isótropo —decorativo, no un
|
||
/// catálogo real— más una sobredensidad de estrellas tenues a lo largo
|
||
/// del plano galáctico, que dibuja la Vía Láctea. Ambos giran con la
|
||
/// esfera, así que delatan su rotación de un vistazo.
|
||
fn add_starfield(items: &mut Vec<(f32, DrawCommand)>, proj: &Projector, size: f32, eps: f32) {
|
||
const FONDO: u32 = 210;
|
||
for i in 0..FONDO {
|
||
let pos = sphere_point(hash01(i * 3), hash01(i * 3 + 1));
|
||
push_star(items, proj, size, pos, hash01(i * 3 + 2), hash01(i * 7 + 1));
|
||
}
|
||
// Vía Láctea — el plano galáctico ubicado con el polo galáctico real.
|
||
let gpole = rot_x(equatorial_dir(GAL_POLE_RA, GAL_POLE_DEC), eps);
|
||
let geq = great_circle_perp(gpole, 256);
|
||
const VIA: u32 = 240;
|
||
for i in 0..VIA {
|
||
let s = 9001 + i;
|
||
let idx = (hash01(s * 5) * geq.len() as f32) as usize % geq.len();
|
||
let on_eq = geq[idx];
|
||
// Latitud galáctica pequeña, concentrada cerca de 0 — producto
|
||
// de dos uniformes centrados → densa en el plano.
|
||
let u = hash01(s * 5 + 1) - 0.5;
|
||
let v = hash01(s * 5 + 2) - 0.5;
|
||
let b = (u * v * 4.0 * 13.0).to_radians();
|
||
let (sb, cb) = b.sin_cos();
|
||
let pos = Vec3::new(
|
||
on_eq.x * cb + gpole.x * sb,
|
||
on_eq.y * cb + gpole.y * sb,
|
||
on_eq.z * cb + gpole.z * sb,
|
||
);
|
||
push_star(items, proj, size, pos, hash01(s * 5 + 3) * 0.55, hash01(s * 5 + 4));
|
||
}
|
||
}
|
||
|
||
/// El resplandor difuso de la Vía Láctea — una luminosidad repartida a
|
||
/// lo largo del plano galáctico, no un brillo fijo a la pantalla. Gira
|
||
/// con la esfera. Es más intensa hacia el centro galáctico (en
|
||
/// Sagitario, como en el cielo real) y, si hay horizonte, se atenúa en
|
||
/// la parte que queda bajo tierra esa noche — la franja como se ve
|
||
/// desde la Tierra ese día.
|
||
fn add_milky_way_glow(
|
||
items: &mut Vec<(f32, DrawCommand)>,
|
||
proj: &Projector,
|
||
eps: f32,
|
||
size: f32,
|
||
zenith: Option<Vec3>,
|
||
) {
|
||
let gpole = rot_x(equatorial_dir(GAL_POLE_RA, GAL_POLE_DEC), eps);
|
||
let gcenter = rot_x(equatorial_dir(GAL_CENTER_RA, GAL_CENTER_DEC), eps);
|
||
let band = Rgba::opaque(0.78, 0.82, 0.96);
|
||
for p3 in great_circle_perp(gpole, 54) {
|
||
// Más brillo hacia el centro galáctico.
|
||
let toward = (p3.dot(gcenter) * 0.5 + 0.5).clamp(0.0, 1.0);
|
||
let bright = 0.28 + 0.72 * toward * toward;
|
||
// Atenuada bajo el horizonte local (no se ve esa noche).
|
||
let vis = match zenith {
|
||
Some(z) if p3.dot(z) < 0.0 => 0.40,
|
||
_ => 1.0,
|
||
};
|
||
let p = proj.project(p3);
|
||
items.push((
|
||
p.depth - 4.0,
|
||
DrawCommand::Circle {
|
||
cx: p.x,
|
||
cy: p.y,
|
||
r: size * 0.045,
|
||
stroke: None,
|
||
fill: Some(band.with_alpha(0.030 * bright * vis * depth_alpha(p.depth))),
|
||
stroke_w: 0.0,
|
||
},
|
||
));
|
||
}
|
||
}
|
||
|
||
// --- Estrellas fijas notables ----------------------------------------
|
||
|
||
/// Latitud eclíptica (grados, J2000) de las estrellas fijas notables
|
||
/// que emite el motor. La latitud apenas cambia con la precesión, así
|
||
/// que se fija aquí; la **longitud** —la coordenada astrológicamente
|
||
/// viva, que sí precesiona— la calcula el motor
|
||
/// (`build_fixed_stars_overlay`) y llega en el `Glyph`. Valores de
|
||
/// catálogo estándar, precisión ~0.5° (de sobra para el alambre).
|
||
fn fixed_star_latitude(name: &str) -> f32 {
|
||
match name {
|
||
"Regulus" => 0.47,
|
||
"Spica" => -2.06,
|
||
"Antares" => -4.57,
|
||
"Aldebaran" => -5.47,
|
||
"Pollux" => 6.68,
|
||
"Algol" => 22.43,
|
||
"Fomalhaut" => -21.14,
|
||
"Sirius" => -39.61,
|
||
"Vega" => 61.73,
|
||
_ => 0.0,
|
||
}
|
||
}
|
||
|
||
/// Dibuja una estrella fija: un disco brillante con destello de cuatro
|
||
/// rayos y su nombre.
|
||
fn add_fixed_star(
|
||
items: &mut Vec<(f32, DrawCommand)>,
|
||
proj: &Projector,
|
||
pos: Vec3,
|
||
size: f32,
|
||
name: &str,
|
||
pal: &Palette,
|
||
) {
|
||
let p = proj.project(pos);
|
||
let glow = Rgba::opaque(1.0, 0.96, 0.84);
|
||
let c = dim(glow, p.depth);
|
||
items.push((
|
||
p.depth + 0.004,
|
||
DrawCommand::Circle {
|
||
cx: p.x,
|
||
cy: p.y,
|
||
r: size * 0.006,
|
||
stroke: None,
|
||
fill: Some(c),
|
||
stroke_w: 0.0,
|
||
},
|
||
));
|
||
let ray = size * 0.018;
|
||
let thin = c.with_alpha(c.a * 0.8);
|
||
for (dx, dy) in [(ray, 0.0), (-ray, 0.0), (0.0, ray), (0.0, -ray)] {
|
||
items.push((
|
||
p.depth + 0.004,
|
||
DrawCommand::Line {
|
||
x1: p.x,
|
||
y1: p.y,
|
||
x2: p.x + dx,
|
||
y2: p.y + dy,
|
||
color: thin,
|
||
width: 0.9,
|
||
dash: None,
|
||
},
|
||
));
|
||
}
|
||
let lp = proj.project(pos.scale(1.10));
|
||
items.push((
|
||
lp.depth + 0.005,
|
||
DrawCommand::Text {
|
||
x: lp.x,
|
||
y: lp.y,
|
||
content: name.into(),
|
||
color: dim(pal.fg_text, lp.depth),
|
||
size: size * 0.017,
|
||
anchor: TextAnchor::Middle,
|
||
},
|
||
));
|
||
}
|
||
|
||
// --- Tierra interior ------------------------------------------------
|
||
|
||
/// Contornos continentales **esquemáticos** (lat, lon en grados) — solo
|
||
/// referenciales, trazos muy gruesos para la Tierra interior. NO son un
|
||
/// mapa de precisión; dan el «ahí está tu continente» y nada más.
|
||
const CONTINENTES: &[&[(f32, f32)]] = &[
|
||
// África
|
||
&[
|
||
(35.0, -6.0), (37.0, 10.0), (33.0, 22.0), (31.0, 32.0), (12.0, 43.0),
|
||
(11.0, 51.0), (-4.0, 40.0), (-26.0, 33.0), (-34.0, 26.0), (-34.0, 19.0),
|
||
(-18.0, 12.0), (0.0, 9.0), (5.0, -4.0), (11.0, -15.0), (21.0, -17.0),
|
||
(28.0, -13.0),
|
||
],
|
||
// Sudamérica
|
||
&[
|
||
(12.0, -72.0), (11.0, -61.0), (5.0, -52.0), (-5.0, -35.0), (-23.0, -43.0),
|
||
(-34.0, -54.0), (-52.0, -69.0), (-55.0, -67.0), (-42.0, -74.0),
|
||
(-18.0, -70.0), (-5.0, -81.0), (2.0, -79.0), (8.0, -77.0),
|
||
],
|
||
// Norteamérica
|
||
&[
|
||
(70.0, -160.0), (71.0, -125.0), (68.0, -95.0), (63.0, -78.0),
|
||
(47.0, -53.0), (45.0, -67.0), (30.0, -81.0), (25.0, -81.0),
|
||
(20.0, -97.0), (23.0, -110.0), (34.0, -120.0), (48.0, -125.0),
|
||
(60.0, -148.0),
|
||
],
|
||
// Eurasia
|
||
&[
|
||
(36.0, -9.0), (43.0, -9.0), (58.0, 5.0), (71.0, 26.0), (73.0, 80.0),
|
||
(73.0, 140.0), (66.0, 180.0), (53.0, 141.0), (40.0, 130.0), (30.0, 122.0),
|
||
(22.0, 110.0), (9.0, 105.0), (8.0, 77.0), (21.0, 72.0), (25.0, 57.0),
|
||
(13.0, 45.0), (30.0, 33.0), (41.0, 28.0), (38.0, 15.0), (40.0, 0.0),
|
||
],
|
||
// Australia
|
||
&[
|
||
(-11.0, 131.0), (-12.0, 142.0), (-25.0, 153.0), (-38.0, 147.0),
|
||
(-35.0, 138.0), (-32.0, 116.0), (-22.0, 114.0), (-14.0, 127.0),
|
||
],
|
||
// Antártida (casquete polar aproximado)
|
||
&[
|
||
(-72.0, -180.0), (-70.0, -120.0), (-73.0, -60.0), (-70.0, 0.0),
|
||
(-73.0, 60.0), (-70.0, 120.0), (-72.0, 170.0),
|
||
],
|
||
];
|
||
|
||
/// Dirección (marco eclíptico, unitaria) de un punto geográfico. La
|
||
/// longitud del observador y el RAMC fijan la fase de rotación de la
|
||
/// Tierra: el observador está en AR = RAMC, así que cualquier otra
|
||
/// longitud geográfica `lon` está en AR = RAMC + (lon − lon_obs).
|
||
fn geo_to_ecliptic(lat: f32, lon: f32, lon_obs: f32, ramc: f32, eps_rad: f32) -> Vec3 {
|
||
let ra = (ramc + lon - lon_obs).to_radians();
|
||
let dec = lat.to_radians();
|
||
let (sra, cra) = ra.sin_cos();
|
||
let (sd, cd) = dec.sin_cos();
|
||
rot_x(Vec3::new(cd * cra, cd * sra, sd), eps_rad)
|
||
}
|
||
|
||
/// La Tierra interior: un globo pequeño en el centro de la esfera
|
||
/// celeste, con el **mar** y los **continentes** teñidos distinto, un
|
||
/// **sombreado día/noche** según la posición del Sol, y el observador
|
||
/// marcado en su lugar real. Orientada de modo que el punto geográfico
|
||
/// del observador mira al cénit — y gira con la vista.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn add_inner_earth(
|
||
items: &mut Vec<(f32, DrawCommand)>,
|
||
proj: &Projector,
|
||
model: &RenderModel,
|
||
eps: f32,
|
||
size: f32,
|
||
center: f32,
|
||
rad: f32,
|
||
pal: &Palette,
|
||
) {
|
||
const R_EARTH: f32 = 0.26;
|
||
let ramc = ramc_deg(model.midheaven_deg, eps);
|
||
let lon_obs = model.geo_longitude_deg;
|
||
// Dirección unitaria de un punto geográfico (sin escalar).
|
||
let dir = |lat: f32, lon: f32| geo_to_ecliptic(lat, lon, lon_obs, ramc, eps);
|
||
// El mismo punto, escalado al radio de la Tierra interior.
|
||
let geo = |lat: f32, lon: f32| dir(lat, lon).scale(R_EARTH);
|
||
|
||
// El Sol de la carta (si está) — para el día/noche. El lado de la
|
||
// Tierra que mira al Sol es el día: un punto `d` está de día si
|
||
// `d · sol > 0`.
|
||
let sun_dir: Option<Vec3> = model
|
||
.layers
|
||
.iter()
|
||
.filter(|l| matches!(l.kind, LayerKind::Bodies) && l.module_id == "natal")
|
||
.flat_map(|l| l.glyphs.iter())
|
||
.find(|g| g.symbol == "sun")
|
||
.map(|g| eclip(g.deg));
|
||
let es_dia = |d: Vec3| -> bool { sun_dir.map(|s| d.dot(s) > 0.0).unwrap_or(true) };
|
||
|
||
// Mar — disco base teñido de azul.
|
||
let sea = if pal.is_dark {
|
||
Rgba::opaque(0.10, 0.21, 0.39)
|
||
} else {
|
||
Rgba::opaque(0.58, 0.72, 0.86)
|
||
};
|
||
items.push((
|
||
-0.95,
|
||
DrawCommand::Circle {
|
||
cx: center,
|
||
cy: center,
|
||
r: R_EARTH * rad,
|
||
stroke: Some(pal.fg_muted.with_alpha(0.30)),
|
||
fill: Some(sea.with_alpha(0.55)),
|
||
stroke_w: 0.8,
|
||
},
|
||
));
|
||
|
||
// Resplandor diurno — el hemisferio iluminado. Discos concéntricos
|
||
// sobre el punto subsolar; se apagan si el Sol queda detrás de la
|
||
// Tierra (entonces vemos su cara nocturna).
|
||
if let Some(s) = sun_dir {
|
||
let sub = proj.project(s.scale(R_EARTH));
|
||
let face = ((sub.depth / R_EARTH) * 0.5 + 0.5).clamp(0.0, 1.0);
|
||
let day = if pal.is_dark {
|
||
Rgba::opaque(0.40, 0.60, 0.85)
|
||
} else {
|
||
Rgba::opaque(1.0, 0.98, 0.88)
|
||
};
|
||
for i in 0..10 {
|
||
let t = i as f32 / 9.0;
|
||
items.push((
|
||
-0.93 + t * 0.04,
|
||
DrawCommand::Circle {
|
||
cx: sub.x,
|
||
cy: sub.y,
|
||
r: R_EARTH * rad * (1.0 - 0.92 * t),
|
||
stroke: None,
|
||
fill: Some(day.with_alpha(0.07 * face)),
|
||
stroke_w: 0.0,
|
||
},
|
||
));
|
||
}
|
||
}
|
||
|
||
// Ecuador terrestre.
|
||
let equator: Vec<Vec3> = (0..72)
|
||
.map(|i| geo(0.0, (i as f32) / 72.0 * 360.0))
|
||
.collect();
|
||
add_loop(items, proj, &equator, pal.fg_muted.with_alpha(0.20), 0.5);
|
||
|
||
// Terminador — la línea día/noche, círculo máximo ⊥ al Sol.
|
||
if let Some(s) = sun_dir {
|
||
let term: Vec<Vec3> = great_circle_perp(s, 72)
|
||
.iter()
|
||
.map(|p| p.scale(R_EARTH))
|
||
.collect();
|
||
add_loop(items, proj, &term, pal.angle_highlight.with_alpha(0.45), 0.7);
|
||
}
|
||
|
||
// Continentes — polígonos rellenos, teñidos de verde; el tono
|
||
// depende de si la masa está de día o de noche.
|
||
let land_day = if pal.is_dark {
|
||
Rgba::opaque(0.38, 0.60, 0.34)
|
||
} else {
|
||
Rgba::opaque(0.52, 0.66, 0.40)
|
||
};
|
||
let land_night = if pal.is_dark {
|
||
Rgba::opaque(0.13, 0.25, 0.19)
|
||
} else {
|
||
Rgba::opaque(0.40, 0.50, 0.40)
|
||
};
|
||
for outline in CONTINENTES {
|
||
let pts3: Vec<Vec3> = outline.iter().map(|&(lat, lon)| geo(lat, lon)).collect();
|
||
let mut cen = Vec3::new(0.0, 0.0, 0.0);
|
||
let mut depth_sum = 0.0_f32;
|
||
let pts2: Vec<(f32, f32)> = pts3
|
||
.iter()
|
||
.map(|v| {
|
||
cen = Vec3::new(cen.x + v.x, cen.y + v.y, cen.z + v.z);
|
||
let p = proj.project(*v);
|
||
depth_sum += p.depth;
|
||
(p.x, p.y)
|
||
})
|
||
.collect();
|
||
let n = pts3.len().max(1) as f32;
|
||
let depth = depth_sum / n;
|
||
let base = if es_dia(cen.scale(1.0 / n)) {
|
||
land_day
|
||
} else {
|
||
land_night
|
||
};
|
||
items.push((
|
||
depth,
|
||
DrawCommand::Polygon {
|
||
points: pts2,
|
||
fill: Some(dim(base, depth).with_alpha(0.62 * depth_alpha(depth))),
|
||
stroke: Some(dim(base, depth)),
|
||
stroke_w: 0.7,
|
||
},
|
||
));
|
||
}
|
||
|
||
// El observador, en su lugar real sobre la Tierra.
|
||
let obs_dir = dir(model.geo_latitude_deg, lon_obs);
|
||
let p = proj.project(obs_dir.scale(R_EARTH));
|
||
let oc = dim(pal.sun, p.depth);
|
||
items.push((
|
||
p.depth + 0.01,
|
||
DrawCommand::Circle {
|
||
cx: p.x,
|
||
cy: p.y,
|
||
r: size * 0.0075,
|
||
stroke: Some(oc),
|
||
fill: Some(oc.with_alpha(oc.a * if es_dia(obs_dir) { 0.6 } else { 0.15 })),
|
||
stroke_w: 1.2,
|
||
},
|
||
));
|
||
}
|
||
|
||
// =====================================================================
|
||
// 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<DrawCommand> {
|
||
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();
|
||
// El cénit del observador — disponible cuando se pide el horizonte.
|
||
// Lo usan tanto la sección del horizonte como el día/noche de los
|
||
// cuerpos.
|
||
let zenith = if opts.show_horizon {
|
||
Some(zenith_ecliptic(model.geo_latitude_deg, model.midheaven_deg, eps))
|
||
} else {
|
||
None
|
||
};
|
||
|
||
// (profundidad, comando) — se ordena al final.
|
||
let mut items: Vec<(f32, DrawCommand)> = Vec::new();
|
||
|
||
// --- Cuerpo de la esfera: sombreado con volumen ---
|
||
add_sphere_shading(&mut items, pal, center, rad);
|
||
|
||
// --- Cielo de fondo: Vía Láctea + estrellas (solo tema oscuro) ---
|
||
if opts.show_sky && pal.is_dark {
|
||
add_milky_way_glow(&mut items, &proj, eps, size, zenith);
|
||
add_starfield(&mut items, &proj, size, eps);
|
||
}
|
||
|
||
// --- Figuras de las constelaciones ---
|
||
if opts.show_constellations {
|
||
add_constellations(&mut items, &proj, eps, size, pal);
|
||
}
|
||
|
||
// --- 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<Vec3> = 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,
|
||
},
|
||
));
|
||
}
|
||
|
||
// --- Polos: eclípticos (punto dorado) y celestes (anillo + cruz) ---
|
||
for z in [1.0_f32, -1.0] {
|
||
let p = proj.project(Vec3::new(0.0, 0.0, z));
|
||
items.push((
|
||
p.depth + 0.001,
|
||
DrawCommand::Circle {
|
||
cx: p.x,
|
||
cy: p.y,
|
||
r: size * 0.009,
|
||
stroke: None,
|
||
fill: Some(dim(pal.dial_ring, p.depth)),
|
||
stroke_w: 0.0,
|
||
},
|
||
));
|
||
}
|
||
for (z, label) in [(1.0_f32, "PN"), (-1.0, "PS")] {
|
||
let pole = rot_x(Vec3::new(0.0, 0.0, z), eps);
|
||
let p = proj.project(pole);
|
||
let col = dim(pal.uranus, p.depth);
|
||
let arm = size * 0.013;
|
||
items.push((
|
||
p.depth + 0.001,
|
||
DrawCommand::Circle {
|
||
cx: p.x,
|
||
cy: p.y,
|
||
r: size * 0.012,
|
||
stroke: Some(col),
|
||
fill: None,
|
||
stroke_w: 1.2,
|
||
},
|
||
));
|
||
items.push((
|
||
p.depth + 0.001,
|
||
DrawCommand::Line {
|
||
x1: p.x - arm,
|
||
y1: p.y,
|
||
x2: p.x + arm,
|
||
y2: p.y,
|
||
color: col,
|
||
width: 1.0,
|
||
dash: None,
|
||
},
|
||
));
|
||
items.push((
|
||
p.depth + 0.001,
|
||
DrawCommand::Line {
|
||
x1: p.x,
|
||
y1: p.y - arm,
|
||
x2: p.x,
|
||
y2: p.y + arm,
|
||
color: col,
|
||
width: 1.0,
|
||
dash: None,
|
||
},
|
||
));
|
||
let lp = proj.project(pole.scale(1.13));
|
||
items.push((
|
||
lp.depth + 0.002,
|
||
DrawCommand::Text {
|
||
x: lp.x,
|
||
y: lp.y,
|
||
content: label.into(),
|
||
color: dim(pal.uranus, lp.depth),
|
||
size: size * 0.018,
|
||
anchor: TextAnchor::Middle,
|
||
},
|
||
));
|
||
}
|
||
|
||
// --- Horizonte local, cénit del observador y meridiano ---
|
||
if let Some(z) = zenith {
|
||
let horiz_color = if pal.is_dark {
|
||
Rgba::opaque(0.90, 0.58, 0.32)
|
||
} else {
|
||
Rgba::opaque(0.66, 0.38, 0.14)
|
||
};
|
||
add_loop(
|
||
&mut items,
|
||
&proj,
|
||
&great_circle_perp(z, 96),
|
||
horiz_color.with_alpha(0.90),
|
||
1.7,
|
||
);
|
||
// El meridiano local: círculo máximo por el cénit y el polo
|
||
// celeste — su normal es `z × NCP`.
|
||
let ncp = rot_x(Vec3::new(0.0, 0.0, 1.0), eps);
|
||
add_loop(
|
||
&mut items,
|
||
&proj,
|
||
&great_circle_perp(z.cross(ncp), 96),
|
||
pal.fg_muted.with_alpha(0.28),
|
||
0.7,
|
||
);
|
||
// Cénit — el punto geográfico del observador — y nadir.
|
||
add_point_marker(&mut items, &proj, z, pal.sun, size, "Cénit", true);
|
||
add_point_marker(
|
||
&mut items,
|
||
&proj,
|
||
z.scale(-1.0),
|
||
pal.fg_muted,
|
||
size,
|
||
"Nadir",
|
||
false,
|
||
);
|
||
}
|
||
|
||
// --- 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 (disco lleno) y topocéntricos (disco hueco
|
||
// + conector a su par geocéntrico) ---
|
||
if opts.show_bodies {
|
||
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)
|
||
};
|
||
// 1) Cuerpos natales (geocéntricos). Se recuerdan sus posiciones
|
||
// para poder tender el conector hacia los topocéntricos.
|
||
let mut natal_pos: Vec<(String, Vec3)> = Vec::new();
|
||
for layer in &model.layers {
|
||
if !matches!(layer.kind, LayerKind::Bodies) || layer.module_id != "natal" {
|
||
continue;
|
||
}
|
||
for g in &layer.glyphs {
|
||
let pos = eclip(g.deg);
|
||
natal_pos.push((g.symbol.clone(), pos));
|
||
let p = proj.project(pos);
|
||
let mut color = pal.planet(&g.symbol);
|
||
// Día/noche: un cuerpo bajo el horizonte se atenúa — de
|
||
// un vistazo se ve qué planetas estaban sobre la tierra
|
||
// en el momento de la carta.
|
||
if let Some(z) = zenith {
|
||
if pos.dot(z) < 0.0 {
|
||
color = color.with_alpha(color.a * 0.40);
|
||
}
|
||
}
|
||
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,
|
||
},
|
||
));
|
||
}
|
||
}
|
||
// 2) Cuerpos topocéntricos — si la capa está activa. Disco hueco
|
||
// (sin relleno, lo distingue del natal) + un conector hasta
|
||
// su par geocéntrico: el LARGO del conector es la paralaje,
|
||
// así no se miente sobre su magnitud (un cinturón aparte la
|
||
// exageraría — la diferencia es sub-grado salvo la Luna).
|
||
for layer in &model.layers {
|
||
if !matches!(layer.kind, LayerKind::Bodies) || layer.module_id != "topocentric" {
|
||
continue;
|
||
}
|
||
for g in &layer.glyphs {
|
||
let pos = eclip(g.deg);
|
||
let p = proj.project(pos);
|
||
let color = dim(pal.planet(&g.symbol), p.depth);
|
||
if let Some((_, npos)) = natal_pos.iter().find(|(s, _)| s == &g.symbol) {
|
||
let np = proj.project(*npos);
|
||
items.push((
|
||
p.depth - 0.001,
|
||
DrawCommand::Line {
|
||
x1: np.x,
|
||
y1: np.y,
|
||
x2: p.x,
|
||
y2: p.y,
|
||
color: color.with_alpha(color.a * 0.70),
|
||
width: 1.0,
|
||
dash: None,
|
||
},
|
||
));
|
||
}
|
||
items.push((
|
||
p.depth + 0.002,
|
||
DrawCommand::Circle {
|
||
cx: p.x,
|
||
cy: p.y,
|
||
r: size * 0.014,
|
||
stroke: Some(color),
|
||
fill: None,
|
||
stroke_w: 1.3,
|
||
},
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Estrellas fijas notables (capa del motor, si está activa) ---
|
||
// El motor emite la capa `FixedStars` con la longitud eclíptica ya
|
||
// precesionada; aquí se le suma la latitud para situarla en su
|
||
// lugar real de la esfera, no aplastada sobre la eclíptica.
|
||
for layer in &model.layers {
|
||
if !matches!(layer.kind, LayerKind::FixedStars) {
|
||
continue;
|
||
}
|
||
for g in &layer.glyphs {
|
||
let name = g.annotation.as_deref().unwrap_or("");
|
||
let pos = eclip_latlon(g.deg, fixed_star_latitude(name));
|
||
add_fixed_star(&mut items, &proj, pos, size, name, pal);
|
||
}
|
||
}
|
||
|
||
// --- Tierra interior: globo esquemático con el observador ---
|
||
if opts.show_earth {
|
||
add_inner_earth(&mut items, &proj, model, eps, size, center, rad, pal);
|
||
}
|
||
|
||
// 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,
|
||
geo_latitude_deg: -34.6,
|
||
geo_longitude_deg: -58.4,
|
||
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() {
|
||
// Sin constelaciones, para contar solo el esqueleto base.
|
||
let cmds = compose_sphere(
|
||
&modelo_demo(),
|
||
&SphereView::default(),
|
||
&SphereOpts { show_constellations: false, ..Default::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 signos + 4 ángulos + 2 polos celestes + cénit + nadir + 2
|
||
// cuerpos = 22 etiquetas de texto.
|
||
assert_eq!(textos, 22, "glifos de signos, ángulos, polos y cuerpos: {textos}");
|
||
}
|
||
|
||
#[test]
|
||
fn las_constelaciones_dibujan_sus_figuras() {
|
||
assert!(
|
||
crate::constellations_data::FIGURAS.len() > 80,
|
||
"el catálogo trae las 88 constelaciones"
|
||
);
|
||
let modelo = modelo_demo();
|
||
let lineas = |c: &[DrawCommand]| {
|
||
c.iter().filter(|d| matches!(d, DrawCommand::Line { .. })).count()
|
||
};
|
||
let con = compose_sphere(&modelo, &SphereView::default(), &SphereOpts::default());
|
||
let sin = compose_sphere(
|
||
&modelo,
|
||
&SphereView::default(),
|
||
&SphereOpts { show_constellations: false, ..Default::default() },
|
||
);
|
||
assert!(
|
||
lineas(&con) > lineas(&sin) + 500,
|
||
"las figuras agregan cientos de trazos: {} vs {}",
|
||
lineas(&con),
|
||
lineas(&sin),
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn el_cenit_esta_a_la_colatitud_del_polo_celeste() {
|
||
let eps = OBLICUIDAD_DEG.to_radians();
|
||
for &(lat, mc) in &[(-34.6_f32, 10.0_f32), (40.0, 200.0), (0.0, 95.0), (60.0, 300.0)] {
|
||
let z = zenith_ecliptic(lat, mc, eps);
|
||
let ncp = rot_x(Vec3::new(0.0, 0.0, 1.0), eps);
|
||
// El ángulo cénit↔polo celeste es la colatitud (90°−φ): su
|
||
// coseno —el producto punto de dos unitarios— es sin φ.
|
||
assert!(
|
||
(z.dot(ncp) - lat.to_radians().sin()).abs() < 1e-4,
|
||
"lat {lat}: z·NCP = {} vs sin φ = {}",
|
||
z.dot(ncp),
|
||
lat.to_radians().sin(),
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn el_cielo_dibuja_un_campo_de_estrellas() {
|
||
let modelo = modelo_demo();
|
||
let con = compose_sphere(
|
||
&modelo,
|
||
&SphereView::default(),
|
||
&SphereOpts { show_sky: true, ..Default::default() },
|
||
);
|
||
let sin = compose_sphere(
|
||
&modelo,
|
||
&SphereView::default(),
|
||
&SphereOpts { show_sky: false, ..Default::default() },
|
||
);
|
||
let discos = |c: &[DrawCommand]| {
|
||
c.iter().filter(|d| matches!(d, DrawCommand::Circle { .. })).count()
|
||
};
|
||
assert!(
|
||
discos(&con) > discos(&sin) + 300,
|
||
"el cielo agrega cientos de estrellas: {} vs {}",
|
||
discos(&con),
|
||
discos(&sin),
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn eclip_latlon_respeta_la_latitud() {
|
||
let sobre = eclip_latlon(123.0, 0.0);
|
||
assert!(sobre.z.abs() < 1e-5, "latitud 0 → sobre la eclíptica");
|
||
let polo = eclip_latlon(45.0, 90.0);
|
||
assert!((polo.z - 1.0).abs() < 1e-5, "latitud 90 → polo eclíptico");
|
||
let sirio = eclip_latlon(200.0, -39.61);
|
||
assert!((sirio.z - (-39.61_f32).to_radians().sin()).abs() < 1e-5);
|
||
}
|
||
|
||
#[test]
|
||
fn las_latitudes_de_estrellas_fijas_son_coherentes() {
|
||
// Sirio es la más austral; Vega la más boreal; Régulo casi
|
||
// sobre la eclíptica; una desconocida cae a latitud 0.
|
||
assert!(fixed_star_latitude("Sirius") < -30.0);
|
||
assert!(fixed_star_latitude("Vega") > 55.0);
|
||
assert!(fixed_star_latitude("Regulus").abs() < 1.0);
|
||
assert_eq!(fixed_star_latitude("Inexistente"), 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn compose_sphere_dibuja_las_estrellas_fijas_de_la_capa() {
|
||
let mut modelo = modelo_demo();
|
||
modelo.layers.push(Layer {
|
||
module_id: "fixed_stars".into(),
|
||
kind: LayerKind::FixedStars,
|
||
ring: 1.04,
|
||
z: 16,
|
||
geometry: Geometry::GlyphsOnly,
|
||
glyphs: vec![Glyph {
|
||
deg: 104.0,
|
||
symbol: "✦Sir".into(),
|
||
annotation: Some("Sirius".into()),
|
||
..Default::default()
|
||
}],
|
||
});
|
||
let cmds = compose_sphere(&modelo, &SphereView::default(), &SphereOpts::default());
|
||
assert!(
|
||
cmds.iter().any(|c| matches!(
|
||
c,
|
||
DrawCommand::Text { content, .. } if content == "Sirius"
|
||
)),
|
||
"la estrella fija de la capa aparece etiquetada en la esfera"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn el_observador_sobre_la_tierra_coincide_con_el_cenit() {
|
||
let eps = OBLICUIDAD_DEG.to_radians();
|
||
for &(lat, lon, mc) in &[(-34.6_f32, -58.4, 10.0), (40.0, 14.0, 200.0), (51.5, 0.0, 280.0)] {
|
||
let ramc = ramc_deg(mc, eps);
|
||
// El punto geográfico del observador mira exactamente al
|
||
// cénit — eso ancla la orientación de la Tierra interior.
|
||
let obs = geo_to_ecliptic(lat, lon, lon, ramc, eps);
|
||
let zen = zenith_ecliptic(lat, mc, eps);
|
||
assert!(obs.dot(zen) > 0.9999, "obs·cénit = {}", obs.dot(zen));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn la_tierra_interior_dibuja_continentes_rellenos() {
|
||
let modelo = modelo_demo();
|
||
let poligonos = |c: &[DrawCommand]| {
|
||
c.iter().filter(|d| matches!(d, DrawCommand::Polygon { .. })).count()
|
||
};
|
||
let con = compose_sphere(&modelo, &SphereView::default(), &SphereOpts::default());
|
||
let sin = compose_sphere(
|
||
&modelo,
|
||
&SphereView::default(),
|
||
&SphereOpts { show_earth: false, ..Default::default() },
|
||
);
|
||
assert_eq!(poligonos(&sin), 0, "sin Tierra no hay continentes");
|
||
assert!(
|
||
poligonos(&con) >= 6,
|
||
"la Tierra interior rellena cada continente como polígono"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn el_meridiano_contiene_cenit_polo_y_medio_cielo() {
|
||
let eps = OBLICUIDAD_DEG.to_radians();
|
||
for &(lat, mc) in &[(-34.6_f32, 10.0_f32), (40.0, 200.0), (51.5, 280.0)] {
|
||
let z = zenith_ecliptic(lat, mc, eps);
|
||
let ncp = rot_x(Vec3::new(0.0, 0.0, 1.0), eps);
|
||
// Cénit, polo celeste y MC son coplanares (el plano del
|
||
// meridiano) → su producto mixto se anula. Esto verifica
|
||
// que el RAMC se derivó bien del Medio Cielo.
|
||
let triple = z.cross(ncp).dot(eclip(mc));
|
||
assert!(triple.abs() < 1e-4, "lat {lat}, mc {mc}: triple = {triple}");
|
||
}
|
||
}
|
||
|
||
#[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"
|
||
);
|
||
}
|
||
}
|