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>
362 lines
15 KiB
Python
362 lines
15 KiB
Python
"""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
|