Files
unreal-engine-mcp-system-pl…/ue_side/widget_tree.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

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