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:
215
ue_side/ai.py
Normal file
215
ue_side/ai.py
Normal file
@ -0,0 +1,215 @@
|
||||
"""AI ops — BehaviorTree, Blackboard, EQS, AIController.
|
||||
|
||||
Ops:
|
||||
create_behavior_tree {path, blackboard?}
|
||||
create_blackboard {path, parent?}
|
||||
create_eqs_query {path}
|
||||
create_aicontroller_bp {path, parent_class?:'AIController'}
|
||||
add_blackboard_key {path, name, type:'Bool|Int|Float|String|Name|Vector|Rotator|Object|Class|Enum', base_class?}
|
||||
remove_blackboard_key {path, name}
|
||||
list_blackboard_keys {path}
|
||||
set_bt_blackboard {bt_path, blackboard_path}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _at(): return unreal.AssetToolsHelpers.get_asset_tools()
|
||||
def _load(p): return unreal.EditorAssetLibrary.load_asset(p)
|
||||
def _split(p):
|
||||
folder, name = p.rsplit("/", 1)
|
||||
return folder, name
|
||||
|
||||
|
||||
KEY_TYPES = {
|
||||
"Bool": "BlackboardKeyType_Bool",
|
||||
"Int": "BlackboardKeyType_Int",
|
||||
"Float": "BlackboardKeyType_Float",
|
||||
"String": "BlackboardKeyType_String",
|
||||
"Name": "BlackboardKeyType_Name",
|
||||
"Vector": "BlackboardKeyType_Vector",
|
||||
"Rotator": "BlackboardKeyType_Rotator",
|
||||
"Object": "BlackboardKeyType_Object",
|
||||
"Class": "BlackboardKeyType_Class",
|
||||
"Enum": "BlackboardKeyType_Enum",
|
||||
}
|
||||
|
||||
|
||||
def create_behavior_tree(args):
|
||||
folder, name = _split(args["path"])
|
||||
factory_cls = getattr(unreal, "BehaviorTreeFactory", None)
|
||||
if factory_cls is None:
|
||||
return {"ok": False, "error": "BehaviorTreeFactory missing — enable AIModule"}
|
||||
asset = _at().create_asset(name, folder, unreal.BehaviorTree, factory_cls())
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create returned None"}
|
||||
if args.get("blackboard"):
|
||||
bb = _load(args["blackboard"])
|
||||
if bb is not None:
|
||||
asset.set_editor_property("blackboard_asset", bb)
|
||||
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def create_blackboard(args):
|
||||
folder, name = _split(args["path"])
|
||||
factory_cls = getattr(unreal, "BlackboardDataFactory", None)
|
||||
if factory_cls is None:
|
||||
return {"ok": False, "error": "BlackboardDataFactory missing"}
|
||||
asset = _at().create_asset(name, folder, unreal.BlackboardData, factory_cls())
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create returned None"}
|
||||
if args.get("parent"):
|
||||
parent = _load(args["parent"])
|
||||
if parent is not None:
|
||||
try:
|
||||
asset.set_editor_property("parent", parent)
|
||||
except Exception:
|
||||
pass
|
||||
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def create_eqs_query(args):
|
||||
folder, name = _split(args["path"])
|
||||
factory_cls = getattr(unreal, "EnvQueryFactory", None)
|
||||
asset_cls = getattr(unreal, "EnvQuery", None)
|
||||
if factory_cls is None or asset_cls is None:
|
||||
return {"ok": False, "error": "EnvQuery factory missing — enable EQS in project settings"}
|
||||
asset = _at().create_asset(name, folder, asset_cls, factory_cls())
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create returned None"}
|
||||
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def create_aicontroller_bp(args):
|
||||
folder, name = _split(args["path"])
|
||||
parent_name = args.get("parent_class") or "AIController"
|
||||
parent = unreal.load_class(None, "/Script/AIModule." + parent_name)
|
||||
if parent is None:
|
||||
parent = unreal.load_class(None, parent_name)
|
||||
if parent is None:
|
||||
return {"ok": False, "error": f"parent {parent_name!r} not found"}
|
||||
factory = unreal.BlueprintFactory()
|
||||
factory.set_editor_property("parent_class", parent)
|
||||
asset = _at().create_asset(name, folder, unreal.Blueprint, factory)
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create returned None"}
|
||||
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def add_blackboard_key(args):
|
||||
bb = _load(args["path"])
|
||||
if bb is None:
|
||||
return {"ok": False, "error": "blackboard not found"}
|
||||
type_name = KEY_TYPES.get(args["type"])
|
||||
if not type_name:
|
||||
return {"ok": False, "error": f"unknown key type {args['type']!r}; valid: {list(KEY_TYPES)}"}
|
||||
key_type_cls = getattr(unreal, type_name, None)
|
||||
if key_type_cls is None:
|
||||
return {"ok": False, "error": f"{type_name} not exposed"}
|
||||
entry_cls = getattr(unreal, "BlackboardEntry", None)
|
||||
if entry_cls is None:
|
||||
return {"ok": False, "error": "BlackboardEntry not exposed; edit via UI"}
|
||||
entry = entry_cls()
|
||||
entry.set_editor_property("entry_name", args["name"])
|
||||
kt = key_type_cls()
|
||||
if args.get("base_class") and hasattr(kt, "base_class"):
|
||||
cls = unreal.load_class(None, args["base_class"])
|
||||
if cls is not None:
|
||||
kt.set_editor_property("base_class", cls)
|
||||
entry.set_editor_property("key_type", kt)
|
||||
keys = list(bb.get_editor_property("keys") or [])
|
||||
keys.append(entry)
|
||||
bb.set_editor_property("keys", keys)
|
||||
unreal.EditorAssetLibrary.save_asset(bb.get_path_name())
|
||||
return {"ok": True, "name": args["name"]}
|
||||
|
||||
|
||||
def remove_blackboard_key(args):
|
||||
bb = _load(args["path"])
|
||||
if bb is None:
|
||||
return {"ok": False, "error": "blackboard not found"}
|
||||
target = args["name"]
|
||||
keys = [k for k in (bb.get_editor_property("keys") or [])
|
||||
if str(k.get_editor_property("entry_name")) != target]
|
||||
bb.set_editor_property("keys", keys)
|
||||
unreal.EditorAssetLibrary.save_asset(bb.get_path_name())
|
||||
return {"ok": True, "removed": target}
|
||||
|
||||
|
||||
def list_blackboard_keys(args):
|
||||
bb = _load(args["path"])
|
||||
if bb is None:
|
||||
return {"ok": False, "error": "blackboard not found"}
|
||||
out = []
|
||||
for k in (bb.get_editor_property("keys") or []):
|
||||
try:
|
||||
kt = k.get_editor_property("key_type")
|
||||
out.append({"name": str(k.get_editor_property("entry_name")),
|
||||
"type": kt.get_class().get_name() if kt else None})
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "keys": out}
|
||||
|
||||
|
||||
def set_bt_blackboard(args):
|
||||
bt = _load(args["bt_path"])
|
||||
bb = _load(args["blackboard_path"])
|
||||
if bt is None or bb is None:
|
||||
return {"ok": False, "error": "bt or blackboard not found"}
|
||||
bt.set_editor_property("blackboard_asset", bb)
|
||||
unreal.EditorAssetLibrary.save_asset(bt.get_path_name())
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
OPS = {
|
||||
"create_behavior_tree": create_behavior_tree,
|
||||
"create_blackboard": create_blackboard,
|
||||
"create_eqs_query": create_eqs_query,
|
||||
"create_aicontroller_bp": create_aicontroller_bp,
|
||||
"add_blackboard_key": add_blackboard_key,
|
||||
"remove_blackboard_key": remove_blackboard_key,
|
||||
"list_blackboard_keys": list_blackboard_keys,
|
||||
"set_bt_blackboard": set_bt_blackboard,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json):
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
op = p.get("op"); fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result, p):
|
||||
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-AI] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
386
ue_side/animation.py
Normal file
386
ue_side/animation.py
Normal file
@ -0,0 +1,386 @@
|
||||
"""Animation ops — AnimBlueprint, AnimSequence, AnimMontage, Notifies, Skeleton sockets.
|
||||
|
||||
Ops:
|
||||
create_anim_blueprint {path, skeleton}
|
||||
create_anim_montage {path, sequence?}
|
||||
create_blend_space {path, skeleton, dim?:1|2}
|
||||
get_sequence_info {path} — length, fps, frames, additive type, skeleton
|
||||
list_anim_assets {folder?, skeleton?}
|
||||
list_sockets {skeleton}
|
||||
add_socket {skeleton, name, bone, location?, rotation?}
|
||||
remove_socket {skeleton, name}
|
||||
list_montage_sections {path}
|
||||
add_montage_section {path, name, start_time?}
|
||||
list_notifies {sequence}
|
||||
add_notify {sequence, name, time, notify_class?}
|
||||
remove_notify {sequence, index}
|
||||
list_state_machines {anim_bp} — best-effort (graph names)
|
||||
retarget_animation {src, dst_skeleton, dst_path}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _at():
|
||||
return unreal.AssetToolsHelpers.get_asset_tools()
|
||||
|
||||
|
||||
def _load(path: str):
|
||||
return unreal.EditorAssetLibrary.load_asset(path)
|
||||
|
||||
|
||||
def _split_pkg(path: str):
|
||||
folder, name = path.rsplit("/", 1)
|
||||
return folder, name
|
||||
|
||||
|
||||
def _vec(v, default=(0, 0, 0)):
|
||||
if v is None:
|
||||
v = default
|
||||
return unreal.Vector(float(v[0]), float(v[1]), float(v[2]))
|
||||
|
||||
|
||||
def _rot(v, default=(0, 0, 0)):
|
||||
if v is None:
|
||||
v = default
|
||||
return unreal.Rotator(float(v[0]), float(v[1]), float(v[2]))
|
||||
|
||||
|
||||
def create_anim_blueprint(args: dict) -> dict:
|
||||
skel = _load(args["skeleton"])
|
||||
if skel is None:
|
||||
return {"ok": False, "error": f"skeleton not found: {args['skeleton']}"}
|
||||
folder, name = _split_pkg(args["path"])
|
||||
factory = unreal.AnimBlueprintFactory()
|
||||
factory.set_editor_property("target_skeleton", skel)
|
||||
asset = _at().create_asset(name, folder, unreal.AnimBlueprint, factory)
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create_asset returned None"}
|
||||
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def create_anim_montage(args: dict) -> dict:
|
||||
folder, name = _split_pkg(args["path"])
|
||||
factory = unreal.AnimMontageFactory()
|
||||
seq_path = args.get("sequence")
|
||||
if seq_path:
|
||||
seq = _load(seq_path)
|
||||
if seq is None:
|
||||
return {"ok": False, "error": f"sequence not found: {seq_path}"}
|
||||
if hasattr(factory, "set_editor_property"):
|
||||
try:
|
||||
factory.set_editor_property("preview_skeletal_mesh", seq.get_editor_property("skeleton"))
|
||||
except Exception:
|
||||
pass
|
||||
asset = _at().create_asset(name, folder, unreal.AnimMontage, factory)
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create_asset returned None"}
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def create_blend_space(args: dict) -> dict:
|
||||
skel = _load(args["skeleton"])
|
||||
if skel is None:
|
||||
return {"ok": False, "error": "skeleton not found"}
|
||||
folder, name = _split_pkg(args["path"])
|
||||
dim = int(args.get("dim") or 2)
|
||||
factory_cls = getattr(unreal, "BlendSpaceFactory1D" if dim == 1 else "BlendSpaceFactoryNew", None)
|
||||
if factory_cls is None:
|
||||
return {"ok": False, "error": "BlendSpace factory unavailable in this UE build"}
|
||||
factory = factory_cls()
|
||||
if hasattr(factory, "target_skeleton"):
|
||||
factory.target_skeleton = skel
|
||||
asset_cls = unreal.BlendSpace1D if dim == 1 else unreal.BlendSpace
|
||||
asset = _at().create_asset(name, folder, asset_cls, factory)
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create_asset returned None"}
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def get_sequence_info(args: dict) -> dict:
|
||||
seq = _load(args["path"])
|
||||
if seq is None:
|
||||
return {"ok": False, "error": "not found"}
|
||||
out = {"ok": True, "path": args["path"], "class": seq.get_class().get_name()}
|
||||
for attr in ("get_play_length", "sequence_length"):
|
||||
try:
|
||||
v = getattr(seq, attr)
|
||||
out["length"] = v() if callable(v) else float(v)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
out["num_frames"] = int(seq.get_editor_property("number_of_sampled_keys") or 0)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
out["frame_rate"] = float(seq.get_editor_property("target_frame_rate") or 0)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
skel = seq.get_editor_property("skeleton")
|
||||
if skel:
|
||||
out["skeleton"] = skel.get_path_name().split(".")[0]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
out["additive_type"] = str(seq.get_editor_property("additive_anim_type"))
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def list_anim_assets(args: dict) -> dict:
|
||||
folder = args.get("folder") or "/Game"
|
||||
skel_filter = args.get("skeleton")
|
||||
ar = unreal.AssetRegistryHelpers.get_asset_registry()
|
||||
filt = unreal.ARFilter(package_paths=[folder], recursive_paths=True,
|
||||
class_names=["AnimSequence", "AnimMontage", "BlendSpace", "BlendSpace1D", "AnimBlueprint"])
|
||||
items = []
|
||||
for d in ar.get_assets(filt):
|
||||
path = str(d.package_name)
|
||||
if skel_filter:
|
||||
try:
|
||||
asset = _load(path)
|
||||
sk = asset.get_editor_property("skeleton") if asset else None
|
||||
if not sk or sk.get_path_name().split(".")[0] != skel_filter:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
items.append({"path": path, "class": str(d.asset_class)})
|
||||
return {"ok": True, "count": len(items), "items": items}
|
||||
|
||||
|
||||
def list_sockets(args: dict) -> dict:
|
||||
skel = _load(args["skeleton"])
|
||||
if skel is None:
|
||||
return {"ok": False, "error": "skeleton not found"}
|
||||
try:
|
||||
sockets = skel.get_editor_property("sockets") or []
|
||||
except Exception:
|
||||
sockets = []
|
||||
out = []
|
||||
for s in sockets:
|
||||
try:
|
||||
out.append({
|
||||
"name": str(s.get_editor_property("socket_name")),
|
||||
"bone": str(s.get_editor_property("bone_name")),
|
||||
"location": list(s.get_editor_property("relative_location")),
|
||||
"rotation": list(s.get_editor_property("relative_rotation")),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "count": len(out), "sockets": out}
|
||||
|
||||
|
||||
def add_socket(args: dict) -> dict:
|
||||
skel = _load(args["skeleton"])
|
||||
if skel is None:
|
||||
return {"ok": False, "error": "skeleton not found"}
|
||||
sock = unreal.SkeletalMeshSocket()
|
||||
sock.set_editor_property("socket_name", args["name"])
|
||||
sock.set_editor_property("bone_name", args["bone"])
|
||||
sock.set_editor_property("relative_location", _vec(args.get("location")))
|
||||
sock.set_editor_property("relative_rotation", _rot(args.get("rotation")))
|
||||
sockets = list(skel.get_editor_property("sockets") or [])
|
||||
sockets.append(sock)
|
||||
skel.set_editor_property("sockets", sockets)
|
||||
unreal.EditorAssetLibrary.save_asset(skel.get_path_name())
|
||||
return {"ok": True, "name": args["name"]}
|
||||
|
||||
|
||||
def remove_socket(args: dict) -> dict:
|
||||
skel = _load(args["skeleton"])
|
||||
if skel is None:
|
||||
return {"ok": False, "error": "skeleton not found"}
|
||||
target = args["name"]
|
||||
sockets = [s for s in (skel.get_editor_property("sockets") or [])
|
||||
if str(s.get_editor_property("socket_name")) != target]
|
||||
skel.set_editor_property("sockets", sockets)
|
||||
unreal.EditorAssetLibrary.save_asset(skel.get_path_name())
|
||||
return {"ok": True, "removed": target}
|
||||
|
||||
|
||||
def list_montage_sections(args: dict) -> dict:
|
||||
m = _load(args["path"])
|
||||
if m is None:
|
||||
return {"ok": False, "error": "not found"}
|
||||
try:
|
||||
secs = m.get_editor_property("composite_sections") or []
|
||||
except Exception:
|
||||
secs = []
|
||||
out = []
|
||||
for s in secs:
|
||||
try:
|
||||
out.append({"name": str(s.get_editor_property("section_name")),
|
||||
"start_time": float(s.get_editor_property("segment_begin_time") or 0)})
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "sections": out}
|
||||
|
||||
|
||||
def add_montage_section(args: dict) -> dict:
|
||||
m = _load(args["path"])
|
||||
if m is None:
|
||||
return {"ok": False, "error": "not found"}
|
||||
sec = unreal.CompositeSection() if hasattr(unreal, "CompositeSection") else None
|
||||
if sec is None:
|
||||
return {"ok": False, "error": "CompositeSection not exposed in this UE build"}
|
||||
sec.set_editor_property("section_name", args["name"])
|
||||
sec.set_editor_property("segment_begin_time", float(args.get("start_time") or 0))
|
||||
secs = list(m.get_editor_property("composite_sections") or [])
|
||||
secs.append(sec)
|
||||
m.set_editor_property("composite_sections", secs)
|
||||
unreal.EditorAssetLibrary.save_asset(m.get_path_name())
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def list_notifies(args: dict) -> dict:
|
||||
seq = _load(args["sequence"])
|
||||
if seq is None:
|
||||
return {"ok": False, "error": "not found"}
|
||||
try:
|
||||
notifies = seq.get_editor_property("notifies") or []
|
||||
except Exception:
|
||||
notifies = []
|
||||
out = []
|
||||
for n in notifies:
|
||||
try:
|
||||
cls = n.get_editor_property("notify")
|
||||
out.append({
|
||||
"name": str(n.get_editor_property("notify_name")),
|
||||
"time": float(n.get_editor_property("trigger_time_offset") or 0) + float(n.get_editor_property("link_value") or 0),
|
||||
"class": cls.get_class().get_name() if cls else None,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "notifies": out}
|
||||
|
||||
|
||||
def add_notify(args: dict) -> dict:
|
||||
seq = _load(args["sequence"])
|
||||
if seq is None:
|
||||
return {"ok": False, "error": "not found"}
|
||||
ev = unreal.AnimNotifyEvent() if hasattr(unreal, "AnimNotifyEvent") else None
|
||||
if ev is None:
|
||||
return {"ok": False, "error": "AnimNotifyEvent not exposed; use editor UI"}
|
||||
ev.set_editor_property("notify_name", args["name"])
|
||||
ev.set_editor_property("link_value", float(args["time"]))
|
||||
notifies = list(seq.get_editor_property("notifies") or [])
|
||||
notifies.append(ev)
|
||||
seq.set_editor_property("notifies", notifies)
|
||||
unreal.EditorAssetLibrary.save_asset(seq.get_path_name())
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def remove_notify(args: dict) -> dict:
|
||||
seq = _load(args["sequence"])
|
||||
if seq is None:
|
||||
return {"ok": False, "error": "not found"}
|
||||
notifies = list(seq.get_editor_property("notifies") or [])
|
||||
idx = int(args["index"])
|
||||
if idx < 0 or idx >= len(notifies):
|
||||
return {"ok": False, "error": "index out of range"}
|
||||
notifies.pop(idx)
|
||||
seq.set_editor_property("notifies", notifies)
|
||||
unreal.EditorAssetLibrary.save_asset(seq.get_path_name())
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def list_state_machines(args: dict) -> dict:
|
||||
bp = _load(args["anim_bp"])
|
||||
if bp is None:
|
||||
return {"ok": False, "error": "not found"}
|
||||
out = []
|
||||
try:
|
||||
for g in bp.get_editor_property("ubergraph_pages") or []:
|
||||
out.append(g.get_name())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
for g in bp.get_editor_property("function_graphs") or []:
|
||||
out.append(g.get_name())
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "graphs": out}
|
||||
|
||||
|
||||
def retarget_animation(args: dict) -> dict:
|
||||
src = _load(args["src"])
|
||||
dst_skel = _load(args["dst_skeleton"])
|
||||
if src is None or dst_skel is None:
|
||||
return {"ok": False, "error": "src or dst_skeleton not found"}
|
||||
folder, name = _split_pkg(args["dst_path"])
|
||||
try:
|
||||
new_asset = unreal.AnimationLibrary.duplicate_animation_asset(src, name, folder) \
|
||||
if hasattr(unreal, "AnimationLibrary") and hasattr(unreal.AnimationLibrary, "duplicate_animation_asset") \
|
||||
else unreal.EditorAssetLibrary.duplicate_asset(src.get_path_name(), folder + "/" + name)
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"duplicate failed: {e}"}
|
||||
try:
|
||||
new_asset.set_editor_property("skeleton", dst_skel)
|
||||
except Exception:
|
||||
pass
|
||||
unreal.EditorAssetLibrary.save_asset(new_asset.get_path_name())
|
||||
return {"ok": True, "path": new_asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
OPS = {
|
||||
"create_anim_blueprint": create_anim_blueprint,
|
||||
"create_anim_montage": create_anim_montage,
|
||||
"create_blend_space": create_blend_space,
|
||||
"get_sequence_info": get_sequence_info,
|
||||
"list_anim_assets": list_anim_assets,
|
||||
"list_sockets": list_sockets,
|
||||
"add_socket": add_socket,
|
||||
"remove_socket": remove_socket,
|
||||
"list_montage_sections": list_montage_sections,
|
||||
"add_montage_section": add_montage_section,
|
||||
"list_notifies": list_notifies,
|
||||
"add_notify": add_notify,
|
||||
"remove_notify": remove_notify,
|
||||
"list_state_machines": list_state_machines,
|
||||
"retarget_animation": retarget_animation,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json: str) -> str:
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
op = p.get("op")
|
||||
fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e:
|
||||
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-ANIM] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
519
ue_side/apply_graph.py
Normal file
519
ue_side/apply_graph.py
Normal file
@ -0,0 +1,519 @@
|
||||
"""
|
||||
UE-side runtime for the Blueprint MCP server.
|
||||
|
||||
Reaches into the editor via `unreal.MCPGraphLibrary` — a C++ BlueprintFunction
|
||||
library provided by Plugins/UEBlueprintMCP/Source. That library exposes K2 node
|
||||
spawning, which is NOT available in stock UE Python.
|
||||
|
||||
Flow per apply():
|
||||
1. Resolve target Blueprint (by path, or via Content Browser selection).
|
||||
2. Resolve target graph (EventGraph by default, else named function).
|
||||
3. For each NodeSpec — call the matching MCPGraphLibrary spawn function and
|
||||
remember NodeGuid keyed by local id.
|
||||
4. Set pin defaults (params).
|
||||
5. For each EdgeSpec — call ConnectPins using NodeGuids.
|
||||
6. Compile + save Blueprint.
|
||||
7. Write the result envelope to <Project>/Saved/MCP/last.json and unreal.log.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
import graph_layout
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError: # pragma: no cover — tests run outside UE
|
||||
unreal = None
|
||||
|
||||
|
||||
def _lib():
|
||||
"""Return the MCPGraphLibrary class, or raise if the plugin isn't built."""
|
||||
L = getattr(unreal, "MCPGraphLibrary", None)
|
||||
if L is None:
|
||||
raise RuntimeError(
|
||||
"unreal.MCPGraphLibrary not found — the UEBlueprintMCP C++ plugin "
|
||||
"is not built. Right-click your .uproject → Generate VS project files, "
|
||||
"then build the editor."
|
||||
)
|
||||
return L
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Blueprint / graph resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
def _load_blueprint(path: str):
|
||||
asset = unreal.EditorAssetLibrary.load_asset(path)
|
||||
if asset is None:
|
||||
raise RuntimeError(f"Blueprint not found at {path}")
|
||||
if not isinstance(asset, unreal.Blueprint):
|
||||
raise RuntimeError(f"Asset at {path} is not a Blueprint ({type(asset).__name__})")
|
||||
return asset
|
||||
|
||||
|
||||
def _active_blueprint():
|
||||
"""Return (Blueprint, path) from Content Browser selection."""
|
||||
selected = []
|
||||
try:
|
||||
selected = list(unreal.EditorUtilityLibrary.get_selected_assets() or [])
|
||||
except Exception:
|
||||
selected = []
|
||||
bps = [a for a in selected if isinstance(a, unreal.Blueprint)]
|
||||
if len(bps) == 1:
|
||||
bp = bps[0]
|
||||
return bp, bp.get_path_name().split(".")[0]
|
||||
if len(bps) > 1:
|
||||
raise RuntimeError(f"{len(bps)} Blueprints selected — select exactly one or pass bp_path")
|
||||
raise RuntimeError(
|
||||
"auto-detect needs one Blueprint selected in Content Browser. "
|
||||
"Click your BP asset and retry, or pass bp_path explicitly."
|
||||
)
|
||||
|
||||
|
||||
def _find_graph(bp, function_name: str):
|
||||
L = _lib()
|
||||
if function_name in (None, "", "EventGraph"):
|
||||
g = L.find_event_graph(bp)
|
||||
if g is None:
|
||||
raise RuntimeError("Blueprint has no EventGraph")
|
||||
return g
|
||||
g = L.find_function_graph(bp, function_name)
|
||||
if g is None:
|
||||
raise RuntimeError(f"Graph '{function_name}' not found on Blueprint")
|
||||
return g
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Function target parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
def _resolve_function_owner(target: str):
|
||||
"""Parse 'KismetSystemLibrary.PrintString' or '/Script/Engine.KismetSystemLibrary:PrintString'.
|
||||
Returns (UClass, FName-able str).
|
||||
"""
|
||||
if ":" in target:
|
||||
cls_path, fn = target.split(":", 1)
|
||||
elif "." in target:
|
||||
cls_path, fn = target.rsplit(".", 1)
|
||||
else:
|
||||
raise RuntimeError(f"call_function target must be Class.Function, got {target!r}")
|
||||
|
||||
cls = None
|
||||
if "/" in cls_path:
|
||||
cls = unreal.load_class(None, cls_path)
|
||||
else:
|
||||
for prefix in ("/Script/Engine.", "/Script/CoreUObject.", "/Script/UMG.",
|
||||
"/Script/GameplayTags.", "/Script/UnrealEd.", "/Script/Kismet."):
|
||||
cls = unreal.load_class(None, prefix + cls_path)
|
||||
if cls is not None:
|
||||
break
|
||||
if cls is None:
|
||||
raise RuntimeError(f"Class not found for target {target!r}")
|
||||
return cls, fn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Apply
|
||||
# ---------------------------------------------------------------------------
|
||||
def apply(graph: dict) -> dict:
|
||||
result = {
|
||||
"ok": False, "spawned": {}, "node_guids": {},
|
||||
"errors": [], "warnings": [], "compile": None,
|
||||
}
|
||||
if unreal is None:
|
||||
result["errors"].append("unreal module not importable — must run inside UE editor")
|
||||
return result
|
||||
|
||||
try:
|
||||
L = _lib()
|
||||
bp_path = graph.get("bp_path") or ""
|
||||
if bp_path:
|
||||
bp = _load_blueprint(bp_path)
|
||||
result["resolved_bp"] = bp_path
|
||||
else:
|
||||
bp, bp_path = _active_blueprint()
|
||||
result["resolved_bp"] = bp_path
|
||||
result["auto_detected"] = True
|
||||
|
||||
function_name = graph.get("function") or "EventGraph"
|
||||
target_graph = _find_graph(bp, function_name)
|
||||
|
||||
# ---- mode handling ----
|
||||
mode = (graph.get("mode") or "append").lower()
|
||||
if mode in ("replace_function", "replace_event"):
|
||||
try:
|
||||
cleared = L.clear_graph(target_graph)
|
||||
result["cleared_nodes"] = int(cleared)
|
||||
except Exception as e: # noqa: BLE001
|
||||
result["warnings"].append(f"clear_graph failed: {e}")
|
||||
|
||||
layout = str(graph.get("layout") or "").lower()
|
||||
auto_layout = bool(graph.get("auto_layout", False))
|
||||
should_layout = auto_layout or layout in ("compact", "columns")
|
||||
|
||||
node_specs = graph_layout.normalize_specs(
|
||||
graph.get("nodes", []),
|
||||
graph.get("edges", []),
|
||||
domain="blueprint",
|
||||
) if should_layout else list(graph.get("nodes", []))
|
||||
|
||||
# ---- spawn nodes ----
|
||||
guid_map: dict[str, str] = {}
|
||||
for spec in node_specs:
|
||||
try:
|
||||
guid = _spawn_one(target_graph, spec, bp, L)
|
||||
if not guid:
|
||||
raise RuntimeError("spawn returned empty guid")
|
||||
guid_map[spec["id"]] = guid
|
||||
result["spawned"][spec["id"]] = guid
|
||||
except Exception as e: # noqa: BLE001
|
||||
result["errors"].append(f"node {spec.get('id')!r}: {e}")
|
||||
|
||||
# ---- post-spawn config (switch cases, format text, sequence pins are spawn-time) ----
|
||||
for spec in node_specs:
|
||||
local_id = spec.get("id")
|
||||
guid = guid_map.get(local_id)
|
||||
if not guid:
|
||||
continue
|
||||
p = spec.get("params") or {}
|
||||
kind = spec.get("kind")
|
||||
|
||||
# FormatText: set Format & reconstruct so {arg} pins appear
|
||||
if kind == "format_text" and "Format" in p:
|
||||
try:
|
||||
L.format_text_set_format(target_graph, guid, str(p["Format"]))
|
||||
except Exception as e: # noqa: BLE001
|
||||
result["warnings"].append(f"format_text_set_format: {local_id}: {e}")
|
||||
|
||||
# Switch nodes: add case pins
|
||||
if kind in ("switch_int", "switch_string", "switch_enum"):
|
||||
cases = int(p.get("cases", 0))
|
||||
for _ in range(cases):
|
||||
try:
|
||||
L.add_case_pin_to_switch(target_graph, guid)
|
||||
except Exception as e: # noqa: BLE001
|
||||
result["warnings"].append(f"add_case_pin_to_switch: {local_id}: {e}")
|
||||
|
||||
# ---- set pin defaults ----
|
||||
SKIP_PARAMS = {"cases", "then_count", "width", "height", "text", "Format"}
|
||||
for spec in node_specs:
|
||||
local_id = spec.get("id")
|
||||
guid = guid_map.get(local_id)
|
||||
if not guid:
|
||||
continue
|
||||
for pin_name, value in (spec.get("params") or {}).items():
|
||||
if pin_name in SKIP_PARAMS:
|
||||
continue
|
||||
try:
|
||||
ok = L.set_pin_default(target_graph, guid, pin_name, str(value))
|
||||
if not ok:
|
||||
result["warnings"].append(f"set_pin_default failed: {local_id}.{pin_name}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
result["warnings"].append(f"set_pin_default raised: {local_id}.{pin_name}: {e}")
|
||||
|
||||
# ---- connect edges ----
|
||||
def _resolve_ref(ref: str):
|
||||
# "@guid:<NodeGuid>" -> raw guid (existing node)
|
||||
if isinstance(ref, str) and ref.startswith("@guid:"):
|
||||
return ref[len("@guid:"):]
|
||||
return guid_map.get(ref)
|
||||
|
||||
for edge in graph.get("edges", []):
|
||||
try:
|
||||
(fa, fp) = edge["from"]
|
||||
(tb, tp) = edge["to"]
|
||||
ga, gb = _resolve_ref(fa), _resolve_ref(tb)
|
||||
if not ga or not gb:
|
||||
raise RuntimeError("edge references unspawned/unknown node")
|
||||
ok = L.connect_pins(target_graph, ga, fp, gb, tp)
|
||||
if not ok:
|
||||
raise RuntimeError(f"connect_pins returned false ({fa}.{fp} -> {tb}.{tp})")
|
||||
except Exception as e: # noqa: BLE001
|
||||
result["errors"].append(f"edge {edge}: {e}")
|
||||
|
||||
if result["errors"]:
|
||||
result["ok"] = False
|
||||
else:
|
||||
try:
|
||||
msgs = list(L.compile_with_messages(bp))
|
||||
result["compile_messages"] = msgs
|
||||
result["compile"] = "ok" if all(not m.startswith("ERROR|") for m in msgs) else "errors"
|
||||
except Exception:
|
||||
L.compile_and_save_blueprint(bp)
|
||||
result["compile"] = "ok"
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(bp)
|
||||
result["ok"] = (result["compile"] == "ok")
|
||||
except Exception as e: # noqa: BLE001
|
||||
result["errors"].append(str(e))
|
||||
result["traceback"] = traceback.format_exc()
|
||||
return result
|
||||
|
||||
|
||||
def _load_class(path_or_name: str):
|
||||
if "/" in path_or_name:
|
||||
return unreal.load_class(None, path_or_name)
|
||||
for prefix in ("/Script/Engine.", "/Script/CoreUObject.", "/Script/UMG.",
|
||||
"/Script/GameplayTags.", "/Script/UnrealEd.", "/Script/Kismet."):
|
||||
c = unreal.load_class(None, prefix + path_or_name)
|
||||
if c is not None:
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _load_struct(path: str):
|
||||
if "/" in path:
|
||||
return unreal.load_object(None, path)
|
||||
return unreal.load_object(None, "/Script/CoreUObject." + path)
|
||||
|
||||
|
||||
def _load_enum(path: str):
|
||||
if "/" in path:
|
||||
return unreal.load_object(None, path)
|
||||
return unreal.load_object(None, "/Script/CoreUObject." + path)
|
||||
|
||||
|
||||
def _spawn_one(graph, spec: dict, bp, L) -> str:
|
||||
kind = spec["kind"]
|
||||
pos = unreal.Vector2D(*(spec.get("position") or [0, 0]))
|
||||
target = spec.get("target") or ""
|
||||
|
||||
# ---- Function / event ----
|
||||
if kind == "call_function":
|
||||
cls, fn = _resolve_function_owner(target)
|
||||
return L.spawn_call_function_node(bp, graph, cls, fn, pos)
|
||||
if kind == "event":
|
||||
return L.spawn_event_node(bp, graph, None, target, pos)
|
||||
if kind == "custom_event":
|
||||
return L.spawn_custom_event_node(bp, graph, target, pos)
|
||||
if kind == "component_event":
|
||||
if "." not in target:
|
||||
raise RuntimeError(f"component_event target must be 'Component.Delegate', got {target!r}")
|
||||
comp, delegate = target.split(".", 1)
|
||||
return L.spawn_component_bound_event(bp, graph, comp, delegate, pos)
|
||||
|
||||
# ---- Variables ----
|
||||
if kind == "variable_get":
|
||||
return L.spawn_variable_get_node(bp, graph, target, pos)
|
||||
if kind == "variable_set":
|
||||
return L.spawn_variable_set_node(bp, graph, target, pos)
|
||||
if kind == "self":
|
||||
return L.spawn_self_node(bp, graph, pos)
|
||||
|
||||
# ---- Flow control ----
|
||||
if kind == "branch":
|
||||
return L.spawn_branch_node(bp, graph, pos)
|
||||
if kind == "sequence":
|
||||
out_count = int((spec.get("params") or {}).get("then_count", 2))
|
||||
return L.spawn_sequence_node(bp, graph, pos, out_count)
|
||||
if kind == "switch_int":
|
||||
return L.spawn_switch_on_int_node(bp, graph, pos)
|
||||
if kind == "switch_string":
|
||||
return L.spawn_switch_on_string_node(bp, graph, pos)
|
||||
if kind == "switch_enum":
|
||||
e = _load_enum(target)
|
||||
if e is None:
|
||||
raise RuntimeError(f"switch_enum: enum not found at {target!r}")
|
||||
return L.spawn_switch_on_enum_node(bp, graph, e, pos)
|
||||
if kind == "macro":
|
||||
# target: macro name from StandardMacros (e.g. "ForLoop", "ForEachLoop",
|
||||
# "WhileLoop", "DoOnce", "Gate", "FlipFlop", "MultiGate", "IsValid")
|
||||
return L.spawn_standard_macro_node(bp, graph, target, pos)
|
||||
|
||||
# ---- Cast ----
|
||||
if kind == "cast":
|
||||
cls = _load_class(target)
|
||||
if cls is None:
|
||||
raise RuntimeError(f"cast: class not found at {target!r}")
|
||||
return L.spawn_cast_node(bp, graph, cls, pos)
|
||||
|
||||
# ---- Collections / structs ----
|
||||
if kind == "make_array":
|
||||
return L.spawn_make_array_node(bp, graph, pos)
|
||||
if kind == "make_struct":
|
||||
s = _load_struct(target)
|
||||
if s is None:
|
||||
raise RuntimeError(f"make_struct: struct not found at {target!r}")
|
||||
return L.spawn_make_struct_node(bp, graph, s, pos)
|
||||
if kind == "break_struct":
|
||||
s = _load_struct(target)
|
||||
if s is None:
|
||||
raise RuntimeError(f"break_struct: struct not found at {target!r}")
|
||||
return L.spawn_break_struct_node(bp, graph, s, pos)
|
||||
if kind == "set_fields_in_struct":
|
||||
s = _load_struct(target)
|
||||
if s is None:
|
||||
raise RuntimeError(f"set_fields_in_struct: struct not found at {target!r}")
|
||||
return L.spawn_set_fields_in_struct_node(bp, graph, s, pos)
|
||||
|
||||
# ---- Misc ----
|
||||
if kind == "spawn_actor":
|
||||
return L.spawn_spawn_actor_from_class_node(bp, graph, pos)
|
||||
if kind == "format_text":
|
||||
return L.spawn_format_text_node(bp, graph, pos)
|
||||
if kind == "get_subsystem":
|
||||
cls = _load_class(target)
|
||||
if cls is None:
|
||||
raise RuntimeError(f"get_subsystem: class not found at {target!r}")
|
||||
return L.spawn_get_subsystem_node(bp, graph, cls, pos)
|
||||
if kind == "load_asset":
|
||||
return L.spawn_load_asset_node(bp, graph, pos)
|
||||
|
||||
# ---- Delegates ----
|
||||
if kind in ("bind_delegate", "unbind_delegate", "assign_delegate"):
|
||||
if "." not in target:
|
||||
raise RuntimeError(f"{kind} target must be 'Component.Delegate', got {target!r}")
|
||||
comp, delegate = target.split(".", 1)
|
||||
fn = {
|
||||
"bind_delegate": L.spawn_bind_delegate_node,
|
||||
"unbind_delegate": L.spawn_unbind_delegate_node,
|
||||
"assign_delegate": L.spawn_assign_delegate_node,
|
||||
}[kind]
|
||||
return fn(bp, graph, comp, delegate, pos)
|
||||
if kind == "call_delegate":
|
||||
return L.spawn_call_delegate_node(bp, graph, target, pos)
|
||||
|
||||
# ---- Tier 2 nodes ----
|
||||
if kind == "knot":
|
||||
return L.spawn_knot_node(bp, graph, pos)
|
||||
if kind == "comment":
|
||||
p = spec.get("params") or {}
|
||||
size = unreal.Vector2D(float(p.get("width", 400)), float(p.get("height", 200)))
|
||||
text = str(p.get("text", target or "Comment"))
|
||||
return L.spawn_comment_node(bp, graph, pos, size, text)
|
||||
if kind == "enum_literal":
|
||||
e = _load_enum(target)
|
||||
if e is None:
|
||||
raise RuntimeError(f"enum_literal: enum not found at {target!r}")
|
||||
return L.spawn_enum_literal_node(bp, graph, e, pos)
|
||||
if kind == "get_class_defaults":
|
||||
cls = _load_class(target)
|
||||
if cls is None:
|
||||
raise RuntimeError(f"get_class_defaults: class not found at {target!r}")
|
||||
return L.spawn_get_class_defaults_node(bp, graph, cls, pos)
|
||||
if kind == "function_result":
|
||||
return L.spawn_function_result_node(bp, graph, pos)
|
||||
if kind == "get_array_item":
|
||||
return L.spawn_get_array_item_node(bp, graph, pos)
|
||||
if kind == "operator":
|
||||
# target = "+", "-", "*", etc.
|
||||
return L.spawn_promotable_operator_node(bp, graph, target, pos)
|
||||
|
||||
raise RuntimeError(f"kind {kind!r} not supported")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation (dry-resolve all targets without spawning)
|
||||
# ---------------------------------------------------------------------------
|
||||
def validate(graph: dict) -> dict:
|
||||
"""Resolve all targets in NodeSpec without mutating UE.
|
||||
|
||||
Returns {ok, missing: [{id, kind, target, reason}], resolved: [{id, ...}]}.
|
||||
"""
|
||||
out = {"ok": True, "missing": [], "resolved": []}
|
||||
if unreal is None:
|
||||
out["ok"] = False
|
||||
out["missing"].append({"reason": "unreal module not importable"})
|
||||
return out
|
||||
for spec in graph.get("nodes", []):
|
||||
kind = spec.get("kind", "")
|
||||
target = spec.get("target") or ""
|
||||
try:
|
||||
if kind == "call_function":
|
||||
cls, fn = _resolve_function_owner(target)
|
||||
if not cls.find_function(fn):
|
||||
raise RuntimeError(f"function {fn!r} not on {cls.get_name()}")
|
||||
elif kind in ("cast", "get_subsystem", "get_class_defaults"):
|
||||
if _load_class(target) is None:
|
||||
raise RuntimeError(f"class not found: {target!r}")
|
||||
elif kind in ("make_struct", "break_struct", "set_fields_in_struct"):
|
||||
if _load_struct(target) is None:
|
||||
raise RuntimeError(f"struct not found: {target!r}")
|
||||
elif kind in ("switch_enum", "enum_literal"):
|
||||
if _load_enum(target) is None:
|
||||
raise RuntimeError(f"enum not found: {target!r}")
|
||||
out["resolved"].append({"id": spec.get("id"), "kind": kind, "target": target})
|
||||
except Exception as e: # noqa: BLE001
|
||||
out["ok"] = False
|
||||
out["missing"].append({"id": spec.get("id"), "kind": kind, "target": target, "reason": str(e)})
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# read_graph — dump existing graph via C++ DumpGraphToJson
|
||||
# ---------------------------------------------------------------------------
|
||||
def read_graph(bp_path: str, function_name: str = "EventGraph") -> dict:
|
||||
try:
|
||||
L = _lib()
|
||||
if bp_path:
|
||||
bp = _load_blueprint(bp_path)
|
||||
else:
|
||||
bp, bp_path = _active_blueprint()
|
||||
g = _find_graph(bp, function_name)
|
||||
raw = L.dump_graph_to_json(g)
|
||||
return {"ok": True, "bp_path": bp_path, "function": function_name, "graph": json.loads(raw)}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"ok": False, "error": str(e), "traceback": traceback.format_exc()}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entrypoint (called by the MCP server's bootstrap snippet)
|
||||
# ---------------------------------------------------------------------------
|
||||
def entrypoint(payload_json: str) -> str:
|
||||
try:
|
||||
graph = json.loads(payload_json)
|
||||
except Exception as e: # noqa: BLE001
|
||||
result_json = json.dumps({"ok": False, "errors": [f"bad payload json: {e}"]})
|
||||
_tee(result_json, {})
|
||||
return result_json
|
||||
op = graph.get("__op", "apply")
|
||||
if op == "read":
|
||||
out = read_graph(graph.get("bp_path") or "", graph.get("function") or "EventGraph")
|
||||
elif op == "validate":
|
||||
out = validate(graph)
|
||||
elif op == "batch":
|
||||
out = batch_apply(graph.get("ops") or [])
|
||||
else:
|
||||
out = apply(graph)
|
||||
result_json = json.dumps(out)
|
||||
_tee(result_json, graph)
|
||||
return result_json
|
||||
|
||||
|
||||
def batch_apply(ops: list) -> dict:
|
||||
"""Run a list of apply()s atomically-ish — same transaction, single undo."""
|
||||
results = []
|
||||
for i, op in enumerate(ops):
|
||||
op_kind = op.get("op") or "apply"
|
||||
if op_kind == "apply":
|
||||
results.append({"index": i, "op": "apply", "result": apply(op)})
|
||||
elif op_kind == "validate":
|
||||
results.append({"index": i, "op": "validate", "result": validate(op)})
|
||||
elif op_kind == "read":
|
||||
results.append({"index": i, "op": "read", "result": read_graph(op.get("bp_path") or "", op.get("function") or "EventGraph")})
|
||||
else:
|
||||
results.append({"index": i, "op": op_kind, "result": {"ok": False, "error": f"unknown op kind {op_kind!r}"}})
|
||||
overall_ok = all(r["result"].get("ok") for r in results)
|
||||
return {"ok": overall_ok, "results": results}
|
||||
|
||||
|
||||
def _tee(result_json: str, payload: dict) -> None:
|
||||
try:
|
||||
if unreal is None:
|
||||
return
|
||||
proj_dir = unreal.Paths.project_saved_dir()
|
||||
out_dir = proj_dir + "MCP/"
|
||||
try:
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
rid = (payload or {}).get("__rid") or "last"
|
||||
# Always write to last.json for human inspection, and to {rid}.json for race-free reads
|
||||
with open(out_dir + "last.json", "w", encoding="utf-8") as f:
|
||||
f.write(result_json)
|
||||
if rid != "last":
|
||||
with open(out_dir + f"{rid}.json", "w", encoding="utf-8") as f:
|
||||
f.write(result_json)
|
||||
unreal.log("[MCP] " + result_json[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
243
ue_side/assets.py
Normal file
243
ue_side/assets.py
Normal file
@ -0,0 +1,243 @@
|
||||
"""Asset CRUD via UE AssetTools — pure Python, no C++ needed.
|
||||
|
||||
Exposed ops (dispatched by `entrypoint(payload_json)` from the MCP server):
|
||||
create_blueprint {path, parent_class}
|
||||
create_widget_bp {path}
|
||||
create_enum {path, values:[]}
|
||||
create_struct {path, fields:[{name,type}]}
|
||||
create_data_asset {path, class}
|
||||
create_data_table {path, row_struct}
|
||||
duplicate {src, dst}
|
||||
rename {src, dst}
|
||||
move {src, dst_folder}
|
||||
delete {path}
|
||||
reparent_blueprint {bp, new_parent_class}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _at():
|
||||
return unreal.AssetToolsHelpers.get_asset_tools()
|
||||
|
||||
|
||||
def _load_class(path_or_name: str):
|
||||
if "/" in path_or_name:
|
||||
return unreal.load_class(None, path_or_name)
|
||||
for prefix in ("/Script/Engine.", "/Script/CoreUObject.", "/Script/UMG.",
|
||||
"/Script/UnrealEd.", "/Script/Kismet."):
|
||||
c = unreal.load_class(None, prefix + path_or_name)
|
||||
if c is not None:
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _split_pkg(path: str):
|
||||
"""/Game/Foo/Bar/Asset -> (/Game/Foo/Bar, Asset)"""
|
||||
if "/" not in path:
|
||||
raise RuntimeError(f"bad asset path {path!r}")
|
||||
folder, name = path.rsplit("/", 1)
|
||||
return folder, name
|
||||
|
||||
|
||||
def create_blueprint(args: dict) -> dict:
|
||||
path = args["path"]
|
||||
parent = _load_class(args.get("parent_class") or "Actor")
|
||||
if parent is None:
|
||||
return {"ok": False, "error": f"parent class not found: {args.get('parent_class')!r}"}
|
||||
folder, name = _split_pkg(path)
|
||||
factory = unreal.BlueprintFactory()
|
||||
factory.set_editor_property("parent_class", parent)
|
||||
asset = _at().create_asset(name, folder, unreal.Blueprint, factory)
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create_asset returned None"}
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0], "parent": parent.get_name()}
|
||||
|
||||
|
||||
def create_widget_bp(args: dict) -> dict:
|
||||
path = args["path"]
|
||||
folder, name = _split_pkg(path)
|
||||
factory = unreal.WidgetBlueprintFactory()
|
||||
asset = _at().create_asset(name, folder, unreal.WidgetBlueprint, factory)
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create_asset returned None"}
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def create_enum(args: dict) -> dict:
|
||||
path = args["path"]
|
||||
values = args.get("values") or []
|
||||
folder, name = _split_pkg(path)
|
||||
factory = unreal.EnumFactory()
|
||||
asset = _at().create_asset(name, folder, unreal.UserDefinedEnum, factory)
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create_asset returned None"}
|
||||
for i, v in enumerate(values):
|
||||
try:
|
||||
unreal.EnumEditorUtils.add_enumerator_to_user_defined_enum(asset, str(v))
|
||||
except Exception:
|
||||
pass
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0], "values_added": len(values)}
|
||||
|
||||
|
||||
def create_struct(args: dict) -> dict:
|
||||
path = args["path"]
|
||||
fields = args.get("fields") or [] # [{name, type}]
|
||||
folder, name = _split_pkg(path)
|
||||
factory = unreal.StructureFactory()
|
||||
asset = _at().create_asset(name, folder, unreal.UserDefinedStruct, factory)
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create_asset returned None"}
|
||||
# Field editing via StructureEditorUtils not exposed reliably in stock Python.
|
||||
# User can rename default member via UI; we just create the struct.
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0], "note": "Field add not exposed in stock Python; edit in editor or pre-create C++ struct."}
|
||||
|
||||
|
||||
def create_data_asset(args: dict) -> dict:
|
||||
path = args["path"]
|
||||
cls = _load_class(args.get("class") or "")
|
||||
if cls is None:
|
||||
return {"ok": False, "error": f"class not found: {args.get('class')!r}"}
|
||||
folder, name = _split_pkg(path)
|
||||
factory = unreal.DataAssetFactory()
|
||||
factory.set_editor_property("data_asset_class", cls)
|
||||
asset = _at().create_asset(name, folder, cls, factory)
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create_asset returned None"}
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def create_data_table(args: dict) -> dict:
|
||||
path = args["path"]
|
||||
row_struct_path = args.get("row_struct") or ""
|
||||
struct = unreal.load_object(None, row_struct_path) if row_struct_path else None
|
||||
if struct is None:
|
||||
return {"ok": False, "error": f"row_struct not found: {row_struct_path!r}"}
|
||||
folder, name = _split_pkg(path)
|
||||
factory = unreal.DataTableFactory()
|
||||
factory.set_editor_property("struct", struct)
|
||||
asset = _at().create_asset(name, folder, unreal.DataTable, factory)
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create_asset returned None"}
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def duplicate(args: dict) -> dict:
|
||||
src, dst = args["src"], args["dst"]
|
||||
asset = unreal.EditorAssetLibrary.duplicate_asset(src, dst)
|
||||
return {"ok": asset is not None, "src": src, "dst": dst}
|
||||
|
||||
|
||||
def rename(args: dict) -> dict:
|
||||
src, dst = args["src"], args["dst"]
|
||||
ok = unreal.EditorAssetLibrary.rename_asset(src, dst)
|
||||
return {"ok": bool(ok), "src": src, "dst": dst}
|
||||
|
||||
|
||||
def move(args: dict) -> dict:
|
||||
src, folder = args["src"], args["dst_folder"]
|
||||
name = src.rsplit("/", 1)[-1]
|
||||
dst = folder.rstrip("/") + "/" + name
|
||||
ok = unreal.EditorAssetLibrary.rename_asset(src, dst)
|
||||
return {"ok": bool(ok), "src": src, "dst": dst}
|
||||
|
||||
|
||||
def delete(args: dict) -> dict:
|
||||
path = args["path"]
|
||||
ok = unreal.EditorAssetLibrary.delete_asset(path)
|
||||
return {"ok": bool(ok), "path": path}
|
||||
|
||||
|
||||
def reparent_blueprint(args: dict) -> dict:
|
||||
bp_path = args["bp"]
|
||||
new_parent = _load_class(args["new_parent_class"])
|
||||
if new_parent is None:
|
||||
return {"ok": False, "error": f"new_parent_class not found: {args['new_parent_class']!r}"}
|
||||
bp = unreal.EditorAssetLibrary.load_asset(bp_path)
|
||||
if not isinstance(bp, unreal.Blueprint):
|
||||
return {"ok": False, "error": "not a blueprint"}
|
||||
# WidgetBlueprint (and others) don't expose ParentClass as a settable editor
|
||||
# property — use the proper reparent API, fall back to set_editor_property.
|
||||
try:
|
||||
unreal.BlueprintEditorLibrary.reparent_blueprint(bp, new_parent)
|
||||
except Exception:
|
||||
bp.set_editor_property("parent_class", new_parent)
|
||||
unreal.BlueprintEditorLibrary.compile_blueprint(bp)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(bp)
|
||||
return {"ok": True, "bp": bp_path, "new_parent": new_parent.get_name()}
|
||||
|
||||
|
||||
OPS = {
|
||||
"create_blueprint": create_blueprint,
|
||||
"create_widget_bp": create_widget_bp,
|
||||
"create_enum": create_enum,
|
||||
"create_struct": create_struct,
|
||||
"create_data_asset": create_data_asset,
|
||||
"create_data_table": create_data_table,
|
||||
"duplicate": duplicate,
|
||||
"rename": rename,
|
||||
"move": move,
|
||||
"delete": delete,
|
||||
"reparent_blueprint": reparent_blueprint,
|
||||
}
|
||||
|
||||
# Member-variable authoring lives in variables.py and is surfaced as the
|
||||
# dedicated `variable_op` tool. Merge its NON-colliding op names here too so the
|
||||
# capability is reachable via `asset_op` without a JS server restart. (We skip
|
||||
# the short aliases add/rename/delete/list — they would shadow asset ops.)
|
||||
try:
|
||||
import importlib as _il
|
||||
import variables as _vars
|
||||
_il.reload(_vars)
|
||||
for _k in ("add_variable", "set_var_default", "set_var_meta",
|
||||
"rename_variable", "delete_variable", "list_variables"):
|
||||
OPS[_k] = _vars.VAR_OPS[_k]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
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}"}, {})
|
||||
op = p.get("op")
|
||||
fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {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-ASSETS] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
145
ue_side/audio.py
Normal file
145
ue_side/audio.py
Normal file
@ -0,0 +1,145 @@
|
||||
"""Audio ops — MetaSounds, SoundCue, Attenuation, SoundClass/Mix.
|
||||
|
||||
Ops:
|
||||
create_metasound_source {path}
|
||||
create_metasound_patch {path}
|
||||
create_sound_cue {path}
|
||||
create_sound_attenuation {path}
|
||||
create_sound_class {path}
|
||||
create_sound_mix {path}
|
||||
set_property {path, property, value}
|
||||
get_property {path, property}
|
||||
list_sound_assets {folder?}
|
||||
play_sound_2d {path} — preview in editor
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _at():
|
||||
return unreal.AssetToolsHelpers.get_asset_tools()
|
||||
|
||||
|
||||
def _load(p): return unreal.EditorAssetLibrary.load_asset(p)
|
||||
def _split(p):
|
||||
folder, name = p.rsplit("/", 1)
|
||||
return folder, name
|
||||
|
||||
|
||||
def _create(path, asset_cls_name, factory_name):
|
||||
folder, name = _split(path)
|
||||
cls = getattr(unreal, asset_cls_name, None)
|
||||
factory_cls = getattr(unreal, factory_name, None)
|
||||
if cls is None or factory_cls is None:
|
||||
return {"ok": False, "error": f"{asset_cls_name}/{factory_name} not exposed (plugin disabled?)"}
|
||||
asset = _at().create_asset(name, folder, cls, factory_cls())
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create_asset returned None"}
|
||||
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def create_metasound_source(args): return _create(args["path"], "MetaSoundSource", "MetaSoundSourceFactory")
|
||||
def create_metasound_patch(args): return _create(args["path"], "MetaSoundPatch", "MetaSoundPatchFactory")
|
||||
def create_sound_cue(args): return _create(args["path"], "SoundCue", "SoundCueFactoryNew")
|
||||
def create_sound_attenuation(args): return _create(args["path"], "SoundAttenuation", "SoundAttenuationFactory")
|
||||
def create_sound_class(args): return _create(args["path"], "SoundClass", "SoundClassFactory")
|
||||
def create_sound_mix(args): return _create(args["path"], "SoundMix", "SoundMixFactory")
|
||||
|
||||
|
||||
def set_property(args):
|
||||
a = _load(args["path"])
|
||||
if a is None:
|
||||
return {"ok": False, "error": "asset not found"}
|
||||
try:
|
||||
a.set_editor_property(args["property"], args["value"])
|
||||
unreal.EditorAssetLibrary.save_asset(a.get_path_name())
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def get_property(args):
|
||||
a = _load(args["path"])
|
||||
if a is None:
|
||||
return {"ok": False, "error": "asset not found"}
|
||||
try:
|
||||
v = a.get_editor_property(args["property"])
|
||||
return {"ok": True, "value": str(v)}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def list_sound_assets(args):
|
||||
folder = args.get("folder") or "/Game"
|
||||
ar = unreal.AssetRegistryHelpers.get_asset_registry()
|
||||
classes = ["SoundCue", "SoundWave", "MetaSoundSource", "MetaSoundPatch",
|
||||
"SoundAttenuation", "SoundClass", "SoundMix", "SoundConcurrency"]
|
||||
filt = unreal.ARFilter(package_paths=[folder], recursive_paths=True, class_names=classes)
|
||||
items = [{"path": str(d.package_name), "class": str(d.asset_class)} for d in ar.get_assets(filt)]
|
||||
return {"ok": True, "count": len(items), "items": items}
|
||||
|
||||
|
||||
def play_sound_2d(args):
|
||||
a = _load(args["path"])
|
||||
if a is None:
|
||||
return {"ok": False, "error": "not found"}
|
||||
try:
|
||||
world = unreal.EditorLevelLibrary.get_editor_world()
|
||||
unreal.GameplayStatics.play_sound2d(world, a)
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
OPS = {
|
||||
"create_metasound_source": create_metasound_source,
|
||||
"create_metasound_patch": create_metasound_patch,
|
||||
"create_sound_cue": create_sound_cue,
|
||||
"create_sound_attenuation": create_sound_attenuation,
|
||||
"create_sound_class": create_sound_class,
|
||||
"create_sound_mix": create_sound_mix,
|
||||
"set_property": set_property,
|
||||
"get_property": get_property,
|
||||
"list_sound_assets": list_sound_assets,
|
||||
"play_sound_2d": play_sound_2d,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json: str) -> str:
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
op = p.get("op")
|
||||
fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result, p):
|
||||
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-AUDIO] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
213
ue_side/capture.py
Normal file
213
ue_side/capture.py
Normal file
@ -0,0 +1,213 @@
|
||||
"""Headless capture ops — SEE editor content without opening any tab/window.
|
||||
|
||||
Backed by the C++ helper unreal.MCPCaptureLibrary (Live-Coding compile the
|
||||
plugin if it's missing). Every op writes a PNG under Saved/<out_dir>/ and
|
||||
returns its absolute path + file:// URL. The JS layer (capture_op) reads that
|
||||
PNG back and attaches it INLINE so the agent actually sees the image.
|
||||
|
||||
Ops:
|
||||
asset {asset_path, size?:[512,512], out_dir?:'MCP/capture'}
|
||||
Render ANY asset's thumbnail headlessly (mesh, material, texture,
|
||||
blueprint, niagara, anim, sound, data asset, ...). No editor open.
|
||||
thumbnail alias for asset.
|
||||
material {material_path, size?} — alias for asset, reads nicer in intent.
|
||||
mesh {mesh_path, size?} — alias for asset.
|
||||
scene {location?:[x,y,z], rotation?:[pitch,yaw,roll], fov?:90,
|
||||
size?:[1280,720], out_dir?} — render the editor world from an
|
||||
arbitrary camera via an offscreen SceneCapture2D. No viewport.
|
||||
scene_from_actor {actor, distance?:300, pitch?:-20, yaw?:0, fov?:75, size?}
|
||||
— frame an existing level actor and capture it.
|
||||
list_assets {dir} — quick content-browser listing to find paths to capture.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _cap_lib():
|
||||
L = getattr(unreal, "MCPCaptureLibrary", None)
|
||||
if L is None:
|
||||
raise RuntimeError(
|
||||
"MCPCaptureLibrary not exposed — Live-Coding compile the C++ plugin "
|
||||
"(live_coding_op compile), then retry."
|
||||
)
|
||||
return L
|
||||
|
||||
|
||||
def _out_dir(args) -> tuple[str, str]:
|
||||
"""Returns (abs_dir_with_os_sep, abs_dir_forward_slash)."""
|
||||
proj_saved = unreal.Paths.project_saved_dir()
|
||||
sub = (args.get("out_dir") or "MCP/capture").strip("/")
|
||||
fwd = proj_saved + sub + "/"
|
||||
os.makedirs(fwd, exist_ok=True)
|
||||
return fwd.rstrip("/").replace("/", os.sep), fwd
|
||||
|
||||
|
||||
def _safe_name(path: str) -> str:
|
||||
return path.rsplit("/", 1)[-1].split(".")[0] or "capture"
|
||||
|
||||
|
||||
def _ok_png(full_path: str, strategy: str, size) -> dict:
|
||||
norm = full_path.replace("\\", "/")
|
||||
return {
|
||||
"ok": True,
|
||||
"strategy": strategy,
|
||||
"png_path": full_path,
|
||||
"file_url": "file:///" + norm,
|
||||
"size": size,
|
||||
}
|
||||
|
||||
|
||||
def _vec(v, d=(0.0, 0.0, 0.0)):
|
||||
if v is None:
|
||||
v = d
|
||||
return unreal.Vector(float(v[0]), float(v[1]), float(v[2]))
|
||||
|
||||
|
||||
def _rot(v, d=(0.0, 0.0, 0.0)):
|
||||
if v is None:
|
||||
v = d
|
||||
# UE Rotator(pitch, yaw, roll); we accept [pitch, yaw, roll].
|
||||
return unreal.Rotator(float(v[0]), float(v[1]), float(v[2]))
|
||||
|
||||
|
||||
def asset(args: dict) -> dict:
|
||||
path = args.get("asset_path") or args.get("material_path") or args.get("mesh_path") or args.get("path")
|
||||
if not path:
|
||||
return {"ok": False, "error": "asset_path required"}
|
||||
if not unreal.EditorAssetLibrary.does_asset_exist(path):
|
||||
return {"ok": False, "error": f"asset does not exist: {path}"}
|
||||
size = args.get("size") or [512, 512]
|
||||
w, h = int(size[0]), int(size[1])
|
||||
os_dir, _ = _out_dir(args)
|
||||
name = _safe_name(path)
|
||||
_ret = _cap_lib().render_asset_thumbnail_to_png(path, os_dir, name, w, h)
|
||||
if isinstance(_ret, (tuple, list)):
|
||||
if len(_ret) >= 3:
|
||||
ok, full, err = _ret[0], _ret[1], _ret[2]
|
||||
elif len(_ret) == 2:
|
||||
full, err = _ret[0], _ret[1]
|
||||
ok = bool(full)
|
||||
else:
|
||||
ok, full, err = bool(_ret[0]), (_ret[0] if isinstance(_ret[0], str) else ""), ""
|
||||
else:
|
||||
full = _ret
|
||||
ok = bool(full)
|
||||
err = ""
|
||||
if not ok:
|
||||
return {"ok": False, "error": err or "thumbnail render failed", "asset_path": path}
|
||||
res = _ok_png(full, "AssetThumbnail", [w, h])
|
||||
res["asset_path"] = path
|
||||
return res
|
||||
|
||||
|
||||
def scene(args: dict) -> dict:
|
||||
size = args.get("size") or [1280, 720]
|
||||
w, h = int(size[0]), int(size[1])
|
||||
os_dir, _ = _out_dir(args)
|
||||
name = args.get("name") or "scene"
|
||||
loc = _vec(args.get("location"))
|
||||
rot = _rot(args.get("rotation"))
|
||||
fov = float(args.get("fov", 90.0))
|
||||
ok, full, err = _cap_lib().capture_editor_scene_to_png(loc, rot, fov, os_dir, name, w, h)
|
||||
if not ok:
|
||||
return {"ok": False, "error": err or "scene capture failed"}
|
||||
res = _ok_png(full, "SceneCapture2D", [w, h])
|
||||
res["camera"] = {"location": [loc.x, loc.y, loc.z],
|
||||
"rotation": [rot.pitch, rot.yaw, rot.roll], "fov": fov}
|
||||
return res
|
||||
|
||||
|
||||
def scene_from_actor(args: dict) -> dict:
|
||||
name = args.get("actor")
|
||||
if not name:
|
||||
return {"ok": False, "error": "actor required"}
|
||||
ess = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
target = None
|
||||
for a in ess.get_all_level_actors():
|
||||
if a and (a.get_actor_label() == name or a.get_name() == name):
|
||||
target = a
|
||||
break
|
||||
if target is None:
|
||||
return {"ok": False, "error": f"actor not found: {name}"}
|
||||
|
||||
origin, extent = target.get_actor_bounds(False)
|
||||
radius = max(extent.x, extent.y, extent.z, 50.0)
|
||||
pitch = float(args.get("pitch", -20.0))
|
||||
yaw = float(args.get("yaw", 0.0))
|
||||
fov = float(args.get("fov", 75.0))
|
||||
dist = float(args.get("distance", radius * 3.0))
|
||||
|
||||
# Place camera 'dist' away along yaw/pitch, looking back at the actor center.
|
||||
look_dir = unreal.MathLibrary.get_forward_vector(unreal.Rotator(pitch, yaw, 0.0))
|
||||
cam_loc = origin - (look_dir * dist)
|
||||
look_rot = unreal.MathLibrary.find_look_at_rotation(cam_loc, origin)
|
||||
|
||||
size = args.get("size") or [1280, 720]
|
||||
w, h = int(size[0]), int(size[1])
|
||||
os_dir, _ = _out_dir(args)
|
||||
fname = args.get("name") or f"actor_{_safe_name(name)}"
|
||||
ok, full, err = _cap_lib().capture_editor_scene_to_png(cam_loc, look_rot, fov, os_dir, fname, w, h)
|
||||
if not ok:
|
||||
return {"ok": False, "error": err or "scene capture failed"}
|
||||
res = _ok_png(full, "SceneCapture2D/actor", [w, h])
|
||||
res["actor"] = name
|
||||
res["camera"] = {"location": [cam_loc.x, cam_loc.y, cam_loc.z], "fov": fov}
|
||||
return res
|
||||
|
||||
|
||||
def list_assets(args: dict) -> dict:
|
||||
folder = args.get("dir") or "/Game"
|
||||
recursive = bool(args.get("recursive", False))
|
||||
paths = unreal.EditorAssetLibrary.list_assets(folder, recursive=recursive, include_folder=False)
|
||||
return {"ok": True, "dir": folder, "count": len(paths), "assets": list(paths)[:500]}
|
||||
|
||||
|
||||
OPS = {
|
||||
"asset": asset,
|
||||
"thumbnail": asset,
|
||||
"material": asset,
|
||||
"mesh": asset,
|
||||
"scene": scene,
|
||||
"scene_from_actor": scene_from_actor,
|
||||
"list_assets": list_assets,
|
||||
}
|
||||
|
||||
|
||||
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-CAPTURE] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
118
ue_side/console.py
Normal file
118
ue_side/console.py
Normal file
@ -0,0 +1,118 @@
|
||||
"""Console commands and CVars.
|
||||
|
||||
Ops:
|
||||
run {command} — execute editor console command
|
||||
get_cvar {name}
|
||||
set_cvar {name, value}
|
||||
list_cvars {prefix?} — search registered cvars by prefix (best-effort)
|
||||
stat {name, enable?:true} — toggle stat command (`stat fps`, `stat unit`, ...)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _world():
|
||||
return unreal.EditorLevelLibrary.get_editor_world()
|
||||
|
||||
|
||||
def run(args):
|
||||
cmd = args["command"]
|
||||
try:
|
||||
unreal.SystemLibrary.execute_console_command(_world(), cmd)
|
||||
return {"ok": True, "command": cmd}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def get_cvar(args):
|
||||
name = args["name"]
|
||||
# No direct read API in stable Python; we issue ?-style print and grab from log.
|
||||
try:
|
||||
unreal.SystemLibrary.execute_console_command(_world(), name)
|
||||
# Heuristic: also use ConsoleVariablesEditorFunctionLibrary if available
|
||||
cvl = getattr(unreal, "ConsoleVariablesEditorFunctionLibrary", None)
|
||||
if cvl and hasattr(cvl, "get_console_variable_string_value"):
|
||||
return {"ok": True, "name": name, "value": cvl.get_console_variable_string_value(name)}
|
||||
return {"ok": True, "name": name, "note": "value echoed to Output Log (no direct read API)"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def set_cvar(args):
|
||||
name = args["name"]
|
||||
val = args["value"]
|
||||
cmd = f"{name} {val}"
|
||||
try:
|
||||
unreal.SystemLibrary.execute_console_command(_world(), cmd)
|
||||
return {"ok": True, "command": cmd}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def list_cvars(args):
|
||||
prefix = args.get("prefix") or ""
|
||||
cmd = f"DumpConsoleCommands {prefix}".strip()
|
||||
try:
|
||||
unreal.SystemLibrary.execute_console_command(_world(), cmd)
|
||||
return {"ok": True, "note": "results dumped to log; use read_python_log or grep the project log",
|
||||
"command": cmd}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def stat(args):
|
||||
name = args["name"]
|
||||
if not name.lower().startswith("stat "):
|
||||
name = "stat " + name
|
||||
try:
|
||||
unreal.SystemLibrary.execute_console_command(_world(), name)
|
||||
return {"ok": True, "command": name}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
OPS = {
|
||||
"run": run,
|
||||
"get_cvar": get_cvar,
|
||||
"set_cvar": set_cvar,
|
||||
"list_cvars": list_cvars,
|
||||
"stat": stat,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json):
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
op = p.get("op"); fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result, p):
|
||||
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-CONSOLE] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
271
ue_side/cpp_scaffold.py
Normal file
271
ue_side/cpp_scaffold.py
Normal file
@ -0,0 +1,271 @@
|
||||
"""C++ class scaffold — generate .h/.cpp templates under a project module.
|
||||
|
||||
This does NOT compile the code — pair with live_coding.compile or a regular
|
||||
hot-reload after the files appear. UE will pick up new files on next build.
|
||||
|
||||
Ops:
|
||||
list_modules {} — read Source/*/Build.cs
|
||||
create_class {name, parent_class, module?(default: primary game module), subfolder?, header_only?:false, public?:true, api_macro?:auto}
|
||||
list_classes {module?, subfolder?} — list .h files under a module
|
||||
read_class {name, module?} — return .h+.cpp text
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _project_dir():
|
||||
if unreal is not None:
|
||||
return unreal.Paths.convert_relative_path_to_full(unreal.Paths.project_dir()).rstrip("/\\")
|
||||
return os.getcwd()
|
||||
|
||||
|
||||
def _source_dir():
|
||||
return os.path.join(_project_dir(), "Source")
|
||||
|
||||
|
||||
def _project_name():
|
||||
"""Project name = the .uproject base name (also the conventional primary module)."""
|
||||
if unreal is not None:
|
||||
try:
|
||||
up = unreal.Paths.get_project_file_path() # e.g. .../MyGame.uproject
|
||||
return os.path.splitext(os.path.basename(up))[0]
|
||||
except Exception:
|
||||
pass
|
||||
return os.path.basename(_project_dir().rstrip("/\\"))
|
||||
|
||||
|
||||
def _default_module():
|
||||
"""Primary game module to scaffold into. Prefer the Runtime module declared in
|
||||
the .uproject; fall back to the module whose folder matches the project name;
|
||||
else the first Source module; else the project name."""
|
||||
proj = _project_name()
|
||||
# 1. Read the .uproject Modules list for a Runtime/Game module.
|
||||
try:
|
||||
up = unreal.Paths.get_project_file_path() if unreal is not None else None
|
||||
if up and os.path.isfile(up):
|
||||
with open(up, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
mods = data.get("Modules") or []
|
||||
runtime = [m for m in mods if str(m.get("Type", "")).lower() in ("runtime", "")]
|
||||
if runtime:
|
||||
return runtime[0].get("Name") or proj
|
||||
if mods:
|
||||
return mods[0].get("Name") or proj
|
||||
except Exception:
|
||||
pass
|
||||
# 2. Folder matching the project name.
|
||||
src = _source_dir()
|
||||
if os.path.isdir(os.path.join(src, proj)):
|
||||
return proj
|
||||
# 3. First Source module with a Build.cs.
|
||||
try:
|
||||
for name in sorted(os.listdir(src)):
|
||||
if os.path.isfile(os.path.join(src, name, f"{name}.Build.cs")):
|
||||
return name
|
||||
except Exception:
|
||||
pass
|
||||
return proj
|
||||
|
||||
|
||||
def list_modules(args):
|
||||
out = []
|
||||
src = _source_dir()
|
||||
if not os.path.isdir(src):
|
||||
return {"ok": False, "error": f"no Source dir at {src}"}
|
||||
for name in os.listdir(src):
|
||||
p = os.path.join(src, name)
|
||||
if os.path.isdir(p):
|
||||
build_cs = os.path.join(p, f"{name}.Build.cs")
|
||||
if os.path.isfile(build_cs):
|
||||
out.append({"name": name, "build_cs": build_cs,
|
||||
"has_public": os.path.isdir(os.path.join(p, "Public")),
|
||||
"has_private": os.path.isdir(os.path.join(p, "Private"))})
|
||||
return {"ok": True, "modules": out}
|
||||
|
||||
|
||||
def _api_macro(module):
|
||||
return f"{module.upper()}_API"
|
||||
|
||||
|
||||
_HEADER_TPL = """// Generated by UEBlueprintMCP cpp_scaffold.
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "{base_header}"
|
||||
#include "{name}.generated.h"
|
||||
|
||||
UCLASS()
|
||||
class {api} {name} : public {parent}
|
||||
{{
|
||||
\tGENERATED_BODY()
|
||||
|
||||
public:
|
||||
\t{name}();
|
||||
}};
|
||||
"""
|
||||
|
||||
_CPP_TPL = """// Generated by UEBlueprintMCP cpp_scaffold.
|
||||
#include "{include_path}{name}.h"
|
||||
|
||||
{name}::{name}()
|
||||
{{
|
||||
\tPrimaryActorTick.bCanEverTick = false;
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
# Map of common parents -> include they need
|
||||
_PARENT_HEADERS = {
|
||||
"AActor": "GameFramework/Actor.h",
|
||||
"APawn": "GameFramework/Pawn.h",
|
||||
"ACharacter": "GameFramework/Character.h",
|
||||
"APlayerController": "GameFramework/PlayerController.h",
|
||||
"AGameModeBase": "GameFramework/GameModeBase.h",
|
||||
"AAIController": "AIController.h",
|
||||
"UObject": "UObject/NoExportTypes.h",
|
||||
"UActorComponent": "Components/ActorComponent.h",
|
||||
"USceneComponent": "Components/SceneComponent.h",
|
||||
"UPrimitiveComponent": "Components/PrimitiveComponent.h",
|
||||
"UStaticMeshComponent": "Components/StaticMeshComponent.h",
|
||||
"USkeletalMeshComponent":"Components/SkeletalMeshComponent.h",
|
||||
"UAnimInstance": "Animation/AnimInstance.h",
|
||||
"UUserWidget": "Blueprint/UserWidget.h",
|
||||
"UDataAsset": "Engine/DataAsset.h",
|
||||
"UInterface": "UObject/Interface.h",
|
||||
}
|
||||
|
||||
|
||||
def create_class(args):
|
||||
name = args["name"]
|
||||
parent = args["parent_class"] # e.g. AActor / UObject / UActorComponent
|
||||
module = args.get("module") or _default_module()
|
||||
subfolder = (args.get("subfolder") or "").replace("\\", "/").strip("/")
|
||||
header_only = bool(args.get("header_only"))
|
||||
public = bool(args.get("public", True))
|
||||
|
||||
if not re.match(r"^[AU][A-Z]\w+$", name):
|
||||
return {"ok": False, "error": "class name must start with A or U followed by PascalCase (e.g. AMyActor, UMyComp)"}
|
||||
|
||||
src_mod = os.path.join(_source_dir(), module)
|
||||
if not os.path.isdir(src_mod):
|
||||
return {"ok": False, "error": f"module not found: {module}"}
|
||||
|
||||
pub_dir = os.path.join(src_mod, "Public", subfolder) if public else os.path.join(src_mod, subfolder)
|
||||
priv_dir = os.path.join(src_mod, "Private", subfolder) if public else os.path.join(src_mod, subfolder)
|
||||
os.makedirs(pub_dir, exist_ok=True)
|
||||
if not header_only:
|
||||
os.makedirs(priv_dir, exist_ok=True)
|
||||
|
||||
base_header = args.get("base_header") or _PARENT_HEADERS.get(parent, "CoreMinimal.h")
|
||||
api = args.get("api_macro") or _api_macro(module)
|
||||
|
||||
h_path = os.path.join(pub_dir, f"{name}.h")
|
||||
c_path = os.path.join(priv_dir, f"{name}.cpp")
|
||||
|
||||
if os.path.exists(h_path):
|
||||
return {"ok": False, "error": f"file exists: {h_path}"}
|
||||
|
||||
h_txt = _HEADER_TPL.format(name=name, parent=parent, api=api, base_header=base_header)
|
||||
with open(h_path, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write(h_txt)
|
||||
out = {"ok": True, "header": h_path}
|
||||
|
||||
if not header_only:
|
||||
include_rel = (subfolder + "/") if subfolder else ""
|
||||
c_txt = _CPP_TPL.format(name=name, include_path=include_rel)
|
||||
with open(c_path, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write(c_txt)
|
||||
out["cpp"] = c_path
|
||||
|
||||
out["note"] = "Files written. Run live_coding.compile or rebuild from VS to register the class."
|
||||
return out
|
||||
|
||||
|
||||
def list_classes(args):
|
||||
module = args.get("module") or _default_module()
|
||||
subfolder = (args.get("subfolder") or "").replace("\\", "/").strip("/")
|
||||
mod_root = os.path.join(_source_dir(), module)
|
||||
if not os.path.isdir(mod_root):
|
||||
return {"ok": False, "error": f"module not found: {mod_root}"}
|
||||
scan_root = os.path.join(mod_root, subfolder) if subfolder else mod_root
|
||||
out = []
|
||||
for dirpath, _, fnames in os.walk(scan_root):
|
||||
for fn in fnames:
|
||||
if fn.endswith(".h"):
|
||||
full = os.path.join(dirpath, fn)
|
||||
rel = os.path.relpath(full, mod_root).replace("\\", "/")
|
||||
# Skip the module's own header (e.g. <Module>.h)
|
||||
if rel == f"{module}.h":
|
||||
continue
|
||||
out.append(rel)
|
||||
return {"ok": True, "module": module, "headers": sorted(out), "count": len(out)}
|
||||
|
||||
|
||||
def read_class(args):
|
||||
module = args.get("module") or _default_module()
|
||||
name = args["name"]
|
||||
found = {"header": None, "cpp": None}
|
||||
for dirpath, _, fnames in os.walk(os.path.join(_source_dir(), module)):
|
||||
for fn in fnames:
|
||||
if fn == f"{name}.h":
|
||||
found["header"] = os.path.join(dirpath, fn)
|
||||
elif fn == f"{name}.cpp":
|
||||
found["cpp"] = os.path.join(dirpath, fn)
|
||||
out = {"ok": True}
|
||||
for k, p in found.items():
|
||||
if p and os.path.isfile(p):
|
||||
try:
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
out[k] = {"path": p, "content": f.read()}
|
||||
except Exception as e:
|
||||
out[k] = {"path": p, "error": str(e)}
|
||||
if not (found["header"] or found["cpp"]):
|
||||
return {"ok": False, "error": f"class {name} not found under Source/{module}"}
|
||||
return out
|
||||
|
||||
|
||||
OPS = {
|
||||
"list_modules": list_modules,
|
||||
"create_class": create_class,
|
||||
"list_classes": list_classes,
|
||||
"read_class": read_class,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json):
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
op = p.get("op"); fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result, p):
|
||||
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-CPP] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
227
ue_side/cull.py
Normal file
227
ue_side/cull.py
Normal file
@ -0,0 +1,227 @@
|
||||
"""Cull-distance ops — batch set/inspect Desired Max Draw Distance on ISM/HISM/StaticMesh components.
|
||||
|
||||
Encapsulates the gotchas learned the hard way:
|
||||
* CachedMaxDrawDistance is READ-ONLY -> we only write LDMaxDrawDistance (+ SetCullDistance on live instances).
|
||||
* SubobjectDataHandle must be unwrapped via k2_find_subobject_data_from_handle (UE 5.7).
|
||||
* target='asset_template' edits the Blueprint asset (default for ALL instances, survives reconstruct);
|
||||
target='level_instance' edits placed actors only (fast, but lost on BP recompile / PLA repack).
|
||||
* only_zero keeps it idempotent and never clobbers hand-tuned non-zero distances.
|
||||
|
||||
Ops:
|
||||
audit {scope?, asset_paths?, component_types?, target?} — read-only report, no mutation
|
||||
apply {distance, scope?, asset_paths?, component_types?, only_zero?, target?, save?:true}
|
||||
verify {...same as audit} — alias of audit (post-apply check)
|
||||
|
||||
scope: 'level' (default) | 'assets' | 'selection'
|
||||
target: 'asset_template' (default) | 'level_instance'
|
||||
component_types: subset of ['ISM','HISM','StaticMesh','Primitive'] (default ['ISM'])
|
||||
distance: cm; 0 disables culling
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _eas(): return unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
def _sub(): return unreal.get_engine_subsystem(unreal.SubobjectDataSubsystem)
|
||||
|
||||
|
||||
def _type_classes(args):
|
||||
types = args.get("component_types") or ["ISM"]
|
||||
mapping = {
|
||||
"ISM": unreal.InstancedStaticMeshComponent,
|
||||
"HISM": unreal.HierarchicalInstancedStaticMeshComponent,
|
||||
"StaticMesh": unreal.StaticMeshComponent,
|
||||
"Primitive": unreal.PrimitiveComponent,
|
||||
}
|
||||
out = []
|
||||
for t in types:
|
||||
cls = mapping.get(t)
|
||||
if cls is None:
|
||||
raise ValueError(f"unknown component_type {t!r}; allowed: {list(mapping)}")
|
||||
out.append(cls)
|
||||
return out
|
||||
|
||||
|
||||
def _matches(obj, classes):
|
||||
return any(isinstance(obj, c) for c in classes)
|
||||
|
||||
|
||||
def _asset_path_from_actor(actor):
|
||||
gen = actor.get_class().get_path_name()
|
||||
asset = gen.split(".")[0]
|
||||
return asset[:-2] if asset.endswith("_C") else asset
|
||||
|
||||
|
||||
# ---- target='asset_template' : edit Blueprint component templates -----------
|
||||
|
||||
def _template_components(bp, classes):
|
||||
"""Returns deduped list of (name, component_template) for matching types."""
|
||||
handles = _sub().k2_gather_subobject_data_for_blueprint(bp)
|
||||
seen, out = set(), []
|
||||
for h in handles:
|
||||
data = _sub().k2_find_subobject_data_from_handle(h)
|
||||
obj = unreal.SubobjectDataBlueprintFunctionLibrary.get_object(data)
|
||||
if obj is None or not _matches(obj, classes):
|
||||
continue
|
||||
nm = obj.get_name()
|
||||
if nm in seen:
|
||||
continue
|
||||
seen.add(nm)
|
||||
out.append((nm, obj))
|
||||
return out
|
||||
|
||||
|
||||
def _collect_assets(args):
|
||||
"""Returns dict {asset_path: blueprint_asset} for the requested scope."""
|
||||
ael = unreal.EditorAssetLibrary
|
||||
paths = {}
|
||||
scope = args.get("scope") or "level"
|
||||
if scope == "assets":
|
||||
for p in (args.get("asset_paths") or []):
|
||||
paths[p.split(".")[0]] = None
|
||||
else: # level / selection -> discover from placed actors
|
||||
classes = _type_classes(args)
|
||||
actors = (_eas().get_selected_level_actors() if scope == "selection"
|
||||
else _eas().get_all_level_actors())
|
||||
for a in actors:
|
||||
if any(a.get_components_by_class(c) for c in classes):
|
||||
paths[_asset_path_from_actor(a)] = None
|
||||
result = {}
|
||||
for p in paths:
|
||||
result[p] = ael.load_asset(p)
|
||||
return result
|
||||
|
||||
|
||||
def _run_assets(args, mutate):
|
||||
classes = _type_classes(args)
|
||||
distance = float(args.get("distance", 0.0))
|
||||
only_zero = args.get("only_zero", True)
|
||||
save = args.get("save", True)
|
||||
rows, total_set = [], 0
|
||||
for path, bp in _collect_assets(args).items():
|
||||
if bp is None:
|
||||
rows.append({"asset": path, "error": "load failed"}); continue
|
||||
comps = _template_components(bp, classes)
|
||||
vals, set_n = [], 0
|
||||
for _nm, c in comps:
|
||||
cur = float(c.get_editor_property("ld_max_draw_distance"))
|
||||
if mutate:
|
||||
if not (only_zero and abs(cur) > 0.001):
|
||||
c.set_editor_property("ld_max_draw_distance", distance)
|
||||
set_n += 1; cur = distance
|
||||
vals.append(round(cur, 1))
|
||||
if mutate and set_n:
|
||||
unreal.BlueprintEditorLibrary.compile_blueprint(bp)
|
||||
if save:
|
||||
unreal.EditorAssetLibrary.save_asset(path)
|
||||
total_set += 1
|
||||
rows.append({
|
||||
"asset": path, "components": len(comps),
|
||||
"zeros": sum(1 for v in vals if abs(v) < 0.001),
|
||||
"distinct": sorted(set(vals)), "set": set_n,
|
||||
})
|
||||
return {"ok": True, "target": "asset_template",
|
||||
"assets_modified": total_set, "rows": rows}
|
||||
|
||||
|
||||
# ---- target='level_instance' : edit placed actors --------------------------
|
||||
|
||||
def _run_instances(args, mutate):
|
||||
classes = _type_classes(args)
|
||||
distance = float(args.get("distance", 0.0))
|
||||
only_zero = args.get("only_zero", True)
|
||||
scope = args.get("scope") or "level"
|
||||
actors = (_eas().get_selected_level_actors() if scope == "selection"
|
||||
else _eas().get_all_level_actors())
|
||||
rows, changed_actors, changed_comps = [], 0, 0
|
||||
for a in actors:
|
||||
comps, seen = [], set()
|
||||
for c in classes:
|
||||
for comp in a.get_components_by_class(c):
|
||||
if comp.get_name() not in seen:
|
||||
seen.add(comp.get_name()); comps.append(comp)
|
||||
if not comps:
|
||||
continue
|
||||
vals, set_n = [], 0
|
||||
for comp in comps:
|
||||
cur = float(comp.get_editor_property("ld_max_draw_distance"))
|
||||
if mutate and not (only_zero and abs(cur) > 0.001):
|
||||
comp.modify()
|
||||
comp.set_editor_property("ld_max_draw_distance", distance)
|
||||
comp.set_cull_distance(distance) # updates cached + re-registers
|
||||
set_n += 1; cur = distance
|
||||
vals.append(round(cur, 1))
|
||||
if mutate and set_n:
|
||||
a.modify(); changed_actors += 1; changed_comps += set_n
|
||||
rows.append({"actor": a.get_actor_label(), "components": len(comps),
|
||||
"zeros": sum(1 for v in vals if abs(v) < 0.001),
|
||||
"distinct": sorted(set(vals)), "set": set_n})
|
||||
return {"ok": True, "target": "level_instance",
|
||||
"actors_modified": changed_actors, "components_set": changed_comps,
|
||||
"rows": rows, "note": "instance edits are lost on BP recompile / PLA repack"}
|
||||
|
||||
|
||||
# ---- op table --------------------------------------------------------------
|
||||
|
||||
def _dispatch(args, mutate):
|
||||
target = args.get("target") or "asset_template"
|
||||
if target == "asset_template":
|
||||
return _run_assets(args, mutate)
|
||||
if target == "level_instance":
|
||||
return _run_instances(args, mutate)
|
||||
return {"ok": False, "error": f"unknown target {target!r}; use 'asset_template' or 'level_instance'"}
|
||||
|
||||
|
||||
def audit(args):
|
||||
return _dispatch(args, mutate=False)
|
||||
|
||||
|
||||
def verify(args):
|
||||
return _dispatch(args, mutate=False)
|
||||
|
||||
|
||||
def apply(args):
|
||||
if "distance" not in args:
|
||||
return {"ok": False, "error": "apply requires 'distance' (cm)"}
|
||||
return _dispatch(args, mutate=True)
|
||||
|
||||
|
||||
OPS = {"audit": audit, "verify": verify, "apply": apply}
|
||||
|
||||
|
||||
def entrypoint(payload_json):
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
op = p.get("op"); fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result, p):
|
||||
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-CULL] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
153
ue_side/discover.py
Normal file
153
ue_side/discover.py
Normal file
@ -0,0 +1,153 @@
|
||||
"""Read-only discovery ops via MCPGraphLibrary v0.3.
|
||||
|
||||
Ops:
|
||||
list_open_bps {} — open BP editors
|
||||
list_graphs {bp_path} — all graphs of a BP
|
||||
describe_bp {bp_path} — parent/interfaces/vars/components/funcs/dispatchers
|
||||
get_selection {bp_path} — selected nodes in BP editor
|
||||
resolve_call {bp_path, function, guid} — CallFunction node → source bp/class
|
||||
find_var_refs {bp_path, name} — variable usage sites
|
||||
find_fn_refs {bp_path, name} — function call sites in this BP
|
||||
find_bp_by_parent {parent_class_path} — AssetRegistry search
|
||||
get_refs_of {asset_path} — assets referenced by this asset
|
||||
get_referencers {asset_path} — assets that reference this asset
|
||||
read_function {bp_path, function} — wrapper over apply_graph.read_graph for function
|
||||
"""
|
||||
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_bp(path: str):
|
||||
a = unreal.EditorAssetLibrary.load_asset(path)
|
||||
if not isinstance(a, unreal.Blueprint):
|
||||
raise RuntimeError(f"not a blueprint: {path!r}")
|
||||
return a
|
||||
|
||||
|
||||
def _find_graph(bp, name: str):
|
||||
L = _lib()
|
||||
if name in (None, "", "EventGraph"):
|
||||
return L.find_event_graph(bp)
|
||||
return L.find_function_graph(bp, name)
|
||||
|
||||
|
||||
def list_open_bps(args: dict) -> dict:
|
||||
return {"ok": True, "open": list(_lib().list_open_blueprints())}
|
||||
|
||||
|
||||
def list_graphs(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
raw = _lib().list_graphs_json(bp)
|
||||
return {"ok": True, "bp_path": args["bp_path"], **json.loads(raw)}
|
||||
|
||||
|
||||
def describe_bp(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
raw = _lib().describe_blueprint_json(bp)
|
||||
return {"ok": True, **json.loads(raw)}
|
||||
|
||||
|
||||
def get_selection(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
raw = _lib().get_selected_nodes_json(bp)
|
||||
return {"ok": True, "bp_path": args["bp_path"], **json.loads(raw)}
|
||||
|
||||
|
||||
def resolve_call(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
g = _find_graph(bp, args.get("function") or "EventGraph")
|
||||
if g is None:
|
||||
return {"ok": False, "error": "graph not found"}
|
||||
raw = _lib().resolve_function_source_json(g, args["guid"])
|
||||
return {"ok": True, **json.loads(raw)}
|
||||
|
||||
|
||||
def find_var_refs(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
raw = _lib().find_variable_references_json(bp, args["name"])
|
||||
return {"ok": True, **json.loads(raw)}
|
||||
|
||||
|
||||
def find_fn_refs(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
raw = _lib().find_function_references_json(bp, args["name"])
|
||||
return {"ok": True, **json.loads(raw)}
|
||||
|
||||
|
||||
def find_bp_by_parent(args: dict) -> dict:
|
||||
found = list(_lib().find_blueprints_by_parent(args["parent_class_path"]))
|
||||
return {"ok": True, "blueprints": found, "count": len(found)}
|
||||
|
||||
|
||||
def get_refs_of(args: dict) -> dict:
|
||||
return {"ok": True, "references": list(_lib().get_referenced_assets(args["asset_path"]))}
|
||||
|
||||
|
||||
def get_referencers(args: dict) -> dict:
|
||||
return {"ok": True, "referencers": list(_lib().get_referencers_of_asset(args["asset_path"]))}
|
||||
|
||||
|
||||
def read_function(args: dict) -> dict:
|
||||
# Convenience: read_graph for a named function
|
||||
import importlib, apply_graph
|
||||
importlib.reload(apply_graph)
|
||||
return apply_graph.read_graph(args["bp_path"], args.get("function") or "EventGraph")
|
||||
|
||||
|
||||
OPS = {
|
||||
"list_open_bps": list_open_bps,
|
||||
"list_graphs": list_graphs,
|
||||
"describe_bp": describe_bp,
|
||||
"get_selection": get_selection,
|
||||
"resolve_call": resolve_call,
|
||||
"find_var_refs": find_var_refs,
|
||||
"find_fn_refs": find_fn_refs,
|
||||
"find_bp_by_parent": find_bp_by_parent,
|
||||
"get_refs_of": get_refs_of,
|
||||
"get_referencers": get_referencers,
|
||||
"read_function": read_function,
|
||||
}
|
||||
|
||||
|
||||
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}"}, p={})
|
||||
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 + f"{rid}.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
unreal.log("[MCP-DISCOVER] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
186
ue_side/edit_ops.py
Normal file
186
ue_side/edit_ops.py
Normal file
@ -0,0 +1,186 @@
|
||||
"""Single-node / single-edge edit ops + navigation.
|
||||
|
||||
Ops:
|
||||
delete_node {bp_path, function?, guid}
|
||||
break_link {bp_path, function?, from:[guid,pin], to:[guid,pin]}
|
||||
break_all {bp_path, function?, guid}
|
||||
move_nodes {bp_path, function?, moves:[{guid,x,y}]}
|
||||
resize_comment {bp_path, function?, guid, width, height}
|
||||
set_comment {bp_path, function?, guid, text, bubble?}
|
||||
open_bp {bp_path}
|
||||
open_graph {bp_path, function}
|
||||
jump_to_node {bp_path, function?, guid}
|
||||
add_case_pin {bp_path, function?, guid} — switch ops
|
||||
format_text {bp_path, function?, guid, format}
|
||||
reconstruct {bp_path, function?, guid}
|
||||
"""
|
||||
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_bp(path: str):
|
||||
a = unreal.EditorAssetLibrary.load_asset(path)
|
||||
if not isinstance(a, unreal.Blueprint):
|
||||
raise RuntimeError(f"not a blueprint: {path!r}")
|
||||
return a
|
||||
|
||||
|
||||
def _graph(bp, fn: str | None):
|
||||
L = _lib()
|
||||
if fn in (None, "", "EventGraph"):
|
||||
return L.find_event_graph(bp)
|
||||
return L.find_function_graph(bp, fn)
|
||||
|
||||
|
||||
def _compile_save(bp):
|
||||
_lib().compile_and_save_blueprint(bp)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(bp)
|
||||
|
||||
|
||||
def delete_node(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
g = _graph(bp, args.get("function"))
|
||||
ok = bool(_lib().delete_node(g, args["guid"]))
|
||||
if ok: _compile_save(bp)
|
||||
return {"ok": ok, "guid": args["guid"]}
|
||||
|
||||
|
||||
def break_link(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
g = _graph(bp, args.get("function"))
|
||||
(fg, fp) = args["from"]; (tg, tp) = args["to"]
|
||||
ok = bool(_lib().break_link(g, fg, fp, tg, tp))
|
||||
if ok: _compile_save(bp)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def break_all(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
g = _graph(bp, args.get("function"))
|
||||
ok = bool(_lib().break_all_node_links(g, args["guid"]))
|
||||
if ok: _compile_save(bp)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def move_nodes(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
g = _graph(bp, args.get("function"))
|
||||
moves = args.get("moves") or []
|
||||
ids = [m["guid"] for m in moves]
|
||||
pos = [unreal.Vector2D(float(m["x"]), float(m["y"])) for m in moves]
|
||||
n = int(_lib().move_nodes(g, ids, pos))
|
||||
return {"ok": n > 0, "moved": n}
|
||||
|
||||
|
||||
def resize_comment(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
g = _graph(bp, args.get("function"))
|
||||
ok = bool(_lib().resize_comment_node(g, args["guid"], unreal.Vector2D(float(args["width"]), float(args["height"]))))
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def set_comment(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
g = _graph(bp, args.get("function"))
|
||||
ok = bool(_lib().set_node_comment(g, args["guid"], str(args.get("text", "")), bool(args.get("bubble", False))))
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def open_bp(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
return {"ok": bool(_lib().open_blueprint_editor(bp))}
|
||||
|
||||
|
||||
def open_graph(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
g = _graph(bp, args.get("function"))
|
||||
return {"ok": bool(_lib().open_graph_in_editor(bp, g))}
|
||||
|
||||
|
||||
def jump_to_node(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
g = _graph(bp, args.get("function"))
|
||||
return {"ok": bool(_lib().jump_to_node_in_editor(bp, g, args["guid"]))}
|
||||
|
||||
|
||||
def add_case_pin(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
g = _graph(bp, args.get("function"))
|
||||
ok = bool(_lib().add_case_pin_to_switch(g, args["guid"]))
|
||||
if ok: _compile_save(bp)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def format_text(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
g = _graph(bp, args.get("function"))
|
||||
ok = bool(_lib().format_text_set_format(g, args["guid"], args["format"]))
|
||||
if ok: _compile_save(bp)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def reconstruct(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
g = _graph(bp, args.get("function"))
|
||||
ok = bool(_lib().reconstruct_node(g, args["guid"]))
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
OPS = {
|
||||
"delete_node": delete_node,
|
||||
"break_link": break_link,
|
||||
"break_all": break_all,
|
||||
"move_nodes": move_nodes,
|
||||
"resize_comment": resize_comment,
|
||||
"set_comment": set_comment,
|
||||
"open_bp": open_bp,
|
||||
"open_graph": open_graph,
|
||||
"jump_to_node": jump_to_node,
|
||||
"add_case_pin": add_case_pin,
|
||||
"format_text": format_text,
|
||||
"reconstruct": reconstruct,
|
||||
}
|
||||
|
||||
|
||||
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 + f"{rid}.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
unreal.log("[MCP-EDIT] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
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
|
||||
187
ue_side/input.py
Normal file
187
ue_side/input.py
Normal file
@ -0,0 +1,187 @@
|
||||
"""Enhanced Input ops — Input Actions, Input Mapping Contexts.
|
||||
|
||||
Ops:
|
||||
create_input_action {path, value_type?:'Bool|Axis1D|Axis2D|Axis3D'}
|
||||
create_input_mapping_context {path}
|
||||
add_mapping {imc_path, action_path, key, modifiers?:[class_path], triggers?:[class_path]}
|
||||
remove_mapping {imc_path, index}
|
||||
list_mappings {imc_path}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _at(): return unreal.AssetToolsHelpers.get_asset_tools()
|
||||
def _load(p): return unreal.EditorAssetLibrary.load_asset(p)
|
||||
def _split(p):
|
||||
folder, name = p.rsplit("/", 1)
|
||||
return folder, name
|
||||
|
||||
|
||||
VALUE_TYPES = {
|
||||
"Bool": "Boolean",
|
||||
"Axis1D": "Axis1D",
|
||||
"Axis2D": "Axis2D",
|
||||
"Axis3D": "Axis3D",
|
||||
}
|
||||
|
||||
|
||||
def create_input_action(args):
|
||||
folder, name = _split(args["path"])
|
||||
factory_cls = getattr(unreal, "InputActionFactory", None)
|
||||
asset_cls = getattr(unreal, "InputAction", None)
|
||||
if factory_cls is None or asset_cls is None:
|
||||
return {"ok": False, "error": "Enhanced Input plugin not enabled"}
|
||||
asset = _at().create_asset(name, folder, asset_cls, factory_cls())
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create returned None"}
|
||||
vt = args.get("value_type") or "Bool"
|
||||
enum_name = VALUE_TYPES.get(vt)
|
||||
if enum_name:
|
||||
try:
|
||||
enum_obj = getattr(unreal.InputActionValueType, enum_name)
|
||||
asset.set_editor_property("value_type", enum_obj)
|
||||
except Exception:
|
||||
pass
|
||||
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def create_input_mapping_context(args):
|
||||
folder, name = _split(args["path"])
|
||||
factory_cls = getattr(unreal, "InputMappingContextFactory", None)
|
||||
asset_cls = getattr(unreal, "InputMappingContext", None)
|
||||
if factory_cls is None or asset_cls is None:
|
||||
return {"ok": False, "error": "Enhanced Input plugin not enabled"}
|
||||
asset = _at().create_asset(name, folder, asset_cls, factory_cls())
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create returned None"}
|
||||
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def add_mapping(args):
|
||||
imc = _load(args["imc_path"])
|
||||
ia = _load(args["action_path"])
|
||||
if imc is None or ia is None:
|
||||
return {"ok": False, "error": "imc or action not found"}
|
||||
key = unreal.InputCoreLibrary.input_key_from_string(args["key"]) \
|
||||
if hasattr(unreal, "InputCoreLibrary") and hasattr(unreal.InputCoreLibrary, "input_key_from_string") \
|
||||
else unreal.Key(args["key"])
|
||||
mapping_cls = getattr(unreal, "EnhancedActionKeyMapping", None)
|
||||
if mapping_cls is None:
|
||||
try:
|
||||
imc.map_key(ia, key)
|
||||
unreal.EditorAssetLibrary.save_asset(imc.get_path_name())
|
||||
return {"ok": True, "note": "added via map_key()"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"map_key failed: {e}"}
|
||||
m = mapping_cls()
|
||||
m.set_editor_property("action", ia)
|
||||
m.set_editor_property("key", key)
|
||||
# Modifiers/triggers (class CDOs):
|
||||
for cp in (args.get("modifiers") or []):
|
||||
mc = unreal.load_class(None, cp)
|
||||
if mc is not None:
|
||||
try:
|
||||
inst = unreal.new_object(mc)
|
||||
mods = list(m.get_editor_property("modifiers") or [])
|
||||
mods.append(inst)
|
||||
m.set_editor_property("modifiers", mods)
|
||||
except Exception:
|
||||
pass
|
||||
for cp in (args.get("triggers") or []):
|
||||
tc = unreal.load_class(None, cp)
|
||||
if tc is not None:
|
||||
try:
|
||||
inst = unreal.new_object(tc)
|
||||
trigs = list(m.get_editor_property("triggers") or [])
|
||||
trigs.append(inst)
|
||||
m.set_editor_property("triggers", trigs)
|
||||
except Exception:
|
||||
pass
|
||||
mappings = list(imc.get_editor_property("mappings") or [])
|
||||
mappings.append(m)
|
||||
imc.set_editor_property("mappings", mappings)
|
||||
unreal.EditorAssetLibrary.save_asset(imc.get_path_name())
|
||||
return {"ok": True, "index": len(mappings) - 1}
|
||||
|
||||
|
||||
def remove_mapping(args):
|
||||
imc = _load(args["imc_path"])
|
||||
if imc is None:
|
||||
return {"ok": False, "error": "imc not found"}
|
||||
mappings = list(imc.get_editor_property("mappings") or [])
|
||||
idx = int(args["index"])
|
||||
if idx < 0 or idx >= len(mappings):
|
||||
return {"ok": False, "error": "index out of range"}
|
||||
mappings.pop(idx)
|
||||
imc.set_editor_property("mappings", mappings)
|
||||
unreal.EditorAssetLibrary.save_asset(imc.get_path_name())
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def list_mappings(args):
|
||||
imc = _load(args["imc_path"])
|
||||
if imc is None:
|
||||
return {"ok": False, "error": "imc not found"}
|
||||
out = []
|
||||
for i, m in enumerate(imc.get_editor_property("mappings") or []):
|
||||
try:
|
||||
a = m.get_editor_property("action")
|
||||
k = m.get_editor_property("key")
|
||||
out.append({
|
||||
"index": i,
|
||||
"action": a.get_path_name().split(".")[0] if a else None,
|
||||
"key": str(k),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "mappings": out}
|
||||
|
||||
|
||||
OPS = {
|
||||
"create_input_action": create_input_action,
|
||||
"create_input_mapping_context": create_input_mapping_context,
|
||||
"add_mapping": add_mapping,
|
||||
"remove_mapping": remove_mapping,
|
||||
"list_mappings": list_mappings,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json):
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
op = p.get("op"); fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result, p):
|
||||
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-INPUT] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
202
ue_side/level.py
Normal file
202
ue_side/level.py
Normal file
@ -0,0 +1,202 @@
|
||||
"""Level / scene ops via UE EditorActorSubsystem — pure Python, no C++ needed.
|
||||
|
||||
Ops (dispatched by `entrypoint(payload_json)`):
|
||||
spawn_actor {class, location:[x,y,z]?, rotation:[p,y,r]?, scale:[x,y,z]?, name?}
|
||||
destroy {name} — by actor label or path
|
||||
get_selected {} — list selected actors
|
||||
select {names:[]}
|
||||
get_by_class {class}
|
||||
find_by_name {name}
|
||||
set_transform {name, location?, rotation?, scale?}
|
||||
attach {child, parent, socket?}
|
||||
save_level {}
|
||||
load_level {path}
|
||||
current_level {}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _ess():
|
||||
return unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
|
||||
|
||||
def _load_class(p: str):
|
||||
if "/" in p:
|
||||
return unreal.load_class(None, p)
|
||||
for prefix in ("/Script/Engine.", "/Script/UnrealEd."):
|
||||
c = unreal.load_class(None, prefix + p)
|
||||
if c is not None:
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _vec(v, default=(0, 0, 0)):
|
||||
if v is None:
|
||||
v = default
|
||||
return unreal.Vector(float(v[0]), float(v[1]), float(v[2]))
|
||||
|
||||
|
||||
def _rot(v, default=(0, 0, 0)):
|
||||
if v is None:
|
||||
v = default
|
||||
# rotator: pitch, yaw, roll
|
||||
return unreal.Rotator(float(v[0]), float(v[1]), float(v[2]))
|
||||
|
||||
|
||||
def _find_actor(name: str):
|
||||
for a in _ess().get_all_level_actors():
|
||||
if not a:
|
||||
continue
|
||||
if a.get_actor_label() == name or a.get_name() == name or a.get_path_name() == name:
|
||||
return a
|
||||
return None
|
||||
|
||||
|
||||
def spawn_actor(args: dict) -> dict:
|
||||
cls = _load_class(args["class"])
|
||||
if cls is None:
|
||||
return {"ok": False, "error": f"class not found: {args['class']!r}"}
|
||||
loc = _vec(args.get("location"))
|
||||
rot = _rot(args.get("rotation"))
|
||||
scale = _vec(args.get("scale"), (1, 1, 1))
|
||||
actor = _ess().spawn_actor_from_class(cls, loc, rot)
|
||||
if actor is None:
|
||||
return {"ok": False, "error": "spawn returned None"}
|
||||
actor.set_actor_scale3d(scale)
|
||||
if args.get("name"):
|
||||
actor.set_actor_label(args["name"])
|
||||
return {"ok": True, "name": actor.get_actor_label(), "path": actor.get_path_name()}
|
||||
|
||||
|
||||
def destroy(args: dict) -> dict:
|
||||
a = _find_actor(args["name"])
|
||||
if not a:
|
||||
return {"ok": False, "error": "actor not found"}
|
||||
_ess().destroy_actor(a)
|
||||
return {"ok": True, "name": args["name"]}
|
||||
|
||||
|
||||
def get_selected(args: dict) -> dict:
|
||||
return {"ok": True, "actors": [a.get_actor_label() for a in _ess().get_selected_level_actors()]}
|
||||
|
||||
|
||||
def select(args: dict) -> dict:
|
||||
names = args.get("names") or []
|
||||
found = [a for a in (_find_actor(n) for n in names) if a]
|
||||
_ess().set_selected_level_actors(found)
|
||||
return {"ok": True, "selected": [a.get_actor_label() for a in found]}
|
||||
|
||||
|
||||
def get_by_class(args: dict) -> dict:
|
||||
cls = _load_class(args["class"])
|
||||
if cls is None:
|
||||
return {"ok": False, "error": f"class not found: {args['class']!r}"}
|
||||
found = [a for a in _ess().get_all_level_actors() if isinstance(a, cls)]
|
||||
return {"ok": True, "actors": [a.get_actor_label() for a in found]}
|
||||
|
||||
|
||||
def find_by_name(args: dict) -> dict:
|
||||
a = _find_actor(args["name"])
|
||||
if not a:
|
||||
return {"ok": False, "error": "not found"}
|
||||
return {"ok": True, "name": a.get_actor_label(), "path": a.get_path_name(), "class": a.get_class().get_name()}
|
||||
|
||||
|
||||
def set_transform(args: dict) -> dict:
|
||||
a = _find_actor(args["name"])
|
||||
if not a:
|
||||
return {"ok": False, "error": "actor not found"}
|
||||
if args.get("location") is not None:
|
||||
a.set_actor_location(_vec(args["location"]), False, False)
|
||||
if args.get("rotation") is not None:
|
||||
a.set_actor_rotation(_rot(args["rotation"]), False)
|
||||
if args.get("scale") is not None:
|
||||
a.set_actor_scale3d(_vec(args["scale"], (1, 1, 1)))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def attach(args: dict) -> dict:
|
||||
child = _find_actor(args["child"])
|
||||
parent = _find_actor(args["parent"])
|
||||
if not child or not parent:
|
||||
return {"ok": False, "error": "child or parent not found"}
|
||||
socket = args.get("socket") or ""
|
||||
child.attach_to_actor(parent, socket, unreal.AttachmentRule.KEEP_WORLD,
|
||||
unreal.AttachmentRule.KEEP_WORLD, unreal.AttachmentRule.KEEP_WORLD, False)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def save_level(args: dict) -> dict:
|
||||
les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
||||
ok = les.save_current_level()
|
||||
return {"ok": bool(ok)}
|
||||
|
||||
|
||||
def load_level(args: dict) -> dict:
|
||||
les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
||||
ok = les.load_level(args["path"])
|
||||
return {"ok": bool(ok), "path": args["path"]}
|
||||
|
||||
|
||||
def current_level(args: dict) -> dict:
|
||||
les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
|
||||
name = les.get_current_level_name() if hasattr(les, "get_current_level_name") else ""
|
||||
world = unreal.EditorLevelLibrary.get_editor_world() if hasattr(unreal, "EditorLevelLibrary") else None
|
||||
return {"ok": True, "name": name, "world": world.get_path_name() if world else None}
|
||||
|
||||
|
||||
OPS = {
|
||||
"spawn_actor": spawn_actor,
|
||||
"destroy": destroy,
|
||||
"get_selected": get_selected,
|
||||
"select": select,
|
||||
"get_by_class": get_by_class,
|
||||
"find_by_name": find_by_name,
|
||||
"set_transform": set_transform,
|
||||
"attach": attach,
|
||||
"save_level": save_level,
|
||||
"load_level": load_level,
|
||||
"current_level": current_level,
|
||||
}
|
||||
|
||||
|
||||
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}"}, {})
|
||||
op = p.get("op")
|
||||
fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {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-LEVEL] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
307
ue_side/live_coding.py
Normal file
307
ue_side/live_coding.py
Normal file
@ -0,0 +1,307 @@
|
||||
"""Live Coding control — status, compile trigger, error parsing.
|
||||
|
||||
Why this exists: the AI repeatedly hits "Live Coding is open but won't build /
|
||||
won't recompile". This module wraps ILiveCodingModule via Python where exposed,
|
||||
falls back to console commands otherwise, and scrapes the latest LiveCoding log
|
||||
for compile errors so the AI can iterate without screenshots.
|
||||
|
||||
Ops:
|
||||
status {} — is_enabled, has_started_compile, pending_changes (best-effort)
|
||||
compile {} — kick off a Live Coding patch
|
||||
enable {}
|
||||
disable {}
|
||||
get_errors {tail?:200} — read last LiveCoding.log lines, filter for errors
|
||||
open_lc_console{} — opens the LC window
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _project_log_path():
|
||||
"""The main editor log: Saved/Logs/<ProjectName>.log. Project name is derived
|
||||
from the .uproject so this is correct in any project, not just one."""
|
||||
if unreal is None:
|
||||
return None
|
||||
try:
|
||||
up = unreal.Paths.get_project_file_path()
|
||||
name = os.path.splitext(os.path.basename(up))[0]
|
||||
return unreal.Paths.project_saved_dir() + f"Logs/{name}.log"
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _world():
|
||||
return unreal.EditorLevelLibrary.get_editor_world()
|
||||
|
||||
|
||||
def _exec(cmd):
|
||||
unreal.SystemLibrary.execute_console_command(_world(), cmd)
|
||||
|
||||
|
||||
def _lc_log_path():
|
||||
"""Search a wide range of locations for LiveCoding.log + LiveCoding-backup-*.log.
|
||||
Returns newest file by mtime."""
|
||||
candidates = []
|
||||
|
||||
def _add_dir(d):
|
||||
if not d or not os.path.isdir(d):
|
||||
return
|
||||
try:
|
||||
for fn in os.listdir(d):
|
||||
if fn.lower().startswith("livecoding") and fn.lower().endswith(".log"):
|
||||
candidates.append(os.path.join(d, fn))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Project Saved/Logs
|
||||
try:
|
||||
_add_dir(unreal.Paths.project_saved_dir() + "Logs")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Engine Saved/Logs (project-relative)
|
||||
try:
|
||||
_add_dir(unreal.Paths.engine_saved_dir() + "Logs")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Engine Programs / LiveCodingConsole — this is where LC actually writes its server log.
|
||||
# unreal.Paths.engine_dir() gives the engine root (relative or absolute).
|
||||
try:
|
||||
engine_root = unreal.Paths.convert_relative_path_to_full(unreal.Paths.engine_dir())
|
||||
_add_dir(os.path.join(engine_root, "Programs", "LiveCodingConsole", "Saved", "Logs"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# %LOCALAPPDATA%\UnrealEngine\<Engine>\Saved\Logs
|
||||
appdata = os.environ.get("LOCALAPPDATA", "")
|
||||
if appdata:
|
||||
ue_root = os.path.join(appdata, "UnrealEngine")
|
||||
if os.path.isdir(ue_root):
|
||||
for sub in os.listdir(ue_root):
|
||||
_add_dir(os.path.join(ue_root, sub, "Saved", "Logs"))
|
||||
|
||||
# %APPDATA%\Unreal Engine\AutomationTool\Logs (sometimes UBT writes here)
|
||||
appdata_roam = os.environ.get("APPDATA", "")
|
||||
if appdata_roam:
|
||||
_add_dir(os.path.join(appdata_roam, "Unreal Engine", "AutomationTool", "Logs"))
|
||||
|
||||
# Programs/UnrealBuildTool/Log.txt — different file but useful for compile errors
|
||||
# We only care about LiveCoding here; UBT log is separate.
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def status(args):
|
||||
out = {"ok": True}
|
||||
try:
|
||||
lcs = getattr(unreal, "LiveCodingSubsystem", None)
|
||||
sub = unreal.get_editor_subsystem(lcs) if lcs else None
|
||||
if sub is not None:
|
||||
for attr in ("is_enabled", "has_started_compile", "is_compiling"):
|
||||
if hasattr(sub, attr):
|
||||
out[attr] = bool(getattr(sub, attr)())
|
||||
else:
|
||||
out["note"] = "LiveCodingSubsystem not exposed in Python; using log scrape + console"
|
||||
except Exception as e:
|
||||
out["query_error"] = str(e)
|
||||
log = _lc_log_path()
|
||||
out["lc_log_path"] = log
|
||||
out["lc_log_exists"] = bool(log)
|
||||
# Always also expose the main project log as the fallback error source.
|
||||
fb = _project_log_path()
|
||||
if fb:
|
||||
out["fallback_log"] = fb
|
||||
return out
|
||||
|
||||
|
||||
def compile(args):
|
||||
try:
|
||||
lcs = getattr(unreal, "LiveCodingSubsystem", None)
|
||||
sub = unreal.get_editor_subsystem(lcs) if lcs else None
|
||||
if sub is not None and hasattr(sub, "compile"):
|
||||
sub.compile()
|
||||
return {"ok": True, "via": "subsystem"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
try:
|
||||
_exec("LiveCoding.Compile")
|
||||
return {"ok": True, "via": "console"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def enable(args):
|
||||
try: _exec("LiveCoding.Enable")
|
||||
except Exception as e: return {"ok": False, "error": str(e)}
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def disable(args):
|
||||
try: _exec("LiveCoding.Disable")
|
||||
except Exception as e: return {"ok": False, "error": str(e)}
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def open_lc_console(args):
|
||||
try: _exec("LiveCoding.Console")
|
||||
except Exception as e: return {"ok": False, "error": str(e)}
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
_ERR_RX = re.compile(r"\b(error\s+[A-Z]\d{3,5}|fatal error|undefined reference|cannot open|LINK\s*:\s*fatal|: error:|: warning:)\b", re.I)
|
||||
_LC_CATEGORY_RX = re.compile(r"Log(LiveCoding|LiveCodingServer|Compile|HotReload|UnrealBuildTool)\s*:", re.I)
|
||||
_LC_COMPILE_START_RX = re.compile(r"(Manual recompile triggered|Creating patch|Compiling|Starting compile)", re.I)
|
||||
_LC_COMPILE_END_RX = re.compile(r"Finished\s*\(([\d.]+)s\)", re.I)
|
||||
_LC_NO_CHANGES_RX = re.compile(r"No changes detected|nothing to compile", re.I)
|
||||
|
||||
|
||||
def get_errors(args):
|
||||
tail = int(args.get("tail") or 200)
|
||||
log = _lc_log_path()
|
||||
sources_used = []
|
||||
|
||||
if log is not None:
|
||||
sources_used.append(log)
|
||||
try:
|
||||
with open(log, "r", encoding="utf-8", errors="replace") as f:
|
||||
lines = f.readlines()
|
||||
errs = [l.rstrip() for l in lines if _ERR_RX.search(l)]
|
||||
if errs:
|
||||
return {"ok": True, "source": "LiveCoding.log", "log_path": log,
|
||||
"total_lines": len(lines), "errors_count": len(errs),
|
||||
"errors": errs[-tail:]}
|
||||
except Exception as e:
|
||||
sources_used.append(f"(read error: {e})")
|
||||
|
||||
# Fallback: project log, filtered by LiveCoding/Compile/HotReload/UBT categories
|
||||
fallback = _project_log_path()
|
||||
if fallback and os.path.isfile(fallback):
|
||||
sources_used.append(fallback)
|
||||
try:
|
||||
with open(fallback, "r", encoding="utf-8", errors="replace") as f:
|
||||
lines = f.readlines()
|
||||
relevant = [l.rstrip() for l in lines
|
||||
if _LC_CATEGORY_RX.search(l) or _ERR_RX.search(l)]
|
||||
errs = [l for l in relevant if _ERR_RX.search(l) or "Error" in l or "Fatal" in l]
|
||||
return {"ok": True, "source": "project_log_fallback",
|
||||
"log_path": fallback, "total_lines": len(lines),
|
||||
"lc_lines": len(relevant), "errors_count": len(errs),
|
||||
"lc_recent": relevant[-tail:],
|
||||
"errors": errs[-tail:],
|
||||
"sources_searched": sources_used}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"fallback read failed: {e}",
|
||||
"sources_searched": sources_used}
|
||||
|
||||
return {"ok": False, "error": "no LiveCoding.log and no fallback project log",
|
||||
"sources_searched": sources_used}
|
||||
|
||||
|
||||
def last_compile(args):
|
||||
"""Find the most recent compile cycle in LiveCodingConsole.log.
|
||||
|
||||
Returns: status ('success'|'failed'|'in_progress'|'no_recent'),
|
||||
duration_s, timestamp, lines for the cycle, and any errors.
|
||||
"""
|
||||
log = _lc_log_path()
|
||||
if log is None:
|
||||
return {"ok": False, "error": "no LC log found"}
|
||||
try:
|
||||
with open(log, "r", encoding="utf-8", errors="replace") as f:
|
||||
lines = f.readlines()
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
# Walk backwards: find the last "Creating patch" / "Manual recompile" marker
|
||||
start_idx = -1
|
||||
for i in range(len(lines) - 1, -1, -1):
|
||||
if _LC_COMPILE_START_RX.search(lines[i]):
|
||||
start_idx = i
|
||||
break
|
||||
if start_idx == -1:
|
||||
return {"ok": True, "log_path": log, "status": "no_recent",
|
||||
"note": "no compile cycle found in log"}
|
||||
|
||||
cycle = [l.rstrip() for l in lines[start_idx:]]
|
||||
timestamp_match = re.search(r"\[([\d.\-:]+)\]", cycle[0])
|
||||
timestamp = timestamp_match.group(1) if timestamp_match else None
|
||||
|
||||
end_match = None
|
||||
for l in cycle:
|
||||
m = _LC_COMPILE_END_RX.search(l)
|
||||
if m:
|
||||
end_match = m
|
||||
break
|
||||
|
||||
errors = [l for l in cycle if _ERR_RX.search(l)]
|
||||
if errors:
|
||||
status_str = "failed"
|
||||
elif end_match:
|
||||
status_str = "success"
|
||||
else:
|
||||
status_str = "in_progress"
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"log_path": log,
|
||||
"status": status_str,
|
||||
"timestamp": timestamp,
|
||||
"duration_s": float(end_match.group(1)) if end_match else None,
|
||||
"errors_count": len(errors),
|
||||
"errors": errors,
|
||||
"cycle_lines": cycle,
|
||||
}
|
||||
|
||||
|
||||
OPS = {
|
||||
"status": status,
|
||||
"compile": compile,
|
||||
"enable": enable,
|
||||
"disable": disable,
|
||||
"open_lc_console": open_lc_console,
|
||||
"get_errors": get_errors,
|
||||
"last_compile": last_compile,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json):
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
op = p.get("op"); fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result, p):
|
||||
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-LC] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
776
ue_side/materials.py
Normal file
776
ue_side/materials.py
Normal file
@ -0,0 +1,776 @@
|
||||
"""Material graph and material instance operations for UEBlueprintMCP.
|
||||
|
||||
Ops:
|
||||
create_material{path}
|
||||
set_material_settings{asset_path,properties}
|
||||
inspect_material_settings{asset_path,properties:[...]}
|
||||
create_material_instance{path,parent?}
|
||||
read_graph{asset_path}
|
||||
apply_graph{asset_path,clear_existing?,layout?,save?,nodes:[...],edges:[...],outputs:{...}}
|
||||
add_node{asset_path,id?,class,position?,properties?}
|
||||
delete_node{asset_path,guid|name}
|
||||
move_nodes{asset_path,moves:[{guid|name,x,y}]}
|
||||
set_node_property{asset_path,guid|name,property,value}
|
||||
connect{asset_path,from:[guid|name,output],to:[guid|name,input]}
|
||||
connect_output{asset_path,from:[guid|name,output],property}
|
||||
layout{asset_path}
|
||||
recompile_save{asset_path}
|
||||
set_instance_parent{asset_path,parent}
|
||||
set_instance_scalar{asset_path,name,value}
|
||||
set_instance_vector{asset_path,name,value}
|
||||
set_instance_texture{asset_path,name,value}
|
||||
list_parameters{asset_path}
|
||||
open_asset{asset_path}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
import graph_layout
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
MATERIAL_PROPERTY_MAP = {
|
||||
"basecolor": "MP_BASE_COLOR",
|
||||
"base_color": "MP_BASE_COLOR",
|
||||
"metallic": "MP_METALLIC",
|
||||
"specular": "MP_SPECULAR",
|
||||
"roughness": "MP_ROUGHNESS",
|
||||
"emissive": "MP_EMISSIVE_COLOR",
|
||||
"emissivecolor": "MP_EMISSIVE_COLOR",
|
||||
"emissive_color": "MP_EMISSIVE_COLOR",
|
||||
"opacity": "MP_OPACITY",
|
||||
"opacitymask": "MP_OPACITY_MASK",
|
||||
"opacity_mask": "MP_OPACITY_MASK",
|
||||
"normal": "MP_NORMAL",
|
||||
"worldpositionoffset": "MP_WORLD_POSITION_OFFSET",
|
||||
"world_position_offset": "MP_WORLD_POSITION_OFFSET",
|
||||
"ambientocclusion": "MP_AMBIENT_OCCLUSION",
|
||||
"ambient_occlusion": "MP_AMBIENT_OCCLUSION",
|
||||
"refraction": "MP_REFRACTION",
|
||||
"tangent": "MP_TANGENT",
|
||||
"displacement": "MP_DISPLACEMENT",
|
||||
"subsurfacecolor": "MP_SUBSURFACE_COLOR",
|
||||
"subsurface_color": "MP_SUBSURFACE_COLOR",
|
||||
"customizeduvs0": "MP_CUSTOMIZED_UV_0",
|
||||
"customizeduv0": "MP_CUSTOMIZED_UV_0",
|
||||
"customizeduvs1": "MP_CUSTOMIZED_UV_1",
|
||||
"customizeduv1": "MP_CUSTOMIZED_UV_1",
|
||||
"customizeduvs2": "MP_CUSTOMIZED_UV_2",
|
||||
"customizeduv2": "MP_CUSTOMIZED_UV_2",
|
||||
"customizeduvs3": "MP_CUSTOMIZED_UV_3",
|
||||
"customizeduv3": "MP_CUSTOMIZED_UV_3",
|
||||
"customizeduvs4": "MP_CUSTOMIZED_UV_4",
|
||||
"customizeduv4": "MP_CUSTOMIZED_UV_4",
|
||||
"customizeduvs5": "MP_CUSTOMIZED_UV_5",
|
||||
"customizeduv5": "MP_CUSTOMIZED_UV_5",
|
||||
"customizeduvs6": "MP_CUSTOMIZED_UV_6",
|
||||
"customizeduv6": "MP_CUSTOMIZED_UV_6",
|
||||
"customizeduvs7": "MP_CUSTOMIZED_UV_7",
|
||||
"customizeduv7": "MP_CUSTOMIZED_UV_7",
|
||||
}
|
||||
|
||||
|
||||
def _asset_tools():
|
||||
return unreal.AssetToolsHelpers.get_asset_tools()
|
||||
|
||||
|
||||
def _mel():
|
||||
lib = getattr(unreal, "MaterialEditingLibrary", None)
|
||||
if lib is None:
|
||||
raise RuntimeError("unreal.MaterialEditingLibrary is unavailable. Enable the Material Editor scripting support in UE.")
|
||||
return lib
|
||||
|
||||
|
||||
def _split_pkg(path: str):
|
||||
if "/" not in path:
|
||||
raise RuntimeError(f"bad asset path {path!r}")
|
||||
return path.rsplit("/", 1)
|
||||
|
||||
|
||||
def _load_asset(path: str):
|
||||
asset = unreal.EditorAssetLibrary.load_asset(path)
|
||||
if asset is None:
|
||||
try:
|
||||
asset = unreal.load_asset(path)
|
||||
except Exception:
|
||||
asset = None
|
||||
if asset is None and "." not in path.rsplit("/", 1)[-1]:
|
||||
try:
|
||||
object_path = path + "." + path.rsplit("/", 1)[-1]
|
||||
asset = unreal.load_asset(object_path)
|
||||
except Exception:
|
||||
asset = None
|
||||
if asset is None:
|
||||
try:
|
||||
for selected_asset in unreal.EditorUtilityLibrary.get_selected_assets():
|
||||
if selected_asset.get_path_name().startswith(path):
|
||||
asset = selected_asset
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if asset is None:
|
||||
raise RuntimeError(f"asset not found: {path}")
|
||||
return asset
|
||||
|
||||
|
||||
def _load_material(path: str):
|
||||
asset = _load_asset(path)
|
||||
if not isinstance(asset, unreal.Material):
|
||||
raise RuntimeError(f"{path} is not a Material ({type(asset).__name__})")
|
||||
return asset
|
||||
|
||||
|
||||
def _load_material_function(path: str):
|
||||
asset = _load_asset(path)
|
||||
if not isinstance(asset, unreal.MaterialFunction):
|
||||
raise RuntimeError(f"{path} is not a MaterialFunction ({type(asset).__name__})")
|
||||
return asset
|
||||
|
||||
|
||||
def _load_material_interface(path: str):
|
||||
asset = _load_asset(path)
|
||||
if not isinstance(asset, unreal.MaterialInterface):
|
||||
raise RuntimeError(f"{path} is not a MaterialInterface ({type(asset).__name__})")
|
||||
return asset
|
||||
|
||||
|
||||
def _load_expression_class(path_or_name: str):
|
||||
if not path_or_name:
|
||||
raise RuntimeError("expression class is required")
|
||||
candidates = [path_or_name]
|
||||
if "/" not in path_or_name:
|
||||
name = path_or_name
|
||||
if not name.startswith("MaterialExpression"):
|
||||
candidates.append("MaterialExpression" + name)
|
||||
candidates.extend([
|
||||
"/Script/Engine." + name,
|
||||
"/Script/Engine.MaterialExpression" + name,
|
||||
])
|
||||
for candidate in candidates:
|
||||
cls = unreal.load_class(None, candidate) if "/" in candidate else None
|
||||
if cls is not None:
|
||||
return cls
|
||||
raise RuntimeError(f"material expression class not found: {path_or_name!r}")
|
||||
|
||||
|
||||
def _material_expressions(asset):
|
||||
lib = getattr(unreal, "MCPMaterialLibrary", None)
|
||||
if lib is not None:
|
||||
try:
|
||||
return list(lib.get_material_expressions(asset) or []), "MCPMaterialLibrary"
|
||||
except Exception:
|
||||
pass
|
||||
for prop in ("expressions", "function_expressions"):
|
||||
try:
|
||||
expressions = asset.get_editor_property(prop)
|
||||
if expressions is not None:
|
||||
return list(expressions), prop
|
||||
except Exception:
|
||||
pass
|
||||
return [], ""
|
||||
|
||||
|
||||
def _find_expression(asset, ident: str):
|
||||
if not ident:
|
||||
raise RuntimeError("node guid/name is required")
|
||||
lib = getattr(unreal, "MCPMaterialLibrary", None)
|
||||
if lib is not None:
|
||||
try:
|
||||
expr = lib.find_material_expression(asset, ident)
|
||||
if expr is not None:
|
||||
return expr
|
||||
except Exception:
|
||||
pass
|
||||
expressions, _ = _material_expressions(asset)
|
||||
for expr in expressions:
|
||||
if _expr_guid(expr) == ident or expr.get_name() == ident:
|
||||
return expr
|
||||
raise RuntimeError(f"material expression not found: {ident}")
|
||||
|
||||
|
||||
def _connect_material_expressions(asset, from_expr, from_output: str, to_expr, to_input: str) -> dict:
|
||||
try:
|
||||
ok = _mel().connect_material_expressions(from_expr, str(from_output or ""), to_expr, str(to_input or ""))
|
||||
if ok:
|
||||
return {"ok": True, "method": "MaterialEditingLibrary"}
|
||||
except Exception as e:
|
||||
mel_error = str(e)
|
||||
else:
|
||||
mel_error = "MaterialEditingLibrary.connect_material_expressions returned false"
|
||||
|
||||
lib = getattr(unreal, "MCPMaterialLibrary", None)
|
||||
if lib is None or not hasattr(lib, "connect_material_expressions_raw"):
|
||||
return {"ok": False, "error": mel_error}
|
||||
|
||||
try:
|
||||
raw = lib.connect_material_expressions_raw(asset, from_expr, str(from_output or ""), to_expr, str(to_input or ""))
|
||||
result = json.loads(raw) if isinstance(raw, str) else dict(raw)
|
||||
if result.get("ok"):
|
||||
result.setdefault("method", "MCPMaterialLibrary.ConnectMaterialExpressionsRaw")
|
||||
return result
|
||||
result.setdefault("error", mel_error)
|
||||
return result
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"{mel_error}; raw fallback failed: {e}"}
|
||||
|
||||
|
||||
def _expr_guid(expr) -> str:
|
||||
try:
|
||||
guid = expr.get_editor_property("material_expression_editor_x_guid")
|
||||
if guid:
|
||||
return str(guid)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return str(expr.get_outer().get_path_name()) + ":" + expr.get_name()
|
||||
except Exception:
|
||||
return expr.get_name()
|
||||
|
||||
|
||||
def _expr_pos(expr):
|
||||
try:
|
||||
x, y = 0, 0
|
||||
_mel().get_material_expression_node_position(expr, x, y)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return [int(expr.get_editor_property("material_expression_editor_x")),
|
||||
int(expr.get_editor_property("material_expression_editor_y"))]
|
||||
except Exception:
|
||||
return [0, 0]
|
||||
|
||||
|
||||
def _material_property(name: str):
|
||||
enum_name = MATERIAL_PROPERTY_MAP.get(str(name).replace(" ", "").lower(), str(name))
|
||||
try:
|
||||
return getattr(unreal.MaterialProperty, enum_name)
|
||||
except Exception:
|
||||
pass
|
||||
if not str(enum_name).startswith("MP_"):
|
||||
try:
|
||||
return getattr(unreal.MaterialProperty, "MP_" + str(enum_name).upper())
|
||||
except Exception:
|
||||
pass
|
||||
raise RuntimeError(f"unknown material property {name!r}")
|
||||
|
||||
|
||||
def _coerce_value(value, current=None):
|
||||
# The loosely-typed "value" tool field arrives JSON-stringified; unwrap it.
|
||||
if isinstance(value, str):
|
||||
s = value.strip()
|
||||
if current is not None and isinstance(current, bool):
|
||||
if s.lower() in ("true", "1"):
|
||||
return True
|
||||
if s.lower() in ("false", "0"):
|
||||
return False
|
||||
if current is not None and isinstance(current, float):
|
||||
try:
|
||||
return float(s)
|
||||
except Exception:
|
||||
pass
|
||||
if current is not None and isinstance(current, int) and not isinstance(current, bool):
|
||||
try:
|
||||
return int(float(s))
|
||||
except Exception:
|
||||
pass
|
||||
if s[:1] in ("[", "{"):
|
||||
try:
|
||||
import json
|
||||
return _coerce_value(json.loads(s), current)
|
||||
except Exception:
|
||||
pass
|
||||
if s[:1] == "(" and "=" in s:
|
||||
import re
|
||||
low = {k.lower(): float(v) for k, v in re.findall(r"([A-Za-z]+)\s*=\s*(-?[0-9.eE+]+)", s)}
|
||||
if {"r", "g", "b"}.issubset(low):
|
||||
return unreal.LinearColor(low["r"], low["g"], low["b"], low.get("a", 1.0))
|
||||
if {"x", "y", "z"}.issubset(low):
|
||||
return unreal.Vector(low["x"], low["y"], low["z"])
|
||||
if {"x", "y"}.issubset(low):
|
||||
return unreal.Vector2D(low["x"], low["y"])
|
||||
if current is not None:
|
||||
enum_class = getattr(unreal, type(current).__name__, None) or getattr(current, "__class__", None)
|
||||
if enum_class is not None and hasattr(enum_class, s):
|
||||
return getattr(enum_class, s)
|
||||
try:
|
||||
return float(s)
|
||||
except Exception:
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
if "asset" in value:
|
||||
return _load_asset(value["asset"])
|
||||
low = {str(k).lower(): v for k, v in value.items()}
|
||||
if {"r", "g", "b"}.issubset(low):
|
||||
return unreal.LinearColor(float(low["r"]), float(low["g"]), float(low["b"]), float(low.get("a", 1.0)))
|
||||
if {"x", "y", "z"}.issubset(low):
|
||||
return unreal.Vector(float(low["x"]), float(low["y"]), float(low["z"]))
|
||||
if {"x", "y"}.issubset(low):
|
||||
return unreal.Vector2D(float(low["x"]), float(low["y"]))
|
||||
if isinstance(value, list):
|
||||
if len(value) in (3, 4):
|
||||
if current is not None and type(current).__name__ in ("LinearColor", "Color"):
|
||||
return unreal.LinearColor(float(value[0]), float(value[1]), float(value[2]), float(value[3]) if len(value) > 3 else 1.0)
|
||||
if current is not None and type(current).__name__ in ("Vector", "Vector3f"):
|
||||
return unreal.Vector(float(value[0]), float(value[1]), float(value[2]))
|
||||
return unreal.LinearColor(float(value[0]), float(value[1]), float(value[2]), float(value[3]) if len(value) > 3 else 1.0)
|
||||
if len(value) == 2:
|
||||
return unreal.Vector2D(float(value[0]), float(value[1]))
|
||||
if isinstance(value, str) and current is not None:
|
||||
enum_class = getattr(unreal, type(current).__name__, None) or getattr(current, "__class__", None)
|
||||
if enum_class is not None and hasattr(enum_class, value):
|
||||
return getattr(enum_class, value)
|
||||
if isinstance(value, int) and current is not None and hasattr(current, "__class__"):
|
||||
try:
|
||||
return current.__class__(value)
|
||||
except Exception:
|
||||
pass
|
||||
return value
|
||||
|
||||
|
||||
def _set_expr_properties(expr, properties: dict):
|
||||
changed = []
|
||||
for key, value in (properties or {}).items():
|
||||
if key.lower() == "inputs" and isinstance(expr, unreal.MaterialExpressionCustom):
|
||||
custom_inputs = []
|
||||
for item in value:
|
||||
input_name = item.get("name") if isinstance(item, dict) else str(item)
|
||||
custom_input = unreal.CustomInput()
|
||||
custom_input.set_editor_property("input_name", input_name)
|
||||
custom_inputs.append(custom_input)
|
||||
expr.set_editor_property("inputs", custom_inputs)
|
||||
changed.append(key)
|
||||
continue
|
||||
current = None
|
||||
try:
|
||||
current = expr.get_editor_property(key)
|
||||
except Exception:
|
||||
pass
|
||||
expr.set_editor_property(key, _coerce_value(value, current))
|
||||
changed.append(key)
|
||||
return changed
|
||||
|
||||
|
||||
def _create_expression(asset, spec: dict):
|
||||
cls = _load_expression_class(spec.get("class") or spec.get("target") or "")
|
||||
pos = spec.get("position") or [0, 0]
|
||||
x = int(pos[0]) if len(pos) > 0 else 0
|
||||
y = int(pos[1]) if len(pos) > 1 else 0
|
||||
if isinstance(asset, unreal.Material):
|
||||
expr = _mel().create_material_expression(asset, cls, x, y)
|
||||
elif isinstance(asset, unreal.MaterialFunction):
|
||||
expr = _mel().create_material_expression_in_function(asset, cls, x, y)
|
||||
else:
|
||||
raise RuntimeError(f"unsupported graph asset type {type(asset).__name__}")
|
||||
if expr is None:
|
||||
raise RuntimeError("create_material_expression returned None")
|
||||
if spec.get("name"):
|
||||
try:
|
||||
expr.rename(str(spec["name"]))
|
||||
except Exception:
|
||||
pass
|
||||
_set_expr_properties(expr, spec.get("properties") or spec.get("params") or {})
|
||||
return expr
|
||||
|
||||
|
||||
def create_material(args: dict) -> dict:
|
||||
folder, name = _split_pkg(args["path"])
|
||||
asset = _asset_tools().create_asset(name, folder, unreal.Material, unreal.MaterialFactoryNew())
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create_asset returned None"}
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def set_material_settings(args: dict) -> dict:
|
||||
asset = _load_material(args["asset_path"])
|
||||
changed = []
|
||||
for key, value in (args.get("properties") or {}).items():
|
||||
current = None
|
||||
try:
|
||||
current = asset.get_editor_property(key)
|
||||
except Exception:
|
||||
pass
|
||||
asset.set_editor_property(key, _coerce_value(value, current))
|
||||
changed.append(key)
|
||||
_update_asset(asset)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True, "changed": changed}
|
||||
|
||||
|
||||
def inspect_material_settings(args: dict) -> dict:
|
||||
asset = _load_material(args["asset_path"])
|
||||
result = {"ok": True, "properties": {}}
|
||||
for key in args.get("properties") or []:
|
||||
value = asset.get_editor_property(key)
|
||||
result["properties"][key] = {
|
||||
"type": type(value).__name__,
|
||||
"repr": repr(value),
|
||||
"str": str(value),
|
||||
"members": list(getattr(value.__class__, "__members__", {}).keys()),
|
||||
"unreal_class": repr(getattr(unreal, type(value).__name__, None)),
|
||||
"class_dir": [n for n in dir(value.__class__) if n.startswith("BL_") or n.startswith("MD_")],
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def create_material_instance(args: dict) -> dict:
|
||||
folder, name = _split_pkg(args["path"])
|
||||
asset = _asset_tools().create_asset(name, folder, unreal.MaterialInstanceConstant, unreal.MaterialInstanceConstantFactoryNew())
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create_asset returned None"}
|
||||
parent = args.get("parent")
|
||||
if parent:
|
||||
_mel().set_material_instance_parent(asset, _load_material_interface(parent))
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0], "parent": parent or None}
|
||||
|
||||
|
||||
def read_graph(args: dict) -> dict:
|
||||
asset_path = args["asset_path"]
|
||||
asset = _load_asset(asset_path)
|
||||
lib = getattr(unreal, "MCPMaterialLibrary", None)
|
||||
if lib is not None:
|
||||
try:
|
||||
return json.loads(lib.dump_material_graph_to_json(asset))
|
||||
except Exception:
|
||||
pass
|
||||
expressions, _ = _material_expressions(asset)
|
||||
nodes = []
|
||||
edges = []
|
||||
for expr in expressions:
|
||||
node = {
|
||||
"guid": _expr_guid(expr),
|
||||
"name": expr.get_name(),
|
||||
"class": expr.get_class().get_path_name(),
|
||||
"position": _expr_pos(expr),
|
||||
"inputs": [],
|
||||
}
|
||||
try:
|
||||
node["input_names"] = list(_mel().get_material_expression_input_names(expr))
|
||||
except Exception:
|
||||
node["input_names"] = []
|
||||
nodes.append(node)
|
||||
if isinstance(asset, unreal.Material):
|
||||
try:
|
||||
inputs = list(_mel().get_inputs_for_material_expression(asset, expr))
|
||||
for input_expr in inputs:
|
||||
out_name = ""
|
||||
try:
|
||||
out_name = str(_mel().get_input_node_output_name_for_material_expression(expr, input_expr))
|
||||
except Exception:
|
||||
pass
|
||||
edges.append({
|
||||
"from": [_expr_guid(input_expr), out_name],
|
||||
"to": [_expr_guid(expr), ""],
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
outputs = {}
|
||||
if isinstance(asset, unreal.Material):
|
||||
for pretty, enum_name in MATERIAL_PROPERTY_MAP.items():
|
||||
if pretty != pretty.replace("_", ""):
|
||||
continue
|
||||
try:
|
||||
prop = getattr(unreal.MaterialProperty, enum_name)
|
||||
input_node = _mel().get_material_property_input_node(asset, prop)
|
||||
if input_node is not None:
|
||||
outputs[pretty] = {
|
||||
"from": [_expr_guid(input_node), str(_mel().get_material_property_input_node_output_name(asset, prop))],
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "asset_path": asset_path, "nodes": nodes, "edges": edges, "outputs": outputs}
|
||||
|
||||
|
||||
def apply_graph(args: dict) -> dict:
|
||||
asset_path = args["asset_path"]
|
||||
asset = _load_asset(asset_path)
|
||||
if not isinstance(asset, (unreal.Material, unreal.MaterialFunction)):
|
||||
return {"ok": False, "error": f"{asset_path} is not a Material or MaterialFunction"}
|
||||
result = {"ok": False, "asset_path": asset_path, "spawned": {}, "errors": [], "warnings": []}
|
||||
if args.get("clear_existing"):
|
||||
if isinstance(asset, unreal.Material):
|
||||
_mel().delete_all_material_expressions(asset)
|
||||
else:
|
||||
_mel().delete_all_material_expressions_in_function(asset)
|
||||
local_map = {}
|
||||
node_specs = graph_layout.normalize_specs(
|
||||
args.get("nodes") or [],
|
||||
args.get("edges") or [],
|
||||
domain="material",
|
||||
) if args.get("auto_layout", True) else list(args.get("nodes") or [])
|
||||
|
||||
for spec in node_specs:
|
||||
try:
|
||||
expr = _create_expression(asset, spec)
|
||||
local_id = spec.get("id") or expr.get_name()
|
||||
guid = _expr_guid(expr)
|
||||
local_map[local_id] = expr
|
||||
result["spawned"][local_id] = {"guid": guid, "name": expr.get_name(), "class": expr.get_class().get_path_name()}
|
||||
except Exception as e:
|
||||
result["errors"].append(f"node {spec.get('id') or spec.get('class')}: {e}")
|
||||
|
||||
def resolve_node(ref):
|
||||
if ref in local_map:
|
||||
return local_map[ref]
|
||||
return _find_expression(asset, ref)
|
||||
|
||||
for edge in args.get("edges") or []:
|
||||
try:
|
||||
from_ref, from_output = edge["from"]
|
||||
to_ref, to_input = edge["to"]
|
||||
connect_result = _connect_material_expressions(asset, resolve_node(from_ref), str(from_output or ""), resolve_node(to_ref), str(to_input or ""))
|
||||
if not connect_result.get("ok"):
|
||||
result["warnings"].append(f"connect returned false: {edge}: {connect_result.get('error') or connect_result}")
|
||||
except Exception as e:
|
||||
result["errors"].append(f"edge {edge}: {e}")
|
||||
if isinstance(asset, unreal.Material):
|
||||
for prop_name, out_ref in (args.get("outputs") or {}).items():
|
||||
try:
|
||||
if isinstance(out_ref, dict):
|
||||
out_ref = out_ref.get("from")
|
||||
from_ref, from_output = out_ref
|
||||
ok = _mel().connect_material_property(resolve_node(from_ref), str(from_output or ""), _material_property(prop_name))
|
||||
if not ok:
|
||||
result["warnings"].append(f"connect output returned false: {prop_name}")
|
||||
except Exception as e:
|
||||
result["errors"].append(f"output {prop_name}: {e}")
|
||||
if args.get("layout", False):
|
||||
_layout_asset(asset)
|
||||
_update_asset(asset)
|
||||
if args.get("save", True):
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
result["ok"] = not result["errors"]
|
||||
return result
|
||||
|
||||
|
||||
def add_node(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
expr = _create_expression(asset, args)
|
||||
_update_asset(asset)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True, "guid": _expr_guid(expr), "name": expr.get_name(), "class": expr.get_class().get_path_name()}
|
||||
|
||||
|
||||
def delete_node(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
expr = _find_expression(asset, args.get("guid") or args.get("name"))
|
||||
if isinstance(asset, unreal.Material):
|
||||
_mel().delete_material_expression(asset, expr)
|
||||
elif isinstance(asset, unreal.MaterialFunction):
|
||||
_mel().delete_material_expression_in_function(asset, expr)
|
||||
else:
|
||||
raise RuntimeError("asset is not Material or MaterialFunction")
|
||||
_update_asset(asset)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def move_nodes(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
moved = []
|
||||
for move in args.get("moves") or []:
|
||||
expr = _find_expression(asset, move.get("guid") or move.get("name"))
|
||||
expr.set_editor_property("material_expression_editor_x", int(move["x"]))
|
||||
expr.set_editor_property("material_expression_editor_y", int(move["y"]))
|
||||
moved.append(_expr_guid(expr))
|
||||
_update_asset(asset)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True, "moved": moved}
|
||||
|
||||
|
||||
def set_node_property(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
expr = _find_expression(asset, args.get("guid") or args.get("name"))
|
||||
changed = _set_expr_properties(expr, {args["property"]: args.get("value")})
|
||||
_update_asset(asset)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True, "changed": changed}
|
||||
|
||||
|
||||
def connect(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
from_ref, from_output = args["from"]
|
||||
to_ref, to_input = args["to"]
|
||||
result = _connect_material_expressions(
|
||||
asset,
|
||||
_find_expression(asset, from_ref),
|
||||
str(from_output or ""),
|
||||
_find_expression(asset, to_ref),
|
||||
str(to_input or ""),
|
||||
)
|
||||
_update_asset(asset)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return result
|
||||
|
||||
|
||||
def connect_output(args: dict) -> dict:
|
||||
asset = _load_material(args["asset_path"])
|
||||
from_ref, from_output = args["from"]
|
||||
ok = _mel().connect_material_property(_find_expression(asset, from_ref), str(from_output or ""), _material_property(args["property"]))
|
||||
_update_asset(asset)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": bool(ok)}
|
||||
|
||||
|
||||
def layout(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
_layout_asset(asset)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def tidy_graph(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = read_graph({"asset_path": args["asset_path"]})
|
||||
if not graph.get("ok"):
|
||||
return graph
|
||||
moves = graph_layout.tidy_existing_nodes(graph.get("nodes") or [], graph.get("edges") or [], "material")
|
||||
moved = []
|
||||
for move in moves:
|
||||
expr = _find_expression(asset, move["guid"])
|
||||
expr.set_editor_property("material_expression_editor_x", int(move["x"]))
|
||||
expr.set_editor_property("material_expression_editor_y", int(move["y"]))
|
||||
moved.append(move)
|
||||
_update_asset(asset)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True, "moved": moved, "count": len(moved)}
|
||||
|
||||
|
||||
def recompile_save(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
_update_asset(asset)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def set_instance_parent(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
if not isinstance(asset, unreal.MaterialInstanceConstant):
|
||||
return {"ok": False, "error": "asset is not MaterialInstanceConstant"}
|
||||
_mel().set_material_instance_parent(asset, _load_material_interface(args["parent"]))
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def set_instance_scalar(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
ok = _mel().set_material_instance_scalar_parameter_value(asset, str(args["name"]), float(args["value"]))
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": bool(ok)}
|
||||
|
||||
|
||||
def set_instance_vector(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
ok = _mel().set_material_instance_vector_parameter_value(asset, str(args["name"]), _coerce_value(args["value"]))
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": bool(ok)}
|
||||
|
||||
|
||||
def set_instance_texture(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
ok = _mel().set_material_instance_texture_parameter_value(asset, str(args["name"]), _load_asset(args["value"]))
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": bool(ok)}
|
||||
|
||||
|
||||
def list_parameters(args: dict) -> dict:
|
||||
asset = _load_material_interface(args["asset_path"])
|
||||
out = {"ok": True, "asset_path": args["asset_path"], "scalar": [], "vector": [], "texture": [], "static_switch": []}
|
||||
for key, fn_name in (
|
||||
("scalar", "get_scalar_parameter_names"),
|
||||
("vector", "get_vector_parameter_names"),
|
||||
("texture", "get_texture_parameter_names"),
|
||||
("static_switch", "get_static_switch_parameter_names"),
|
||||
):
|
||||
try:
|
||||
out[key] = [str(x) for x in getattr(_mel(), fn_name)(asset)]
|
||||
except Exception as e:
|
||||
out[key + "_error"] = str(e)
|
||||
return out
|
||||
|
||||
|
||||
def open_asset(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
ok = unreal.AssetEditorSubsystem().open_editor_for_assets([asset])
|
||||
return {"ok": bool(ok), "asset_path": args["asset_path"]}
|
||||
|
||||
|
||||
def _layout_asset(asset):
|
||||
if isinstance(asset, unreal.Material):
|
||||
_mel().layout_material_expressions(asset)
|
||||
elif isinstance(asset, unreal.MaterialFunction):
|
||||
_mel().layout_material_function_expressions(asset)
|
||||
|
||||
|
||||
def _update_asset(asset):
|
||||
if isinstance(asset, unreal.Material):
|
||||
_mel().recompile_material(asset)
|
||||
elif isinstance(asset, unreal.MaterialFunction):
|
||||
_mel().update_material_function(asset, None)
|
||||
elif isinstance(asset, unreal.MaterialInstanceConstant):
|
||||
_mel().update_material_instance(asset)
|
||||
|
||||
|
||||
OPS = {
|
||||
"create_material": create_material,
|
||||
"set_material_settings": set_material_settings,
|
||||
"inspect_material_settings": inspect_material_settings,
|
||||
"create_material_instance": create_material_instance,
|
||||
"read_graph": read_graph,
|
||||
"apply_graph": apply_graph,
|
||||
"add_node": add_node,
|
||||
"delete_node": delete_node,
|
||||
"move_nodes": move_nodes,
|
||||
"set_node_property": set_node_property,
|
||||
"connect": connect,
|
||||
"connect_output": connect_output,
|
||||
"layout": layout,
|
||||
"tidy_graph": tidy_graph,
|
||||
"recompile_save": recompile_save,
|
||||
"set_instance_parent": set_instance_parent,
|
||||
"set_instance_scalar": set_instance_scalar,
|
||||
"set_instance_vector": set_instance_vector,
|
||||
"set_instance_texture": set_instance_texture,
|
||||
"list_parameters": list_parameters,
|
||||
"open_asset": open_asset,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json: str) -> str:
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e:
|
||||
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:
|
||||
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-MATERIALS] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
361
ue_side/mesh.py
Normal file
361
ue_side/mesh.py
Normal file
@ -0,0 +1,361 @@
|
||||
"""Static / Skeletal Mesh ops — info, LODs, sockets, collision.
|
||||
|
||||
Ops:
|
||||
static_info {path} — LODs, materials, collision, sockets, bounds, vertex/tri count
|
||||
skeletal_info {path} — LODs, bones, sockets, skeleton, physics asset
|
||||
list_sockets {path} — works for both static and skeletal
|
||||
add_socket {path, name, bone?, location?, rotation?, scale?}
|
||||
remove_socket {path, name}
|
||||
list_lods {path}
|
||||
set_lod_screen_size {path, lod_index, screen_size}
|
||||
remove_lod {path, lod_index}
|
||||
set_collision_complexity{path, complexity:'Default|UseSimpleAsComplex|UseComplexAsSimple'}
|
||||
add_simple_collision {path, shape:'Box|Sphere|Capsule', extents_or_radius}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _load(p): return unreal.EditorAssetLibrary.load_asset(p)
|
||||
def _vec(v, d=(0, 0, 0)):
|
||||
if v is None: v = d
|
||||
return unreal.Vector(float(v[0]), float(v[1]), float(v[2]))
|
||||
def _rot(v, d=(0, 0, 0)):
|
||||
if v is None: v = d
|
||||
return unreal.Rotator(float(v[0]), float(v[1]), float(v[2]))
|
||||
|
||||
|
||||
def static_info(args):
|
||||
m = _load(args["path"])
|
||||
if m is None or not isinstance(m, unreal.StaticMesh):
|
||||
return {"ok": False, "error": "not a StaticMesh"}
|
||||
out = {"ok": True, "path": args["path"]}
|
||||
# UE 5.4+ moved methods from EditorStaticMeshLibrary to StaticMeshEditorSubsystem.
|
||||
smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) if hasattr(unreal, "StaticMeshEditorSubsystem") else None
|
||||
legacy = unreal.EditorStaticMeshLibrary if hasattr(unreal, "EditorStaticMeshLibrary") else None
|
||||
|
||||
def _call(*sources, **kw):
|
||||
method = kw["method"]; args_ = kw.get("args", ())
|
||||
for src in sources:
|
||||
if src is None: continue
|
||||
fn = getattr(src, method, None)
|
||||
if fn:
|
||||
try: return fn(m, *args_)
|
||||
except Exception: pass
|
||||
return None
|
||||
|
||||
try:
|
||||
v = _call(smes, legacy, method="get_lod_count")
|
||||
if v is not None: out["lod_count"] = v
|
||||
v = _call(smes, legacy, method="get_number_verts", args=(0,))
|
||||
if v is not None: out["vertex_count"] = v
|
||||
v = _call(smes, legacy, method="get_number_triangles", args=(0,))
|
||||
if v is not None: out["triangle_count"] = v
|
||||
slots = _call(smes, legacy, method="get_lod_material_slot_names", args=(0,))
|
||||
if slots: out["material_slots"] = [str(n) for n in slots]
|
||||
b = _call(smes, legacy, method="get_bounds")
|
||||
if b is not None:
|
||||
try: out["bounds"] = list(b.box_extent)
|
||||
except Exception: pass
|
||||
except Exception as e:
|
||||
out["info_error"] = str(e)
|
||||
try:
|
||||
smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) if hasattr(unreal, "StaticMeshEditorSubsystem") else None
|
||||
out["sockets"] = [str(n) for n in (smes.get_sockets(m) if smes else [])]
|
||||
except Exception:
|
||||
out["sockets"] = []
|
||||
try:
|
||||
out["materials"] = [{"slot": str(s.material_slot_name),
|
||||
"material": s.material_interface.get_path_name() if s.material_interface else None}
|
||||
for s in (m.get_editor_property("static_materials") or [])]
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def skeletal_info(args):
|
||||
m = _load(args["path"])
|
||||
if m is None:
|
||||
return {"ok": False, "error": "not found"}
|
||||
out = {"ok": True, "path": args["path"], "class": m.get_class().get_name()}
|
||||
try:
|
||||
sk = m.get_editor_property("skeleton")
|
||||
out["skeleton"] = sk.get_path_name().split(".")[0] if sk else None
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
pa = m.get_editor_property("physics_asset")
|
||||
out["physics_asset"] = pa.get_path_name().split(".")[0] if pa else None
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ssel = unreal.EditorSkeletalMeshLibrary if hasattr(unreal, "EditorSkeletalMeshLibrary") else None
|
||||
if ssel:
|
||||
out["lod_count"] = ssel.get_lod_count(m)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
out["sockets"] = [str(s.socket_name) for s in (m.get_editor_property("sockets") or [])]
|
||||
except Exception:
|
||||
out["sockets"] = []
|
||||
try:
|
||||
mats = m.get_editor_property("materials") or []
|
||||
out["materials"] = [(str(x.material_slot_name), x.material_interface.get_path_name() if x.material_interface else None) for x in mats]
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def list_sockets(args):
|
||||
m = _load(args["path"])
|
||||
if m is None:
|
||||
return {"ok": False, "error": "not found"}
|
||||
if isinstance(m, unreal.StaticMesh):
|
||||
out = []
|
||||
# Sockets array is protected — use StaticMeshEditorSubsystem or find_socket fallback.
|
||||
names = []
|
||||
smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) if hasattr(unreal, "StaticMeshEditorSubsystem") else None
|
||||
if smes and hasattr(smes, "get_sockets"):
|
||||
try: names = [str(n) for n in (smes.get_sockets(m) or [])]
|
||||
except Exception: names = []
|
||||
for n in names:
|
||||
try:
|
||||
s = m.find_socket(n)
|
||||
if s:
|
||||
out.append({"name": n,
|
||||
"location": list(s.get_editor_property("relative_location")),
|
||||
"rotation": list(s.get_editor_property("relative_rotation"))})
|
||||
except Exception:
|
||||
out.append({"name": n})
|
||||
else:
|
||||
out = []
|
||||
for s in (m.get_editor_property("sockets") or []):
|
||||
try:
|
||||
out.append({"name": str(s.get_editor_property("socket_name")),
|
||||
"bone": str(s.get_editor_property("bone_name")),
|
||||
"location": list(s.get_editor_property("relative_location")),
|
||||
"rotation": list(s.get_editor_property("relative_rotation"))})
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "sockets": out}
|
||||
|
||||
|
||||
def add_socket(args):
|
||||
m = _load(args["path"])
|
||||
if m is None:
|
||||
return {"ok": False, "error": "not found"}
|
||||
if isinstance(m, unreal.StaticMesh):
|
||||
# The Sockets array is a protected UPROPERTY in Python — fall back to
|
||||
# StaticMeshEditorSubsystem which exposes set_socket / set_sockets
|
||||
# depending on UE version. If neither is exposed, instruct user to use UI.
|
||||
smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) if hasattr(unreal, "StaticMeshEditorSubsystem") else None
|
||||
sock = unreal.StaticMeshSocket()
|
||||
sock.set_editor_property("socket_name", args["name"])
|
||||
sock.set_editor_property("relative_location", _vec(args.get("location")))
|
||||
sock.set_editor_property("relative_rotation", _rot(args.get("rotation")))
|
||||
sock.set_editor_property("relative_scale", _vec(args.get("scale"), (1, 1, 1)))
|
||||
added = False
|
||||
if smes:
|
||||
for fn_name in ("set_socket", "add_socket"):
|
||||
fn = getattr(smes, fn_name, None)
|
||||
if fn:
|
||||
try: fn(m, sock); added = True; break
|
||||
except Exception: pass
|
||||
if not added:
|
||||
return {"ok": False, "error": "StaticMesh sockets array is protected; StaticMeshEditorSubsystem has no set_socket/add_socket on this build. Use Static Mesh Editor UI."}
|
||||
else:
|
||||
sock = unreal.SkeletalMeshSocket()
|
||||
sock.set_editor_property("socket_name", args["name"])
|
||||
sock.set_editor_property("bone_name", args.get("bone") or "")
|
||||
sock.set_editor_property("relative_location", _vec(args.get("location")))
|
||||
sock.set_editor_property("relative_rotation", _rot(args.get("rotation")))
|
||||
sockets = list(m.get_editor_property("sockets") or [])
|
||||
sockets.append(sock)
|
||||
m.set_editor_property("sockets", sockets)
|
||||
unreal.EditorAssetLibrary.save_asset(m.get_path_name())
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def remove_socket(args):
|
||||
m = _load(args["path"])
|
||||
if m is None:
|
||||
return {"ok": False, "error": "not found"}
|
||||
target = args["name"]
|
||||
if isinstance(m, unreal.StaticMesh):
|
||||
smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) if hasattr(unreal, "StaticMeshEditorSubsystem") else None
|
||||
removed = False
|
||||
if smes:
|
||||
for fn_name in ("remove_socket",):
|
||||
fn = getattr(smes, fn_name, None)
|
||||
if fn:
|
||||
try: fn(m, target); removed = True; break
|
||||
except Exception: pass
|
||||
if not removed:
|
||||
return {"ok": False, "error": "StaticMesh sockets array is protected; no remove_socket on subsystem in this build. Use UI."}
|
||||
else:
|
||||
m.set_editor_property("sockets",
|
||||
[s for s in (m.get_editor_property("sockets") or [])
|
||||
if str(s.get_editor_property("socket_name")) != target])
|
||||
unreal.EditorAssetLibrary.save_asset(m.get_path_name())
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def list_lods(args):
|
||||
m = _load(args["path"])
|
||||
if m is None:
|
||||
return {"ok": False, "error": "not found"}
|
||||
out = []
|
||||
if isinstance(m, unreal.StaticMesh):
|
||||
smes = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem) if hasattr(unreal, "StaticMeshEditorSubsystem") else None
|
||||
legacy = unreal.EditorStaticMeshLibrary if hasattr(unreal, "EditorStaticMeshLibrary") else None
|
||||
def _c(method, *a):
|
||||
for src in (smes, legacy):
|
||||
if src is None: continue
|
||||
fn = getattr(src, method, None)
|
||||
if fn:
|
||||
try: return fn(m, *a)
|
||||
except Exception: pass
|
||||
return None
|
||||
n = _c("get_lod_count") or 0
|
||||
for i in range(n):
|
||||
lod = {"index": i}
|
||||
ss = _c("get_lod_screen_size", i)
|
||||
if ss is not None: lod["screen_size"] = ss
|
||||
tr = _c("get_number_triangles", i)
|
||||
if tr is not None: lod["tris"] = tr
|
||||
out.append(lod)
|
||||
else:
|
||||
ssel = unreal.EditorSkeletalMeshLibrary if hasattr(unreal, "EditorSkeletalMeshLibrary") else None
|
||||
sses = unreal.get_editor_subsystem(unreal.SkeletalMeshEditorSubsystem) if hasattr(unreal, "SkeletalMeshEditorSubsystem") else None
|
||||
n = 0
|
||||
for src in (sses, ssel):
|
||||
if src and hasattr(src, "get_lod_count"):
|
||||
try: n = src.get_lod_count(m); break
|
||||
except Exception: pass
|
||||
for i in range(n):
|
||||
out.append({"index": i})
|
||||
return {"ok": True, "lods": out}
|
||||
|
||||
|
||||
def set_lod_screen_size(args):
|
||||
m = _load(args["path"])
|
||||
if m is None or not isinstance(m, unreal.StaticMesh):
|
||||
return {"ok": False, "error": "static mesh only"}
|
||||
smel = unreal.EditorStaticMeshLibrary
|
||||
try:
|
||||
smel.set_lod_reduction_settings if False else None
|
||||
# No direct setter; use BuildSettings via set_lod_screen_size if exposed.
|
||||
if hasattr(smel, "set_lod_screen_size"):
|
||||
smel.set_lod_screen_size(m, int(args["lod_index"]), float(args["screen_size"]))
|
||||
unreal.EditorAssetLibrary.save_asset(m.get_path_name())
|
||||
return {"ok": True}
|
||||
return {"ok": False, "error": "set_lod_screen_size not exposed in this UE build"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def remove_lod(args):
|
||||
m = _load(args["path"])
|
||||
if m is None or not isinstance(m, unreal.StaticMesh):
|
||||
return {"ok": False, "error": "static mesh only"}
|
||||
try:
|
||||
unreal.EditorStaticMeshLibrary.remove_lods(m)
|
||||
return {"ok": True, "note": "all generated LODs removed (UE API limitation)"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def set_collision_complexity(args):
|
||||
m = _load(args["path"])
|
||||
if m is None or not isinstance(m, unreal.StaticMesh):
|
||||
return {"ok": False, "error": "static mesh only"}
|
||||
mode = args["complexity"]
|
||||
enum_map = {
|
||||
"Default": unreal.CollisionTraceFlag.CTF_USE_DEFAULT,
|
||||
"UseSimpleAsComplex": unreal.CollisionTraceFlag.CTF_USE_SIMPLE_AS_COMPLEX,
|
||||
"UseComplexAsSimple": unreal.CollisionTraceFlag.CTF_USE_COMPLEX_AS_SIMPLE,
|
||||
}
|
||||
if mode not in enum_map:
|
||||
return {"ok": False, "error": f"unknown mode; valid: {list(enum_map)}"}
|
||||
try:
|
||||
bs = m.get_editor_property("body_setup")
|
||||
if bs:
|
||||
bs.set_editor_property("collision_trace_flag", enum_map[mode])
|
||||
unreal.EditorAssetLibrary.save_asset(m.get_path_name())
|
||||
return {"ok": True}
|
||||
return {"ok": False, "error": "body_setup missing"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def add_simple_collision(args):
|
||||
m = _load(args["path"])
|
||||
if m is None or not isinstance(m, unreal.StaticMesh):
|
||||
return {"ok": False, "error": "static mesh only"}
|
||||
smel = unreal.EditorStaticMeshLibrary
|
||||
shape = args["shape"]
|
||||
try:
|
||||
if shape == "Box":
|
||||
smel.add_simple_collisions(m, unreal.ScriptingCollisionShapeType.BOX)
|
||||
elif shape == "Sphere":
|
||||
smel.add_simple_collisions(m, unreal.ScriptingCollisionShapeType.SPHERE)
|
||||
elif shape == "Capsule":
|
||||
smel.add_simple_collisions(m, unreal.ScriptingCollisionShapeType.CAPSULE)
|
||||
else:
|
||||
return {"ok": False, "error": "shape must be Box|Sphere|Capsule"}
|
||||
unreal.EditorAssetLibrary.save_asset(m.get_path_name())
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
OPS = {
|
||||
"static_info": static_info,
|
||||
"skeletal_info": skeletal_info,
|
||||
"list_sockets": list_sockets,
|
||||
"add_socket": add_socket,
|
||||
"remove_socket": remove_socket,
|
||||
"list_lods": list_lods,
|
||||
"set_lod_screen_size": set_lod_screen_size,
|
||||
"remove_lod": remove_lod,
|
||||
"set_collision_complexity": set_collision_complexity,
|
||||
"add_simple_collision": add_simple_collision,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json):
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
op = p.get("op"); fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result, p):
|
||||
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-MESH] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
302
ue_side/network.py
Normal file
302
ue_side/network.py
Normal file
@ -0,0 +1,302 @@
|
||||
"""Network ops for Blueprint assets.
|
||||
|
||||
Ops:
|
||||
audit_bp {bp_path}
|
||||
set_actor_flags {bp_path, replicates?, replicate_movement?, net_load_on_client?,
|
||||
always_relevant?, only_relevant_to_owner?, use_owner_relevancy?,
|
||||
net_cull_distance_squared?, net_update_frequency?,
|
||||
min_net_update_frequency?, net_priority?, net_dormancy?}
|
||||
set_component_flags {bp_path, component, replicated?, net_addressable?}
|
||||
set_custom_event_rpc {bp_path, function?, event?, guid?, mode, reliable?}
|
||||
apply_profile {bp_path, profile}
|
||||
|
||||
RPC modes for set_custom_event_rpc:
|
||||
none | server | client | multicast
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
ACTOR_PROP_MAP = {
|
||||
"replicates": "replicates",
|
||||
"replicate_movement": "replicate_movement",
|
||||
"net_load_on_client": "net_load_on_client",
|
||||
"always_relevant": "always_relevant",
|
||||
"only_relevant_to_owner": "only_relevant_to_owner",
|
||||
"use_owner_relevancy": "net_use_owner_relevancy",
|
||||
"net_cull_distance_squared": "net_cull_distance_squared",
|
||||
"net_update_frequency": "net_update_frequency",
|
||||
"min_net_update_frequency": "min_net_update_frequency",
|
||||
"net_priority": "net_priority",
|
||||
"net_dormancy": "net_dormancy",
|
||||
}
|
||||
|
||||
|
||||
def _load_bp(path: str):
|
||||
bp = unreal.EditorAssetLibrary.load_asset(path)
|
||||
if not isinstance(bp, unreal.Blueprint):
|
||||
raise RuntimeError(f"not a Blueprint: {path!r}")
|
||||
return bp
|
||||
|
||||
|
||||
def _generated_class(bp_path: str):
|
||||
cls_path = f"{bp_path}.{bp_path.rsplit('/', 1)[-1]}_C"
|
||||
cls = unreal.load_class(None, cls_path)
|
||||
if cls is None:
|
||||
raise RuntimeError(f"generated class not found: {cls_path}")
|
||||
return cls
|
||||
|
||||
|
||||
def _cdo(bp_path: str):
|
||||
return unreal.get_default_object(_generated_class(bp_path))
|
||||
|
||||
|
||||
def _lib():
|
||||
L = getattr(unreal, "MCPGraphLibrary", None)
|
||||
if L is None:
|
||||
raise RuntimeError("MCPGraphLibrary missing - rebuild C++ plugin")
|
||||
return L
|
||||
|
||||
|
||||
def _find_graph(bp, name: str):
|
||||
L = _lib()
|
||||
if name in (None, "", "EventGraph"):
|
||||
return L.find_event_graph(bp)
|
||||
return L.find_function_graph(bp, name)
|
||||
|
||||
|
||||
def _get_prop(obj, prop):
|
||||
try:
|
||||
return obj.get_editor_property(prop)
|
||||
except Exception:
|
||||
return getattr(obj, prop)
|
||||
|
||||
|
||||
def _set_prop(obj, prop, value):
|
||||
try:
|
||||
obj.set_editor_property(prop, value)
|
||||
except Exception:
|
||||
setattr(obj, prop, value)
|
||||
|
||||
|
||||
def _save_compile(bp):
|
||||
L = getattr(unreal, "MCPGraphLibrary", None)
|
||||
messages = []
|
||||
try:
|
||||
if L:
|
||||
messages = list(L.compile_with_messages(bp))
|
||||
else:
|
||||
unreal.KismetEditorUtilities.compile_blueprint(bp)
|
||||
except Exception as e:
|
||||
messages = [f"WARN||compile failed from network.py: {e}"]
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(bp)
|
||||
return messages
|
||||
|
||||
|
||||
def _read_actor_flags(cdo):
|
||||
out = {}
|
||||
for public_name, prop in ACTOR_PROP_MAP.items():
|
||||
try:
|
||||
v = _get_prop(cdo, prop)
|
||||
out[public_name] = str(v) if public_name == "net_dormancy" else v
|
||||
except Exception as e:
|
||||
out[public_name] = {"error": str(e)}
|
||||
return out
|
||||
|
||||
|
||||
def audit_bp(args: dict) -> dict:
|
||||
bp_path = args["bp_path"]
|
||||
cdo = _cdo(bp_path)
|
||||
result = {
|
||||
"ok": True,
|
||||
"bp_path": bp_path,
|
||||
"actor": _read_actor_flags(cdo),
|
||||
"components": [],
|
||||
"custom_events": [],
|
||||
"notes": [],
|
||||
}
|
||||
|
||||
for name in args.get("components") or []:
|
||||
item = {"name": name}
|
||||
try:
|
||||
comp = _get_prop(cdo, name)
|
||||
item["class"] = comp.get_class().get_name()
|
||||
for prop in ("replicates", "is_replicated", "net_addressable"):
|
||||
try:
|
||||
item[prop] = _get_prop(comp, prop)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
item["error"] = str(e)
|
||||
result["components"].append(item)
|
||||
|
||||
try:
|
||||
import apply_graph
|
||||
raw = apply_graph.read_graph(bp_path, args.get("function") or "EventGraph")
|
||||
for node in raw.get("graph", {}).get("nodes", []):
|
||||
if node.get("class") == "K2Node_CustomEvent":
|
||||
result["custom_events"].append({
|
||||
"guid": node.get("guid"),
|
||||
"title": node.get("title"),
|
||||
"target": node.get("target"),
|
||||
})
|
||||
result["notes"].append("Custom event RPC flags require set_custom_event_rpc; read_graph does not expose them.")
|
||||
except Exception as e:
|
||||
result["notes"].append(f"custom event scan failed: {e}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def set_actor_flags(args: dict) -> dict:
|
||||
bp_path = args["bp_path"]
|
||||
bp = _load_bp(bp_path)
|
||||
cdo = _cdo(bp_path)
|
||||
changed = {}
|
||||
errors = {}
|
||||
for public_name, prop in ACTOR_PROP_MAP.items():
|
||||
if public_name not in args:
|
||||
continue
|
||||
try:
|
||||
_set_prop(cdo, prop, args[public_name])
|
||||
changed[public_name] = args[public_name]
|
||||
except Exception as e:
|
||||
errors[public_name] = str(e)
|
||||
messages = _save_compile(bp)
|
||||
return {"ok": not errors, "bp_path": bp_path, "changed": changed, "errors": errors, "compile_messages": messages}
|
||||
|
||||
|
||||
def _component_from_cdo(cdo, name: str):
|
||||
try:
|
||||
return _get_prop(cdo, name)
|
||||
except Exception:
|
||||
pass
|
||||
getter = getattr(cdo, "get_component_by_class", None)
|
||||
if getter:
|
||||
# Fallback is intentionally conservative: caller should pass the BP component variable name.
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def set_component_flags(args: dict) -> dict:
|
||||
bp_path = args["bp_path"]
|
||||
bp = _load_bp(bp_path)
|
||||
cdo = _cdo(bp_path)
|
||||
comp_name = args["component"]
|
||||
comp = _component_from_cdo(cdo, comp_name)
|
||||
if comp is None:
|
||||
return {"ok": False, "error": f"component not found on CDO by name: {comp_name}"}
|
||||
|
||||
changed = {}
|
||||
errors = {}
|
||||
for public_name, candidates in {
|
||||
"replicated": ("replicates", "is_replicated"),
|
||||
"net_addressable": ("net_addressable",),
|
||||
}.items():
|
||||
if public_name not in args:
|
||||
continue
|
||||
done = False
|
||||
for prop in candidates:
|
||||
try:
|
||||
_set_prop(comp, prop, args[public_name])
|
||||
changed[public_name] = args[public_name]
|
||||
done = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if not done:
|
||||
errors[public_name] = f"no writable property among {candidates}"
|
||||
messages = _save_compile(bp)
|
||||
return {"ok": not errors, "bp_path": bp_path, "component": comp_name, "changed": changed, "errors": errors, "compile_messages": messages}
|
||||
|
||||
|
||||
def set_custom_event_rpc(args: dict) -> dict:
|
||||
bp_path = args["bp_path"]
|
||||
bp = _load_bp(bp_path)
|
||||
graph = _find_graph(bp, args.get("function") or "EventGraph")
|
||||
if graph is None:
|
||||
return {"ok": False, "error": "graph not found"}
|
||||
|
||||
target = args.get("guid") or args.get("event")
|
||||
if not target:
|
||||
return {"ok": False, "error": "set_custom_event_rpc requires guid or event"}
|
||||
mode = args.get("mode") or "none"
|
||||
reliable = bool(args.get("reliable", False))
|
||||
|
||||
L = _lib()
|
||||
setter = getattr(L, "set_custom_event_network_flags", None)
|
||||
if setter is None:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "MCPGraphLibrary.set_custom_event_network_flags is unavailable. Rebuild/restart UE after the plugin C++ update.",
|
||||
"requested": {"target": target, "mode": mode, "reliable": reliable},
|
||||
}
|
||||
ok = bool(setter(bp, graph, target, mode, reliable))
|
||||
messages = _save_compile(bp) if ok else []
|
||||
return {"ok": ok, "bp_path": bp_path, "target": target, "mode": mode, "reliable": reliable, "compile_messages": messages}
|
||||
|
||||
|
||||
def apply_profile(args: dict) -> dict:
|
||||
profile = args["profile"]
|
||||
bp_path = args["bp_path"]
|
||||
if profile == "replicated_world_actor":
|
||||
return set_actor_flags({
|
||||
"bp_path": bp_path,
|
||||
"replicates": True,
|
||||
"net_load_on_client": True,
|
||||
"replicate_movement": bool(args.get("replicate_movement", False)),
|
||||
})
|
||||
if profile == "always_relevant_world_actor":
|
||||
return set_actor_flags({
|
||||
"bp_path": bp_path,
|
||||
"replicates": True,
|
||||
"net_load_on_client": True,
|
||||
"always_relevant": True,
|
||||
"replicate_movement": bool(args.get("replicate_movement", False)),
|
||||
})
|
||||
return {"ok": False, "error": f"unknown profile {profile!r}", "available": ["replicated_world_actor", "always_relevant_world_actor"]}
|
||||
|
||||
|
||||
OPS = {
|
||||
"audit_bp": audit_bp,
|
||||
"set_actor_flags": set_actor_flags,
|
||||
"set_component_flags": set_component_flags,
|
||||
"set_custom_event_rpc": set_custom_event_rpc,
|
||||
"apply_profile": apply_profile,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json: str) -> str:
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e:
|
||||
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:
|
||||
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 + f"{rid}.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
unreal.log("[MCP-NETWORK] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
271
ue_side/niagara.py
Normal file
271
ue_side/niagara.py
Normal file
@ -0,0 +1,271 @@
|
||||
"""Niagara authoring ops. Bridges to unreal.MCPNiagaraLibrary (C++) where
|
||||
editor-only APIs are required, falls back to pure Python where possible.
|
||||
|
||||
Ops:
|
||||
create_system {path}
|
||||
create_emitter_asset {path, template_emitter?} — create empty UNiagaraEmitter asset
|
||||
add_emitter {system, emitter_asset, name?} — add emitter to system, returns handle name
|
||||
remove_emitter {system, emitter_name}
|
||||
list_emitters {system}
|
||||
list_modules {system, emitter_name?, group} — group: EmitterSpawn|EmitterUpdate|ParticleSpawn|ParticleUpdate|SystemSpawn|SystemUpdate
|
||||
add_module {system, emitter_name?, group, module_script}
|
||||
remove_module {system, emitter_name?, group, module_name}
|
||||
add_renderer {system, emitter_name, renderer_class} — e.g. NiagaraSpriteRendererProperties
|
||||
remove_renderer {system, emitter_name, renderer_class}
|
||||
compile {system}
|
||||
set_module_input STUB — see NOTES at bottom.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
# ---------- loaders ----------
|
||||
|
||||
def _load_asset(path: str):
|
||||
if not path:
|
||||
return None
|
||||
return unreal.EditorAssetLibrary.load_asset(path)
|
||||
|
||||
|
||||
def _load_class(p: str):
|
||||
if not p:
|
||||
return None
|
||||
if "/" in p:
|
||||
return unreal.load_class(None, p)
|
||||
# Try common Niagara paths
|
||||
for prefix in ("/Script/Niagara.", "/Script/NiagaraEditor."):
|
||||
c = unreal.load_class(None, prefix + p)
|
||||
if c is not None:
|
||||
return c
|
||||
return unreal.load_class(None, p)
|
||||
|
||||
|
||||
def _lib():
|
||||
if not hasattr(unreal, "MCPNiagaraLibrary"):
|
||||
raise RuntimeError("MCPNiagaraLibrary not found — rebuild the UEBlueprintMCPEditor C++ module")
|
||||
return unreal.MCPNiagaraLibrary
|
||||
|
||||
|
||||
# ---------- ops ----------
|
||||
|
||||
def create_system(p):
|
||||
path = p["path"]
|
||||
ok = _lib().create_niagara_system(path)
|
||||
return {"ok": bool(ok), "path": path}
|
||||
|
||||
|
||||
def create_emitter_asset(p):
|
||||
"""Create an empty UNiagaraEmitter asset. Niagara typically doesn't support
|
||||
fully empty emitters via UI — users start from a template. We create the bare
|
||||
asset; for fully usable emitters duplicate an existing template via asset_op.
|
||||
"""
|
||||
path = p["path"]
|
||||
package_name, _, asset_name = path.rpartition("/")
|
||||
factory = unreal.NiagaraEmitterFactoryNew() if hasattr(unreal, "NiagaraEmitterFactoryNew") else None
|
||||
tools = unreal.AssetToolsHelpers.get_asset_tools()
|
||||
if factory is None:
|
||||
return {"ok": False, "error": "NiagaraEmitterFactoryNew not exposed to Python — duplicate a template emitter via asset_op instead"}
|
||||
asset = tools.create_asset(asset_name, package_name, unreal.NiagaraEmitter, factory)
|
||||
return {"ok": asset is not None, "path": path}
|
||||
|
||||
|
||||
def add_emitter(p):
|
||||
system = _load_asset(p["system"])
|
||||
emitter = _load_asset(p["emitter_asset"])
|
||||
if system is None or emitter is None:
|
||||
return {"ok": False, "error": "system or emitter_asset not found"}
|
||||
name = p.get("name", "")
|
||||
handle_name = _lib().add_emitter_to_system(system, emitter, name)
|
||||
if not handle_name:
|
||||
return {"ok": False, "error": "AddEmitterToSystem returned empty"}
|
||||
unreal.EditorAssetLibrary.save_asset(p["system"])
|
||||
return {"ok": True, "handle_name": handle_name}
|
||||
|
||||
|
||||
def remove_emitter(p):
|
||||
system = _load_asset(p["system"])
|
||||
if system is None:
|
||||
return {"ok": False, "error": "system not found"}
|
||||
ok = _lib().remove_emitter_from_system(system, p["emitter_name"])
|
||||
if ok:
|
||||
unreal.EditorAssetLibrary.save_asset(p["system"])
|
||||
return {"ok": bool(ok)}
|
||||
|
||||
|
||||
def list_emitters(p):
|
||||
system = _load_asset(p["system"])
|
||||
if system is None:
|
||||
return {"ok": False, "error": "system not found"}
|
||||
names = list(_lib().get_emitter_handle_names(system))
|
||||
return {"ok": True, "emitters": names}
|
||||
|
||||
|
||||
def list_modules(p):
|
||||
system = _load_asset(p["system"])
|
||||
if system is None:
|
||||
return {"ok": False, "error": "system not found"}
|
||||
modules = list(_lib().list_modules(system, p.get("emitter_name", ""), p["group"]))
|
||||
return {"ok": True, "modules": modules}
|
||||
|
||||
|
||||
def add_module(p):
|
||||
system = _load_asset(p["system"])
|
||||
script = _load_asset(p["module_script"])
|
||||
if system is None or script is None:
|
||||
return {"ok": False, "error": "system or module_script not found"}
|
||||
name = _lib().add_module_to_stack(
|
||||
system,
|
||||
p.get("emitter_name", ""),
|
||||
p["group"],
|
||||
script,
|
||||
int(p.get("index", -1)),
|
||||
)
|
||||
if not name:
|
||||
return {"ok": False, "error": "AddModuleToStack returned empty (wrong group or script not a module?)"}
|
||||
unreal.EditorAssetLibrary.save_asset(p["system"])
|
||||
return {"ok": True, "module_name": name}
|
||||
|
||||
|
||||
def remove_module(p):
|
||||
system = _load_asset(p["system"])
|
||||
if system is None:
|
||||
return {"ok": False, "error": "system not found"}
|
||||
ok = _lib().remove_module_from_stack(
|
||||
system,
|
||||
p.get("emitter_name", ""),
|
||||
p["group"],
|
||||
p["module_name"],
|
||||
)
|
||||
if ok:
|
||||
unreal.EditorAssetLibrary.save_asset(p["system"])
|
||||
return {"ok": bool(ok)}
|
||||
|
||||
|
||||
def add_renderer(p):
|
||||
system = _load_asset(p["system"])
|
||||
if system is None:
|
||||
return {"ok": False, "error": "system not found"}
|
||||
cls = _load_class(p["renderer_class"])
|
||||
if cls is None:
|
||||
return {"ok": False, "error": f"renderer class {p['renderer_class']!r} not found"}
|
||||
result = _lib().add_renderer_to_emitter(system, p["emitter_name"], cls)
|
||||
if not result:
|
||||
return {"ok": False, "error": "AddRendererToEmitter returned empty"}
|
||||
unreal.EditorAssetLibrary.save_asset(p["system"])
|
||||
return {"ok": True, "renderer": result}
|
||||
|
||||
|
||||
def remove_renderer(p):
|
||||
system = _load_asset(p["system"])
|
||||
if system is None:
|
||||
return {"ok": False, "error": "system not found"}
|
||||
cls = _load_class(p["renderer_class"])
|
||||
if cls is None:
|
||||
return {"ok": False, "error": f"renderer class {p['renderer_class']!r} not found"}
|
||||
n = int(_lib().remove_renderers_from_emitter(system, p["emitter_name"], cls))
|
||||
if n:
|
||||
unreal.EditorAssetLibrary.save_asset(p["system"])
|
||||
return {"ok": True, "removed": n}
|
||||
|
||||
|
||||
def assign_to_actor(p):
|
||||
"""Assign a UNiagaraSystem to the NiagaraComponent of a NiagaraActor in the
|
||||
current level. Identifies the actor by its label (use `name` field).
|
||||
"""
|
||||
system_path = p["system"]
|
||||
actor_name = p["name"]
|
||||
system = _load_asset(system_path)
|
||||
if system is None:
|
||||
return {"ok": False, "error": f"system {system_path!r} not found"}
|
||||
ess = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
target = None
|
||||
for a in ess.get_all_level_actors():
|
||||
if a and (a.get_actor_label() == actor_name or a.get_name() == actor_name):
|
||||
target = a
|
||||
break
|
||||
if target is None:
|
||||
return {"ok": False, "error": f"actor {actor_name!r} not found"}
|
||||
comp = target.get_component_by_class(unreal.NiagaraComponent)
|
||||
if comp is None:
|
||||
return {"ok": False, "error": "actor has no NiagaraComponent"}
|
||||
comp.set_asset(system)
|
||||
comp.activate(reset=True)
|
||||
return {"ok": True, "actor": actor_name, "system": system_path}
|
||||
|
||||
|
||||
def compile_system(p):
|
||||
system = _load_asset(p["system"])
|
||||
if system is None:
|
||||
return {"ok": False, "error": "system not found"}
|
||||
ok = _lib().request_compile(system)
|
||||
return {"ok": bool(ok)}
|
||||
|
||||
|
||||
def set_module_input(p):
|
||||
return {
|
||||
"ok": False,
|
||||
"error": (
|
||||
"set_module_input not implemented yet — requires "
|
||||
"FNiagaraStackFunctionInputBinder + NiagaraClipboard plumbing. "
|
||||
"Workaround: open the system in editor and use UI, or extend "
|
||||
"MCPNiagaraLibrary with SetModuleInput(scalar|vector|enum) wrappers."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
OPS = {
|
||||
"create_system": create_system,
|
||||
"create_emitter_asset": create_emitter_asset,
|
||||
"add_emitter": add_emitter,
|
||||
"remove_emitter": remove_emitter,
|
||||
"list_emitters": list_emitters,
|
||||
"list_modules": list_modules,
|
||||
"add_module": add_module,
|
||||
"remove_module": remove_module,
|
||||
"add_renderer": add_renderer,
|
||||
"remove_renderer": remove_renderer,
|
||||
"compile": compile_system,
|
||||
"assign_to_actor": assign_to_actor,
|
||||
"set_module_input": set_module_input,
|
||||
}
|
||||
|
||||
|
||||
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}"}, {})
|
||||
op = p.get("op")
|
||||
fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {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-NIAGARA] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
1237
ue_side/pcg.py
Normal file
1237
ue_side/pcg.py
Normal file
File diff suppressed because it is too large
Load Diff
167
ue_side/presets.py
Normal file
167
ue_side/presets.py
Normal file
@ -0,0 +1,167 @@
|
||||
"""Preset library — typical Blueprint patterns packaged as one-line MCP calls.
|
||||
|
||||
Each preset is a pure function: args dict → NodeSpec graph dict (apply_graph format).
|
||||
The dispatcher then runs apply_graph on it.
|
||||
|
||||
Add a new preset:
|
||||
1. Write `def my_preset(args): return {...nodes/edges...}` below.
|
||||
2. Register it in PRESETS at the bottom.
|
||||
3. Call via MCP: preset_op({name: "my_preset", args: {...}}).
|
||||
|
||||
Ships with:
|
||||
print_on_begin_play {bp_path, text?} — BeginPlay → PrintString
|
||||
print_on_event {bp_path, event, text?} — any event → PrintString
|
||||
toggle_visibility {bp_path, event, component} — event → ToggleVisibility on a component
|
||||
damage_drops_health {bp_path, health_var} — AnyDamage → Health -= Damage, branch on <=0 → DestroyActor
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
# --- preset implementations ---------------------------------------------------
|
||||
|
||||
def print_on_begin_play(args: dict) -> dict:
|
||||
text = args.get("text", "BeginPlay")
|
||||
return {
|
||||
"bp_path": args["bp_path"],
|
||||
"function": "EventGraph",
|
||||
"mode": "append",
|
||||
"nodes": [
|
||||
{"id": "ev", "kind": "event", "target": "ReceiveBeginPlay", "position": [0, 0]},
|
||||
{"id": "print", "kind": "call_function", "target": "/Script/Engine.KismetSystemLibrary:PrintString",
|
||||
"position": [300, 0], "params": {"InString": text}},
|
||||
],
|
||||
"edges": [{"from": ["ev", "then"], "to": ["print", "execute"]}],
|
||||
}
|
||||
|
||||
|
||||
def print_on_event(args: dict) -> dict:
|
||||
event = args["event"] # e.g. "ReceiveActorBeginOverlap"
|
||||
text = args.get("text", event)
|
||||
return {
|
||||
"bp_path": args["bp_path"],
|
||||
"function": "EventGraph",
|
||||
"mode": "append",
|
||||
"nodes": [
|
||||
{"id": "ev", "kind": "event", "target": event, "position": [0, 0]},
|
||||
{"id": "print", "kind": "call_function", "target": "/Script/Engine.KismetSystemLibrary:PrintString",
|
||||
"position": [300, 0], "params": {"InString": text}},
|
||||
],
|
||||
"edges": [{"from": ["ev", "then"], "to": ["print", "execute"]}],
|
||||
}
|
||||
|
||||
|
||||
def toggle_visibility(args: dict) -> dict:
|
||||
event = args.get("event", "ReceiveActorBeginOverlap")
|
||||
comp = args["component"] # variable name of a SceneComponent on this BP
|
||||
return {
|
||||
"bp_path": args["bp_path"],
|
||||
"function": "EventGraph",
|
||||
"mode": "append",
|
||||
"nodes": [
|
||||
{"id": "ev", "kind": "event", "target": event, "position": [0, 0]},
|
||||
{"id": "getc", "kind": "variable_get", "target": comp, "position": [180, 80]},
|
||||
{"id": "toggle", "kind": "call_function",
|
||||
"target": "/Script/Engine.SceneComponent:ToggleVisibility",
|
||||
"position": [380, 0]},
|
||||
],
|
||||
"edges": [
|
||||
{"from": ["ev", "then"], "to": ["toggle", "execute"]},
|
||||
{"from": ["getc", comp], "to": ["toggle", "self"]},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def damage_drops_health(args: dict) -> dict:
|
||||
"""AnyDamage → Health -= Damage; if Health<=0 → DestroyActor."""
|
||||
hv = args.get("health_var", "Health")
|
||||
return {
|
||||
"bp_path": args["bp_path"],
|
||||
"function": "EventGraph",
|
||||
"mode": "append",
|
||||
"nodes": [
|
||||
{"id": "ev", "kind": "event", "target": "ReceiveAnyDamage", "position": [-200, 0]},
|
||||
{"id": "getH", "kind": "variable_get", "target": hv, "position": [-20, 60]},
|
||||
{"id": "sub", "kind": "call_function",
|
||||
"target": "/Script/Engine.KismetMathLibrary:Subtract_FloatFloat",
|
||||
"position": [160, 60]},
|
||||
{"id": "setH", "kind": "variable_set", "target": hv, "position": [340, 0]},
|
||||
{"id": "getH2", "kind": "variable_get", "target": hv, "position": [560, 80]},
|
||||
{"id": "le", "kind": "call_function",
|
||||
"target": "/Script/Engine.KismetMathLibrary:LessEqual_FloatFloat",
|
||||
"position": [720, 60]},
|
||||
{"id": "br", "kind": "branch", "position": [900, 0]},
|
||||
{"id": "destroy", "kind": "call_function",
|
||||
"target": "/Script/Engine.Actor:K2_DestroyActor",
|
||||
"position": [1080, 0]},
|
||||
],
|
||||
"edges": [
|
||||
{"from": ["ev", "then"], "to": ["setH", "execute"]},
|
||||
{"from": ["getH", hv], "to": ["sub", "A"]},
|
||||
{"from": ["ev", "Damage"], "to": ["sub", "B"]},
|
||||
{"from": ["sub", "ReturnValue"], "to": ["setH", hv]},
|
||||
{"from": ["setH", "then"], "to": ["br", "execute"]},
|
||||
{"from": ["getH2", hv], "to": ["le", "A"]},
|
||||
{"from": ["le", "ReturnValue"], "to": ["br", "Condition"]},
|
||||
{"from": ["br", "then"], "to": ["destroy", "execute"]},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# --- dispatch -----------------------------------------------------------------
|
||||
|
||||
PRESETS = {
|
||||
"print_on_begin_play": print_on_begin_play,
|
||||
"print_on_event": print_on_event,
|
||||
"toggle_visibility": toggle_visibility,
|
||||
"damage_drops_health": damage_drops_health,
|
||||
}
|
||||
|
||||
|
||||
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}"}, {})
|
||||
name = p.get("name")
|
||||
if name == "__list__":
|
||||
return _tee({"ok": True, "presets": sorted(PRESETS.keys())}, p)
|
||||
fn = PRESETS.get(name)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown preset {name!r}", "available": sorted(PRESETS.keys())}, p)
|
||||
try:
|
||||
spec = fn(p.get("args") or {})
|
||||
# Run through apply_graph
|
||||
import importlib, apply_graph
|
||||
importlib.reload(apply_graph)
|
||||
result = apply_graph.apply(spec)
|
||||
result["preset"] = name
|
||||
return _tee(result, p)
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc(), "preset": name}, 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-PRESET] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
198
ue_side/render.py
Normal file
198
ue_side/render.py
Normal file
@ -0,0 +1,198 @@
|
||||
"""Render ops — PostProcess volumes, Render targets, Scene captures, viewport screenshots.
|
||||
|
||||
Ops:
|
||||
spawn_post_process_volume {location?, unbound?:true, settings?:{key:value}}
|
||||
set_pp_setting {actor_name, key, value} — sets PostProcessVolume.Settings entries
|
||||
list_pp_volumes {}
|
||||
create_render_target_2d {path, width?:512, height?:512, format?:'RGBA16f|RGBA8'}
|
||||
spawn_scene_capture_2d {target_render_target_path, location?, rotation?, name?}
|
||||
take_screenshot {filename?, resolution?, hdr?:false} — uses HighResShot console cmd
|
||||
set_viewport_realtime {enabled:bool}
|
||||
capture_thumbnail {asset_path} — refresh editor thumbnail
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _ess(): return unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
def _world(): return unreal.EditorLevelLibrary.get_editor_world()
|
||||
def _vec(v, d=(0, 0, 0)):
|
||||
if v is None: v = d
|
||||
return unreal.Vector(float(v[0]), float(v[1]), float(v[2]))
|
||||
def _rot(v, d=(0, 0, 0)):
|
||||
if v is None: v = d
|
||||
return unreal.Rotator(float(v[0]), float(v[1]), float(v[2]))
|
||||
def _find(name):
|
||||
for a in _ess().get_all_level_actors():
|
||||
if a and (a.get_actor_label() == name or a.get_name() == name):
|
||||
return a
|
||||
return None
|
||||
|
||||
|
||||
def spawn_post_process_volume(args):
|
||||
cls = unreal.load_class(None, "/Script/Engine.PostProcessVolume")
|
||||
actor = _ess().spawn_actor_from_class(cls, _vec(args.get("location")), _rot(None))
|
||||
if actor is None:
|
||||
return {"ok": False, "error": "spawn failed"}
|
||||
if args.get("unbound", True):
|
||||
actor.set_editor_property("unbound", True)
|
||||
settings = args.get("settings") or {}
|
||||
if settings:
|
||||
pp = actor.get_editor_property("settings")
|
||||
for k, v in settings.items():
|
||||
try:
|
||||
pp.set_editor_property(k, v)
|
||||
except Exception:
|
||||
pass
|
||||
actor.set_editor_property("settings", pp)
|
||||
label = args.get("name") or "PostProcessVolume_MCP"
|
||||
actor.set_actor_label(label)
|
||||
return {"ok": True, "name": label, "path": actor.get_path_name()}
|
||||
|
||||
|
||||
def set_pp_setting(args):
|
||||
a = _find(args["actor_name"])
|
||||
if a is None:
|
||||
return {"ok": False, "error": "actor not found"}
|
||||
try:
|
||||
pp = a.get_editor_property("settings")
|
||||
pp.set_editor_property(args["key"], args["value"])
|
||||
a.set_editor_property("settings", pp)
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def list_pp_volumes(args):
|
||||
cls = unreal.load_class(None, "/Script/Engine.PostProcessVolume")
|
||||
out = []
|
||||
for a in _ess().get_all_level_actors():
|
||||
if a and a.get_class() == cls.get_default_object().get_class():
|
||||
out.append({"name": a.get_actor_label(), "unbound": bool(a.get_editor_property("unbound"))})
|
||||
return {"ok": True, "volumes": out}
|
||||
|
||||
|
||||
def create_render_target_2d(args):
|
||||
folder, name = args["path"].rsplit("/", 1)
|
||||
factory = unreal.CanvasRenderTarget2DFactoryNew() if hasattr(unreal, "CanvasRenderTarget2DFactoryNew") else None
|
||||
asset_cls = unreal.TextureRenderTarget2D
|
||||
factory = unreal.TextureRenderTargetFactoryNew() if hasattr(unreal, "TextureRenderTargetFactoryNew") else factory
|
||||
if factory is None:
|
||||
return {"ok": False, "error": "no RT factory exposed"}
|
||||
at = unreal.AssetToolsHelpers.get_asset_tools()
|
||||
asset = at.create_asset(name, folder, asset_cls, factory)
|
||||
if asset is None:
|
||||
return {"ok": False, "error": "create returned None"}
|
||||
asset.set_editor_property("size_x", int(args.get("width") or 512))
|
||||
asset.set_editor_property("size_y", int(args.get("height") or 512))
|
||||
fmt = args.get("format") or "RGBA8"
|
||||
fmt_enum = unreal.TextureRenderTargetFormat.RTF_RGBA16F if fmt == "RGBA16f" else unreal.TextureRenderTargetFormat.RTF_RGBA8
|
||||
try:
|
||||
asset.set_editor_property("render_target_format", fmt_enum)
|
||||
except Exception:
|
||||
pass
|
||||
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
|
||||
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
||||
|
||||
|
||||
def spawn_scene_capture_2d(args):
|
||||
rt = unreal.EditorAssetLibrary.load_asset(args["target_render_target_path"])
|
||||
if rt is None:
|
||||
return {"ok": False, "error": "render target not found"}
|
||||
cls = unreal.load_class(None, "/Script/Engine.SceneCapture2D")
|
||||
actor = _ess().spawn_actor_from_class(cls, _vec(args.get("location")), _rot(args.get("rotation")))
|
||||
if actor is None:
|
||||
return {"ok": False, "error": "spawn failed"}
|
||||
try:
|
||||
comp = actor.get_component_by_class(unreal.SceneCaptureComponent2D)
|
||||
comp.set_editor_property("texture_target", rt)
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"set RT failed: {e}"}
|
||||
label = args.get("name") or "SceneCapture2D_MCP"
|
||||
actor.set_actor_label(label)
|
||||
return {"ok": True, "name": label}
|
||||
|
||||
|
||||
def take_screenshot(args):
|
||||
res = args.get("resolution") or "1920x1080"
|
||||
fname = args.get("filename") or ""
|
||||
hdr = "1" if args.get("hdr") else ""
|
||||
cmd = f"HighResShot {res} {fname} {hdr}".strip()
|
||||
try:
|
||||
unreal.SystemLibrary.execute_console_command(_world(), cmd)
|
||||
return {"ok": True, "command": cmd, "note": "Saved to <Project>/Saved/Screenshots/<Platform>/"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def set_viewport_realtime(args):
|
||||
try:
|
||||
ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
|
||||
# No direct API; use console command
|
||||
cmd = "r.Realtime 1" if args.get("enabled") else "r.Realtime 0"
|
||||
unreal.SystemLibrary.execute_console_command(_world(), cmd)
|
||||
return {"ok": True, "command": cmd}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def capture_thumbnail(args):
|
||||
paths = unreal.Array(str)
|
||||
paths.append(args["asset_path"])
|
||||
try:
|
||||
unreal.AssetEditorSubsystem if False else None
|
||||
# No direct python wrapper; rely on AssetTools? Fall back to editor cmd
|
||||
unreal.SystemLibrary.execute_console_command(_world(), f"AssetThumbnailCapture {args['asset_path']}")
|
||||
return {"ok": True, "note": "issued console command"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
OPS = {
|
||||
"spawn_post_process_volume": spawn_post_process_volume,
|
||||
"set_pp_setting": set_pp_setting,
|
||||
"list_pp_volumes": list_pp_volumes,
|
||||
"create_render_target_2d": create_render_target_2d,
|
||||
"spawn_scene_capture_2d": spawn_scene_capture_2d,
|
||||
"take_screenshot": take_screenshot,
|
||||
"set_viewport_realtime": set_viewport_realtime,
|
||||
"capture_thumbnail": capture_thumbnail,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json):
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
op = p.get("op"); fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result, p):
|
||||
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-RENDER] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
246
ue_side/selection.py
Normal file
246
ue_side/selection.py
Normal file
@ -0,0 +1,246 @@
|
||||
"""Selection-aware Blueprint graph ops.
|
||||
|
||||
Ops:
|
||||
get_context {bp_path, function?}
|
||||
apply_relative {bp_path, function?, placement?, gap_x?, gap_y?,
|
||||
delete_selection?, nodes:[], edges:[]}
|
||||
|
||||
This module keeps partial graph generation out of the full-graph replace path:
|
||||
LLMs can inspect the user's selected nodes, then append or replace only that
|
||||
local area.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
import graph_layout
|
||||
|
||||
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_bp(path: str):
|
||||
a = unreal.EditorAssetLibrary.load_asset(path)
|
||||
if not isinstance(a, unreal.Blueprint):
|
||||
raise RuntimeError(f"not a blueprint: {path!r}")
|
||||
return a
|
||||
|
||||
|
||||
def _graph(bp, fn: str | None):
|
||||
L = _lib()
|
||||
if fn in (None, "", "EventGraph"):
|
||||
return L.find_event_graph(bp)
|
||||
return L.find_function_graph(bp, fn)
|
||||
|
||||
|
||||
def _selected(bp) -> dict:
|
||||
raw = _lib().get_selected_nodes_json(bp)
|
||||
data = json.loads(raw)
|
||||
nodes = data.get("selected") or []
|
||||
bbox = _bbox(nodes)
|
||||
focused_graph = data.get("focused_graph")
|
||||
draft_zones = _draft_zones(bp, focused_graph)
|
||||
return {
|
||||
"focused_graph": focused_graph,
|
||||
"selected": nodes,
|
||||
"selected_count": len(nodes),
|
||||
"bbox": bbox,
|
||||
"anchors": _anchors(bbox),
|
||||
"draft_zones": draft_zones,
|
||||
}
|
||||
|
||||
|
||||
def _draft_zones(bp, function_name: str | None) -> list[dict]:
|
||||
graph = _graph(bp, function_name or "EventGraph")
|
||||
if graph is None:
|
||||
return []
|
||||
raw = _lib().dump_graph_to_json(graph)
|
||||
data = json.loads(raw)
|
||||
zones = []
|
||||
for node in data.get("nodes") or []:
|
||||
if node.get("class") != "EdGraphNode_Comment":
|
||||
continue
|
||||
comment = str(node.get("comment") or node.get("title") or "")
|
||||
if comment.strip().lower() not in ("mcp zone", "mcp draft zone"):
|
||||
continue
|
||||
zone = dict(node)
|
||||
zone["bbox"] = {
|
||||
"min_x": float(node.get("x", 0)),
|
||||
"min_y": float(node.get("y", 0)),
|
||||
"max_x": float(node.get("x", 0)) + float(node.get("w", 0)),
|
||||
"max_y": float(node.get("y", 0)) + float(node.get("h", 0)),
|
||||
"width": float(node.get("w", 0)),
|
||||
"height": float(node.get("h", 0)),
|
||||
"center_x": float(node.get("x", 0)) + (float(node.get("w", 0)) / 2.0),
|
||||
"center_y": float(node.get("y", 0)) + (float(node.get("h", 0)) / 2.0),
|
||||
}
|
||||
zones.append(zone)
|
||||
return zones
|
||||
|
||||
|
||||
def _bbox(nodes: list[dict]) -> dict | None:
|
||||
if not nodes:
|
||||
return None
|
||||
min_x = min(float(n.get("x", 0)) for n in nodes)
|
||||
min_y = min(float(n.get("y", 0)) for n in nodes)
|
||||
max_x = max(float(n.get("x", 0)) + float(n.get("w", 220)) for n in nodes)
|
||||
max_y = max(float(n.get("y", 0)) + float(n.get("h", 120)) for n in nodes)
|
||||
return {
|
||||
"min_x": min_x,
|
||||
"min_y": min_y,
|
||||
"max_x": max_x,
|
||||
"max_y": max_y,
|
||||
"width": max_x - min_x,
|
||||
"height": max_y - min_y,
|
||||
"center_x": min_x + ((max_x - min_x) / 2.0),
|
||||
"center_y": min_y + ((max_y - min_y) / 2.0),
|
||||
}
|
||||
|
||||
|
||||
def _anchors(bbox: dict | None) -> dict:
|
||||
if not bbox:
|
||||
return {
|
||||
"right": [0, 0],
|
||||
"below": [0, 320],
|
||||
"center": [0, 0],
|
||||
}
|
||||
return {
|
||||
"right": [bbox["max_x"] + 320, bbox["min_y"]],
|
||||
"below": [bbox["min_x"], bbox["max_y"] + 220],
|
||||
"center": [bbox["center_x"], bbox["center_y"]],
|
||||
}
|
||||
|
||||
|
||||
def get_context(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
ctx = _selected(bp)
|
||||
return {"ok": True, "bp_path": args["bp_path"], **ctx}
|
||||
|
||||
|
||||
def apply_relative(args: dict) -> dict:
|
||||
import importlib
|
||||
import apply_graph
|
||||
|
||||
importlib.reload(apply_graph)
|
||||
|
||||
bp = _load_bp(args["bp_path"])
|
||||
ctx = _selected(bp)
|
||||
function_name = args.get("function") or ctx.get("focused_graph") or "EventGraph"
|
||||
target_graph = _graph(bp, function_name)
|
||||
if target_graph is None:
|
||||
return {"ok": False, "error": f"graph not found: {function_name!r}", "selection": ctx}
|
||||
|
||||
placement = args.get("placement") or "right"
|
||||
gap_x = float(args.get("gap_x", 0))
|
||||
gap_y = float(args.get("gap_y", 0))
|
||||
|
||||
zone_guid = args.get("zone_guid") or ""
|
||||
zone = None
|
||||
for candidate in ctx.get("draft_zones") or []:
|
||||
if zone_guid and candidate.get("guid") == zone_guid:
|
||||
zone = candidate
|
||||
break
|
||||
if zone is None and not ctx.get("selected") and ctx.get("draft_zones"):
|
||||
zone = ctx["draft_zones"][-1]
|
||||
|
||||
if zone is not None:
|
||||
bbox = zone["bbox"]
|
||||
zone_anchors = {
|
||||
"right": [bbox["min_x"] + 80, bbox["min_y"] + 80],
|
||||
"below": [bbox["min_x"] + 80, bbox["min_y"] + 80],
|
||||
"center": [bbox["center_x"], bbox["center_y"]],
|
||||
}
|
||||
anchor = zone_anchors.get(placement) or zone_anchors["right"]
|
||||
else:
|
||||
anchor = ctx["anchors"].get(placement)
|
||||
if anchor is None:
|
||||
anchor = ctx["anchors"]["right"]
|
||||
ax, ay = float(anchor[0]) + gap_x, float(anchor[1]) + gap_y
|
||||
|
||||
relative_nodes = []
|
||||
for spec in args.get("nodes") or []:
|
||||
n = dict(spec)
|
||||
x, y = n.get("position") or [0, 0]
|
||||
n["position"] = [float(x) + ax, float(y) + ay]
|
||||
relative_nodes.append(n)
|
||||
|
||||
nodes = graph_layout.normalize_specs(
|
||||
relative_nodes,
|
||||
args.get("edges") or [],
|
||||
domain="blueprint",
|
||||
) if args.get("auto_layout", True) else relative_nodes
|
||||
|
||||
deleted = []
|
||||
if args.get("delete_selection"):
|
||||
for n in ctx.get("selected") or []:
|
||||
guid = n.get("guid")
|
||||
if guid and _lib().delete_node(target_graph, guid):
|
||||
deleted.append(guid)
|
||||
|
||||
graph = {
|
||||
"bp_path": args["bp_path"],
|
||||
"function": function_name,
|
||||
"mode": "append",
|
||||
"nodes": nodes,
|
||||
"edges": args.get("edges") or [],
|
||||
}
|
||||
result = apply_graph.apply(graph)
|
||||
return {
|
||||
"ok": bool(result.get("ok")),
|
||||
"bp_path": args["bp_path"],
|
||||
"function": function_name,
|
||||
"placement": placement,
|
||||
"anchor": [ax, ay],
|
||||
"zone_guid": zone.get("guid") if zone else None,
|
||||
"deleted_selection": deleted,
|
||||
"selection": ctx,
|
||||
"applied": result,
|
||||
}
|
||||
|
||||
|
||||
OPS = {
|
||||
"get_context": get_context,
|
||||
"apply_relative": apply_relative,
|
||||
}
|
||||
|
||||
|
||||
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 + f"{rid}.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
with open(sd + "last.json", "w", encoding="utf-8") as f:
|
||||
f.write(txt)
|
||||
unreal.log("[MCP-SELECTION] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
156
ue_side/validation.py
Normal file
156
ue_side/validation.py
Normal file
@ -0,0 +1,156 @@
|
||||
"""Asset validation, redirector cleanup, reference analysis.
|
||||
|
||||
Ops:
|
||||
validate_assets {paths:[]} — run EditorValidatorSubsystem
|
||||
validate_folder {path, recursive?}
|
||||
fix_redirectors {path} — recursively resolve UObjectRedirectors under folder
|
||||
get_references {asset_path} — assets THIS asset depends on
|
||||
get_referencers {asset_path} — assets that REFER TO this asset
|
||||
find_unused {folder, ignore_paths?:[]} — assets in folder with zero referencers
|
||||
disk_size {folder} — total .uasset size on disk
|
||||
list_redirectors {folder}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _ar():
|
||||
return unreal.AssetRegistryHelpers.get_asset_registry()
|
||||
|
||||
|
||||
def validate_assets(args):
|
||||
paths = args.get("paths") or []
|
||||
evs = unreal.get_editor_subsystem(unreal.EditorValidatorSubsystem)
|
||||
if evs is None:
|
||||
return {"ok": False, "error": "EditorValidatorSubsystem unavailable"}
|
||||
objs = [unreal.EditorAssetLibrary.load_asset(p) for p in paths]
|
||||
objs = [o for o in objs if o is not None]
|
||||
results = evs.validate_assets(objs, True, True) if hasattr(evs, "validate_assets") else None
|
||||
return {"ok": True, "validated_count": len(objs), "result": str(results) if results is not None else None}
|
||||
|
||||
|
||||
def validate_folder(args):
|
||||
folder = args["path"]
|
||||
recursive = bool(args.get("recursive", True))
|
||||
filt = unreal.ARFilter(package_paths=[folder], recursive_paths=recursive)
|
||||
assets = _ar().get_assets(filt)
|
||||
paths = [str(d.package_name) for d in assets]
|
||||
return validate_assets({"paths": paths})
|
||||
|
||||
|
||||
def fix_redirectors(args):
|
||||
folder = args["path"]
|
||||
filt = unreal.ARFilter(package_paths=[folder], recursive_paths=True, class_names=["ObjectRedirector"])
|
||||
redirs = _ar().get_assets(filt)
|
||||
if not redirs:
|
||||
return {"ok": True, "fixed": 0}
|
||||
objs = [d.get_asset() for d in redirs]
|
||||
objs = [o for o in objs if o is not None]
|
||||
try:
|
||||
unreal.AssetToolsHelpers.get_asset_tools().fixup_referencers(objs)
|
||||
return {"ok": True, "fixed": len(objs)}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e), "found": len(redirs)}
|
||||
|
||||
|
||||
def get_references(args):
|
||||
deps = _ar().get_dependencies(unreal.Name(args["asset_path"]),
|
||||
unreal.AssetRegistryDependencyOptions(include_soft_package_references=True,
|
||||
include_hard_package_references=True))
|
||||
return {"ok": True, "dependencies": [str(d) for d in (deps or [])]}
|
||||
|
||||
|
||||
def get_referencers(args):
|
||||
refs = _ar().get_referencers(unreal.Name(args["asset_path"]),
|
||||
unreal.AssetRegistryDependencyOptions(include_soft_package_references=True,
|
||||
include_hard_package_references=True))
|
||||
return {"ok": True, "referencers": [str(d) for d in (refs or [])]}
|
||||
|
||||
|
||||
def find_unused(args):
|
||||
folder = args["folder"]
|
||||
ignore = set(args.get("ignore_paths") or [])
|
||||
filt = unreal.ARFilter(package_paths=[folder], recursive_paths=True)
|
||||
out = []
|
||||
for d in _ar().get_assets(filt):
|
||||
pkg = str(d.package_name)
|
||||
if pkg in ignore or any(pkg.startswith(p) for p in ignore):
|
||||
continue
|
||||
refs = _ar().get_referencers(unreal.Name(pkg),
|
||||
unreal.AssetRegistryDependencyOptions(include_soft_package_references=True,
|
||||
include_hard_package_references=True)) or []
|
||||
if not refs:
|
||||
out.append({"path": pkg, "class": str(d.asset_class)})
|
||||
return {"ok": True, "count": len(out), "unused": out}
|
||||
|
||||
|
||||
def disk_size(args):
|
||||
folder = args["folder"]
|
||||
# Translate /Game -> Content path
|
||||
proj_content = unreal.Paths.project_content_dir()
|
||||
rel = folder.replace("/Game", "").lstrip("/")
|
||||
root = os.path.join(proj_content, rel)
|
||||
total = 0; files = 0
|
||||
for dirpath, _, fnames in os.walk(root):
|
||||
for f in fnames:
|
||||
if f.endswith((".uasset", ".umap")):
|
||||
total += os.path.getsize(os.path.join(dirpath, f))
|
||||
files += 1
|
||||
return {"ok": True, "folder": folder, "bytes": total, "files": files, "mb": round(total / 1024 / 1024, 2)}
|
||||
|
||||
|
||||
def list_redirectors(args):
|
||||
folder = args["folder"]
|
||||
filt = unreal.ARFilter(package_paths=[folder], recursive_paths=True, class_names=["ObjectRedirector"])
|
||||
out = [str(d.package_name) for d in _ar().get_assets(filt)]
|
||||
return {"ok": True, "count": len(out), "redirectors": out}
|
||||
|
||||
|
||||
OPS = {
|
||||
"validate_assets": validate_assets,
|
||||
"validate_folder": validate_folder,
|
||||
"fix_redirectors": fix_redirectors,
|
||||
"get_references": get_references,
|
||||
"get_referencers": get_referencers,
|
||||
"find_unused": find_unused,
|
||||
"disk_size": disk_size,
|
||||
"list_redirectors": list_redirectors,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json):
|
||||
try:
|
||||
p = json.loads(payload_json)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
||||
op = p.get("op"); fn = OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
|
||||
try:
|
||||
return _tee(fn(p), p)
|
||||
except Exception as e:
|
||||
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
||||
|
||||
|
||||
def _tee(result, p):
|
||||
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-VALIDATE] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
180
ue_side/variables.py
Normal file
180
ue_side/variables.py
Normal file
@ -0,0 +1,180 @@
|
||||
"""Blueprint member-variable authoring ops.
|
||||
|
||||
Wraps the (already-compiled) C++ helpers on unreal.MCPGraphLibrary:
|
||||
AddMemberVariable / SetVariableDefaultValue / SetVariableMetadata /
|
||||
RenameVariable / DeleteVariable / DescribeBlueprintJson.
|
||||
|
||||
Reachable from JS as the `variable_op` tool, and also merged into `asset_op`'s
|
||||
op-table for zero-restart availability (see assets.py).
|
||||
|
||||
Ops:
|
||||
add {bp_path, name, type, default?, instance_editable?, expose_on_spawn?,
|
||||
category?, tooltip?} — create a member variable, optionally set its
|
||||
default value + metadata. type is one of
|
||||
bool|int|float|double|string|name|text or a
|
||||
class path / short name for an object ref.
|
||||
set_default {bp_path, name, value}
|
||||
set_meta {bp_path, name, instance_editable?, expose_on_spawn?, category?, tooltip?}
|
||||
rename {bp_path, old, new}
|
||||
delete {bp_path, name}
|
||||
list {bp_path} — current member variables (name+type) as JSON.
|
||||
"""
|
||||
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_bp(path: str):
|
||||
a = unreal.EditorAssetLibrary.load_asset(path)
|
||||
if not isinstance(a, unreal.Blueprint):
|
||||
raise RuntimeError(f"not a blueprint: {path!r}")
|
||||
return a
|
||||
|
||||
|
||||
def _compile_save(bp):
|
||||
_lib().compile_and_save_blueprint(bp)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(bp)
|
||||
|
||||
|
||||
def _variables(bp) -> list:
|
||||
"""Return [{name,type,...}] from DescribeBlueprintJson."""
|
||||
try:
|
||||
info = json.loads(_lib().describe_blueprint_json(bp))
|
||||
return info.get("variables", [])
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
|
||||
|
||||
def _has_var(bp, name: str) -> bool:
|
||||
return any(v.get("name") == name for v in _variables(bp))
|
||||
|
||||
|
||||
def add(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
name = args["name"]
|
||||
type_spec = args.get("type") or args.get("type_spec")
|
||||
if not name or not type_spec:
|
||||
return {"ok": False, "error": "add requires {name, type}"}
|
||||
if _has_var(bp, name):
|
||||
return {"ok": False, "error": f"variable {name!r} already exists", "exists": True}
|
||||
|
||||
ok = bool(_lib().add_member_variable(bp, name, type_spec))
|
||||
if not ok:
|
||||
return {"ok": False, "error": f"add_member_variable failed (bad type {type_spec!r}?)"}
|
||||
|
||||
# Optional default value + metadata in the same transaction.
|
||||
if "default" in args and args["default"] is not None:
|
||||
_lib().set_variable_default_value(bp, name, str(args["default"]))
|
||||
if any(k in args for k in ("instance_editable", "expose_on_spawn", "category", "tooltip")):
|
||||
_lib().set_variable_metadata(
|
||||
bp, name,
|
||||
bool(args.get("instance_editable", False)),
|
||||
bool(args.get("expose_on_spawn", False)),
|
||||
str(args.get("category", "")),
|
||||
str(args.get("tooltip", "")),
|
||||
)
|
||||
_compile_save(bp)
|
||||
return {"ok": True, "name": name, "type": type_spec}
|
||||
|
||||
|
||||
def set_default(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
ok = bool(_lib().set_variable_default_value(bp, args["name"], str(args.get("value", ""))))
|
||||
if ok: _compile_save(bp)
|
||||
return {"ok": ok, "name": args["name"]}
|
||||
|
||||
|
||||
def set_meta(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
ok = bool(_lib().set_variable_metadata(
|
||||
bp, args["name"],
|
||||
bool(args.get("instance_editable", False)),
|
||||
bool(args.get("expose_on_spawn", False)),
|
||||
str(args.get("category", "")),
|
||||
str(args.get("tooltip", "")),
|
||||
))
|
||||
if ok: _compile_save(bp)
|
||||
return {"ok": ok, "name": args["name"]}
|
||||
|
||||
|
||||
def rename(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
ok = bool(_lib().rename_variable(bp, args["old"], args["new"]))
|
||||
if ok: _compile_save(bp)
|
||||
return {"ok": ok, "old": args["old"], "new": args["new"]}
|
||||
|
||||
|
||||
def delete(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
ok = bool(_lib().delete_variable(bp, args["name"]))
|
||||
if ok: _compile_save(bp)
|
||||
return {"ok": ok, "name": args["name"]}
|
||||
|
||||
|
||||
def list_vars(args: dict) -> dict:
|
||||
bp = _load_bp(args["bp_path"])
|
||||
return {"ok": True, "variables": _variables(bp)}
|
||||
|
||||
|
||||
# op-table — also merged into assets.OPS for zero-restart availability.
|
||||
VAR_OPS = {
|
||||
"add_variable": add,
|
||||
"add": add,
|
||||
"set_var_default": set_default,
|
||||
"set_default": set_default,
|
||||
"set_var_meta": set_meta,
|
||||
"set_meta": set_meta,
|
||||
"rename_variable": rename,
|
||||
"rename": rename,
|
||||
"delete_variable": delete,
|
||||
"delete": delete,
|
||||
"list_variables": list_vars,
|
||||
"list": list_vars,
|
||||
}
|
||||
|
||||
|
||||
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}"}, {})
|
||||
op = p.get("op")
|
||||
fn = VAR_OPS.get(op)
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": sorted(VAR_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-VARS] " + txt[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return txt
|
||||
430
ue_side/voxel_graph.py
Normal file
430
ue_side/voxel_graph.py
Normal file
@ -0,0 +1,430 @@
|
||||
"""Voxel Graph MCP ops.
|
||||
|
||||
This worker mirrors the small, dependable graph-editing surface used by the
|
||||
Blueprint worker, but targets Voxel Plugin terminal graphs.
|
||||
|
||||
Ops:
|
||||
list_terminals {asset_path}
|
||||
read_graph {asset_path, terminal?}
|
||||
get_selection {asset_path, terminal?}
|
||||
get_context {asset_path, terminal?}
|
||||
list_node_types {asset_path?, filter?, limit?}
|
||||
spawn_node {asset_path, terminal?, node, x,y, compile?}
|
||||
create_graph {path}
|
||||
add_property {asset_path, terminal?, kind, name, type?, category?}
|
||||
list_properties {asset_path, terminal?, kind}
|
||||
spawn_property {asset_path, terminal?, kind, ref, x,y, declaration?, compile?}
|
||||
spawn_call {asset_path, terminal?, function, x,y, compile?}
|
||||
compile {asset_path, terminal?}
|
||||
spawn_comment {asset_path, terminal?, x,y,width,height,text?, color?}
|
||||
clear_zones {asset_path, terminal?}
|
||||
delete_node {asset_path, terminal?, guid}
|
||||
break_link {asset_path, terminal?, from:[guid,pin], to:[guid,pin]}
|
||||
break_all {asset_path, terminal?, guid}
|
||||
connect_pins {asset_path, terminal?, from:[guid,pin], to:[guid,pin]}
|
||||
set_pin_default {asset_path, terminal?, guid,pin,value}
|
||||
move_nodes {asset_path, terminal?, moves:[{guid,x,y}]}
|
||||
resize_comment {asset_path, terminal?, guid,width,height}
|
||||
set_comment {asset_path, terminal?, guid,text,bubble?}
|
||||
open_asset {asset_path}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
try:
|
||||
import unreal # type: ignore
|
||||
except ImportError:
|
||||
unreal = None
|
||||
|
||||
|
||||
def _graph_lib():
|
||||
lib = getattr(unreal, "MCPGraphLibrary", None)
|
||||
if lib is None:
|
||||
raise RuntimeError("MCPGraphLibrary missing - rebuild C++ plugin")
|
||||
return lib
|
||||
|
||||
|
||||
def _voxel_lib():
|
||||
lib = getattr(unreal, "MCPVoxelGraphLibrary", None)
|
||||
if lib is None:
|
||||
raise RuntimeError("MCPVoxelGraphLibrary missing - rebuild C++ plugin")
|
||||
return lib
|
||||
|
||||
|
||||
def _load_asset(path: str):
|
||||
asset = unreal.EditorAssetLibrary.load_asset(path)
|
||||
if asset is None:
|
||||
raise RuntimeError(f"asset not found: {path!r}")
|
||||
if not bool(_voxel_lib().is_voxel_graph_asset(asset)):
|
||||
raise RuntimeError(f"asset is not a UVoxelGraph: {path!r} ({asset.get_class().get_name()})")
|
||||
return asset
|
||||
|
||||
|
||||
def _terminal(args: dict) -> str:
|
||||
return str(args.get("terminal") or "main")
|
||||
|
||||
|
||||
def _ed_graph(asset, args: dict):
|
||||
graph = _voxel_lib().find_terminal_ed_graph(asset, _terminal(args))
|
||||
if graph is None:
|
||||
raise RuntimeError(f"terminal graph not found: {_terminal(args)!r}")
|
||||
return graph
|
||||
|
||||
|
||||
def _save(asset):
|
||||
try:
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _changed(asset, graph, compile_graph: bool = True):
|
||||
_voxel_lib().notify_graph_changed(graph, bool(compile_graph))
|
||||
_save(asset)
|
||||
|
||||
|
||||
def list_terminals(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
return json.loads(_voxel_lib().list_terminal_graphs_json(asset))
|
||||
|
||||
|
||||
def read_graph(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
return json.loads(_voxel_lib().dump_terminal_graph_json(asset, _terminal(args)))
|
||||
|
||||
|
||||
def get_selection(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
return json.loads(_voxel_lib().get_selected_nodes_json(asset, _terminal(args)))
|
||||
|
||||
|
||||
def _bbox(nodes: list[dict]) -> dict | None:
|
||||
if not nodes:
|
||||
return None
|
||||
min_x = min(float(node.get("x", 0)) for node in nodes)
|
||||
min_y = min(float(node.get("y", 0)) for node in nodes)
|
||||
max_x = max(float(node.get("x", 0)) + float(node.get("w", 220)) for node in nodes)
|
||||
max_y = max(float(node.get("y", 0)) + float(node.get("h", 120)) for node in nodes)
|
||||
return {
|
||||
"min_x": min_x,
|
||||
"min_y": min_y,
|
||||
"max_x": max_x,
|
||||
"max_y": max_y,
|
||||
"width": max_x - min_x,
|
||||
"height": max_y - min_y,
|
||||
"center_x": min_x + ((max_x - min_x) / 2.0),
|
||||
"center_y": min_y + ((max_y - min_y) / 2.0),
|
||||
}
|
||||
|
||||
|
||||
def _zones(nodes: list[dict]) -> list[dict]:
|
||||
out = []
|
||||
for node in nodes:
|
||||
if node.get("class") != "EdGraphNode_Comment":
|
||||
continue
|
||||
comment = str(node.get("comment") or node.get("title") or "").strip().lower()
|
||||
if comment not in ("mcp zone", "mcp draft zone"):
|
||||
continue
|
||||
zone = dict(node)
|
||||
zone["bbox"] = _bbox([node])
|
||||
out.append(zone)
|
||||
return out
|
||||
|
||||
|
||||
def get_context(args: dict) -> dict:
|
||||
graph_data = read_graph(args)
|
||||
selection_data = get_selection(args)
|
||||
selected = selection_data.get("selected") or []
|
||||
return {
|
||||
"ok": bool(graph_data.get("ok")),
|
||||
"asset": graph_data.get("asset"),
|
||||
"terminal": graph_data.get("terminal"),
|
||||
"nodes": graph_data.get("nodes") or [],
|
||||
"selected": selected,
|
||||
"selected_count": len(selected),
|
||||
"bbox": _bbox(selected),
|
||||
"zones": _zones(graph_data.get("nodes") or []),
|
||||
"selection_error": selection_data.get("error"),
|
||||
}
|
||||
|
||||
|
||||
def list_node_types(args: dict) -> dict:
|
||||
# asset_path is optional here: the catalogue is global, but we keep the
|
||||
# signature consistent with the rest of the worker when one is supplied.
|
||||
flt = str(args.get("filter") or "")
|
||||
limit = int(args.get("limit") or 0)
|
||||
return json.loads(_voxel_lib().list_node_types_json(flt, limit))
|
||||
|
||||
|
||||
def spawn_node(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = _ed_graph(asset, args)
|
||||
node_id = str(args.get("node") or args.get("node_id") or "").strip()
|
||||
if not node_id:
|
||||
raise RuntimeError("spawn_node requires 'node' (id / display name)")
|
||||
guid = _voxel_lib().spawn_struct_node(
|
||||
graph,
|
||||
node_id,
|
||||
unreal.Vector2D(float(args.get("x", 0)), float(args.get("y", 0))),
|
||||
)
|
||||
ok = bool(guid)
|
||||
if ok:
|
||||
_changed(asset, graph, compile_graph=bool(args.get("compile", False)))
|
||||
return {"ok": ok, "guid": guid, "node": node_id, "terminal": _terminal(args)}
|
||||
|
||||
|
||||
def create_graph(args: dict) -> dict:
|
||||
path = str(args["path"]).rstrip("/")
|
||||
pkg_dir, _, name = path.rpartition("/")
|
||||
if not name or not pkg_dir:
|
||||
raise RuntimeError(f"bad path: {path!r} (expected e.g. /Game/MyGraph)")
|
||||
tools = unreal.AssetToolsHelpers.get_asset_tools()
|
||||
asset = tools.create_asset(name, pkg_dir, unreal.VoxelGraph, None)
|
||||
if asset is None:
|
||||
raise RuntimeError(f"failed to create voxel graph at {path}")
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return {"ok": True, "asset": asset.get_path_name()}
|
||||
|
||||
|
||||
def add_property(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
result = json.loads(_voxel_lib().add_graph_property_json(
|
||||
asset,
|
||||
_terminal(args),
|
||||
str(args.get("kind") or "parameter"),
|
||||
str(args.get("name") or ""),
|
||||
str(args.get("type") or "float"),
|
||||
str(args.get("category") or ""),
|
||||
))
|
||||
if result.get("ok"):
|
||||
_save(asset)
|
||||
return result
|
||||
|
||||
|
||||
def list_properties(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
return json.loads(_voxel_lib().list_graph_properties_json(
|
||||
asset, _terminal(args), str(args.get("kind") or "parameter")))
|
||||
|
||||
|
||||
def spawn_property(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = _ed_graph(asset, args)
|
||||
ref = str(args.get("ref") or args.get("name") or "").strip()
|
||||
if not ref:
|
||||
raise RuntimeError("spawn_property requires 'ref' (guid or name)")
|
||||
guid = _voxel_lib().spawn_property_node(
|
||||
graph,
|
||||
str(args.get("kind") or "parameter"),
|
||||
ref,
|
||||
unreal.Vector2D(float(args.get("x", 0)), float(args.get("y", 0))),
|
||||
bool(args.get("declaration", False)),
|
||||
)
|
||||
ok = bool(guid)
|
||||
if ok:
|
||||
_changed(asset, graph, compile_graph=bool(args.get("compile", False)))
|
||||
return {"ok": ok, "guid": guid, "kind": str(args.get("kind") or "parameter"), "terminal": _terminal(args)}
|
||||
|
||||
|
||||
def spawn_call(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = _ed_graph(asset, args)
|
||||
func = str(args.get("function") or "").strip()
|
||||
if not func:
|
||||
raise RuntimeError("spawn_call requires 'function' (terminal guid or name)")
|
||||
guid = _voxel_lib().spawn_call_function_node(
|
||||
graph,
|
||||
func,
|
||||
unreal.Vector2D(float(args.get("x", 0)), float(args.get("y", 0))),
|
||||
)
|
||||
ok = bool(guid)
|
||||
if ok:
|
||||
_changed(asset, graph, compile_graph=bool(args.get("compile", False)))
|
||||
return {"ok": ok, "guid": guid, "function": func, "terminal": _terminal(args)}
|
||||
|
||||
|
||||
def compile_graph(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = _ed_graph(asset, args)
|
||||
_changed(asset, graph, compile_graph=True)
|
||||
return {"ok": True, "terminal": _terminal(args)}
|
||||
|
||||
|
||||
def spawn_comment(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = _ed_graph(asset, args)
|
||||
color = args.get("color") or [0.05, 0.45, 0.9, 0.35]
|
||||
while len(color) < 4:
|
||||
color.append(1.0)
|
||||
guid = _voxel_lib().spawn_comment_node(
|
||||
graph,
|
||||
unreal.Vector2D(float(args.get("x", 0)), float(args.get("y", 0))),
|
||||
unreal.Vector2D(float(args.get("width", 420)), float(args.get("height", 260))),
|
||||
str(args.get("text") or "MCP Zone"),
|
||||
unreal.LinearColor(float(color[0]), float(color[1]), float(color[2]), float(color[3])),
|
||||
)
|
||||
ok = bool(guid)
|
||||
if ok:
|
||||
_changed(asset, graph, compile_graph=False)
|
||||
return {"ok": ok, "guid": guid, "terminal": _terminal(args)}
|
||||
|
||||
|
||||
def clear_zones(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = _ed_graph(asset, args)
|
||||
data = read_graph(args)
|
||||
deleted = []
|
||||
for zone in _zones(data.get("nodes") or []):
|
||||
guid = zone.get("guid")
|
||||
if guid and _graph_lib().delete_node(graph, guid):
|
||||
deleted.append(guid)
|
||||
if deleted:
|
||||
_changed(asset, graph, compile_graph=False)
|
||||
return {"ok": True, "deleted": deleted, "deleted_count": len(deleted)}
|
||||
|
||||
|
||||
def delete_node(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = _ed_graph(asset, args)
|
||||
ok = bool(_graph_lib().delete_node(graph, args["guid"]))
|
||||
if ok:
|
||||
_changed(asset, graph)
|
||||
return {"ok": ok, "guid": args["guid"]}
|
||||
|
||||
|
||||
def break_link(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = _ed_graph(asset, args)
|
||||
from_guid, from_pin = args["from"]
|
||||
to_guid, to_pin = args["to"]
|
||||
ok = bool(_graph_lib().break_link(graph, from_guid, from_pin, to_guid, to_pin))
|
||||
if ok:
|
||||
_changed(asset, graph)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def break_all(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = _ed_graph(asset, args)
|
||||
ok = bool(_graph_lib().break_all_node_links(graph, args["guid"]))
|
||||
if ok:
|
||||
_changed(asset, graph)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def connect_pins(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = _ed_graph(asset, args)
|
||||
from_guid, from_pin = args["from"]
|
||||
to_guid, to_pin = args["to"]
|
||||
ok = bool(_graph_lib().connect_pins(graph, from_guid, from_pin, to_guid, to_pin))
|
||||
if ok:
|
||||
_changed(asset, graph)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def set_pin_default(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = _ed_graph(asset, args)
|
||||
ok = bool(_graph_lib().set_pin_default(graph, args["guid"], args["pin"], str(args.get("value", ""))))
|
||||
if ok:
|
||||
_changed(asset, graph)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def move_nodes(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = _ed_graph(asset, args)
|
||||
moves = args.get("moves") or []
|
||||
ids = [m["guid"] for m in moves]
|
||||
positions = [unreal.Vector2D(float(m["x"]), float(m["y"])) for m in moves]
|
||||
count = int(_graph_lib().move_nodes(graph, ids, positions))
|
||||
if count:
|
||||
_changed(asset, graph, compile_graph=False)
|
||||
return {"ok": count > 0, "moved": count}
|
||||
|
||||
|
||||
def resize_comment(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = _ed_graph(asset, args)
|
||||
ok = bool(_graph_lib().resize_comment_node(graph, args["guid"], unreal.Vector2D(float(args["width"]), float(args["height"]))))
|
||||
if ok:
|
||||
_changed(asset, graph, compile_graph=False)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def set_comment(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
graph = _ed_graph(asset, args)
|
||||
ok = bool(_graph_lib().set_node_comment(graph, args["guid"], str(args.get("text", "")), bool(args.get("bubble", False))))
|
||||
if ok:
|
||||
_changed(asset, graph, compile_graph=False)
|
||||
return {"ok": ok}
|
||||
|
||||
|
||||
def open_asset(args: dict) -> dict:
|
||||
asset = _load_asset(args["asset_path"])
|
||||
return {"ok": bool(_voxel_lib().open_voxel_graph_asset(asset))}
|
||||
|
||||
|
||||
OPS = {
|
||||
"list_terminals": list_terminals,
|
||||
"read_graph": read_graph,
|
||||
"get_selection": get_selection,
|
||||
"get_context": get_context,
|
||||
"list_node_types": list_node_types,
|
||||
"spawn_node": spawn_node,
|
||||
"create_graph": create_graph,
|
||||
"add_property": add_property,
|
||||
"list_properties": list_properties,
|
||||
"spawn_property": spawn_property,
|
||||
"spawn_call": spawn_call,
|
||||
"compile": compile_graph,
|
||||
"spawn_comment": spawn_comment,
|
||||
"clear_zones": clear_zones,
|
||||
"delete_node": delete_node,
|
||||
"break_link": break_link,
|
||||
"break_all": break_all,
|
||||
"connect_pins": connect_pins,
|
||||
"set_pin_default": set_pin_default,
|
||||
"move_nodes": move_nodes,
|
||||
"resize_comment": resize_comment,
|
||||
"set_comment": set_comment,
|
||||
"open_asset": open_asset,
|
||||
}
|
||||
|
||||
|
||||
def entrypoint(payload_json: str) -> str:
|
||||
try:
|
||||
payload = json.loads(payload_json)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return _tee({"ok": False, "error": f"bad json: {exc}"}, {})
|
||||
|
||||
fn = OPS.get(payload.get("op"))
|
||||
if not fn:
|
||||
return _tee({"ok": False, "error": f"unknown op {payload.get('op')!r}", "available": list(OPS)}, payload)
|
||||
|
||||
try:
|
||||
return _tee(fn(payload), payload)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return _tee({"ok": False, "error": str(exc), "traceback": traceback.format_exc()}, payload)
|
||||
|
||||
|
||||
def _tee(result: dict, payload: dict) -> str:
|
||||
text = json.dumps(result)
|
||||
try:
|
||||
if unreal is not None:
|
||||
saved_dir = unreal.Paths.project_saved_dir() + "MCP/"
|
||||
os.makedirs(saved_dir, exist_ok=True)
|
||||
rid = payload.get("__rid") or "last"
|
||||
with open(saved_dir + f"{rid}.json", "w", encoding="utf-8") as handle:
|
||||
handle.write(text)
|
||||
with open(saved_dir + "last.json", "w", encoding="utf-8") as handle:
|
||||
handle.write(text)
|
||||
unreal.log("[MCP-VOXEL-GRAPH] " + text[:1500])
|
||||
except Exception:
|
||||
pass
|
||||
return text
|
||||
451
ue_side/widget_tree.py
Normal file
451
ue_side/widget_tree.py
Normal file
@ -0,0 +1,451 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user