"""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/ 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