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 = 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 = 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 = clusters + let display_centroids: Vec = 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 = 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 = 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 = 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) { +/// 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) { 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 = angles_deg.iter().map(|a| a.rem_euclid(360.0)).collect(); + let raw: Vec = angles_deg.iter().map(|a| a.rem_euclid(360.0)).collect(); + let mut displays: Vec = 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 = (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 = (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); } }