tawasuyu

tawasuyu/brahman

Rust 483TOML 191Markdown 50JSON 176771 Sutichaykuna

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 · (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

5 qillqakuna · +362 −16

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]