feat(cosmobiologia): esfera celeste 3D — la carta como objeto rotable
GPUI no es 3D y empotrar wgpu sería frágil; la esfera celeste es de alambre —círculos máximos y puntos— y eso se proyecta a software con trigonometría pura. Cada superficie ya sabe dibujar DrawCommand, así que el módulo nuevo solo decide dónde cae cada trazo: una esfera real, rotable, sin una línea de GPU. - cosmobiologia-render/sphere3d.rs: marco eclíptico (z=0), proyección ortográfica con yaw/pitch, eclíptica + ecuador celeste inclinado por la oblicuidad (se cruzan en los equinoccios, como en el cielo), rejilla de meridianos/paralelos, signos, ángulos y cuerpos natales. Algoritmo del pintor + atenuación del hemisferio lejano. 5 tests. - compose_sphere emite Vec<DrawCommand> — lo consumen igual el canvas gpui y el SVG del cliente web. - cosmobiologia-canvas: modo esfera 3D en el lienzo (tecla V o el botón flotante «Esfera 3D»), drag para orbitar, traductor DrawCommand→GPUI. Falta (2da capa): el horizonte local + día/noche — necesita la latitud geográfica, que aún no viaja en el RenderModel. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,7 @@ use cosmobiologia_engine::{
|
|||||||
OUTER_RING_MODULES,
|
OUTER_RING_MODULES,
|
||||||
};
|
};
|
||||||
use cosmobiologia_model::{ChartId, ContactId, GroupId};
|
use cosmobiologia_model::{ChartId, ContactId, GroupId};
|
||||||
|
use cosmobiologia_render::{compose_sphere, DrawCommand, Palette, SphereOpts, SphereView};
|
||||||
use cosmobiologia_theme::{AspectKind as TAspectKind, AstroPalette, Element, Planet};
|
use cosmobiologia_theme::{AspectKind as TAspectKind, AstroPalette, Element, Planet};
|
||||||
use nahual_theme::Theme;
|
use nahual_theme::Theme;
|
||||||
|
|
||||||
@@ -125,6 +126,15 @@ struct PanDragState {
|
|||||||
pan_y_start: f32,
|
pan_y_start: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drag activo de rotación de la esfera 3D. El delta del cursor desde
|
||||||
|
/// `start` se suma a `yaw`/`pitch` de partida — arrastrar = orbitar.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
struct SphereDragState {
|
||||||
|
start: Point<Pixels>,
|
||||||
|
yaw_start: f32,
|
||||||
|
pitch_start: f32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct CanvasState {
|
pub struct CanvasState {
|
||||||
pub mode: CanvasMode,
|
pub mode: CanvasMode,
|
||||||
@@ -156,8 +166,14 @@ pub struct CanvasState {
|
|||||||
/// El canvas dibuja su perfil como una curva en el footer; el valle
|
/// El canvas dibuja su perfil como una curva en el footer; el valle
|
||||||
/// marca la hora de nacimiento que mejor explica los eventos.
|
/// marca la hora de nacimiento que mejor explica los eventos.
|
||||||
pub rectificacion: Option<Rectificacion>,
|
pub rectificacion: Option<Rectificacion>,
|
||||||
|
/// `true` cuando la carta se muestra como esfera celeste 3D en vez
|
||||||
|
/// de la rueda 2D. Solo aplica al modo `Wheel`.
|
||||||
|
pub sphere_3d: bool,
|
||||||
|
/// Orientación de la esfera 3D — la muta el drag.
|
||||||
|
pub sphere_view: SphereView,
|
||||||
drag_jog: Option<JogDragState>,
|
drag_jog: Option<JogDragState>,
|
||||||
drag_pan: Option<PanDragState>,
|
drag_pan: Option<PanDragState>,
|
||||||
|
drag_sphere: Option<SphereDragState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Límites del zoom — bajo 0.5 los glyphs se vuelven ilegibles; sobre
|
/// Límites del zoom — bajo 0.5 los glyphs se vuelven ilegibles; sobre
|
||||||
@@ -240,8 +256,11 @@ impl Default for CanvasState {
|
|||||||
show_coords: true,
|
show_coords: true,
|
||||||
hover: None,
|
hover: None,
|
||||||
rectificacion: None,
|
rectificacion: None,
|
||||||
|
sphere_3d: false,
|
||||||
|
sphere_view: SphereView::default(),
|
||||||
drag_jog: None,
|
drag_jog: None,
|
||||||
drag_pan: None,
|
drag_pan: None,
|
||||||
|
drag_sphere: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -357,6 +376,13 @@ impl AstrologyCanvas {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Alterna entre la rueda 2D y la esfera celeste 3D. Solo tiene
|
||||||
|
/// efecto visible cuando hay una carta cargada (`CanvasMode::Wheel`).
|
||||||
|
pub fn toggle_sphere(&mut self, cx: &mut Context<'_, Self>) {
|
||||||
|
self.state.sphere_3d = !self.state.sphere_3d;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
/// Resetea zoom y pan a sus defaults (1.0 y 0,0). No toca rotation
|
/// Resetea zoom y pan a sus defaults (1.0 y 0,0). No toca rotation
|
||||||
/// ni time offset — esos son ortogonales y tienen su propio reset.
|
/// ni time offset — esos son ortogonales y tienen su propio reset.
|
||||||
pub fn reset_view(&mut self, cx: &mut Context<'_, Self>) {
|
pub fn reset_view(&mut self, cx: &mut Context<'_, Self>) {
|
||||||
@@ -694,6 +720,35 @@ impl AstrologyCanvas {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- Internos: rotación de la esfera 3D -----
|
||||||
|
|
||||||
|
fn on_sphere_down(&mut self, position: Point<Pixels>) {
|
||||||
|
self.state.drag_sphere = Some(SphereDragState {
|
||||||
|
start: position,
|
||||||
|
yaw_start: self.state.sphere_view.yaw_deg,
|
||||||
|
pitch_start: self.state.sphere_view.pitch_deg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_sphere_move(&mut self, position: Point<Pixels>, cx: &mut Context<'_, Self>) {
|
||||||
|
let Some(drag) = self.state.drag_sphere else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let dx: f32 = (position.x - drag.start.x).into();
|
||||||
|
let dy: f32 = (position.y - drag.start.y).into();
|
||||||
|
// 0.4°/px da una rotación cómoda. El pitch se clampea para no
|
||||||
|
// dar vuelta la esfera de adentro hacia afuera.
|
||||||
|
self.state.sphere_view.yaw_deg = (drag.yaw_start + dx * 0.4).rem_euclid(360.0);
|
||||||
|
self.state.sphere_view.pitch_deg = (drag.pitch_start + dy * 0.4).clamp(-89.0, 89.0);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_sphere_up(&mut self, cx: &mut Context<'_, Self>) {
|
||||||
|
if self.state.drag_sphere.take().is_some() {
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn on_scroll(
|
fn on_scroll(
|
||||||
&mut self,
|
&mut self,
|
||||||
event: &ScrollWheelEvent,
|
event: &ScrollWheelEvent,
|
||||||
@@ -762,6 +817,10 @@ impl AstrologyCanvas {
|
|||||||
cx.emit(CanvasEvent::ExportSvgRequested);
|
cx.emit(CanvasEvent::ExportSvgRequested);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
"v" | "V" => {
|
||||||
|
self.toggle_sphere(cx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
_ => return,
|
_ => return,
|
||||||
};
|
};
|
||||||
self.toggle_layer(kind, cx);
|
self.toggle_layer(kind, cx);
|
||||||
@@ -851,6 +910,15 @@ impl Render for AstrologyCanvas {
|
|||||||
|
|
||||||
let body = match &self.state.mode {
|
let body = match &self.state.mode {
|
||||||
CanvasMode::Empty => render_empty(&theme),
|
CanvasMode::Empty => render_empty(&theme),
|
||||||
|
CanvasMode::Wheel { render } if self.state.sphere_3d => render_sphere(
|
||||||
|
&theme,
|
||||||
|
render,
|
||||||
|
self.state.sphere_view,
|
||||||
|
self.state.view_scale,
|
||||||
|
self.state.view_pan_x,
|
||||||
|
self.state.view_pan_y,
|
||||||
|
entity,
|
||||||
|
),
|
||||||
CanvasMode::Wheel { render } => render_wheel(
|
CanvasMode::Wheel { render } => render_wheel(
|
||||||
&theme,
|
&theme,
|
||||||
&palette,
|
&palette,
|
||||||
@@ -869,6 +937,34 @@ impl Render for AstrologyCanvas {
|
|||||||
CanvasMode::Thumbnails { items, .. } => render_thumbnails(&theme, items),
|
CanvasMode::Thumbnails { items, .. } => render_thumbnails(&theme, items),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Botón flotante 2D ⇄ 3D — visible solo con una carta cargada.
|
||||||
|
// Muestra el modo al que se cambiará, no el activo.
|
||||||
|
let sphere_toggle = matches!(self.state.mode, CanvasMode::Wheel { .. }).then(|| {
|
||||||
|
let label = if self.state.sphere_3d {
|
||||||
|
"Rueda 2D"
|
||||||
|
} else {
|
||||||
|
"Esfera 3D"
|
||||||
|
};
|
||||||
|
div()
|
||||||
|
.absolute()
|
||||||
|
.top(px(12.0))
|
||||||
|
.right(px(12.0))
|
||||||
|
.px(px(11.0))
|
||||||
|
.py(px(5.0))
|
||||||
|
.rounded(px(6.0))
|
||||||
|
.bg(theme.bg_panel_alt.clone())
|
||||||
|
.border_1()
|
||||||
|
.border_color(theme.border)
|
||||||
|
.text_size(px(11.0))
|
||||||
|
.text_color(theme.fg_text)
|
||||||
|
.cursor_pointer()
|
||||||
|
.child(label)
|
||||||
|
.on_mouse_down(
|
||||||
|
MouseButton::Left,
|
||||||
|
cx.listener(|this, _, _w, cx| this.toggle_sphere(cx)),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
// Depth field: capa absoluta detrás del body, ocupa todo el
|
// Depth field: capa absoluta detrás del body, ocupa todo el
|
||||||
// canvas. Vignette radial — el centro queda claro y los
|
// canvas. Vignette radial — el centro queda claro y los
|
||||||
// bordes se oscurecen, dando profundidad sin "ruido" de
|
// bordes se oscurecen, dando profundidad sin "ruido" de
|
||||||
@@ -908,6 +1004,7 @@ impl Render for AstrologyCanvas {
|
|||||||
.justify_center()
|
.justify_center()
|
||||||
.child(body),
|
.child(body),
|
||||||
)
|
)
|
||||||
|
.children(sphere_toggle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -969,6 +1066,169 @@ fn render_thumbnails(theme: &Theme, items: &[ThumbnailItem]) -> gpui::Div {
|
|||||||
row
|
row
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Esfera celeste 3D
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
/// Render del modo esfera 3D. Compone la escena agnóstica con
|
||||||
|
/// `compose_sphere` (en `cosmobiologia-render`) y traduce sus
|
||||||
|
/// `DrawCommand`s a primitivas GPUI: las líneas y discos se pintan en
|
||||||
|
/// el `canvas`; los glifos van como hijos DOM sobre las coordenadas ya
|
||||||
|
/// proyectadas. El drag rota la esfera.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn render_sphere(
|
||||||
|
theme: &Theme,
|
||||||
|
render: &RenderModel,
|
||||||
|
view: SphereView,
|
||||||
|
view_scale: f32,
|
||||||
|
view_pan_x: f32,
|
||||||
|
view_pan_y: f32,
|
||||||
|
entity: gpui::Entity<AstrologyCanvas>,
|
||||||
|
) -> gpui::Div {
|
||||||
|
let sphere_size = WHEEL_SIZE * view_scale;
|
||||||
|
let opts = SphereOpts {
|
||||||
|
size: sphere_size,
|
||||||
|
palette: if theme.is_dark {
|
||||||
|
Palette::dark()
|
||||||
|
} else {
|
||||||
|
Palette::light()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let commands = compose_sphere(render, &view, &opts);
|
||||||
|
// Las líneas y círculos se pintan en el canvas; el texto va al DOM.
|
||||||
|
let paint_cmds: Vec<DrawCommand> = commands
|
||||||
|
.iter()
|
||||||
|
.filter(|c| !matches!(c, DrawCommand::Text { .. }))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let entity_for_canvas = entity.clone();
|
||||||
|
let canvas_element = canvas(
|
||||||
|
move |_b: Bounds<Pixels>, _w, _cx| (),
|
||||||
|
move |bounds: Bounds<Pixels>, _, window, _| {
|
||||||
|
let ox: f32 = bounds.origin.x.into();
|
||||||
|
let oy: f32 = bounds.origin.y.into();
|
||||||
|
for cmd in &paint_cmds {
|
||||||
|
match cmd {
|
||||||
|
DrawCommand::Line { x1, y1, x2, y2, color, width, dash } => {
|
||||||
|
paint_segment(
|
||||||
|
window,
|
||||||
|
ox + *x1,
|
||||||
|
oy + *y1,
|
||||||
|
ox + *x2,
|
||||||
|
oy + *y2,
|
||||||
|
rgba_to_hsla(*color),
|
||||||
|
*dash,
|
||||||
|
*width,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
DrawCommand::Circle { cx, cy, r, stroke, fill, stroke_w } => {
|
||||||
|
if let Some(f) = fill {
|
||||||
|
fill_circle(window, ox + *cx, oy + *cy, *r, rgba_to_hsla(*f));
|
||||||
|
}
|
||||||
|
if let Some(s) = stroke {
|
||||||
|
stroke_circle(
|
||||||
|
window,
|
||||||
|
ox + *cx,
|
||||||
|
oy + *cy,
|
||||||
|
*r,
|
||||||
|
*stroke_w,
|
||||||
|
rgba_to_hsla(*s),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DrawCommand::Text { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag para orbitar la esfera.
|
||||||
|
let ent = entity_for_canvas.clone();
|
||||||
|
window.on_mouse_event(move |ev: &MouseDownEvent, _, _w, cx| {
|
||||||
|
if ev.button == MouseButton::Left && bounds.contains(&ev.position) {
|
||||||
|
ent.update(cx, |this, _cx| this.on_sphere_down(ev.position));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let ent = entity_for_canvas.clone();
|
||||||
|
window.on_mouse_event(move |ev: &MouseMoveEvent, _, _w, cx| {
|
||||||
|
if ev.dragging() {
|
||||||
|
ent.update(cx, |this, cx| this.on_sphere_move(ev.position, cx));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let ent = entity_for_canvas.clone();
|
||||||
|
window.on_mouse_event(move |_: &MouseUpEvent, _, _w, cx| {
|
||||||
|
ent.update(cx, |this, cx| this.on_sphere_up(cx));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.absolute()
|
||||||
|
.w(px(sphere_size))
|
||||||
|
.h(px(sphere_size));
|
||||||
|
|
||||||
|
let mut sphere = div()
|
||||||
|
.relative()
|
||||||
|
.w(px(sphere_size))
|
||||||
|
.h(px(sphere_size))
|
||||||
|
.ml(px(view_pan_x))
|
||||||
|
.mt(px(view_pan_y))
|
||||||
|
.child(canvas_element);
|
||||||
|
|
||||||
|
// Glifos (signos, ángulos, cuerpos) como hijos DOM, ubicados sobre
|
||||||
|
// las coordenadas que ya proyectó `compose_sphere`.
|
||||||
|
for cmd in &commands {
|
||||||
|
if let DrawCommand::Text { x, y, content, color, size, .. } = cmd {
|
||||||
|
sphere = sphere.child(centered_glyph(
|
||||||
|
*x,
|
||||||
|
*y,
|
||||||
|
size * 1.9,
|
||||||
|
*size,
|
||||||
|
content.clone().into(),
|
||||||
|
rgba_to_hsla(*color),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pista de interacción al pie.
|
||||||
|
sphere.child(
|
||||||
|
div()
|
||||||
|
.absolute()
|
||||||
|
.bottom(px(6.0))
|
||||||
|
.left(px(0.0))
|
||||||
|
.w(px(sphere_size))
|
||||||
|
.flex()
|
||||||
|
.justify_center()
|
||||||
|
.text_size(px(10.0))
|
||||||
|
.text_color(theme.fg_disabled)
|
||||||
|
.child("Arrastrá para rotar la esfera"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convierte un `Rgba` agnóstico (`[0,1]^4`) al `Hsla` de gpui.
|
||||||
|
fn rgba_to_hsla(c: cosmobiologia_render::Rgba) -> Hsla {
|
||||||
|
let (r, g, b) = (c.r, c.g, c.b);
|
||||||
|
let max = r.max(g).max(b);
|
||||||
|
let min = r.min(g).min(b);
|
||||||
|
let l = (max + min) / 2.0;
|
||||||
|
let d = max - min;
|
||||||
|
if d < 1e-6 {
|
||||||
|
return hsla(0.0, 0.0, l.clamp(0.0, 1.0), c.a.clamp(0.0, 1.0));
|
||||||
|
}
|
||||||
|
let s = d / (1.0 - (2.0 * l - 1.0).abs());
|
||||||
|
let h = (if max == r {
|
||||||
|
((g - b) / d).rem_euclid(6.0)
|
||||||
|
} else if max == g {
|
||||||
|
(b - r) / d + 2.0
|
||||||
|
} else {
|
||||||
|
(r - g) / d + 4.0
|
||||||
|
}) * 60.0;
|
||||||
|
hsla(
|
||||||
|
h.rem_euclid(360.0) / 360.0,
|
||||||
|
s.clamp(0.0, 1.0),
|
||||||
|
l.clamp(0.0, 1.0),
|
||||||
|
c.a.clamp(0.0, 1.0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// Wheel
|
// Wheel
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|||||||
@@ -582,7 +582,7 @@ fn svg_escape(s: &str) -> String {
|
|||||||
.replace('"', """)
|
.replace('"', """)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sign_unicode(name: &str) -> &'static str {
|
pub(crate) fn sign_unicode(name: &str) -> &'static str {
|
||||||
match name {
|
match name {
|
||||||
"aries" => "♈",
|
"aries" => "♈",
|
||||||
"taurus" => "♉",
|
"taurus" => "♉",
|
||||||
@@ -622,7 +622,7 @@ fn planet_unicode(name: &str) -> &'static str {
|
|||||||
|
|
||||||
/// Glyph del cuerpo con sufijo "℞" si está retrógrado — concatenación
|
/// Glyph del cuerpo con sufijo "℞" si está retrógrado — concatenación
|
||||||
/// directa en el text para no agregar más comandos por planeta.
|
/// directa en el text para no agregar más comandos por planeta.
|
||||||
fn planet_unicode_with_retro(name: &str, retrograde: bool) -> String {
|
pub(crate) fn planet_unicode_with_retro(name: &str, retrograde: bool) -> String {
|
||||||
if retrograde {
|
if retrograde {
|
||||||
format!("{}℞", planet_unicode(name))
|
format!("{}℞", planet_unicode(name))
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ pub mod gr;
|
|||||||
pub mod harmonic;
|
pub mod harmonic;
|
||||||
pub mod math;
|
pub mod math;
|
||||||
pub mod palette;
|
pub mod palette;
|
||||||
|
pub mod sphere3d;
|
||||||
|
|
||||||
pub use draw::{
|
pub use draw::{
|
||||||
compose_wheel, draw_commands_to_svg, CompositionOpts, DrawCommand, Rgba, TextAnchor,
|
compose_wheel, draw_commands_to_svg, CompositionOpts, DrawCommand, Rgba, TextAnchor,
|
||||||
@@ -45,6 +46,7 @@ pub use math::{
|
|||||||
find_clusters, format_coord_compact, polar_to_screen, spread_angles, Radii,
|
find_clusters, format_coord_compact, polar_to_screen, spread_angles, Radii,
|
||||||
};
|
};
|
||||||
pub use palette::Palette;
|
pub use palette::Palette;
|
||||||
|
pub use sphere3d::{compose_sphere, SphereOpts, SphereView, OBLICUIDAD_DEG};
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// RenderModel — lo que el client renderea
|
// RenderModel — lo que el client renderea
|
||||||
|
|||||||
@@ -0,0 +1,545 @@
|
|||||||
|
//! `sphere3d` — la esfera celeste en 3D, proyectada a primitivas 2D.
|
||||||
|
//!
|
||||||
|
//! GPUI no es un motor 3D y empotrar wgpu sería frágil. La estrategia
|
||||||
|
//! es otra: la esfera celeste es un objeto de **alambre** —círculos
|
||||||
|
//! máximos y puntos—, y eso se proyecta a software con trigonometría
|
||||||
|
//! pura. Cada superficie (canvas gpui nativo, SVG del cliente web) ya
|
||||||
|
//! sabe traducir un [`DrawCommand`] (línea, círculo, texto); este
|
||||||
|
//! módulo solo decide DÓNDE cae cada trazo. Resultado: una esfera
|
||||||
|
//! celeste real, rotable, sin una sola línea de GPU.
|
||||||
|
//!
|
||||||
|
//! ## Marco de coordenadas — la eclíptica como plano de referencia
|
||||||
|
//!
|
||||||
|
//! El plano de la eclíptica es el plano `z = 0`. El eje `x` apunta al
|
||||||
|
//! 0° de Aries (el punto vernal). Una longitud eclíptica `λ` —con
|
||||||
|
//! latitud eclíptica ≈ 0, lo cual vale para los planetas— es el punto
|
||||||
|
//! unitario `(cos λ, sin λ, 0)`. El polo norte de la eclíptica es
|
||||||
|
//! `(0, 0, 1)`.
|
||||||
|
//!
|
||||||
|
//! El **ecuador celeste** es ese mismo círculo inclinado por la
|
||||||
|
//! oblicuidad ε ≈ 23.44° alrededor del eje `x`: los dos se cruzan en
|
||||||
|
//! los equinoccios, exactamente como en el cielo. Ver esa inclinación
|
||||||
|
//! —imposible en la rueda 2D— es el corazón de esta vista.
|
||||||
|
//!
|
||||||
|
//! ## Lo que esta primera entrega NO hace todavía
|
||||||
|
//!
|
||||||
|
//! El **horizonte local** (y con él el día/noche: qué planetas están
|
||||||
|
//! sobre el horizonte) necesita la latitud geográfica del lugar, que
|
||||||
|
//! hoy no viaja en el [`RenderModel`]. Queda para una segunda capa.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::draw::{planet_unicode_with_retro, sign_unicode, DrawCommand, Rgba, TextAnchor};
|
||||||
|
use crate::palette::Palette;
|
||||||
|
use crate::{LayerKind, RenderModel};
|
||||||
|
|
||||||
|
/// Oblicuidad media de la eclíptica, en grados. Varía ~0.013°/siglo —
|
||||||
|
/// despreciable para una vista de alambre, así que se fija.
|
||||||
|
pub const OBLICUIDAD_DEG: f32 = 23.4393;
|
||||||
|
|
||||||
|
const SIGN_NAMES: [&str; 12] = [
|
||||||
|
"aries", "taurus", "gemini", "cancer", "leo", "virgo", "libra",
|
||||||
|
"scorpio", "sagittarius", "capricorn", "aquarius", "pisces",
|
||||||
|
];
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Cámara — cómo se orienta la esfera frente al observador
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
/// Orientación de la esfera frente a la cámara. El usuario la muta
|
||||||
|
/// arrastrando: `yaw` gira alrededor del eje polar de la eclíptica,
|
||||||
|
/// `pitch` inclina la cámara hacia arriba o abajo.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
pub struct SphereView {
|
||||||
|
/// Giro alrededor del eje polar de la eclíptica, en grados.
|
||||||
|
pub yaw_deg: f32,
|
||||||
|
/// Inclinación de la cámara, en grados. Un `pitch` negativo mira la
|
||||||
|
/// esfera desde el norte hacia abajo, dejando la eclíptica como una
|
||||||
|
/// elipse abierta en vez de una raya de canto.
|
||||||
|
pub pitch_deg: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SphereView {
|
||||||
|
fn default() -> Self {
|
||||||
|
// Tres-cuartos desde arriba: la eclíptica se ve como un aro
|
||||||
|
// ancho y la inclinación del ecuador se lee de inmediato.
|
||||||
|
Self { yaw_deg: 26.0, pitch_deg: -64.0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opciones de composición de la esfera.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SphereOpts {
|
||||||
|
/// Lado en px del cuadrado contenedor.
|
||||||
|
pub size: f32,
|
||||||
|
pub palette: Palette,
|
||||||
|
/// Oblicuidad de la eclíptica en grados (ver [`OBLICUIDAD_DEG`]).
|
||||||
|
pub obliquity_deg: f32,
|
||||||
|
/// Rejilla de meridianos y paralelos eclípticos.
|
||||||
|
pub show_grid: bool,
|
||||||
|
/// El ecuador celeste y el eje de la Tierra.
|
||||||
|
pub show_equator: bool,
|
||||||
|
/// Los cuerpos natales sobre la eclíptica.
|
||||||
|
pub show_bodies: bool,
|
||||||
|
/// Los glifos y divisiones de los signos.
|
||||||
|
pub show_signs: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SphereOpts {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
size: 600.0,
|
||||||
|
palette: Palette::dark(),
|
||||||
|
obliquity_deg: OBLICUIDAD_DEG,
|
||||||
|
show_grid: true,
|
||||||
|
show_equator: true,
|
||||||
|
show_bodies: true,
|
||||||
|
show_signs: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Vector 3D y proyección
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct Vec3 {
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
z: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Vec3 {
|
||||||
|
fn new(x: f32, y: f32, z: f32) -> Self {
|
||||||
|
Self { x, y, z }
|
||||||
|
}
|
||||||
|
fn scale(self, k: f32) -> Self {
|
||||||
|
Self::new(self.x * k, self.y * k, self.z * k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Un punto ya proyectado a la pantalla, con su profundidad conservada
|
||||||
|
/// para ordenar de atrás hacia adelante y atenuar el hemisferio lejano.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct Projected {
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
/// `+` hacia el observador (frente), `−` lejos (fondo).
|
||||||
|
depth: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Proyector ortográfico: gira un punto por la cámara (`yaw` alrededor
|
||||||
|
/// del eje polar, `pitch` alrededor del eje horizontal de pantalla) y
|
||||||
|
/// lo aplana a coordenadas de pantalla.
|
||||||
|
struct Projector {
|
||||||
|
ys: f32,
|
||||||
|
yc: f32,
|
||||||
|
ps: f32,
|
||||||
|
pc: f32,
|
||||||
|
ox: f32,
|
||||||
|
oy: f32,
|
||||||
|
rad: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Projector {
|
||||||
|
fn new(view: &SphereView, ox: f32, oy: f32, rad: f32) -> Self {
|
||||||
|
let (ys, yc) = view.yaw_deg.to_radians().sin_cos();
|
||||||
|
let (ps, pc) = view.pitch_deg.to_radians().sin_cos();
|
||||||
|
Self { ys, yc, ps, pc, ox, oy, rad }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project(&self, p: Vec3) -> Projected {
|
||||||
|
// 1) yaw alrededor del eje Z (polar de la eclíptica).
|
||||||
|
let x1 = p.x * self.yc - p.y * self.ys;
|
||||||
|
let y1 = p.x * self.ys + p.y * self.yc;
|
||||||
|
let z1 = p.z;
|
||||||
|
// 2) pitch alrededor del eje X (horizontal de pantalla).
|
||||||
|
let x2 = x1;
|
||||||
|
let y2 = y1 * self.pc - z1 * self.ps;
|
||||||
|
let z2 = y1 * self.ps + z1 * self.pc;
|
||||||
|
// 3) ortográfica: la pantalla tiene la Y hacia abajo.
|
||||||
|
Projected {
|
||||||
|
x: self.ox + self.rad * x2,
|
||||||
|
y: self.oy - self.rad * y2,
|
||||||
|
depth: z2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Punto unitario sobre la eclíptica a la longitud `deg`.
|
||||||
|
fn eclip(deg: f32) -> Vec3 {
|
||||||
|
let (s, c) = deg.to_radians().sin_cos();
|
||||||
|
Vec3::new(c, s, 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rota `p` alrededor del eje X (la línea de los equinoccios).
|
||||||
|
fn rot_x(p: Vec3, ang_rad: f32) -> Vec3 {
|
||||||
|
let (s, c) = ang_rad.sin_cos();
|
||||||
|
Vec3::new(p.x, p.y * c - p.z * s, p.y * s + p.z * c)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atenuación por profundidad: el frente brilla pleno, el fondo se
|
||||||
|
/// apaga hasta ~0.30 para que el ojo lea el volumen de la esfera.
|
||||||
|
fn depth_alpha(depth: f32) -> f32 {
|
||||||
|
0.30 + 0.70 * ((depth + 1.0) * 0.5).clamp(0.0, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `color` con su alpha modulada por la profundidad.
|
||||||
|
fn dim(color: Rgba, depth: f32) -> Rgba {
|
||||||
|
color.with_alpha(color.a * depth_alpha(depth))
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Generadores de círculos
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
/// El círculo de la eclíptica (z = 0), `n` puntos.
|
||||||
|
fn ring_points(n: usize) -> Vec<Vec3> {
|
||||||
|
(0..n)
|
||||||
|
.map(|i| eclip((i as f32) / (n as f32) * 360.0))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Un meridiano eclíptico: círculo máximo por ambos polos a la
|
||||||
|
/// longitud `lon0`.
|
||||||
|
fn meridian_points(lon0: f32, n: usize) -> Vec<Vec3> {
|
||||||
|
let (ls, lc) = lon0.to_radians().sin_cos();
|
||||||
|
(0..n)
|
||||||
|
.map(|i| {
|
||||||
|
let a = (i as f32) / (n as f32) * std::f32::consts::TAU;
|
||||||
|
let (asin, acos) = a.sin_cos();
|
||||||
|
Vec3::new(acos * lc, acos * ls, asin)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Un paralelo eclíptico: círculo menor a la latitud `beta`.
|
||||||
|
fn parallel_points(beta: f32, n: usize) -> Vec<Vec3> {
|
||||||
|
let (bs, bc) = beta.to_radians().sin_cos();
|
||||||
|
(0..n)
|
||||||
|
.map(|i| {
|
||||||
|
let lon = (i as f32) / (n as f32) * 360.0;
|
||||||
|
let (ls, lc) = lon.to_radians().sin_cos();
|
||||||
|
Vec3::new(bc * lc, bc * ls, bs)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(
|
||||||
|
items: &mut Vec<(f32, DrawCommand)>,
|
||||||
|
proj: &Projector,
|
||||||
|
pts: &[Vec3],
|
||||||
|
color: Rgba,
|
||||||
|
width: f32,
|
||||||
|
) {
|
||||||
|
let n = pts.len();
|
||||||
|
for i in 0..n {
|
||||||
|
let a = proj.project(pts[i]);
|
||||||
|
let b = proj.project(pts[(i + 1) % n]);
|
||||||
|
let d = (a.depth + b.depth) * 0.5;
|
||||||
|
items.push((
|
||||||
|
d,
|
||||||
|
DrawCommand::Line {
|
||||||
|
x1: a.x,
|
||||||
|
y1: a.y,
|
||||||
|
x2: b.x,
|
||||||
|
y2: b.y,
|
||||||
|
color: dim(color, d),
|
||||||
|
width,
|
||||||
|
dash: None,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// Composición
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
/// Compone la esfera celeste como una lista de [`DrawCommand`]s, ya
|
||||||
|
/// ordenada de atrás hacia adelante (algoritmo del pintor). El canvas
|
||||||
|
/// nativo y el cliente web la consumen igual que la rueda 2D.
|
||||||
|
pub fn compose_sphere(
|
||||||
|
model: &RenderModel,
|
||||||
|
view: &SphereView,
|
||||||
|
opts: &SphereOpts,
|
||||||
|
) -> Vec<DrawCommand> {
|
||||||
|
let pal = &opts.palette;
|
||||||
|
let size = opts.size;
|
||||||
|
let center = size * 0.5;
|
||||||
|
let rad = size * 0.36;
|
||||||
|
let proj = Projector::new(view, center, center, rad);
|
||||||
|
let eps = opts.obliquity_deg.to_radians();
|
||||||
|
|
||||||
|
// (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,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
// --- Rejilla: meridianos + paralelos de la eclíptica ---
|
||||||
|
if opts.show_grid {
|
||||||
|
let grid = pal.fg_muted.with_alpha(0.16);
|
||||||
|
for k in 0..6 {
|
||||||
|
add_loop(&mut items, &proj, &meridian_points((k as f32) * 30.0, 64), grid, 0.5);
|
||||||
|
}
|
||||||
|
for &beta in &[-60.0_f32, -30.0, 30.0, 60.0] {
|
||||||
|
add_loop(&mut items, &proj, ¶llel_points(beta, 64), grid, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ecuador celeste + eje de la Tierra ---
|
||||||
|
if opts.show_equator {
|
||||||
|
let equator: Vec<Vec3> = ring_points(96).iter().map(|p| rot_x(*p, eps)).collect();
|
||||||
|
add_loop(&mut items, &proj, &equator, pal.uranus.with_alpha(0.85), 1.3);
|
||||||
|
let n = proj.project(rot_x(Vec3::new(0.0, 0.0, 1.0), eps));
|
||||||
|
let s = proj.project(rot_x(Vec3::new(0.0, 0.0, -1.0), eps));
|
||||||
|
items.push((
|
||||||
|
(n.depth + s.depth) * 0.5,
|
||||||
|
DrawCommand::Line {
|
||||||
|
x1: s.x,
|
||||||
|
y1: s.y,
|
||||||
|
x2: n.x,
|
||||||
|
y2: n.y,
|
||||||
|
color: pal.uranus.with_alpha(0.45),
|
||||||
|
width: 0.8,
|
||||||
|
dash: Some((4.0, 4.0)),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Eclíptica: el camino del zodíaco, el aro prominente ---
|
||||||
|
add_loop(&mut items, &proj, &ring_points(96), pal.dial_ring, 2.0);
|
||||||
|
{
|
||||||
|
// Eje polar de la eclíptica, tenue.
|
||||||
|
let n = proj.project(Vec3::new(0.0, 0.0, 1.0));
|
||||||
|
let s = proj.project(Vec3::new(0.0, 0.0, -1.0));
|
||||||
|
items.push((
|
||||||
|
(n.depth + s.depth) * 0.5,
|
||||||
|
DrawCommand::Line {
|
||||||
|
x1: s.x,
|
||||||
|
y1: s.y,
|
||||||
|
x2: n.x,
|
||||||
|
y2: n.y,
|
||||||
|
color: pal.fg_muted.with_alpha(0.30),
|
||||||
|
width: 0.6,
|
||||||
|
dash: None,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Signos: espolón en cada borde + glifo en el centro ---
|
||||||
|
if opts.show_signs {
|
||||||
|
for i in 0..12 {
|
||||||
|
let boundary = (i as f32) * 30.0;
|
||||||
|
let a = proj.project(eclip(boundary));
|
||||||
|
let b = proj.project(eclip(boundary).scale(1.09));
|
||||||
|
let d = (a.depth + b.depth) * 0.5;
|
||||||
|
items.push((
|
||||||
|
d,
|
||||||
|
DrawCommand::Line {
|
||||||
|
x1: a.x,
|
||||||
|
y1: a.y,
|
||||||
|
x2: b.x,
|
||||||
|
y2: b.y,
|
||||||
|
color: dim(pal.dial_ring, d),
|
||||||
|
width: 1.0,
|
||||||
|
dash: None,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
let mid = boundary + 15.0;
|
||||||
|
let g = proj.project(eclip(mid).scale(1.17));
|
||||||
|
let name = SIGN_NAMES[i];
|
||||||
|
items.push((
|
||||||
|
g.depth + 0.002,
|
||||||
|
DrawCommand::Text {
|
||||||
|
x: g.x,
|
||||||
|
y: g.y,
|
||||||
|
content: sign_unicode(name).into(),
|
||||||
|
color: dim(pal.sign(name), g.depth),
|
||||||
|
size: size * 0.030,
|
||||||
|
anchor: TextAnchor::Middle,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ángulos ASC / MC / DSC / IC ---
|
||||||
|
for (deg, label) in [
|
||||||
|
(model.ascendant_deg, "Asc"),
|
||||||
|
(model.midheaven_deg, "MC"),
|
||||||
|
(model.descendant_deg, "Dsc"),
|
||||||
|
(model.imum_coeli_deg, "IC"),
|
||||||
|
] {
|
||||||
|
let a = proj.project(eclip(deg));
|
||||||
|
let b = proj.project(eclip(deg).scale(1.14));
|
||||||
|
let d = (a.depth + b.depth) * 0.5;
|
||||||
|
items.push((
|
||||||
|
d,
|
||||||
|
DrawCommand::Line {
|
||||||
|
x1: a.x,
|
||||||
|
y1: a.y,
|
||||||
|
x2: b.x,
|
||||||
|
y2: b.y,
|
||||||
|
color: dim(pal.angle_highlight, d),
|
||||||
|
width: 1.6,
|
||||||
|
dash: None,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
let lbl = proj.project(eclip(deg).scale(1.30));
|
||||||
|
items.push((
|
||||||
|
lbl.depth + 0.002,
|
||||||
|
DrawCommand::Text {
|
||||||
|
x: lbl.x,
|
||||||
|
y: lbl.y,
|
||||||
|
content: label.into(),
|
||||||
|
color: dim(pal.angle_highlight, lbl.depth),
|
||||||
|
size: size * 0.021,
|
||||||
|
anchor: TextAnchor::Middle,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Cuerpos natales sobre la eclíptica ---
|
||||||
|
if opts.show_bodies {
|
||||||
|
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 p = proj.project(eclip(g.deg));
|
||||||
|
let color = pal.planet(&g.symbol);
|
||||||
|
items.push((
|
||||||
|
p.depth,
|
||||||
|
DrawCommand::Circle {
|
||||||
|
cx: p.x,
|
||||||
|
cy: p.y,
|
||||||
|
r: size * 0.020,
|
||||||
|
stroke: Some(dim(color, p.depth)),
|
||||||
|
fill: Some(halo),
|
||||||
|
stroke_w: 1.3,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
items.push((
|
||||||
|
p.depth + 0.003,
|
||||||
|
DrawCommand::Text {
|
||||||
|
x: p.x,
|
||||||
|
y: p.y,
|
||||||
|
content: planet_unicode_with_retro(&g.symbol, g.retrograde),
|
||||||
|
color: dim(color, p.depth),
|
||||||
|
size: size * 0.026,
|
||||||
|
anchor: TextAnchor::Middle,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{ChartId, ChartKind, Geometry, Glyph, Layer};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vernal_point_y_cuadratura_sobre_la_eclyptica() {
|
||||||
|
let v = eclip(0.0);
|
||||||
|
assert!((v.x - 1.0).abs() < 1e-5 && v.y.abs() < 1e-5 && v.z.abs() < 1e-5);
|
||||||
|
let q = eclip(90.0);
|
||||||
|
assert!(q.x.abs() < 1e-5 && (q.y - 1.0).abs() < 1e-5 && q.z.abs() < 1e-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn la_oblicuidad_inclina_el_polo_celeste() {
|
||||||
|
// El polo norte celeste = polo eclíptico rotado por ε. El
|
||||||
|
// ángulo entre ambos debe ser exactamente ε.
|
||||||
|
let ncp = rot_x(Vec3::new(0.0, 0.0, 1.0), OBLICUIDAD_DEG.to_radians());
|
||||||
|
let cos_ang = ncp.z; // producto punto con (0,0,1).
|
||||||
|
let ang = cos_ang.acos().to_degrees();
|
||||||
|
assert!((ang - OBLICUIDAD_DEG).abs() < 1e-3, "ángulo {ang}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn la_proyeccion_no_se_sale_del_cuadro() {
|
||||||
|
let view = SphereView::default();
|
||||||
|
let proj = Projector::new(&view, 300.0, 300.0, 108.0);
|
||||||
|
for i in 0..360 {
|
||||||
|
let p = proj.project(eclip(i as f32));
|
||||||
|
assert!(p.x >= 300.0 - 109.0 && p.x <= 300.0 + 109.0);
|
||||||
|
assert!(p.y >= 300.0 - 109.0 && p.y <= 300.0 + 109.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn modelo_demo() -> RenderModel {
|
||||||
|
RenderModel {
|
||||||
|
chart_id: ChartId::default(),
|
||||||
|
chart_kind: ChartKind::Natal,
|
||||||
|
title: "demo".into(),
|
||||||
|
subtitle: None,
|
||||||
|
compute_ms: 0,
|
||||||
|
ascendant_deg: 100.0,
|
||||||
|
midheaven_deg: 10.0,
|
||||||
|
descendant_deg: 280.0,
|
||||||
|
imum_coeli_deg: 190.0,
|
||||||
|
layers: vec![Layer {
|
||||||
|
module_id: "natal".into(),
|
||||||
|
kind: LayerKind::Bodies,
|
||||||
|
ring: 0.0,
|
||||||
|
z: 0,
|
||||||
|
geometry: Geometry::GlyphsOnly,
|
||||||
|
glyphs: vec![
|
||||||
|
Glyph { deg: 12.0, symbol: "sun".into(), ..Default::default() },
|
||||||
|
Glyph { deg: 200.0, symbol: "moon".into(), ..Default::default() },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
overlays: vec![],
|
||||||
|
aspect_summary: vec![],
|
||||||
|
uranian_groups: vec![],
|
||||||
|
gr_triggers: vec![],
|
||||||
|
harmonic: 1,
|
||||||
|
harmonic_spectrum: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compose_sphere_emite_esqueleto_y_cuerpos() {
|
||||||
|
let cmds = compose_sphere(&modelo_demo(), &SphereView::default(), &SphereOpts::default());
|
||||||
|
assert!(!cmds.is_empty(), "la esfera produce comandos");
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn el_primer_comando_es_el_limbo_de_fondo() {
|
||||||
|
let cmds = compose_sphere(&modelo_demo(), &SphereView::default(), &SphereOpts::default());
|
||||||
|
assert!(
|
||||||
|
matches!(cmds.first(), Some(DrawCommand::Circle { .. })),
|
||||||
|
"el limbo (profundidad −100) se pinta primero"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user