//! `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 { (0..n) .map(|i| eclip((i as f32) / (n as f32) * 360.0)) .collect() } /// Un meridiano eclíptico: círculo máximo por ambos polos a la /// longitud `lon0`. fn meridian_points(lon0: f32, n: usize) -> Vec { let (ls, lc) = lon0.to_radians().sin_cos(); (0..n) .map(|i| { let a = (i as f32) / (n as f32) * std::f32::consts::TAU; let (asin, acos) = a.sin_cos(); Vec3::new(acos * lc, acos * ls, asin) }) .collect() } /// Un paralelo eclíptico: círculo menor a la latitud `beta`. fn parallel_points(beta: f32, n: usize) -> Vec { let (bs, bc) = beta.to_radians().sin_cos(); (0..n) .map(|i| { let lon = (i as f32) / (n as f32) * 360.0; let (ls, lc) = lon.to_radians().sin_cos(); Vec3::new(bc * lc, bc * ls, bs) }) .collect() } /// 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 = 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 { 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, ) { 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 = 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 = (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 = 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 = 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 { 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 = 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" ); } }