From 3454b8ba1e92b52d561368187b70d41ff5db717f Mon Sep 17 00:00:00 2001 From: sergio Date: Fri, 22 May 2026 19:23:47 +0000 Subject: [PATCH] =?UTF-8?q?feat(cosmobiologia):=20esfera=203D=20=E2=80=94?= =?UTF-8?q?=20Tierra=20interior=20con=20continentes=20+=20topoc=C3=A9ntric?= =?UTF-8?q?os?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tierra interior: un globo pequeño y transparente en el centro de la esfera celeste, con los continentes esquemáticos (referenciales, no un mapa de precisión) y el observador marcado en su lugar real. Orientada por la longitud geográfica y el RAMC, de modo que el punto del observador mira exactamente al cénit — y gira con la vista, así que delata la rotación que el sombreado fijo no daba. Topocéntricos: la capa topocéntrica del motor se dibuja como disco hueco con un conector hasta su par geocéntrico. El LARGO del conector es la paralaje — honesto sobre su magnitud (un cinturón aparte la exageraría: la diferencia es sub-grado salvo la Luna). `RenderModel` gana `geo_longitude_deg` (lo puebla el bridge). 41 tests verdes (3 nuevos: orientación de la Tierra, observador↔cénit, continentes). Co-Authored-By: Claude Opus 4.7 --- .../cosmobiologia-engine/src/bridge.rs | 1 + .../cosmobiologia-engine/src/lib.rs | 1 + .../cosmobiologia-render/src/harmonic.rs | 1 + .../cosmobiologia-render/src/lib.rs | 5 + .../cosmobiologia-render/src/sphere3d.rs | 242 +++++++++++++++++- 5 files changed, 237 insertions(+), 13 deletions(-) diff --git a/crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs b/crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs index da5331e..9235d21 100644 --- a/crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs +++ b/crates/modules/cosmobiologia/cosmobiologia-engine/src/bridge.rs @@ -1497,6 +1497,7 @@ fn build_render_model( descendant_deg, imum_coeli_deg, geo_latitude_deg: chart.birth_data.latitude_deg as f32, + geo_longitude_deg: chart.birth_data.longitude_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 10b4b8d..8135603 100644 --- a/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs +++ b/crates/modules/cosmobiologia/cosmobiologia-engine/src/lib.rs @@ -389,6 +389,7 @@ pub fn compute_mock(chart: &Chart) -> RenderModel { descendant_deg: 180.0, imum_coeli_deg: 90.0, geo_latitude_deg: chart.birth_data.latitude_deg as f32, + geo_longitude_deg: chart.birth_data.longitude_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 5975fee..9b13518 100644 --- a/crates/modules/cosmobiologia/cosmobiologia-render/src/harmonic.rs +++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/harmonic.rs @@ -201,6 +201,7 @@ mod tests { descendant_deg: 180.0, imum_coeli_deg: 90.0, geo_latitude_deg: 0.0, + geo_longitude_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 2283604..54d5744 100644 --- a/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs +++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs @@ -76,6 +76,11 @@ pub struct RenderModel { /// observador. `default` = 0.0 para compat serde con modelos viejos. #[serde(default)] pub geo_latitude_deg: f32, + /// Longitud geográfica del lugar, en grados (este positivo). La + /// esfera 3D la usa para orientar la Tierra interior — que el + /// observador caiga en su continente real. `default` = 0.0. + #[serde(default)] + pub geo_longitude_deg: f32, /// Capas a pintar. Orden = z-order ascendente. pub layers: Vec, diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/sphere3d.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/sphere3d.rs index 4b49dc3..ee1acfd 100644 --- a/crates/modules/cosmobiologia/cosmobiologia-render/src/sphere3d.rs +++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/sphere3d.rs @@ -89,6 +89,9 @@ pub struct SphereOpts { /// El cielo de fondo: campo de estrellas + Vía Láctea. Solo se /// dibuja en tema oscuro (en papel rompería la metáfora de imprenta). pub show_sky: bool, + /// La Tierra interior — un globo pequeño, transparente, con los + /// continentes esquemáticos y el observador marcado en su lugar. + pub show_earth: bool, } impl Default for SphereOpts { @@ -103,6 +106,7 @@ impl Default for SphereOpts { show_signs: true, show_horizon: true, show_sky: true, + show_earth: true, } } } @@ -392,16 +396,22 @@ fn great_circle_perp(normal: Vec3, n: usize) -> Vec { .collect() } +/// RAMC — ascensión recta del Medio Cielo, en grados: la AR del punto +/// eclíptico del MC (latitud eclíptica 0). +fn ramc_deg(mc_deg: f32, eps_rad: f32) -> f32 { + let lmc = mc_deg.to_radians(); + (lmc.sin() * eps_rad.cos()) + .atan2(lmc.cos()) + .to_degrees() +} + /// 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. +/// justo sobre su cabeza. Tiene declinación `φ` (la latitud geográfica) +/// 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 ramc = ramc_deg(mc_deg, eps_rad).to_radians(); let (sphi, cphi) = phi.sin_cos(); let (sr, cr) = ramc.sin_cos(); rot_x(Vec3::new(cphi * cr, cphi * sr, sphi), eps_rad) @@ -639,6 +649,132 @@ fn add_fixed_star( )); } +// --- Tierra interior ------------------------------------------------ + +/// Contornos continentales **esquemáticos** (lat, lon en grados) — solo +/// referenciales, trazos muy gruesos para la Tierra interior. NO son un +/// mapa de precisión; dan el «ahí está tu continente» y nada más. +const CONTINENTES: &[&[(f32, f32)]] = &[ + // África + &[ + (35.0, -6.0), (37.0, 10.0), (33.0, 22.0), (31.0, 32.0), (12.0, 43.0), + (11.0, 51.0), (-4.0, 40.0), (-26.0, 33.0), (-34.0, 26.0), (-34.0, 19.0), + (-18.0, 12.0), (0.0, 9.0), (5.0, -4.0), (11.0, -15.0), (21.0, -17.0), + (28.0, -13.0), + ], + // Sudamérica + &[ + (12.0, -72.0), (11.0, -61.0), (5.0, -52.0), (-5.0, -35.0), (-23.0, -43.0), + (-34.0, -54.0), (-52.0, -69.0), (-55.0, -67.0), (-42.0, -74.0), + (-18.0, -70.0), (-5.0, -81.0), (2.0, -79.0), (8.0, -77.0), + ], + // Norteamérica + &[ + (70.0, -160.0), (71.0, -125.0), (68.0, -95.0), (63.0, -78.0), + (47.0, -53.0), (45.0, -67.0), (30.0, -81.0), (25.0, -81.0), + (20.0, -97.0), (23.0, -110.0), (34.0, -120.0), (48.0, -125.0), + (60.0, -148.0), + ], + // Eurasia + &[ + (36.0, -9.0), (43.0, -9.0), (58.0, 5.0), (71.0, 26.0), (73.0, 80.0), + (73.0, 140.0), (66.0, 180.0), (53.0, 141.0), (40.0, 130.0), (30.0, 122.0), + (22.0, 110.0), (9.0, 105.0), (8.0, 77.0), (21.0, 72.0), (25.0, 57.0), + (13.0, 45.0), (30.0, 33.0), (41.0, 28.0), (38.0, 15.0), (40.0, 0.0), + ], + // Australia + &[ + (-11.0, 131.0), (-12.0, 142.0), (-25.0, 153.0), (-38.0, 147.0), + (-35.0, 138.0), (-32.0, 116.0), (-22.0, 114.0), (-14.0, 127.0), + ], + // Antártida (casquete polar aproximado) + &[ + (-72.0, -180.0), (-70.0, -120.0), (-73.0, -60.0), (-70.0, 0.0), + (-73.0, 60.0), (-70.0, 120.0), (-72.0, 170.0), + ], +]; + +/// Dirección (marco eclíptico, unitaria) de un punto geográfico. La +/// longitud del observador y el RAMC fijan la fase de rotación de la +/// Tierra: el observador está en AR = RAMC, así que cualquier otra +/// longitud geográfica `lon` está en AR = RAMC + (lon − lon_obs). +fn geo_to_ecliptic(lat: f32, lon: f32, lon_obs: f32, ramc: f32, eps_rad: f32) -> Vec3 { + let ra = (ramc + lon - lon_obs).to_radians(); + let dec = lat.to_radians(); + let (sra, cra) = ra.sin_cos(); + let (sd, cd) = dec.sin_cos(); + rot_x(Vec3::new(cd * cra, cd * sra, sd), eps_rad) +} + +/// La Tierra interior: un globo pequeño y transparente en el centro de +/// la esfera celeste, con los continentes esquemáticos y el observador +/// marcado en su lugar real. Orientada de modo que el punto geográfico +/// del observador mira exactamente al cénit — y gira con la vista, así +/// que delata la rotación. +#[allow(clippy::too_many_arguments)] +fn add_inner_earth( + items: &mut Vec<(f32, DrawCommand)>, + proj: &Projector, + model: &RenderModel, + eps: f32, + size: f32, + center: f32, + rad: f32, + pal: &Palette, +) { + const R_EARTH: f32 = 0.26; + let ramc = ramc_deg(model.midheaven_deg, eps); + let lon_obs = model.geo_longitude_deg; + let geo = |lat: f32, lon: f32| -> Vec3 { + geo_to_ecliptic(lat, lon, lon_obs, ramc, eps).scale(R_EARTH) + }; + + // Limbo del globo — disco tenue. + items.push(( + -0.9, + DrawCommand::Circle { + cx: center, + cy: center, + r: R_EARTH * rad, + stroke: Some(pal.fg_muted.with_alpha(0.30)), + fill: Some(pal.water.with_alpha(if pal.is_dark { 0.12 } else { 0.07 })), + stroke_w: 0.8, + }, + )); + + // Ecuador terrestre. + let equator: Vec = (0..72) + .map(|i| geo(0.0, (i as f32) / 72.0 * 360.0)) + .collect(); + add_loop(items, proj, &equator, pal.fg_muted.with_alpha(0.22), 0.5); + + // Continentes — esquemáticos, muy transparentes. + let land = if pal.is_dark { + Rgba::opaque(0.50, 0.74, 0.58) + } else { + Rgba::opaque(0.26, 0.46, 0.32) + }; + for outline in CONTINENTES { + let pts: Vec = outline.iter().map(|&(lat, lon)| geo(lat, lon)).collect(); + add_loop(items, proj, &pts, land.with_alpha(0.36), 0.9); + } + + // El observador, en su lugar real sobre la Tierra. + let p = proj.project(geo(model.geo_latitude_deg, lon_obs)); + let oc = dim(pal.sun, p.depth); + items.push(( + p.depth + 0.01, + DrawCommand::Circle { + cx: p.x, + cy: p.y, + r: size * 0.0075, + stroke: Some(oc), + fill: Some(oc.with_alpha(oc.a * 0.5)), + stroke_w: 1.2, + }, + )); +} + // ===================================================================== // Composición // ===================================================================== @@ -906,19 +1042,24 @@ pub fn compose_sphere( )); } - // --- Cuerpos natales sobre la eclíptica --- + // --- Cuerpos: natales (disco lleno) y topocéntricos (disco hueco + // + conector a su par geocéntrico) --- if opts.show_bodies { + let halo = if pal.is_dark { + pal.bg_panel.with_alpha(0.92) + } else { + Rgba::opaque(1.0, 1.0, 1.0).with_alpha(0.92) + }; + // 1) Cuerpos natales (geocéntricos). Se recuerdan sus posiciones + // para poder tender el conector hacia los topocéntricos. + let mut natal_pos: Vec<(String, Vec3)> = Vec::new(); for layer in &model.layers { if !matches!(layer.kind, LayerKind::Bodies) || layer.module_id != "natal" { continue; } - let halo = if pal.is_dark { - pal.bg_panel.with_alpha(0.92) - } else { - Rgba::opaque(1.0, 1.0, 1.0).with_alpha(0.92) - }; for g in &layer.glyphs { let pos = eclip(g.deg); + natal_pos.push((g.symbol.clone(), pos)); let p = proj.project(pos); let mut color = pal.planet(&g.symbol); // Día/noche: un cuerpo bajo el horizonte se atenúa — de @@ -953,6 +1094,47 @@ pub fn compose_sphere( )); } } + // 2) Cuerpos topocéntricos — si la capa está activa. Disco hueco + // (sin relleno, lo distingue del natal) + un conector hasta + // su par geocéntrico: el LARGO del conector es la paralaje, + // así no se miente sobre su magnitud (un cinturón aparte la + // exageraría — la diferencia es sub-grado salvo la Luna). + for layer in &model.layers { + if !matches!(layer.kind, LayerKind::Bodies) || layer.module_id != "topocentric" { + continue; + } + for g in &layer.glyphs { + let pos = eclip(g.deg); + let p = proj.project(pos); + let color = dim(pal.planet(&g.symbol), p.depth); + if let Some((_, npos)) = natal_pos.iter().find(|(s, _)| s == &g.symbol) { + let np = proj.project(*npos); + items.push(( + p.depth - 0.001, + DrawCommand::Line { + x1: np.x, + y1: np.y, + x2: p.x, + y2: p.y, + color: color.with_alpha(color.a * 0.70), + width: 1.0, + dash: None, + }, + )); + } + items.push(( + p.depth + 0.002, + DrawCommand::Circle { + cx: p.x, + cy: p.y, + r: size * 0.014, + stroke: Some(color), + fill: None, + stroke_w: 1.3, + }, + )); + } + } } // --- Estrellas fijas notables (capa del motor, si está activa) --- @@ -970,6 +1152,11 @@ pub fn compose_sphere( } } + // --- Tierra interior: globo esquemático con el observador --- + if opts.show_earth { + add_inner_earth(&mut items, &proj, model, eps, size, center, rad, 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() @@ -1021,6 +1208,7 @@ mod tests { descendant_deg: 280.0, imum_coeli_deg: 190.0, geo_latitude_deg: -34.6, + geo_longitude_deg: -58.4, layers: vec![Layer { module_id: "natal".into(), kind: LayerKind::Bodies, @@ -1140,6 +1328,34 @@ mod tests { ); } + #[test] + fn el_observador_sobre_la_tierra_coincide_con_el_cenit() { + let eps = OBLICUIDAD_DEG.to_radians(); + for &(lat, lon, mc) in &[(-34.6_f32, -58.4, 10.0), (40.0, 14.0, 200.0), (51.5, 0.0, 280.0)] { + let ramc = ramc_deg(mc, eps); + // El punto geográfico del observador mira exactamente al + // cénit — eso ancla la orientación de la Tierra interior. + let obs = geo_to_ecliptic(lat, lon, lon, ramc, eps); + let zen = zenith_ecliptic(lat, mc, eps); + assert!(obs.dot(zen) > 0.9999, "obs·cénit = {}", obs.dot(zen)); + } + } + + #[test] + fn la_tierra_interior_dibuja_continentes() { + let modelo = modelo_demo(); + let lineas = |c: &[DrawCommand]| { + c.iter().filter(|d| matches!(d, DrawCommand::Line { .. })).count() + }; + let con = compose_sphere(&modelo, &SphereView::default(), &SphereOpts::default()); + let sin = compose_sphere( + &modelo, + &SphereView::default(), + &SphereOpts { show_earth: false, ..Default::default() }, + ); + assert!(lineas(&con) > lineas(&sin), "los continentes agregan trazos"); + } + #[test] fn el_meridiano_contiene_cenit_polo_y_medio_cielo() { let eps = OBLICUIDAD_DEG.to_radians();