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>
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(¢roids, 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);
}
}