//! 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); } }