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:
@@ -1155,26 +1155,30 @@ fn render_wheel(
|
|||||||
22.0
|
22.0
|
||||||
}) * s;
|
}) * s;
|
||||||
|
|
||||||
// Anti-solapamiento en dos pasos para evitar que el
|
// Anti-solapamiento: spread directo sobre TODOS los
|
||||||
// empuje se propague en cadena (un cluster denso no
|
// glyphs con `min_sep = disk_angular` (tangencial: los
|
||||||
// debe mover planetas que estaban lejos):
|
// 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
|
// En paralelo, `find_clusters` con threshold = ancho
|
||||||
// `cluster_thresh` (~4° real); dentro del cluster
|
// del disco × 1.2 detecta pares/tríos cercanos para
|
||||||
// los glyphs se mantienen en sus posiciones REALES
|
// que compartan label. Sin esto, dos planetas en
|
||||||
// — los discos pueden tocarse, eso refleja la
|
// conjunción a 5° real se ven con sus discos
|
||||||
// verdadera geometría astrológica.
|
// separados a 10° y CADA UNO con su pill — dos labels
|
||||||
// 2) `spread_angles` se aplica SOLO a los centroides
|
// que dicen casi lo mismo, exactamente lo que el
|
||||||
// de los clusters, con threshold = ancho angular
|
// usuario reporta como "se repiten en vez de
|
||||||
// del disco. El empuje queda contenido a la
|
// reutilizarse".
|
||||||
// vecindad inmediata; planetas lejos no se mueven.
|
|
||||||
let raw_degs: Vec<f32> = layer.glyphs.iter().map(|g| g.deg).collect();
|
let raw_degs: Vec<f32> = layer.glyphs.iter().map(|g| g.deg).collect();
|
||||||
let disk_angular_deg =
|
let disk_angular_deg =
|
||||||
(disk_size_base / (std::f32::consts::TAU * ring)) * 360.0;
|
(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);
|
let clusters = find_clusters(&raw_degs, cluster_thresh);
|
||||||
|
|
||||||
// Centroide circular de cada cluster (en grados reales).
|
|
||||||
let cluster_centroids: Vec<f32> = clusters
|
let cluster_centroids: Vec<f32> = clusters
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| {
|
.map(|c| {
|
||||||
@@ -1188,42 +1192,26 @@ fn render_wheel(
|
|||||||
sy.atan2(sx).to_degrees().rem_euclid(360.0)
|
sy.atan2(sx).to_degrees().rem_euclid(360.0)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
let display_centroids: Vec<f32> = clusters
|
||||||
// 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
|
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.map(|c| {
|
||||||
.map(|(ci, _)| {
|
let mut sx = 0.0_f32;
|
||||||
let mut d = display_centroids[ci] - cluster_centroids[ci];
|
let mut sy = 0.0_f32;
|
||||||
if d > 180.0 {
|
for &idx in c {
|
||||||
d -= 360.0;
|
let a = display_degs[idx].to_radians();
|
||||||
|
sx += a.cos();
|
||||||
|
sy += a.sin();
|
||||||
}
|
}
|
||||||
if d < -180.0 {
|
sy.atan2(sx).to_degrees().rem_euclid(360.0)
|
||||||
d += 360.0;
|
|
||||||
}
|
|
||||||
d
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Asignar el shift a cada glyph según su cluster.
|
|
||||||
let mut cluster_of = vec![0usize; layer.glyphs.len()];
|
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 (ci, c) in clusters.iter().enumerate() {
|
||||||
for &idx in c {
|
for &idx in c {
|
||||||
cluster_of[idx] = ci;
|
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 shrink = (1.0 - residual * 0.30).clamp(0.60, 1.0);
|
||||||
let disk_size = disk_size_base * shrink;
|
let disk_size = disk_size_base * shrink;
|
||||||
let font_size_eff = (font_size * shrink).max(11.0);
|
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 raw_degs: Vec<f32> = layer.glyphs.iter().map(|g| g.deg).collect();
|
||||||
let disk_angular =
|
let disk_angular =
|
||||||
(disk_base / (std::f32::consts::TAU * radii.transits)) * 360.0;
|
(disk_base / (std::f32::consts::TAU * radii.transits)) * 360.0;
|
||||||
let cluster_thresh = 4.0_f32.min(disk_angular * 0.5);
|
let (display_degs, residual) =
|
||||||
let clusters = find_clusters(&raw_degs, cluster_thresh);
|
spread_angles(&raw_degs, disk_angular, disk_angular);
|
||||||
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 shrink = (1.0 - residual * 0.30).clamp(0.60, 1.0);
|
let shrink = (1.0 - residual * 0.30).clamp(0.60, 1.0);
|
||||||
for (i, g) in layer.glyphs.iter().enumerate() {
|
for (i, g) in layer.glyphs.iter().enumerate() {
|
||||||
let display_deg = display_degs[i];
|
let display_deg = display_degs[i];
|
||||||
@@ -2613,64 +2566,80 @@ fn planet_glyph(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Reposiciona angularmente un conjunto de longitudes para que pares
|
/// Reposiciona angularmente un conjunto de longitudes para que pares
|
||||||
/// adyacentes mantengan al menos `min_sep_deg` de separación. Las
|
/// adyacentes mantengan al menos `min_sep_deg` de separación, **sin
|
||||||
/// posiciones reales NO se tocan — esto solo afecta la geometría
|
/// que ningún glyph se aleje más de `max_shift_deg` de su posición
|
||||||
/// visual del glyph, no su significado astrológico. Devuelve el
|
/// real**. La acotación es clave para evitar que un cluster denso
|
||||||
/// vector de display angles en el mismo orden de entrada.
|
/// "empuje" a planetas que estaban lejos.
|
||||||
///
|
///
|
||||||
/// Algoritmo: ordenamos por longitud, iteramos N veces empujando
|
/// Algoritmo: iteramos hasta 60 veces; en cada pasada re-ordenamos
|
||||||
/// pares adyacentes en sentidos opuestos cuando están muy cerca. El
|
/// los displays para mantener el orden circular, y en cada par
|
||||||
/// centro angular del cluster se preserva (los empujes son
|
/// adyacente que esté muy cerca empujamos los dos extremos en
|
||||||
/// simétricos y suman cero). Convergencia típica: 5-15 iteraciones
|
/// sentidos opuestos. Tras cada empuje clampeamos `displays[i]` al
|
||||||
/// para clusters realistas; ponemos cap en 40.
|
/// 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
|
/// Devuelve `(displays, residual)` con `residual ∈ [0, 1]` =
|
||||||
/// infactible), el spreading deja el último delta sin convergir —
|
/// fracción de presión no resuelta tras el clamp.
|
||||||
/// el caller debe pasarse a modo "compacto" (disco más chico) en
|
fn spread_angles(angles_deg: &[f32], min_sep_deg: f32, max_shift_deg: f32) -> (Vec<f32>, f32) {
|
||||||
/// 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) {
|
|
||||||
let n = angles_deg.len();
|
let n = angles_deg.len();
|
||||||
if n <= 1 {
|
if n <= 1 {
|
||||||
return (angles_deg.to_vec(), 0.0);
|
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 {
|
if (n as f32) * min_sep_deg >= 360.0 {
|
||||||
return (angles_deg.to_vec(), 1.0);
|
return (angles_deg.to_vec(), 1.0);
|
||||||
}
|
}
|
||||||
// `displays` mantiene la posición ajustada de cada glyph (indexado
|
let raw: Vec<f32> = angles_deg.iter().map(|a| a.rem_euclid(360.0)).collect();
|
||||||
// como el input). En cada iteración re-derivamos el orden circular
|
let mut displays: Vec<f32> = raw.clone();
|
||||||
// — 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 mut last_residual = 0.0_f32;
|
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();
|
let mut order: Vec<usize> = (0..n).collect();
|
||||||
order.sort_by(|&a, &b| {
|
order.sort_by(|&a, &b| {
|
||||||
displays[a]
|
displays[a]
|
||||||
.partial_cmp(&displays[b])
|
.partial_cmp(&displays[b])
|
||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
});
|
});
|
||||||
|
let mut forces = vec![0.0_f32; n];
|
||||||
let mut max_residual: f32 = 0.0;
|
let mut max_residual: f32 = 0.0;
|
||||||
for k in 0..n {
|
for k in 0..n {
|
||||||
let i = order[k];
|
let i = order[k];
|
||||||
let j = order[(k + 1) % n];
|
let j = order[(k + 1) % n];
|
||||||
let cur = displays[i];
|
let diff = (displays[j] - displays[i]).rem_euclid(360.0);
|
||||||
let nx = displays[j];
|
|
||||||
let diff = (nx - cur).rem_euclid(360.0);
|
|
||||||
if diff < min_sep_deg {
|
if diff < min_sep_deg {
|
||||||
let push = (min_sep_deg - diff) / 2.0;
|
let push = (min_sep_deg - diff) / 2.0;
|
||||||
displays[i] = (cur - push).rem_euclid(360.0);
|
forces[i] -= push;
|
||||||
displays[j] = (nx + push).rem_euclid(360.0);
|
forces[j] += push;
|
||||||
let r = (min_sep_deg - diff) / min_sep_deg;
|
let r = (min_sep_deg - diff) / min_sep_deg;
|
||||||
if r > max_residual {
|
if r > max_residual {
|
||||||
max_residual = r;
|
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;
|
last_residual = max_residual;
|
||||||
if max_residual < 0.001 {
|
if max_residual < 0.001 {
|
||||||
break;
|
break;
|
||||||
@@ -2786,19 +2755,18 @@ mod spread_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_and_single_unchanged() {
|
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!(r.is_empty());
|
||||||
assert_eq!(residual, 0.0);
|
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!(r, vec![42.0]);
|
||||||
assert_eq!(residual, 0.0);
|
assert_eq!(residual, 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn spaced_input_left_alone() {
|
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 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);
|
assert!(residual < 0.001);
|
||||||
for (a, b) in input.iter().zip(out.iter()) {
|
for (a, b) in input.iter().zip(out.iter()) {
|
||||||
assert!((a - b).abs() < 1e-3, "{} vs {}", a, b);
|
assert!((a - b).abs() < 1e-3, "{} vs {}", a, b);
|
||||||
@@ -2807,29 +2775,44 @@ mod spread_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tight_cluster_gets_spread() {
|
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 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!(residual < 0.05, "residual {}", residual);
|
||||||
assert_min_sep(&out, 10.0);
|
assert_min_sep(&out, 10.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn preserves_input_order_in_result() {
|
fn shift_is_bounded() {
|
||||||
// El Vec de salida indexea igual que el de entrada.
|
// Si max_shift=2°, los planetas no pueden alejarse más allá.
|
||||||
let input = vec![350.0, 5.0, 100.0];
|
let input = vec![100.0, 101.0];
|
||||||
let (out, _) = spread_angles(&input, 8.0);
|
let (out, _) = spread_angles(&input, 10.0, 2.0);
|
||||||
assert_eq!(out.len(), 3);
|
for (raw, disp) in input.iter().zip(out.iter()) {
|
||||||
// El primer elemento de salida corresponde al primero de entrada
|
let mut delta = (disp - raw).abs();
|
||||||
// (350.0 / cercano al wrap).
|
if delta > 180.0 {
|
||||||
assert!(((out[0] - 350.0 + 360.0).rem_euclid(360.0)).abs() < 30.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]
|
#[test]
|
||||||
fn unfeasible_cluster_reports_max_residual() {
|
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 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);
|
assert!((residual - 1.0).abs() < 0.01);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user