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
@@ -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).
+92 -41
View File
@@ -47,6 +47,7 @@
#include <filesystem> #include <filesystem>
#include <fstream> #include <fstream>
#include <unordered_map> #include <unordered_map>
#include <unordered_set>
#include <vector> #include <vector>
#ifndef _WIN32 #ifndef _WIN32
@@ -343,12 +344,14 @@ static void place_orphans_near_neighbors(GraphData& g, float min_dist,
} }
// ----- Pase 2: place clusters (orphans con anchor) ----- // ----- Pase 2: place clusters (orphans con anchor) -----
// Para cada anchor con sus hijos, los repartimos en un anillo // Issue 0037: los hijos del mismo anchor se reparten en un ABANICO
// alrededor del padre. Si hay mas hijos de los que caben en el // de 45 grados saliendo del anchor en la direccion outward (alejada
// anillo base, abrimos anillos adicionales. Cada hijo sigue // del centroide del resto del grafo). Antes era 360 grados — con
// pasando find_collision_free_slot como fallback si el slot ideal // muchos hijos llenaba TODAS las direcciones y pisaba nodos
// estaba ocupado por otro nodo del grafo. // preexistentes. Ahora el grafo crece direccionalmente por cada
const float two_pi = 6.28318530718f; // 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) { for (auto& kv : orphans_by_anchor) {
int parent = kv.first; int parent = kv.first;
std::vector<int>& kids = kv.second; 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 cx = g.nodes[parent].x;
float cy = g.nodes[parent].y; float cy = g.nodes[parent].y;
// Capacidad por anillo: circunferencia / min_dist.
// Para min_dist=60, ring r=80 -> ~8 slots; r=140 -> ~14. // --- Outward direction: anchor - centroide(rest of graph) ---
for (size_t k = 0; k < kids.size(); ++k) { // Excluir el propio anchor y los kids (que aun estan en (0,0)).
// Anillo y slot dentro del anillo en funcion del indice. // Si solo hay 1 nodo placed o el centroide coincide con el
int ri = 0; size_t accum = 0; size_t cap = 0; // anchor, default a la derecha (out_angle = 0).
for (; ri < n_neighbor_radii; ++ri) { float out_angle = 0.0f;
float r_here = neighbor_radii[ri]; {
cap = (size_t)std::max(6.0f, two_pi * r_here / min_dist); // Marcar kids para excluirlos rapido.
if (k < accum + cap) break; std::unordered_set<int> kid_set(kids.begin(), kids.end());
accum += cap; 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; if (n_other > 0) {
float r_use = neighbor_radii[ri]; float cent_x = (float)(sum_x / n_other);
cap = (size_t)std::max(6.0f, two_pi * r_use / min_dist); float cent_y = (float)(sum_y / n_other);
size_t slot = k - accum; float dx = cx - cent_x;
// Jitter pequeno por user_data para que rondas distintas no float dy = cy - cent_y;
// queden alineadas si comparten anchor. if (dx != 0.0f || dy != 0.0f) {
uint64_t seed = g.nodes[kids[k]].user_data; out_angle = std::atan2(dy, dx);
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
} }
} }
}
// --- 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; kid.vx = kid.vy = 0.0f;
++placed_neighbor; ++placed_neighbor;
} }