feat(cosmobiologia): esfera 3D batch 2 — horizonte local, cénit y relieve
Sobre el batch 1 (eclíptica + ecuador + cuerpos):
- Horizonte local: círculo máximo perpendicular al cénit, derivado de
la latitud geográfica y el RAMC. El cénit (declinación φ, AR RAMC,
llevado al marco eclíptico) es el «punto del observador» — marcado
como tal, con su nadir y el meridiano local.
- Día/noche: los cuerpos bajo el horizonte se atenúan — de un vistazo
se ve qué planetas estaban sobre la tierra en el momento de la carta.
- Marcadores de polos: eclípticos (punto dorado) y celestes (anillo +
cruz, etiquetados PN/PS) — el ángulo entre ambos ejes ES la
oblicuidad, ahora visible.
- Relieve de la esfera: disco base + degradado radial + brillo
especular desplazado a la luz — volumen sin gradientes nativos.
- RenderModel gana `geo_latitude_deg` (#[serde(default)]); el bridge
lo puebla desde birth_data.
Verificación: 2 tests nuevos fijan la construcción del cénit — está a
la colatitud del polo celeste, y cénit/polo/MC son coplanares (el
plano del meridiano), lo que ancla el RAMC. 35 tests verdes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
8fc26b0c0c39cbcc347a705a4ae7b72806c6dbb3 · ruwaq sergio · 2026-05-22T18:49:02Z (5 killa ñawpaq) · tayta 900cd19e4 · kay commitpi sach'ata qhaway
Semántico sayk'uy · 20
crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs
crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs
crates/modules/cosmobiologia/cosmobiologia-render/src/harmonic.rs
crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs
crates/modules/cosmobiologia/cosmobiologia-render/src/sphere3d.rs
diff --git a/crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs b/crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs
index 8743e60..da5331e 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs
@@ -1496,6 +1496,7 @@ fn build_render_model(
midheaven_deg,
descendant_deg,
imum_coeli_deg,
+ geo_latitude_deg: chart.birth_data.latitude_deg as f32,
layers: vec![sign_dial, houses, bodies, aspects_layer],
overlays: Vec::new(),
aspect_summary: Vec::new(),
diff --git a/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs
index 1db0aed..10b4b8d 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs
@@ -388,6 +388,7 @@ pub fn compute_mock(chart: &Chart) -> RenderModel {
midheaven_deg: 270.0,
descendant_deg: 180.0,
imum_coeli_deg: 90.0,
+ geo_latitude_deg: chart.birth_data.latitude_deg as f32,
layers: vec![sign_dial],
overlays: Vec::new(),
aspect_summary: Vec::new(),
diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/harmonic.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/harmonic.rs
index f0987b0..5975fee 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-render/src/harmonic.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/harmonic.rs
@@ -200,6 +200,7 @@ mod tests {
midheaven_deg: 270.0,
descendant_deg: 180.0,
imum_coeli_deg: 90.0,
+ geo_latitude_deg: 0.0,
layers: vec![
Layer {
module_id: "natal".into(),
diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs
index 8aa7b53..2283604 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs
@@ -71,6 +71,11 @@ pub struct RenderModel {
pub midheaven_deg: f32,
pub descendant_deg: f32,
pub imum_coeli_deg: f32,
+ /// Latitud geográfica del lugar, en grados. La vista de esfera 3D
+ /// la usa para construir el horizonte local y el cénit del
+ /// observador. `default` = 0.0 para compat serde con modelos viejos.
+ #[serde(default)]
+ pub geo_latitude_deg: f32,
/// Capas a pintar. Orden = z-order ascendente.
pub layers: Vec<Layer>,
diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/sphere3d.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/sphere3d.rs
index f20ae0f..96c68d3 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-render/src/sphere3d.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/sphere3d.rs
@@ -83,6 +83,9 @@ pub struct SphereOpts {
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,
}
impl Default for SphereOpts {
@@ -95,6 +98,7 @@ impl Default for SphereOpts {
show_equator: true,
show_bodies: true,
show_signs: true,
+ show_horizon: true,
}
}
}
@@ -117,6 +121,24 @@ impl Vec3 {
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
@@ -226,6 +248,89 @@ fn parallel_points(beta: f32, n: usize) -> Vec<Vec3> {
.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.04)),
+ stroke_w: 0.0,
+ },
+ ));
+ }
+ // Brillo especular desplazado hacia la luz.
+ let hx = center - rad * 0.34;
+ let hy = center - rad * 0.34;
+ const HALO: usize = 7;
+ 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.05)),
+ 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(
@@ -255,6 +360,93 @@ fn add_loop(
}
}
+/// 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()
+}
+
+/// El cénit del observador en el marco eclíptico — el punto del cielo
+/// justo sobre su cabeza. Se deriva de la latitud geográfica `φ` y de
+/// la ascensión recta del Medio Cielo (RAMC): el cénit tiene
+/// declinación `φ` 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 lmc = mc_deg.to_radians();
+ // RAMC: AR del punto eclíptico del MC (latitud eclíptica 0).
+ let ramc = (lmc.sin() * eps_rad.cos()).atan2(lmc.cos());
+ 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,
+ },
+ ));
+}
+
// =====================================================================
// Composición
// =====================================================================
@@ -273,22 +465,20 @@ pub fn compose_sphere(
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();
- // --- Limbo: disco tenue de fondo + contorno (siempre al fondo) ---
- items.push((
- -100.0,
- DrawCommand::Circle {
- cx: center,
- cy: center,
- r: rad,
- stroke: Some(pal.fg_muted.with_alpha(0.22)),
- fill: Some(pal.water.with_alpha(if pal.is_dark { 0.07 } else { 0.05 })),
- stroke_w: 1.0,
- },
- ));
+ // --- Cuerpo de la esfera: sombreado con volumen ---
+ add_sphere_shading(&mut items, pal, center, rad);
// --- Rejilla: meridianos + paralelos de la eclíptica ---
if opts.show_grid {
@@ -341,6 +531,112 @@ pub fn compose_sphere(
));
}
+ // --- 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 {
@@ -425,8 +721,17 @@ pub fn compose_sphere(
Rgba::opaque(1.0, 1.0, 1.0).with_alpha(0.92)
};
for g in &layer.glyphs {
- let p = proj.project(eclip(g.deg));
- let color = pal.planet(&g.symbol);
+ let pos = eclip(g.deg);
+ 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 {
@@ -503,6 +808,7 @@ mod tests {
midheaven_deg: 10.0,
descendant_deg: 280.0,
imum_coeli_deg: 190.0,
+ geo_latitude_deg: -34.6,
layers: vec![Layer {
module_id: "natal".into(),
kind: LayerKind::Bodies,
@@ -530,8 +836,40 @@ mod tests {
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 glifos de signo + 4 etiquetas de ángulo + 2 cuerpos.
- assert_eq!(textos, 18, "glifos de signos, ángulos y cuerpos: {textos}");
+ // 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 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_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]