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).
This commit is contained in:
@@ -139,6 +139,7 @@ members = [
|
|||||||
"crates/modules/tahuantinsuyu/tahuantinsuyu-card",
|
"crates/modules/tahuantinsuyu/tahuantinsuyu-card",
|
||||||
"crates/modules/tahuantinsuyu/tahuantinsuyu-model",
|
"crates/modules/tahuantinsuyu/tahuantinsuyu-model",
|
||||||
"crates/modules/tahuantinsuyu/tahuantinsuyu-store",
|
"crates/modules/tahuantinsuyu/tahuantinsuyu-store",
|
||||||
|
"crates/modules/tahuantinsuyu/tahuantinsuyu-render",
|
||||||
"crates/modules/tahuantinsuyu/tahuantinsuyu-engine",
|
"crates/modules/tahuantinsuyu/tahuantinsuyu-engine",
|
||||||
"crates/modules/tahuantinsuyu/tahuantinsuyu-modules",
|
"crates/modules/tahuantinsuyu/tahuantinsuyu-modules",
|
||||||
"crates/modules/tahuantinsuyu/tahuantinsuyu-theme",
|
"crates/modules/tahuantinsuyu/tahuantinsuyu-theme",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ description = "Tahuantinsuyu — widget GPUI del canvas astrológico. Capas modu
|
|||||||
tahuantinsuyu-engine = { path = "../tahuantinsuyu-engine" }
|
tahuantinsuyu-engine = { path = "../tahuantinsuyu-engine" }
|
||||||
tahuantinsuyu-model = { path = "../tahuantinsuyu-model" }
|
tahuantinsuyu-model = { path = "../tahuantinsuyu-model" }
|
||||||
tahuantinsuyu-modules = { path = "../tahuantinsuyu-modules" }
|
tahuantinsuyu-modules = { path = "../tahuantinsuyu-modules" }
|
||||||
|
tahuantinsuyu-render = { path = "../tahuantinsuyu-render" }
|
||||||
tahuantinsuyu-theme = { path = "../tahuantinsuyu-theme" }
|
tahuantinsuyu-theme = { path = "../tahuantinsuyu-theme" }
|
||||||
yahweh-theme = { workspace = true }
|
yahweh-theme = { workspace = true }
|
||||||
gpui = { workspace = true }
|
gpui = { workspace = true }
|
||||||
|
|||||||
@@ -1808,116 +1808,11 @@ fn format_offset(minutes: i64) -> String {
|
|||||||
// Painting
|
// Painting
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|
||||||
/// Geometría radial canónica de la rueda. Aros nombrados según
|
// `Radii` + helpers migraron a `tahuantinsuyu-render` (crate
|
||||||
/// convención del usuario, de afuera hacia adentro:
|
// agnóstico de surface, compila a WASM y nativo). Re-export para
|
||||||
///
|
// que el código del canvas siga refiriendo `Radii` sin cambiar
|
||||||
/// * **Aro A** (`sign_outer`) — externo del zodiaco.
|
// imports en cada call site.
|
||||||
/// * **Zona AB** — sign dial: glyphs de signos zodiacales.
|
use tahuantinsuyu_render::Radii;
|
||||||
/// * **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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
// `hover_focus`: symbol del planeta hovereado en este frame (si lo
|
// `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()
|
(dx2 * dx2 + dy2 * dy2).sqrt()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn polar_to_screen(
|
// `polar_to_screen` se importa desde `tahuantinsuyu-render`.
|
||||||
longitude_deg: f32,
|
use tahuantinsuyu_render::polar_to_screen;
|
||||||
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())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn centered_glyph(
|
fn centered_glyph(
|
||||||
x: f32,
|
x: f32,
|
||||||
@@ -2671,291 +2558,15 @@ fn body_disk_base(module_id: &str, kind: LayerKind, view_scale: f32) -> f32 {
|
|||||||
base * view_scale
|
base * view_scale
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reposiciona angularmente un conjunto de longitudes para que pares
|
// `spread_angles` y `find_clusters` migraron a `tahuantinsuyu-render`.
|
||||||
/// adyacentes mantengan al menos `min_sep_deg` de separación, **sin
|
use tahuantinsuyu_render::{find_clusters, spread_angles};
|
||||||
/// 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
|
// `format_coord_compact` migró a `tahuantinsuyu-render`.
|
||||||
// `raw ± max_shift_deg`, midiendo distancia mínima circular.
|
use tahuantinsuyu_render::format_coord_compact;
|
||||||
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
|
// Los tests de `spread_angles`, `find_clusters` y
|
||||||
// adyacentes en una pasada, luego aplicamos un step con damping.
|
// `format_coord_compact` viven ahora en `tahuantinsuyu-render::math`
|
||||||
// Esto evita las oscilaciones del empuje inmediato (que reordenaba
|
// junto a sus implementaciones.
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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<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'♓");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pill pequeña con un coord ("14°♈") junto al glyph de un planeta
|
/// 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
|
/// o cusp. Fondo halo + texto fg_muted, padding mínimo para no
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ description = "Tahuantinsuyu — bridge entre el modelo agnóstico y eternal-ast
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tahuantinsuyu-model = { path = "../tahuantinsuyu-model" }
|
tahuantinsuyu-model = { path = "../tahuantinsuyu-model" }
|
||||||
|
tahuantinsuyu-render = { path = "../tahuantinsuyu-render" }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
|
||||||
|
|||||||
@@ -25,11 +25,21 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
#![warn(rust_2018_idioms)]
|
#![warn(rust_2018_idioms)]
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
pub use tahuantinsuyu_model::{Chart, ChartId, ChartKind};
|
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`
|
// `Chart` reexportado arriba es lo que `PipelineRequest::Synastry`
|
||||||
// transporta — el caller (shell) lee del Store y pasa el Chart entero
|
// transporta — el caller (shell) lee del Store y pasa el Chart entero
|
||||||
// para que el bridge construya su NatalChart en eternal.
|
// para que el bridge construya su NatalChart en eternal.
|
||||||
@@ -43,181 +53,6 @@ mod natal_cache;
|
|||||||
#[cfg(feature = "eternal-bridge")]
|
#[cfg(feature = "eternal-bridge")]
|
||||||
pub mod svg_export;
|
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
|
// Errores
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -246,12 +81,6 @@ pub enum EngineError {
|
|||||||
/// `tahuantinsuyu-modules` por id string. Esto deja la engine como
|
/// `tahuantinsuyu-modules` por id string. Esto deja la engine como
|
||||||
/// dueña única del cómputo (no depende del trait Module — los módulos
|
/// dueña única del cómputo (no depende del trait Module — los módulos
|
||||||
/// son sólo metadata + UI controls).
|
/// 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)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum PipelineRequest {
|
pub enum PipelineRequest {
|
||||||
/// `module_id = "transit"` — anillo externo con planetas al
|
/// `module_id = "transit"` — anillo externo con planetas al
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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"];
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user