From fdd169bc350738f838d491efea87865aeeeb0581 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Mon, 4 May 2026 01:27:11 +0200 Subject: [PATCH] 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 --- issues/0037-directional-orphan-placement.md | 95 ++++++++++++++ main.cpp | 133 ++++++++++++++------ 2 files changed, 187 insertions(+), 41 deletions(-) create mode 100644 issues/0037-directional-orphan-placement.md diff --git a/issues/0037-directional-orphan-placement.md b/issues/0037-directional-orphan-placement.md new file mode 100644 index 0000000..3af20e9 --- /dev/null +++ b/issues/0037-directional-orphan-placement.md @@ -0,0 +1,95 @@ +--- +id: 0037 +title: Placement direccional de orphans — abanico de 45 grados en una sola direccion +status: done +priority: high +created: 2026-05-04 +completed: 2026-05-03 +--- + +## Contexto + +Hoy `place_orphans_near_neighbors` (en `main.cpp`) reparte los hijos del +mismo anchor en un anillo de 360 grados alrededor del padre. Resultado: +con muchos hijos, el viewport se llena en todas las direcciones, +pisando nodos preexistentes y dificultando la lectura del grafo. + +Issue 0035 ya cluster los hijos del mismo anchor (no los desperdiga +por anillos crecientes), pero siguen ocupando 360 grados. + +## Objetivo + +Que los nuevos hijos creados por un enricher (orphans con un anchor +detectado por `layout_first_placed_neighbor`) se desplieguen en un +**abanico de 45 grados** (no 360) en UNA sola direccion saliendo del +anchor. Asi el grafo "crece" en una direccion clara por cada +ejecucion de enricher, sin pisar lo existente. + +## Diseño + +### 1. Direccion del abanico — outward por defecto + +Para cada anchor con orphans: +- Calcular el centroide del resto de nodos placed del grafo (excluyendo + los orphans propios y el anchor). +- La direccion outward es el vector `anchor - centroide`, normalizado. +- Si solo hay 1 nodo en el grafo (el propio anchor) o el centroide + coincide con el anchor: usar direccion `(1, 0)` (derecha). + +### 2. Reparto en abanico de 45 grados + +`arc_span = π / 4` (45 grados, configurable como constante). + +Para los N orphans del anchor: +- Si `N == 1`: lo plantamos exactamente en la direccion outward al + radio base (~80 px). +- Si `N <= 8` (capacidad del primer anillo en 45 grados con min_dist): + se reparten equiespaciados en el arco a un radio fijo. +- Si `N > 8`: se llenan anillos sucesivos del mismo arco con radios + crecientes (`80, 140, 200, 280, 400`) y misma capacidad por anillo. + +Cada slot dentro del arco tiene angulo: +``` +angle = out_angle - arc_span/2 + slot * (arc_span / (slot_count - 1)) +``` +(ajusta para cuando `slot_count == 1` para no dividir entre cero). + +### 3. Colision con nodos preexistentes + +Despues de calcular `(px, py)` para cada hijo, si colisiona con un +nodo no-orphan, incrementar el indice de anillo (mismo angulo, +radio mayor) hasta encontrar hueco o agotar anillos. En el ultimo +recurso aceptar solape — coherente con la logica actual. + +### 4. Fallback sin anchor + +Los orphans sin anchor (no tienen vecino placed) siguen el camino +existente: ring placement alrededor de la camara o parking lot. +NO se les aplica el abanico. + +## Acceptance criteria + +- Lanzar `split_words` (≥50 palabras unicas) sobre un nodo `text`: + los 10 sueltos del preview + 1 Group caen en un abanico de 45 + grados saliendo del nodo `text`, en la direccion alejada del + resto del grafo. +- Lanzar el mismo enricher otra vez sobre OTRO nodo en otra + posicion: el abanico nuevo apunta hacia su propia direccion + outward. +- Si el grafo solo tiene 1 nodo (sin existing centroide), el + abanico sale a la derecha por default. +- Tests pytest siguen verdes (no requiere tests nuevos — el cambio + es algoritmico y se valida visualmente; opcional anyadir un test + C++ standalone que verifique angulos contra una fixture). + +## TBD + +Branch `issue/0037-directional-orphan-placement`, merge `--no-ff` a +master. + +## Out of scope + +- Animar la transicion del placement (poof-in suave). Fase 2. +- Layout interno del Group cuando se expande — sigue siendo todos + los hijos del Group ocultos en colapsado. +- Grouping logic (eso es 0035, ya cerrado fase 1). diff --git a/main.cpp b/main.cpp index 1cf092b..914c34c 100644 --- a/main.cpp +++ b/main.cpp @@ -47,6 +47,7 @@ #include #include #include +#include #include #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& 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 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; }