feat(0037): placement direccional 45 grados de orphans (away from centroide)

Antes los hijos del mismo anchor se distribuian en un anillo de 360
grados alrededor del padre. Cuando un enricher producia 10+ hijos,
se llenaban todas las direcciones y se pisaban nodos preexistentes.

Ahora los hijos se reparten en un abanico de 45 grados (pi/4) saliendo
del anchor en la direccion outward (vector anchor - centroide del resto
del grafo). Si solo hay 1 nodo placed o coincide con el anchor, default
a la derecha (0 rad). Capacidad por anillo restringida al arco
(arc_span * r / min_dist), con fallback de subida de radio en mismo
angulo si el slot ideal colisiona con un nodo no-orphan.

Solo afecta la pasada 2 (orphans con anchor). Pasadas 1 y 3 intactas.
build limpio, 102 pytest passed (WSL) + 91 passed/11 skipped (Windows).

Refs: issues/0037-directional-orphan-placement.md
This commit is contained in:
2026-05-04 01:27:11 +02:00
parent 502ce80b9f
commit fdd169bc35
2 changed files with 187 additions and 41 deletions
+92 -41
View File
@@ -47,6 +47,7 @@
#include <filesystem>
#include <fstream>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#ifndef _WIN32
@@ -343,12 +344,14 @@ static void place_orphans_near_neighbors(GraphData& g, float min_dist,
}
// ----- Pase 2: place clusters (orphans con anchor) -----
// Para cada anchor con sus hijos, los repartimos en un anillo
// alrededor del padre. Si hay mas hijos de los que caben en el
// anillo base, abrimos anillos adicionales. Cada hijo sigue
// pasando find_collision_free_slot como fallback si el slot ideal
// estaba ocupado por otro nodo del grafo.
const float two_pi = 6.28318530718f;
// Issue 0037: los hijos del mismo anchor se reparten en un ABANICO
// de 45 grados saliendo del anchor en la direccion outward (alejada
// del centroide del resto del grafo). Antes era 360 grados — con
// muchos hijos llenaba TODAS las direcciones y pisaba nodos
// preexistentes. Ahora el grafo crece direccionalmente por cada
// ejecucion de enricher.
const float pi = 3.14159265359f;
const float arc_span = pi / 4.0f; // 45 grados — issue 0037
for (auto& kv : orphans_by_anchor) {
int parent = kv.first;
std::vector<int>& kids = kv.second;
@@ -361,44 +364,92 @@ static void place_orphans_near_neighbors(GraphData& g, float min_dist,
});
float cx = g.nodes[parent].x;
float cy = g.nodes[parent].y;
// Capacidad por anillo: circunferencia / min_dist.
// Para min_dist=60, ring r=80 -> ~8 slots; r=140 -> ~14.
for (size_t k = 0; k < kids.size(); ++k) {
// Anillo y slot dentro del anillo en funcion del indice.
int ri = 0; size_t accum = 0; size_t cap = 0;
for (; ri < n_neighbor_radii; ++ri) {
float r_here = neighbor_radii[ri];
cap = (size_t)std::max(6.0f, two_pi * r_here / min_dist);
if (k < accum + cap) break;
accum += cap;
// --- Outward direction: anchor - centroide(rest of graph) ---
// Excluir el propio anchor y los kids (que aun estan en (0,0)).
// Si solo hay 1 nodo placed o el centroide coincide con el
// anchor, default a la derecha (out_angle = 0).
float out_angle = 0.0f;
{
// Marcar kids para excluirlos rapido.
std::unordered_set<int> kid_set(kids.begin(), kids.end());
double sum_x = 0.0, sum_y = 0.0;
int n_other = 0;
for (int j = 0; j < g.node_count; ++j) {
if (j == parent) continue;
if (kid_set.count(j)) continue;
const GraphNode& nj = g.nodes[j];
if (nj.x == 0.0f && nj.y == 0.0f) continue;
sum_x += nj.x; sum_y += nj.y; ++n_other;
}
if (ri >= n_neighbor_radii) ri = n_neighbor_radii - 1;
float r_use = neighbor_radii[ri];
cap = (size_t)std::max(6.0f, two_pi * r_use / min_dist);
size_t slot = k - accum;
// Jitter pequeno por user_data para que rondas distintas no
// queden alineadas si comparten anchor.
uint64_t seed = g.nodes[kids[k]].user_data;
float jitter = ((float)((seed >> 16) & 0xFF) / 255.0f) * (two_pi / cap);
float angle = jitter + (float)slot * (two_pi / cap);
float px = cx + r_use * std::cos(angle);
float py = cy + r_use * std::sin(angle);
// Si el slot ideal colisiona con un nodo ajeno al cluster,
// delegamos en find_collision_free_slot que probara mas
// angulos en radios crecientes.
GraphNode& kid = g.nodes[kids[k]];
if (layout_no_collision(g, kids[k], px, py, min_dist)) {
kid.x = px; kid.y = py;
} else {
float ox, oy;
if (find_collision_free_slot(
g, kids[k], cx, cy, min_dist, seed,
neighbor_radii, n_neighbor_radii, &ox, &oy)) {
kid.x = ox; kid.y = oy;
} else {
kid.x = px; kid.y = py; // ultimo recurso: solape
if (n_other > 0) {
float cent_x = (float)(sum_x / n_other);
float cent_y = (float)(sum_y / n_other);
float dx = cx - cent_x;
float dy = cy - cent_y;
if (dx != 0.0f || dy != 0.0f) {
out_angle = std::atan2(dy, dx);
}
}
}
// --- Distribucion en abanico: capacidad por anillo restringida ---
// cap_r = max(2, arc_span * r / min_dist). Para min_dist=60,
// r=80 -> cap=2; r=140 -> 2; r=200 -> 3; etc. Ajustamos a un
// minimo de 2 para que kids unitarios o duos no degeneren.
size_t n = kids.size();
size_t accum = 0;
int ri = 0;
size_t cap_r = (size_t)std::max(2.0f, arc_span * neighbor_radii[ri] / min_dist);
for (size_t k = 0; k < n; ++k) {
// Avanzar de anillo cuando el actual se ha llenado.
while (k >= accum + cap_r && ri < n_neighbor_radii - 1) {
accum += cap_r;
++ri;
cap_r = (size_t)std::max(2.0f, arc_span * neighbor_radii[ri] / min_dist);
}
if (k >= accum + cap_r) {
// Ultimo anillo desbordado: aceptamos solape angular.
cap_r = std::max((size_t)2, n - accum);
}
size_t slot = k - accum;
size_t slot_count = std::min(cap_r, n - accum);
float angle;
if (slot_count <= 1) {
angle = out_angle;
} else {
angle = out_angle - arc_span * 0.5f
+ (float)slot * (arc_span / (float)(slot_count - 1));
}
// Jitter pequeno por user_data — pequeno para no romper el arco.
uint64_t seed = g.nodes[kids[k]].user_data;
float jit_max = (arc_span / (float)cap_r) * 0.3f;
float jitter = (((float)((seed >> 16) & 0xFF) / 255.0f) - 0.5f) * jit_max;
angle += jitter;
float r_use = neighbor_radii[ri];
float px = cx + r_use * std::cos(angle);
float py = cy + r_use * std::sin(angle);
// Si colisiona con un nodo no-orphan (ya placed), subimos
// de radio en MISMO angulo hasta encontrar hueco. Si se
// agotan los radios, aceptamos solape en el actual.
GraphNode& kid = g.nodes[kids[k]];
if (!layout_no_collision(g, kids[k], px, py, min_dist)) {
bool found = false;
for (int ri2 = ri + 1; ri2 < n_neighbor_radii; ++ri2) {
float r2 = neighbor_radii[ri2];
float px2 = cx + r2 * std::cos(angle);
float py2 = cy + r2 * std::sin(angle);
if (layout_no_collision(g, kids[k], px2, py2, min_dist)) {
px = px2; py = py2;
found = true;
break;
}
}
(void)found; // ultimo recurso: solape coherente con la logica previa
}
kid.x = px; kid.y = py;
kid.vx = kid.vy = 0.0f;
++placed_neighbor;
}