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>
452 lines
17 KiB
Python
452 lines
17 KiB
Python
"""UMG Designer / WidgetTree ops.
|
|
|
|
Ops:
|
|
describe {bp_path}
|
|
get_props {bp_path, widget}
|
|
get_slot_props {bp_path, widget}
|
|
add_widget {bp_path, class, name?, parent?, index?, is_variable?, properties?, canvas_slot?}
|
|
remove_widget {bp_path, widget}
|
|
rename_widget {bp_path, widget, new_name}
|
|
move_widget {bp_path, widget, parent?, index?}
|
|
set_root {bp_path, widget}
|
|
set_is_variable {bp_path, widget, is_variable}
|
|
set_property {bp_path, widget, property, value}
|
|
set_properties {bp_path, widget, properties}
|
|
set_slot_property {bp_path, widget, property, value}
|
|
set_canvas_slot {bp_path, widget, position?, size?, anchors_min?, anchors_max?, alignment?, auto_size?, z_order?}
|
|
set_named_slot {bp_path, host, slot, content}
|
|
clear_named_slot {bp_path, host, slot}
|
|
set_desired_focus {bp_path, widget}
|
|
apply_tree {bp_path, clear_existing?, widgets:[...], desired_focus?}
|
|
compile_save {bp_path}
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import traceback
|
|
|
|
try:
|
|
import unreal # type: ignore
|
|
except ImportError:
|
|
unreal = None
|
|
|
|
|
|
def _lib():
|
|
L = getattr(unreal, "MCPGraphLibrary", None)
|
|
if L is None:
|
|
raise RuntimeError("MCPGraphLibrary missing - rebuild C++ plugin")
|
|
return L
|
|
|
|
|
|
def _load_widget_bp(path: str):
|
|
asset = unreal.EditorAssetLibrary.load_asset(path)
|
|
if not isinstance(asset, unreal.WidgetBlueprint):
|
|
raise RuntimeError(f"not a WidgetBlueprint: {path!r}")
|
|
return asset
|
|
|
|
|
|
def _load_widget_class(path_or_name: str):
|
|
if not path_or_name:
|
|
raise RuntimeError("widget class required")
|
|
if "/" in path_or_name:
|
|
cls = unreal.load_class(None, path_or_name)
|
|
if cls:
|
|
return cls
|
|
for prefix in (
|
|
"/Script/UMG.",
|
|
"/Script/CommonUI.",
|
|
"/Script/AdvancedWidgets.",
|
|
"/Script/Engine.",
|
|
):
|
|
cls = unreal.load_class(None, prefix + path_or_name)
|
|
if cls:
|
|
return cls
|
|
raise RuntimeError(f"widget class not found: {path_or_name!r}")
|
|
|
|
|
|
def _vec2(value, default):
|
|
if value is None:
|
|
value = default
|
|
return unreal.Vector2D(float(value[0]), float(value[1]))
|
|
|
|
|
|
def _json(raw: str) -> dict:
|
|
return json.loads(raw or "{}")
|
|
|
|
|
|
def _save(bp):
|
|
unreal.BlueprintEditorLibrary.compile_blueprint(bp)
|
|
unreal.EditorAssetLibrary.save_loaded_asset(bp)
|
|
|
|
|
|
def describe(args: dict) -> dict:
|
|
bp = _load_widget_bp(args["bp_path"])
|
|
return {"ok": True, **_json(_lib().describe_widget_tree_json(bp))}
|
|
|
|
|
|
def get_props(args: dict) -> dict:
|
|
bp = _load_widget_bp(args["bp_path"])
|
|
return {"ok": True, "widget": args["widget"], **_json(_lib().get_widget_properties_json(bp, args["widget"]))}
|
|
|
|
|
|
def get_slot_props(args: dict) -> dict:
|
|
bp = _load_widget_bp(args["bp_path"])
|
|
return {"ok": True, "widget": args["widget"], **_json(_lib().get_widget_slot_properties_json(bp, args["widget"]))}
|
|
|
|
|
|
def _set_props(bp, widget_name: str, props: dict) -> list[str]:
|
|
warnings = []
|
|
for key, value in (props or {}).items():
|
|
ok = bool(_lib().set_widget_property(bp, widget_name, str(key), str(value)))
|
|
if not ok:
|
|
warnings.append(f"set_widget_property failed: {widget_name}.{key}")
|
|
return warnings
|
|
|
|
|
|
def _set_canvas_slot(bp, widget_name: str, slot: dict) -> bool:
|
|
return bool(_lib().set_canvas_slot_layout(
|
|
bp,
|
|
widget_name,
|
|
_vec2(slot.get("position"), (0, 0)),
|
|
_vec2(slot.get("size"), (100, 40)),
|
|
_vec2(slot.get("anchors_min"), (0, 0)),
|
|
_vec2(slot.get("anchors_max"), (0, 0)),
|
|
_vec2(slot.get("alignment"), (0, 0)),
|
|
bool(slot.get("auto_size", False)),
|
|
int(slot.get("z_order", 0)),
|
|
))
|
|
|
|
|
|
def add_widget(args: dict) -> dict:
|
|
bp = _load_widget_bp(args["bp_path"])
|
|
cls = _load_widget_class(args["class"])
|
|
name = _lib().add_widget_to_tree(
|
|
bp,
|
|
cls,
|
|
args.get("name") or "",
|
|
args.get("parent") or "",
|
|
int(args.get("index", -1)),
|
|
bool(args.get("is_variable", True)),
|
|
)
|
|
if not name:
|
|
return {"ok": False, "error": "add_widget_to_tree failed"}
|
|
warnings = _set_props(bp, name, args.get("properties") or {})
|
|
if args.get("canvas_slot") and not _set_canvas_slot(bp, name, args["canvas_slot"]):
|
|
warnings.append(f"set_canvas_slot failed: {name}")
|
|
_save(bp)
|
|
return {"ok": True, "bp_path": args["bp_path"], "widget": name, "warnings": warnings}
|
|
|
|
|
|
def remove_widget(args: dict) -> dict:
|
|
bp = _load_widget_bp(args["bp_path"])
|
|
ok = bool(_lib().remove_widget_from_tree(bp, args["widget"]))
|
|
_save(bp)
|
|
return {"ok": ok, "widget": args["widget"]}
|
|
|
|
|
|
def rename_widget(args: dict) -> dict:
|
|
bp = _load_widget_bp(args["bp_path"])
|
|
ok = bool(_lib().rename_widget_in_tree(bp, args["widget"], args["new_name"]))
|
|
_save(bp)
|
|
return {"ok": ok, "widget": args["widget"], "new_name": args["new_name"]}
|
|
|
|
|
|
def move_widget(args: dict) -> dict:
|
|
bp = _load_widget_bp(args["bp_path"])
|
|
ok = bool(_lib().move_widget_in_tree(bp, args["widget"], args.get("parent") or "", int(args.get("index", -1))))
|
|
_save(bp)
|
|
return {"ok": ok, "widget": args["widget"], "parent": args.get("parent") or ""}
|
|
|
|
|
|
def set_root(args: dict) -> dict:
|
|
bp = _load_widget_bp(args["bp_path"])
|
|
ok = bool(_lib().set_root_widget(bp, args["widget"]))
|
|
_save(bp)
|
|
return {"ok": ok, "root": args["widget"]}
|
|
|
|
|
|
def set_is_variable(args: dict) -> dict:
|
|
bp = _load_widget_bp(args["bp_path"])
|
|
ok = bool(_lib().set_widget_is_variable(bp, args["widget"], bool(args.get("is_variable", True))))
|
|
_save(bp)
|
|
return {"ok": ok, "widget": args["widget"], "is_variable": bool(args.get("is_variable", True))}
|
|
|
|
|
|
def set_property(args: dict) -> dict:
|
|
bp = _load_widget_bp(args["bp_path"])
|
|
ok = bool(_lib().set_widget_property(bp, args["widget"], args["property"], str(args.get("value", ""))))
|
|
_save(bp)
|
|
return {"ok": ok, "widget": args["widget"], "property": args["property"]}
|
|
|
|
|
|
def set_properties(args: dict) -> dict:
|
|
bp = _load_widget_bp(args["bp_path"])
|
|
warnings = _set_props(bp, args["widget"], args.get("properties") or {})
|
|
_save(bp)
|
|
return {"ok": not warnings, "widget": args["widget"], "warnings": warnings}
|
|
|
|
|
|
def set_slot_property(args: dict) -> dict:
|
|
bp = _load_widget_bp(args["bp_path"])
|
|
ok = bool(_lib().set_widget_slot_property(bp, args["widget"], args["property"], str(args.get("value", ""))))
|
|
_save(bp)
|
|
return {"ok": ok, "widget": args["widget"], "property": args["property"]}
|
|
|
|
|
|
def set_canvas_slot(args: dict) -> dict:
|
|
bp = _load_widget_bp(args["bp_path"])
|
|
ok = _set_canvas_slot(bp, args["widget"], args)
|
|
_save(bp)
|
|
return {"ok": ok, "widget": args["widget"]}
|
|
|
|
|
|
def set_named_slot(args: dict) -> dict:
|
|
bp = _load_widget_bp(args["bp_path"])
|
|
ok = bool(_lib().set_named_slot_content(bp, args["host"], args["slot"], args["content"]))
|
|
_save(bp)
|
|
return {"ok": ok, "host": args["host"], "slot": args["slot"], "content": args["content"]}
|
|
|
|
|
|
def clear_named_slot(args: dict) -> dict:
|
|
bp = _load_widget_bp(args["bp_path"])
|
|
ok = bool(_lib().clear_named_slot_content(bp, args["host"], args["slot"]))
|
|
_save(bp)
|
|
return {"ok": ok, "host": args["host"], "slot": args["slot"]}
|
|
|
|
|
|
def set_desired_focus(args: dict) -> dict:
|
|
bp = _load_widget_bp(args["bp_path"])
|
|
ok = bool(_lib().set_desired_focus_widget(bp, args["widget"]))
|
|
_save(bp)
|
|
return {"ok": ok, "widget": args["widget"]}
|
|
|
|
|
|
def apply_tree(args: dict) -> dict:
|
|
bp = _load_widget_bp(args["bp_path"])
|
|
warnings = []
|
|
created = []
|
|
if args.get("clear_existing"):
|
|
tree = _json(_lib().describe_widget_tree_json(bp))
|
|
root = tree.get("root")
|
|
if root:
|
|
_lib().remove_widget_from_tree(bp, root)
|
|
|
|
for spec in args.get("widgets") or []:
|
|
cls = _load_widget_class(spec["class"])
|
|
name = _lib().add_widget_to_tree(
|
|
bp,
|
|
cls,
|
|
spec.get("name") or "",
|
|
spec.get("parent") or "",
|
|
int(spec.get("index", -1)),
|
|
bool(spec.get("is_variable", True)),
|
|
)
|
|
if not name:
|
|
warnings.append(f"add failed: {spec.get('name') or spec.get('class')}")
|
|
continue
|
|
created.append(name)
|
|
warnings.extend(_set_props(bp, name, spec.get("properties") or {}))
|
|
if spec.get("canvas_slot") and not _set_canvas_slot(bp, name, spec["canvas_slot"]):
|
|
warnings.append(f"set_canvas_slot failed: {name}")
|
|
|
|
if args.get("desired_focus"):
|
|
if not _lib().set_desired_focus_widget(bp, args["desired_focus"]):
|
|
warnings.append(f"set_desired_focus failed: {args['desired_focus']}")
|
|
|
|
_save(bp)
|
|
return {"ok": not warnings, "created": created, "warnings": warnings, "tree": _json(_lib().describe_widget_tree_json(bp))}
|
|
|
|
|
|
def compile_save(args: dict) -> dict:
|
|
bp = _load_widget_bp(args["bp_path"])
|
|
_save(bp)
|
|
return {"ok": True, "bp_path": args["bp_path"]}
|
|
|
|
|
|
def create_animation(args: dict) -> dict:
|
|
"""Create a UMG WidgetAnimation that keys one float property on a widget.
|
|
|
|
{bp_path, name, widget, property?='RenderOpacity', times?, values?}
|
|
times/values are parallel lists (seconds / value). Omit both for a default
|
|
blink pulse (1.0 -> 0.12 -> 1.0 over 0.9s). Loop is set at PlayAnimation time
|
|
(NumLoopsToPlay=0), not stored on the animation.
|
|
"""
|
|
bp = _load_widget_bp(args["bp_path"])
|
|
name = args["name"]
|
|
widget = args["widget"]
|
|
prop = args.get("property") or "RenderOpacity"
|
|
times = args.get("times")
|
|
values = args.get("values")
|
|
if isinstance(times, str):
|
|
times = json.loads(times)
|
|
if isinstance(values, str):
|
|
values = json.loads(values)
|
|
if not times or not values:
|
|
times = [0.0, 0.45, 0.9]
|
|
values = [1.0, 0.12, 1.0]
|
|
if len(times) != len(values):
|
|
return {"ok": False, "error": "times and values must be the same length"}
|
|
times = [float(t) for t in times]
|
|
values = [float(v) for v in values]
|
|
ok = bool(_lib().create_widget_float_animation(bp, name, widget, prop, times, values))
|
|
if ok:
|
|
_save(bp)
|
|
return {"ok": ok, "name": name, "widget": widget, "property": prop,
|
|
"keys": list(zip(times, values))}
|
|
|
|
|
|
def screenshot(args: dict) -> dict:
|
|
"""Render a WidgetBlueprint to a PNG file. Vision loop for AI iteration.
|
|
|
|
Strategy stack (first that works wins):
|
|
1) unreal.WidgetRenderer -> RenderTarget -> ExportRenderTarget (clean, widget-only)
|
|
2) Open the WBP in the UMG editor and use HighResShot (captures editor chrome)
|
|
|
|
Args:
|
|
bp_path: /Game/.../WBP_Foo
|
|
size: [w, h] default [1920, 1080]
|
|
out_dir: project-relative folder under Saved/ default 'MCP/widgets'
|
|
Returns:
|
|
{ ok, png_path, file_url, strategy }
|
|
"""
|
|
bp_path = args["bp_path"]
|
|
bp = _load_widget_bp(bp_path)
|
|
if bp is None:
|
|
return {"ok": False, "error": "widget bp not found"}
|
|
size = args.get("size") or [1920, 1080]
|
|
w, h = int(size[0]), int(size[1])
|
|
proj_saved = unreal.Paths.project_saved_dir()
|
|
out_subdir = (args.get("out_dir") or "MCP/widgets").strip("/")
|
|
out_dir = proj_saved + out_subdir + "/"
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
safe_name = bp_path.rsplit("/", 1)[-1]
|
|
png_name = f"{safe_name}.png"
|
|
|
|
# ---- Strategy 0: native C++ helper (best — pure widget, no editor chrome) ----
|
|
if hasattr(unreal, "MCPWidgetRenderLibrary"):
|
|
try:
|
|
widget_class = bp.generated_class()
|
|
ok, full_path, err = unreal.MCPWidgetRenderLibrary.render_widget_to_png(
|
|
widget_class, out_dir.rstrip("/").replace("/", os.sep), safe_name, w, h
|
|
)
|
|
if ok:
|
|
return {"ok": True, "strategy": "MCPWidgetRenderLibrary",
|
|
"png_path": full_path,
|
|
"file_url": "file:///" + full_path.replace("\\", "/"),
|
|
"size": [w, h]}
|
|
cpp_err = err or "unknown C++ error"
|
|
except Exception as e:
|
|
cpp_err = str(e)
|
|
else:
|
|
cpp_err = "MCPWidgetRenderLibrary not exposed — Live-Coding compile the plugin"
|
|
|
|
# ---- Strategy 1: WidgetRenderer (if exposed) ----
|
|
if hasattr(unreal, "WidgetRenderer"):
|
|
try:
|
|
world = unreal.EditorLevelLibrary.get_editor_world() if hasattr(unreal, "EditorLevelLibrary") else None
|
|
widget_class = bp.generated_class()
|
|
instance = unreal.create_widget(world, widget_class) if world else None
|
|
if instance is None:
|
|
raise RuntimeError("create_widget returned None")
|
|
# Transient render target
|
|
rt_factory = getattr(unreal, "TextureRenderTargetFactoryNew", None)
|
|
if rt_factory is None:
|
|
raise RuntimeError("TextureRenderTargetFactoryNew unavailable")
|
|
rt = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
|
|
f"RT_MCP_{safe_name}", "/Game/_MCPTransient", unreal.TextureRenderTarget2D, rt_factory()
|
|
)
|
|
rt.set_editor_property("size_x", w)
|
|
rt.set_editor_property("size_y", h)
|
|
rt.set_editor_property("render_target_format", unreal.TextureRenderTargetFormat.RTF_RGBA8)
|
|
renderer = unreal.WidgetRenderer()
|
|
renderer.draw_widget(instance, rt, 0.016)
|
|
unreal.RenderingLibrary.export_render_target(world, rt, out_dir, png_name)
|
|
# Cleanup transient RT (best effort)
|
|
try:
|
|
unreal.EditorAssetLibrary.delete_asset(rt.get_path_name())
|
|
except Exception:
|
|
pass
|
|
full = out_dir + png_name
|
|
return {"ok": True, "strategy": "WidgetRenderer", "png_path": full,
|
|
"file_url": "file:///" + full.replace("\\", "/"),
|
|
"size": [w, h]}
|
|
except Exception as e:
|
|
renderer_err = str(e)
|
|
else:
|
|
renderer_err = "WidgetRenderer not exposed in this UE build"
|
|
|
|
# ---- Strategy 2: open in UMG editor + HighResShot ----
|
|
try:
|
|
aes = unreal.get_editor_subsystem(unreal.AssetEditorSubsystem)
|
|
aes.open_editor_for_assets([bp])
|
|
# Force a screenshot of the editor window
|
|
world = unreal.EditorLevelLibrary.get_editor_world()
|
|
cmd = f"HighResShot {w}x{h} {png_name}"
|
|
unreal.SystemLibrary.execute_console_command(world, cmd)
|
|
# UE saves under Saved/Screenshots/Windows/<png_name>
|
|
guessed = proj_saved + f"Screenshots/Windows/{png_name}"
|
|
return {"ok": True, "strategy": "HighResShot_editor_chrome",
|
|
"warning": f"widget-only render failed: {renderer_err}. Editor chrome included.",
|
|
"png_path": guessed,
|
|
"file_url": "file:///" + guessed.replace("\\", "/"),
|
|
"size": [w, h]}
|
|
except Exception as e:
|
|
return {"ok": False, "error": f"all strategies failed; cpp={cpp_err}; renderer={renderer_err}; screenshot={e}"}
|
|
|
|
|
|
OPS = {
|
|
"describe": describe,
|
|
"get_props": get_props,
|
|
"get_slot_props": get_slot_props,
|
|
"add_widget": add_widget,
|
|
"remove_widget": remove_widget,
|
|
"rename_widget": rename_widget,
|
|
"move_widget": move_widget,
|
|
"set_root": set_root,
|
|
"set_is_variable": set_is_variable,
|
|
"set_property": set_property,
|
|
"set_properties": set_properties,
|
|
"set_slot_property": set_slot_property,
|
|
"set_canvas_slot": set_canvas_slot,
|
|
"set_named_slot": set_named_slot,
|
|
"clear_named_slot": clear_named_slot,
|
|
"set_desired_focus": set_desired_focus,
|
|
"apply_tree": apply_tree,
|
|
"compile_save": compile_save,
|
|
"create_animation": create_animation,
|
|
"screenshot": screenshot,
|
|
}
|
|
|
|
|
|
def entrypoint(payload_json: str) -> str:
|
|
try:
|
|
p = json.loads(payload_json)
|
|
except Exception as e: # noqa: BLE001
|
|
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
|
fn = OPS.get(p.get("op"))
|
|
if not fn:
|
|
return _tee({"ok": False, "error": f"unknown op {p.get('op')!r}", "available": list(OPS)}, p)
|
|
try:
|
|
return _tee(fn(p), p)
|
|
except Exception as e: # noqa: BLE001
|
|
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
|
|
|
|
|
def _tee(result: dict, p: dict) -> str:
|
|
txt = json.dumps(result)
|
|
try:
|
|
if unreal is not None:
|
|
sd = unreal.Paths.project_saved_dir() + "MCP/"
|
|
os.makedirs(sd, exist_ok=True)
|
|
rid = p.get("__rid") or "last"
|
|
with open(sd + "last.json", "w", encoding="utf-8") as f:
|
|
f.write(txt)
|
|
if rid != "last":
|
|
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
|
|
f.write(txt)
|
|
unreal.log("[MCP-WIDGET] " + txt[:1500])
|
|
except Exception:
|
|
pass
|
|
return txt
|