feat(cosmobiologia): Tierra interior — tinte mar/continente + día/noche
La Tierra interior ahora se lee como un planeta: - Mar y continentes teñidos distinto: el mar es un disco azul, los continentes son polígonos rellenos de verde. Para eso se sumó la primitiva DrawCommand::Polygon (relleno + trazo) — agnóstica, con su traductor GPUI y su emisor SVG. - Sombreado día/noche según el Sol de la carta: el hemisferio que mira al Sol se ilumina (resplandor concéntrico sobre el punto subsolar, que se apaga si el Sol queda detrás de la Tierra), el terminador marca la línea día/noche, y cada continente se tiñe verde claro u oscuro según esté de día o de noche. El observador se atenúa si nació de noche. 42 tests verdes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1138,6 +1138,16 @@ fn render_sphere(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
DrawCommand::Polygon { points, fill, stroke, stroke_w } => {
|
||||||
|
paint_polygon(
|
||||||
|
window,
|
||||||
|
points,
|
||||||
|
ox,
|
||||||
|
oy,
|
||||||
|
(*fill).map(rgba_to_hsla),
|
||||||
|
(*stroke).map(|c| (rgba_to_hsla(c), *stroke_w)),
|
||||||
|
);
|
||||||
|
}
|
||||||
DrawCommand::Text { .. } => {}
|
DrawCommand::Text { .. } => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3113,6 +3123,43 @@ fn paint_glow(window: &mut Window, cx: f32, cy: f32, base_r: f32, color: Hsla) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pinta un polígono cerrado: relleno y/o trazo. `points` en coords del
|
||||||
|
/// lienzo (sin el offset del bounds — se le suma `ox`/`oy`).
|
||||||
|
fn paint_polygon(
|
||||||
|
window: &mut Window,
|
||||||
|
points: &[(f32, f32)],
|
||||||
|
ox: f32,
|
||||||
|
oy: f32,
|
||||||
|
fill: Option<Hsla>,
|
||||||
|
stroke: Option<(Hsla, f32)>,
|
||||||
|
) {
|
||||||
|
if points.len() < 3 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(color) = fill {
|
||||||
|
let mut b = PathBuilder::fill();
|
||||||
|
b.move_to(point(px(ox + points[0].0), px(oy + points[0].1)));
|
||||||
|
for p in &points[1..] {
|
||||||
|
b.line_to(point(px(ox + p.0), px(oy + p.1)));
|
||||||
|
}
|
||||||
|
b.close();
|
||||||
|
if let Ok(path) = b.build() {
|
||||||
|
window.paint_path(path, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some((color, w)) = stroke {
|
||||||
|
let mut b = PathBuilder::stroke(px(w));
|
||||||
|
b.move_to(point(px(ox + points[0].0), px(oy + points[0].1)));
|
||||||
|
for p in &points[1..] {
|
||||||
|
b.line_to(point(px(ox + p.0), px(oy + p.1)));
|
||||||
|
}
|
||||||
|
b.line_to(point(px(ox + points[0].0), px(oy + points[0].1)));
|
||||||
|
if let Ok(path) = b.build() {
|
||||||
|
window.paint_path(path, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn fill_circle(window: &mut Window, cx: f32, cy: f32, r: f32, color: Hsla) {
|
fn fill_circle(window: &mut Window, cx: f32, cy: f32, r: f32, color: Hsla) {
|
||||||
const SEGMENTS: usize = 32;
|
const SEGMENTS: usize = 32;
|
||||||
let mut builder = PathBuilder::fill();
|
let mut builder = PathBuilder::fill();
|
||||||
|
|||||||
@@ -85,6 +85,16 @@ pub enum DrawCommand {
|
|||||||
#[serde(default = "default_anchor")]
|
#[serde(default = "default_anchor")]
|
||||||
anchor: TextAnchor,
|
anchor: TextAnchor,
|
||||||
},
|
},
|
||||||
|
/// Polígono cerrado — lista de vértices, con relleno y/o trazo.
|
||||||
|
Polygon {
|
||||||
|
points: Vec<(f32, f32)>,
|
||||||
|
#[serde(default)]
|
||||||
|
fill: Option<Rgba>,
|
||||||
|
#[serde(default)]
|
||||||
|
stroke: Option<Rgba>,
|
||||||
|
#[serde(default = "default_stroke_width")]
|
||||||
|
stroke_w: f32,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_stroke_width() -> f32 {
|
fn default_stroke_width() -> f32 {
|
||||||
@@ -557,6 +567,25 @@ pub fn draw_commands_to_svg(commands: &[DrawCommand], size: f32) -> String {
|
|||||||
x1, y1, x2, y2, color.to_css(), width, dash_attr
|
x1, y1, x2, y2, color.to_css(), width, dash_attr
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
DrawCommand::Polygon { points, fill, stroke, stroke_w } => {
|
||||||
|
let pts: String = points
|
||||||
|
.iter()
|
||||||
|
.map(|(x, y)| format!("{:.2},{:.2} ", x, y))
|
||||||
|
.collect();
|
||||||
|
let fill_attr = match fill {
|
||||||
|
Some(c) => format!(" fill=\"{}\"", c.to_css()),
|
||||||
|
None => " fill=\"none\"".into(),
|
||||||
|
};
|
||||||
|
let stroke_attr = stroke
|
||||||
|
.map(|c| format!(" stroke=\"{}\" stroke-width=\"{}\"", c.to_css(), stroke_w))
|
||||||
|
.unwrap_or_default();
|
||||||
|
s.push_str(&format!(
|
||||||
|
"<polygon points=\"{}\"{}{}/>",
|
||||||
|
pts.trim_end(),
|
||||||
|
fill_attr,
|
||||||
|
stroke_attr
|
||||||
|
));
|
||||||
|
}
|
||||||
DrawCommand::Text { x, y, content, color, size: sz, anchor } => {
|
DrawCommand::Text { x, y, content, color, size: sz, anchor } => {
|
||||||
let anchor_attr = match anchor {
|
let anchor_attr = match anchor {
|
||||||
TextAnchor::Start => "start",
|
TextAnchor::Start => "start",
|
||||||
|
|||||||
@@ -793,11 +793,11 @@ fn geo_to_ecliptic(lat: f32, lon: f32, lon_obs: f32, ramc: f32, eps_rad: f32) ->
|
|||||||
rot_x(Vec3::new(cd * cra, cd * sra, sd), eps_rad)
|
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 Tierra interior: un globo pequeño en el centro de la esfera
|
||||||
/// la esfera celeste, con los continentes esquemáticos y el observador
|
/// celeste, con el **mar** y los **continentes** teñidos distinto, un
|
||||||
|
/// **sombreado día/noche** según la posición del Sol, y el observador
|
||||||
/// marcado en su lugar real. Orientada de modo que el punto geográfico
|
/// 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í
|
/// del observador mira al cénit — y gira con la vista.
|
||||||
/// que delata la rotación.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn add_inner_earth(
|
fn add_inner_earth(
|
||||||
items: &mut Vec<(f32, DrawCommand)>,
|
items: &mut Vec<(f32, DrawCommand)>,
|
||||||
@@ -812,42 +812,129 @@ fn add_inner_earth(
|
|||||||
const R_EARTH: f32 = 0.26;
|
const R_EARTH: f32 = 0.26;
|
||||||
let ramc = ramc_deg(model.midheaven_deg, eps);
|
let ramc = ramc_deg(model.midheaven_deg, eps);
|
||||||
let lon_obs = model.geo_longitude_deg;
|
let lon_obs = model.geo_longitude_deg;
|
||||||
let geo = |lat: f32, lon: f32| -> Vec3 {
|
// Dirección unitaria de un punto geográfico (sin escalar).
|
||||||
geo_to_ecliptic(lat, lon, lon_obs, ramc, eps).scale(R_EARTH)
|
let dir = |lat: f32, lon: f32| geo_to_ecliptic(lat, lon, lon_obs, ramc, eps);
|
||||||
};
|
// El mismo punto, escalado al radio de la Tierra interior.
|
||||||
|
let geo = |lat: f32, lon: f32| dir(lat, lon).scale(R_EARTH);
|
||||||
|
|
||||||
// Limbo del globo — disco tenue.
|
// El Sol de la carta (si está) — para el día/noche. El lado de la
|
||||||
|
// Tierra que mira al Sol es el día: un punto `d` está de día si
|
||||||
|
// `d · sol > 0`.
|
||||||
|
let sun_dir: Option<Vec3> = model
|
||||||
|
.layers
|
||||||
|
.iter()
|
||||||
|
.filter(|l| matches!(l.kind, LayerKind::Bodies) && l.module_id == "natal")
|
||||||
|
.flat_map(|l| l.glyphs.iter())
|
||||||
|
.find(|g| g.symbol == "sun")
|
||||||
|
.map(|g| eclip(g.deg));
|
||||||
|
let es_dia = |d: Vec3| -> bool { sun_dir.map(|s| d.dot(s) > 0.0).unwrap_or(true) };
|
||||||
|
|
||||||
|
// Mar — disco base teñido de azul.
|
||||||
|
let sea = if pal.is_dark {
|
||||||
|
Rgba::opaque(0.10, 0.21, 0.39)
|
||||||
|
} else {
|
||||||
|
Rgba::opaque(0.58, 0.72, 0.86)
|
||||||
|
};
|
||||||
items.push((
|
items.push((
|
||||||
-0.9,
|
-0.95,
|
||||||
DrawCommand::Circle {
|
DrawCommand::Circle {
|
||||||
cx: center,
|
cx: center,
|
||||||
cy: center,
|
cy: center,
|
||||||
r: R_EARTH * rad,
|
r: R_EARTH * rad,
|
||||||
stroke: Some(pal.fg_muted.with_alpha(0.30)),
|
stroke: Some(pal.fg_muted.with_alpha(0.30)),
|
||||||
fill: Some(pal.water.with_alpha(if pal.is_dark { 0.12 } else { 0.07 })),
|
fill: Some(sea.with_alpha(0.55)),
|
||||||
stroke_w: 0.8,
|
stroke_w: 0.8,
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// Resplandor diurno — el hemisferio iluminado. Discos concéntricos
|
||||||
|
// sobre el punto subsolar; se apagan si el Sol queda detrás de la
|
||||||
|
// Tierra (entonces vemos su cara nocturna).
|
||||||
|
if let Some(s) = sun_dir {
|
||||||
|
let sub = proj.project(s.scale(R_EARTH));
|
||||||
|
let face = ((sub.depth / R_EARTH) * 0.5 + 0.5).clamp(0.0, 1.0);
|
||||||
|
let day = if pal.is_dark {
|
||||||
|
Rgba::opaque(0.40, 0.60, 0.85)
|
||||||
|
} else {
|
||||||
|
Rgba::opaque(1.0, 0.98, 0.88)
|
||||||
|
};
|
||||||
|
for i in 0..10 {
|
||||||
|
let t = i as f32 / 9.0;
|
||||||
|
items.push((
|
||||||
|
-0.93 + t * 0.04,
|
||||||
|
DrawCommand::Circle {
|
||||||
|
cx: sub.x,
|
||||||
|
cy: sub.y,
|
||||||
|
r: R_EARTH * rad * (1.0 - 0.92 * t),
|
||||||
|
stroke: None,
|
||||||
|
fill: Some(day.with_alpha(0.07 * face)),
|
||||||
|
stroke_w: 0.0,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ecuador terrestre.
|
// Ecuador terrestre.
|
||||||
let equator: Vec<Vec3> = (0..72)
|
let equator: Vec<Vec3> = (0..72)
|
||||||
.map(|i| geo(0.0, (i as f32) / 72.0 * 360.0))
|
.map(|i| geo(0.0, (i as f32) / 72.0 * 360.0))
|
||||||
.collect();
|
.collect();
|
||||||
add_loop(items, proj, &equator, pal.fg_muted.with_alpha(0.22), 0.5);
|
add_loop(items, proj, &equator, pal.fg_muted.with_alpha(0.20), 0.5);
|
||||||
|
|
||||||
// Continentes — esquemáticos, muy transparentes.
|
// Terminador — la línea día/noche, círculo máximo ⊥ al Sol.
|
||||||
let land = if pal.is_dark {
|
if let Some(s) = sun_dir {
|
||||||
Rgba::opaque(0.50, 0.74, 0.58)
|
let term: Vec<Vec3> = great_circle_perp(s, 72)
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.scale(R_EARTH))
|
||||||
|
.collect();
|
||||||
|
add_loop(items, proj, &term, pal.angle_highlight.with_alpha(0.45), 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continentes — polígonos rellenos, teñidos de verde; el tono
|
||||||
|
// depende de si la masa está de día o de noche.
|
||||||
|
let land_day = if pal.is_dark {
|
||||||
|
Rgba::opaque(0.38, 0.60, 0.34)
|
||||||
} else {
|
} else {
|
||||||
Rgba::opaque(0.26, 0.46, 0.32)
|
Rgba::opaque(0.52, 0.66, 0.40)
|
||||||
|
};
|
||||||
|
let land_night = if pal.is_dark {
|
||||||
|
Rgba::opaque(0.13, 0.25, 0.19)
|
||||||
|
} else {
|
||||||
|
Rgba::opaque(0.40, 0.50, 0.40)
|
||||||
};
|
};
|
||||||
for outline in CONTINENTES {
|
for outline in CONTINENTES {
|
||||||
let pts: Vec<Vec3> = outline.iter().map(|&(lat, lon)| geo(lat, lon)).collect();
|
let pts3: Vec<Vec3> = outline.iter().map(|&(lat, lon)| geo(lat, lon)).collect();
|
||||||
add_loop(items, proj, &pts, land.with_alpha(0.36), 0.9);
|
let mut cen = Vec3::new(0.0, 0.0, 0.0);
|
||||||
|
let mut depth_sum = 0.0_f32;
|
||||||
|
let pts2: Vec<(f32, f32)> = pts3
|
||||||
|
.iter()
|
||||||
|
.map(|v| {
|
||||||
|
cen = Vec3::new(cen.x + v.x, cen.y + v.y, cen.z + v.z);
|
||||||
|
let p = proj.project(*v);
|
||||||
|
depth_sum += p.depth;
|
||||||
|
(p.x, p.y)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let n = pts3.len().max(1) as f32;
|
||||||
|
let depth = depth_sum / n;
|
||||||
|
let base = if es_dia(cen.scale(1.0 / n)) {
|
||||||
|
land_day
|
||||||
|
} else {
|
||||||
|
land_night
|
||||||
|
};
|
||||||
|
items.push((
|
||||||
|
depth,
|
||||||
|
DrawCommand::Polygon {
|
||||||
|
points: pts2,
|
||||||
|
fill: Some(dim(base, depth).with_alpha(0.62 * depth_alpha(depth))),
|
||||||
|
stroke: Some(dim(base, depth)),
|
||||||
|
stroke_w: 0.7,
|
||||||
|
},
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// El observador, en su lugar real sobre la Tierra.
|
// El observador, en su lugar real sobre la Tierra.
|
||||||
let p = proj.project(geo(model.geo_latitude_deg, lon_obs));
|
let obs_dir = dir(model.geo_latitude_deg, lon_obs);
|
||||||
|
let p = proj.project(obs_dir.scale(R_EARTH));
|
||||||
let oc = dim(pal.sun, p.depth);
|
let oc = dim(pal.sun, p.depth);
|
||||||
items.push((
|
items.push((
|
||||||
p.depth + 0.01,
|
p.depth + 0.01,
|
||||||
@@ -856,7 +943,7 @@ fn add_inner_earth(
|
|||||||
cy: p.y,
|
cy: p.y,
|
||||||
r: size * 0.0075,
|
r: size * 0.0075,
|
||||||
stroke: Some(oc),
|
stroke: Some(oc),
|
||||||
fill: Some(oc.with_alpha(oc.a * 0.5)),
|
fill: Some(oc.with_alpha(oc.a * if es_dia(obs_dir) { 0.6 } else { 0.15 })),
|
||||||
stroke_w: 1.2,
|
stroke_w: 1.2,
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
@@ -1463,10 +1550,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn la_tierra_interior_dibuja_continentes() {
|
fn la_tierra_interior_dibuja_continentes_rellenos() {
|
||||||
let modelo = modelo_demo();
|
let modelo = modelo_demo();
|
||||||
let lineas = |c: &[DrawCommand]| {
|
let poligonos = |c: &[DrawCommand]| {
|
||||||
c.iter().filter(|d| matches!(d, DrawCommand::Line { .. })).count()
|
c.iter().filter(|d| matches!(d, DrawCommand::Polygon { .. })).count()
|
||||||
};
|
};
|
||||||
let con = compose_sphere(&modelo, &SphereView::default(), &SphereOpts::default());
|
let con = compose_sphere(&modelo, &SphereView::default(), &SphereOpts::default());
|
||||||
let sin = compose_sphere(
|
let sin = compose_sphere(
|
||||||
@@ -1474,7 +1561,11 @@ mod tests {
|
|||||||
&SphereView::default(),
|
&SphereView::default(),
|
||||||
&SphereOpts { show_earth: false, ..Default::default() },
|
&SphereOpts { show_earth: false, ..Default::default() },
|
||||||
);
|
);
|
||||||
assert!(lineas(&con) > lineas(&sin), "los continentes agregan trazos");
|
assert_eq!(poligonos(&sin), 0, "sin Tierra no hay continentes");
|
||||||
|
assert!(
|
||||||
|
poligonos(&con) >= 6,
|
||||||
|
"la Tierra interior rellena cada continente como polígono"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user