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>
This commit is contained in:
192
ue_side/graph_layout.py
Normal file
192
ue_side/graph_layout.py
Normal file
@ -0,0 +1,192 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user