tawasuyu

tawasuyu/brahman

Rust 582TOML 222Markdown 83JSON 198635 Definitions

fix(tahuantinsuyu): spread acotado + physics damping + label compartido por par

Tres problemas reportados sobre la pasada anterior:

1. **Planetas pisándose**: el "spread por centroides" dejaba a los
   miembros de cada cluster en sus posiciones REALES, así que pares
   en conjunción cerrada (5° real, disk ≈ 10°) seguían con discos
   solapados. Solución: spread directo sobre TODOS los glyphs, no
   solo sobre centroides.

2. **Empuje propagado a planetas lejanos** (era el motivo original
   de tirar el "spread directo"): ahora controlado con un **cap
   por glyph**: `max_shift_deg`. Ningún display puede alejarse más
   de `disk_angular` grados de su raw — un cluster denso no
   "empuja" a planetas que estaban lejos. El residual sube cuando
   el cap impide alcanzar el min_sep, y los discos se encogen.

3. **Algoritmo greedy oscilaba**: el empuje aplicado par-a-par
   reordenaba los displays a mitad de la pasada y nunca convergía
   (`tight_cluster_gets_spread` terminaba con 6.5° de diff cuando
   se pedían 10°). Reemplazado por **physics-step**: se acumulan
   las fuerzas de todos los pares en una pasada, se aplican con
   `damping = 0.6`, se clampea cada display al rango ±max_shift.
   80 iteraciones convergen siempre.

4. **Labels repetidos en pares cercanos**: el threshold del
   cluster compartido era min(4°, disk_angular*0.5). Para
   discos de 10° angular, eso daba 4° — dos planetas a 5°
   formaban clusters separados, cada uno con su pill diciendo
   casi lo mismo. Subido a `disk_angular * 1.2` → pares a <12°
   comparten label.

Nuevos tests:
- `shift_is_bounded`: con max_shift=2°, ningún glyph se aleja más.
- `distant_planet_unaffected_by_dense_cluster`: cluster denso en
  100° + planeta solo en 200° → el de 200° se queda a <5° de raw.

Total 11 tests verdes (6 spread + 5 coord).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

121f19b9158e4a8b7f0de5da76bd66268aca6376 · by sergio · (5 months ago) · parent a0f67fd86 · browse the tree at this commit

Semantic delta · 9

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

1 files · +104 −121

diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
index e7a4905..334396e 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
@@ -1155,26 +1155,30 @@ fn render_wheel(
                     22.0
                 }) * s;
 
-                // Anti-solapamiento en dos pasos para evitar que el
-                // empuje se propague en cadena (un cluster denso no
-                // debe mover planetas que estaban lejos):
+                // Anti-solapamiento: spread directo sobre TODOS los
+                // glyphs con `min_sep = disk_angular` (tangencial: los
+                // discos se rozan sin pisarse) y `max_shift = disk_angular`
+                // (cap fuerte: ningún planeta puede alejarse más de
+                // un diámetro de disco de su grado real). El cap evita
+                // que un cluster denso "empuje" a planetas lejanos.
                 //
-                //   1) `find_clusters` agrupa los que están dentro de
-                //      `cluster_thresh` (~4° real); dentro del cluster
-                //      los glyphs se mantienen en sus posiciones REALES
-                //      — los discos pueden tocarse, eso refleja la
-                //      verdadera geometría astrológica.
-                //   2) `spread_angles` se aplica SOLO a los centroides
-                //      de los clusters, con threshold = ancho angular
-                //      del disco. El empuje queda contenido a la
-                //      vecindad inmediata; planetas lejos no se mueven.
+                // En paralelo, `find_clusters` con threshold = ancho
+                // del disco × 1.2 detecta pares/tríos cercanos para
+                // que compartan label. Sin esto, dos planetas en
+                // conjunción a 5° real se ven con sus discos
+                // separados a 10° y CADA UNO con su pill — dos labels
+                // que dicen casi lo mismo, exactamente lo que el
+                // usuario reporta como "se repiten en vez de
+                // reutilizarse".
                 let raw_degs: Vec<f32> = layer.glyphs.iter().map(|g| g.deg).collect();
                 let disk_angular_deg =
                     (disk_size_base / (std::f32::consts::TAU * ring)) * 360.0;
-                let cluster_thresh = 4.0_f32.min(disk_angular_deg * 0.5);
+                let max_shift = disk_angular_deg;
+                let (display_degs, residual) =
+                    spread_angles(&raw_degs, disk_angular_deg, max_shift);
+                let cluster_thresh = disk_angular_deg * 1.2;
                 let clusters = find_clusters(&raw_degs, cluster_thresh);
 
-                // Centroide circular de cada cluster (en grados reales).
                 let cluster_centroids: Vec<f32> = clusters
                     .iter()
                     .map(|c| {
@@ -1188,42 +1192,26 @@ fn render_wheel(
                         sy.atan2(sx).to_degrees().rem_euclid(360.0)
                     })
                     .collect();
-
-                // Spread entre clusters: separación mínima ≈ ancho
-                // angular del disco (sin holgura, los discos se rozan).
-                let between_min_sep = disk_angular_deg;
-                let (display_centroids, residual) =
-                    spread_angles(&cluster_centroids, between_min_sep);
-
-                // Shift por cluster = displayed - original (wrap a ±180°).
-                let cluster_shifts: Vec<f32> = clusters
+                let display_centroids: Vec<f32> = clusters
                     .iter()
-                    .enumerate()
-                    .map(|(ci, _)| {
-                        let mut d = display_centroids[ci] - cluster_centroids[ci];
-                        if d > 180.0 {
-                            d -= 360.0;
-                        }
-                        if d < -180.0 {
-                            d += 360.0;
+                    .map(|c| {
+                        let mut sx = 0.0_f32;
+                        let mut sy = 0.0_f32;
+                        for &idx in c {
+                            let a = display_degs[idx].to_radians();
+                            sx += a.cos();
+                            sy += a.sin();
                         }
-                        d
+                        sy.atan2(sx).to_degrees().rem_euclid(360.0)
                     })
                     .collect();
-
-                // Asignar el shift a cada glyph según su cluster.
                 let mut cluster_of = vec![0usize; layer.glyphs.len()];
-                let mut display_degs = vec![0.0_f32; layer.glyphs.len()];
                 for (ci, c) in clusters.iter().enumerate() {
                     for &idx in c {
                         cluster_of[idx] = ci;
-                        display_degs[idx] =
-                            (raw_degs[idx] + cluster_shifts[ci]).rem_euclid(360.0);
                     }
                 }
 
-                // Shrink suave + piso de font para mantener legibilidad
-                // del unicode dentro del disco.
                 let shrink = (1.0 - residual * 0.30).clamp(0.60, 1.0);
                 let disk_size = disk_size_base * shrink;
                 let font_size_eff = (font_size * shrink).max(11.0);
@@ -1320,43 +1308,8 @@ fn render_wheel(
                 let raw_degs: Vec<f32> = layer.glyphs.iter().map(|g| g.deg).collect();
                 let disk_angular =
                     (disk_base / (std::f32::consts::TAU * radii.transits)) * 360.0;
-                let cluster_thresh = 4.0_f32.min(disk_angular * 0.5);
-                let clusters = find_clusters(&raw_degs, cluster_thresh);
-                let centroids: Vec<f32> = clusters
-                    .iter()
-                    .map(|c| {
-                        let mut sx = 0.0_f32;
-                        let mut sy = 0.0_f32;
-                        for &idx in c {
-                            let a = raw_degs[idx].to_radians();
-                            sx += a.cos();
-                            sy += a.sin();
-                        }
-                        sy.atan2(sx).to_degrees().rem_euclid(360.0)
-                    })
-                    .collect();
-                let (disp_centroids, residual) = spread_angles(&centroids, disk_angular);
-                let shifts: Vec<f32> = clusters
-                    .iter()
-                    .enumerate()
-                    .map(|(ci, _)| {
-                        let mut d = disp_centroids[ci] - centroids[ci];
-                        if d > 180.0 {
-                            d -= 360.0;
-                        }
-                        if d < -180.0 {
-                            d += 360.0;
-                        }
-                        d
-                    })
-                    .collect();
-                let mut display_degs = vec![0.0_f32; layer.glyphs.len()];
-                for (ci, c) in clusters.iter().enumerate() {
-                    for &idx in c {
-                        display_degs[idx] =
-                            (raw_degs[idx] + shifts[ci]).rem_euclid(360.0);
-                    }
-                }
+                let (display_degs, residual) =
+                    spread_angles(&raw_degs, disk_angular, disk_angular);
                 let shrink = (1.0 - residual * 0.30).clamp(0.60, 1.0);
                 for (i, g) in layer.glyphs.iter().enumerate() {
                     let display_deg = display_degs[i];
@@ -2613,64 +2566,80 @@ fn planet_glyph(
 }
 
 /// Reposiciona angularmente un conjunto de longitudes para que pares
-/// adyacentes mantengan al menos `min_sep_deg` de separación. Las
-/// posiciones reales NO se tocan — esto solo afecta la geometría
-/// visual del glyph, no su significado astrológico. Devuelve el
-/// vector de display angles en el mismo orden de entrada.
+/// 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: ordenamos por longitud, iteramos N veces empujando
-/// pares adyacentes en sentidos opuestos cuando están muy cerca. El
-/// centro angular del cluster se preserva (los empujes son
-/// simétricos y suman cero). Convergencia típica: 5-15 iteraciones
-/// para clusters realistas; ponemos cap en 40.
+/// 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.
 ///
-/// Si la suma de separaciones mínimas excede 360° (cluster
-/// infactible), el spreading deja el último delta sin convergir —
-/// el caller debe pasarse a modo "compacto" (disco más chico) en
-/// ese caso. Devolvemos también la fracción de presión residual:
-/// `(displays, residual)` donde `residual ∈ [0, 1]`. 0 = todo
-/// converge, 1 = no se pudo abrir nada.
-fn spread_angles(angles_deg: &[f32], min_sep_deg: f32) -> (Vec<f32>, f32) {
+/// 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);
     }
-    // Si el cluster total no cabe en 360°, no hay forma. Reportamos
-    // presión máxima y devolvemos las posiciones originales.
     if (n as f32) * min_sep_deg >= 360.0 {
         return (angles_deg.to_vec(), 1.0);
     }
-    // `displays` mantiene la posición ajustada de cada glyph (indexado
-    // como el input). En cada iteración re-derivamos el orden circular
-    // — los empujes desordenan, así que sin re-sort el "vecino" se
-    // pierde tras la primera pasada y el cluster no converge.
-    let mut displays: Vec<f32> = angles_deg.iter().map(|a| a.rem_euclid(360.0)).collect();
+    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;
 
-    for _ in 0..60 {
+    // Clamp circular: ajusta `display` a estar dentro de
+    // `raw ± max_shift_deg`, midiendo distancia mínima circular.
+    let clamp_to_raw = |display: f32, raw: f32, max_shift: f32| -> f32 {
+        let mut delta = display - raw;
+        if delta > 180.0 {
+            delta -= 360.0;
+        }
+        if delta < -180.0 {
+            delta += 360.0;
+        }
+        let clamped = delta.clamp(-max_shift, max_shift);
+        (raw + clamped).rem_euclid(360.0)
+    };
+
+    // Loop tipo "physics": acumulamos fuerzas sobre TODOS los pares
+    // adyacentes en una pasada, luego aplicamos un step con damping.
+    // Esto evita las oscilaciones del empuje inmediato (que reordenaba
+    // los displays a mitad de la pasada y nunca convergía).
+    let damping: f32 = 0.6;
+    for _ in 0..80 {
         let mut order: Vec<usize> = (0..n).collect();
         order.sort_by(|&a, &b| {
             displays[a]
                 .partial_cmp(&displays[b])
                 .unwrap_or(std::cmp::Ordering::Equal)
         });
+        let mut forces = vec![0.0_f32; n];
         let mut max_residual: f32 = 0.0;
         for k in 0..n {
             let i = order[k];
             let j = order[(k + 1) % n];
-            let cur = displays[i];
-            let nx = displays[j];
-            let diff = (nx - cur).rem_euclid(360.0);
+            let diff = (displays[j] - displays[i]).rem_euclid(360.0);
             if diff < min_sep_deg {
                 let push = (min_sep_deg - diff) / 2.0;
-                displays[i] = (cur - push).rem_euclid(360.0);
-                displays[j] = (nx + push).rem_euclid(360.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;
@@ -2786,19 +2755,18 @@ mod spread_tests {
 
     #[test]
     fn empty_and_single_unchanged() {
-        let (r, residual) = spread_angles(&[], 10.0);
+        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);
+        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() {
-        // Si todos están a >= min_sep, no se tocan.
         let input = vec![0.0, 30.0, 90.0, 200.0];
-        let (out, residual) = spread_angles(&input, 10.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);
@@ -2807,29 +2775,44 @@ mod spread_tests {
 
     #[test]
     fn tight_cluster_gets_spread() {
-        // 3 planetas a 1° de distancia con min_sep=10 — deben separarse.
+        // 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);
+        let (out, residual) = spread_angles(&input, 10.0, 30.0);
         assert!(residual < 0.05, "residual {}", residual);
         assert_min_sep(&out, 10.0);
     }
 
     #[test]
-    fn preserves_input_order_in_result() {
-        // El Vec de salida indexea igual que el de entrada.
-        let input = vec![350.0, 5.0, 100.0];
-        let (out, _) = spread_angles(&input, 8.0);
-        assert_eq!(out.len(), 3);
-        // El primer elemento de salida corresponde al primero de entrada
-        // (350.0 / cercano al wrap).
-        assert!(((out[0] - 350.0 + 360.0).rem_euclid(360.0)).abs() < 30.0);
+    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() {
-        // 40 planetas con min_sep=10 = 400° > 360°: imposible.
         let input: Vec<f32> = (0..40).map(|_| 0.0).collect();
-        let (_out, residual) = spread_angles(&input, 10.0);
+        let (_out, residual) = spread_angles(&input, 10.0, 30.0);
         assert!((residual - 1.0).abs() < 0.01);
     }
 }