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) { - 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 = angles_deg.iter().map(|a| a.rem_euclid(360.0)).collect(); - let mut displays: Vec = raw.clone(); - let mut last_residual = 0.0_f32; +// `spread_angles` y `find_clusters` migraron a `tahuantinsuyu-render`. +use tahuantinsuyu_render::{find_clusters, spread_angles}; - // 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) - }; +// `format_coord_compact` migró a `tahuantinsuyu-render`. +use tahuantinsuyu_render::format_coord_compact; - // 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 = (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> { - 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::new(); - let mut cur: Vec = 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); - } - - #[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); - } - } - - #[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 = (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, - 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, - /// 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, - /// 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, - /// 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, -} - -/// 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, - /// 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, -} - -#[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, -} - -#[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 }, - Lines(Vec), - Points(Vec), -} - -#[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, - #[serde(default)] - pub retrograde: bool, - #[serde(default)] - pub house: Option, - /// Marker de dignidad esencial, set solo cuando - /// `NatalOptions::show_dignities` está activo: `"+"` (domicilio), - /// `"·"` (exaltación), `"−"` (exilio), `"*"` (caída). - #[serde(default)] - pub dignity_marker: Option, -} - // ===================================================================== // 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, + 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, + /// 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, + /// 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, + /// 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, +} + +/// 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, + /// 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, +} + +#[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, +} + +#[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 }, + Lines(Vec), + Points(Vec), +} + +#[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, + #[serde(default)] + pub retrograde: bool, + #[serde(default)] + pub house: Option, + /// Marker de dignidad esencial, set solo cuando + /// `NatalOptions::show_dignities` está activo: `"+"` (domicilio), + /// `"·"` (exaltación), `"−"` (exilio), `"*"` (caída). + #[serde(default)] + pub dignity_marker: Option, +} + +/// 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) { + 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 = angles_deg.iter().map(|a| a.rem_euclid(360.0)).collect(); + let mut displays: Vec = 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 = (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> { + 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::new(); + let mut cur: Vec = 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); + } +}