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>
193 lines
7.0 KiB
Python
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
|