Files
brahman/crates/modules/cosmobiologia/cosmobiologia-render/src/sphere3d.rs
T
sergio 6e30dc2d72 feat(cosmobiologia): esfera 3D — switch de constelaciones + luz de la Vía Láctea
- 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>
2026-05-22 19:40:11 +00:00

1640 lines
56 KiB
Rust
Raw Blame History

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