netx/netx_api/mcp_remote/topology/layout_ops/press_crossings.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

826 lines
30 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.

"""Stage-2 crossing pressure without temp scripts.
Actions used by agents via layoutTopologyView:
- press_hot_edges: rotate non-portal ends of top crossing edges about the other end
- press_crossers: move nodes that participate in crossings against those hot edges
- polish_crossings: straighten → hot_edges → crossers → untangle(portals)
Hot path uses incremental crossing deltas (O(deg·E) per trial) so KND-scale
graphs (~500 links) stay interactive under MCP stdio timeouts.
"""
from __future__ import annotations
import math
from typing import Any
from netx_api.mcp_remote.topology.layout_metrics import (
count_edge_crossings,
crossings_after_node_move,
crossings_involving_node,
crossing_participation_full,
node_footprint,
segments_properly_intersect,
top_crossing_edges,
)
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_api.mcp_remote.topology.layout_ops.untangle import untangle_crossings
def park_phantom_nodes(state: LayoutState) -> set[str]:
"""Park region: markers / absurd coords so they do not blow bbox/util."""
moved: set[str] = set()
for nid, (x, y) in list(state.positions.items()):
if str(nid).startswith("region:") or abs(x) > 1e5 or abs(y) > 1e5:
if not math.isfinite(x) or not math.isfinite(y) or abs(x) > 1e5 or abs(y) > 1e5:
state.positions[nid] = (160.0, 160.0)
moved.add(nid)
elif str(nid).startswith("region:"):
# keep finite but park markers to origin corner
if abs(x) > 1e4 or abs(y) > 1e4:
state.positions[nid] = (160.0, 160.0)
moved.add(nid)
return moved
def _overlaps_any(
node: str,
pos: dict[str, tuple[float, float]],
names: dict[str, str],
) -> bool:
x, y = pos[node]
fa = node_footprint(names.get(node, ""))
ax0, ay0, ax1, ay1 = x + fa[0], y + fa[1], x + fa[2], y + fa[3]
for b, (bx, by) in pos.items():
if b == node:
continue
if abs(bx - x) > 90 and abs(by - y) > 70:
continue
fb = node_footprint(names.get(b, ""))
bx0, by0, bx1, by1 = bx + fb[0], by + fb[1], bx + fb[2], by + fb[3]
if ax0 < bx1 and ax1 > bx0 and ay0 < by1 and ay1 > by0:
return True
return False
def _rotate_about(
hub: tuple[float, float], leaf: tuple[float, float], ang: float
) -> tuple[float, float]:
hx, hy = hub
lx, ly = leaf
dx, dy = lx - hx, ly - hy
c, s = math.cos(ang), math.sin(ang)
return (hx + dx * c - dy * s, hy + dx * s + dy * c)
def frozen_portals_from_state(
state: LayoutState, portal_ids: list[str] | None = None
) -> set[str]:
from netx_api.mcp_remote.topology.layout_ops.rigid_units import (
_hub_portals,
frozen_ids_for_protect,
)
if portal_ids:
raw = {str(x) for x in portal_ids if str(x)}
# Explicit dual-unit portals (~20) must stay frozen; only shrink if
# caller passed the inflated multi-membership set.
return _hub_portals(state, raw, cap=32)
return frozen_ids_for_protect(state, "portals")
def _large_graph_budget(n_links: int) -> dict[str, Any]:
"""Shrink search when E is large so MCP stdio does not time out.
Target: full polish_crossings on ~500-link graphs finishes in ~15–25s.
"""
e = max(0, int(n_links))
if e >= 1200:
return {
"hot_top_n": 6,
"hot_max_moves": 10,
"hot_max_sweeps": 1,
"hot_angle_step": math.pi / 6,
"hot_radii": [0.95, 1.2],
"cross_top_n": 4,
"cross_max_moves": 12,
"cross_max_sweeps": 1,
"cross_cand_cap": 48,
"straighten": False,
"skip_dual_full_x": True,
"untangle_rounds": 18,
"untangle_moves": 2,
"untangle_rank_cap": 36,
"untangle_angle_step": 30,
}
if e >= 400:
return {
"hot_top_n": 8,
"hot_max_moves": 14,
"hot_max_sweeps": 2,
"hot_angle_step": math.pi / 8,
"hot_radii": [0.9, 1.15, 1.35],
"cross_top_n": 5,
"cross_max_moves": 16,
"cross_max_sweeps": 2,
"cross_cand_cap": 72,
# straighten_channels is O(channels×modes×crossings); on 1k+ link
# metros it can stall for minutes with no cancel checkpoints.
"straighten": False,
"skip_dual_full_x": e >= 800,
"untangle_rounds": 28,
"untangle_moves": 3,
"untangle_rank_cap": 48,
"untangle_angle_step": 24,
}
if e >= 200:
return {
"hot_top_n": 8,
"hot_max_moves": 14,
"hot_max_sweeps": 2,
"hot_angle_step": math.pi / 9,
"hot_radii": [0.9, 1.1, 1.25],
"cross_top_n": 4,
"cross_max_moves": 14,
"cross_max_sweeps": 2,
"cross_cand_cap": 80,
"straighten": False,
"skip_dual_full_x": False,
"untangle_rounds": 24,
"untangle_moves": 3,
"untangle_rank_cap": 60,
"untangle_angle_step": 20,
}
return {
"hot_top_n": 10,
"hot_max_moves": 24,
"hot_max_sweeps": 4,
"hot_angle_step": math.pi / 12,
"hot_radii": [0.85, 1.0, 1.15, 1.35],
"cross_top_n": 6,
"cross_max_moves": 40,
"cross_max_sweeps": 6,
"cross_cand_cap": 220,
# Off by default: straighten flattens dual-unit petal eyes into H-chains.
"straighten": False,
"skip_dual_full_x": False,
"untangle_rounds": 120,
"untangle_moves": 5,
"untangle_rank_cap": 140,
"untangle_angle_step": 15,
}
def _has_petal_dual_eye(state: LayoutState) -> bool:
"""True when a multi-corridor dual-unit covers a large share of the canvas."""
try:
from netx_api.mcp_remote.topology.layout_ops.dual_units import (
classify_dual_unit,
find_dual_portal_units,
)
except Exception:
return False
units = find_dual_portal_units(state, max_units=24)
if not units:
return False
n = max(1, len(state.positions) or len(state.names) or len(state.adj))
for u in units:
if classify_dual_unit(u) != "petal":
continue
if len(u.member_ids()) >= max(8, int(0.25 * n)):
return True
return False
def press_hot_edges(
state: LayoutState,
params: LayoutParams | None = None,
*,
portal_ids: list[str] | None = None,
top_n: int | None = None,
max_moves: int | None = None,
max_sweeps: int | None = None,
) -> OpResult:
"""Rotate non-portal ends of top crossing edges about the other endpoint."""
from netx_api.mcp_remote.topology.layout_jobs import raise_if_cancelled, touch_heartbeat
del params
st = state.copy()
park_phantom_nodes(st)
frozen = frozen_portals_from_state(st, portal_ids)
pos = dict(st.positions)
names = st.names
links = st.links
adj = st.adj
budget = _large_graph_budget(len(links))
# Cap overrides — giant metros must not explode search via params.top_n.
top_n = min(
int(top_n if top_n is not None else budget["hot_top_n"]),
int(budget["hot_top_n"]) + (0 if len(links) >= 800 else 4),
)
max_moves = min(
int(max_moves if max_moves is not None else budget["hot_max_moves"]),
int(budget["hot_max_moves"]) + (0 if len(links) >= 800 else 6),
)
max_sweeps = min(
int(max_sweeps if max_sweeps is not None else budget["hot_max_sweeps"]),
int(budget["hot_max_sweeps"]),
)
angle_step = float(budget["hot_angle_step"])
radii_scale = list(budget["hot_radii"])
angles = [i * angle_step for i in range(1, max(2, int(round(2 * math.pi / angle_step))))]
skip_dual = bool(budget.get("skip_dual_full_x"))
touch_heartbeat()
start = count_edge_crossings(pos, links)
cur = start
moved: set[str] = set()
accepted = 0
for _sweep in range(max(1, int(max_sweeps))):
raise_if_cancelled()
touch_heartbeat()
_n, _nh, edge_hit = crossing_participation_full(pos, links)
touch_heartbeat()
tops = top_crossing_edges(
pos, links, names=names, top_n=top_n, edge_participation=edge_hit
)
sweep_moves = 0
for ei, edge in enumerate(tops):
if ei % 2 == 0:
raise_if_cancelled()
touch_heartbeat()
a, b = str(edge["a_node_id"]), str(edge["b_node_id"])
if a not in pos or b not in pos:
continue
cand_pairs: list[tuple[str, str]] = []
if a not in frozen:
cand_pairs.append((a, b))
if b not in frozen:
cand_pairs.append((b, a))
if not cand_pairs:
continue
cand_pairs.sort(
key=lambda pair: (
0 if pair[1] in frozen else 1,
len(adj.get(pair[0], ())),
)
)
leaf, hub = cand_pairs[0]
hub_xy, leaf_xy = pos[hub], pos[leaf]
r0 = math.hypot(leaf_xy[0] - hub_xy[0], leaf_xy[1] - hub_xy[1]) or 200.0
# Cap orbit radius so long spokes cannot fling leaves across a giant bbox.
# Giant metros still need a higher reel target than 900 (bridges ≫5k).
r_cap = 2200.0 if len(links) >= 400 else 1400.0
r_use = min(r0, r_cap)
max_disp = 1100.0 if len(links) >= 400 else 1100.0
# Inward reel on long metro bridges needs a larger displacement budget
# (otherwise max_disp rejects the only moves that cut crossings).
if r0 > max_disp * 1.5:
max_disp = max(max_disp, min(r0 * 0.55, r0 - 0.35 * r_use))
local_before = crossings_involving_node(leaf, pos, links, adj)
best: tuple[int, tuple[float, float]] | None = None
best_dual: (
tuple[int, tuple[float, float], tuple[float, float]] | None
) = None
def _try_cand(cand: tuple[float, float], disp_cap: float | None = None) -> None:
nonlocal best
cap = max_disp if disp_cap is None else disp_cap
if math.hypot(cand[0] - leaf_xy[0], cand[1] - leaf_xy[1]) > cap:
return
trial_pos = {**pos, leaf: cand}
if _overlaps_any(leaf, trial_pos, names):
return
c1 = crossings_after_node_move(
pos,
links,
adj,
leaf,
cand,
current_total=cur,
local_before=local_before,
)
if c1 < cur and (best is None or c1 < best[0]):
best = (c1, cand)
# Reel-in along the spoke first (shorten without hunting angles).
# Pulling straight toward the hub cannot fling the leaf across the
# bbox, so allow it to travel all the way to the r_use target;
# max_disp alone stops short (e.g. r0=8000 → cap 4400 < 5800 needed).
if r0 > r_use + 50.0:
reel_cap = max(max_disp, r0 - r_use)
ux = (leaf_xy[0] - hub_xy[0]) / r0
uy = (leaf_xy[1] - hub_xy[1]) / r0
for rt in (
r_use,
r_use * 1.15,
max(220.0, r0 * 0.25),
max(220.0, r0 * 0.4),
max(220.0, r0 * 0.55),
max(220.0, r0 * 0.7),
):
rt = min(rt, r0 - 1.0)
if rt < 120.0:
continue
_try_cand((hub_xy[0] + ux * rt, hub_xy[1] + uy * rt), reel_cap)
# Both ends walk toward the midpoint (metro bridges often need this).
# Skip on giant E: each trial is a full O(E²) count and stalls MCP.
if (not skip_dual) and r0 > 1500.0 and hub not in frozen:
mx = 0.5 * (hub_xy[0] + leaf_xy[0])
my = 0.5 * (hub_xy[1] + leaf_xy[1])
ux = (leaf_xy[0] - hub_xy[0]) / r0
uy = (leaf_xy[1] - hub_xy[1]) / r0
px, py = -uy, ux
dual_plans: list[tuple[tuple[float, float], tuple[float, float]]] = []
for t in (0.15, 0.25, 0.35, 0.45, 0.55):
dual_plans.append(
(
(
leaf_xy[0] + t * (mx - leaf_xy[0]),
leaf_xy[1] + t * (my - leaf_xy[1]),
),
(
hub_xy[0] + t * (mx - hub_xy[0]),
hub_xy[1] + t * (my - hub_xy[1]),
),
)
)
# Asymmetric: reel the free leaf harder than the hub.
for tl, th in ((0.45, 0.15), (0.6, 0.2), (0.7, 0.25)):
dual_plans.append(
(
(
leaf_xy[0] + tl * (mx - leaf_xy[0]),
leaf_xy[1] + tl * (my - leaf_xy[1]),
),
(
hub_xy[0] + th * (mx - hub_xy[0]),
hub_xy[1] + th * (my - hub_xy[1]),
),
)
)
for s in (-900.0, -520.0, -280.0, 280.0, 520.0, 900.0):
dual_plans.append(
(
(leaf_xy[0] + px * s, leaf_xy[1] + py * s),
(hub_xy[0] + px * s, hub_xy[1] + py * s),
)
)
for leaf_c, hub_c in dual_plans:
trial = {**pos, leaf: leaf_c, hub: hub_c}
if _overlaps_any(leaf, trial, names) or _overlaps_any(
hub, trial, names
):
continue
c1 = count_edge_crossings(trial, links)
if c1 < cur and (best_dual is None or c1 < best_dual[0]):
best_dual = (c1, leaf_c, hub_c)
for ang in angles:
for rs in radii_scale:
rx, ry = _rotate_about(hub_xy, leaf_xy, ang)
dx, dy = rx - hub_xy[0], ry - hub_xy[1]
L = math.hypot(dx, dy) or 1.0
cand = (
hub_xy[0] + dx / L * r_use * min(rs, 1.25),
hub_xy[1] + dy / L * r_use * min(rs, 1.25),
)
_try_cand(cand)
if best is None:
hx, hy = hub_xy
lx, ly = leaf_xy
dx, dy = lx - hx, ly - hy
L = math.hypot(dx, dy) or 1.0
px, py = -dy / L, dx / L
for s in (-720.0, -480.0, -320.0, -200.0, -120.0, 120.0, 200.0, 320.0, 480.0, 720.0):
_try_cand((lx + px * s, ly + py * s))
# Prefer dual mid-walk when it beats single-end moves.
if best_dual is not None and (
best is None or best_dual[0] < best[0]
):
pos[leaf] = best_dual[1]
pos[hub] = best_dual[2]
cur = best_dual[0]
moved.add(leaf)
moved.add(hub)
accepted += 1
sweep_moves += 1
if accepted >= max_moves:
break
continue
if best is None:
continue
pos[leaf] = best[1]
cur = best[0]
moved.add(leaf)
accepted += 1
sweep_moves += 1
if accepted >= max_moves:
break
if sweep_moves == 0 or accepted >= max_moves:
break
st.positions = pos
st.meta = dict(st.meta or {})
st.meta["press_hot_edges"] = {
"start_crossings": start,
"end_crossings": cur,
"accepted_moves": accepted,
"frozen_n": len(frozen),
"budget": {"top_n": top_n, "max_moves": max_moves, "max_sweeps": max_sweeps},
}
return OpResult(
state=st,
moved=moved,
op="press_hot_edges",
params=st.meta["press_hot_edges"],
note=f"press_hot_edges {start}->{cur} moves={accepted}",
)
def _edges_crossing(
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
a: str,
b: str,
) -> list[tuple[str, str]]:
if a not in pos or b not in pos:
return []
p1, p2 = pos[a], pos[b]
out: list[tuple[str, str]] = []
for c, d in links:
if len({a, b, c, d}) < 4 or c not in pos or d not in pos:
continue
if segments_properly_intersect(p1, p2, pos[c], pos[d]):
out.append((c, d))
return out
def _try_move_node(
pos: dict[str, tuple[float, float]],
names: dict[str, str],
links: list[tuple[str, str]],
adj: dict[str, set[str]],
node: str,
*,
jump: float,
cur: int,
cand_cap: int = 220,
) -> tuple[int, tuple[float, float]] | None:
x0, y0 = pos[node]
before_local = crossings_involving_node(node, pos, links, adj)
if before_local <= 0:
return None
ang_step = 30 if cand_cap <= 60 else (20 if cand_cap <= 100 else 10)
# Keep jumps local — long radii explode util on metro canvases.
# Large graphs still need mid-range radii to clear hot-edge crossers.
radii = (
(120, 220, 360, 520)
if cand_cap <= 80
else (80, 140, 220, 320, 450, 600, 800, 1100)
)
cands: list[tuple[float, float]] = []
for ang in range(0, 360, ang_step):
for r in radii:
if r > jump:
continue
rad = math.radians(ang)
cands.append((x0 + r * math.cos(rad), y0 + r * math.sin(rad)))
nbs = [pos[v] for v in adj.get(node, ()) if v in pos]
if nbs:
cx = sum(p[0] for p in nbs) / len(nbs)
cy = sum(p[1] for p in nbs) / len(nbs)
dx, dy = x0 - cx, y0 - cy
L = math.hypot(dx, dy) or 1.0
ux, uy = dx / L, dy / L
px, py = -uy, ux
for s in (-800.0, -500.0, -350.0, -200.0, 200.0, 350.0, 500.0, 800.0):
cands.append((x0 + ux * s, y0 + uy * s))
cands.append((x0 + px * s, y0 + py * s))
if len(cands) > cand_cap:
cands = cands[:: max(1, len(cands) // cand_cap)]
best: tuple[int, tuple[float, float]] | None = None
for cand in cands:
if math.hypot(cand[0] - x0, cand[1] - y0) > jump + 1:
continue
trial_pos = {**pos, node: cand}
if _overlaps_any(node, trial_pos, names):
continue
after_local = crossings_involving_node(node, trial_pos, links, adj)
if after_local >= before_local:
continue
c1 = cur - before_local + after_local
if c1 < cur and (best is None or c1 < best[0]):
best = (c1, cand)
return best
def press_crossers(
state: LayoutState,
params: LayoutParams | None = None,
*,
portal_ids: list[str] | None = None,
top_n: int | None = None,
max_moves: int | None = None,
max_sweeps: int | None = None,
) -> OpResult:
"""Move non-portal nodes involved in crossings against top hot edges."""
del params
st = state.copy()
park_phantom_nodes(st)
frozen = frozen_portals_from_state(st, portal_ids)
pos = dict(st.positions)
names = st.names
links = st.links
adj = st.adj
budget = _large_graph_budget(len(links))
top_n = int(top_n if top_n is not None else budget["cross_top_n"])
max_moves = int(max_moves if max_moves is not None else budget["cross_max_moves"])
max_sweeps = int(max_sweeps if max_sweeps is not None else budget["cross_max_sweeps"])
cand_cap = int(budget["cross_cand_cap"])
start = count_edge_crossings(pos, links)
cur = start
moved: set[str] = set()
accepted = 0
for _sweep in range(max(1, int(max_sweeps))):
_n, _nh, edge_hit = crossing_participation_full(pos, links)
tops = top_crossing_edges(
pos, links, names=names, top_n=top_n, edge_participation=edge_hit
)
scores: dict[str, int] = {}
for he in tops:
a, b = str(he["a_node_id"]), str(he["b_node_id"])
for c, d in _edges_crossing(pos, links, a, b):
for nid in (c, d, a, b):
if nid in frozen or nid not in pos:
continue
deg = len(adj.get(nid, ()))
if deg >= 16:
continue
scores[nid] = scores.get(nid, 0) + int(he["crossing_hits"]) + max(
0, 10 - deg
)
ranked = sorted(scores, key=lambda n: (-scores[n], len(adj.get(n, ())), n))
node_cap = 16 if cand_cap <= 60 else (24 if cand_cap <= 100 else 40)
improved = False
for node in ranked[:node_cap]:
jump = 1400.0 if len(adj.get(node, ())) <= 4 else 1000.0
best = _try_move_node(
pos, names, links, adj, node, jump=jump, cur=cur, cand_cap=cand_cap
)
if best is None:
continue
pos[node] = best[1]
cur = best[0]
moved.add(node)
accepted += 1
improved = True
if accepted >= max_moves:
break
if not improved or accepted >= max_moves:
break
st.positions = pos
st.meta = dict(st.meta or {})
st.meta["press_crossers"] = {
"start_crossings": start,
"end_crossings": cur,
"accepted_moves": accepted,
"frozen_n": len(frozen),
"budget": {"top_n": top_n, "max_moves": max_moves, "max_sweeps": max_sweeps},
}
return OpResult(
state=st,
moved=moved,
op="press_crossers",
params=st.meta["press_crossers"],
note=f"press_crossers {start}->{cur} moves={accepted}",
)
def polish_crossings(
state: LayoutState,
params: LayoutParams | None = None,
*,
portal_ids: list[str] | None = None,
straighten: bool | None = None,
max_degree: int = 9,
untangle_rounds: int | None = None,
top_n: int | None = None,
max_moves: int | None = None,
max_sweeps: int | None = None,
preserve_dual_eye: bool | None = None,
) -> OpResult:
"""Pipeline: park phantoms → (optional straighten) → hot → crossers → untangle.
Petal dual-unit eyes: straighten stays off unless ``straighten=true`` —
channel flattening destroys parallel-lane eye geometry.
"""
from netx_api.mcp_remote.topology.layout_jobs import raise_if_cancelled, report_progress
params = params or LayoutParams()
st = state.copy()
park_phantom_nodes(st)
budget = _large_graph_budget(len(st.links))
preserve = True if preserve_dual_eye is None else bool(preserve_dual_eye)
petal_eye = False
eye_portals: list[str] = []
if preserve:
try:
from netx_api.mcp_remote.topology.layout_ops.dual_units import (
classify_dual_unit,
find_dual_portal_units,
)
units = find_dual_portal_units(st, max_units=24)
n = max(1, len(st.positions) or len(st.names) or len(st.adj))
for u in units:
if classify_dual_unit(u) != "petal":
continue
if len(u.member_ids()) >= max(8, int(0.25 * n)):
petal_eye = True
eye_portals = [u.portal_a, u.portal_b]
break
except Exception:
petal_eye = _has_petal_dual_eye(st)
if portal_ids is None and eye_portals:
portal_ids = eye_portals
# Giant graphs: never allow straighten even if caller asks (stalls for minutes).
if len(st.links) >= 800:
do_straighten = False
elif petal_eye and straighten is not True:
do_straighten = False
else:
do_straighten = (
bool(budget["straighten"]) if straighten is None else bool(straighten)
)
untangle_rounds = int(
untangle_rounds if untangle_rounds is not None else budget["untangle_rounds"]
)
if len(st.links) >= 800:
untangle_rounds = min(untangle_rounds, int(budget["untangle_rounds"]))
top_n = (
min(int(top_n), int(budget["hot_top_n"]))
if top_n is not None
else top_n
)
max_moves = (
min(int(max_moves), int(budget["hot_max_moves"]))
if max_moves is not None
else max_moves
)
report_progress(
"polish_start",
pct=42.0,
message=f"links={len(st.links)} rounds={untangle_rounds}",
links=len(st.links),
untangle_rounds=untangle_rounds,
)
start = count_edge_crossings(st.positions, st.links)
trace: list[dict[str, Any]] = []
moved: set[str] = set()
if do_straighten:
from netx_api.mcp_remote.topology.layout_ops.channels import straighten_channels_greedy
raise_if_cancelled()
report_progress("polish_straighten", pct=48.0, message=f"x0={start}")
op = straighten_channels_greedy(st, params)
st = op.state
moved |= op.moved
trace.append({"op": "straighten_channels", "note": op.note, **(op.params or {})})
raise_if_cancelled()
report_progress("polish_hot_edges", pct=55.0, message="press_hot_edges")
op_h = press_hot_edges(
st,
params,
portal_ids=portal_ids,
top_n=top_n,
max_moves=max_moves,
max_sweeps=max_sweeps,
)
st = op_h.state
moved |= op_h.moved
trace.append({"op": "press_hot_edges", "note": op_h.note, **(op_h.params or {})})
raise_if_cancelled()
report_progress("polish_crossers", pct=62.0, message="press_crossers")
op_c = press_crossers(
st,
params,
portal_ids=portal_ids,
top_n=top_n,
max_moves=max_moves,
max_sweeps=max_sweeps,
)
st = op_c.state
moved |= op_c.moved
trace.append({"op": "press_crossers", "note": op_c.note, **(op_c.params or {})})
focus: list[str] = []
_n, node_hit, edge_hit = crossing_participation_full(st.positions, st.links)
tops_e = top_crossing_edges(
st.positions, st.links, names=st.names, top_n=5, edge_participation=edge_hit
)
for row in tops_e:
focus.append(str(row["a_node_id"]))
focus.append(str(row["b_node_id"]))
ranked_nodes = sorted(node_hit.items(), key=lambda kv: -kv[1])[:5]
focus.extend(nid for nid, _ in ranked_nodes)
focus = list(dict.fromkeys(focus))
raise_if_cancelled()
report_progress(
"polish_untangle",
pct=70.0,
message=f"untangle rounds={untangle_rounds} focus={len(focus)}",
untangle_rounds=untangle_rounds,
focus_n=len(focus),
)
op_u = untangle_crossings(
st,
params,
protect_rigid="portals",
focus_ids=focus,
max_rounds=untangle_rounds,
max_degree=max_degree,
target_crossings=40,
max_jump=1000.0,
moves_per_round=int(budget["untangle_moves"]),
frozen_ids=frozen_portals_from_state(st, portal_ids) if portal_ids else None,
rank_cap=int(budget["untangle_rank_cap"]),
angle_step=int(budget["untangle_angle_step"]),
refresh_every=3 if len(st.links) >= 200 else 1,
)
st = op_u.state
moved |= op_u.moved
trace.append({"op": "untangle", "note": op_u.note, **(op_u.params or {})})
end = count_edge_crossings(st.positions, st.links)
report_progress(
"polish_done",
pct=74.0,
message=f"crossings {start}->{end}",
crossings_before=start,
crossings_after=end,
)
st.meta = dict(st.meta or {})
st.meta["polish_crossings"] = {
"start_crossings": start,
"end_crossings": end,
"trace": trace,
"focus_n": len(focus),
"budget": budget,
"straighten": do_straighten,
"preserve_dual_eye": preserve,
"petal_eye_detected": petal_eye,
"untangle_rounds": untangle_rounds,
}
return OpResult(
state=st,
moved=moved,
op="polish_crossings",
params=st.meta["polish_crossings"],
note=f"polish_crossings {start}->{end}",
)
def press_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
o = overrides or {}
out: dict[str, Any] = {}
for key, cast in (
("top_n", int),
("max_moves", int),
("max_sweeps", int),
("max_degree", int),
("untangle_rounds", int),
):
if key in o and o[key] is not None:
try:
out[key] = cast(o[key])
except (TypeError, ValueError):
pass
if "straighten" in o:
v = o["straighten"]
out["straighten"] = (
v
if isinstance(v, bool)
else str(v).strip().lower() in {"1", "true", "yes", "on"}
)
if "preserve_dual_eye" in o:
v = o["preserve_dual_eye"]
out["preserve_dual_eye"] = (
v
if isinstance(v, bool)
else str(v).strip().lower() in {"1", "true", "yes", "on"}
)
if "portal_ids" in o and isinstance(o["portal_ids"], list):
out["portal_ids"] = [str(x) for x in o["portal_ids"] if str(x)]
if "source_view_ids" in o and isinstance(o["source_view_ids"], list):
out["source_view_ids"] = [str(x) for x in o["source_view_ids"] if str(x)]
return out