tawasuyu

tawasuyu/brahman

Rust 467TOML 184Markdown 49JSON 176411 Sutichaykuna

refactor(tahuantinsuyu): extrae tahuantinsuyu-render — preparación para WASM

Fase 1 de "módulo web": extracción del modelo y la matemática
agnóstica de surface a un crate separado, sin dependencia de
gpui ni de eternal. Es la base sobre la que el cliente WASM y
el canvas nativo van a converger.

Crate nuevo `tahuantinsuyu-render`:
- Tipos del RenderModel migrados desde `tahuantinsuyu-engine`:
  `RenderModel`, `Layer`, `LayerKind`, `Geometry`, `LineSeg`,
  `PointMark`, `Glyph`, `OverlayMeta`, `UranianGroup`,
  `AspectSummary`, `OUTER_RING_MODULES`. El engine los
  reexporta — ningún call site del shell/canvas/modules/tree/
  panel cambia su `use`.
- Módulo `math` con la geometría canónica del wheel migrada
  desde `tahuantinsuyu-canvas`:
  * `Radii` con los aros A/B/C/D/E + helpers `body_ring` y
    `aspect_endpoints`
  * `polar_to_screen` (Asc a las 9 del reloj)
  * `spread_angles` (anti-solapamiento con damping + clamp por
    glyph)
  * `find_clusters` (con wrap-around)
  * `format_coord_compact` ("DD°MM'{signo}")
- 10 tests del math (5 spread + 4 coord + 1 polar) viajaron con
  las implementaciones. El canvas se queda solo con los tests
  de UI.

Por qué un crate aparte:
- `tahuantinsuyu-engine` arrastra `eternal-sky` (VSOP2013 +
  I/O de tablas) que NO compila a WASM sin empaquetar 30+ MB
  de efemérides. Los tipos del modelo son serde puro y sí
  compilan a WASM — extraerlos libera al cliente web futuro
  de la dependencia transitiva.
- Cuando llegue la fase 2 (`tahuantinsuyu-server` axum) y la
  fase 3 (`tahuantinsuyu-web` cdylib WASM), ambos consumen
  `tahuantinsuyu-render` con la misma fuente de verdad sobre
  el layout, evitando duplicar la lógica entre desktop y web.

Pendiente: `tahuantinsuyu-model` arrastra `uuid → getrandom`
que falla a WASM sin `wasm_js` feature flag. Lo resuelvo en la
fase del cliente WASM (necesita su propio Cargo.toml con la
config getrandom + .cargo/config con RUSTFLAGS).

Tests: 20 verdes (10 shell + 10 render math). Compilación
nativa OK; canvas sin cambios visuales (mismo código,
diferente origen).

9084cf4b796b835033e23f2fb29a9dd2748480df · ruwaq sergio · (5 killa ñawpaq) · tayta 8e95c884e · kay commitpi sach'ata qhaway

Semántico sayk'uy · 65

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

crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs

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

crates/modules/tahuantinsuyu/tahuantinsuyu-render/src/math.rs

8 qillqakuna · +657 −585

diff --git a/Cargo.toml b/Cargo.toml
index 1bd13f0..166eb85 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -139,6 +139,7 @@ members = [
     "crates/modules/tahuantinsuyu/tahuantinsuyu-card",
     "crates/modules/tahuantinsuyu/tahuantinsuyu-model",
     "crates/modules/tahuantinsuyu/tahuantinsuyu-store",
+    "crates/modules/tahuantinsuyu/tahuantinsuyu-render",
     "crates/modules/tahuantinsuyu/tahuantinsuyu-engine",
     "crates/modules/tahuantinsuyu/tahuantinsuyu-modules",
     "crates/modules/tahuantinsuyu/tahuantinsuyu-theme",
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/Cargo.toml b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/Cargo.toml
index fdc1780..ad9278e 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/Cargo.toml
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/Cargo.toml
@@ -9,6 +9,7 @@ description = "Tahuantinsuyu — widget GPUI del canvas astrológico. Capas modu
 tahuantinsuyu-engine = { path = "../tahuantinsuyu-engine" }
 tahuantinsuyu-model = { path = "../tahuantinsuyu-model" }
 tahuantinsuyu-modules = { path = "../tahuantinsuyu-modules" }
+tahuantinsuyu-render = { path = "../tahuantinsuyu-render" }
 tahuantinsuyu-theme = { path = "../tahuantinsuyu-theme" }
 yahweh-theme = { workspace = true }
 gpui = { workspace = true }
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
index b2814c0..d9a70e1 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
@@ -1808,116 +1808,11 @@ fn format_offset(minutes: i64) -> String {
 // Painting
 // =====================================================================
 
-/// Geometría radial canónica de la rueda. Aros nombrados según
-/// convención del usuario, de afuera hacia adentro:
-///
-/// * **Aro A** (`sign_outer`) — externo del zodiaco.
-/// * **Zona AB** — sign dial: glyphs de signos zodiacales.
-/// * **Aro B** (`sign_inner` = `topo_houses_outer`) — interno del
-///   zodiaco / externo del bloque ascensional.
-/// * **Zona BC** — casas topocéntricas (cusps b→c) + planetas
-///   topocéntricos, ambos con sus coordenadas.
-/// * **Aro C** (`topo_houses_inner` = `houses_outer`) — separador
-///   ascensional / casas geo.
-/// * **Zona CD** — casas geocéntricas (cusps c→d) + sus coordenadas.
-/// * **Aro D** (`houses_inner`) — externo de los planetas natales.
-///   Junto a D, hacia adentro, se posan los planetas natales y sus
-///   coordenadas.
-/// * **Aro E** (`aspects`) — el más interno. Desde aquí nacen las
-///   líneas de aspecto / relaciones / overlays opcionales.
-///
-/// Los overlays adicionales (transits, midpoints, progression, solar
-/// arc, composite) viven INTERIORES al aro E — solo se pintan
-/// cuando el módulo correspondiente está activo, así no compiten
-/// con el layout base.
-#[derive(Clone, Copy)]
-struct Radii {
-    sign_outer: f32,          // Aro A
-    sign_inner: f32,          // Aro B
-    topo_houses_outer: f32,   // = Aro B
-    topocentric: f32,         // Zona BC: planetas topo
-    topo_houses_inner: f32,   // Aro C
-    houses_outer: f32,        // = Aro C
-    houses_inner: f32,        // Aro D
-    bodies: f32,              // Zona D-E: planetas natales (junto a D)
-    pd_direct: f32,           // GR (cuando activo): exterior al cinturón natal
-    pd_converse: f32,         // GR (cuando activo): interior al cinturón natal
-    aspects: f32,             // Aro E (invisible, ancla de líneas)
-    // Overlays adicionales — todos interiores a E.
-    transits: f32,
-    midpoints: f32,
-    progression: f32,
-    solar_arc: f32,
-    composite: f32,
-}
-
-impl Radii {
-    fn from_outer(r: f32) -> Self {
-        Self {
-            // Aro A — externo zodiaco.
-            sign_outer: r,
-            // Aro B — interno zodiaco / externo bloque ascensional.
-            sign_inner: r * 0.92,
-            topo_houses_outer: r * 0.92,
-            // Zona BC: planetas topocéntricos centrados.
-            topocentric: r * 0.85,
-            // Aro C — separador ascensional / casas geo.
-            topo_houses_inner: r * 0.78,
-            houses_outer: r * 0.78,
-            // Aro D — externo planetas natales.
-            houses_inner: r * 0.62,
-            // Planetas natales justo dentro de D.
-            bodies: r * 0.57,
-            // GR dual-ring (cuando se activa): abraza el cinturón
-            // natal por afuera (`pd_direct`) y por adentro
-            // (`pd_converse`). Si GR está OFF, ninguno de los dos se
-            // pinta — no compite con el layout base.
-            pd_direct: r * 0.545,
-            pd_converse: r * 0.515,
-            // Aro E — anclaje invisible de las líneas de aspecto.
-            aspects: r * 0.49,
-            // Overlays adicionales — todos INTERIORES al aro E. Se
-            // pintan solo cuando el módulo correspondiente está
-            // activo, así no compiten con el layout base.
-            transits: r * 0.43,
-            midpoints: r * 0.39,
-            progression: r * 0.33,
-            solar_arc: r * 0.27,
-            composite: r * 0.21,
-        }
-    }
-
-    /// Radio del ring de cuerpos según el `module_id` del Layer.
-    fn body_ring(&self, module_id: &str) -> f32 {
-        match module_id {
-            "progression" => self.progression,
-            "solar_arc" => self.solar_arc,
-            "composite" => self.composite,
-            "midpoints" => self.midpoints,
-            "topocentric" => self.topocentric,
-            "pd_direct" => self.pd_direct,
-            "pd_converse" => self.pd_converse,
-            _ => self.bodies,
-        }
-    }
-
-    /// Resuelve qué radios corresponden a una capa de aspectos según el
-    /// `module_id`: natal-natal en `aspects`, cross con cada overlay
-    /// desde `bodies` (extremo natal) al ring del módulo. Los módulos
-    /// del outer ring (OUTER_RING_MODULES) comparten el slot de
-    /// tránsito (son mutuamente excluyentes a nivel de Shell).
-    fn aspect_endpoints(&self, module_id: &str) -> (f32, f32) {
-        if OUTER_RING_MODULES.contains(&module_id) {
-            return (self.bodies, self.transits);
-        }
-        match module_id {
-            "progression" => (self.bodies, self.progression),
-            "solar_arc" => (self.bodies, self.solar_arc),
-            "composite" => (self.bodies, self.composite),
-            _ => (self.aspects, self.aspects),
-        }
-    }
-}
+// `Radii` + helpers migraron a `tahuantinsuyu-render` (crate
+// agnóstico de surface, compila a WASM y nativo). Re-export para
+// que el código del canvas siga refiriendo `Radii` sin cambiar
+// imports en cada call site.
+use tahuantinsuyu_render::Radii;
 
 #[allow(clippy::too_many_arguments)]
 // `hover_focus`: symbol del planeta hovereado en este frame (si lo
@@ -2587,16 +2482,8 @@ fn dist_point_segment(px: f32, py: f32, ax: f32, ay: f32, bx: f32, by: f32) -> f
     (dx2 * dx2 + dy2 * dy2).sqrt()
 }
 
-fn polar_to_screen(
-    longitude_deg: f32,
-    ascendant_deg: f32,
-    rot_offset_deg: f32,
-    radius: f32,
-) -> (f32, f32) {
-    let deg = 180.0 - (longitude_deg - ascendant_deg + rot_offset_deg);
-    let rad = deg * PI / 180.0;
-    (radius * rad.cos(), radius * rad.sin())
-}
+// `polar_to_screen` se importa desde `tahuantinsuyu-render`.
+use tahuantinsuyu_render::polar_to_screen;
 
 fn centered_glyph(
     x: f32,
@@ -2671,291 +2558,15 @@ fn body_disk_base(module_id: &str, kind: LayerKind, view_scale: f32) -> f32 {
     base * view_scale
 }
 
-/// Reposiciona angularmente un conjunto de longitudes para que pares
-/// adyacentes mantengan al menos `min_sep_deg` de separación, **sin
-/// que ningún glyph se aleje más de `max_shift_deg` de su posición
-/// real**. La acotación es clave para evitar que un cluster denso
-/// "empuje" a planetas que estaban lejos.
-///
-/// Algoritmo: iteramos hasta 60 veces; en cada pasada re-ordenamos
-/// los displays para mantener el orden circular, y en cada par
-/// adyacente que esté muy cerca empujamos los dos extremos en
-/// sentidos opuestos. Tras cada empuje clampeamos `displays[i]` al
-/// rango `[raw[i] - max_shift, raw[i] + max_shift]` (circular).
-/// Si el cluster es tan denso que el clamp impide alcanzar el
-/// `min_sep`, el residual queda alto y el caller encoge los discos.
-///
-/// Devuelve `(displays, residual)` con `residual ∈ [0, 1]` =
-/// fracción de presión no resuelta tras el clamp.
-fn spread_angles(angles_deg: &[f32], min_sep_deg: f32, max_shift_deg: f32) -> (Vec<f32>, f32) {
-    let n = angles_deg.len();
-    if n <= 1 {
-        return (angles_deg.to_vec(), 0.0);
-    }
-    if (n as f32) * min_sep_deg >= 360.0 {
-        return (angles_deg.to_vec(), 1.0);
-    }
-    let raw: Vec<f32> = angles_deg.iter().map(|a| a.rem_euclid(360.0)).collect();
-    let mut displays: Vec<f32> = raw.clone();
-    let mut last_residual = 0.0_f32;
-
-    // Clamp circular: ajusta `display` a estar dentro de
-    // `raw ± max_shift_deg`, midiendo distancia mínima circular.
-    let clamp_to_raw = |display: f32, raw: f32, max_shift: f32| -> f32 {
-        let mut delta = display - raw;
-        if delta > 180.0 {
-            delta -= 360.0;
-        }
-        if delta < -180.0 {
-            delta += 360.0;
-        }
-        let clamped = delta.clamp(-max_shift, max_shift);
-        (raw + clamped).rem_euclid(360.0)
-    };
-
-    // Loop tipo "physics": acumulamos fuerzas sobre TODOS los pares
-    // adyacentes en una pasada, luego aplicamos un step con damping.
-    // Esto evita las oscilaciones del empuje inmediato (que reordenaba
-    // los displays a mitad de la pasada y nunca convergía).
-    let damping: f32 = 0.6;
-    for _ in 0..80 {
-        let mut order: Vec<usize> = (0..n).collect();
-        order.sort_by(|&a, &b| {
-            displays[a]
-                .partial_cmp(&displays[b])
-                .unwrap_or(std::cmp::Ordering::Equal)
-        });
-        let mut forces = vec![0.0_f32; n];
-        let mut max_residual: f32 = 0.0;
-        for k in 0..n {
-            let i = order[k];
-            let j = order[(k + 1) % n];
-            let diff = (displays[j] - displays[i]).rem_euclid(360.0);
-            if diff < min_sep_deg {
-                let push = (min_sep_deg - diff) / 2.0;
-                forces[i] -= push;
-                forces[j] += push;
-                let r = (min_sep_deg - diff) / min_sep_deg;
-                if r > max_residual {
-                    max_residual = r;
-                }
-            }
-        }
-        // Aplicar fuerzas con damping + clamp al rango ±max_shift.
-        for i in 0..n {
-            let stepped = (displays[i] + forces[i] * damping).rem_euclid(360.0);
-            displays[i] = clamp_to_raw(stepped, raw[i], max_shift_deg);
-        }
-        last_residual = max_residual;
-        if max_residual < 0.001 {
-            break;
-        }
-    }
-
-    (displays, last_residual)
-}
-
-/// Detecta clusters de longitudes angularmente cercanas. Dos
-/// elementos están en el mismo cluster si su separación circular es
-/// menor a `threshold_deg`. Devuelve los índices originales
-/// agrupados; cada Vec interno representa un cluster (incluso si
-/// es de tamaño 1). Cluster con wrap-around (último→primero) se
-/// fusionan correctamente.
-fn find_clusters(angles_deg: &[f32], threshold_deg: f32) -> Vec<Vec<usize>> {
-    let n = angles_deg.len();
-    if n == 0 {
-        return Vec::new();
-    }
-    let mut idxed: Vec<(usize, f32)> = angles_deg
-        .iter()
-        .copied()
-        .map(|a| a.rem_euclid(360.0))
-        .enumerate()
-        .collect();
-    idxed.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
-    let mut clusters: Vec<Vec<usize>> = Vec::new();
-    let mut cur: Vec<usize> = vec![idxed[0].0];
-    let mut last = idxed[0].1;
-    for (idx, a) in idxed.iter().skip(1).copied() {
-        if (a - last) < threshold_deg {
-            cur.push(idx);
-        } else {
-            clusters.push(std::mem::take(&mut cur));
-            cur.push(idx);
-        }
-        last = a;
-    }
-    clusters.push(cur);
-    // Wrap-around: si el último cluster y el primero "tocan" a través
-    // de 0° (la diferencia circular < threshold), mergearlos.
-    if clusters.len() >= 2 {
-        let first_a = angles_deg[clusters[0][0]].rem_euclid(360.0);
-        let last_a = angles_deg[*clusters.last().unwrap().last().unwrap()].rem_euclid(360.0);
-        let wrap_diff = 360.0 - last_a + first_a;
-        if wrap_diff < threshold_deg {
-            let mut tail = clusters.pop().unwrap();
-            tail.extend(clusters[0].iter().copied());
-            clusters[0] = tail;
-        }
-    }
-    clusters
-}
-
-/// Formato compacto con precisión de minutos: "DD°MM'{signo}" donde
-/// el signo es el glyph zodiacal (♈♉♊…). Ej: 14.93° → "14°56'♈".
-/// Los minutos se redondean al entero más cercano; si el redondeo
-/// excede 60, bumpea el grado y mantiene la representación canónica
-/// (29°60' → 30°00', que a su vez = 0° del signo siguiente — lo
-/// recalculamos para evitar mostrar "30°00'♈" en vez de "0°00'♉").
-fn format_coord_compact(deg: f32) -> String {
-    let normalized = deg.rem_euclid(360.0);
-    let total_minutes = (normalized * 60.0).round() as i64;
-    // Carry-overs: 60' → siguiente grado; 30° → siguiente signo (eso
-    // ya está cubierto porque el total_minutes refleja la posición
-    // ABSOLUTA y volvemos a derivar sign + minutos del entero limpio).
-    let total_minutes = total_minutes.rem_euclid(360 * 60);
-    let sign_idx = (total_minutes / (30 * 60)) as usize % 12;
-    let within_sign = total_minutes - (sign_idx as i64) * 30 * 60;
-    let deg_int = (within_sign / 60) as i32;
-    let minutes = (within_sign % 60) as i32;
-    let sign_glyph = match sign_idx {
-        0 => "♈",
-        1 => "♉",
-        2 => "♊",
-        3 => "♋",
-        4 => "♌",
-        5 => "♍",
-        6 => "♎",
-        7 => "♏",
-        8 => "♐",
-        9 => "♑",
-        10 => "♒",
-        _ => "♓",
-    };
-    format!("{}°{:02}'{}", deg_int, minutes, sign_glyph)
-}
-
-#[cfg(test)]
-mod spread_tests {
-    use super::spread_angles;
-
-    fn assert_min_sep(displays: &[f32], min_sep: f32) {
-        let n = displays.len();
-        let mut sorted = displays.to_vec();
-        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
-        // Tolerancia: el algoritmo converge dentro del 1% del min_sep.
-        let tol = min_sep * 0.02;
-        for i in 0..n {
-            let nxt = (i + 1) % n;
-            let diff = (sorted[nxt] - sorted[i]).rem_euclid(360.0);
-            assert!(
-                diff + tol >= min_sep,
-                "vecinos {} y {} a {}° (mínimo {})",
-                sorted[i],
-                sorted[nxt],
-                diff,
-                min_sep
-            );
-        }
-    }
-
-    #[test]
-    fn empty_and_single_unchanged() {
-        let (r, residual) = spread_angles(&[], 10.0, 30.0);
-        assert!(r.is_empty());
-        assert_eq!(residual, 0.0);
-        let (r, residual) = spread_angles(&[42.0], 10.0, 30.0);
-        assert_eq!(r, vec![42.0]);
-        assert_eq!(residual, 0.0);
-    }
+// `spread_angles` y `find_clusters` migraron a `tahuantinsuyu-render`.
+use tahuantinsuyu_render::{find_clusters, spread_angles};
 
-    #[test]
-    fn spaced_input_left_alone() {
-        let input = vec![0.0, 30.0, 90.0, 200.0];
-        let (out, residual) = spread_angles(&input, 10.0, 30.0);
-        assert!(residual < 0.001);
-        for (a, b) in input.iter().zip(out.iter()) {
-            assert!((a - b).abs() < 1e-3, "{} vs {}", a, b);
-        }
-    }
+// `format_coord_compact` migró a `tahuantinsuyu-render`.
+use tahuantinsuyu_render::format_coord_compact;
 
-    #[test]
-    fn tight_cluster_gets_spread() {
-        // 3 planetas a 1° con min_sep=10, max_shift=30 — caben.
-        let input = vec![100.0, 101.0, 102.0];
-        let (out, residual) = spread_angles(&input, 10.0, 30.0);
-        assert!(residual < 0.05, "residual {}", residual);
-        assert_min_sep(&out, 10.0);
-    }
-
-    #[test]
-    fn shift_is_bounded() {
-        // Si max_shift=2°, los planetas no pueden alejarse más allá.
-        let input = vec![100.0, 101.0];
-        let (out, _) = spread_angles(&input, 10.0, 2.0);
-        for (raw, disp) in input.iter().zip(out.iter()) {
-            let mut delta = (disp - raw).abs();
-            if delta > 180.0 {
-                delta = 360.0 - delta;
-            }
-            assert!(delta <= 2.0 + 0.01, "shift {} > 2°", delta);
-        }
-    }
-
-    #[test]
-    fn distant_planet_unaffected_by_dense_cluster() {
-        // Cluster denso en 100-101° + planeta solo en 200°. El de 200°
-        // debe quedarse cerca de 200° con max_shift=10°.
-        let input = vec![100.0, 100.5, 101.0, 200.0];
-        let (out, _) = spread_angles(&input, 10.0, 10.0);
-        let mut delta = (out[3] - 200.0).abs();
-        if delta > 180.0 {
-            delta = 360.0 - delta;
-        }
-        assert!(delta < 5.0, "planeta lejano se movió {}°", delta);
-    }
-
-    #[test]
-    fn unfeasible_cluster_reports_max_residual() {
-        let input: Vec<f32> = (0..40).map(|_| 0.0).collect();
-        let (_out, residual) = spread_angles(&input, 10.0, 30.0);
-        assert!((residual - 1.0).abs() < 0.01);
-    }
-}
-
-#[cfg(test)]
-mod coord_tests {
-    use super::format_coord_compact;
-
-    #[test]
-    fn zero_aries() {
-        assert_eq!(format_coord_compact(0.0), "0°00'♈");
-    }
-
-    #[test]
-    fn fourteen_fiftysix_aries() {
-        // 14.9333° = 14° 56'
-        assert_eq!(format_coord_compact(14.933_3), "14°56'♈");
-    }
-
-    #[test]
-    fn rollover_to_taurus() {
-        // 29.9995° debería redondear a 30° y caer en 0°00'♉.
-        assert_eq!(format_coord_compact(29.9995), "0°00'♉");
-    }
-
-    #[test]
-    fn capricorn_anchor() {
-        // 270° = inicio de Capricornio.
-        assert_eq!(format_coord_compact(270.0), "0°00'♑");
-    }
-
-    #[test]
-    fn negative_wraps() {
-        // -10° = 350° = 20°00'♓.
-        assert_eq!(format_coord_compact(-10.0), "20°00'♓");
-    }
-}
+// Los tests de `spread_angles`, `find_clusters` y
+// `format_coord_compact` viven ahora en `tahuantinsuyu-render::math`
+// junto a sus implementaciones.
 
 /// Pill pequeña con un coord ("14°♈") junto al glyph de un planeta
 /// o cusp. Fondo halo + texto fg_muted, padding mínimo para no
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/Cargo.toml b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/Cargo.toml
index 0690997..9c01931 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/Cargo.toml
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/Cargo.toml
@@ -7,6 +7,7 @@ description = "Tahuantinsuyu — bridge entre el modelo agnóstico y eternal-ast
 
 [dependencies]
 tahuantinsuyu-model = { path = "../tahuantinsuyu-model" }
+tahuantinsuyu-render = { path = "../tahuantinsuyu-render" }
 serde = { workspace = true }
 thiserror = { workspace = true }
 
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
index 55615a9..09c40c4 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
@@ -25,11 +25,21 @@
 #![forbid(unsafe_code)]
 #![warn(rust_2018_idioms)]
 
-use serde::{Deserialize, Serialize};
 use thiserror::Error;
 
 pub use tahuantinsuyu_model::{Chart, ChartId, ChartKind};
 
+// Los tipos del RenderModel viven en `tahuantinsuyu-render` (crate
+// agnóstico de surface — compila a WASM, lo consumen tanto el canvas
+// gpui como el cliente web). El engine los reexporta para mantener
+// compatibilidad con todos los call sites históricos
+// (`tahuantinsuyu_engine::Layer`, etc.) sin tener que cambiar
+// imports en el shell, canvas, modules, tree, panel...
+pub use tahuantinsuyu_render::{
+    AspectSummary, Geometry, Glyph, Layer, LayerKind, LineSeg, OverlayMeta, PointMark,
+    RenderModel, UranianGroup, OUTER_RING_MODULES,
+};
+
 // `Chart` reexportado arriba es lo que `PipelineRequest::Synastry`
 // transporta — el caller (shell) lee del Store y pasa el Chart entero
 // para que el bridge construya su NatalChart en eternal.
@@ -43,181 +53,6 @@ mod natal_cache;
 #[cfg(feature = "eternal-bridge")]
 pub mod svg_export;
 
-// =====================================================================
-// RenderModel — lo que el canvas necesita pintar
-// =====================================================================
-
-/// Resultado agnóstico de un cómputo astrológico, listo para renderizar.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct RenderModel {
-    pub chart_id: ChartId,
-    pub chart_kind: ChartKind,
-    pub title: String,
-    #[serde(default)]
-    pub subtitle: Option<String>,
-    pub compute_ms: u64,
-
-    // ─── Ángulos del chart (grados eclípticos, 0..360) ───────────────
-    /// Ascendente — punto fijo de rotación del lienzo. La rueda se gira
-    /// de modo que el Asc cae a las 9 (lado izquierdo).
-    pub ascendant_deg: f32,
-    pub midheaven_deg: f32,
-    pub descendant_deg: f32,
-    pub imum_coeli_deg: f32,
-
-    /// Capas a pintar. Orden = z-order ascendente.
-    pub layers: Vec<Layer>,
-    /// Metadata humana por overlay activo (transit, progresión,
-    /// sinastría, retorno...). Vacío para una carta natal pura. La UI
-    /// la pinta como badges en el footer.
-    #[serde(default)]
-    pub overlays: Vec<OverlayMeta>,
-    /// Lista paralela a las LineSeg de aspectos — uno por aspecto
-    /// natal o cross. Ordenado por `orb_deg` ascendente (los más
-    /// cerrados primero). La UI lo usa para la lista textual.
-    #[serde(default)]
-    pub aspect_summary: Vec<AspectSummary>,
-    /// Grupos uranianos detectados (cuerpos en la misma posición mod 90).
-    /// Vacío sino se activó el módulo Uranian.
-    #[serde(default)]
-    pub uranian_groups: Vec<UranianGroup>,
-}
-
-/// Etiqueta legible de un overlay para el footer del canvas. La engine
-/// la pushea desde cada `build_*_overlay`; el canvas solo lee y pinta.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct OverlayMeta {
-    pub module_id: String,
-    /// Etiqueta corta — ej. "Tránsito ahora", "Progresión 38.2a",
-    /// "Sinastría · Ana", "Saturn return 29a".
-    pub label: String,
-}
-
-/// Grupo de cuerpos natales que caen en la misma posición del
-/// dial uraniano de 90° (su longitud zodiacal módulo 90 es igual o
-/// muy cercana). En la astrología uraniana esto es una "fórmula" o
-/// "axis" — los cuerpos están en correspondencia simbólica directa
-/// porque comparten un cuadrante simétrico.
-///
-/// Solo se emiten grupos con 2+ miembros (los singletons no son
-/// fórmulas). La engine los ordena por proximidad al ε de tolerancia.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct UranianGroup {
-    /// Identificadores agnósticos de los cuerpos en el grupo
-    /// (ej. `["sun", "jupiter", "saturn"]`).
-    pub bodies: Vec<String>,
-    /// Posición en el dial de 90° (la longitud módulo 90).
-    pub mod90_deg: f64,
-}
-
-/// Resumen textual de un aspecto para listas legibles. La engine lo
-/// emite en paralelo con las `LineSeg` de la capa de aspectos, así
-/// el canvas no tiene que re-derivar nombres de cuerpos desde grados.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct AspectSummary {
-    /// Module al que pertenece — "natal", "transit", "synastry",
-    /// "progression", "solar_arc", "planetary_return".
-    pub module_id: String,
-    /// Identificador agnóstico del cuerpo "a" — "sun", "moon", etc.
-    pub from_body: String,
-    pub to_body: String,
-    /// Identificador del aspecto — "conjunction", "trine", etc.
-    pub kind: String,
-    pub orb_deg: f64,
-    /// `Some(true)` = applying, `Some(false)` = separating. `None` para
-    /// cross-aspects (sinastría/return) donde no se computa.
-    #[serde(default)]
-    pub applying: Option<bool>,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct Layer {
-    pub module_id: String,
-    pub kind: LayerKind,
-    /// Radio normalizado [0, 1] sobre el lienzo — el canvas lo convierte
-    /// a píxeles. Permite stack de anillos.
-    pub ring: f32,
-    #[serde(default)]
-    pub z: i32,
-    pub geometry: Geometry,
-    #[serde(default)]
-    pub glyphs: Vec<Glyph>,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum LayerKind {
-    SignDial,
-    Houses,
-    Bodies,
-    Aspects,
-    Lots,
-    FixedStars,
-    Midpoints,
-    Outer,
-    Custom,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub enum Geometry {
-    GlyphsOnly,
-    /// Anillo dividido en sectores. `cusps_deg` son los grados
-    /// zodiacales donde van las divisiones radiales.
-    Ring { cusps_deg: Vec<f32> },
-    Lines(Vec<LineSeg>),
-    Points(Vec<PointMark>),
-}
-
-#[derive(Debug, Clone, Default, Serialize, Deserialize)]
-pub struct LineSeg {
-    /// Grados zodiacales del extremo "a".
-    pub from_deg: f32,
-    /// Grados zodiacales del extremo "b".
-    pub to_deg: f32,
-    /// Categoría simbólica (`"conjunction"`, `"trine"`, …) — el theme la
-    /// resuelve a color.
-    pub kind: String,
-    pub opacity: f32,
-    /// Cuerpo en el extremo "a" — populado para LineSegs de aspectos
-    /// (natal × natal, cross con overlays). Vacío en `Default::default`
-    /// para serde back-compat.
-    #[serde(default)]
-    pub from_body: String,
-    /// Cuerpo en el extremo "b".
-    #[serde(default)]
-    pub to_body: String,
-    /// Orb absoluto en grados (para tooltips).
-    #[serde(default)]
-    pub orb_deg: f32,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct PointMark {
-    pub deg: f32,
-    pub label: String,
-    pub tag: String,
-}
-
-#[derive(Debug, Clone, Default, Serialize, Deserialize)]
-pub struct Glyph {
-    /// Grado eclíptico [0, 360).
-    pub deg: f32,
-    /// Glyph simbólico — el theme/canvas lo mapea a unicode o imagen.
-    /// Ej: `"sun"`, `"moon"`, `"aries"`, `"asc"`, `"mc"`.
-    pub symbol: String,
-    #[serde(default)]
-    pub annotation: Option<String>,
-    #[serde(default)]
-    pub retrograde: bool,
-    #[serde(default)]
-    pub house: Option<u8>,
-    /// Marker de dignidad esencial, set solo cuando
-    /// `NatalOptions::show_dignities` está activo: `"+"` (domicilio),
-    /// `"·"` (exaltación), `"−"` (exilio), `"*"` (caída).
-    #[serde(default)]
-    pub dignity_marker: Option<String>,
-}
-
 // =====================================================================
 // Errores
 // =====================================================================
@@ -246,12 +81,6 @@ pub enum EngineError {
 /// `tahuantinsuyu-modules` por id string. Esto deja la engine como
 /// dueña única del cómputo (no depende del trait Module — los módulos
 /// son sólo metadata + UI controls).
-/// Módulos overlay que pintan en el mismo slot (outer ring del wheel)
-/// y por lo tanto son **mutuamente excluyentes** a nivel de UI: al
-/// prender uno, el shell debe apagar los otros. Single source of truth
-/// — el shell y el canvas leen de acá en vez de hardcodear listas.
-pub const OUTER_RING_MODULES: &[&str] = &["transit", "synastry", "planetary_return"];
-
 #[derive(Debug, Clone)]
 pub enum PipelineRequest {
     /// `module_id = "transit"` — anillo externo con planetas al
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-render/Cargo.toml b/crates/modules/tahuantinsuyu/tahuantinsuyu-render/Cargo.toml
new file mode 100644
index 0000000..d60585a
--- /dev/null
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-render/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "tahuantinsuyu-render"
+version = { workspace = true }
+edition = { workspace = true }
+license = { workspace = true }
+description = "Tahuantinsuyu — modelo y matemática de render agnósticos de surface. Compila a WASM y a nativo; el canvas gpui y el cliente web lo consumen para emitir las primitivas comunes de la rueda."
+
+[dependencies]
+tahuantinsuyu-model = { path = "../tahuantinsuyu-model" }
+serde = { workspace = true }
+
+[lib]
+crate-type = ["rlib"]
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-render/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-render/src/lib.rs
new file mode 100644
index 0000000..71b2f29
--- /dev/null
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-render/src/lib.rs
@@ -0,0 +1,220 @@
+//! `tahuantinsuyu-render` — modelo y matemática de render
+//! **agnósticos de surface**. Lo consumen tanto el canvas gpui
+//! (nativo, render Vulkan/Metal) como el cliente web (WASM, render
+//! SVG / Canvas2D). Cualquier mejora del layout / spread / cluster /
+//! coords vive acá una sola vez y aparece en ambos clientes.
+//!
+//! ## Por qué un crate aparte
+//!
+//! `tahuantinsuyu-engine` arrastra `eternal-sky` (VSOP2013 + I/O de
+//! tablas) que **no compila a WASM** sin empaquetar 30+ MB de
+//! efemérides. Los tipos del `RenderModel` en sí son serde puro y
+//! sí compilan a WASM — extraerlos a este crate libera al cliente
+//! web de la dependencia transitiva.
+//!
+//! ## Capas
+//!
+//! 1. **Modelo de render** — `RenderModel`, `Layer`, `Glyph`,
+//!    `LineSeg`, `Geometry`, `LayerKind`. Estructuras serde-friendly
+//!    que el engine emite y los clients consumen.
+//! 2. **Matemática agnóstica** *(módulos siguientes, no en esta primera
+//!    versión)* — `polar_to_screen`, `spread_angles`, `find_clusters`,
+//!    `format_coord_compact`, `Radii`. Migran desde el canvas gpui.
+//! 3. **`DrawCommand`** *(módulo siguiente)* — primitivas de pintura
+//!    (line, circle, glyph, pill) que cada surface traduce a su API.
+
+#![forbid(unsafe_code)]
+#![warn(rust_2018_idioms)]
+
+use serde::{Deserialize, Serialize};
+
+pub use tahuantinsuyu_model::{Chart, ChartId, ChartKind};
+
+pub mod math;
+
+pub use math::{
+    find_clusters, format_coord_compact, polar_to_screen, spread_angles, Radii,
+};
+
+// =====================================================================
+// RenderModel — lo que el client renderea
+// =====================================================================
+
+/// Resultado agnóstico de un cómputo astrológico, listo para renderizar.
+/// El canvas gpui y el cliente web lo consumen idénticamente: el engine
+/// computa (en nativo, con eternal) y publica este struct.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RenderModel {
+    pub chart_id: ChartId,
+    pub chart_kind: ChartKind,
+    pub title: String,
+    #[serde(default)]
+    pub subtitle: Option<String>,
+    pub compute_ms: u64,
+
+    // ─── Ángulos del chart (grados eclípticos, 0..360) ───────────────
+    /// Ascendente — punto fijo de rotación del lienzo. La rueda se gira
+    /// de modo que el Asc cae a las 9 (lado izquierdo).
+    pub ascendant_deg: f32,
+    pub midheaven_deg: f32,
+    pub descendant_deg: f32,
+    pub imum_coeli_deg: f32,
+
+    /// Capas a pintar. Orden = z-order ascendente.
+    pub layers: Vec<Layer>,
+    /// Metadata humana por overlay activo (transit, progresión,
+    /// sinastría, retorno...). Vacío para una carta natal pura. La UI
+    /// la pinta como badges en el footer.
+    #[serde(default)]
+    pub overlays: Vec<OverlayMeta>,
+    /// Lista paralela a las LineSeg de aspectos — uno por aspecto
+    /// natal o cross. Ordenado por `orb_deg` ascendente (los más
+    /// cerrados primero). La UI lo usa para la lista textual.
+    #[serde(default)]
+    pub aspect_summary: Vec<AspectSummary>,
+    /// Grupos uranianos detectados (cuerpos en la misma posición mod 90).
+    /// Vacío sino se activó el módulo Uranian.
+    #[serde(default)]
+    pub uranian_groups: Vec<UranianGroup>,
+}
+
+/// Etiqueta legible de un overlay para el footer del canvas. La engine
+/// la pushea desde cada `build_*_overlay`; el canvas solo lee y pinta.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct OverlayMeta {
+    pub module_id: String,
+    /// Etiqueta corta — ej. "Tránsito ahora", "Progresión 38.2a",
+    /// "Sinastría · Ana", "Saturn return 29a".
+    pub label: String,
+}
+
+/// Grupo de cuerpos natales que caen en la misma posición del
+/// dial uraniano de 90° (su longitud zodiacal módulo 90 es igual o
+/// muy cercana). En la astrología uraniana esto es una "fórmula" o
+/// "axis" — los cuerpos están en correspondencia simbólica directa
+/// porque comparten un cuadrante simétrico.
+///
+/// Solo se emiten grupos con 2+ miembros (los singletons no son
+/// fórmulas). La engine los ordena por proximidad al ε de tolerancia.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct UranianGroup {
+    /// Identificadores agnósticos de los cuerpos en el grupo
+    /// (ej. `["sun", "jupiter", "saturn"]`).
+    pub bodies: Vec<String>,
+    /// Posición en el dial de 90° (la longitud módulo 90).
+    pub mod90_deg: f64,
+}
+
+/// Resumen textual de un aspecto para listas legibles. La engine lo
+/// emite en paralelo con las `LineSeg` de la capa de aspectos, así
+/// el canvas no tiene que re-derivar nombres de cuerpos desde grados.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct AspectSummary {
+    /// Module al que pertenece — "natal", "transit", "synastry",
+    /// "progression", "solar_arc", "planetary_return".
+    pub module_id: String,
+    /// Identificador agnóstico del cuerpo "a" — "sun", "moon", etc.
+    pub from_body: String,
+    pub to_body: String,
+    /// Identificador del aspecto — "conjunction", "trine", etc.
+    pub kind: String,
+    pub orb_deg: f64,
+    /// `Some(true)` = applying, `Some(false)` = separating. `None` para
+    /// cross-aspects (sinastría/return) donde no se computa.
+    #[serde(default)]
+    pub applying: Option<bool>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Layer {
+    pub module_id: String,
+    pub kind: LayerKind,
+    /// Radio normalizado [0, 1] sobre el lienzo — el canvas lo convierte
+    /// a píxeles. Permite stack de anillos.
+    pub ring: f32,
+    #[serde(default)]
+    pub z: i32,
+    pub geometry: Geometry,
+    #[serde(default)]
+    pub glyphs: Vec<Glyph>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum LayerKind {
+    SignDial,
+    Houses,
+    Bodies,
+    Aspects,
+    Lots,
+    FixedStars,
+    Midpoints,
+    Outer,
+    Custom,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum Geometry {
+    GlyphsOnly,
+    /// Anillo dividido en sectores. `cusps_deg` son los grados
+    /// zodiacales donde van las divisiones radiales.
+    Ring { cusps_deg: Vec<f32> },
+    Lines(Vec<LineSeg>),
+    Points(Vec<PointMark>),
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct LineSeg {
+    /// Grados zodiacales del extremo "a".
+    pub from_deg: f32,
+    /// Grados zodiacales del extremo "b".
+    pub to_deg: f32,
+    /// Categoría simbólica (`"conjunction"`, `"trine"`, …) — el theme la
+    /// resuelve a color.
+    pub kind: String,
+    pub opacity: f32,
+    /// Cuerpo en el extremo "a" — populado para LineSegs de aspectos
+    /// (natal × natal, cross con overlays). Vacío en `Default::default`
+    /// para serde back-compat.
+    #[serde(default)]
+    pub from_body: String,
+    /// Cuerpo en el extremo "b".
+    #[serde(default)]
+    pub to_body: String,
+    /// Orb absoluto en grados (para tooltips).
+    #[serde(default)]
+    pub orb_deg: f32,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PointMark {
+    pub deg: f32,
+    pub label: String,
+    pub tag: String,
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct Glyph {
+    /// Grado eclíptico [0, 360).
+    pub deg: f32,
+    /// Glyph simbólico — el theme/canvas lo mapea a unicode o imagen.
+    /// Ej: `"sun"`, `"moon"`, `"aries"`, `"asc"`, `"mc"`.
+    pub symbol: String,
+    #[serde(default)]
+    pub annotation: Option<String>,
+    #[serde(default)]
+    pub retrograde: bool,
+    #[serde(default)]
+    pub house: Option<u8>,
+    /// Marker de dignidad esencial, set solo cuando
+    /// `NatalOptions::show_dignities` está activo: `"+"` (domicilio),
+    /// `"·"` (exaltación), `"−"` (exilio), `"*"` (caída).
+    #[serde(default)]
+    pub dignity_marker: Option<String>,
+}
+
+/// Módulos overlay que pintan en el mismo slot (outer ring del wheel)
+/// y por lo tanto son **mutuamente excluyentes** a nivel de UI: al
+/// prender uno, el shell debe apagar los otros. Single source of truth
+/// — el shell y el canvas leen de acá en vez de hardcodear listas.
+pub const OUTER_RING_MODULES: &[&str] = &["transit", "synastry", "planetary_return"];
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-render/src/math.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-render/src/math.rs
new file mode 100644
index 0000000..22f60db
--- /dev/null
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-render/src/math.rs
@@ -0,0 +1,396 @@
+//! Matemática agnóstica de surface — radios canónicos del wheel,
+//! conversión polar → pantalla, spread anti-solapamiento, detección
+//! de clusters, formato de coordenadas.
+//!
+//! Vive aquí (no en el canvas gpui) porque exactamente la misma
+//! lógica corre en el cliente web (WASM) y en la app desktop. Cualquier
+//! ajuste de geometría aparece en ambos a la vez.
+
+use core::f32::consts::PI;
+
+use crate::OUTER_RING_MODULES;
+
+// =====================================================================
+// Radii — geometría radial canónica de la rueda
+// =====================================================================
+
+/// Geometría radial canónica del wheel. Aros nombrados según convención
+/// de Sergio, de afuera hacia adentro:
+///
+/// * **Aro A** (`sign_outer`) — externo del zodiaco.
+/// * **Zona AB** — sign dial: glyphs de signos zodiacales.
+/// * **Aro B** (`sign_inner` = `topo_houses_outer`) — interno del
+///   zodiaco / externo del bloque ascensional.
+/// * **Zona BC** — casas topocéntricas (cusps b→c) + planetas
+///   topocéntricos, ambos con sus coordenadas.
+/// * **Aro C** (`topo_houses_inner` = `houses_outer`) — separador
+///   ascensional / casas geo.
+/// * **Zona CD** — casas geocéntricas (cusps c→d) + sus coordenadas.
+/// * **Aro D** (`houses_inner`) — externo de los planetas natales.
+///   Junto a D, hacia adentro, se posan los planetas natales y sus
+///   coordenadas.
+/// * **Aro E** (`aspects`) — el más interno. Desde aquí nacen las
+///   líneas de aspecto / relaciones / overlays opcionales.
+///
+/// Los overlays adicionales (transits, midpoints, progression, solar
+/// arc, composite) viven INTERIORES al aro E — solo se pintan
+/// cuando el módulo correspondiente está activo, así no compiten
+/// con el layout base.
+#[derive(Clone, Copy, Debug)]
+pub struct Radii {
+    pub sign_outer: f32,        // Aro A
+    pub sign_inner: f32,        // Aro B
+    pub topo_houses_outer: f32, // = Aro B
+    pub topocentric: f32,       // Zona BC: planetas topo
+    pub topo_houses_inner: f32, // Aro C
+    pub houses_outer: f32,      // = Aro C
+    pub houses_inner: f32,      // Aro D
+    pub bodies: f32,            // Zona D-E: planetas natales (junto a D)
+    pub pd_direct: f32,         // GR (cuando activo): exterior al cinturón natal
+    pub pd_converse: f32,       // GR (cuando activo): interior al cinturón natal
+    pub aspects: f32,           // Aro E (invisible, ancla de líneas)
+    // Overlays adicionales — todos interiores a E.
+    pub transits: f32,
+    pub midpoints: f32,
+    pub progression: f32,
+    pub solar_arc: f32,
+    pub composite: f32,
+}
+
+impl Radii {
+    pub fn from_outer(r: f32) -> Self {
+        Self {
+            sign_outer: r,
+            sign_inner: r * 0.92,
+            topo_houses_outer: r * 0.92,
+            topocentric: r * 0.85,
+            topo_houses_inner: r * 0.78,
+            houses_outer: r * 0.78,
+            houses_inner: r * 0.62,
+            bodies: r * 0.57,
+            pd_direct: r * 0.545,
+            pd_converse: r * 0.515,
+            aspects: r * 0.49,
+            transits: r * 0.43,
+            midpoints: r * 0.39,
+            progression: r * 0.33,
+            solar_arc: r * 0.27,
+            composite: r * 0.21,
+        }
+    }
+
+    /// Radio del ring de cuerpos según el `module_id` del Layer.
+    pub fn body_ring(&self, module_id: &str) -> f32 {
+        match module_id {
+            "progression" => self.progression,
+            "solar_arc" => self.solar_arc,
+            "composite" => self.composite,
+            "midpoints" => self.midpoints,
+            "topocentric" => self.topocentric,
+            "pd_direct" => self.pd_direct,
+            "pd_converse" => self.pd_converse,
+            _ => self.bodies,
+        }
+    }
+
+    /// Resuelve qué radios corresponden a una capa de aspectos según el
+    /// `module_id`: natal-natal en `aspects`, cross con cada overlay
+    /// desde `bodies` (extremo natal) al ring del módulo. Los módulos
+    /// del outer ring (OUTER_RING_MODULES) comparten el slot de
+    /// tránsito (son mutuamente excluyentes a nivel de Shell).
+    pub fn aspect_endpoints(&self, module_id: &str) -> (f32, f32) {
+        if OUTER_RING_MODULES.contains(&module_id) {
+            return (self.bodies, self.transits);
+        }
+        match module_id {
+            "progression" => (self.bodies, self.progression),
+            "solar_arc" => (self.bodies, self.solar_arc),
+            "composite" => (self.bodies, self.composite),
+            _ => (self.aspects, self.aspects),
+        }
+    }
+}
+
+// =====================================================================
+// polar_to_screen — convención de rotación del wheel
+// =====================================================================
+
+/// Convierte una longitud eclíptica a coords cartesianas relativas al
+/// centro del wheel. Convención: el Ascendente cae a las 9 (lado
+/// izquierdo). `rot_offset_deg` permite rotar la vista (jog-dial).
+pub fn polar_to_screen(
+    longitude_deg: f32,
+    ascendant_deg: f32,
+    rot_offset_deg: f32,
+    radius: f32,
+) -> (f32, f32) {
+    let deg = 180.0 - (longitude_deg - ascendant_deg + rot_offset_deg);
+    let rad = deg * PI / 180.0;
+    (radius * rad.cos(), radius * rad.sin())
+}
+
+// =====================================================================
+// Spread anti-solapamiento de glyphs
+// =====================================================================
+
+/// Reposiciona angularmente un conjunto de longitudes para que pares
+/// adyacentes mantengan al menos `min_sep_deg` de separación, **sin
+/// que ningún glyph se aleje más de `max_shift_deg` de su posición
+/// real**. La acotación es clave para evitar que un cluster denso
+/// "empuje" a planetas que estaban lejos.
+///
+/// Algoritmo: iteramos hasta 80 veces; en cada pasada re-ordenamos
+/// los displays para mantener el orden circular, y en cada par
+/// adyacente que esté muy cerca acumulamos fuerzas en sentidos
+/// opuestos. Aplicamos las fuerzas con `damping = 0.6` y clampeamos
+/// cada display al rango `[raw[i] - max_shift, raw[i] + max_shift]`.
+/// Si el cluster es tan denso que el clamp impide alcanzar el
+/// `min_sep`, el residual queda alto y el caller encoge los discos.
+///
+/// Devuelve `(displays, residual)` con `residual ∈ [0, 1]` =
+/// fracción de presión no resuelta tras el clamp.
+pub fn spread_angles(
+    angles_deg: &[f32],
+    min_sep_deg: f32,
+    max_shift_deg: f32,
+) -> (Vec<f32>, f32) {
+    let n = angles_deg.len();
+    if n <= 1 {
+        return (angles_deg.to_vec(), 0.0);
+    }
+    if (n as f32) * min_sep_deg >= 360.0 {
+        return (angles_deg.to_vec(), 1.0);
+    }
+    let raw: Vec<f32> = angles_deg.iter().map(|a| a.rem_euclid(360.0)).collect();
+    let mut displays: Vec<f32> = raw.clone();
+    let mut last_residual = 0.0_f32;
+
+    let clamp_to_raw = |display: f32, raw: f32, max_shift: f32| -> f32 {
+        let mut delta = display - raw;
+        if delta > 180.0 {
+            delta -= 360.0;
+        }
+        if delta < -180.0 {
+            delta += 360.0;
+        }
+        let clamped = delta.clamp(-max_shift, max_shift);
+        (raw + clamped).rem_euclid(360.0)
+    };
+
+    let damping: f32 = 0.6;
+    for _ in 0..80 {
+        let mut order: Vec<usize> = (0..n).collect();
+        order.sort_by(|&a, &b| {
+            displays[a]
+                .partial_cmp(&displays[b])
+                .unwrap_or(core::cmp::Ordering::Equal)
+        });
+        let mut forces = vec![0.0_f32; n];
+        let mut max_residual: f32 = 0.0;
+        for k in 0..n {
+            let i = order[k];
+            let j = order[(k + 1) % n];
+            let diff = (displays[j] - displays[i]).rem_euclid(360.0);
+            if diff < min_sep_deg {
+                let push = (min_sep_deg - diff) / 2.0;
+                forces[i] -= push;
+                forces[j] += push;
+                let r = (min_sep_deg - diff) / min_sep_deg;
+                if r > max_residual {
+                    max_residual = r;
+                }
+            }
+        }
+        for i in 0..n {
+            let stepped = (displays[i] + forces[i] * damping).rem_euclid(360.0);
+            displays[i] = clamp_to_raw(stepped, raw[i], max_shift_deg);
+        }
+        last_residual = max_residual;
+        if max_residual < 0.001 {
+            break;
+        }
+    }
+
+    (displays, last_residual)
+}
+
+/// Detecta clusters de longitudes angularmente cercanas. Dos
+/// elementos están en el mismo cluster si su separación circular es
+/// menor a `threshold_deg`. Devuelve los índices originales
+/// agrupados; cada Vec interno representa un cluster (incluso si
+/// es de tamaño 1). Cluster con wrap-around (último→primero) se
+/// fusionan correctamente.
+pub fn find_clusters(angles_deg: &[f32], threshold_deg: f32) -> Vec<Vec<usize>> {
+    let n = angles_deg.len();
+    if n == 0 {
+        return Vec::new();
+    }
+    let mut idxed: Vec<(usize, f32)> = angles_deg
+        .iter()
+        .copied()
+        .map(|a| a.rem_euclid(360.0))
+        .enumerate()
+        .collect();
+    idxed.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(core::cmp::Ordering::Equal));
+    let mut clusters: Vec<Vec<usize>> = Vec::new();
+    let mut cur: Vec<usize> = vec![idxed[0].0];
+    let mut last = idxed[0].1;
+    for (idx, a) in idxed.iter().skip(1).copied() {
+        if (a - last) < threshold_deg {
+            cur.push(idx);
+        } else {
+            clusters.push(core::mem::take(&mut cur));
+            cur.push(idx);
+        }
+        last = a;
+    }
+    clusters.push(cur);
+    if clusters.len() >= 2 {
+        let first_a = angles_deg[clusters[0][0]].rem_euclid(360.0);
+        let last_a = angles_deg[*clusters.last().unwrap().last().unwrap()].rem_euclid(360.0);
+        let wrap_diff = 360.0 - last_a + first_a;
+        if wrap_diff < threshold_deg {
+            let mut tail = clusters.pop().unwrap();
+            tail.extend(clusters[0].iter().copied());
+            clusters[0] = tail;
+        }
+    }
+    clusters
+}
+
+// =====================================================================
+// Coord formatter
+// =====================================================================
+
+/// Formato compacto con precisión de minutos: "DD°MM'{signo}" donde
+/// el signo es el glyph zodiacal (♈♉♊…). Ej: 14.93° → "14°56'♈".
+/// Los minutos se redondean al entero más cercano; carry-overs entre
+/// signos están cubiertos por trabajar en minutos enteros absolutos.
+pub fn format_coord_compact(deg: f32) -> String {
+    let normalized = deg.rem_euclid(360.0);
+    let total_minutes = (normalized * 60.0).round() as i64;
+    let total_minutes = total_minutes.rem_euclid(360 * 60);
+    let sign_idx = (total_minutes / (30 * 60)) as usize % 12;
+    let within_sign = total_minutes - (sign_idx as i64) * 30 * 60;
+    let deg_int = (within_sign / 60) as i32;
+    let minutes = (within_sign % 60) as i32;
+    let sign_glyph = match sign_idx {
+        0 => "♈",
+        1 => "♉",
+        2 => "♊",
+        3 => "♋",
+        4 => "♌",
+        5 => "♍",
+        6 => "♎",
+        7 => "♏",
+        8 => "♐",
+        9 => "♑",
+        10 => "♒",
+        _ => "♓",
+    };
+    format!("{}°{:02}'{}", deg_int, minutes, sign_glyph)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn assert_min_sep(displays: &[f32], min_sep: f32) {
+        let n = displays.len();
+        let mut sorted = displays.to_vec();
+        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
+        let tol = min_sep * 0.02;
+        for i in 0..n {
+            let nxt = (i + 1) % n;
+            let diff = (sorted[nxt] - sorted[i]).rem_euclid(360.0);
+            assert!(
+                diff + tol >= min_sep,
+                "vecinos {} y {} a {}° (mínimo {})",
+                sorted[i],
+                sorted[nxt],
+                diff,
+                min_sep
+            );
+        }
+    }
+
+    #[test]
+    fn spread_empty_and_single_unchanged() {
+        let (r, residual) = spread_angles(&[], 10.0, 30.0);
+        assert!(r.is_empty());
+        assert_eq!(residual, 0.0);
+        let (r, residual) = spread_angles(&[42.0], 10.0, 30.0);
+        assert_eq!(r, vec![42.0]);
+        assert_eq!(residual, 0.0);
+    }
+
+    #[test]
+    fn spread_spaced_input_left_alone() {
+        let input = vec![0.0, 30.0, 90.0, 200.0];
+        let (out, residual) = spread_angles(&input, 10.0, 30.0);
+        assert!(residual < 0.001);
+        for (a, b) in input.iter().zip(out.iter()) {
+            assert!((a - b).abs() < 1e-3, "{} vs {}", a, b);
+        }
+    }
+
+    #[test]
+    fn spread_tight_cluster_gets_spread() {
+        let input = vec![100.0, 101.0, 102.0];
+        let (out, residual) = spread_angles(&input, 10.0, 30.0);
+        assert!(residual < 0.05, "residual {}", residual);
+        assert_min_sep(&out, 10.0);
+    }
+
+    #[test]
+    fn spread_shift_is_bounded() {
+        let input = vec![100.0, 101.0];
+        let (out, _) = spread_angles(&input, 10.0, 2.0);
+        for (raw, disp) in input.iter().zip(out.iter()) {
+            let mut delta = (disp - raw).abs();
+            if delta > 180.0 {
+                delta = 360.0 - delta;
+            }
+            assert!(delta <= 2.0 + 0.01, "shift {} > 2°", delta);
+        }
+    }
+
+    #[test]
+    fn spread_distant_planet_unaffected_by_dense_cluster() {
+        let input = vec![100.0, 100.5, 101.0, 200.0];
+        let (out, _) = spread_angles(&input, 10.0, 10.0);
+        let mut delta = (out[3] - 200.0).abs();
+        if delta > 180.0 {
+            delta = 360.0 - delta;
+        }
+        assert!(delta < 5.0, "planeta lejano se movió {}°", delta);
+    }
+
+    #[test]
+    fn coord_zero_aries() {
+        assert_eq!(format_coord_compact(0.0), "0°00'♈");
+    }
+
+    #[test]
+    fn coord_fourteen_fiftysix_aries() {
+        assert_eq!(format_coord_compact(14.933_3), "14°56'♈");
+    }
+
+    #[test]
+    fn coord_rollover_to_taurus() {
+        assert_eq!(format_coord_compact(29.9995), "0°00'♉");
+    }
+
+    #[test]
+    fn coord_negative_wraps() {
+        assert_eq!(format_coord_compact(-10.0), "20°00'♓");
+    }
+
+    #[test]
+    fn polar_to_screen_asc_on_left() {
+        // Si la longitud = asc, el punto cae a las 9 (x = -radius, y = 0).
+        let (x, y) = polar_to_screen(120.0, 120.0, 0.0, 100.0);
+        assert!((x - (-100.0)).abs() < 1e-3, "x={}", x);
+        assert!(y.abs() < 1e-3, "y={}", y);
+    }
+}