feat(cosmobiologia): esfera 3D — estrellas fijas notables en su lugar real

La esfera ahora dibuja las 9 estrellas fijas del motor (Sirio, Régulo,
Antares, Spica, Aldebarán, Fomalhaut, Algol, Vega, Pólux) — disco
brillante con destello de cuatro rayos y su nombre.

La longitud eclíptica —la coordenada astrológicamente viva, que
precesiona— viene intacta del motor (`build_fixed_stars_overlay`). El
módulo nuevo solo le suma la **latitud eclíptica** (valor de catálogo,
~constante con la precesión) para situar cada estrella en su lugar
real de la esfera en vez de aplastada sobre la eclíptica: Sirio cae
bien al sur, Vega bien al norte, Régulo casi sobre la eclíptica.

Se ven al activar el módulo «Estrellas fijas» en el panel. 39 tests
verdes (3 nuevos: eclip_latlon, coherencia de latitudes, render).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-22 19:03:27 +00:00
parent 93856cd4d7
commit e94023d8af
@@ -199,6 +199,14 @@ fn eclip(deg: f32) -> Vec3 {
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();
@@ -554,6 +562,83 @@ fn add_starfield(items: &mut Vec<(f32, DrawCommand)>, proj: &Projector, size: f3
}
}
// --- 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,
},
));
}
// =====================================================================
// Composición
// =====================================================================
@@ -870,6 +955,21 @@ pub fn compose_sphere(
}
}
// --- 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);
}
}
// 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()
@@ -994,6 +1094,52 @@ mod tests {
);
}
#[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_meridiano_contiene_cenit_polo_y_medio_cielo() {
let eps = OBLICUIDAD_DEG.to_radians();