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>
This commit is contained in:
sergio
2026-05-22 18:58:34 +00:00
parent 65c88ccf25
commit 93856cd4d7
@@ -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();