netx/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/skeleton.py
oliver 79f929de0c Converge netx-topology MCP to dual-unit drain + orbit/polish mainline.
Trim public tools to 14, expose only kept layout actions/recipes, merge Fabric queries, drop createTopologyView/listTopologyViews, and align skill/docs with the live catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 23:18:53 +08:00

231 lines
8.3 KiB
Python

"""Atomic op: build corridor skeleton (Tutte + island pack)."""
from __future__ import annotations
from collections import defaultdict, deque
from itertools import product
from netx_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.graph_util import (
bbox,
connected_components,
order_ans,
spine_backbone,
)
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
def _place_component_tutte(
comp: list[str],
state: LayoutState,
params: LayoutParams,
) -> tuple[dict[str, tuple[float, float]], list[str]]:
"""Tutte embedding for one component. Returns positions + spine order (EN)."""
g, layers, names = state.adj, state.layers, state.names
edge_pairs = state.links
ans = sorted([n for n in comp if layers[n] == "agg"], key=lambda n: names[n])
ens = [n for n in comp if layers[n] == "access"]
cores = [n for n in comp if layers[n] == "core"]
ens_set = set(ens)
an_order = order_ans(ans, ens_set, g, names)
pinned: dict[str, tuple[float, float]] = {}
for i, a in enumerate(an_order):
pinned[a] = (i * params.an_gap, params.an_y)
for i, c in enumerate(sorted(cores, key=lambda n: names[n])):
pinned[c] = (
(len(an_order) - 1) * params.an_gap / 2 + i * params.an_gap,
params.an_y - params.an_gap,
)
pos: dict[str, list[float]] = {
n: [float(pinned[n][0]), float(pinned[n][1])] for n in pinned
}
if ens:
dist: dict[str, int] = {}
nearest: dict[str, str] = {}
q: deque[str] = deque()
for a in an_order:
for nb in g.get(a, ()):
if nb in ens_set and nb not in dist:
dist[nb] = 1
nearest[nb] = a
q.append(nb)
while q:
u = q.popleft()
for v in g.get(u, ()):
if v in ens_set and v not in dist:
dist[v] = dist[u] + 1
nearest[v] = nearest[u]
q.append(v)
for n in ens:
if n not in dist:
dist[n] = max(dist.values(), default=1) + 1
nearest[n] = an_order[0] if an_order else n
buckets: dict[tuple[str, int], list[str]] = defaultdict(list)
for n in ens:
buckets[(nearest[n], dist[n])].append(n)
for (an, d), group in buckets.items():
group.sort(key=lambda n: names[n])
ax = pinned[an][0] if an in pinned else 0.0
for k, n in enumerate(group):
pos[n] = [ax + (k - (len(group) - 1) / 2) * 0.35, float(d)]
for _ in range(80):
for n in ens:
neigh = [v for v in g.get(n, ()) if v in pos]
if not neigh:
continue
pos[n][0] = sum(pos[v][0] for v in neigh) / len(neigh)
pos[n][1] = sum(pos[v][1] for v in neigh) / len(neigh)
for n, xy in pinned.items():
pos[n][0], pos[n][1] = float(xy[0]), float(xy[1])
for n in ens:
if pos[n][1] < params.an_y + 0.4:
pos[n][1] = params.an_y + 0.4
raw = {n: (pos[n][0], pos[n][1]) for n in pos}
x0, y0, x1, y1 = bbox(raw)
bw, bh = max(x1 - x0, 1e-6), max(y1 - y0, 1e-6)
unit = {n: ((x - x0) / bw, (y - y0) / bh) for n, (x, y) in raw.items()}
w = max(params.an_gap * max(len(an_order), 1), 1120.0) * params.width_mul
h = max(140.0 * (1 + len(ens) ** 0.5), 840.0) * params.height_mul
node_set = set(comp)
internal = [(a, b) for a, b in edge_pairs if a in node_set and b in node_set]
best, best_c = None, 10**9
for fx, fy in product([False, True], repeat=2):
p = {
n: (((1 - x) if fx else x) * w, ((1 - y) if fy else y) * h)
for n, (x, y) in unit.items()
}
if an_order and ens:
if sum(p[a][1] for a in an_order) / len(an_order) > sum(
p[e][1] for e in ens
) / len(ens):
continue
c = count_edge_crossings(p, internal)
if c < best_c:
best_c, best = c, p
assert best is not None
x0, y0, _, _ = bbox(best)
placed = {n: (x - x0, y - y0) for n, (x, y) in best.items()}
spine = spine_backbone(ens, g, names) if ens else []
return placed, spine
def _pack_islands(
islands: list[dict[str, tuple[float, float]]],
edge_pairs: list[tuple[str, str]],
params: LayoutParams,
) -> dict[str, tuple[float, float]]:
islands = sorted(islands, key=lambda p: -len(p))
cols = 2 if len(islands) >= 3 else 1
n = len(islands)
def build(flips: list[bool]) -> dict[str, tuple[float, float]]:
cells: list[list[dict]] = [[] for _ in range(cols)]
for i, isl in enumerate(islands):
loc = dict(isl)
if flips[i]:
x0, _, x1, _ = bbox(loc)
mid = (x0 + x1) / 2
loc = {nid: (2 * mid - x, y) for nid, (x, y) in loc.items()}
cells[i % cols].append(loc)
col_w = []
for c in range(cols):
w = 0.0
for loc in cells[c]:
x0, y0, x1, y1 = bbox(loc)
w = max(w, x1 - x0 + params.island_pad_x)
col_w.append(max(w, 1.0))
out: dict[str, tuple[float, float]] = {}
for c in range(cols):
oy = 0.0
ox = sum(col_w[:c])
for loc in cells[c]:
x0, y0, x1, y1 = bbox(loc)
pad = (col_w[c] - params.island_pad_x - (x1 - x0)) / 2
for nid, (x, y) in loc.items():
out[nid] = (ox + pad + (x - x0), oy + (y - y0))
oy += (y1 - y0) + params.island_pad_y
return out
k = min(n, 5)
best, best_c = None, 10**9
for bits in product([False, True], repeat=k):
flips = list(bits) + [False] * (n - k)
p = build(flips)
c = count_edge_crossings(p, edge_pairs)
if c < best_c:
best_c, best = c, p
assert best is not None
return best
def build_skeleton(state: LayoutState, params: LayoutParams | None = None) -> OpResult:
"""Place Tutte corridor skeleton; pin agg+core+spine; leave sides for next op."""
params = params or LayoutParams()
st = state.copy()
ids = list(st.meta.get("ids") or 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: list[dict[str, tuple[float, float]]] = []
spine_all: set[str] = set()
for c in comps:
placed, spine = _place_component_tutte(c, st, params)
if placed:
islands.append(placed)
spine_all.update(spine)
pos = _pack_islands(islands, st.links, params) if islands else {}
if others:
if pos:
x0, y0, x1, y1 = bbox(pos)
fallback_x, fallback_y = x1 + params.island_pad_x * 0.4, y0
else:
fallback_x, fallback_y = 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 * (1.0 + (i % 3) * 0.35),
my + params.side * ((i % 5) - 2) * 0.25,
)
else:
pos[n] = (
fallback_x + (i % 3) * params.pitch,
fallback_y + (i // 3) * params.side,
)
for n in ids:
if n not in pos:
pos[n] = (0.0, 0.0)
st.positions = pos
st.spine = spine_all
pin = {
n
for n in pos
if st.layers.get(n) in ("agg", "core") or n in spine_all
}
st.pinned = pin
st.last_moved = set(pos.keys())
st.meta["components"] = len(comps)
return OpResult(
state=st,
moved=set(pos.keys()),
op="build_skeleton",
params={
"width_mul": params.width_mul,
"height_mul": params.height_mul,
"an_gap": params.an_gap,
"spine_n": len(spine_all),
"pinned_n": len(pin),
},
note="Tutte islands packed; pinned=agg+core+spine",
)