netx/netx_api/mcp_remote/topology/layout_ops/sugiyama.py
oliver 52679a7cfc MCP: serve tools from the API process over Streamable HTTP at /mcp
- netx_api/mcp_remote: ops / topology / bizMonitor tools, Bearer auth,
  per-tool RBAC filtering, ?groups= filter, readOnlyHint and group meta.
- Mount the official MCP Streamable HTTP app at /mcp (stateless);
  AuthAuditMiddleware is pure ASGI and skips /mcp.
- Let uvicorn chunk MCP responses; some Windows LSPs rewrite
  Content-Length replies on */mcp* paths into broken chunked framing.
- NETX_MCP_ALLOWED_HOSTS opts into DNS-rebinding checks (off by default
  so LAN and reverse-proxy hosts work; Bearer is the gate).
- Remove the three stdio Python MCP packages; tests moved to /mcp,
  including real-TCP framing regressions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:52:08 +08:00

2157 lines
72 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""UME-inspired metro layout: 2D AN anchors + rectangular petals.
Learned from human SMD canvas:
- ANs sit in a 2D constellation (not one top rail)
- Access grows around each AN (above+below), on axis-aligned rectangle edges
- Dangling feeders (非环纯链路): leaf → … → first ring/junction attach; no through-cross
- Two-portal minimal rings are atomic hollow units; nested rings use trapezoid bands
(shortest/narrowest inner → longer/wider outer)
- Islands stay roughly square (aspect ~1.2–1.7)
"""
from __future__ import annotations
import math
from collections import deque
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_api.mcp_remote.topology.layout_ops.graph_util import (
bbox,
chain_order,
connected_components,
order_ans,
)
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
def _nearest_an(
n: str, ans: list[str], adj: dict[str, set[str]], names: dict[str, str]
) -> str:
if not ans:
return ""
an_set = set(ans)
q: deque[tuple[str, int]] = deque([(n, 0)])
seen = {n}
best_d: int | None = None
cands: list[str] = []
while q:
u, d = q.popleft()
if best_d is not None and d > best_d:
break
if u in an_set:
best_d = d
cands.append(u)
continue
if d >= 24:
continue
for v in adj.get(u, ()):
if v not in seen:
seen.add(v)
q.append((v, d + 1))
if not cands:
return min(ans, key=lambda a: names.get(a, a))
return min(cands, key=lambda a: (-len(adj.get(a, ())), names.get(a, a)))
def _hop_from_roots(
nodes: list[str], roots: list[str], adj: dict[str, set[str]]
) -> dict[str, int]:
node_set = set(nodes)
dist: dict[str, int] = {}
q: deque[str] = deque()
for r in roots:
for nb in adj.get(r, ()):
if nb in node_set and nb not in dist:
dist[nb] = 1
q.append(nb)
while q:
u = q.popleft()
for v in adj.get(u, ()):
if v in node_set and v not in dist:
dist[v] = dist[u] + 1
q.append(v)
for n in nodes:
if n not in dist:
dist[n] = (max(dist.values()) if dist else 0) + 1
return dist
def _point_on_rect(t: float, x0: float, y0: float, w: float, h: float) -> tuple[float, float]:
perim = 2.0 * (w + h)
if perim <= 1e-9:
return x0, y0
t = t % perim
if t <= w:
return x0 + t, y0
t -= w
if t <= h:
return x0 + w, y0 + t
t -= h
if t <= w:
return x0 + w - t, y0 + h
t -= w
return x0, y0 + h - t
def _squarish_rect_dims(n: int, params: LayoutParams) -> tuple[float, float]:
"""Rectangle sized for n edge nodes; keep aspect near UME (~1–1.6)."""
pitch, side = params.pitch, params.side
n = max(n, 4)
perim = n * pitch
# Solve 2(w+h)=perim with w/h ≈ 1.35
ratio = 1.35
h = perim / (2.0 * (ratio + 1.0))
w = ratio * h
h = max(side * 1.2, h)
w = max(pitch * 2.5, w)
return w, h
def _place_on_rect_edge(
order: list[str], x0: float, y0: float, w: float, h: float
) -> dict[str, tuple[float, float]]:
n = len(order)
if n == 0:
return {}
perim = 2.0 * (w + h)
offset = w / 2.0
out: dict[str, tuple[float, float]] = {}
for i, nid in enumerate(order):
t = offset + (i / n) * perim
out[nid] = _point_on_rect(t, x0, y0, w, h)
return out
def _cycle_order(
ring_nodes: list[str], adj: dict[str, set[str]], names: dict[str, str]
) -> list[str]:
s = set(ring_nodes)
if len(s) < 3:
return chain_order(ring_nodes, adj, names)
sub = {n: [v for v in adj.get(n, ()) if v in s] for n in s}
deg2 = [n for n in ring_nodes if len(sub.get(n, ())) == 2]
start = min(deg2 or ring_nodes, key=lambda n: names.get(n, n))
order = [start]
prev = None
cur = start
for _ in range(len(s) - 1):
nxts = [v for v in sub.get(cur, ()) if v != prev]
fresh = [v for v in nxts if v not in order]
if not fresh:
break
pick = min(fresh, key=lambda n: names.get(n, n))
order.append(pick)
prev, cur = cur, pick
if len(order) < len(s):
return chain_order(ring_nodes, adj, names)
return order
def _find_access_rings(
access: list[str],
adj: dict[str, set[str]],
names: dict[str, str],
*,
min_len: int = 4,
max_len: int = 24,
) -> list[list[str]]:
"""Prefer small/medium rings (UME rings ~10 nodes), not mega cycles."""
s = {n for n in access if sum(1 for v in adj.get(n, ()) if v in access) >= 2}
if len(s) < min_len:
return []
sub = {
n: sorted([v for v in adj.get(n, ()) if v in s], key=lambda x: names.get(x, x))
for n in s
}
seen_sets: set[frozenset[str]] = set()
rings: list[list[str]] = []
budget = [8000]
def dfs(start: str, cur: str, path: list[str], parent: str | None) -> None:
if budget[0] <= 0 or len(path) > max_len:
return
for v in sub.get(cur, ()):
budget[0] -= 1
if budget[0] <= 0:
return
if v == parent:
continue
if v == start and len(path) >= min_len:
key = frozenset(path)
if key not in seen_sets:
seen_sets.add(key)
rings.append(list(path))
continue
if v in path or names.get(v, v) < names.get(start, start):
continue
path.append(v)
dfs(start, v, path, cur)
path.pop()
for start in sorted(s, key=lambda n: names.get(n, n)):
if budget[0] <= 0:
break
dfs(start, start, [start], None)
# Prefer UME-like sizes (6–16) first, then longer
rings.sort(
key=lambda r: (
0 if 6 <= len(r) <= 16 else 1,
-len(r),
names.get(r[0], r[0]),
)
)
kept: list[list[str]] = []
used: set[str] = set()
for r in rings:
if any(n in used for n in r):
continue
kept.append(r)
used.update(r)
if len(kept) >= 12:
break
return kept
def _assign_layers_column(
nodes: list[str],
roots: list[str],
layers: dict[str, str],
adj: dict[str, set[str]],
names: dict[str, str],
) -> dict[str, int]:
cores = sorted(
[n for n in nodes if layers.get(n) == "core"], key=lambda n: names[n]
)
ans = [n for n in roots if n in nodes]
ens = [n for n in nodes if layers.get(n) == "access"]
others = sorted(
[n for n in nodes if layers.get(n) == "other"], key=lambda n: names[n]
)
layer_of: dict[str, int] = {}
base = 0
for c in cores:
layer_of[c] = 0
if cores:
base = 1
for a in ans:
layer_of[a] = base
access_base = base + (1 if ans else 0)
hops = _hop_from_roots(ens, ans, adj) if ens and ans else {e: 1 for e in ens}
hop_cap = min(max(hops.values(), default=1), 6)
for e in ens:
h = min(max(hops.get(e, 1), 1), hop_cap)
layer_of[e] = access_base + (h - 1)
other_layer = (max(layer_of.values()) if layer_of else 0) + 1
for o in others:
layer_of[o] = other_layer
for n in nodes:
if n not in layer_of:
layer_of[n] = other_layer
return layer_of
def _count_layer_crossings(
order_a: list[str], order_b: list[str], adj: dict[str, set[str]]
) -> int:
pos_a = {n: i for i, n in enumerate(order_a)}
pos_b = {n: i for i, n in enumerate(order_b)}
edges: list[tuple[int, int]] = []
for u in order_a:
for v in adj.get(u, ()):
if v in pos_b:
edges.append((pos_a[u], pos_b[v]))
edges.sort()
cross = 0
for i in range(len(edges)):
ai, bi = edges[i]
for j in range(i + 1, len(edges)):
aj, bj = edges[j]
if aj == ai:
continue
if (ai - aj) * (bi - bj) < 0:
cross += 1
return cross
def _minimize_crossings(
layer_lists: list[list[str]],
adj: dict[str, set[str]],
names: dict[str, str],
*,
sweeps: int = 10,
) -> list[list[str]]:
def total(layers: list[list[str]]) -> int:
t = 0
for i in range(len(layers) - 1):
if layers[i] and layers[i + 1]:
t += _count_layer_crossings(layers[i], layers[i + 1], adj)
return t
best = [list(L) for L in layer_lists]
best_c = total(best)
cur = best
for s in range(sweeps):
layers = [list(L) for L in cur]
downward = s % 2 == 0
indices = range(1, len(layers)) if downward else range(len(layers) - 2, -1, -1)
ref_delta = -1 if downward else 1
for i in indices:
ref = layers[i + ref_delta]
if not ref or not layers[i]:
continue
ref_pos = {n: k for k, n in enumerate(ref)}
scored = []
for idx, n in enumerate(layers[i]):
neigh = [ref_pos[v] for v in adj.get(n, ()) if v in ref_pos]
bc = sum(neigh) / len(neigh) if neigh else float(idx)
scored.append((bc, idx, names.get(n, n), n))
scored.sort()
layers[i] = [t[3] for t in scored]
c = total(layers)
if c < best_c:
best_c = c
best = layers
cur = layers
return best
def _sugiyama_column(
nodes: list[str],
roots: list[str],
state: LayoutState,
params: LayoutParams,
*,
adj: dict[str, set[str]] | None = None,
) -> dict[str, tuple[float, float]]:
"""Low-cross layered skeleton for one AN territory."""
g = adj if adj is not None else state.adj
layers, names = state.layers, state.names
layer_of = _assign_layers_column(nodes, roots, layers, g, names)
max_l = max(layer_of.values()) if layer_of else 0
layer_lists: list[list[str]] = [[] for _ in range(max_l + 1)]
for n in sorted(nodes, key=lambda x: names.get(x, x)):
layer_lists[layer_of[n]].append(n)
while len(layer_lists) > 1 and not layer_lists[-1]:
layer_lists.pop()
layer_lists = _minimize_crossings(layer_lists, g, names, sweeps=10)
# Adaptive pitch: wide fans may tighten, but never below label-safe spacing.
n_access = sum(1 for n in nodes if layers.get(n) == "access")
pitch = params.pitch
if n_access > 40:
pitch = max(165.0, params.pitch * min(1.0, 32.0 / math.sqrt(n_access)))
side = params.side
pos: dict[str, tuple[float, float]] = {}
for li, L in enumerate(layer_lists):
for i, n in enumerate(L):
pos[n] = (i * pitch, float(li * side))
for _ in range(8):
for li, L in enumerate(layer_lists):
if not L:
continue
prev = set(layer_lists[li - 1]) if li else set()
nxt = set(layer_lists[li + 1]) if li + 1 < len(layer_lists) else set()
new_x = {}
for n in L:
xs = [
pos[v][0]
for v in g.get(n, ())
if v in pos and (v in prev or v in nxt)
]
new_x[n] = sum(xs) / len(xs) if xs else pos[n][0]
order_idx = {n: i for i, n in enumerate(L)}
ordered = sorted(L, key=lambda n: (new_x[n], order_idx[n]))
xs = [new_x[n] for n in ordered]
for i in range(1, len(xs)):
if xs[i] < xs[i - 1] + pitch:
xs[i] = xs[i - 1] + pitch
for n, x in zip(ordered, xs):
pos[n] = (x, float(li * side))
layer_lists[li] = ordered
return pos
def _an_subtrees(
an: str, ens: list[str], adj: dict[str, set[str]], names: dict[str, str]
) -> list[list[str]]:
"""Partition access into subtrees hanging off each AN neighbor."""
ens_set = set(ens)
children = sorted(
[v for v in adj.get(an, ()) if v in ens_set],
key=lambda n: names.get(n, n),
)
claimed: set[str] = set()
trees: list[list[str]] = []
for c in children:
if c in claimed:
continue
tree: list[str] = []
q: deque[str] = deque([c])
seen = {c, an}
while q:
u = q.popleft()
tree.append(u)
claimed.add(u)
for v in adj.get(u, ()):
if v in ens_set and v not in seen:
seen.add(v)
q.append(v)
if tree:
trees.append(tree)
# Orphans not reached
rest = [e for e in ens if e not in claimed]
if rest:
trees.append(rest)
trees.sort(key=lambda t: -len(t))
return trees
def _access_degree(n: str, ens_set: set[str], adj: dict[str, set[str]]) -> int:
return sum(1 for v in adj.get(n, ()) if v in ens_set)
def _extract_dangling_feeders(
ens: list[str],
adj: dict[str, set[str]],
names: dict[str, str],
attach_set: set[str],
*,
min_len: int = 2,
an_set: set[str] | None = None,
) -> list[list[str]]:
"""Non-ring pure chains: leaf → corridor → first ring/junction attach.
Junction↔junction corridors and cycle sides are NOT feeders — those belong
to rings. A feeder is a dead-end spur that should stay an uncrossed unit.
Nodes that touch an AN are never leaves (they are ring/backbone portals);
otherwise AN-side ring legs get misclassified as feeders and cross chains.
"""
ens_set = set(ens)
ans = set(an_set or ())
if len(ens_set) < min_len:
return []
deg = {n: _access_degree(n, ens_set, adj) for n in ens_set}
attach = set(attach_set) & (ens_set | ans)
used: set[str] = set()
chains: list[list[str]] = []
def is_leaf(n: str) -> bool:
if n in attach or n in ans:
return False
if adj.get(n, ()) & ans:
return False # hangs on AN → ring/backbone, not a dangling tip
return deg.get(n, 0) <= 1
leaves = sorted(
[n for n in ens_set if is_leaf(n)],
key=lambda n: names.get(n, n),
)
for leaf in leaves:
if leaf in used:
continue
path = [leaf]
prev: str | None = None
cur = leaf
while cur not in attach:
nbs = [
v
for v in adj.get(cur, ())
if v != prev and (v in ens_set or v in attach)
]
if not nbs:
break
# Corridor: unique forward; stop at branch (non-attach junction).
if len(nbs) != 1:
hit = [v for v in nbs if v in attach]
if len(hit) == 1:
path.append(hit[0])
cur = hit[0]
break
break
nxt = nbs[0]
path.append(nxt)
prev, cur = cur, nxt
if len(path) > 48:
break
if cur not in attach and deg.get(cur, 0) >= 3:
break
if len(path) < min_len:
continue
if path[-1] not in attach and deg.get(path[-1], 0) < 3:
continue
# Mark only the dangling body (keep attach node free for rings).
body = path[:-1] if path[-1] in attach or deg.get(path[-1], 0) >= 3 else path
if len(body) < 1:
continue
used.update(body)
chains.append(path)
chains.sort(key=lambda c: (-len(c), names.get(c[0], c[0])))
return chains
def _extract_pure_chains(
ens: list[str],
adj: dict[str, set[str]],
names: dict[str, str],
*,
min_len: int = 2,
attach_set: set[str] | None = None,
) -> list[list[str]]:
"""Backward-compatible alias → dangling feeders (non-ring only)."""
ens_set = set(ens)
if attach_set is None:
# Fallback attach: junctions + leaves' far hubs (deg≥3).
attach_set = {
n
for n in ens_set
if _access_degree(n, ens_set, adj) >= 3
}
return _extract_dangling_feeders(
ens, adj, names, attach_set, min_len=min_len
)
def _orient_feeder_outward(chain: list[str], attach_set: set[str]) -> list[str]:
"""Orient feeder as leaf → … → attach."""
if not chain:
return []
if chain[-1] in attach_set:
return list(chain)
if chain[0] in attach_set:
return list(reversed(chain))
return list(chain)
def _chain_axis_frac(pts: list[tuple[float, float]]) -> float:
if len(pts) < 2:
return 1.0
ok = 0
for i in range(len(pts) - 1):
dx = abs(pts[i + 1][0] - pts[i][0])
dy = abs(pts[i + 1][1] - pts[i][1])
if dx < 1.0 or dy < 1.0:
ok += 1
return ok / (len(pts) - 1)
def _place_chain_unit_at(
order: list[str],
pts: list[tuple[float, float]],
params: LayoutParams,
) -> dict[str, tuple[float, float]]:
"""Refit one pure chain to an axis-aligned polyline, preserving step lengths.
Fixed-pitch packing caused mass overlaps → fix_overlaps blew crossings up.
"""
if not order or len(order) != len(pts):
return {}
min_step = max(params.pitch * 0.85, 1.0)
out: dict[str, tuple[float, float]] = {order[0]: pts[0]}
x, y = pts[0]
for i in range(1, len(order)):
tx, ty = pts[i]
dx, dy = tx - x, ty - y
dist = math.hypot(dx, dy)
step = max(dist, min_step)
if abs(dx) >= abs(dy):
x = x + (step if dx >= 0 else -step)
else:
y = y + (step if dy >= 0 else -step)
out[order[i]] = (x, y)
return out
def _snap_chains_as_wholes(
pos: dict[str, tuple[float, float]],
chains: list[list[str]],
params: LayoutParams,
edges: list[tuple[str, str]],
) -> tuple[dict[str, tuple[float, float]], set[str]]:
"""Priority pass: accept each pure chain as one polyline only if crossings hold."""
if not chains:
return pos, set()
cur = dict(pos)
pinned: set[str] = set()
c_cur = count_edge_crossings(cur, edges) if edges else 0
# Longer feeders first — they benefit most from whole-unit treatment.
ordered = sorted(chains, key=lambda c: (-len(c), c[0] if c else ""))
for ch in ordered:
order = [n for n in ch if n in cur]
if len(order) < 3:
continue
pts = [cur[n] for n in order]
# Already a coherent axis unit — leave as-is, still pin.
if _chain_axis_frac(pts) >= 0.8:
pinned.update(order)
continue
unit = _place_chain_unit_at(order, pts, params)
if len(unit) != len(order):
continue
trial = dict(cur)
trial.update(unit)
c1 = count_edge_crossings(trial, edges) if edges else 0
# Strict: no crossing regression for a cosmetic straighten.
if c1 <= c_cur:
cur = trial
c_cur = c1
pinned.update(order)
return cur, pinned
def _surround_by_an_subtrees(
pos: dict[str, tuple[float, float]],
an: str,
ens: list[str],
adj: dict[str, set[str]],
names: dict[str, str],
*,
pinned: set[str] | None = None,
) -> dict[str, tuple[float, float]]:
"""Balance AN subtrees above/below by node weight (UME surround)."""
if an not in pos or not ens:
return pos
pin = pinned or set()
out = dict(pos)
ax, ay = out[an]
out = {n: (x - ax, y - ay) for n, (x, y) in out.items()}
trees = _an_subtrees(an, ens, adj, names)
# Skip trees already claimed by chain-first placement.
movable = []
for t in trees:
free = [n for n in t if n not in pin]
if free:
movable.append(free)
if len(movable) <= 1:
return out
north_w = south_w = 0
for tree in movable:
# Greedy: put next tree on the lighter side.
to_north = north_w < south_w
if to_north:
north_w += len(tree)
for n in tree:
if n in out:
x, y = out[n]
out[n] = (x, -abs(y) if abs(y) > 1e-6 else -1.0)
else:
south_w += len(tree)
for n in tree:
if n in out:
x, y = out[n]
out[n] = (x, abs(y) if abs(y) > 1e-6 else 1.0)
return out
def _access_junctions(
ens: list[str],
adj: dict[str, set[str]],
an_set: set[str],
) -> list[str]:
"""Portal candidates: access deg≥3, or deg≥2 and touching an AN."""
ens_set = set(ens)
out: list[str] = []
for n in ens:
adeg = _access_degree(n, ens_set, adj)
if adeg >= 3 or (adeg >= 2 and (adj.get(n, ()) & an_set)):
out.append(n)
return out
def _corridor_paths_between(
p1: str,
p2: str,
ens_set: set[str],
adj: dict[str, set[str]],
names: dict[str, str],
*,
max_len: int = 24,
an_set: set[str] | None = None,
) -> list[list[str]]:
"""All p1→p2 paths whose interior nodes are pure corridor (access-deg ≤2).
Portals may be ANs (outside ens_set); interiors stay on access.
"""
ans = set(an_set or ())
def adeg(n: str) -> int:
# Count AN touch as a stub so AN-hanging corridor nodes stay walkable.
return _access_degree(n, ens_set, adj) + (
1 if (adj.get(n, ()) & ans) and n not in (p1, p2) else 0
)
def corridor_ok(n: str) -> bool:
"""Interior: at most 2 access neighbors (AN link ignored for cap)."""
return _access_degree(n, ens_set, adj) <= 2
paths: list[list[str]] = []
# Direct edge counts as an empty-interior side.
if p2 in adj.get(p1, ()):
paths.append([p1, p2])
for nxt in sorted(adj.get(p1, ()), key=lambda n: names.get(n, n)):
if nxt == p2:
continue
if nxt not in ens_set:
continue
if not corridor_ok(nxt):
continue
path = [p1, nxt]
prv, cur = p1, nxt
seen = {p1, nxt}
ok = True
while cur != p2:
nbs = [
v
for v in adj.get(cur, ())
if v != prv and (v == p2 or v in ens_set)
]
forward = [
v
for v in nbs
if v == p2 or (v in ens_set and corridor_ok(v) and v not in seen)
]
if p2 in nbs and p2 not in forward:
forward.append(p2)
if len(forward) != 1:
ok = False
break
prv, cur = cur, forward[0]
if cur in seen and cur != p2:
ok = False
break
path.append(cur)
seen.add(cur)
if len(path) > max_len:
ok = False
break
if ok and path[-1] == p2 and len(path) >= 2:
paths.append(path)
# Unique by interior node set (keep shortest name-stable representative).
best: dict[frozenset[str], list[str]] = {}
for p in paths:
key = frozenset(p[1:-1])
prev = best.get(key)
if prev is None or len(p) < len(prev):
best[key] = p
return sorted(
best.values(),
key=lambda p: (len(p), names.get(p[1], p[1]) if len(p) > 1 else ""),
)
def _is_two_portal_cycle(
path_a: list[str],
path_b: list[str],
adj: dict[str, set[str]],
ens_set: set[str],
an_set: set[str],
) -> bool:
"""True if cycle(path_a ∪ path_b) touches the outside only at the two portals."""
if len(path_a) < 2 or len(path_b) < 2:
return False
if path_a[0] != path_b[0] or path_a[-1] != path_b[-1]:
return False
a, b = path_a[0], path_a[-1]
if set(path_a[1:-1]) & set(path_b[1:-1]):
return False
node_set = set(path_a) | set(path_b)
if len(node_set) < 3:
return False
portals = []
for n in node_set:
# Any neighbor outside the cycle counts (AN uplink, peer AN, side chain).
ext = [v for v in adj.get(n, ()) if v not in node_set]
if ext:
portals.append(n)
return set(portals) == {a, b}
def _find_two_portal_ring_groups(
ens: list[str],
adj: dict[str, set[str]],
names: dict[str, str],
an_set: set[str],
) -> list[dict[str, object]]:
"""Portal pairs with ≥2 corridor paths; paths sorted shortest→longest (inner→outer).
ANs may be portals — metro rings often hang off an AN with a direct edge
plus a longer access corridor (the TNM/JROS/ADAK/SRIN pattern).
"""
ens_set = set(ens)
junc = _access_junctions(ens, adj, an_set)
an_portals = sorted(
[a for a in an_set if any(v in ens_set for v in adj.get(a, ()))],
key=lambda n: names.get(n, n),
)
portals = sorted(set(junc) | set(an_portals), key=lambda n: names.get(n, n))
groups: list[dict[str, object]] = []
for i, a in enumerate(portals):
for b in portals[i + 1 :]:
paths = _corridor_paths_between(
a, b, ens_set, adj, names, an_set=an_set
)
if len(paths) < 2:
continue
# Disjoint interiors, shortest first (= innermost).
paths = sorted(
paths,
key=lambda p: (len(p), names.get(p[1], p[1]) if len(p) > 2 else ""),
)
chosen: list[list[str]] = []
used_mid: set[str] = set()
for p in paths:
mid = set(p[1:-1])
if mid & used_mid:
continue
if chosen and not any(
_is_two_portal_cycle(p, q, adj, ens_set, an_set) for q in chosen
):
continue
chosen.append(p)
used_mid |= mid
if len(chosen) < 2:
continue
if not any(
_is_two_portal_cycle(chosen[0], q, adj, ens_set, an_set)
for q in chosen[1:]
):
continue
groups.append({"portals": (a, b), "paths": chosen})
groups.sort(
key=lambda g: (
-len(g["paths"]), # type: ignore[arg-type]
names.get(g["portals"][0], g["portals"][0]), # type: ignore[index]
)
)
kept: list[dict[str, object]] = []
used_interior: set[str] = set()
for g in groups:
a, b = g["portals"] # type: ignore[misc]
paths = g["paths"] # type: ignore[assignment]
interiors = set()
for p in paths: # type: ignore[union-attr]
interiors |= set(p[1:-1])
if interiors & used_interior:
continue
used_interior |= interiors
kept.append(g)
return kept
def _place_path_on_span(
path: list[str],
x_left: float,
x_right: float,
y: float,
) -> dict[str, tuple[float, float]]:
"""Place corridor interiors evenly on a horizontal span (portals excluded)."""
mid = path[1:-1]
if not mid:
return {}
out: dict[str, tuple[float, float]] = {}
for i, n in enumerate(mid):
t = (i + 1) / (len(mid) + 1)
out[n] = (x_left + t * (x_right - x_left), y)
return out
def _ring_nodes_from_groups(groups: list[dict[str, object]]) -> set[str]:
out: set[str] = set()
for g in groups:
a, b = g["portals"] # type: ignore[misc]
out.add(a)
out.add(b)
for p in g["paths"]: # type: ignore[union-attr]
out.update(p)
return out
def _reflect_point_across_segment(
p: tuple[float, float],
a: tuple[float, float],
b: tuple[float, float],
) -> tuple[float, float]:
"""Reflect point p across the infinite line through a—b."""
ax, ay = a
bx, by = b
px, py = p
dx, dy = bx - ax, by - ay
L2 = dx * dx + dy * dy
if L2 < 1e-12:
return p
t = ((px - ax) * dx + (py - ay) * dy) / L2
qx, qy = ax + t * dx, ay + t * dy
return (2.0 * qx - px, 2.0 * qy - py)
def _point_in_poly(x: float, y: float, poly: list[tuple[float, float]]) -> bool:
"""Ray-casting inclusion; boundary counts as inside."""
n = len(poly)
if n < 3:
return False
inside = False
j = n - 1
for i in range(n):
xi, yi = poly[i]
xj, yj = poly[j]
if abs((yj - yi) * (x - xi) - (xj - xi) * (y - yi)) < 1e-6:
# on edge segment?
if min(xi, xj) - 1e-6 <= x <= max(xi, xj) + 1e-6 and min(
yi, yj
) - 1e-6 <= y <= max(yi, yj) + 1e-6:
return True
intersect = ((yi > y) != (yj > y)) and (
x < (xj - xi) * (y - yi) / ((yj - yi) or 1e-12) + xi
)
if intersect:
inside = not inside
j = i
return inside
def _ring_polygon(
group: dict[str, object], pos: dict[str, tuple[float, float]]
) -> tuple[set[str], list[tuple[float, float]], list[str]]:
"""Cycle polygon for a two-portal ring group (portals + corridor bands)."""
a, b = group["portals"] # type: ignore[misc]
paths: list[list[str]] = group["paths"] # type: ignore[assignment]
unit: set[str] = {a, b}
for p in paths:
unit.update(p)
# Prefer longest path as one arc; close via remaining arcs / chord.
arcs = []
for p in paths:
if p[0] == b and p[-1] == a:
p = list(reversed(p))
if p[0] == a and p[-1] == b:
arcs.append(p)
if not arcs:
order = [n for n in unit if n in pos]
pts = [pos[n] for n in order]
return unit, pts, order
arcs.sort(key=len)
# Walk a → short … → b → reverse(long) → a
short, longp = arcs[0], arcs[-1]
order = list(short) + list(reversed(longp[1:-1]))
poly = [pos[n] for n in order if n in pos]
return unit, poly, order
def _stub_crosses_ring(
stub: str,
portal: str,
pos: dict[str, tuple[float, float]],
order: list[str],
) -> bool:
"""True if stub—portal properly intersects a ring edge (not at portal)."""
from netx_api.mcp_remote.topology.layout_metrics import segments_properly_intersect
if stub not in pos or portal not in pos or len(order) < 2:
return False
p0, p1 = pos[stub], pos[portal]
m = len(order)
for i in range(m):
u, v = order[i], order[(i + 1) % m]
if portal in (u, v):
continue
if u not in pos or v not in pos:
continue
if segments_properly_intersect(p0, p1, pos[u], pos[v]):
return True
return False
def _ring_descriptors(
groups: list[dict[str, object]],
pos: dict[str, tuple[float, float]],
) -> list[tuple[str, str, set[str], list[tuple[float, float]], list[str]]]:
"""(portal_a, portal_b, unit, poly, order) for placeable rings; small first."""
descs: list[tuple[str, str, set[str], list[tuple[float, float]], list[str]]] = []
for g in groups:
a, b = g["portals"] # type: ignore[misc]
unit, poly, order = _ring_polygon(g, pos)
if len(poly) < 3 or a not in pos or b not in pos:
continue
descs.append((a, b, unit, poly, order))
descs.sort(key=lambda t: len(t[2]))
return descs
def _stub_clean_for_rings(
stub: str,
portal: str,
pos: dict[str, tuple[float, float]],
descs: list[tuple[str, str, set[str], list[tuple[float, float]], list[str]]],
) -> bool:
"""True if stub is outside every portal-ring and its portal edge pierces none."""
if stub not in pos:
return False
sx, sy = pos[stub]
for a, b, unit, poly, order in descs:
if stub in unit or portal not in (a, b):
continue
if _point_in_poly(sx, sy, poly):
return False
if _stub_crosses_ring(stub, portal, pos, order):
return False
return True
def _total_ring_thru(
pos: dict[str, tuple[float, float]],
descs: list[tuple[str, str, set[str], list[tuple[float, float]], list[str]]],
edges: list[tuple[str, str]],
) -> int:
return sum(_foreign_edge_crossings(pos, unit, edges) for _, _, unit, _, _ in descs)
def _eject_intruders_from_rings(
pos: dict[str, tuple[float, float]],
groups: list[dict[str, object]],
adj: dict[str, set[str]],
params: LayoutParams,
edges: list[tuple[str, str]],
*,
protected: set[str] | None = None,
) -> dict[str, tuple[float, float]]:
"""Keep minimal rings hollow: no portal-stub node/edge through any ring.
Covers triangles (SPB–VOTI–MRBD) as well as larger trapezoid rings. A stub
may sit *outside* the polygon yet still pierce a chord (PNBR–SPB × MRBD–VOTI).
Candidates must stay clean for **all** portal-rings sharing the stub (so
ejecting from SPB–TNM cannot park PNBR through the MRBD triangle).
"""
if not groups:
return pos
out = dict(pos)
prot = set(protected or ())
pad = max(params.pitch * 0.75, 120.0)
for _round in range(4):
descs = _ring_descriptors(groups, out)
if not descs:
break
moved_any = False
# Portal stubs across all rings (dedupe).
jobs: list[tuple[str, str]] = []
seen: set[tuple[str, str]] = set()
for a, b, unit, _poly, _order in descs:
for p in (a, b):
for n in adj.get(p, ()):
if n in unit or n in prot or n not in out:
continue
key = (n, p)
if key in seen:
continue
seen.add(key)
jobs.append((n, p))
# Prefer stubs that currently violate something; smaller rings first via descs.
jobs.sort(
key=lambda t: (
0 if not _stub_clean_for_rings(t[0], t[1], out, descs) else 1,
t[0],
t[1],
)
)
for n, p in jobs:
if _stub_clean_for_rings(n, p, out, descs):
continue
px, py = out[p]
# Aim toward outside neighbors not in any ring that uses this portal.
ring_units = [unit for a, b, unit, _, _ in descs if p in (a, b)]
blocked = {p} | set().union(*ring_units) if ring_units else {p}
outs = [v for v in adj.get(n, ()) if v not in blocked and v in out]
if outs:
tx = sum(out[v][0] for v in outs) / len(outs)
ty = sum(out[v][1] for v in outs) / len(outs)
else:
# Push away from the other portals of rings sharing p.
others = [
o
for a, b, _, _, _ in descs
if p in (a, b)
for o in ((a if b == p else b),)
if o in out
]
if others:
ox = sum(out[o][0] for o in others) / len(others)
oy = sum(out[o][1] for o in others) / len(others)
tx, ty = px + (px - ox), py + (py - oy)
else:
tx, ty = px - pad, py
dx, dy = tx - px, ty - py
L = math.hypot(dx, dy) or 1.0
ux, uy = dx / L, dy / L
candidates: list[tuple[float, float]] = []
for scale in (1.0, 1.4, 1.9, 2.5, 3.2):
candidates.append((px + ux * pad * scale, py + uy * pad * scale))
for rad in (1.0, 1.3, 1.8, 2.4):
for k in range(16):
ang = (2.0 * math.pi * k) / 16.0
candidates.append(
(px + pad * rad * math.cos(ang), py + pad * rad * math.sin(ang))
)
thru0 = _total_ring_thru(out, descs, edges)
best = None
best_key: tuple[float, float, float] | None = None
for cand in candidates:
trial = dict(out)
trial[n] = cand
# Refresh polys for point-in-poly against moved stub only — ring
# nodes are fixed, so existing descs polys stay valid.
if not _stub_clean_for_rings(n, p, trial, descs):
continue
thru1 = _total_ring_thru(trial, descs, edges)
if thru1 > thru0:
continue
aim_d = (cand[0] - tx) ** 2 + (cand[1] - ty) ** 2
key = (float(thru1), aim_d, abs(cand[0] - px) + abs(cand[1] - py))
if best_key is None or key < best_key:
best_key = key
best = cand
if best is not None and best != out[n]:
out[n] = best
moved_any = True
if not moved_any:
break
return out
def _foreign_edge_crossings(
pos: dict[str, tuple[float, float]],
unit_nodes: set[str],
edges: list[tuple[str, str]],
) -> int:
"""Crossings of unit-internal edges by foreign edges (through the unit)."""
if len(unit_nodes) < 2 or not edges:
return 0
from netx_api.mcp_remote.topology.layout_metrics import segments_properly_intersect
internal = [(a, b) for a, b in edges if a in unit_nodes and b in unit_nodes]
foreign = [
(a, b) for a, b in edges if a not in unit_nodes or b not in unit_nodes
]
if not internal or not foreign:
return 0
n = 0
for a, b in internal:
if a not in pos or b not in pos:
continue
p1, p2 = pos[a], pos[b]
for c, d in foreign:
if c not in pos or d not in pos:
continue
if c in (a, b) or d in (a, b):
continue
if segments_properly_intersect(p1, p2, pos[c], pos[d]):
n += 1
return n
def _point_segment_dist(
p: tuple[float, float], a: tuple[float, float], b: tuple[float, float]
) -> float:
ax, ay = a
bx, by = b
px, py = p
vx, vy = bx - ax, by - ay
wx, wy = px - ax, py - ay
L2 = vx * vx + vy * vy
if L2 < 1e-12:
return math.hypot(wx, wy)
t = max(0.0, min(1.0, (wx * vx + wy * vy) / L2))
return math.hypot(px - (ax + t * vx), py - (ay + t * vy))
def _foreign_edges_through_nodes(
pos: dict[str, tuple[float, float]],
unit_nodes: set[str],
edges: list[tuple[str, str]],
*,
thr: float = 28.0,
) -> int:
"""Count foreign edges that pass too close to a ring interior node (icon hit)."""
if not unit_nodes or not edges:
return 0
interiors = [
n
for n in unit_nodes
if n in pos
and sum(1 for u, v in edges if n in (u, v) and (u in unit_nodes and v in unit_nodes))
>= 2
]
# Prefer explicit interiors: nodes in unit that are not only portals of a 2-set.
# Fallback: any unit node that is an endpoint of ≥2 internal edges.
if not interiors:
interiors = [n for n in unit_nodes if n in pos]
foreign = [
(a, b)
for a, b in edges
if (a not in unit_nodes or b not in unit_nodes)
and a in pos
and b in pos
]
hits = 0
for n in interiors:
# Skip if n is an endpoint of the foreign edge.
for a, b in foreign:
if n in (a, b):
continue
if _point_segment_dist(pos[n], pos[a], pos[b]) < thr:
hits += 1
break
return hits
def _ring_side_cost(
pos: dict[str, tuple[float, float]],
unit_nodes: set[str],
edges: list[tuple[str, str]],
) -> tuple[float, float]:
"""Lower is better: (through-crossings, icon-hits by foreign edges)."""
return (
float(_foreign_edge_crossings(pos, unit_nodes, edges)),
float(_foreign_edges_through_nodes(pos, unit_nodes, edges)),
)
def _place_two_portal_ring_groups(
pos: dict[str, tuple[float, float]],
ens: list[str],
adj: dict[str, set[str]],
names: dict[str, str],
an_set: set[str],
params: LayoutParams,
edges: list[tuple[str, str]],
*,
blocked: set[str] | None = None,
groups: list[dict[str, object]] | None = None,
) -> tuple[dict[str, tuple[float, float]], set[str]]:
"""Place two-portal rings as trapezoid bands (short/narrow inner → wide outer)."""
groups = groups if groups is not None else _find_two_portal_ring_groups(
ens, adj, names, an_set
)
if not groups:
return pos, set()
base = dict(pos)
c0 = count_edge_crossings(base, edges) if edges else 0
trial = dict(base)
pinned: set[str] = set()
blocked = set(blocked or ())
gap = max(params.side * 0.9, 150.0)
flare = max(params.pitch * 0.45, 70.0)
for g in groups:
a, b = g["portals"] # type: ignore[misc]
paths: list[list[str]] = g["paths"] # type: ignore[assignment]
if a in blocked or b in blocked:
continue
if a not in trial or b not in trial:
continue
interiors = {n for p in paths for n in p[1:-1]}
if interiors & blocked:
continue
ax, ay = trial[a]
bx, by = trial[b]
mx, my = (ax + bx) / 2, (ay + by) / 2
base_span = max(
math.hypot(bx - ax, by - ay),
params.pitch * max(3, max(len(p) for p in paths)),
)
x0, x1 = mx - base_span / 2, mx + base_span / 2
trial[a] = (x0, my)
trial[b] = (x1, my)
unit_nodes = {a, b} | interiors
# Trapezoid nest: path i at ±dist, span widens with i (outer wider).
# Orient every path a→…→b so portal-adjacent nodes sit on the correct side
# (reversed corridors are what produced the TNM/JROS × chain X-crossing).
for i, path in enumerate(paths):
if path[0] == b and path[-1] == a:
path = list(reversed(path))
elif path[0] != a or path[-1] != b:
continue
sign = -1.0 if i % 2 == 0 else 1.0
dist = (i // 2 + 1) * gap
half = base_span / 2 + i * flare
xl, xr = mx - half, mx + half
y = my + sign * dist
trial.update(_place_path_on_span(path, xl, xr, y))
trial[a] = (x0, my)
trial[b] = (x1, my)
pinned.update(path)
# Prefer geometry that does not let foreign links pierce the ring unit.
thru0 = _foreign_edge_crossings(base, unit_nodes, edges)
thru1 = _foreign_edge_crossings(trial, unit_nodes, edges)
if thru1 > thru0 + 2:
for n in unit_nodes:
if n in base:
trial[n] = base[n]
pinned -= unit_nodes
if not pinned:
return base, set()
c1 = count_edge_crossings(trial, edges) if edges else 0
if c1 <= max(c0 * 1.35, c0 + 50):
return trial, pinned
return base, set()
def _orient_ring_sides(
pos: dict[str, tuple[float, float]],
groups: list[dict[str, object]],
edges: list[tuple[str, str]],
*,
pinned: set[str] | None = None,
max_interiors: int = 3,
push: float = 0.0,
) -> dict[str, tuple[float, float]]:
"""Flip small-ring corridors across the portal chord if that clears pierces.
Cross-petal chords (BNT–SPB through MRBD–VOTI) are only visible after AN
petals are packed — call this on the full component, not inside one petal.
Restrict to small rings (triangles / short arcs) so large trapezoids stay put.
"""
if not groups or not edges:
return pos
out = dict(pos)
pin = set(pinned or ())
# Smallest first so nested outer bands see the settled inner apex.
ordered = sorted(
groups,
key=lambda g: len({n for p in g["paths"] for n in p[1:-1]}), # type: ignore[index]
)
for g in ordered:
a, b = g["portals"] # type: ignore[misc]
paths: list[list[str]] = g["paths"] # type: ignore[assignment]
if a not in out or b not in out:
continue
interiors = {n for p in paths for n in p[1:-1] if n in out}
if not interiors or len(interiors) > max_interiors:
continue
if pin and (interiors - pin):
continue
unit_nodes = {a, b} | interiors
pa, pb = out[a], out[b]
flipped = dict(out)
for n in interiors:
flipped[n] = _reflect_point_across_segment(out[n], pa, pb)
k0 = _ring_side_cost(out, unit_nodes, edges)
k1 = _ring_side_cost(flipped, unit_nodes, edges)
c_cur = count_edge_crossings(out, edges)
c_flip = count_edge_crossings(flipped, edges)
take = False
if k1 < k0 and c_flip <= c_cur + 2:
take = True
elif k1 == k0 and c_flip < c_cur:
take = True
if take:
out = flipped
# If foreign edges still graze the apex, push interiors further off-chord.
if push > 0 and _ring_side_cost(out, unit_nodes, edges)[1] > 0:
pa, pb = out[a], out[b]
dx, dy = pb[0] - pa[0], pb[1] - pa[1]
L = math.hypot(dx, dy) or 1.0
# Normal pointing toward current interior centroid.
cx = sum(out[n][0] for n in interiors) / len(interiors)
cy = sum(out[n][1] for n in interiors) / len(interiors)
mx, my = (pa[0] + pb[0]) / 2, (pa[1] + pb[1]) / 2
nx, ny = -dy / L, dx / L
if (cx - mx) * nx + (cy - my) * ny < 0:
nx, ny = -nx, -ny
pushed = dict(out)
for n in interiors:
x, y = out[n]
pushed[n] = (x + nx * push, y + ny * push)
pk = _ring_side_cost(pushed, unit_nodes, edges)
pc = count_edge_crossings(pushed, edges)
if pk < _ring_side_cost(out, unit_nodes, edges) and pc <= c_cur + 2:
out = pushed
elif pk[0] <= k0[0] and pk[1] < k0[1] and pc <= c_cur + 2:
out = pushed
return out
def _snap_small_rings(
pos: dict[str, tuple[float, float]],
ens: list[str],
adj: dict[str, set[str]],
names: dict[str, str],
params: LayoutParams,
edges: list[tuple[str, str]],
*,
blocked: set[str] | None = None,
) -> dict[str, tuple[float, float]]:
"""Snap UME-sized rings (6–16) onto local rectangle edges if crossings allow."""
rings = _find_access_rings(ens, adj, names, min_len=6, max_len=16)
if not rings:
return pos
base = dict(pos)
c0 = count_edge_crossings(base, edges) if edges else 0
trial = dict(base)
used: set[str] = set(blocked or ())
for ring in rings[:8]:
if any(n in used for n in ring):
continue
order = _cycle_order(ring, adj, names)
pts = [trial[n] for n in order if n in trial]
if len(pts) < 4:
continue
xs, ys = [p[0] for p in pts], [p[1] for p in pts]
cx, cy = sum(xs) / len(xs), sum(ys) / len(ys)
w, h = _squarish_rect_dims(len(order), params)
# Keep local scale of the cluster
w = max(w * 0.55, max(xs) - min(xs), params.pitch * 2)
h = max(h * 0.55, max(ys) - min(ys), params.side)
trial.update(_place_on_rect_edge(order, cx - w / 2, cy - h / 2, w, h))
used.update(order)
c1 = count_edge_crossings(trial, edges) if edges else 0
if c1 <= max(c0 * 1.25, c0 + 20):
return trial
return base
def _compact_petal_aspect(
pos: dict[str, tuple[float, float]],
*,
max_aspect: float = 2.4,
) -> dict[str, tuple[float, float]]:
"""Soft axis compress so each AN territory is not a mega-wide strip."""
if len(pos) < 3:
return pos
x0, y0, x1, y1 = bbox(pos)
w, h = max(x1 - x0, 1.0), max(y1 - y0, 1.0)
aspect = max(w / h, h / w)
if aspect <= max_aspect:
return pos
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
if w >= h:
sx = (max_aspect * h) / w
return {n: (cx + (x - cx) * sx, y) for n, (x, y) in pos.items()}
sy = (max_aspect * w) / h
return {n: (x, cy + (y - cy) * sy) for n, (x, y) in pos.items()}
def _contract_dangling_feeders(
nodes: list[str],
ens: list[str],
adj: dict[str, set[str]],
names: dict[str, str],
attach_set: set[str],
) -> tuple[list[str], dict[str, set[str]], list[list[str]], set[str]]:
"""Contract feeder bodies onto their attach node (leaf…→attach)."""
# Infer ANs from attach_set members that are not in ens (call site passes union).
ens_set = set(ens)
an_guess = {n for n in attach_set if n not in ens_set}
chains = [
_orient_feeder_outward(ch, attach_set)
for ch in _extract_dangling_feeders(
ens, adj, names, attach_set, min_len=2, an_set=an_guess
)
]
if not chains:
node_set = set(nodes)
return list(nodes), {n: set(adj.get(n, ())) & node_set for n in nodes}, [], set()
feeders: list[list[str]] = []
hidden: dict[str, str] = {}
for order in chains:
if len(order) < 2:
continue
attach = order[-1]
feeders.append(order)
for n in order[:-1]:
hidden[n] = attach
reduced = [n for n in nodes if n not in hidden]
reduced_set = set(reduced)
def remap(v: str) -> str | None:
if v in hidden:
return hidden[v]
if v in reduced_set:
return v
return None
# Edges from hidden feeder bodies attach to their attach node.
by_attach: dict[str, list[list[str]]] = {}
for order in feeders:
by_attach.setdefault(order[-1], []).append(order)
cadj: dict[str, set[str]] = {n: set() for n in reduced}
for n in reduced:
srcs = [n]
for order in by_attach.get(n, ()):
srcs.extend(order[:-1])
for u in srcs:
for v in adj.get(u, ()):
rv = remap(v)
if rv is not None and rv != n:
cadj[n].add(rv)
pinned = {n for order in feeders for n in order[:-1]}
return reduced, cadj, feeders, pinned
def _expand_feeder_units(
pos: dict[str, tuple[float, float]],
feeders: list[list[str]],
avoid: dict[str, tuple[float, float]],
params: LayoutParams,
) -> dict[str, tuple[float, float]]:
"""Expand feeders as axis-aligned rays outward from attach (away from ring)."""
if not feeders:
return pos
out = dict(pos)
step = max(params.side, 150.0)
# Lateral offset when several feeders share one attach.
per_attach: dict[str, int] = {}
for order in feeders:
if len(order) < 2:
continue
attach = order[-1]
if attach not in out:
continue
ax, ay = out[attach]
cx, cy = avoid.get(attach, (ax, ay - step))
dx, dy = ax - cx, ay - cy
if abs(dx) + abs(dy) < 1e-6:
ux, uy = 0.0, 1.0
elif abs(dx) >= abs(dy):
ux, uy = (1.0 if dx >= 0 else -1.0), 0.0
else:
ux, uy = 0.0, (1.0 if dy >= 0 else -1.0)
# Perpendicular lane so sibling feeders do not overlap.
lane = per_attach.get(attach, 0)
per_attach[attach] = lane + 1
px, py = -uy, ux
ox = lane * step * 0.9
n = len(order)
for i, node in enumerate(order):
k = n - 1 - i
out[node] = (
ax + k * step * ux + ox * px,
ay + k * step * uy + ox * py,
)
out[attach] = (ax, ay)
return out
def _layout_an_petals(
nodes: list[str],
roots: list[str],
state: LayoutState,
params: LayoutParams,
) -> dict[str, tuple[float, float]]:
"""Rings (trapezoid) + dangling feeders (uncrossed) + layered remainder."""
if not nodes:
return {}
g, layers, names = state.adj, state.layers, state.names
ans = [n for n in roots if n in nodes]
an = ans[0] if ans else None
ens = [n for n in nodes if layers.get(n) == "access"]
an_set = {n for n in nodes if layers.get(n) == "agg"} | ({an} if an else set())
node_set = set(nodes)
edges = [(a, b) for a, b in state.links if a in node_set and b in node_set]
# 1) Minimal two-portal rings first (define attach targets for feeders).
ring_groups = _find_two_portal_ring_groups(ens, g, names, an_set)
ring_nodes = _ring_nodes_from_groups(ring_groups)
junctions = set(_access_junctions(ens, g, an_set))
attach_set = ring_nodes | junctions | an_set
# 2) Dangling non-ring feeders → contract onto attach, layer backbone.
# Pass ANs in attach_set so AN-hanging ring legs are not mistaken for leaves.
reduced, cadj, feeders, pinned = _contract_dangling_feeders(
nodes, ens, g, names, attach_set | an_set
)
pos = _sugiyama_column(reduced, ans, state, params, adj=cadj)
c_base = count_edge_crossings(pos, edges) if edges else 0
if feeders:
plain = _sugiyama_column(nodes, ans, state, params)
c_plain = count_edge_crossings(plain, edges) if edges else 0
if c_base > max(c_plain * 1.55, c_plain + 100):
pos, feeders, pinned, c_base = plain, {}, set(), c_plain
# 3) Surround remainder (keep feeder bodies for later expand).
if an and an in pos and ens:
trial = _surround_by_an_subtrees(pos, an, ens, g, names, pinned=pinned)
c1 = count_edge_crossings(trial, edges) if edges else 0
if c1 <= max(c_base * 1.2, c_base + 30):
pos = trial
# 4) Place rings as trapezoid bands (hollow units; reject if pierced worse).
pos, ring_pins = _place_two_portal_ring_groups(
pos,
ens,
g,
names,
an_set,
params,
edges,
blocked=set(),
groups=ring_groups,
)
pinned |= ring_pins
# Side stubs (PNBR etc.) must not sit inside the hollow ring.
pos = _eject_intruders_from_rings(
pos, ring_groups, g, params, edges, protected=ring_nodes
)
# Avoid centroids so feeders grow outward, not through the ring.
avoid: dict[str, tuple[float, float]] = {}
if an and an in pos:
for n in attach_set:
avoid[n] = pos[an]
for g_ring in ring_groups:
unit = set()
a, b = g_ring["portals"] # type: ignore[misc]
unit.add(a)
unit.add(b)
for p in g_ring["paths"]: # type: ignore[union-attr]
unit.update(p)
pts = [pos[n] for n in unit if n in pos]
if not pts:
continue
cx = sum(p[0] for p in pts) / len(pts)
cy = sum(p[1] for p in pts) / len(pts)
for n in unit:
avoid[n] = (cx, cy)
# 5) Expand dangling feeders as uncrossed outward rays.
if feeders:
pos = _expand_feeder_units(pos, feeders, avoid, params)
pinned |= {n for order in feeders for n in order[:-1]}
# Feeder expand can re-enter a ring — eject again.
pos = _eject_intruders_from_rings(
pos, ring_groups, g, params, edges, protected=ring_nodes
)
# 6) Other small rings on leftovers.
free_ens = [e for e in ens if e not in pinned]
if free_ens:
before = dict(pos)
snapped = _snap_small_rings(
pos, free_ens, g, names, params, edges, blocked=pinned | ring_nodes
)
for n in pinned | ring_nodes:
if n in before:
snapped[n] = before[n]
if an and an in before:
snapped[an] = before[an]
pos = snapped
pos = _compact_petal_aspect(pos, max_aspect=4.0)
if edges and pos:
c0 = count_edge_crossings(pos, edges)
xs = [p[0] for p in pos.values()]
ys = [p[1] for p in pos.values()]
mx = (min(xs) + max(xs)) / 2
my = (min(ys) + max(ys)) / 2
for flipped in (
{n: (2 * mx - x, y) for n, (x, y) in pos.items()},
{n: (x, 2 * my - y) for n, (x, y) in pos.items()},
):
c1 = count_edge_crossings(flipped, edges)
if c1 < c0:
pos, c0 = flipped, c1
# Aspect compress / mirror can re-pierce hollow rings — final eject.
# (Ring-side flip waits until petals are packed — cross-AN edges missing here.)
if ring_groups:
pos = _eject_intruders_from_rings(
pos, ring_groups, g, params, edges, protected=ring_nodes
)
if pos:
x0, y0, _, _ = bbox(pos)
pos = {n: (x - x0, y - y0) for n, (x, y) in pos.items()}
if pinned:
bucket = state.meta.setdefault("_chain_pins", set())
if not isinstance(bucket, set):
bucket = set(bucket)
state.meta["_chain_pins"] = bucket
bucket.update(pinned)
return pos
def _layout_column(
nodes: list[str],
roots: list[str],
state: LayoutState,
params: LayoutParams,
) -> dict[str, tuple[float, float]]:
return _layout_an_petals(nodes, roots, state, params)
def _pack_columns(
columns: list[dict[str, tuple[float, float]]],
params: LayoutParams,
*,
nrows: int,
gap_x: float | None = None,
gap_y: float | None = None,
) -> dict[str, tuple[float, float]]:
cols = [c for c in columns if c]
if not cols:
return {}
nrows = max(1, min(nrows, len(cols)))
per_row = int(math.ceil(len(cols) / nrows))
gx = params.an_gap * 1.8 if gap_x is None else gap_x
gy = params.island_pad_y * 1.2 if gap_y is None else gap_y
rows: list[list[int]] = []
for r in range(nrows):
chunk = list(range(r * per_row, min((r + 1) * per_row, len(cols))))
if r % 2 == 1:
chunk.reverse()
rows.append(chunk)
row_h = [0.0] * nrows
col_w = [0.0] * per_row
meta: list[tuple[int, int, int]] = []
for r, chunk in enumerate(rows):
for visual_k, idx in enumerate(chunk):
x0, y0, x1, y1 = bbox(cols[idx])
w, h = x1 - x0, y1 - y0
row_h[r] = max(row_h[r], h)
col_w[visual_k] = max(col_w[visual_k], w)
meta.append((r, visual_k, idx))
x_off = [0.0]
for j in range(per_row):
x_off.append(x_off[-1] + col_w[j] + (gx if j + 1 < per_row else 0.0))
y_off = [0.0]
for r in range(nrows):
y_off.append(y_off[-1] + row_h[r] + (gy if r + 1 < nrows else 0.0))
out: dict[str, tuple[float, float]] = {}
for r, visual_k, idx in meta:
c = cols[idx]
x0, y0, x1, y1 = bbox(c)
w = x1 - x0
ox = x_off[visual_k] + max(0.0, (col_w[visual_k] - w) / 2)
oy = y_off[r]
for nid, (x, y) in c.items():
out[nid] = (ox + (x - x0), oy + (y - y0))
return out
def _best_square_pack(
columns: list[dict[str, tuple[float, float]]],
comp: list[str],
state: LayoutState,
params: LayoutParams,
) -> dict[str, tuple[float, float]]:
"""Pack AN petals favoring UME-like aspect (~1.55) with soft crossing slack."""
node_set = set(comp)
edges = [(a, b) for a, b in state.links if a in node_set and b in node_set]
n = len([c for c in columns if c])
candidates = [1]
if n >= 2:
candidates.append(2)
if n >= 4:
candidates.append(3)
target_aspect = 1.55
scored: list[tuple[float, int, float, dict[str, tuple[float, float]]]] = []
for nrows in candidates:
packed = _pack_columns(columns, params, nrows=nrows)
if not packed:
continue
cross = count_edge_crossings(packed, edges) if edges else 0
x0, y0, x1, y1 = bbox(packed)
w, h = max(x1 - x0, 1.0), max(y1 - y0, 1.0)
aspect = max(w / h, h / w)
scored.append((aspect, cross, w * h, packed))
if not scored:
return {}
min_cross = min(t[1] for t in scored)
# Allow larger crossing trade to kill mega-wide strips (UME aspect ~1.6)
slack = max(80, int(1.1 * min_cross))
pool = [t for t in scored if t[1] <= min_cross + slack]
if not pool:
pool = scored
def rank(t: tuple[float, int, float, dict]) -> tuple:
aspect, cross, area, _ = t
return (
abs(math.log(aspect / target_aspect)) * 2.5
+ 0.35 * (cross / max(min_cross, 1)),
area,
)
pool.sort(key=rank)
return pool[0][3]
def _nearest_core(
n: str, cores: list[str], adj: dict[str, set[str]], names: dict[str, str]
) -> str:
if not cores:
return ""
return _nearest_an(n, cores, adj, names)
def _place_component_core_beam(
comp: list[str],
state: LayoutState,
params: LayoutParams,
*,
ans: list[str],
ens: list[str],
cores: list[str],
others: list[str],
) -> dict[str, tuple[float, float]]:
"""core_bar: pin cores on a horizontal beam; AN petals hang off (no core-in-column)."""
g, names = state.adj, state.names
ens_set = set(ens)
an_set = set(ans)
core_set = set(cores)
# Prefer stable beam order: degree desc then name
core_order = sorted(
cores,
key=lambda c: (-len(g.get(c, ())), names.get(c, c)),
)
gap = max(float(params.an_gap or 0) or 0.0, float(params.pitch) * 2.2, 360.0)
petal_dy = max(float(params.side) * 3.2, gap * 1.35)
# Multi-source ownership: BFS from ANs through access/other (cores are barriers)
claimable = ens_set | set(others) | an_set
owner: dict[str, str] = {}
q: deque[str] = deque()
for a in ans:
owner[a] = a
q.append(a)
while q:
u = q.popleft()
for v in g.get(u, ()):
if v in core_set or v in owner or v not in claimable:
continue
owner[v] = owner[u]
q.append(v)
for e in ens:
if e in owner:
continue
owner[e] = _nearest_core(e, core_order, g, names) or (
_nearest_an(e, ans, g, names) if ans else e
)
out: dict[str, tuple[float, float]] = {}
for i, c in enumerate(core_order):
out[c] = (i * gap, 0.0)
# Core-owned stubs: local petal around each core (prefer upward / negative Y)
for c in core_order:
col = [c] + [
n
for n, o in owner.items()
if o == c and n != c and n not in an_set and n not in core_set
]
if len(col) <= 1:
continue
placed = _layout_an_petals(col, [], state, params)
if c not in placed:
continue
cx0, cy0 = placed[c]
tx, ty = out[c]
for n, (x, y) in placed.items():
if n == c:
continue
# Flip so stubs mostly sit above the beam
dx, dy = x - cx0, y - cy0
out[n] = (tx + dx, ty - abs(dy) - params.pitch * 0.15)
# AN petals: layout without cores, attach under preferred core
an_order = order_ans(ans, ens_set, g, names)
by_core: dict[str, list[str]] = {c: [] for c in core_order}
for a in an_order:
nc = _nearest_core(a, core_order, g, names) or core_order[0]
by_core.setdefault(nc, []).append(a)
for c in core_order:
group = by_core.get(c, [])
cx, cy = out[c]
n_g = len(group)
for j, a in enumerate(group):
col = [a]
col.extend(
[
e
for e in ens
if owner.get(e) == a
]
)
for o in others:
if o in out or o in core_set:
continue
nbs = [v for v in g.get(o, ()) if v in ens_set or v == a]
if nbs and all(owner.get(v, v) == a or v == a for v in nbs):
col.append(o)
placed = _layout_an_petals(col, [a], state, params)
if not placed or a not in placed:
out[a] = (
cx + (j - (n_g - 1) / 2) * gap * 0.55 if n_g else cx,
cy + petal_dy,
)
continue
ax0, ay0 = placed[a]
if n_g <= 1:
ax = cx
else:
ax = cx + (j - (n_g - 1) / 2) * gap * 0.55
ay = cy + petal_dy
for n, (x, y) in placed.items():
if n in core_set:
continue
out[n] = (ax + (x - ax0), ay + (y - ay0))
out[a] = (ax, ay)
# Leftovers: near neighbor mean
for n in comp:
if n in out:
continue
nbs = [out[v] for v in g.get(n, ()) if v in out]
if nbs:
out[n] = (
sum(p[0] for p in nbs) / len(nbs),
sum(p[1] for p in nbs) / len(nbs) + params.pitch,
)
else:
out[n] = (0.0, petal_dy * 2)
return out
def _place_component(
comp: list[str],
state: LayoutState,
params: LayoutParams,
) -> dict[str, tuple[float, float]]:
g, layers, names = state.adj, state.layers, state.names
ans = sorted([n for n in comp if layers.get(n) == "agg"], key=lambda n: names[n])
ens = [n for n in comp if layers.get(n) == "access"]
cores = [n for n in comp if layers.get(n) == "core"]
others = [n for n in comp if layers.get(n) == "other"]
if len(ans) <= 1:
out = _layout_an_petals(comp, ans, state, params)
node_set = set(comp)
edges = [(a, b) for a, b in state.links if a in node_set and b in node_set]
if out and edges:
ring_groups = _find_two_portal_ring_groups(ens, g, names, set(ans))
if ring_groups:
out = _orient_ring_sides(
out,
ring_groups,
edges,
pinned=_ring_nodes_from_groups(ring_groups),
max_interiors=1,
push=max(params.side * 0.25, 40.0),
)
return out
# Dual+ cores → core_bar beam-first (do not stuff cores into AN columns).
if len(cores) >= 2:
out = _place_component_core_beam(
comp,
state,
params,
ans=ans,
ens=ens,
cores=cores,
others=others,
)
node_set = set(comp)
edges = [(a, b) for a, b in state.links if a in node_set and b in node_set]
if out and edges:
an_set = set(ans)
ring_groups = _find_two_portal_ring_groups(ens, g, names, an_set)
if ring_groups:
before = dict(out)
c_before = count_edge_crossings(out, edges)
out = _orient_ring_sides(
out,
ring_groups,
edges,
pinned=_ring_nodes_from_groups(ring_groups),
max_interiors=1,
push=max(params.side * 0.25, 40.0),
)
out = _eject_intruders_from_rings(
out,
ring_groups,
g,
params,
edges,
protected=_ring_nodes_from_groups(ring_groups),
)
c_after = count_edge_crossings(out, edges)
if c_after > max(c_before * 1.15, c_before + 20):
out = before
# Keep cores on the beam after ring polish
if len(cores) >= 2:
ys = [out[c][1] for c in cores if c in out]
if ys:
beam_y = sum(ys) / len(ys)
for c in cores:
if c in out:
out[c] = (out[c][0], beam_y)
x0, y0, _, _ = bbox(out)
out = {n: (x - x0, y - y0) for n, (x, y) in out.items()}
return out
ens_set = set(ens)
an_order = order_ans(ans, ens_set, g, names)
home = {e: _nearest_an(e, an_order, g, names) for e in ens}
core_home = {c: _nearest_an(c, an_order, g, names) for c in cores}
petals: list[dict[str, tuple[float, float]]] = []
for a in an_order:
col_nodes = [a]
col_nodes.extend([e for e in ens if home.get(e) == a])
col_nodes.extend([c for c in cores if core_home.get(c) == a])
for o in others:
nbs = [v for v in g.get(o, ()) if v in ens_set or v in an_order]
if nbs and all(home.get(v, v) == a or v == a for v in nbs):
col_nodes.append(o)
placed = _layout_an_petals(col_nodes, [a], state, params)
if placed:
petals.append(placed)
placed_ids = {n for p in petals for n in p}
leftover = [n for n in comp if n not in placed_ids]
if leftover:
petals.append(_layout_an_petals(leftover, [], state, params))
out = _best_square_pack(petals, comp, state, params)
node_set = set(comp)
edges = [(a, b) for a, b in state.links if a in node_set and b in node_set]
if out and edges:
c0 = count_edge_crossings(out, edges)
xs = [p[0] for p in out.values()]
mid = (min(xs) + max(xs)) / 2
flipped = {n: (2 * mid - x, y) for n, (x, y) in out.items()}
if count_edge_crossings(flipped, edges) < c0:
out = flipped
# Now cross-AN chords exist — flip triangle apexes (VOTI) off them.
an_set = set(ans)
ring_groups = _find_two_portal_ring_groups(ens, g, names, an_set)
if ring_groups:
before = dict(out)
c_before = count_edge_crossings(out, edges)
out = _orient_ring_sides(
out,
ring_groups,
edges,
pinned=_ring_nodes_from_groups(ring_groups),
max_interiors=1,
push=max(params.side * 0.25, 40.0),
)
out = _eject_intruders_from_rings(
out,
ring_groups,
g,
params,
edges,
protected=_ring_nodes_from_groups(ring_groups),
)
c_after = count_edge_crossings(out, edges)
if c_after > max(c_before * 1.15, c_before + 20):
out = before
x0, y0, _, _ = bbox(out)
out = {n: (x - x0, y - y0) for n, (x, y) in out.items()}
return out
def _pack_islands(
islands: list[dict[str, tuple[float, float]]],
params: LayoutParams,
) -> dict[str, tuple[float, float]]:
islands = sorted([c for c in islands if c], key=lambda p: -len(p))
if not islands:
return {}
if len(islands) == 1:
return dict(islands[0])
n = len(islands)
candidates = [1] + ([2] if n >= 2 else []) + ([3] if n >= 4 else [])
target = 1.55
best = None
best_key = None
for nrows in candidates:
packed = _pack_columns(islands, params, nrows=nrows)
if not packed:
continue
x0, y0, x1, y1 = bbox(packed)
w, h = max(x1 - x0, 1.0), max(y1 - y0, 1.0)
aspect = max(w / h, h / w)
key = (abs(math.log(aspect / target)), w * h)
if best_key is None or key < best_key:
best_key = key
best = packed
return best or {}
def build_sugiyama_layout(
state: LayoutState, params: LayoutParams | None = None
) -> OpResult:
params = params or LayoutParams()
st = state.copy()
ids = list(st.names.keys())
active = {i for i in ids if st.layers.get(i) in ("core", "agg", "access")}
others = sorted(
[i for i in ids if st.layers.get(i) == "other"], key=lambda n: st.names[n]
)
comps = connected_components(active, st.adj)
islands = []
for c in comps:
placed = _place_component(c, st, params)
if placed:
islands.append(placed)
pos = _pack_islands(islands, params) if islands else {}
if others:
if pos:
x0, y0, x1, y1 = bbox(pos)
fx, fy = x1 + params.island_pad_x * 0.35, y0
else:
fx, fy = 0.0, 0.0
for i, n in enumerate(others):
neigh = [v for v in st.adj.get(n, ()) if v in pos]
if neigh:
mx = sum(pos[v][0] for v in neigh) / len(neigh)
my = sum(pos[v][1] for v in neigh) / len(neigh)
pos[n] = (mx + params.pitch, my)
else:
pos[n] = (fx + (i % 4) * params.pitch, fy + (i // 4) * params.side)
for n in ids:
if n not in pos:
pos[n] = (0.0, 0.0)
st.positions = pos
chain_pins = st.meta.pop("_chain_pins", set()) or set()
if not isinstance(chain_pins, set):
chain_pins = set(chain_pins)
hard = {n for n in pos if st.layers.get(n) in ("agg", "core")}
st.pinned = hard | chain_pins
st.spine = set(chain_pins)
st.last_moved = set(pos.keys())
st.meta["components"] = len(comps)
st.meta["rings_mode"] = "ume_petals"
st.meta["chain_unit_nodes"] = len(chain_pins)
return OpResult(
state=st,
moved=set(pos.keys()),
op="build_sugiyama_layout",
params={
"components": len(comps),
"pitch": params.pitch,
"side": params.side,
"mode": "ume_petals",
"chain_unit_nodes": len(chain_pins),
},
note=f"UME petal rects comps={len(comps)} chains={len(chain_pins)}",
)