gioser

tawasuyu/brahman

Rust 496TOML 195Markdown 55JSON 197001 Definitions

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>

6a0781c0a811c29aa592c34c97351dc166916cc6 · by sergio · (5 months ago) · parent 7695dbf3c · browse the tree at this commit

Semantic delta · 43

crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs

crates/modules/cosmobiologia/cosmobiologia-render/src/draw.rs

crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs

crates/modules/cosmobiologia/cosmobiologia-render/src/sphere3d.rs

4 files · +809 −2

diff --git a/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs
index 4c11c18..b4f8613 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs
@@ -45,6 +45,7 @@ use cosmobiologia_engine::{
     OUTER_RING_MODULES,
 };
 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 nahual_theme::Theme;
 
@@ -125,6 +126,15 @@ struct PanDragState {
     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)]
 pub struct CanvasState {
     pub mode: CanvasMode,
@@ -156,8 +166,14 @@ pub struct CanvasState {
     /// El canvas dibuja su perfil como una curva en el footer; el valle
     /// marca la hora de nacimiento que mejor explica los eventos.
     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_pan: Option<PanDragState>,
+    drag_sphere: Option<SphereDragState>,
 }
 
 /// Límites del zoom — bajo 0.5 los glyphs se vuelven ilegibles; sobre
@@ -240,8 +256,11 @@ impl Default for CanvasState {
             show_coords: true,
             hover: None,
             rectificacion: None,
+            sphere_3d: false,
+            sphere_view: SphereView::default(),
             drag_jog: None,
             drag_pan: None,
+            drag_sphere: None,
         }
     }
 }
@@ -357,6 +376,13 @@ impl AstrologyCanvas {
         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
     /// ni time offset — esos son ortogonales y tienen su propio reset.
     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(
         &mut self,
         event: &ScrollWheelEvent,
@@ -762,6 +817,10 @@ impl AstrologyCanvas {
                 cx.emit(CanvasEvent::ExportSvgRequested);
                 return;
             }
+            "v" | "V" => {
+                self.toggle_sphere(cx);
+                return;
+            }
             _ => return,
         };
         self.toggle_layer(kind, cx);
@@ -851,6 +910,15 @@ impl Render for AstrologyCanvas {
 
         let body = match &self.state.mode {
             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(
                 &theme,
                 &palette,
@@ -869,6 +937,34 @@ impl Render for AstrologyCanvas {
             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
         // canvas. Vignette radial — el centro queda claro y los
         // bordes se oscurecen, dando profundidad sin "ruido" de
@@ -908,6 +1004,7 @@ impl Render for AstrologyCanvas {
                     .justify_center()
                     .child(body),
             )
+            .children(sphere_toggle)
     }
 }
 
@@ -969,6 +1066,169 @@ fn render_thumbnails(theme: &Theme, items: &[ThumbnailItem]) -> gpui::Div {
     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
 // =====================================================================
diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/draw.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/draw.rs
index 5218d74..99e655a 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-render/src/draw.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/draw.rs
@@ -582,7 +582,7 @@ fn svg_escape(s: &str) -> String {
         .replace('"', "&quot;")
 }
 
-fn sign_unicode(name: &str) -> &'static str {
+pub(crate) fn sign_unicode(name: &str) -> &'static str {
     match name {
         "aries" => "♈",
         "taurus" => "♉",
@@ -622,7 +622,7 @@ fn planet_unicode(name: &str) -> &'static str {
 
 /// Glyph del cuerpo con sufijo "℞" si está retrógrado — concatenación
 /// 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 {
         format!("{}℞", planet_unicode(name))
     } else {
diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs
index b976048..8aa7b53 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/lib.rs
@@ -35,6 +35,7 @@ pub mod gr;
 pub mod harmonic;
 pub mod math;
 pub mod palette;
+pub mod sphere3d;
 
 pub use draw::{
     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,
 };
 pub use palette::Palette;
+pub use sphere3d::{compose_sphere, SphereOpts, SphereView, OBLICUIDAD_DEG};
 
 // =====================================================================
 // RenderModel — lo que el client renderea
diff --git a/crates/modules/cosmobiologia/cosmobiologia-render/src/sphere3d.rs b/crates/modules/cosmobiologia/cosmobiologia-render/src/sphere3d.rs
new file mode 100644
index 0000000..f20ae0f
--- /dev/null
+++ b/crates/modules/cosmobiologia/cosmobiologia-render/src/sphere3d.rs
@@ -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, &parallel_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"
+        );
+    }
+}