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>
This commit is contained in:
sergio
2026-05-18 19:00:08 +00:00
parent a0f67fd86f
commit 121f19b915
@@ -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;
.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();
}
if d < -180.0 {
d += 360.0;
}
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=30caben.
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);
}
}