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:
@@ -1496,6 +1496,7 @@ fn build_render_model(
|
|||||||
midheaven_deg,
|
midheaven_deg,
|
||||||
descendant_deg,
|
descendant_deg,
|
||||||
imum_coeli_deg,
|
imum_coeli_deg,
|
||||||
|
geo_latitude_deg: chart.birth_data.latitude_deg as f32,
|
||||||
layers: vec![sign_dial, houses, bodies, aspects_layer],
|
layers: vec![sign_dial, houses, bodies, aspects_layer],
|
||||||
overlays: Vec::new(),
|
overlays: Vec::new(),
|
||||||
aspect_summary: Vec::new(),
|
aspect_summary: Vec::new(),
|
||||||
|
|||||||
@@ -388,6 +388,7 @@ pub fn compute_mock(chart: &Chart) -> RenderModel {
|
|||||||
midheaven_deg: 270.0,
|
midheaven_deg: 270.0,
|
||||||
descendant_deg: 180.0,
|
descendant_deg: 180.0,
|
||||||
imum_coeli_deg: 90.0,
|
imum_coeli_deg: 90.0,
|
||||||
|
geo_latitude_deg: chart.birth_data.latitude_deg as f32,
|
||||||
layers: vec![sign_dial],
|
layers: vec![sign_dial],
|
||||||
overlays: Vec::new(),
|
overlays: Vec::new(),
|
||||||
aspect_summary: Vec::new(),
|
aspect_summary: Vec::new(),
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ mod tests {
|
|||||||
midheaven_deg: 270.0,
|
midheaven_deg: 270.0,
|
||||||
descendant_deg: 180.0,
|
descendant_deg: 180.0,
|
||||||
imum_coeli_deg: 90.0,
|
imum_coeli_deg: 90.0,
|
||||||
|
geo_latitude_deg: 0.0,
|
||||||
layers: vec![
|
layers: vec![
|
||||||
Layer {
|
Layer {
|
||||||
module_id: "natal".into(),
|
module_id: "natal".into(),
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ pub struct RenderModel {
|
|||||||
pub midheaven_deg: f32,
|
pub midheaven_deg: f32,
|
||||||
pub descendant_deg: f32,
|
pub descendant_deg: f32,
|
||||||
pub imum_coeli_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.
|
/// Capas a pintar. Orden = z-order ascendente.
|
||||||
pub layers: Vec<Layer>,
|
pub layers: Vec<Layer>,
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ pub struct SphereOpts {
|
|||||||
pub show_bodies: bool,
|
pub show_bodies: bool,
|
||||||
/// Los glifos y divisiones de los signos.
|
/// Los glifos y divisiones de los signos.
|
||||||
pub show_signs: bool,
|
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 {
|
impl Default for SphereOpts {
|
||||||
@@ -95,6 +98,7 @@ impl Default for SphereOpts {
|
|||||||
show_equator: true,
|
show_equator: true,
|
||||||
show_bodies: true,
|
show_bodies: true,
|
||||||
show_signs: true,
|
show_signs: true,
|
||||||
|
show_horizon: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,6 +121,24 @@ impl Vec3 {
|
|||||||
fn scale(self, k: f32) -> Self {
|
fn scale(self, k: f32) -> Self {
|
||||||
Self::new(self.x * k, self.y * k, self.z * k)
|
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
|
/// 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()
|
.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
|
/// Proyecta una polilínea cerrada y empuja un `Line` por segmento, con
|
||||||
/// la profundidad como clave de orden y la atenuación ya aplicada.
|
/// la profundidad como clave de orden y la atenuación ya aplicada.
|
||||||
fn add_loop(
|
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
|
// Composición
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -273,22 +465,20 @@ pub fn compose_sphere(
|
|||||||
let rad = size * 0.36;
|
let rad = size * 0.36;
|
||||||
let proj = Projector::new(view, center, center, rad);
|
let proj = Projector::new(view, center, center, rad);
|
||||||
let eps = opts.obliquity_deg.to_radians();
|
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.
|
// (profundidad, comando) — se ordena al final.
|
||||||
let mut items: Vec<(f32, DrawCommand)> = Vec::new();
|
let mut items: Vec<(f32, DrawCommand)> = Vec::new();
|
||||||
|
|
||||||
// --- Limbo: disco tenue de fondo + contorno (siempre al fondo) ---
|
// --- Cuerpo de la esfera: sombreado con volumen ---
|
||||||
items.push((
|
add_sphere_shading(&mut items, pal, center, rad);
|
||||||
-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,
|
|
||||||
},
|
|
||||||
));
|
|
||||||
|
|
||||||
// --- Rejilla: meridianos + paralelos de la eclíptica ---
|
// --- Rejilla: meridianos + paralelos de la eclíptica ---
|
||||||
if opts.show_grid {
|
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 ---
|
// --- Signos: espolón en cada borde + glifo en el centro ---
|
||||||
if opts.show_signs {
|
if opts.show_signs {
|
||||||
for i in 0..12 {
|
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)
|
Rgba::opaque(1.0, 1.0, 1.0).with_alpha(0.92)
|
||||||
};
|
};
|
||||||
for g in &layer.glyphs {
|
for g in &layer.glyphs {
|
||||||
let p = proj.project(eclip(g.deg));
|
let pos = eclip(g.deg);
|
||||||
let color = pal.planet(&g.symbol);
|
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((
|
items.push((
|
||||||
p.depth,
|
p.depth,
|
||||||
DrawCommand::Circle {
|
DrawCommand::Circle {
|
||||||
@@ -503,6 +808,7 @@ mod tests {
|
|||||||
midheaven_deg: 10.0,
|
midheaven_deg: 10.0,
|
||||||
descendant_deg: 280.0,
|
descendant_deg: 280.0,
|
||||||
imum_coeli_deg: 190.0,
|
imum_coeli_deg: 190.0,
|
||||||
|
geo_latitude_deg: -34.6,
|
||||||
layers: vec![Layer {
|
layers: vec![Layer {
|
||||||
module_id: "natal".into(),
|
module_id: "natal".into(),
|
||||||
kind: LayerKind::Bodies,
|
kind: LayerKind::Bodies,
|
||||||
@@ -530,8 +836,40 @@ mod tests {
|
|||||||
let lineas = cmds.iter().filter(|c| matches!(c, DrawCommand::Line { .. })).count();
|
let lineas = cmds.iter().filter(|c| matches!(c, DrawCommand::Line { .. })).count();
|
||||||
let textos = cmds.iter().filter(|c| matches!(c, DrawCommand::Text { .. })).count();
|
let textos = cmds.iter().filter(|c| matches!(c, DrawCommand::Text { .. })).count();
|
||||||
assert!(lineas > 100, "círculos máximos como polilíneas: {lineas}");
|
assert!(lineas > 100, "círculos máximos como polilíneas: {lineas}");
|
||||||
// 12 glifos de signo + 4 etiquetas de ángulo + 2 cuerpos.
|
// 12 signos + 4 ángulos + 2 polos celestes + cénit + nadir + 2
|
||||||
assert_eq!(textos, 18, "glifos de signos, ángulos y cuerpos: {textos}");
|
// 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]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user