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>
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user