Files
unreal-engine-mcp-system-pl…/ue_side/graph_layout.py
Bonchellon eba71c4ca8 Unified, portable Unreal Engine MCP system plugin
Merge of the two project copies into one self-contained plugin (the superset:
variable_op + variables.py, full pcg_op runtime/declarative/preset ops, the
CreateWidgetFloatAnimation widget tool, and full Voxel graph authoring).

Made fully project- and machine-agnostic — no hardcoded paths:
- New src/projectPaths.js auto-detects the host .uproject (walk-up), project
  name, Editor build target, log file, and engine install (EngineAssociation
  via launcher manifest/registry, else installed-engine scan). All overridable
  via UE_* env vars.
- Rewired buildOrchestrator/insights/launcher/insightsExporter/server.js and the
  Python workers (cpp_scaffold, live_coding, apply_graph, console) off the old
  C:/Github/ihy, IHY*, E:/UE_Versions and UE_5.7 literals onto the resolver.
- Voxel made optional: Build.cs auto-detects the Voxel plugin (env
  UE_MCP_WITH_VOXEL override) and the C++ compiles to stubs under WITH_MCP_VOXEL,
  so the module builds in projects without Voxel; .uplugin marks Voxel optional.
- De-branded the agent-gateway and docs; scrubbed a leaked API key; excluded
  node_modules/Binaries/Intermediate/__pycache__/secrets from the repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:07:24 +03:00

193 lines
7.0 KiB
Python

"""Small deterministic graph layout helpers shared by MCP graph tools.
The goal is not to replace Unreal's graph layout. It is a guard rail for
generated graphs: keep columns readable and prevent heavy nodes from being
spawned on top of each other.
"""
from __future__ import annotations
DEFAULT_NODE_W = 260
DEFAULT_NODE_H = 150
DEFAULT_GAP_X = 180
DEFAULT_GAP_Y = 80
def estimate_size(spec: dict, domain: str = "blueprint") -> tuple[int, int]:
if domain == "pcg":
cls = str(spec.get("class") or spec.get("type") or "").lower()
# I/O nodes and reroutes are slim; spawners/samplers carry big detail panels.
if cls in ("input", "output"):
return 220, 120
if "spawner" in cls or "sampler" in cls or "subgraph" in cls:
return 340, 240
if "filter" in cls or "transform" in cls or "createpoints" in cls:
return 320, 200
return 300, 170
if domain == "material":
cls = str(spec.get("class") or spec.get("target") or "").lower()
if "texturesample" in cls:
return 310, 360
if "vectorparameter" in cls or "constant3vector" in cls or "constant4vector" in cls:
return 300, 300
if "scalarparameter" in cls:
return 260, 150
if "panner" in cls:
return 280, 190
if "linearinterpolate" in cls or "multiply" in cls or "add" in cls:
return 260, 170
if "texturecoordinate" in cls:
return 260, 130
return 280, 180
kind = str(spec.get("kind") or "").lower()
params = spec.get("params") or {}
if kind == "comment":
return int(params.get("width", 400)), int(params.get("height", 220))
if kind in ("event", "custom_event", "branch", "sequence", "operator", "knot"):
return 240, 120
if kind in ("call_function", "spawn_actor", "format_text"):
return 340, 190
if kind in ("make_struct", "break_struct", "set_fields_in_struct"):
return 360, 260
if kind in ("variable_get", "variable_set", "self"):
return 260, 130
return DEFAULT_NODE_W, DEFAULT_NODE_H
def normalize_specs(nodes: list[dict], edges: list[dict] | None = None, domain: str = "blueprint",
gap_x: int = DEFAULT_GAP_X, gap_y: int = DEFAULT_GAP_Y,
snap_x: int = 360) -> list[dict]:
"""Return copied nodes with collision-resistant positions.
The algorithm preserves the user's left-to-right intent from positions, but
snaps nearby x values into columns and stacks each column by estimated node
height. This keeps generated graphs stable and readable.
"""
copied = [dict(n) for n in (nodes or [])]
if len(copied) < 2:
return copied
with_meta = []
for index, node in enumerate(copied):
pos = node.get("position") or [0, 0]
x = float(pos[0]) if len(pos) > 0 else 0.0
y = float(pos[1]) if len(pos) > 1 else 0.0
w, h = estimate_size(node, domain)
with_meta.append({"index": index, "node": node, "x": x, "y": y, "w": w, "h": h})
sorted_by_x = sorted(with_meta, key=lambda item: (item["x"], item["y"], item["index"]))
columns: list[list[dict]] = []
for item in sorted_by_x:
if not columns:
columns.append([item])
continue
avg_x = sum(i["x"] for i in columns[-1]) / len(columns[-1])
if abs(item["x"] - avg_x) <= max(120, snap_x * 0.45):
columns[-1].append(item)
else:
columns.append([item])
min_x = min(item["x"] for item in with_meta)
for column_index, column in enumerate(columns):
column_x = min_x + column_index * (max(item["w"] for item in column) + gap_x)
column.sort(key=lambda item: (item["y"], item["index"]))
cursor_y = min(item["y"] for item in column)
for item in column:
desired_y = item["y"]
y = max(desired_y, cursor_y)
item["node"]["position"] = [round(column_x), round(y)]
cursor_y = y + item["h"] + gap_y
return copied
def tidy_existing_nodes(nodes: list[dict], edges: list[dict] | None = None, domain: str = "material") -> list[dict]:
"""Return [{guid/name,x,y}] for existing graph dump nodes."""
if edges:
return _tidy_existing_by_edges(nodes, edges, domain)
specs = []
for node in nodes or []:
cls = node.get("class") or ""
x = node.get("x", (node.get("position") or [0, 0])[0])
y = node.get("y", (node.get("position") or [0, 0])[1])
specs.append({
"id": node.get("guid") or node.get("name"),
"class": cls,
"position": [float(x), float(y)],
})
normalized = normalize_specs(specs, domain=domain)
moves = []
for spec in normalized:
x, y = spec.get("position") or [0, 0]
moves.append({"guid": spec.get("id"), "x": x, "y": y})
return moves
def _tidy_existing_by_edges(nodes: list[dict], edges: list[dict], domain: str) -> list[dict]:
by_id = {}
for node in nodes or []:
node_id = node.get("guid") or node.get("name")
if node_id:
by_id[node_id] = node
if not by_id:
return []
incoming = {node_id: set() for node_id in by_id}
outgoing = {node_id: set() for node_id in by_id}
for edge in edges or []:
try:
src = edge["from"][0]
dst = edge["to"][0]
except Exception:
continue
if src in by_id and dst in by_id:
outgoing[src].add(dst)
incoming[dst].add(src)
depths = {node_id: 0 for node_id in by_id}
changed = True
guard = 0
while changed and guard < max(4, len(by_id) * 3):
changed = False
guard += 1
for src, dsts in outgoing.items():
for dst in dsts:
candidate = depths[src] + 1
if candidate > depths[dst]:
depths[dst] = candidate
changed = True
columns: dict[int, list[dict]] = {}
for node_id, node in by_id.items():
columns.setdefault(depths.get(node_id, 0), []).append(node)
all_x = []
all_y = []
for node in by_id.values():
pos = node.get("position")
all_x.append(float(node.get("x", pos[0] if pos else 0)))
all_y.append(float(node.get("y", pos[1] if pos else 0)))
min_x = min(all_x) if all_x else 0
min_y = min(all_y) if all_y else 0
moves = []
sorted_depths = sorted(columns)
col_x = min_x
for depth in sorted_depths:
column = columns[depth]
column.sort(key=lambda node: float(node.get("y", (node.get("position") or [0, 0])[1])))
max_w = 0
cursor_y = min_y
for node in column:
spec = {"class": node.get("class"), "kind": node.get("kind")}
w, h = estimate_size(spec, domain)
max_w = max(max_w, w)
node_id = node.get("guid") or node.get("name")
moves.append({"guid": node_id, "x": round(col_x), "y": round(cursor_y)})
cursor_y += h + DEFAULT_GAP_Y
col_x += max_w + DEFAULT_GAP_X
return moves