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:
sergio
2026-05-19 00:33:39 +00:00
parent 8e95c884ed
commit 9084cf4b79
8 changed files with 657 additions and 585 deletions
@@ -1808,116 +1808,11 @@ fn format_offset(minutes: i64) -> String {
// Painting
// =====================================================================
/// Geometría radial canónica de la rueda. Aros nombrados según
/// convención del usuario, de afuera hacia adentro:
///
/// * **Aro A** (`sign_outer`) — externo del zodiaco.
/// * **Zona AB** — sign dial: glyphs de signos zodiacales.
/// * **Aro B** (`sign_inner` = `topo_houses_outer`) — interno del
/// zodiaco / externo del bloque ascensional.
/// * **Zona BC** — casas topocéntricas (cusps b→c) + planetas
/// topocéntricos, ambos con sus coordenadas.
/// * **Aro C** (`topo_houses_inner` = `houses_outer`) — separador
/// ascensional / casas geo.
/// * **Zona CD** — casas geocéntricas (cusps c→d) + sus coordenadas.
/// * **Aro D** (`houses_inner`) — externo de los planetas natales.
/// Junto a D, hacia adentro, se posan los planetas natales y sus
/// coordenadas.
/// * **Aro E** (`aspects`) — el más interno. Desde aquí nacen las
/// líneas de aspecto / relaciones / overlays opcionales.
///
/// Los overlays adicionales (transits, midpoints, progression, solar
/// arc, composite) viven INTERIORES al aro E — solo se pintan
/// cuando el módulo correspondiente está activo, así no compiten
/// con el layout base.
#[derive(Clone, Copy)]
struct Radii {
sign_outer: f32, // Aro A
sign_inner: f32, // Aro B
topo_houses_outer: f32, // = Aro B
topocentric: f32, // Zona BC: planetas topo
topo_houses_inner: f32, // Aro C
houses_outer: f32, // = Aro C
houses_inner: f32, // Aro D
bodies: f32, // Zona D-E: planetas natales (junto a D)
pd_direct: f32, // GR (cuando activo): exterior al cinturón natal
pd_converse: f32, // GR (cuando activo): interior al cinturón natal
aspects: f32, // Aro E (invisible, ancla de líneas)
// Overlays adicionales — todos interiores a E.
transits: f32,
midpoints: f32,
progression: f32,
solar_arc: f32,
composite: f32,
}
impl Radii {
fn from_outer(r: f32) -> Self {
Self {
// Aro A — externo zodiaco.
sign_outer: r,
// Aro B — interno zodiaco / externo bloque ascensional.
sign_inner: r * 0.92,
topo_houses_outer: r * 0.92,
// Zona BC: planetas topocéntricos centrados.
topocentric: r * 0.85,
// Aro C — separador ascensional / casas geo.
topo_houses_inner: r * 0.78,
houses_outer: r * 0.78,
// Aro D — externo planetas natales.
houses_inner: r * 0.62,
// Planetas natales justo dentro de D.
bodies: r * 0.57,
// GR dual-ring (cuando se activa): abraza el cinturón
// natal por afuera (`pd_direct`) y por adentro
// (`pd_converse`). Si GR está OFF, ninguno de los dos se
// pinta — no compite con el layout base.
pd_direct: r * 0.545,
pd_converse: r * 0.515,
// Aro E — anclaje invisible de las líneas de aspecto.
aspects: r * 0.49,
// Overlays adicionales — todos INTERIORES al aro E. Se
// pintan solo cuando el módulo correspondiente está
// activo, así no compiten con el layout base.
transits: r * 0.43,
midpoints: r * 0.39,
progression: r * 0.33,
solar_arc: r * 0.27,
composite: r * 0.21,
}
}
/// Radio del ring de cuerpos según el `module_id` del Layer.
fn body_ring(&self, module_id: &str) -> f32 {
match module_id {
"progression" => self.progression,
"solar_arc" => self.solar_arc,
"composite" => self.composite,
"midpoints" => self.midpoints,
"topocentric" => self.topocentric,
"pd_direct" => self.pd_direct,
"pd_converse" => self.pd_converse,
_ => self.bodies,
}
}
/// Resuelve qué radios corresponden a una capa de aspectos según el
/// `module_id`: natal-natal en `aspects`, cross con cada overlay
/// desde `bodies` (extremo natal) al ring del módulo. Los módulos
/// del outer ring (OUTER_RING_MODULES) comparten el slot de
/// tránsito (son mutuamente excluyentes a nivel de Shell).
fn aspect_endpoints(&self, module_id: &str) -> (f32, f32) {
if OUTER_RING_MODULES.contains(&module_id) {
return (self.bodies, self.transits);
}
match module_id {
"progression" => (self.bodies, self.progression),
"solar_arc" => (self.bodies, self.solar_arc),
"composite" => (self.bodies, self.composite),
_ => (self.aspects, self.aspects),
}
}
}
// `Radii` + helpers migraron a `tahuantinsuyu-render` (crate
// agnóstico de surface, compila a WASM y nativo). Re-export para
// que el código del canvas siga refiriendo `Radii` sin cambiar
// imports en cada call site.
use tahuantinsuyu_render::Radii;
#[allow(clippy::too_many_arguments)]
// `hover_focus`: symbol del planeta hovereado en este frame (si lo
@@ -2587,16 +2482,8 @@ fn dist_point_segment(px: f32, py: f32, ax: f32, ay: f32, bx: f32, by: f32) -> f
(dx2 * dx2 + dy2 * dy2).sqrt()
}
fn polar_to_screen(
longitude_deg: f32,
ascendant_deg: f32,
rot_offset_deg: f32,
radius: f32,
) -> (f32, f32) {
let deg = 180.0 - (longitude_deg - ascendant_deg + rot_offset_deg);
let rad = deg * PI / 180.0;
(radius * rad.cos(), radius * rad.sin())
}
// `polar_to_screen` se importa desde `tahuantinsuyu-render`.
use tahuantinsuyu_render::polar_to_screen;
fn centered_glyph(
x: f32,
@@ -2671,291 +2558,15 @@ fn body_disk_base(module_id: &str, kind: LayerKind, view_scale: f32) -> f32 {
base * view_scale
}
/// Reposiciona angularmente un conjunto de longitudes para que pares
/// adyacentes mantengan al menos `min_sep_deg` de separación, **sin
/// que ningún glyph se aleje más de `max_shift_deg` de su posición
/// real**. La acotación es clave para evitar que un cluster denso
/// "empuje" a planetas que estaban lejos.
///
/// Algoritmo: iteramos hasta 60 veces; en cada pasada re-ordenamos
/// los displays para mantener el orden circular, y en cada par
/// adyacente que esté muy cerca empujamos los dos extremos en
/// sentidos opuestos. Tras cada empuje clampeamos `displays[i]` al
/// rango `[raw[i] - max_shift, raw[i] + max_shift]` (circular).
/// Si el cluster es tan denso que el clamp impide alcanzar el
/// `min_sep`, el residual queda alto y el caller encoge los discos.
///
/// Devuelve `(displays, residual)` con `residual ∈ [0, 1]` =
/// fracción de presión no resuelta tras el clamp.
fn spread_angles(angles_deg: &[f32], min_sep_deg: f32, max_shift_deg: f32) -> (Vec<f32>, f32) {
let n = angles_deg.len();
if n <= 1 {
return (angles_deg.to_vec(), 0.0);
}
if (n as f32) * min_sep_deg >= 360.0 {
return (angles_deg.to_vec(), 1.0);
}
let raw: Vec<f32> = angles_deg.iter().map(|a| a.rem_euclid(360.0)).collect();
let mut displays: Vec<f32> = raw.clone();
let mut last_residual = 0.0_f32;
// `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<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'♓");
}
}
// 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