feat(cosmobiologia): esfera 3D — la piel real del cielo: estrellas + Vía Láctea
La «piel» de una esfera celeste no son continentes —esos van en la
Tierra— sino las estrellas y la Vía Láctea. Y a diferencia del brillo
especular (fijo a la pantalla), esta piel gira CON la esfera, así que
delata la rotación de un vistazo.
- Campo de estrellas isótropo, decorativo (no un catálogo real),
generado con un hash determinista — no titila entre frames.
- Vía Láctea: una sobredensidad de estrellas tenues a lo largo del
plano galáctico, ubicado con el polo galáctico real (J2000, AR
192.859° / Dec +27.128°).
- Estrellas con brillo y tinte variables (azuladas / cálidas),
atenuadas por profundidad. Van detrás de la rejilla, delante del
sombreado — un fondo de planetario. Solo en tema oscuro.
36 tests verdes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
93856cd4d7617025de1837fcdca42c6ad9dceb59 · by sergio · 2026-05-22T18:58:34Z (5 months ago) · parent 65c88ccf2 · browse the tree at this commit
Semantic delta · 13
crates/modules/cosmobiologia/cosmobiologia-render/src/sphere3d.rs
diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/sphere3d.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/sphere3d.rs
index 96c68d3..23ecec2 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-render/src/sphere3d.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/sphere3d.rs
@@ -86,6 +86,9 @@ pub struct SphereOpts {
/// 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,
}
impl Default for SphereOpts {
@@ -99,6 +102,7 @@ impl Default for SphereOpts {
show_bodies: true,
show_signs: true,
show_horizon: true,
+ show_sky: true,
}
}
}
@@ -447,6 +451,109 @@ fn add_point_marker(
));
}
+// --- 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;
+
+/// 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));
+ }
+}
+
// =====================================================================
// Composición
// =====================================================================
@@ -480,6 +587,11 @@ pub fn compose_sphere(
// --- Cuerpo de la esfera: sombreado con volumen ---
add_sphere_shading(&mut items, pal, center, rad);
+ // --- Cielo de fondo: estrellas + Vía Láctea (solo tema oscuro) ---
+ if opts.show_sky && pal.is_dark {
+ add_starfield(&mut items, &proj, size, eps);
+ }
+
// --- Rejilla: meridianos + paralelos de la eclíptica ---
if opts.show_grid {
let grid = pal.fg_muted.with_alpha(0.16);
@@ -858,6 +970,30 @@ mod tests {
}
}
+ #[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 el_meridiano_contiene_cenit_polo_y_medio_cielo() {
let eps = OBLICUIDAD_DEG.to_radians();