Add empty test level and Unreal MCP tooling
- L_TestFlight: empty, zero-gravity level for Phase 0 flight testing, set as editor/game default map. - Enable UnrealMCPython + Python Editor Script Plugin, third-party MCP plugin (GenOrca/unreal-mcp v2.2.0) giving direct editor control (actors, blueprints, levels, materials, etc.) alongside Epic's built-in Unreal MCP plugin. - Vendor the mcp-server Python source used to bridge to the plugin. - .mcp.json intentionally gitignored (machine-specific absolute paths).
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,151 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
"""
|
||||
Python action functions for Animation Blueprint authoring in Unreal Engine.
|
||||
|
||||
This domain owns what is *unique* to Animation Blueprints — creating one bound to
|
||||
a Skeleton, and skeleton-aware introspection. An AnimBlueprint is a UBlueprint, so
|
||||
generic graph work is intentionally NOT duplicated here:
|
||||
- compile -> use `blueprint compile_blueprint`
|
||||
- read AnimGraph -> use `blueprint get_blueprint_graph_info` with graph_name="AnimGraph"
|
||||
- add/remove vars -> use `blueprint add_variable` / `set_variable_flags`
|
||||
|
||||
AnimGraph node *authoring* (sequence players, state machines) lives in the editor-only
|
||||
AnimGraph C++ module and is not exposed to Python (UAnimationGraph::Nodes is protected),
|
||||
so ue_add_anim_graph_sequence_player and ue_build_anim_state_machine are backed by
|
||||
dedicated MCPythonHelper C++ UFUNCTIONs.
|
||||
"""
|
||||
import unreal
|
||||
import json
|
||||
import traceback
|
||||
|
||||
|
||||
def _split_object_path(asset_path: str):
|
||||
asset_path = asset_path.rstrip("/")
|
||||
idx = asset_path.rfind("/")
|
||||
return asset_path[idx + 1:], asset_path[:idx]
|
||||
|
||||
|
||||
def _load_anim_blueprint(asset_path: str):
|
||||
bp = unreal.EditorAssetLibrary.load_asset(asset_path)
|
||||
if not bp:
|
||||
raise FileNotFoundError(f"AnimBlueprint not found at path: {asset_path}")
|
||||
if not isinstance(bp, unreal.AnimBlueprint):
|
||||
raise TypeError(f"Asset at {asset_path} is not an AnimBlueprint, but {type(bp).__name__}.")
|
||||
return bp
|
||||
|
||||
|
||||
def _is_anim_instance_class(cls) -> bool:
|
||||
"""True if cls is AnimInstance or a subclass — checked via its class-default object."""
|
||||
try:
|
||||
return isinstance(unreal.get_default_object(cls), unreal.AnimInstance)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def ue_create_anim_blueprint(asset_path: str = None, skeleton_path: str = None,
|
||||
parent_class_path: str = "/Script/Engine.AnimInstance") -> str:
|
||||
"""Creates an Animation Blueprint bound to a Skeleton (parent defaults to AnimInstance)."""
|
||||
if asset_path is None or skeleton_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, skeleton_path."})
|
||||
try:
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
|
||||
return json.dumps({"success": False, "message": f"Asset already exists: {asset_path}"})
|
||||
|
||||
skeleton = unreal.EditorAssetLibrary.load_asset(skeleton_path)
|
||||
if not skeleton:
|
||||
return json.dumps({"success": False, "message": f"Skeleton not found: {skeleton_path}"})
|
||||
if not isinstance(skeleton, unreal.Skeleton):
|
||||
return json.dumps({"success": False,
|
||||
"message": f"Asset at {skeleton_path} is not a Skeleton, but {type(skeleton).__name__}."})
|
||||
|
||||
parent = unreal.load_class(None, parent_class_path)
|
||||
if not parent:
|
||||
return json.dumps({"success": False, "message": f"Parent class not found: {parent_class_path}"})
|
||||
if not _is_anim_instance_class(parent):
|
||||
return json.dumps({"success": False,
|
||||
"message": f"Parent class '{parent_class_path}' is not an AnimInstance subclass."})
|
||||
|
||||
name, package = _split_object_path(asset_path)
|
||||
factory = unreal.AnimBlueprintFactory()
|
||||
factory.set_editor_property("target_skeleton", skeleton)
|
||||
factory.set_editor_property("parent_class", parent)
|
||||
bp = unreal.AssetToolsHelpers.get_asset_tools().create_asset(name, package, unreal.AnimBlueprint, factory)
|
||||
if not bp:
|
||||
return json.dumps({"success": False, "message": f"Failed to create AnimBlueprint at {asset_path}."})
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(bp)
|
||||
return json.dumps({"success": True, "asset_path": asset_path,
|
||||
"skeleton": skeleton.get_path_name(), "parent_class": parent_class_path})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_get_anim_blueprint_info(asset_path: str = None) -> str:
|
||||
"""Returns an AnimBlueprint's target skeleton, generated class, and graph names."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
bp = _load_anim_blueprint(asset_path)
|
||||
bel = unreal.BlueprintEditorLibrary
|
||||
gen = bel.generated_class(bp)
|
||||
skeleton = bp.get_editor_property("target_skeleton")
|
||||
graphs = [g for g in ("AnimGraph", "EventGraph") if bel.find_graph(bp, unreal.Name(g))]
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"asset_path": asset_path,
|
||||
"generated_class": gen.get_name() if gen else None,
|
||||
"target_skeleton": skeleton.get_path_name() if skeleton else None,
|
||||
"graphs": graphs,
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
# ─── AnimGraph node authoring (backed by the MCPythonHelper C++ AnimGraph helpers) ──
|
||||
|
||||
def ue_add_anim_graph_sequence_player(asset_path: str = None, anim_sequence_path: str = None,
|
||||
link_to_output_pose: bool = True) -> str:
|
||||
"""Adds a looping Sequence Player to the AnimGraph, optionally wired to the Output Pose."""
|
||||
if asset_path is None or anim_sequence_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, anim_sequence_path."})
|
||||
try:
|
||||
bp = _load_anim_blueprint(asset_path)
|
||||
if not unreal.EditorAssetLibrary.does_asset_exist(anim_sequence_path):
|
||||
return json.dumps({"success": False, "message": f"AnimSequence not found: {anim_sequence_path}"})
|
||||
result_json = unreal.MCPythonHelper.add_anim_graph_sequence_player(
|
||||
bp, anim_sequence_path, link_to_output_pose)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(bp)
|
||||
return result_json
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_build_anim_state_machine(asset_path: str = None, spec: dict = None) -> str:
|
||||
"""Builds an arbitrary AnimGraph state machine from a spec: states[{name,anim?}], entry?, transitions[{from,to,var?,op?,value?}]."""
|
||||
if asset_path is None or spec is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, spec."})
|
||||
try:
|
||||
bp = _load_anim_blueprint(asset_path)
|
||||
states = spec.get("states") or []
|
||||
if not states:
|
||||
return json.dumps({"success": False, "message": "spec.states must be a non-empty list."})
|
||||
# Validate referenced anims up front (clearer error than a C++ load failure).
|
||||
for s in states:
|
||||
anim = s.get("anim")
|
||||
if anim and not unreal.EditorAssetLibrary.does_asset_exist(anim):
|
||||
return json.dumps({"success": False,
|
||||
"message": f"State '{s.get('name')}': AnimSequence not found: {anim}"})
|
||||
# Auto-create every float variable referenced by a transition rule.
|
||||
transitions = spec.get("transitions") or []
|
||||
needed_vars = {t["var"] for t in transitions if t.get("var")}
|
||||
if needed_vars:
|
||||
bel = unreal.BlueprintEditorLibrary
|
||||
pin = bel.get_basic_type_by_name(unreal.Name("real"))
|
||||
for v in needed_vars:
|
||||
bel.add_member_variable(bp, unreal.Name(v), pin) # no-op if it already exists
|
||||
bel.compile_blueprint(bp)
|
||||
result_json = unreal.MCPythonHelper.build_anim_state_machine(bp, json.dumps(spec))
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(bp)
|
||||
return result_json
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
@@ -0,0 +1,352 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
"""
|
||||
Python action functions for Animation Sequence editing in Unreal Engine.
|
||||
|
||||
Covers AnimSequence introspection and authoring (notify tracks, sync markers,
|
||||
float curves) via unreal.AnimationLibrary. AnimBlueprint graph editing and IK
|
||||
retargeting need C++ / extra plugins and are out of scope here.
|
||||
"""
|
||||
import unreal
|
||||
import json
|
||||
import traceback
|
||||
|
||||
AL = unreal.AnimationLibrary
|
||||
_RCT_FLOAT = unreal.RawCurveTrackTypes.RCT_FLOAT
|
||||
|
||||
|
||||
def _load_anim_sequence(asset_path: str):
|
||||
if not asset_path:
|
||||
raise ValueError("AnimSequence path cannot be empty.")
|
||||
seq = unreal.EditorAssetLibrary.load_asset(asset_path)
|
||||
if not seq:
|
||||
raise FileNotFoundError(f"AnimSequence not found at path: {asset_path}")
|
||||
if not isinstance(seq, unreal.AnimSequence):
|
||||
raise TypeError(f"Asset at {asset_path} is not an AnimSequence, but {type(seq).__name__}")
|
||||
return seq
|
||||
|
||||
|
||||
# --- Introspection ------------------------------------------------------------
|
||||
|
||||
def ue_get_anim_sequence_info(asset_path: str = None) -> str:
|
||||
"""Returns length, frame count, approximate fps, and skeleton path of an AnimSequence."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
seq = _load_anim_sequence(asset_path)
|
||||
length = AL.get_sequence_length(seq)
|
||||
frames = AL.get_num_frames(seq)
|
||||
fps = round((frames - 1) / length, 2) if length > 0 and frames > 1 else None
|
||||
skel = seq.get_skeleton()
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"asset_path": asset_path,
|
||||
"length_seconds": round(length, 4),
|
||||
"num_frames": frames,
|
||||
"fps": fps,
|
||||
"skeleton": skel.get_path_name() if skel else None,
|
||||
"notify_track_count": len(AL.get_animation_notify_track_names(seq)),
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_list_notify_tracks(asset_path: str = None) -> str:
|
||||
"""Lists the notify track names on an AnimSequence."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
seq = _load_anim_sequence(asset_path)
|
||||
return json.dumps({"success": True, "asset_path": asset_path,
|
||||
"tracks": [str(n) for n in AL.get_animation_notify_track_names(seq)]})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_list_notifies(asset_path: str = None) -> str:
|
||||
"""Lists notify event names on an AnimSequence."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
seq = _load_anim_sequence(asset_path)
|
||||
return json.dumps({"success": True, "asset_path": asset_path,
|
||||
"notifies": [str(n) for n in AL.get_animation_notify_event_names(seq)]})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_list_curves(asset_path: str = None) -> str:
|
||||
"""Lists float animation curve names on an AnimSequence."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
seq = _load_anim_sequence(asset_path)
|
||||
return json.dumps({"success": True, "asset_path": asset_path,
|
||||
"curves": [str(n) for n in AL.get_animation_curve_names(seq, _RCT_FLOAT)]})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_list_sync_markers(asset_path: str = None) -> str:
|
||||
"""Lists sync markers (name + time) on an AnimSequence."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
seq = _load_anim_sequence(asset_path)
|
||||
markers = []
|
||||
for m in AL.get_animation_sync_markers(seq):
|
||||
markers.append({
|
||||
"name": str(getattr(m, "marker_name", "")),
|
||||
"time": round(float(getattr(m, "time", 0.0)), 4),
|
||||
})
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "sync_markers": markers})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
# --- Notify tracks ------------------------------------------------------------
|
||||
|
||||
def ue_add_notify_track(asset_path: str = None, track_name: str = None) -> str:
|
||||
"""Adds a notify track to an AnimSequence."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if track_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'track_name' is missing."})
|
||||
try:
|
||||
seq = _load_anim_sequence(asset_path)
|
||||
AL.add_animation_notify_track(seq, track_name)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(seq)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "track_name": track_name,
|
||||
"tracks": [str(n) for n in AL.get_animation_notify_track_names(seq)]})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_remove_notify_track(asset_path: str = None, track_name: str = None) -> str:
|
||||
"""Removes a notify track (and its notifies) from an AnimSequence."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if track_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'track_name' is missing."})
|
||||
try:
|
||||
seq = _load_anim_sequence(asset_path)
|
||||
if not AL.is_valid_anim_notify_track_name(seq, track_name):
|
||||
return json.dumps({"success": False, "message": f"Notify track '{track_name}' does not exist.",
|
||||
"tracks": [str(n) for n in AL.get_animation_notify_track_names(seq)]})
|
||||
AL.remove_animation_notify_track(seq, track_name)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(seq)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "removed": track_name,
|
||||
"tracks": [str(n) for n in AL.get_animation_notify_track_names(seq)]})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
# --- Sync markers -------------------------------------------------------------
|
||||
|
||||
def ue_add_sync_marker(asset_path: str = None, track_name: str = None,
|
||||
marker_name: str = None, time_seconds: float = None) -> str:
|
||||
"""Adds a sync marker at a time (seconds) on a notify track. Creates the track if needed."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if track_name is None or marker_name is None or time_seconds is None:
|
||||
return json.dumps({"success": False, "message": "Required: track_name, marker_name, time_seconds."})
|
||||
try:
|
||||
seq = _load_anim_sequence(asset_path)
|
||||
if not AL.is_valid_anim_notify_track_name(seq, track_name):
|
||||
AL.add_animation_notify_track(seq, track_name)
|
||||
AL.add_animation_sync_marker(seq, marker_name, float(time_seconds), track_name)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(seq)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "track_name": track_name,
|
||||
"marker_name": marker_name, "time_seconds": float(time_seconds)})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
# --- Curves -------------------------------------------------------------------
|
||||
|
||||
def ue_add_float_curve(asset_path: str = None, curve_name: str = None,
|
||||
time_seconds: float = None, value: float = None) -> str:
|
||||
"""Adds a float curve to an AnimSequence, optionally with an initial key at time_seconds=value."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if curve_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'curve_name' is missing."})
|
||||
try:
|
||||
seq = _load_anim_sequence(asset_path)
|
||||
if not AL.does_curve_exist(seq, curve_name, _RCT_FLOAT):
|
||||
AL.add_curve(seq, curve_name, _RCT_FLOAT, False)
|
||||
if time_seconds is not None and value is not None:
|
||||
AL.add_float_curve_key(seq, curve_name, float(time_seconds), float(value))
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(seq)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "curve_name": curve_name,
|
||||
"curves": [str(n) for n in AL.get_animation_curve_names(seq, _RCT_FLOAT)]})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_remove_curve(asset_path: str = None, curve_name: str = None) -> str:
|
||||
"""Removes a float curve from an AnimSequence."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if curve_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'curve_name' is missing."})
|
||||
try:
|
||||
seq = _load_anim_sequence(asset_path)
|
||||
if not AL.does_curve_exist(seq, curve_name, _RCT_FLOAT):
|
||||
return json.dumps({"success": False, "message": f"Curve '{curve_name}' does not exist.",
|
||||
"curves": [str(n) for n in AL.get_animation_curve_names(seq, _RCT_FLOAT)]})
|
||||
AL.remove_curve(seq, curve_name, False)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(seq)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "removed": curve_name,
|
||||
"curves": [str(n) for n in AL.get_animation_curve_names(seq, _RCT_FLOAT)]})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
# --- SkeletalMesh / Skeleton queries ------------------------------------------
|
||||
|
||||
def _load_skeletal_mesh(asset_path: str):
|
||||
sm = unreal.EditorAssetLibrary.load_asset(asset_path)
|
||||
if not sm:
|
||||
raise FileNotFoundError(f"SkeletalMesh not found at path: {asset_path}")
|
||||
if not isinstance(sm, unreal.SkeletalMesh):
|
||||
raise TypeError(f"Asset at {asset_path} is not a SkeletalMesh, but {type(sm).__name__}")
|
||||
return sm
|
||||
|
||||
|
||||
def ue_get_skeletal_mesh_info(asset_path: str = None) -> str:
|
||||
"""Returns the skeleton path, socket count, and material-slot count of a SkeletalMesh."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
sm = _load_skeletal_mesh(asset_path)
|
||||
skel = sm.skeleton
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"asset_path": asset_path,
|
||||
"skeleton": skel.get_path_name() if skel else None,
|
||||
"num_sockets": sm.num_sockets(),
|
||||
"num_materials": len(sm.materials),
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_list_sockets(asset_path: str = None) -> str:
|
||||
"""Lists sockets on a SkeletalMesh (name, bone, relative location/rotation)."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
sm = _load_skeletal_mesh(asset_path)
|
||||
sockets = []
|
||||
for i in range(sm.num_sockets()):
|
||||
s = sm.get_socket_by_index(i)
|
||||
loc = s.relative_location
|
||||
rot = s.relative_rotation
|
||||
sockets.append({
|
||||
"name": str(s.socket_name),
|
||||
"bone": str(s.bone_name),
|
||||
"relative_location": [round(loc.x, 3), round(loc.y, 3), round(loc.z, 3)],
|
||||
"relative_rotation": [round(rot.pitch, 3), round(rot.yaw, 3), round(rot.roll, 3)],
|
||||
})
|
||||
return json.dumps({"success": True, "asset_path": asset_path,
|
||||
"num_sockets": len(sockets), "sockets": sockets})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_find_socket(asset_path: str = None, socket_name: str = None) -> str:
|
||||
"""Returns details of a named socket on a SkeletalMesh, or success=False if not found."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if socket_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'socket_name' is missing."})
|
||||
try:
|
||||
sm = _load_skeletal_mesh(asset_path)
|
||||
s = sm.find_socket(unreal.Name(socket_name))
|
||||
if not s:
|
||||
return json.dumps({"success": False, "message": f"Socket '{socket_name}' not found."})
|
||||
loc = s.relative_location
|
||||
rot = s.relative_rotation
|
||||
return json.dumps({
|
||||
"success": True, "asset_path": asset_path,
|
||||
"name": str(s.socket_name), "bone": str(s.bone_name),
|
||||
"relative_location": [round(loc.x, 3), round(loc.y, 3), round(loc.z, 3)],
|
||||
"relative_rotation": [round(rot.pitch, 3), round(rot.yaw, 3), round(rot.roll, 3)],
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_get_skeleton_info(asset_path: str = None) -> str:
|
||||
"""Returns curve metadata names for a Skeleton asset."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
skel = unreal.EditorAssetLibrary.load_asset(asset_path)
|
||||
if not skel:
|
||||
return json.dumps({"success": False, "message": f"Skeleton not found at path: {asset_path}"})
|
||||
if not isinstance(skel, unreal.Skeleton):
|
||||
return json.dumps({"success": False, "message": f"Asset at {asset_path} is not a Skeleton, but {type(skel).__name__}"})
|
||||
return json.dumps({
|
||||
"success": True, "asset_path": asset_path,
|
||||
"curve_names": [str(n) for n in skel.get_curve_meta_data_names()],
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
# --- SkeletalMesh bones / socket editing (via MCPythonHelper C++) --------------
|
||||
# Bone enumeration and socket creation are not available through stock Python
|
||||
# (socket_name is read-only on a bare SkeletalMeshSocket), so these proxy the
|
||||
# C++ helper, which returns a JSON string directly.
|
||||
|
||||
def ue_list_bones(asset_path: str = None) -> str:
|
||||
"""Lists reference-skeleton bones (name, index, parent) of a SkeletalMesh."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
sm = _load_skeletal_mesh(asset_path)
|
||||
return unreal.MCPythonHelper.get_skeleton_bones(sm)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_add_socket(asset_path: str = None, socket_name: str = None, bone_name: str = None,
|
||||
location: list = None, rotation: list = None) -> str:
|
||||
"""Adds a socket on a bone of a SkeletalMesh. location=[x,y,z], rotation=[pitch,yaw,roll]."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if socket_name is None or bone_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: socket_name, bone_name."})
|
||||
try:
|
||||
sm = _load_skeletal_mesh(asset_path)
|
||||
loc = location or [0.0, 0.0, 0.0]
|
||||
rot = rotation or [0.0, 0.0, 0.0]
|
||||
if len(loc) != 3 or len(rot) != 3:
|
||||
return json.dumps({"success": False, "message": "location and rotation must be lists of 3 floats."})
|
||||
result = unreal.MCPythonHelper.add_skeletal_mesh_socket(
|
||||
sm, socket_name, bone_name,
|
||||
float(loc[0]), float(loc[1]), float(loc[2]),
|
||||
float(rot[0]), float(rot[1]), float(rot[2]))
|
||||
if json.loads(result).get("success"):
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(sm)
|
||||
return result
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_remove_socket(asset_path: str = None, socket_name: str = None) -> str:
|
||||
"""Removes a named socket from a SkeletalMesh."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if socket_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'socket_name' is missing."})
|
||||
try:
|
||||
sm = _load_skeletal_mesh(asset_path)
|
||||
result = unreal.MCPythonHelper.remove_skeletal_mesh_socket(sm, socket_name)
|
||||
if json.loads(result).get("success"):
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(sm)
|
||||
return result
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
@@ -0,0 +1,490 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
import unreal
|
||||
import json
|
||||
import os
|
||||
import traceback
|
||||
|
||||
ASSET_ACTIONS_MODULE = "asset_actions"
|
||||
|
||||
def ue_find_by_query(name : str = None, asset_type : str = None) -> str:
|
||||
"""
|
||||
Returns a JSON list of asset paths under '/Game' matching the given query dict.
|
||||
Supported keys: 'name' (substring match), 'asset_type' (Unreal class name, e.g. 'StaticMesh')
|
||||
At least one of name or asset_type must be provided.
|
||||
"""
|
||||
if name is None and asset_type is None: # This check is specific to this function's logic
|
||||
return json.dumps({"success": False, "message": "At least one of 'name' or 'asset_type' must be provided for ue_find_by_query.", "assets": []})
|
||||
|
||||
assets = unreal.EditorAssetLibrary.list_assets('/Game', recursive=True)
|
||||
matches = []
|
||||
for asset_path in assets:
|
||||
asset_data = unreal.EditorAssetLibrary.find_asset_data(asset_path)
|
||||
|
||||
current_asset_type_str = ""
|
||||
if hasattr(asset_data, 'asset_class_str') and asset_data.asset_class_str:
|
||||
current_asset_type_str = str(asset_data.asset_class_str)
|
||||
elif hasattr(asset_data, 'asset_class') and asset_data.asset_class:
|
||||
current_asset_type_str = str(asset_data.asset_class)
|
||||
else:
|
||||
# Fallback if asset class information is not directly available or named differently
|
||||
# This might happen with certain asset types or engine versions
|
||||
# unreal.log_warning(f"Could not determine asset class for {asset_path}")
|
||||
pass # Continue checking name if type is indeterminable but name is specified
|
||||
|
||||
name_match = True
|
||||
if name is not None:
|
||||
name_match = name.lower() in asset_path.lower()
|
||||
|
||||
type_match = True
|
||||
if asset_type is not None:
|
||||
if not current_asset_type_str: # If type couldn't be determined, it can't match a specified type
|
||||
type_match = False
|
||||
else:
|
||||
type_match = asset_type.lower() == current_asset_type_str.lower()
|
||||
|
||||
if name_match and type_match:
|
||||
matches.append(asset_path)
|
||||
|
||||
return json.dumps({"success": True, "assets": matches, "message": f"{len(matches)} assets found matching query."})
|
||||
|
||||
def ue_get_static_mesh_details(asset_path: str = None) -> str:
|
||||
"""
|
||||
Retrieves the bounding box and dimensions of a static mesh asset.
|
||||
|
||||
:param asset_path: Path to the static mesh asset (e.g., "/Game/Meshes/MyCube.MyCube").
|
||||
:return: JSON string with asset details including bounding box and dimensions.
|
||||
"""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
static_mesh = unreal.EditorAssetLibrary.load_asset(asset_path)
|
||||
if not static_mesh or not isinstance(static_mesh, unreal.StaticMesh):
|
||||
return json.dumps({"success": False, "message": f"Asset is not a StaticMesh or could not be loaded: {asset_path}"})
|
||||
|
||||
bounds = static_mesh.get_bounding_box() # This returns a Box type object
|
||||
|
||||
min_point = bounds.min
|
||||
max_point = bounds.max
|
||||
|
||||
dimensions = {
|
||||
"x": max_point.x - min_point.x,
|
||||
"y": max_point.y - min_point.y,
|
||||
"z": max_point.z - min_point.z
|
||||
}
|
||||
|
||||
details = {
|
||||
"asset_path": asset_path,
|
||||
"bounding_box_min": {"x": min_point.x, "y": min_point.y, "z": min_point.z},
|
||||
"bounding_box_max": {"x": max_point.x, "y": max_point.y, "z": max_point.z},
|
||||
"dimensions": dimensions
|
||||
}
|
||||
return json.dumps({"success": True, "details": details})
|
||||
except Exception as e:
|
||||
tb_str = traceback.format_exc()
|
||||
unreal.log_error(f"Error in ue_get_static_mesh_details for {asset_path}: {str(e)}\n{tb_str}")
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
|
||||
|
||||
|
||||
# --- Asset management (EditorAssetLibrary) ------------------------------------
|
||||
|
||||
def ue_duplicate_asset(source_path: str = None, dest_path: str = None) -> str:
|
||||
"""Duplicates an asset to a new content-browser path."""
|
||||
if source_path is None or dest_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: source_path, dest_path."})
|
||||
try:
|
||||
if not unreal.EditorAssetLibrary.does_asset_exist(source_path):
|
||||
return json.dumps({"success": False, "message": f"Source asset not found: {source_path}"})
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(dest_path):
|
||||
return json.dumps({"success": False, "message": f"Destination already exists: {dest_path}"})
|
||||
new_asset = unreal.EditorAssetLibrary.duplicate_asset(source_path, dest_path)
|
||||
if not new_asset:
|
||||
return json.dumps({"success": False, "message": f"Failed to duplicate to {dest_path}."})
|
||||
return json.dumps({"success": True, "source_path": source_path, "dest_path": dest_path})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_rename_asset(source_path: str = None, dest_path: str = None) -> str:
|
||||
"""Renames/moves an asset to a new content-browser path."""
|
||||
if source_path is None or dest_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: source_path, dest_path."})
|
||||
try:
|
||||
if not unreal.EditorAssetLibrary.does_asset_exist(source_path):
|
||||
return json.dumps({"success": False, "message": f"Source asset not found: {source_path}"})
|
||||
ok = unreal.EditorAssetLibrary.rename_asset(source_path, dest_path)
|
||||
return json.dumps({"success": bool(ok), "source_path": source_path, "dest_path": dest_path,
|
||||
"message": "Renamed." if ok else "rename_asset returned False."})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_delete_asset(asset_path: str = None) -> str:
|
||||
"""Deletes an asset from the content browser."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
if not unreal.EditorAssetLibrary.does_asset_exist(asset_path):
|
||||
return json.dumps({"success": False, "message": f"Asset not found: {asset_path}"})
|
||||
ok = unreal.EditorAssetLibrary.delete_asset(asset_path)
|
||||
return json.dumps({"success": bool(ok), "asset_path": asset_path,
|
||||
"message": "Deleted." if ok else "delete_asset returned False."})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_save_asset(asset_path: str = None) -> str:
|
||||
"""Saves an asset to disk."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
if not unreal.EditorAssetLibrary.does_asset_exist(asset_path):
|
||||
return json.dumps({"success": False, "message": f"Asset not found: {asset_path}"})
|
||||
ok = unreal.EditorAssetLibrary.save_asset(asset_path)
|
||||
return json.dumps({"success": bool(ok), "asset_path": asset_path})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_asset_exists(asset_path: str = None) -> str:
|
||||
"""Returns whether an asset exists at the given path."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
return json.dumps({"success": True, "asset_path": asset_path,
|
||||
"exists": bool(unreal.EditorAssetLibrary.does_asset_exist(asset_path))})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_get_asset_info(asset_path: str = None) -> str:
|
||||
"""Returns class and package info for an asset."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
data = unreal.EditorAssetLibrary.find_asset_data(asset_path)
|
||||
if not data or not data.is_valid():
|
||||
return json.dumps({"success": False, "message": f"Asset not found: {asset_path}"})
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"asset_path": asset_path,
|
||||
"asset_name": str(data.asset_name),
|
||||
"asset_class": str(data.asset_class_path.asset_name) if hasattr(data, "asset_class_path") else "",
|
||||
"package_name": str(data.package_name),
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_list_assets(directory_path: str = None, recursive: bool = True) -> str:
|
||||
"""Lists asset paths under a content directory."""
|
||||
if directory_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'directory_path' is missing."})
|
||||
try:
|
||||
if not unreal.EditorAssetLibrary.does_directory_exist(directory_path):
|
||||
return json.dumps({"success": False, "message": f"Directory not found: {directory_path}"})
|
||||
assets = [str(a) for a in unreal.EditorAssetLibrary.list_assets(directory_path, recursive=bool(recursive))]
|
||||
return json.dumps({"success": True, "directory_path": directory_path,
|
||||
"count": len(assets), "assets": assets})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_find_referencers(asset_path: str = None) -> str:
|
||||
"""Lists packages that reference the given asset."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
if not unreal.EditorAssetLibrary.does_asset_exist(asset_path):
|
||||
return json.dumps({"success": False, "message": f"Asset not found: {asset_path}"})
|
||||
refs = [str(r) for r in unreal.EditorAssetLibrary.find_package_referencers_for_asset(asset_path, False)]
|
||||
return json.dumps({"success": True, "asset_path": asset_path,
|
||||
"count": len(refs), "referencers": refs})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_make_directory(directory_path: str = None) -> str:
|
||||
"""Creates a content-browser directory."""
|
||||
if directory_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'directory_path' is missing."})
|
||||
try:
|
||||
ok = unreal.EditorAssetLibrary.make_directory(directory_path)
|
||||
return json.dumps({"success": bool(ok), "directory_path": directory_path})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_delete_directory(directory_path: str = None) -> str:
|
||||
"""Deletes a content-browser directory and its assets."""
|
||||
if directory_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'directory_path' is missing."})
|
||||
try:
|
||||
if not unreal.EditorAssetLibrary.does_directory_exist(directory_path):
|
||||
return json.dumps({"success": False, "message": f"Directory not found: {directory_path}"})
|
||||
ok = unreal.EditorAssetLibrary.delete_directory(directory_path)
|
||||
return json.dumps({"success": bool(ok), "directory_path": directory_path})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
# --- Dependencies + metadata tags ---------------------------------------------
|
||||
|
||||
def _package_of(asset_path: str) -> str:
|
||||
return asset_path.split(".")[0]
|
||||
|
||||
|
||||
def ue_get_dependencies(asset_path: str = None) -> str:
|
||||
"""Lists packages that the given asset depends on (references)."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
if not unreal.EditorAssetLibrary.does_asset_exist(asset_path):
|
||||
return json.dumps({"success": False, "message": f"Asset not found: {asset_path}"})
|
||||
ar = unreal.AssetRegistryHelpers.get_asset_registry()
|
||||
opt = unreal.AssetRegistryDependencyOptions(
|
||||
include_soft_package_references=True, include_hard_package_references=True)
|
||||
deps = ar.get_dependencies(unreal.Name(_package_of(asset_path)), opt) or []
|
||||
deps = [str(d) for d in deps]
|
||||
return json.dumps({"success": True, "asset_path": asset_path,
|
||||
"count": len(deps), "dependencies": deps})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_get_metadata_tag(asset_path: str = None, tag: str = None) -> str:
|
||||
"""Reads a metadata tag value on an asset (empty string if unset)."""
|
||||
if asset_path is None or tag is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, tag."})
|
||||
try:
|
||||
asset = unreal.EditorAssetLibrary.load_asset(asset_path)
|
||||
if not asset:
|
||||
return json.dumps({"success": False, "message": f"Asset not found: {asset_path}"})
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "tag": tag,
|
||||
"value": unreal.EditorAssetLibrary.get_metadata_tag(asset, unreal.Name(tag))})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_set_metadata_tag(asset_path: str = None, tag: str = None, value: str = None) -> str:
|
||||
"""Sets a metadata tag value on an asset."""
|
||||
if asset_path is None or tag is None or value is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, tag, value."})
|
||||
try:
|
||||
asset = unreal.EditorAssetLibrary.load_asset(asset_path)
|
||||
if not asset:
|
||||
return json.dumps({"success": False, "message": f"Asset not found: {asset_path}"})
|
||||
unreal.EditorAssetLibrary.set_metadata_tag(asset, unreal.Name(tag), value)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "tag": tag, "value": value})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_remove_metadata_tag(asset_path: str = None, tag: str = None) -> str:
|
||||
"""Removes a metadata tag from an asset."""
|
||||
if asset_path is None or tag is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, tag."})
|
||||
try:
|
||||
asset = unreal.EditorAssetLibrary.load_asset(asset_path)
|
||||
if not asset:
|
||||
return json.dumps({"success": False, "message": f"Asset not found: {asset_path}"})
|
||||
unreal.EditorAssetLibrary.remove_metadata_tag(asset, unreal.Name(tag))
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "removed_tag": tag})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
# --- File import / export (FBX, textures) --------------------------------------
|
||||
# IMPORTANT: imports pin a LEGACY factory (FbxFactory / TextureFactory) on the
|
||||
# AssetImportTask. Routing through Interchange (the default when no factory is
|
||||
# set) crashes the editor from this TCP-handler context with a TaskGraph
|
||||
# RecursionGuard assertion (re-entrant task-graph pumping inside InterchangeEngine).
|
||||
|
||||
def ue_import_fbx(file_path: str = None, destination_path: str = None,
|
||||
destination_name: str = "", as_skeletal: bool = False,
|
||||
import_materials: bool = False, import_textures: bool = False,
|
||||
import_animations: bool = False) -> str:
|
||||
"""Imports an FBX file as a Static/Skeletal mesh using the legacy FBX importer."""
|
||||
if file_path is None or destination_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: file_path, destination_path."})
|
||||
try:
|
||||
if not os.path.isfile(file_path):
|
||||
return json.dumps({"success": False, "message": f"File not found: {file_path}"})
|
||||
ui = unreal.FbxImportUI()
|
||||
ui.automated_import_should_detect_type = False
|
||||
ui.mesh_type_to_import = (unreal.FBXImportType.FBXIT_SKELETAL_MESH if as_skeletal
|
||||
else unreal.FBXImportType.FBXIT_STATIC_MESH)
|
||||
ui.import_mesh = True
|
||||
ui.import_as_skeletal = bool(as_skeletal)
|
||||
ui.import_materials = bool(import_materials)
|
||||
ui.import_textures = bool(import_textures)
|
||||
ui.import_animations = bool(import_animations)
|
||||
task = unreal.AssetImportTask()
|
||||
task.filename = file_path
|
||||
task.destination_path = destination_path
|
||||
if destination_name:
|
||||
task.destination_name = destination_name
|
||||
task.automated = True
|
||||
task.replace_existing = True
|
||||
task.save = True
|
||||
task.options = ui
|
||||
task.factory = unreal.FbxFactory() # legacy importer — bypass Interchange (crash)
|
||||
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
|
||||
paths = [str(p).split(".")[0] for p in task.imported_object_paths]
|
||||
if not paths:
|
||||
return json.dumps({"success": False, "message": "Import produced no assets (see Output Log)."})
|
||||
return json.dumps({"success": True, "file_path": file_path, "imported_assets": paths})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def _gltf_status_path(destination_path: str) -> str:
|
||||
safe = destination_path.strip("/").replace("/", "_").replace("\\", "_")
|
||||
return os.path.join(unreal.Paths.project_saved_dir(), f"_mcp_gltf_{safe}.json").replace("\\", "/")
|
||||
|
||||
|
||||
def ue_import_gltf(file_path: str = None, destination_path: str = None) -> str:
|
||||
"""Imports a .glb/.gltf via Interchange, deferred to the editor tick. Poll get_gltf_import_status for the result."""
|
||||
# glTF import goes through Interchange, which is async (task-graph based). Driving it
|
||||
# synchronously from the MCP game-thread task re-enters the task graph and crashes
|
||||
# (RecursionGuard assertion). There is no legacy synchronous glTF factory in UE 5.7
|
||||
# (unlike FBX), so we schedule the import on the next editor tick — outside our task —
|
||||
# and report the result via a status sidecar polled by get_gltf_import_status.
|
||||
if file_path is None or destination_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: file_path, destination_path."})
|
||||
try:
|
||||
if not os.path.isfile(file_path):
|
||||
return json.dumps({"success": False, "message": f"File not found: {file_path}"})
|
||||
status_path = _gltf_status_path(destination_path)
|
||||
if os.path.isfile(status_path):
|
||||
os.remove(status_path)
|
||||
unreal.EditorAssetLibrary.make_directory(destination_path)
|
||||
|
||||
handle = [None]
|
||||
fired = [False]
|
||||
|
||||
def _run(delta_seconds):
|
||||
if fired[0]:
|
||||
return
|
||||
fired[0] = True
|
||||
try:
|
||||
task = unreal.AssetImportTask()
|
||||
task.filename = file_path
|
||||
task.destination_path = destination_path
|
||||
task.automated = True
|
||||
task.replace_existing = True
|
||||
task.save = True
|
||||
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
|
||||
res = {"success": True}
|
||||
except Exception as e:
|
||||
res = {"success": False, "error": str(e), "traceback": traceback.format_exc()}
|
||||
try:
|
||||
with open(status_path, "w", encoding="utf-8") as f:
|
||||
json.dump(res, f)
|
||||
except Exception:
|
||||
pass
|
||||
if handle[0] is not None:
|
||||
unreal.unregister_slate_post_tick_callback(handle[0])
|
||||
|
||||
handle[0] = unreal.register_slate_post_tick_callback(_run)
|
||||
return json.dumps({"success": True, "pending": True, "destination_path": destination_path,
|
||||
"message": "glTF import scheduled on the editor tick. Poll 'get_gltf_import_status' with this destination_path."})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_get_gltf_import_status(destination_path: str = None) -> str:
|
||||
"""Polls a scheduled glTF import; returns done + imported assets once Interchange finishes."""
|
||||
if destination_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'destination_path' is missing."})
|
||||
try:
|
||||
status_path = _gltf_status_path(destination_path)
|
||||
if not os.path.isfile(status_path):
|
||||
return json.dumps({"success": True, "done": False, "pending": True,
|
||||
"message": "Import tick has not fired yet; retry shortly."})
|
||||
with open(status_path, encoding="utf-8") as f:
|
||||
res = json.load(f)
|
||||
if not res.get("success"):
|
||||
os.remove(status_path)
|
||||
return json.dumps({"success": False, "done": True,
|
||||
"message": res.get("error", "glTF import failed."),
|
||||
"traceback": res.get("traceback")})
|
||||
created = (unreal.EditorAssetLibrary.list_assets(destination_path, recursive=True)
|
||||
if unreal.EditorAssetLibrary.does_directory_exist(destination_path) else [])
|
||||
if not created:
|
||||
# import_asset_tasks was called but Interchange is still finishing asynchronously
|
||||
return json.dumps({"success": True, "done": False, "pending": True,
|
||||
"message": "Interchange still importing; retry shortly."})
|
||||
assets = [{"path": str(p).split(".")[0],
|
||||
"class": str(unreal.EditorAssetLibrary.find_asset_data(p).asset_class_path.asset_name)}
|
||||
for p in created]
|
||||
os.remove(status_path)
|
||||
return json.dumps({"success": True, "done": True, "destination_path": destination_path,
|
||||
"imported_assets": assets, "count": len(assets)})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_import_texture(file_path: str = None, destination_path: str = None,
|
||||
destination_name: str = "") -> str:
|
||||
"""Imports an image file (PNG/JPG/TGA...) as a Texture2D using the legacy texture importer."""
|
||||
if file_path is None or destination_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: file_path, destination_path."})
|
||||
try:
|
||||
if not os.path.isfile(file_path):
|
||||
return json.dumps({"success": False, "message": f"File not found: {file_path}"})
|
||||
task = unreal.AssetImportTask()
|
||||
task.filename = file_path
|
||||
task.destination_path = destination_path
|
||||
if destination_name:
|
||||
task.destination_name = destination_name
|
||||
task.automated = True
|
||||
task.replace_existing = True
|
||||
task.save = True
|
||||
task.factory = unreal.TextureFactory() # legacy importer — bypass Interchange (crash)
|
||||
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
|
||||
paths = [str(p).split(".")[0] for p in task.imported_object_paths]
|
||||
if not paths:
|
||||
return json.dumps({"success": False, "message": "Import produced no assets (see Output Log)."})
|
||||
return json.dumps({"success": True, "file_path": file_path, "imported_assets": paths})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
_FBX_EXPORTERS = {
|
||||
"StaticMesh": "StaticMeshExporterFBX",
|
||||
"SkeletalMesh": "SkeletalMeshExporterFBX",
|
||||
"AnimSequence": "AnimSequenceExporterFBX",
|
||||
}
|
||||
|
||||
|
||||
def ue_export_fbx(asset_path: str = None, file_path: str = None) -> str:
|
||||
"""Exports a StaticMesh, SkeletalMesh, or AnimSequence asset to an FBX file."""
|
||||
if asset_path is None or file_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, file_path."})
|
||||
try:
|
||||
asset = unreal.EditorAssetLibrary.load_asset(asset_path)
|
||||
if not asset:
|
||||
return json.dumps({"success": False, "message": f"Asset not found: {asset_path}"})
|
||||
cls = asset.get_class().get_name()
|
||||
exporter_name = _FBX_EXPORTERS.get(cls)
|
||||
if not exporter_name:
|
||||
return json.dumps({"success": False,
|
||||
"message": f"Unsupported asset class '{cls}' for FBX export.",
|
||||
"supported": list(_FBX_EXPORTERS)})
|
||||
task = unreal.AssetExportTask()
|
||||
task.object = asset
|
||||
task.filename = file_path
|
||||
task.automated = True
|
||||
task.prompt = False
|
||||
task.exporter = getattr(unreal, exporter_name)()
|
||||
task.options = unreal.FbxExportOption()
|
||||
ok = unreal.Exporter.run_asset_export_task(task)
|
||||
if not ok or not os.path.isfile(file_path):
|
||||
return json.dumps({"success": False, "message": "Export failed (see Output Log)."})
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "file_path": file_path,
|
||||
"file_size": os.path.getsize(file_path)})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
@@ -0,0 +1,714 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
import unreal
|
||||
import json
|
||||
import traceback
|
||||
|
||||
BT_ACTIONS_MODULE = "behavior_tree_actions"
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
# Blackboard key type string → full class path for load_class fallback
|
||||
_BB_KEY_TYPE_MAP = {
|
||||
"Bool": "/Script/AIModule.BlackboardKeyType_Bool",
|
||||
"Int": "/Script/AIModule.BlackboardKeyType_Int",
|
||||
"Float": "/Script/AIModule.BlackboardKeyType_Float",
|
||||
"String": "/Script/AIModule.BlackboardKeyType_String",
|
||||
"Name": "/Script/AIModule.BlackboardKeyType_Name",
|
||||
"Vector": "/Script/AIModule.BlackboardKeyType_Vector",
|
||||
"Rotator": "/Script/AIModule.BlackboardKeyType_Rotator",
|
||||
"Object": "/Script/AIModule.BlackboardKeyType_Object",
|
||||
"Class": "/Script/AIModule.BlackboardKeyType_Class",
|
||||
"Enum": "/Script/AIModule.BlackboardKeyType_Enum",
|
||||
}
|
||||
|
||||
|
||||
def _bt_node_info_to_dict(info):
|
||||
"""Convert FMCPythonBTNodeInfo (C++ USTRUCT) to a Python dict."""
|
||||
d = {
|
||||
"node_name": str(info.node_name),
|
||||
"node_class": str(info.node_class),
|
||||
}
|
||||
if info.decorator_classes:
|
||||
d["decorators"] = [
|
||||
{"class": str(info.decorator_classes[i]), "name": str(info.decorator_names[i])}
|
||||
for i in range(len(info.decorator_classes))
|
||||
]
|
||||
if info.service_classes:
|
||||
d["services"] = [
|
||||
{"class": str(info.service_classes[i]), "name": str(info.service_names[i])}
|
||||
for i in range(len(info.service_classes))
|
||||
]
|
||||
if info.children:
|
||||
d["children"] = [_bt_node_info_to_dict(child) for child in info.children]
|
||||
return d
|
||||
|
||||
|
||||
def _load_asset(asset_path, expected_class=None):
|
||||
"""Load an asset and optionally verify its class. Returns (asset, error_json_str)."""
|
||||
asset = unreal.EditorAssetLibrary.load_asset(asset_path)
|
||||
if asset is None:
|
||||
return None, json.dumps({
|
||||
"success": False,
|
||||
"message": f"Asset not found or failed to load: {asset_path}"
|
||||
})
|
||||
if expected_class is not None and not isinstance(asset, expected_class):
|
||||
return None, json.dumps({
|
||||
"success": False,
|
||||
"message": f"Asset at '{asset_path}' is {type(asset).__name__}, expected {expected_class.__name__}."
|
||||
})
|
||||
return asset, None
|
||||
|
||||
|
||||
def _get_bt_blackboard(bt):
|
||||
"""Get the Blackboard asset linked to a BehaviorTree (read-only)."""
|
||||
try:
|
||||
return bt.get_blackboard_asset()
|
||||
except Exception:
|
||||
pass
|
||||
for name in ['blackboard_asset', 'BlackboardAsset']:
|
||||
try:
|
||||
return bt.get_editor_property(name)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _get_node_class_name(node):
|
||||
"""Get a readable class name for a BT node."""
|
||||
if node is None:
|
||||
return "None"
|
||||
return type(node).__name__
|
||||
|
||||
|
||||
def _get_bb_key_type_name(key_type_obj):
|
||||
"""Extract a human-readable type name from a Blackboard key type object."""
|
||||
if key_type_obj is None:
|
||||
return "Unknown"
|
||||
prefix = "BlackboardKeyType_"
|
||||
|
||||
# Try type(obj).__name__
|
||||
class_name = type(key_type_obj).__name__
|
||||
if class_name.startswith(prefix) and len(class_name) > len(prefix):
|
||||
return class_name[len(prefix):]
|
||||
|
||||
# Try get_class().get_name() (UE reflection)
|
||||
try:
|
||||
ue_class = key_type_obj.get_class()
|
||||
if ue_class:
|
||||
ue_name = str(ue_class.get_name())
|
||||
if ue_name.startswith(prefix) and len(ue_name) > len(prefix):
|
||||
return ue_name[len(prefix):]
|
||||
if ue_name != "BlackboardKeyType":
|
||||
return ue_name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try get_name()
|
||||
try:
|
||||
obj_name = str(key_type_obj.get_name())
|
||||
if obj_name.startswith(prefix):
|
||||
return obj_name[len(prefix):]
|
||||
if obj_name and obj_name != "None":
|
||||
return obj_name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return class_name
|
||||
|
||||
|
||||
def _serialize_value(value):
|
||||
"""Convert a UE value to a JSON-safe Python type."""
|
||||
if value is None or isinstance(value, (bool, int, float, str)):
|
||||
return value
|
||||
if isinstance(value, unreal.Vector):
|
||||
return [value.x, value.y, value.z]
|
||||
if isinstance(value, unreal.Rotator):
|
||||
return [value.pitch, value.yaw, value.roll]
|
||||
if isinstance(value, unreal.Name):
|
||||
return str(value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _split_asset_path(asset_path):
|
||||
"""Split '/Game/AI/BT_Enemy' into ('/Game/AI', 'BT_Enemy')."""
|
||||
parts = asset_path.rsplit('/', 1)
|
||||
if len(parts) == 2:
|
||||
return parts[0], parts[1]
|
||||
return '/Game', asset_path
|
||||
|
||||
|
||||
def _create_bb_key_type_instance(key_type):
|
||||
"""Create a BlackboardKeyType instance via direct class or load_class fallback."""
|
||||
class_simple_name = "BlackboardKeyType_" + key_type
|
||||
class_path = _BB_KEY_TYPE_MAP.get(key_type)
|
||||
|
||||
# Direct class access
|
||||
try:
|
||||
cls = getattr(unreal, class_simple_name, None)
|
||||
if cls is not None:
|
||||
return cls(), None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# load_class + new_object
|
||||
if class_path:
|
||||
try:
|
||||
cls = unreal.load_class(None, class_path)
|
||||
if cls is not None:
|
||||
return unreal.new_object(cls), None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None, (
|
||||
f"Cannot create key type '{key_type}': "
|
||||
f"class '{class_simple_name}' is not accessible in this UE version's Python API."
|
||||
)
|
||||
|
||||
|
||||
# ─── Read Actions ─────────────────────────────────────────────────────────────
|
||||
|
||||
def ue_list_behavior_trees() -> str:
|
||||
"""Lists all Behavior Tree assets under /Game."""
|
||||
try:
|
||||
all_assets = unreal.EditorAssetLibrary.list_assets('/Game', recursive=True)
|
||||
|
||||
results = []
|
||||
for asset_path in all_assets:
|
||||
try:
|
||||
asset = unreal.EditorAssetLibrary.load_asset(asset_path)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if asset is None or not isinstance(asset, unreal.BehaviorTree):
|
||||
continue
|
||||
|
||||
asset_name = str(asset_path).rsplit('/', 1)[-1].split('.')[0]
|
||||
entry = {
|
||||
"asset_path": str(asset_path),
|
||||
"asset_name": asset_name,
|
||||
}
|
||||
|
||||
try:
|
||||
bb = _get_bt_blackboard(asset)
|
||||
if bb is not None:
|
||||
entry["blackboard_path"] = bb.get_path_name()
|
||||
except Exception:
|
||||
pass
|
||||
results.append(entry)
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"behavior_trees": results,
|
||||
"count": len(results),
|
||||
"message": f"Found {len(results)} Behavior Tree asset(s)."
|
||||
})
|
||||
except Exception as e:
|
||||
tb_str = traceback.format_exc()
|
||||
unreal.log_error(f"Error in ue_list_behavior_trees: {str(e)}\n{tb_str}")
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
|
||||
|
||||
|
||||
def ue_get_behavior_tree_structure(asset_path: str = None) -> str:
|
||||
"""Returns the full tree structure of a Behavior Tree asset as JSON."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
|
||||
try:
|
||||
bt, err = _load_asset(asset_path, unreal.BehaviorTree)
|
||||
if err:
|
||||
return err
|
||||
|
||||
# Get blackboard info
|
||||
bb_path = None
|
||||
try:
|
||||
bb = _get_bt_blackboard(bt)
|
||||
if bb is not None:
|
||||
bb_path = bb.get_path_name()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Call C++ helper — returns JSON string with full tree
|
||||
result_json = unreal.MCPythonHelper.get_behavior_tree_structure(bt)
|
||||
result = json.loads(result_json)
|
||||
|
||||
if not result.get("success", False):
|
||||
return result_json
|
||||
|
||||
# Merge blackboard info into the result
|
||||
result["asset_path"] = asset_path
|
||||
result["blackboard_path"] = bb_path
|
||||
result["tree"] = [result.pop("root")] if "root" in result else []
|
||||
result["message"] = f"Behavior Tree structure retrieved ({len(result['tree'])} root node(s))."
|
||||
|
||||
return json.dumps(result)
|
||||
except Exception as e:
|
||||
tb_str = traceback.format_exc()
|
||||
unreal.log_error(f"Error in ue_get_behavior_tree_structure: {str(e)}\n{tb_str}")
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
|
||||
|
||||
|
||||
def ue_get_blackboard_data(asset_path: str = None) -> str:
|
||||
"""Reads all keys from a Blackboard asset."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
|
||||
try:
|
||||
bb, err = _load_asset(asset_path, unreal.BlackboardData)
|
||||
if err:
|
||||
return err
|
||||
|
||||
# Get parent blackboard
|
||||
parent_path = None
|
||||
for pp in ['parent', 'Parent']:
|
||||
try:
|
||||
parent = bb.get_editor_property(pp)
|
||||
if parent is not None:
|
||||
parent_path = parent.get_path_name()
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Read keys
|
||||
keys_data = []
|
||||
for kp in ['keys', 'Keys']:
|
||||
try:
|
||||
keys = bb.get_editor_property(kp)
|
||||
if keys is not None:
|
||||
for key in keys:
|
||||
key_info = {}
|
||||
for enp in ['entry_name', 'EntryName']:
|
||||
try:
|
||||
key_info["key_name"] = str(key.get_editor_property(enp))
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for ktp in ['key_type', 'KeyType']:
|
||||
try:
|
||||
key_type = key.get_editor_property(ktp)
|
||||
if key_type is not None:
|
||||
key_info["key_type"] = _get_bb_key_type_name(key_type)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if "key_type" not in key_info:
|
||||
key_info["key_type"] = "Unknown"
|
||||
|
||||
for isp in ['is_instance_synced', 'bIsInstanceSynced', 'instance_synced']:
|
||||
try:
|
||||
key_info["instance_synced"] = bool(key.get_editor_property(isp))
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
keys_data.append(key_info)
|
||||
break
|
||||
except Exception as keys_err:
|
||||
unreal.log_warning(f"Could not read keys with prop '{kp}': {keys_err}")
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"asset_path": asset_path,
|
||||
"parent_path": parent_path,
|
||||
"keys": keys_data,
|
||||
"key_count": len(keys_data),
|
||||
"message": f"Blackboard has {len(keys_data)} key(s)."
|
||||
})
|
||||
except Exception as e:
|
||||
tb_str = traceback.format_exc()
|
||||
unreal.log_error(f"Error in ue_get_blackboard_data: {str(e)}\n{tb_str}")
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
|
||||
|
||||
|
||||
def ue_get_bt_node_details(asset_path: str = None, node_name: str = None) -> str:
|
||||
"""Retrieves detailed properties of a specific node in a Behavior Tree."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if node_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'node_name' is missing."})
|
||||
|
||||
try:
|
||||
bt, err = _load_asset(asset_path, unreal.BehaviorTree)
|
||||
if err:
|
||||
return err
|
||||
|
||||
# Call C++ helper — returns JSON string with node details
|
||||
details_json = unreal.MCPythonHelper.get_behavior_tree_node_details(bt, node_name)
|
||||
return details_json
|
||||
except Exception as e:
|
||||
tb_str = traceback.format_exc()
|
||||
unreal.log_error(f"Error in ue_get_bt_node_details: {str(e)}\n{tb_str}")
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
|
||||
|
||||
|
||||
def ue_get_selected_bt_nodes() -> str:
|
||||
"""Returns details of selected nodes in the currently open BT editor."""
|
||||
try:
|
||||
result_json = unreal.MCPythonHelper.get_selected_bt_nodes()
|
||||
return result_json
|
||||
except Exception as e:
|
||||
tb_str = traceback.format_exc()
|
||||
unreal.log_error(f"Error in ue_get_selected_bt_nodes: {str(e)}\n{tb_str}")
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
|
||||
|
||||
|
||||
# ─── Write Actions ────────────────────────────────────────────────────────────
|
||||
|
||||
def ue_create_behavior_tree(asset_path: str = None, blackboard_path: str = None) -> str:
|
||||
"""Creates a new empty Behavior Tree asset."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
|
||||
try:
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
|
||||
return json.dumps({"success": False, "message": f"Asset already exists at '{asset_path}'."})
|
||||
|
||||
package_path, asset_name = _split_asset_path(asset_path)
|
||||
|
||||
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
|
||||
bt = None
|
||||
try:
|
||||
factory = unreal.BehaviorTreeFactory()
|
||||
bt = asset_tools.create_asset(asset_name, package_path, unreal.BehaviorTree, factory)
|
||||
except Exception:
|
||||
try:
|
||||
bt = asset_tools.create_asset(asset_name, package_path, unreal.BehaviorTree, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if bt is None:
|
||||
return json.dumps({"success": False, "message": f"Failed to create Behavior Tree at '{asset_path}'."})
|
||||
|
||||
unreal.EditorAssetLibrary.save_asset(bt.get_path_name())
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"asset_path": bt.get_path_name(),
|
||||
"message": f"Behavior Tree created at '{bt.get_path_name()}'.",
|
||||
}
|
||||
|
||||
# Link Blackboard via C++ helper
|
||||
if blackboard_path is not None:
|
||||
try:
|
||||
bb = unreal.EditorAssetLibrary.load_asset(blackboard_path)
|
||||
if bb is not None and isinstance(bb, unreal.BlackboardData):
|
||||
linked = unreal.MCPythonHelper.set_behavior_tree_blackboard(bt, bb)
|
||||
if linked:
|
||||
result["blackboard_linked"] = True
|
||||
result["blackboard_path"] = bb.get_path_name()
|
||||
unreal.EditorAssetLibrary.save_asset(bt.get_path_name())
|
||||
else:
|
||||
result["blackboard_linked"] = False
|
||||
result["blackboard_link_note"] = "Failed to link Blackboard via C++ helper."
|
||||
else:
|
||||
result["blackboard_linked"] = False
|
||||
result["blackboard_link_note"] = f"Blackboard not found at '{blackboard_path}'."
|
||||
except Exception as bb_err:
|
||||
result["blackboard_linked"] = False
|
||||
result["blackboard_link_note"] = f"Error linking Blackboard: {str(bb_err)}"
|
||||
|
||||
return json.dumps(result)
|
||||
except Exception as e:
|
||||
tb_str = traceback.format_exc()
|
||||
unreal.log_error(f"Error in ue_create_behavior_tree: {str(e)}\n{tb_str}")
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
|
||||
|
||||
|
||||
def ue_create_blackboard(asset_path: str = None, parent_path: str = None) -> str:
|
||||
"""Creates a new Blackboard Data asset."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
|
||||
try:
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
|
||||
return json.dumps({"success": False, "message": f"Asset already exists at '{asset_path}'."})
|
||||
|
||||
package_path, asset_name = _split_asset_path(asset_path)
|
||||
|
||||
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
|
||||
bb = None
|
||||
try:
|
||||
factory = unreal.BlackboardDataFactory()
|
||||
bb = asset_tools.create_asset(asset_name, package_path, unreal.BlackboardData, factory)
|
||||
except Exception:
|
||||
try:
|
||||
bb = asset_tools.create_asset(asset_name, package_path, unreal.BlackboardData, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if bb is None:
|
||||
return json.dumps({"success": False, "message": f"Failed to create Blackboard at '{asset_path}'."})
|
||||
|
||||
if parent_path is not None:
|
||||
try:
|
||||
parent_bb = unreal.EditorAssetLibrary.load_asset(parent_path)
|
||||
if parent_bb is not None and isinstance(parent_bb, unreal.BlackboardData):
|
||||
for pp in ['parent', 'Parent']:
|
||||
try:
|
||||
bb.set_editor_property(pp, parent_bb)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
unreal.log_warning(f"Parent Blackboard not found or invalid: {parent_path}")
|
||||
except Exception as parent_err:
|
||||
unreal.log_warning(f"Could not set parent blackboard: {parent_err}")
|
||||
|
||||
unreal.EditorAssetLibrary.save_asset(bb.get_path_name())
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"asset_path": bb.get_path_name(),
|
||||
"message": f"Blackboard created at '{bb.get_path_name()}'."
|
||||
})
|
||||
except Exception as e:
|
||||
tb_str = traceback.format_exc()
|
||||
unreal.log_error(f"Error in ue_create_blackboard: {str(e)}\n{tb_str}")
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
|
||||
|
||||
|
||||
def ue_add_blackboard_key(asset_path: str = None, key_name: str = None,
|
||||
key_type: str = None, instance_synced: bool = False) -> str:
|
||||
"""Adds a new key to a Blackboard asset."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if key_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'key_name' is missing."})
|
||||
if key_type is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'key_type' is missing."})
|
||||
|
||||
if key_type not in _BB_KEY_TYPE_MAP:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Invalid key_type '{key_type}'. Supported types: {', '.join(_BB_KEY_TYPE_MAP.keys())}"
|
||||
})
|
||||
|
||||
try:
|
||||
bb, err = _load_asset(asset_path, unreal.BlackboardData)
|
||||
if err:
|
||||
return err
|
||||
|
||||
# Check if key already exists
|
||||
existing_keys = None
|
||||
for kp in ['keys', 'Keys']:
|
||||
try:
|
||||
existing_keys = bb.get_editor_property(kp)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if existing_keys is not None:
|
||||
for key in existing_keys:
|
||||
for enp in ['entry_name', 'EntryName']:
|
||||
try:
|
||||
if str(key.get_editor_property(enp)) == key_name:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Key '{key_name}' already exists in Blackboard."
|
||||
})
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Create the new key entry
|
||||
new_entry = unreal.BlackboardEntry()
|
||||
|
||||
name_set = False
|
||||
for enp in ['entry_name', 'EntryName']:
|
||||
try:
|
||||
new_entry.set_editor_property(enp, key_name)
|
||||
name_set = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if not name_set:
|
||||
return json.dumps({"success": False, "message": "Failed to set entry name on BlackboardEntry."})
|
||||
|
||||
key_type_obj, key_err = _create_bb_key_type_instance(key_type)
|
||||
if key_err:
|
||||
return json.dumps({"success": False, "message": key_err})
|
||||
|
||||
type_set = False
|
||||
for ktp in ['key_type', 'KeyType']:
|
||||
try:
|
||||
new_entry.set_editor_property(ktp, key_type_obj)
|
||||
type_set = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if not type_set:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Failed to set key type on BlackboardEntry. Key type object: {type(key_type_obj).__name__}"
|
||||
})
|
||||
|
||||
for isp in ['is_instance_synced', 'bIsInstanceSynced', 'instance_synced']:
|
||||
try:
|
||||
new_entry.set_editor_property(isp, instance_synced)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Add to keys array
|
||||
added = False
|
||||
for kp in ['keys', 'Keys']:
|
||||
try:
|
||||
keys = list(bb.get_editor_property(kp) or [])
|
||||
keys.append(new_entry)
|
||||
bb.set_editor_property(kp, keys)
|
||||
added = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not added:
|
||||
return json.dumps({"success": False, "message": "Failed to add key to Blackboard keys array."})
|
||||
|
||||
unreal.EditorAssetLibrary.save_asset(bb.get_path_name())
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"asset_path": asset_path,
|
||||
"key_name": key_name,
|
||||
"key_type": key_type,
|
||||
"instance_synced": instance_synced,
|
||||
"message": f"Key '{key_name}' ({key_type}) added to Blackboard."
|
||||
})
|
||||
except Exception as e:
|
||||
tb_str = traceback.format_exc()
|
||||
unreal.log_error(f"Error in ue_add_blackboard_key: {str(e)}\n{tb_str}")
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
|
||||
|
||||
|
||||
def ue_remove_blackboard_key(asset_path: str = None, key_name: str = None) -> str:
|
||||
"""Removes a key from a Blackboard asset."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if key_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'key_name' is missing."})
|
||||
|
||||
try:
|
||||
bb, err = _load_asset(asset_path, unreal.BlackboardData)
|
||||
if err:
|
||||
return err
|
||||
|
||||
removed = False
|
||||
for kp in ['keys', 'Keys']:
|
||||
try:
|
||||
keys = bb.get_editor_property(kp)
|
||||
if keys is None or len(keys) == 0:
|
||||
return json.dumps({"success": False, "message": "Blackboard has no keys to remove."})
|
||||
|
||||
new_keys = []
|
||||
found = False
|
||||
for key in keys:
|
||||
existing_name = None
|
||||
for enp in ['entry_name', 'EntryName']:
|
||||
try:
|
||||
existing_name = str(key.get_editor_property(enp))
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if existing_name == key_name:
|
||||
found = True
|
||||
else:
|
||||
new_keys.append(key)
|
||||
|
||||
if not found:
|
||||
return json.dumps({"success": False, "message": f"Key '{key_name}' not found in Blackboard."})
|
||||
|
||||
bb.set_editor_property(kp, new_keys)
|
||||
removed = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not removed:
|
||||
return json.dumps({"success": False, "message": "Failed to modify Blackboard keys array."})
|
||||
|
||||
unreal.EditorAssetLibrary.save_asset(bb.get_path_name())
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"asset_path": asset_path,
|
||||
"key_name": key_name,
|
||||
"message": f"Key '{key_name}' removed from Blackboard."
|
||||
})
|
||||
except Exception as e:
|
||||
tb_str = traceback.format_exc()
|
||||
unreal.log_error(f"Error in ue_remove_blackboard_key: {str(e)}\n{tb_str}")
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
|
||||
|
||||
|
||||
def ue_set_blackboard_to_behavior_tree(bt_path: str = None, bb_path: str = None) -> str:
|
||||
"""Links a Blackboard asset to a Behavior Tree."""
|
||||
if bt_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'bt_path' is missing."})
|
||||
if bb_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'bb_path' is missing."})
|
||||
|
||||
try:
|
||||
bt, err = _load_asset(bt_path, unreal.BehaviorTree)
|
||||
if err:
|
||||
return err
|
||||
|
||||
bb, err = _load_asset(bb_path, unreal.BlackboardData)
|
||||
if err:
|
||||
return err
|
||||
|
||||
# Call C++ helper to set BlackboardAsset directly
|
||||
success = unreal.MCPythonHelper.set_behavior_tree_blackboard(bt, bb)
|
||||
|
||||
if success:
|
||||
unreal.EditorAssetLibrary.save_asset(bt_path)
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"bt_path": bt_path,
|
||||
"bb_path": bb_path,
|
||||
"message": "Blackboard linked to Behavior Tree successfully."
|
||||
})
|
||||
else:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": "Failed to set Blackboard on Behavior Tree."
|
||||
})
|
||||
except Exception as e:
|
||||
tb_str = traceback.format_exc()
|
||||
unreal.log_error(f"Error in ue_set_blackboard_to_behavior_tree: {str(e)}\n{tb_str}")
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
|
||||
|
||||
|
||||
def ue_build_behavior_tree(asset_path: str = None, tree_structure: dict = None) -> str:
|
||||
"""Builds a complete Behavior Tree from a JSON structure."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if tree_structure is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'tree_structure' is missing."})
|
||||
|
||||
try:
|
||||
bt, err = _load_asset(asset_path, unreal.BehaviorTree)
|
||||
if err:
|
||||
return err
|
||||
|
||||
# Convert dict to JSON string for C++ helper
|
||||
tree_json = json.dumps(tree_structure)
|
||||
|
||||
# Call C++ helper to build the tree
|
||||
result_json = unreal.MCPythonHelper.build_behavior_tree(bt, tree_json)
|
||||
return result_json
|
||||
except Exception as e:
|
||||
tb_str = traceback.format_exc()
|
||||
unreal.log_error(f"Error in ue_build_behavior_tree: {str(e)}\n{tb_str}")
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
|
||||
|
||||
|
||||
def ue_list_bt_node_classes() -> str:
|
||||
"""Lists all available BT node classes (composites, tasks, decorators, services)."""
|
||||
try:
|
||||
result_json = unreal.MCPythonHelper.list_bt_node_classes()
|
||||
return result_json
|
||||
except Exception as e:
|
||||
tb_str = traceback.format_exc()
|
||||
unreal.log_error(f"Error in ue_list_bt_node_classes: {str(e)}\n{tb_str}")
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
|
||||
@@ -0,0 +1,517 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
import unreal
|
||||
import json
|
||||
import traceback
|
||||
from collections import deque
|
||||
|
||||
|
||||
def _load_asset(asset_path, expected_class=None):
|
||||
"""Load an asset and optionally verify its class. Returns (asset, error_json_str)."""
|
||||
asset = unreal.EditorAssetLibrary.load_asset(asset_path)
|
||||
if asset is None:
|
||||
return None, json.dumps({
|
||||
"success": False,
|
||||
"message": f"Asset not found or failed to load: {asset_path}"
|
||||
})
|
||||
if expected_class is not None and not isinstance(asset, expected_class):
|
||||
return None, json.dumps({
|
||||
"success": False,
|
||||
"message": f"Asset at '{asset_path}' is {type(asset).__name__}, expected {expected_class.__name__}."
|
||||
})
|
||||
return asset, None
|
||||
|
||||
|
||||
# ─── Read Actions ─────────────────────────────────────────────────────────────
|
||||
|
||||
def ue_get_selected_bp_nodes() -> str:
|
||||
"""Returns information about currently selected blueprint nodes in the editor."""
|
||||
try:
|
||||
nodes = unreal.MCPythonHelper.get_selected_blueprint_nodes()
|
||||
node_infos = []
|
||||
for node in nodes:
|
||||
node_info = {
|
||||
"name": node.get_name() if hasattr(node, 'get_name') else str(node),
|
||||
"class": node.get_class().get_name() if hasattr(node, 'get_class') else str(type(node)),
|
||||
"object_path": node.get_path_name() if hasattr(node, 'get_path_name') else None
|
||||
}
|
||||
node_infos.append(node_info)
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"selected_nodes_count": len(node_infos),
|
||||
"selected_nodes": node_infos
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_get_selected_bp_node_infos() -> str:
|
||||
"""Returns compact blueprint node info optimized for LLM token efficiency."""
|
||||
try:
|
||||
node_infos = unreal.MCPythonHelper.get_selected_blueprint_node_infos()
|
||||
|
||||
name_to_id = {}
|
||||
for i, n in enumerate(node_infos):
|
||||
name_to_id[n.node_name] = i
|
||||
|
||||
def link_to_dict(link):
|
||||
d = {}
|
||||
if link.node_name in name_to_id:
|
||||
d["node"] = name_to_id[link.node_name]
|
||||
else:
|
||||
d["node"] = link.node_title
|
||||
if link.pin_name:
|
||||
d["pin"] = link.pin_name
|
||||
return d
|
||||
|
||||
def pin_to_dict(pin):
|
||||
name = pin.friendly_name if pin.friendly_name else pin.pin_name
|
||||
d = {"name": name, "dir": pin.direction}
|
||||
ptype = pin.pin_type
|
||||
if pin.pin_sub_type:
|
||||
ptype += ":" + pin.pin_sub_type
|
||||
d["type"] = ptype
|
||||
if pin.default_value:
|
||||
d["default"] = pin.default_value
|
||||
linked = list(pin.linked_to)
|
||||
if linked:
|
||||
d["linked"] = [link_to_dict(l) for l in linked]
|
||||
return d
|
||||
|
||||
def node_to_dict(node, idx):
|
||||
d = {"id": idx, "title": node.node_title}
|
||||
if node.node_comment:
|
||||
d["comment"] = node.node_comment
|
||||
d["pins"] = [pin_to_dict(p) for p in node.pins]
|
||||
return d
|
||||
|
||||
nodes = [node_to_dict(n, i) for i, n in enumerate(node_infos)]
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"nodes": nodes
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_get_blueprint_graph_info(asset_path: str = None, graph_name: str = "EventGraph") -> str:
|
||||
"""Returns the full graph info for a Blueprint graph."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
bp, err = _load_asset(asset_path, unreal.Blueprint)
|
||||
if err:
|
||||
return err
|
||||
result_json = unreal.MCPythonHelper.get_blueprint_graph_info(bp, graph_name)
|
||||
return result_json
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_list_callable_functions(asset_path: str = None, filter: str = "") -> str:
|
||||
"""Lists callable functions available in a Blueprint context."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
bp, err = _load_asset(asset_path, unreal.Blueprint)
|
||||
if err:
|
||||
return err
|
||||
result_json = unreal.MCPythonHelper.list_callable_functions(bp, filter)
|
||||
return result_json
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_list_blueprint_variables(asset_path: str = None) -> str:
|
||||
"""Lists all variables defined in a Blueprint."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
bp, err = _load_asset(asset_path, unreal.Blueprint)
|
||||
if err:
|
||||
return err
|
||||
result_json = unreal.MCPythonHelper.list_blueprint_variables(bp)
|
||||
return result_json
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
# ─── Write Actions ────────────────────────────────────────────────────────────
|
||||
|
||||
def ue_add_blueprint_node(asset_path: str = None, graph_name: str = "EventGraph",
|
||||
node_json: dict = None) -> str:
|
||||
"""Adds a single node to a Blueprint graph."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if node_json is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'node_json' is missing."})
|
||||
try:
|
||||
bp, err = _load_asset(asset_path, unreal.Blueprint)
|
||||
if err:
|
||||
return err
|
||||
node_json_str = json.dumps(node_json)
|
||||
result_json = unreal.MCPythonHelper.add_blueprint_node(bp, graph_name, node_json_str)
|
||||
return result_json
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_connect_blueprint_pins(asset_path: str = None, graph_name: str = "EventGraph",
|
||||
source_node: str = None, source_pin: str = None,
|
||||
target_node: str = None, target_pin: str = None) -> str:
|
||||
"""Connects two pins in a Blueprint graph."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
for name, val in [("source_node", source_node), ("source_pin", source_pin),
|
||||
("target_node", target_node), ("target_pin", target_pin)]:
|
||||
if val is None:
|
||||
return json.dumps({"success": False, "message": f"Required parameter '{name}' is missing."})
|
||||
try:
|
||||
bp, err = _load_asset(asset_path, unreal.Blueprint)
|
||||
if err:
|
||||
return err
|
||||
result_json = unreal.MCPythonHelper.connect_blueprint_pins(
|
||||
bp, graph_name, source_node, source_pin, target_node, target_pin)
|
||||
return result_json
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_remove_blueprint_node(asset_path: str = None, graph_name: str = "EventGraph",
|
||||
node_name: str = None) -> str:
|
||||
"""Removes a node from a Blueprint graph."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if node_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'node_name' is missing."})
|
||||
try:
|
||||
bp, err = _load_asset(asset_path, unreal.Blueprint)
|
||||
if err:
|
||||
return err
|
||||
result_json = unreal.MCPythonHelper.remove_blueprint_node(bp, graph_name, node_name)
|
||||
return result_json
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_build_blueprint_graph(asset_path: str = None, graph_name: str = "EventGraph",
|
||||
graph_structure: dict = None) -> str:
|
||||
"""Builds a Blueprint graph from JSON adjacency list."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if graph_structure is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'graph_structure' is missing."})
|
||||
try:
|
||||
bp, err = _load_asset(asset_path, unreal.Blueprint)
|
||||
if err:
|
||||
return err
|
||||
graph_json_str = json.dumps(graph_structure)
|
||||
result_json = unreal.MCPythonHelper.build_blueprint_graph(bp, graph_name, graph_json_str)
|
||||
return result_json
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_compile_blueprint(asset_path: str = None) -> str:
|
||||
"""Compiles a Blueprint and returns the result."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
bp, err = _load_asset(asset_path, unreal.Blueprint)
|
||||
if err:
|
||||
return err
|
||||
result_json = unreal.MCPythonHelper.compile_blueprint(bp)
|
||||
return result_json
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
# ─── Component Management ──────────────────────────────────────────────────────
|
||||
|
||||
def ue_list_blueprint_components(asset_path: str = None) -> str:
|
||||
"""Lists all SCS components on a Blueprint."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
bp, err = _load_asset(asset_path, unreal.Blueprint)
|
||||
if err:
|
||||
return err
|
||||
return unreal.MCPythonHelper.list_blueprint_components(bp)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_add_component_to_blueprint(asset_path: str = None,
|
||||
component_class_path: str = None,
|
||||
component_name: str = None,
|
||||
location_x: float = 0.0, location_y: float = 0.0, location_z: float = 0.0,
|
||||
rotation_pitch: float = 0.0, rotation_yaw: float = 0.0, rotation_roll: float = 0.0,
|
||||
parent_component_name: str = "") -> str:
|
||||
"""Adds a component to a Blueprint's SCS."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if component_class_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'component_class_path' is missing."})
|
||||
if component_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'component_name' is missing."})
|
||||
try:
|
||||
bp, err = _load_asset(asset_path, unreal.Blueprint)
|
||||
if err:
|
||||
return err
|
||||
result = unreal.MCPythonHelper.add_component_to_blueprint(
|
||||
bp, component_class_path, component_name,
|
||||
location_x, location_y, location_z,
|
||||
rotation_pitch, rotation_yaw, rotation_roll,
|
||||
parent_component_name or ""
|
||||
)
|
||||
unreal.EditorAssetLibrary.save_asset(bp.get_path_name(), only_if_is_dirty=False)
|
||||
return result
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_remove_component_from_blueprint(asset_path: str = None, component_name: str = None) -> str:
|
||||
"""Removes a component by variable name from a Blueprint's SCS."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if component_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'component_name' is missing."})
|
||||
try:
|
||||
bp, err = _load_asset(asset_path, unreal.Blueprint)
|
||||
if err:
|
||||
return err
|
||||
result = unreal.MCPythonHelper.remove_component_from_blueprint(bp, component_name)
|
||||
unreal.EditorAssetLibrary.save_asset(bp.get_path_name(), only_if_is_dirty=False)
|
||||
return result
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_set_component_property(asset_path: str = None, component_name: str = None,
|
||||
property_name: str = None, value: str = None) -> str:
|
||||
"""Sets a property on a component template in a Blueprint's SCS."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if component_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'component_name' is missing."})
|
||||
if property_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'property_name' is missing."})
|
||||
if value is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'value' is missing."})
|
||||
try:
|
||||
bp, err = _load_asset(asset_path, unreal.Blueprint)
|
||||
if err:
|
||||
return err
|
||||
result = unreal.MCPythonHelper.set_component_property(bp, component_name, property_name, value)
|
||||
unreal.EditorAssetLibrary.save_asset(bp.get_path_name(), only_if_is_dirty=False)
|
||||
return result
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
# ─── Graph Auto-Layout ─────────────────────────────────────────────────────────
|
||||
|
||||
def ue_set_blueprint_node_position(asset_path: str = None, graph_name: str = "EventGraph",
|
||||
node_name: str = None, pos_x: float = 0.0, pos_y: float = 0.0) -> str:
|
||||
"""Sets the canvas position of a node in a Blueprint graph."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if node_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'node_name' is missing."})
|
||||
try:
|
||||
bp, err = _load_asset(asset_path, unreal.Blueprint)
|
||||
if err:
|
||||
return err
|
||||
return unreal.MCPythonHelper.set_blueprint_node_position(bp, graph_name, node_name, pos_x, pos_y)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_auto_layout_graph(asset_path: str = None, graph_name: str = "EventGraph",
|
||||
x_step: float = 380.0, y_step: float = 200.0) -> str:
|
||||
"""Auto-lays out all nodes in a Blueprint graph using DAG topological sort."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
bp, err = _load_asset(asset_path, unreal.Blueprint)
|
||||
if err:
|
||||
return err
|
||||
|
||||
graph_info_str = unreal.MCPythonHelper.get_blueprint_graph_info(bp, graph_name)
|
||||
graph_info = json.loads(graph_info_str)
|
||||
if not graph_info.get("success"):
|
||||
return graph_info_str
|
||||
|
||||
nodes = graph_info.get("nodes", [])
|
||||
if not nodes:
|
||||
return json.dumps({"success": True, "message": "No nodes to lay out.", "positioned": 0})
|
||||
|
||||
node_names = [n["node_name"] for n in nodes]
|
||||
name_set = set(node_names)
|
||||
|
||||
in_degree = {n: 0 for n in node_names}
|
||||
successors = {n: [] for n in node_names}
|
||||
|
||||
ENTRY_TYPES = {"K2Node_Event", "K2Node_CustomEvent", "K2Node_InputKey",
|
||||
"K2Node_InputAction", "K2Node_FunctionEntry"}
|
||||
|
||||
for node in nodes:
|
||||
node_name = node["node_name"]
|
||||
for pin in node.get("pins", []):
|
||||
if pin.get("direction") != "Output":
|
||||
continue
|
||||
pin_type = pin.get("type", "")
|
||||
if pin_type not in ("exec", ""):
|
||||
continue
|
||||
for link in pin.get("linked_to", []):
|
||||
target = link.get("node_name", "")
|
||||
if target in name_set and target != node_name:
|
||||
if target not in successors[node_name]:
|
||||
successors[node_name].append(target)
|
||||
in_degree[target] += 1
|
||||
|
||||
forced_entry = set()
|
||||
for node in nodes:
|
||||
node_class = node.get("node_class", node.get("node_name", ""))
|
||||
for et in ENTRY_TYPES:
|
||||
if et in node_class or et in node.get("node_name", ""):
|
||||
forced_entry.add(node["node_name"])
|
||||
break
|
||||
for node in nodes:
|
||||
n = node["node_name"]
|
||||
if in_degree[n] == 0:
|
||||
for pin in node.get("pins", []):
|
||||
if pin.get("direction") == "Output" and pin.get("type") in ("exec", ""):
|
||||
forced_entry.add(n)
|
||||
break
|
||||
|
||||
column = {}
|
||||
queue = deque()
|
||||
for n in node_names:
|
||||
if in_degree[n] == 0 or n in forced_entry:
|
||||
column[n] = 0
|
||||
queue.append(n)
|
||||
|
||||
while queue:
|
||||
n = queue.popleft()
|
||||
for s in successors[n]:
|
||||
if column.get(s, -1) < column[n] + 1:
|
||||
column[s] = column[n] + 1
|
||||
in_degree[s] -= 1
|
||||
if in_degree[s] <= 0 and s not in column:
|
||||
queue.append(s)
|
||||
|
||||
for n in node_names:
|
||||
if n not in column:
|
||||
column[n] = 0
|
||||
|
||||
col_row = {}
|
||||
positions = {}
|
||||
for node in nodes:
|
||||
n = node["node_name"]
|
||||
c = column[n]
|
||||
r = col_row.get(c, 0)
|
||||
positions[n] = (c * x_step, r * y_step)
|
||||
col_row[c] = r + 1
|
||||
|
||||
errors = []
|
||||
positioned = 0
|
||||
for n, (px, py) in positions.items():
|
||||
result_str = unreal.MCPythonHelper.set_blueprint_node_position(bp, graph_name, n, px, py)
|
||||
result = json.loads(result_str)
|
||||
if result.get("success"):
|
||||
positioned += 1
|
||||
else:
|
||||
errors.append(f"{n}: {result.get('message', '?')}")
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"positioned": positioned,
|
||||
"total": len(node_names),
|
||||
"errors": errors,
|
||||
"message": f"Auto-layout complete: {positioned}/{len(node_names)} nodes positioned.",
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_create_blueprint(asset_path: str = None, parent_class_path: str = "/Script/Engine.Actor") -> str:
|
||||
"""Creates a Blueprint asset with the given parent class (default Actor)."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
|
||||
return json.dumps({"success": False, "message": f"Asset already exists: {asset_path}"})
|
||||
parent = unreal.load_class(None, parent_class_path)
|
||||
if not parent:
|
||||
return json.dumps({"success": False, "message": f"Parent class not found: {parent_class_path}"})
|
||||
asset_path = asset_path.rstrip("/")
|
||||
idx = asset_path.rfind("/")
|
||||
name, package = asset_path[idx + 1:], asset_path[:idx]
|
||||
factory = unreal.BlueprintFactory()
|
||||
factory.set_editor_property("parent_class", parent)
|
||||
bp = unreal.AssetToolsHelpers.get_asset_tools().create_asset(name, package, unreal.Blueprint, factory)
|
||||
if not bp:
|
||||
return json.dumps({"success": False, "message": f"Failed to create Blueprint at {asset_path}."})
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(bp)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "parent_class": parent_class_path})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
_BP_VAR_TYPES = {"int", "byte", "bool", "real", "name", "string", "text"}
|
||||
|
||||
|
||||
def ue_add_variable(asset_path: str = None, variable_name: str = None, variable_type: str = "real") -> str:
|
||||
"""Adds a member variable to a Blueprint. variable_type: int, byte, bool, real (float), name, string, text."""
|
||||
if asset_path is None or variable_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, variable_name."})
|
||||
vt = (variable_type or "real").lower()
|
||||
if vt == "float":
|
||||
vt = "real"
|
||||
if vt not in _BP_VAR_TYPES:
|
||||
return json.dumps({"success": False, "message": f"Unsupported variable_type '{variable_type}'.",
|
||||
"valid_types": sorted(_BP_VAR_TYPES | {"float"})})
|
||||
try:
|
||||
bp = unreal.EditorAssetLibrary.load_asset(asset_path)
|
||||
if not bp or not isinstance(bp, unreal.Blueprint):
|
||||
return json.dumps({"success": False, "message": f"Not a Blueprint: {asset_path}"})
|
||||
bel = unreal.BlueprintEditorLibrary
|
||||
pin = bel.get_basic_type_by_name(unreal.Name(vt))
|
||||
ok = bel.add_member_variable(bp, unreal.Name(variable_name), pin)
|
||||
if not ok:
|
||||
return json.dumps({"success": False, "message": f"add_member_variable failed for '{variable_name}'."})
|
||||
bel.compile_blueprint(bp)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(bp)
|
||||
return json.dumps({"success": True, "asset_path": asset_path,
|
||||
"variable_name": variable_name, "variable_type": vt})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_set_variable_flags(asset_path: str = None, variable_name: str = None,
|
||||
instance_editable: bool = None, expose_on_spawn: bool = None) -> str:
|
||||
"""Sets a Blueprint variable's 'Instance Editable' and/or 'Expose On Spawn' flags."""
|
||||
if asset_path is None or variable_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, variable_name."})
|
||||
if instance_editable is None and expose_on_spawn is None:
|
||||
return json.dumps({"success": False, "message": "Provide instance_editable and/or expose_on_spawn."})
|
||||
try:
|
||||
bp = unreal.EditorAssetLibrary.load_asset(asset_path)
|
||||
if not bp or not isinstance(bp, unreal.Blueprint):
|
||||
return json.dumps({"success": False, "message": f"Not a Blueprint: {asset_path}"})
|
||||
bel = unreal.BlueprintEditorLibrary
|
||||
name = unreal.Name(variable_name)
|
||||
applied = {}
|
||||
if instance_editable is not None:
|
||||
bel.set_blueprint_variable_instance_editable(bp, name, bool(instance_editable))
|
||||
applied["instance_editable"] = bool(instance_editable)
|
||||
if expose_on_spawn is not None:
|
||||
bel.set_blueprint_variable_expose_on_spawn(bp, name, bool(expose_on_spawn))
|
||||
applied["expose_on_spawn"] = bool(expose_on_spawn)
|
||||
bel.compile_blueprint(bp)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(bp)
|
||||
return json.dumps({"success": True, "asset_path": asset_path,
|
||||
"variable_name": variable_name, "applied": applied})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
@@ -0,0 +1,191 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
"""
|
||||
Control Rig authoring: create rigs, build the element hierarchy, add RigVM
|
||||
unit nodes, and recompile.
|
||||
|
||||
All actions require the ControlRig plugin (built-in, usually enabled by
|
||||
default). The dependency is soft: actions guard at call time. Adding controls
|
||||
(RigControlSettings/Value structs) is deferred to a follow-up.
|
||||
"""
|
||||
import unreal
|
||||
import json
|
||||
import traceback
|
||||
|
||||
|
||||
def _plugin_missing():
|
||||
if not hasattr(unreal, "ControlRigBlueprint"):
|
||||
return json.dumps({"success": False,
|
||||
"message": "Requires the ControlRig plugin. Enable it in Edit > Plugins and restart."})
|
||||
return None
|
||||
|
||||
|
||||
def _load_rig(asset_path: str):
|
||||
rig = unreal.EditorAssetLibrary.load_asset(asset_path)
|
||||
if not rig:
|
||||
raise FileNotFoundError(f"Control Rig not found at path: {asset_path}")
|
||||
if not isinstance(rig, unreal.ControlRigBlueprint):
|
||||
raise TypeError(f"Asset at {asset_path} is not a ControlRigBlueprint, but {type(rig).__name__}")
|
||||
return rig
|
||||
|
||||
|
||||
def _parent_key(rig, parent_name: str, parent_type: str):
|
||||
if not parent_name:
|
||||
return unreal.RigElementKey()
|
||||
etype = {"bone": unreal.RigElementType.BONE,
|
||||
"null": unreal.RigElementType.NULL,
|
||||
"control": unreal.RigElementType.CONTROL}.get((parent_type or "bone").lower())
|
||||
if etype is None:
|
||||
raise ValueError(f"Unknown parent_type '{parent_type}' (use bone, null, or control).")
|
||||
return unreal.RigElementKey(type=etype, name=parent_name)
|
||||
|
||||
|
||||
def ue_create_control_rig(asset_path: str = None, skeletal_mesh_path: str = None) -> str:
|
||||
"""Creates a Control Rig at asset_path; with a skeletal mesh, imports its bones and sets it as preview (requires the ControlRig plugin)."""
|
||||
guard = _plugin_missing()
|
||||
if guard:
|
||||
return guard
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
|
||||
return json.dumps({"success": False, "message": f"Asset already exists: {asset_path}"})
|
||||
rig = unreal.ControlRigBlueprintFactory.create_new_control_rig_asset(asset_path)
|
||||
if not rig:
|
||||
return json.dumps({"success": False, "message": f"Failed to create Control Rig at {asset_path}."})
|
||||
imported = 0
|
||||
if skeletal_mesh_path:
|
||||
mesh = unreal.EditorAssetLibrary.load_asset(skeletal_mesh_path)
|
||||
if not mesh or not isinstance(mesh, unreal.SkeletalMesh):
|
||||
return json.dumps({"success": False, "message": f"Not a SkeletalMesh: {skeletal_mesh_path}"})
|
||||
# Import bones BEFORE set_preview_mesh (which itself populates the
|
||||
# hierarchy, making a later import report 0 new keys), then count
|
||||
# bones from the hierarchy so the number is reliable either way.
|
||||
rig.get_hierarchy_controller().import_bones(mesh.skeleton)
|
||||
rig.set_preview_mesh(mesh)
|
||||
imported = sum(1 for k in rig.hierarchy.get_all_keys()
|
||||
if getattr(k.type, "name", "") == "BONE")
|
||||
rig.recompile_vm()
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(rig)
|
||||
return json.dumps({"success": True, "asset_path": asset_path,
|
||||
"skeletal_mesh": skeletal_mesh_path, "imported_bones": imported})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_get_control_rig_info(asset_path: str = None) -> str:
|
||||
"""Returns element counts by type and the preview mesh of a Control Rig (requires the ControlRig plugin)."""
|
||||
guard = _plugin_missing()
|
||||
if guard:
|
||||
return guard
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
rig = _load_rig(asset_path)
|
||||
counts = {}
|
||||
names = {}
|
||||
for k in rig.hierarchy.get_all_keys():
|
||||
t = getattr(k.type, "name", str(k.type))
|
||||
counts[t] = counts.get(t, 0) + 1
|
||||
names.setdefault(t, []).append(str(k.name))
|
||||
mesh = rig.get_preview_mesh()
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"asset_path": asset_path,
|
||||
"element_counts": counts,
|
||||
"elements": {t: v[:50] for t, v in names.items()},
|
||||
"preview_mesh": mesh.get_path_name() if mesh else None,
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_add_rig_bone(asset_path: str = None, bone_name: str = None,
|
||||
parent_name: str = "", parent_type: str = "bone",
|
||||
location: list = None) -> str:
|
||||
"""Adds a bone to a Control Rig hierarchy under an optional parent (requires the ControlRig plugin)."""
|
||||
guard = _plugin_missing()
|
||||
if guard:
|
||||
return guard
|
||||
if asset_path is None or bone_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, bone_name."})
|
||||
try:
|
||||
rig = _load_rig(asset_path)
|
||||
parent = _parent_key(rig, parent_name, parent_type)
|
||||
tf = unreal.Transform()
|
||||
if location:
|
||||
if len(location) != 3:
|
||||
return json.dumps({"success": False, "message": "location must be a list of 3 floats."})
|
||||
tf.translation = unreal.Vector(float(location[0]), float(location[1]), float(location[2]))
|
||||
key = rig.get_hierarchy_controller().add_bone(bone_name, parent, tf, True)
|
||||
if not str(key.name):
|
||||
return json.dumps({"success": False, "message": "add_bone returned an invalid key (bad parent?)."})
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(rig)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "bone": str(key.name)})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_add_rig_null(asset_path: str = None, null_name: str = None,
|
||||
parent_name: str = "", parent_type: str = "bone",
|
||||
location: list = None) -> str:
|
||||
"""Adds a null (group transform) to a Control Rig hierarchy (requires the ControlRig plugin)."""
|
||||
guard = _plugin_missing()
|
||||
if guard:
|
||||
return guard
|
||||
if asset_path is None or null_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, null_name."})
|
||||
try:
|
||||
rig = _load_rig(asset_path)
|
||||
parent = _parent_key(rig, parent_name, parent_type)
|
||||
tf = unreal.Transform()
|
||||
if location:
|
||||
if len(location) != 3:
|
||||
return json.dumps({"success": False, "message": "location must be a list of 3 floats."})
|
||||
tf.translation = unreal.Vector(float(location[0]), float(location[1]), float(location[2]))
|
||||
key = rig.get_hierarchy_controller().add_null(null_name, parent, tf, True)
|
||||
if not str(key.name):
|
||||
return json.dumps({"success": False, "message": "add_null returned an invalid key (bad parent?)."})
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(rig)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "null": str(key.name)})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_add_unit_node(asset_path: str = None, struct_path: str = None,
|
||||
method: str = "Execute", pos_x: float = 0.0, pos_y: float = 0.0) -> str:
|
||||
"""Adds a RigVM unit node by struct path (e.g. '/Script/ControlRig.RigUnit_GetTransform') to a Control Rig graph (requires the ControlRig plugin)."""
|
||||
guard = _plugin_missing()
|
||||
if guard:
|
||||
return guard
|
||||
if asset_path is None or struct_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, struct_path."})
|
||||
try:
|
||||
rig = _load_rig(asset_path)
|
||||
controller = rig.get_controller()
|
||||
node = controller.add_unit_node_from_struct_path(
|
||||
struct_path, method or "Execute", unreal.Vector2D(float(pos_x), float(pos_y)))
|
||||
if not node:
|
||||
return json.dumps({"success": False, "message": f"Could not add unit node for '{struct_path}'."})
|
||||
rig.recompile_vm()
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(rig)
|
||||
return json.dumps({"success": True, "asset_path": asset_path,
|
||||
"node": node.get_node_path(), "struct_path": struct_path})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_recompile_control_rig(asset_path: str = None) -> str:
|
||||
"""Recompiles a Control Rig's VM and saves it (requires the ControlRig plugin)."""
|
||||
guard = _plugin_missing()
|
||||
if guard:
|
||||
return guard
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
rig = _load_rig(asset_path)
|
||||
rig.recompile_vm()
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(rig)
|
||||
return json.dumps({"success": True, "asset_path": asset_path})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
@@ -0,0 +1,150 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
"""Python action functions for DataTable assets (read/write rows via DataTableFunctionLibrary)."""
|
||||
import unreal
|
||||
import json
|
||||
import traceback
|
||||
|
||||
DFL = unreal.DataTableFunctionLibrary
|
||||
|
||||
|
||||
def _load_data_table(asset_path: str):
|
||||
if not asset_path:
|
||||
raise ValueError("DataTable path cannot be empty.")
|
||||
dt = unreal.EditorAssetLibrary.load_asset(asset_path)
|
||||
if not dt:
|
||||
raise FileNotFoundError(f"DataTable not found at path: {asset_path}")
|
||||
if not isinstance(dt, unreal.DataTable):
|
||||
raise TypeError(f"Asset at {asset_path} is not a DataTable, but {type(dt).__name__}")
|
||||
return dt
|
||||
|
||||
|
||||
def _split_asset_path(asset_path: str):
|
||||
asset_path = asset_path.rstrip("/")
|
||||
idx = asset_path.rfind("/")
|
||||
return asset_path[idx + 1:], asset_path[:idx]
|
||||
|
||||
|
||||
def ue_create_data_table(asset_path: str = None, row_struct_path: str = None) -> str:
|
||||
"""Creates a DataTable asset with the given row struct (e.g. '/Script/MyModule.MyRow' or a UserDefinedStruct path)."""
|
||||
if asset_path is None or row_struct_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, row_struct_path."})
|
||||
try:
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
|
||||
return json.dumps({"success": False, "message": f"Asset already exists: {asset_path}"})
|
||||
struct = unreal.load_object(None, row_struct_path)
|
||||
if not struct:
|
||||
return json.dumps({"success": False, "message": f"Row struct not found: {row_struct_path}"})
|
||||
name, package = _split_asset_path(asset_path)
|
||||
factory = unreal.DataTableFactory()
|
||||
factory.set_editor_property("struct", struct)
|
||||
dt = unreal.AssetToolsHelpers.get_asset_tools().create_asset(name, package, unreal.DataTable, factory)
|
||||
if not dt:
|
||||
return json.dumps({"success": False, "message": f"Failed to create DataTable at {asset_path}."})
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(dt)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "row_struct": row_struct_path})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_get_row_names(asset_path: str = None) -> str:
|
||||
"""Lists the row names of a DataTable."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
dt = _load_data_table(asset_path)
|
||||
names = [str(n) for n in DFL.get_data_table_row_names(dt)]
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "count": len(names), "row_names": names})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_get_column_names(asset_path: str = None) -> str:
|
||||
"""Lists the column (property) names of a DataTable's row struct."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
dt = _load_data_table(asset_path)
|
||||
cols = [str(c) for c in DFL.get_data_table_column_export_names(dt)]
|
||||
struct = dt.get_row_struct()
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "columns": cols,
|
||||
"row_struct": struct.get_path_name() if struct else None})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_get_rows_as_json(asset_path: str = None) -> str:
|
||||
"""Returns all rows of a DataTable as a JSON string (under the 'rows' field)."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
dt = _load_data_table(asset_path)
|
||||
return json.dumps({"success": True, "asset_path": asset_path,
|
||||
"rows": DFL.export_data_table_to_json_string(dt)})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_export_to_csv(asset_path: str = None) -> str:
|
||||
"""Returns all rows of a DataTable as a CSV string."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
dt = _load_data_table(asset_path)
|
||||
return json.dumps({"success": True, "asset_path": asset_path,
|
||||
"csv": DFL.export_data_table_to_csv_string(dt)})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_does_row_exist(asset_path: str = None, row_name: str = None) -> str:
|
||||
"""Returns whether a row exists in a DataTable."""
|
||||
if asset_path is None or row_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, row_name."})
|
||||
try:
|
||||
dt = _load_data_table(asset_path)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "row_name": row_name,
|
||||
"exists": bool(DFL.does_data_table_row_exist(dt, unreal.Name(row_name)))})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_remove_row(asset_path: str = None, row_name: str = None) -> str:
|
||||
"""Removes a row from a DataTable by name."""
|
||||
if asset_path is None or row_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, row_name."})
|
||||
try:
|
||||
dt = _load_data_table(asset_path)
|
||||
if not DFL.does_data_table_row_exist(dt, unreal.Name(row_name)):
|
||||
return json.dumps({"success": False, "message": f"Row '{row_name}' does not exist."})
|
||||
# remove_data_table_row returns None; confirm via does_row_exist.
|
||||
DFL.remove_data_table_row(dt, unreal.Name(row_name))
|
||||
still = DFL.does_data_table_row_exist(dt, unreal.Name(row_name))
|
||||
if still:
|
||||
return json.dumps({"success": False, "message": f"Row '{row_name}' was not removed."})
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(dt)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "removed": row_name})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_set_rows_from_json(asset_path: str = None, json_string: str = None) -> str:
|
||||
"""Replaces a DataTable's rows from a JSON string (array of row objects with a 'Name' key)."""
|
||||
if asset_path is None or json_string is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, json_string."})
|
||||
try:
|
||||
dt = _load_data_table(asset_path)
|
||||
# Return type varies by engine version: bool (ok) or a list of problem strings.
|
||||
result = DFL.fill_data_table_from_json_string(dt, json_string)
|
||||
if isinstance(result, bool):
|
||||
if not result:
|
||||
return json.dumps({"success": False, "message": "fill_data_table_from_json_string returned False."})
|
||||
else:
|
||||
problems = [str(p) for p in result] if result else []
|
||||
if problems:
|
||||
return json.dumps({"success": False, "message": "Fill reported problems.", "problems": problems})
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(dt)
|
||||
names = [str(n) for n in DFL.get_data_table_row_names(dt)]
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "row_count": len(names)})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
@@ -0,0 +1,704 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
import unreal
|
||||
import json
|
||||
import traceback
|
||||
from typing import List, Dict, Optional, Any # Modified import
|
||||
|
||||
def ue_get_selected_assets() -> str:
|
||||
"""Gets the set of currently selected assets."""
|
||||
try:
|
||||
selected_assets = unreal.EditorUtilityLibrary.get_selected_assets()
|
||||
|
||||
serialized_assets = []
|
||||
for asset in selected_assets:
|
||||
serialized_assets.append({
|
||||
"asset_name": asset.get_name(),
|
||||
"asset_path": asset.get_path_name(),
|
||||
"asset_class": asset.get_class().get_name(),
|
||||
})
|
||||
|
||||
return json.dumps({"success": True, "selected_assets": serialized_assets})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e)})
|
||||
|
||||
# Helper function to load MaterialInterface assets (can be Material or MaterialInstance)
|
||||
def _load_material_interface(material_path: str):
|
||||
# Helper implementation (ensure this is robust or use existing if available)
|
||||
material = unreal.EditorAssetLibrary.load_asset(material_path)
|
||||
if not material:
|
||||
raise FileNotFoundError(f"Material asset not found at path: {material_path}")
|
||||
if not isinstance(material, unreal.MaterialInterface): # Allows Material or MaterialInstance
|
||||
raise TypeError(f"Asset at {material_path} is not a MaterialInterface, but {type(material).__name__}")
|
||||
return material
|
||||
|
||||
# Helper function to load StaticMesh assets
|
||||
def _load_static_mesh(mesh_path: str):
|
||||
# Helper implementation (ensure this is robust or use existing if available)
|
||||
mesh = unreal.EditorAssetLibrary.load_asset(mesh_path)
|
||||
if not mesh:
|
||||
raise FileNotFoundError(f"StaticMesh asset not found at path: {mesh_path}")
|
||||
if not isinstance(mesh, unreal.StaticMesh):
|
||||
raise TypeError(f"Asset at {mesh_path} is not a StaticMesh, but {type(mesh).__name__}")
|
||||
return mesh
|
||||
|
||||
# Helper function to get material paths from a mesh component
|
||||
def _get_component_material_paths(component: unreal.MeshComponent) -> List[str]:
|
||||
material_paths = []
|
||||
if component:
|
||||
for i in range(component.get_num_materials()):
|
||||
material = component.get_material(i)
|
||||
if material:
|
||||
material_paths.append(material.get_path_name())
|
||||
else:
|
||||
material_paths.append("") # Represent empty slot
|
||||
return material_paths
|
||||
|
||||
# Helper function to get actors by their paths
|
||||
def _get_actors_by_paths(actor_paths: List[str]) -> List[unreal.Actor]:
|
||||
actors = []
|
||||
all_level_actors = unreal.EditorLevelLibrary.get_all_level_actors()
|
||||
for path in actor_paths:
|
||||
actor = next((a for a in all_level_actors if a.get_path_name() == path), None)
|
||||
if actor:
|
||||
actors.append(actor)
|
||||
else:
|
||||
unreal.log_warning(f"MCP: Actor not found at path: {path}")
|
||||
return actors
|
||||
|
||||
# Helper to create a map of actor paths to their unique material asset paths
|
||||
def _get_materials_map_for_actors(actors_list: List[unreal.Actor]) -> Dict[str, List[str]]:
|
||||
materials_map = {}
|
||||
if not actors_list:
|
||||
return materials_map
|
||||
for actor in actors_list:
|
||||
if not actor: continue
|
||||
actor_path_name = actor.get_path_name()
|
||||
actor_material_paths = set()
|
||||
mesh_components = actor.get_components_by_class(unreal.MeshComponent.static_class())
|
||||
for comp in mesh_components:
|
||||
if comp:
|
||||
for i in range(comp.get_num_materials()):
|
||||
material = comp.get_material(i)
|
||||
if material:
|
||||
actor_material_paths.add(material.get_path_name())
|
||||
materials_map[actor_path_name] = sorted(list(actor_material_paths))
|
||||
return materials_map
|
||||
|
||||
# Helper to get static mesh path from a static mesh component
|
||||
def _get_component_mesh_path(component: unreal.StaticMeshComponent) -> str:
|
||||
if component and hasattr(component, 'static_mesh') and component.static_mesh:
|
||||
return component.static_mesh.get_path_name()
|
||||
return ""
|
||||
|
||||
# Helper to create a map of actor paths to their unique static mesh asset paths
|
||||
def _get_meshes_map_for_actors(actors_list: List[unreal.Actor]) -> Dict[str, List[str]]:
|
||||
meshes_map = {}
|
||||
if not actors_list:
|
||||
return meshes_map
|
||||
for actor in actors_list:
|
||||
if not actor: continue
|
||||
actor_path_name = actor.get_path_name()
|
||||
actor_mesh_paths = set()
|
||||
sm_components = actor.get_components_by_class(unreal.StaticMeshComponent.static_class())
|
||||
for comp in sm_components:
|
||||
mesh_path = _get_component_mesh_path(comp)
|
||||
if mesh_path:
|
||||
actor_mesh_paths.add(mesh_path)
|
||||
meshes_map[actor_path_name] = sorted(list(actor_mesh_paths))
|
||||
return meshes_map
|
||||
|
||||
# Helper to create a map of actor paths to their unique skeletal mesh asset paths
|
||||
def _get_skeletal_meshes_map_for_actors(actors_list: List[unreal.Actor]) -> Dict[str, List[str]]:
|
||||
skeletal_meshes_map = {}
|
||||
if not actors_list:
|
||||
return skeletal_meshes_map
|
||||
for actor in actors_list:
|
||||
if not actor:
|
||||
continue
|
||||
actor_path_name = actor.get_path_name()
|
||||
actor_skel_mesh_paths = set()
|
||||
skel_components = actor.get_components_by_class(unreal.SkeletalMeshComponent.static_class())
|
||||
for comp in skel_components:
|
||||
if hasattr(comp, 'skeletal_mesh') and comp.skeletal_mesh:
|
||||
actor_skel_mesh_paths.add(comp.skeletal_mesh.get_path_name())
|
||||
skeletal_meshes_map[actor_path_name] = sorted(list(actor_skel_mesh_paths))
|
||||
return skeletal_meshes_map
|
||||
|
||||
|
||||
# Base helper for replacing meshes
|
||||
def _replace_meshes_on_actors_components_base(
|
||||
actors: List[unreal.Actor],
|
||||
mesh_to_be_replaced_path: str, # Can be empty/None to replace any mesh
|
||||
new_mesh_path: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Base logic for replacing static meshes on components of given actors."""
|
||||
mesh_to_replace = None
|
||||
# Only load if a specific mesh is targeted for replacement
|
||||
if mesh_to_be_replaced_path and mesh_to_be_replaced_path.lower() not in ["", "none", "any"]:
|
||||
mesh_to_replace = _load_static_mesh(mesh_to_be_replaced_path) # Uses existing helper
|
||||
# If _load_static_mesh raises, it will be caught by the calling ue_ function's try-except
|
||||
|
||||
new_mesh = _load_static_mesh(new_mesh_path) # Uses existing helper
|
||||
# If _load_static_mesh raises, it will be caught by the calling ue_ function's try-except
|
||||
|
||||
if mesh_to_replace and new_mesh and mesh_to_replace.get_path_name() == new_mesh.get_path_name():
|
||||
return {"success": True, "message": "Mesh to be replaced is the same as the new mesh. No changes made.", "changed_actors_count": 0, "changed_components_count": 0}
|
||||
|
||||
|
||||
changed_actors_count = 0
|
||||
changed_components_count = 0
|
||||
details = {"actors_affected": []}
|
||||
|
||||
with unreal.ScopedEditorTransaction("Replace Static Meshes on Components") as trans:
|
||||
for actor in actors:
|
||||
if not actor: continue
|
||||
actor_path = actor.get_path_name()
|
||||
actor_changed_this_iteration = False
|
||||
actor_details = {"actor_path": actor_path, "components_changed": []}
|
||||
|
||||
static_mesh_components = actor.get_components_by_class(unreal.StaticMeshComponent.static_class())
|
||||
for component in static_mesh_components:
|
||||
if not component or component.get_owner() != actor:
|
||||
continue
|
||||
|
||||
component_path = component.get_path_name()
|
||||
current_mesh = component.static_mesh
|
||||
|
||||
should_replace = False
|
||||
# Case 1: Replace any mesh (mesh_to_be_replaced_path is empty/None/Any)
|
||||
if not mesh_to_be_replaced_path or mesh_to_be_replaced_path.lower() in ["", "none", "any"]:
|
||||
if current_mesh != new_mesh : # Avoid replacing with itself if no specific mesh to replace
|
||||
should_replace = True
|
||||
# Case 2: Replace a specific mesh
|
||||
elif mesh_to_replace and current_mesh and current_mesh.get_path_name() == mesh_to_replace.get_path_name():
|
||||
if current_mesh.get_path_name() != new_mesh.get_path_name(): # Don't replace if it's already the new mesh
|
||||
should_replace = True
|
||||
# Case 3: Component has no mesh, and we want to set one (mesh_to_be_replaced_path is empty/None/Any)
|
||||
elif (not mesh_to_be_replaced_path or mesh_to_be_replaced_path.lower() in ["", "none", "any"]) and not current_mesh:
|
||||
should_replace = True
|
||||
|
||||
|
||||
if should_replace:
|
||||
if component.set_static_mesh(new_mesh):
|
||||
changed_components_count += 1
|
||||
actor_changed_this_iteration = True
|
||||
actor_details["components_changed"].append({"component_path": component_path, "previous_mesh": current_mesh.get_path_name() if current_mesh else None})
|
||||
else:
|
||||
unreal.log_warning(f"MCP: Failed to set static mesh on component {component_path} for actor {actor_path}")
|
||||
|
||||
if actor_changed_this_iteration:
|
||||
changed_actors_count += 1
|
||||
details["actors_affected"].append(actor_details)
|
||||
|
||||
if changed_actors_count > 0:
|
||||
unreal.EditorLevelLibrary.refresh_all_level_editors()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Mesh replacement processed. Actors processed: {len(actors)}.",
|
||||
"changed_actors_count": changed_actors_count,
|
||||
"changed_components_count": changed_components_count,
|
||||
"details": details
|
||||
}
|
||||
|
||||
def ue_replace_mtl_on_selected(material_to_be_replaced_path: str, new_material_path: str) -> str:
|
||||
try:
|
||||
material_to_replace = _load_material_interface(material_to_be_replaced_path)
|
||||
new_material = _load_material_interface(new_material_path)
|
||||
|
||||
selected_actors = unreal.EditorLevelLibrary.get_selected_level_actors()
|
||||
if not selected_actors:
|
||||
return json.dumps({"success": False, "message": "No actors selected."})
|
||||
|
||||
mesh_components = []
|
||||
for actor in selected_actors:
|
||||
components = actor.get_components_by_class(unreal.MeshComponent.static_class())
|
||||
mesh_components.extend(c for c in components if c)
|
||||
|
||||
if not mesh_components:
|
||||
return json.dumps({"success": False, "message": "No mesh components found on selected actors."})
|
||||
|
||||
initial_materials_map = {}
|
||||
for comp in mesh_components:
|
||||
initial_materials_map[comp.get_path_name()] = _get_component_material_paths(comp)
|
||||
|
||||
unreal.EditorLevelLibrary.replace_mesh_components_materials(
|
||||
mesh_components,
|
||||
material_to_replace,
|
||||
new_material
|
||||
)
|
||||
|
||||
num_components_affected = 0
|
||||
affected_component_paths = []
|
||||
for comp in mesh_components:
|
||||
current_materials = _get_component_material_paths(comp)
|
||||
original_materials = initial_materials_map.get(comp.get_path_name(), [])
|
||||
|
||||
component_changed_here = False
|
||||
for slot_idx, original_mat_path in enumerate(original_materials):
|
||||
if original_mat_path == material_to_replace.get_path_name():
|
||||
if slot_idx < len(current_materials) and current_materials[slot_idx] == new_material.get_path_name():
|
||||
component_changed_here = True
|
||||
break
|
||||
if component_changed_here:
|
||||
num_components_affected += 1
|
||||
affected_component_paths.append(comp.get_path_name())
|
||||
|
||||
if num_components_affected > 0:
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"message": f"Successfully replaced material '{material_to_be_replaced_path}' with '{new_material_path}' on {num_components_affected} mesh component(s) across {len(selected_actors)} selected actor(s).",
|
||||
"affected_actors_count": len(selected_actors),
|
||||
"affected_components_count": num_components_affected,
|
||||
"affected_component_paths": affected_component_paths
|
||||
})
|
||||
else:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Failed to replace material. Target material '{material_to_be_replaced_path}' not found or not replaced on any mesh components of selected actors."
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
def ue_replace_mtl_on_specified(actor_paths: List[str], material_to_be_replaced_path: str, new_material_path: str) -> str:
|
||||
try:
|
||||
material_to_replace = _load_material_interface(material_to_be_replaced_path)
|
||||
new_material = _load_material_interface(new_material_path)
|
||||
|
||||
actors_to_process = _get_actors_by_paths(actor_paths)
|
||||
if not actors_to_process:
|
||||
return json.dumps({"success": False, "message": "No valid actors found from the provided paths."})
|
||||
|
||||
all_mesh_components_in_actors = []
|
||||
for actor in actors_to_process:
|
||||
components = actor.get_components_by_class(unreal.MeshComponent.static_class())
|
||||
all_mesh_components_in_actors.extend(c for c in components if c)
|
||||
|
||||
if not all_mesh_components_in_actors:
|
||||
return json.dumps({"success": False, "message": "No mesh components found on the specified actors."})
|
||||
|
||||
initial_materials_map = {}
|
||||
for comp in all_mesh_components_in_actors:
|
||||
initial_materials_map[comp.get_path_name()] = _get_component_material_paths(comp)
|
||||
|
||||
unreal.EditorLevelLibrary.replace_mesh_components_materials_on_actors(
|
||||
actors_to_process,
|
||||
material_to_replace,
|
||||
new_material
|
||||
)
|
||||
|
||||
num_components_affected = 0
|
||||
affected_component_paths = []
|
||||
for comp in all_mesh_components_in_actors:
|
||||
current_materials = _get_component_material_paths(comp)
|
||||
original_materials = initial_materials_map.get(comp.get_path_name(), [])
|
||||
|
||||
component_changed_here = False
|
||||
for slot_idx, original_mat_path in enumerate(original_materials):
|
||||
if original_mat_path == material_to_replace.get_path_name():
|
||||
if slot_idx < len(current_materials) and current_materials[slot_idx] == new_material.get_path_name():
|
||||
component_changed_here = True
|
||||
break
|
||||
if component_changed_here:
|
||||
num_components_affected += 1
|
||||
affected_component_paths.append(comp.get_path_name())
|
||||
|
||||
if num_components_affected > 0:
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"message": f"Successfully replaced material '{material_to_be_replaced_path}' with '{new_material_path}' on {num_components_affected} mesh component(s) across {len(actors_to_process)} specified actor(s).",
|
||||
"affected_actors_count": len(actors_to_process),
|
||||
"affected_components_count": num_components_affected,
|
||||
"affected_component_paths": affected_component_paths
|
||||
})
|
||||
else:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Failed to replace material. Target material '{material_to_be_replaced_path}' not found or not replaced on any mesh components of specified actors."
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
def ue_replace_mesh_on_selected(mesh_to_be_replaced_path: str, new_mesh_path: str) -> str:
|
||||
"""Replaces static meshes on components of selected actors using Unreal's batch API if available."""
|
||||
try:
|
||||
# Check if mesh_to_be_replaced_path exists (if specified)
|
||||
if mesh_to_be_replaced_path and mesh_to_be_replaced_path.lower() not in ["", "none", "any"]:
|
||||
try:
|
||||
_ = _load_static_mesh(mesh_to_be_replaced_path)
|
||||
except FileNotFoundError:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"The mesh_to_be_replaced_path '{mesh_to_be_replaced_path}' does not exist as a StaticMesh asset.",
|
||||
"error_type": "MeshToReplaceNotFound"
|
||||
})
|
||||
|
||||
selected_actors = unreal.EditorLevelLibrary.get_selected_level_actors()
|
||||
if not selected_actors:
|
||||
return json.dumps({"success": True, "message": "No actors selected.", "changed_actors_count": 0, "changed_components_count": 0})
|
||||
|
||||
mesh_to_replace = None
|
||||
if mesh_to_be_replaced_path and mesh_to_be_replaced_path.lower() not in ["", "none", "any"]:
|
||||
mesh_to_replace = _load_static_mesh(mesh_to_be_replaced_path)
|
||||
new_mesh = _load_static_mesh(new_mesh_path)
|
||||
|
||||
# Gather all static mesh components from selected actors
|
||||
all_mesh_components = []
|
||||
for actor in selected_actors:
|
||||
comps = actor.get_components_by_class(unreal.StaticMeshComponent.static_class())
|
||||
all_mesh_components.extend(c for c in comps if c)
|
||||
|
||||
if not all_mesh_components:
|
||||
return json.dumps({"success": False, "message": "No static mesh components found on selected actors."})
|
||||
|
||||
# Save initial mesh paths for change detection
|
||||
initial_meshes_map = {comp.get_path_name(): comp.static_mesh.get_path_name() if comp.static_mesh else "" for comp in all_mesh_components}
|
||||
|
||||
# Use Unreal's batch API if available
|
||||
if hasattr(unreal.EditorLevelLibrary, "replace_mesh_components_meshes_on_actors"):
|
||||
unreal.EditorLevelLibrary.replace_mesh_components_meshes_on_actors(
|
||||
selected_actors,
|
||||
mesh_to_replace,
|
||||
new_mesh
|
||||
)
|
||||
else:
|
||||
# Fallback to manual replacement if batch API is not available
|
||||
for comp in all_mesh_components:
|
||||
current_mesh = comp.static_mesh
|
||||
should_replace = False
|
||||
if not mesh_to_replace:
|
||||
if current_mesh != new_mesh:
|
||||
should_replace = True
|
||||
elif current_mesh and current_mesh.get_path_name() == mesh_to_replace.get_path_name():
|
||||
if current_mesh.get_path_name() != new_mesh.get_path_name():
|
||||
should_replace = True
|
||||
elif not current_mesh and not mesh_to_replace:
|
||||
should_replace = True
|
||||
if should_replace:
|
||||
comp.set_static_mesh(new_mesh)
|
||||
|
||||
# Detect changes
|
||||
changed_components_count = 0
|
||||
affected_component_paths = []
|
||||
for comp in all_mesh_components:
|
||||
before = initial_meshes_map.get(comp.get_path_name(), "")
|
||||
after = comp.static_mesh.get_path_name() if comp.static_mesh else ""
|
||||
if mesh_to_replace:
|
||||
if before == mesh_to_replace.get_path_name() and after == new_mesh.get_path_name():
|
||||
changed_components_count += 1
|
||||
affected_component_paths.append(comp.get_path_name())
|
||||
else:
|
||||
if before != after and after == new_mesh.get_path_name():
|
||||
changed_components_count += 1
|
||||
affected_component_paths.append(comp.get_path_name())
|
||||
|
||||
if changed_components_count > 0:
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"message": f"Successfully replaced mesh on {changed_components_count} static mesh component(s) across {len(selected_actors)} selected actor(s).",
|
||||
"affected_actors_count": len(selected_actors),
|
||||
"affected_components_count": changed_components_count,
|
||||
"affected_component_paths": affected_component_paths
|
||||
})
|
||||
else:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Failed to replace mesh. Target mesh '{mesh_to_be_replaced_path}' not found or not replaced on any static mesh components of selected actors."
|
||||
})
|
||||
except FileNotFoundError as e:
|
||||
unreal.log_error(f"MCP: Asset loading error in ue_replace_mesh_on_selected: {e}")
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
except TypeError as e:
|
||||
unreal.log_error(f"MCP: Asset type error in ue_replace_mesh_on_selected: {e}")
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
except Exception as e:
|
||||
unreal.log_error(f"MCP: Error in ue_replace_mesh_on_selected: {e}\n{traceback.format_exc()}")
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
def ue_replace_mesh_on_specified(actor_paths: List[str], mesh_to_be_replaced_path: str, new_mesh_path: str) -> str:
|
||||
"""Replaces static meshes on components of specified actors using Unreal's batch API if available."""
|
||||
try:
|
||||
# Check if mesh_to_be_replaced_path exists (if specified)
|
||||
if mesh_to_be_replaced_path and mesh_to_be_replaced_path.lower() not in ["", "none", "any"]:
|
||||
try:
|
||||
_ = _load_static_mesh(mesh_to_be_replaced_path)
|
||||
except FileNotFoundError:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"The mesh_to_be_replaced_path '{mesh_to_be_replaced_path}' does not exist as a StaticMesh asset.",
|
||||
"error_type": "MeshToReplaceNotFound"
|
||||
})
|
||||
actors_to_process = _get_actors_by_paths(actor_paths)
|
||||
if not actors_to_process:
|
||||
return json.dumps({"success": False, "message": "No valid actors found from the provided paths."})
|
||||
mesh_to_replace = None
|
||||
if mesh_to_be_replaced_path and mesh_to_be_replaced_path.lower() not in ["", "none", "any"]:
|
||||
mesh_to_replace = _load_static_mesh(mesh_to_be_replaced_path)
|
||||
new_mesh = _load_static_mesh(new_mesh_path)
|
||||
# Gather all static mesh components from specified actors
|
||||
all_mesh_components = []
|
||||
for actor in actors_to_process:
|
||||
comps = actor.get_components_by_class(unreal.StaticMeshComponent.static_class())
|
||||
all_mesh_components.extend(c for c in comps if c)
|
||||
if not all_mesh_components:
|
||||
# Get the actual actor types and names for better diagnosis
|
||||
actor_info = [{"name": actor.get_name(), "class": actor.get_class().get_name()} for actor in actors_to_process]
|
||||
actors_materials_info = _get_materials_map_for_actors(actors_to_process)
|
||||
actors_meshes_info = _get_meshes_map_for_actors(actors_to_process)
|
||||
actors_skel_meshes_info = _get_skeletal_meshes_map_for_actors(actors_to_process)
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": "No static mesh components found on specified actors.",
|
||||
"specified_actors_info": actor_info,
|
||||
"current_materials": actors_materials_info,
|
||||
"current_meshes": actors_meshes_info,
|
||||
"current_skeletal_meshes": actors_skel_meshes_info
|
||||
})
|
||||
# Save initial mesh paths for change detection
|
||||
initial_meshes_map = {comp.get_path_name(): comp.static_mesh.get_path_name() if comp.static_mesh else "" for comp in all_mesh_components}
|
||||
# Use Unreal's batch API if available
|
||||
if hasattr(unreal.EditorLevelLibrary, "replace_mesh_components_meshes_on_actors"):
|
||||
unreal.EditorLevelLibrary.replace_mesh_components_meshes_on_actors(
|
||||
actors_to_process,
|
||||
mesh_to_replace,
|
||||
new_mesh
|
||||
)
|
||||
else:
|
||||
# Fallback to manual replacement if batch API is not available
|
||||
for comp in all_mesh_components:
|
||||
current_mesh = comp.static_mesh
|
||||
should_replace = False
|
||||
if not mesh_to_replace:
|
||||
if current_mesh != new_mesh:
|
||||
should_replace = True
|
||||
elif current_mesh and current_mesh.get_path_name() == mesh_to_replace.get_path_name():
|
||||
if current_mesh.get_path_name() != new_mesh.get_path_name():
|
||||
should_replace = True
|
||||
elif not current_mesh and not mesh_to_replace:
|
||||
should_replace = True
|
||||
if should_replace:
|
||||
comp.set_static_mesh(new_mesh)
|
||||
# Detect changes
|
||||
changed_components_count = 0
|
||||
affected_component_paths = []
|
||||
unchanged_components_info = []
|
||||
for comp in all_mesh_components:
|
||||
before = initial_meshes_map.get(comp.get_path_name(), "")
|
||||
after = comp.static_mesh.get_path_name() if comp.static_mesh else ""
|
||||
owner_actor = comp.get_owner()
|
||||
owner_name = owner_actor.get_name() if owner_actor else "Unknown"
|
||||
if mesh_to_replace:
|
||||
if before == mesh_to_replace.get_path_name() and after == new_mesh.get_path_name():
|
||||
changed_components_count += 1
|
||||
affected_component_paths.append(comp.get_path_name())
|
||||
else:
|
||||
is_candidate = before == mesh_to_replace.get_path_name()
|
||||
unchanged_components_info.append({
|
||||
"component_path": comp.get_path_name(),
|
||||
"component_name": comp.get_name(),
|
||||
"actor_name": owner_name,
|
||||
"current_mesh": before,
|
||||
"is_candidate": is_candidate,
|
||||
"reason": (
|
||||
"Component is a candidate for replacement (matches mesh_to_be_replaced_path) but was not changed"
|
||||
if is_candidate else
|
||||
("Current mesh doesn't match the mesh to be replaced" if before != mesh_to_replace.get_path_name() else "Failed to set new mesh")
|
||||
)
|
||||
})
|
||||
else:
|
||||
if before != after and after == new_mesh.get_path_name():
|
||||
changed_components_count += 1
|
||||
affected_component_paths.append(comp.get_path_name())
|
||||
else:
|
||||
unchanged_components_info.append({
|
||||
"component_path": comp.get_path_name(),
|
||||
"component_name": comp.get_name(),
|
||||
"actor_name": owner_name,
|
||||
"current_mesh": before,
|
||||
"is_candidate": False,
|
||||
"reason": "Component already has the target mesh" if before == new_mesh.get_path_name() else "Failed to set new mesh"
|
||||
})
|
||||
unchanged_components_info = unchanged_components_info if 'unchanged_components_info' in locals() else []
|
||||
if changed_components_count > 0:
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"message": f"Successfully replaced mesh on {changed_components_count} static mesh component(s) across {len(actors_to_process)} specified actor(s).",
|
||||
"affected_actors_count": len(actors_to_process),
|
||||
"affected_components_count": changed_components_count,
|
||||
"affected_component_paths": affected_component_paths,
|
||||
"unchanged_components": unchanged_components_info
|
||||
})
|
||||
else:
|
||||
actors_materials_info = _get_materials_map_for_actors(actors_to_process)
|
||||
actors_meshes_info = _get_meshes_map_for_actors(actors_to_process)
|
||||
actors_skel_meshes_info = _get_skeletal_meshes_map_for_actors(actors_to_process)
|
||||
unchanged_components_info = unchanged_components_info if 'unchanged_components_info' in locals() else []
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Failed to replace mesh. Target mesh '{mesh_to_be_replaced_path}' not found or not replaced on any static mesh components of specified actors.",
|
||||
"current_materials": actors_materials_info,
|
||||
"current_meshes": actors_meshes_info,
|
||||
"current_skeletal_meshes": actors_skel_meshes_info,
|
||||
"unchanged_components": unchanged_components_info
|
||||
})
|
||||
except FileNotFoundError as e:
|
||||
unreal.log_error(f"MCP: Asset loading error in ue_replace_mesh_on_specified: {e}")
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
except TypeError as e:
|
||||
unreal.log_error(f"MCP: Asset type error in ue_replace_mesh_on_specified: {e}")
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
except Exception as e:
|
||||
unreal.log_error(f"MCP: Error in ue_replace_mesh_on_specified: {e}\n{traceback.format_exc()}")
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
def ue_replace_selected_with_bp(blueprint_asset_path: str) -> str:
|
||||
"""Replaces the currently selected actors with new actors spawned from a specified Blueprint asset path using Unreal's official API."""
|
||||
import unreal
|
||||
import json
|
||||
import traceback
|
||||
try:
|
||||
selected_actors = unreal.EditorLevelLibrary.get_selected_level_actors()
|
||||
if not selected_actors:
|
||||
return json.dumps({"success": False, "message": "No actors selected."})
|
||||
# Check if the blueprint asset exists
|
||||
blueprint = unreal.EditorAssetLibrary.load_asset(blueprint_asset_path)
|
||||
if not blueprint:
|
||||
return json.dumps({"success": False, "message": f"Blueprint asset not found at path: {blueprint_asset_path}"})
|
||||
# Use the official API
|
||||
unreal.EditorLevelLibrary.replace_selected_actors(blueprint_asset_path)
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"message": f"Replaced {len(selected_actors)} actors with Blueprint '{blueprint_asset_path}' using official API.",
|
||||
"replaced_actors_count": len(selected_actors)
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
# --- Actor merging / proxy geometry --------------------------------------------
|
||||
|
||||
def _actors_by_labels(labels):
|
||||
sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
by_label = {a.get_actor_label(): a for a in sub.get_all_level_actors()}
|
||||
found, missing = [], []
|
||||
for l in labels:
|
||||
(found.append(by_label[l]) if l in by_label else missing.append(l))
|
||||
return found, missing
|
||||
|
||||
|
||||
def _merged_mesh_path(actor):
|
||||
try:
|
||||
mesh = actor.static_mesh_component.static_mesh
|
||||
return mesh.get_path_name().split(".")[0] if mesh else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def ue_merge_actors(actor_labels: list = None, base_package_name: str = None,
|
||||
destroy_source_actors: bool = False) -> str:
|
||||
"""Merges static mesh actors into ONE new static mesh asset + actor (geometry is baked together)."""
|
||||
if not actor_labels or base_package_name is None:
|
||||
return json.dumps({"success": False, "message": "Required: actor_labels (non-empty), base_package_name."})
|
||||
try:
|
||||
actors, missing = _actors_by_labels(actor_labels)
|
||||
if missing:
|
||||
return json.dumps({"success": False, "message": f"Actors not found: {missing}"})
|
||||
sms = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem)
|
||||
opts = unreal.EditorScriptingMergeStaticMeshActorsOptions()
|
||||
opts.base_package_name = base_package_name
|
||||
opts.destroy_source_actors = bool(destroy_source_actors)
|
||||
merged = sms.merge_static_mesh_actors(actors, opts)
|
||||
if not merged:
|
||||
return json.dumps({"success": False, "message": "merge_static_mesh_actors returned None (are these StaticMesh actors?)."})
|
||||
return json.dumps({"success": True, "merged_actor": merged.get_actor_label(),
|
||||
"mesh_asset": _merged_mesh_path(merged),
|
||||
"source_destroyed": bool(destroy_source_actors)})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_join_actors(actor_labels: list = None, new_actor_label: str = "") -> str:
|
||||
"""Joins static mesh actors into one actor with multiple components (no new mesh asset is baked)."""
|
||||
if not actor_labels:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'actor_labels' is missing."})
|
||||
try:
|
||||
actors, missing = _actors_by_labels(actor_labels)
|
||||
if missing:
|
||||
return json.dumps({"success": False, "message": f"Actors not found: {missing}"})
|
||||
sms = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem)
|
||||
opts = unreal.EditorScriptingJoinStaticMeshActorsOptions()
|
||||
if new_actor_label:
|
||||
opts.new_actor_label = new_actor_label
|
||||
joined = sms.join_static_mesh_actors(actors, opts)
|
||||
if not joined:
|
||||
return json.dumps({"success": False, "message": "join_static_mesh_actors returned None."})
|
||||
return json.dumps({"success": True, "joined_actor": joined.get_actor_label()})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_create_proxy_actor(actor_labels: list = None, base_package_name: str = None,
|
||||
screen_size: int = 300, destroy_source_actors: bool = False) -> str:
|
||||
"""Bakes static mesh actors into ONE simplified proxy mesh (Proxy Geometry tool) and spawns it."""
|
||||
if not actor_labels or base_package_name is None:
|
||||
return json.dumps({"success": False, "message": "Required: actor_labels (non-empty), base_package_name."})
|
||||
try:
|
||||
actors, missing = _actors_by_labels(actor_labels)
|
||||
if missing:
|
||||
return json.dumps({"success": False, "message": f"Actors not found: {missing}"})
|
||||
sms = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem)
|
||||
opts = unreal.EditorScriptingCreateProxyMeshActorOptions()
|
||||
opts.base_package_name = base_package_name
|
||||
opts.destroy_source_actors = bool(destroy_source_actors)
|
||||
settings = opts.mesh_proxy_settings
|
||||
settings.set_editor_property("screen_size", int(screen_size))
|
||||
opts.mesh_proxy_settings = settings
|
||||
proxy = sms.create_proxy_mesh_actor(actors, opts)
|
||||
if not proxy:
|
||||
return json.dumps({"success": False, "message": "create_proxy_mesh_actor returned None (ProxyLOD plugin enabled? StaticMesh actors?)."})
|
||||
return json.dumps({"success": True, "proxy_actor": proxy.get_actor_label(),
|
||||
"mesh_asset": _merged_mesh_path(proxy),
|
||||
"source_destroyed": bool(destroy_source_actors)})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
# ─── Asset editor control ─────────────────────────────────────────────────────
|
||||
|
||||
def ue_open_editor_for_asset(asset_path: str = None) -> str:
|
||||
"""Opens the asset-specific editor (Blueprint, Material, etc.) for an asset."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
asset = unreal.EditorAssetLibrary.load_asset(asset_path)
|
||||
if not asset:
|
||||
return json.dumps({"success": False, "message": f"Asset not found: {asset_path}"})
|
||||
unreal.get_editor_subsystem(unreal.AssetEditorSubsystem).open_editor_for_assets([asset])
|
||||
return json.dumps({"success": True, "asset_path": asset_path})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_get_open_assets() -> str:
|
||||
"""Lists assets that currently have an editor open."""
|
||||
try:
|
||||
assets = unreal.MCPythonHelper.get_all_edited_assets()
|
||||
open_assets = [{"asset_name": a.get_name(), "asset_path": a.get_path_name(),
|
||||
"asset_class": a.get_class().get_name()} for a in assets if a]
|
||||
return json.dumps({"success": True, "count": len(open_assets), "open_assets": open_assets})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_close_asset_editor(asset_path: str = None) -> str:
|
||||
"""Closes any open editor for the given asset."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
asset = unreal.EditorAssetLibrary.load_asset(asset_path)
|
||||
if not asset:
|
||||
return json.dumps({"success": False, "message": f"Asset not found: {asset_path}"})
|
||||
unreal.get_editor_subsystem(unreal.AssetEditorSubsystem).close_all_editors_for_asset(asset)
|
||||
return json.dumps({"success": True, "asset_path": asset_path})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
@@ -0,0 +1,297 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
import unreal
|
||||
import json
|
||||
import traceback
|
||||
|
||||
|
||||
def _split_asset_path(asset_path):
|
||||
"""Split '/Game/Input/IA_Jump' into ('/Game/Input', 'IA_Jump')."""
|
||||
parts = asset_path.rsplit('/', 1)
|
||||
if len(parts) == 2:
|
||||
return parts[0], parts[1]
|
||||
return '/Game', asset_path
|
||||
|
||||
|
||||
def ue_set_game_mode(game_mode_class_path: str = None) -> str:
|
||||
"""Sets the GameMode Override on the current level's World Settings."""
|
||||
try:
|
||||
world = unreal.EditorLevelLibrary.get_editor_world()
|
||||
if world is None:
|
||||
return json.dumps({"success": False, "message": "No editor world available."})
|
||||
|
||||
world_settings = world.get_world_settings()
|
||||
if world_settings is None:
|
||||
return json.dumps({"success": False, "message": "Could not get WorldSettings."})
|
||||
|
||||
GAME_MODE_PROPS = ['default_game_mode', 'game_mode_override', 'GameModeOverride']
|
||||
|
||||
# Clear override if path is None or empty
|
||||
if not game_mode_class_path:
|
||||
set_ok = False
|
||||
for prop_name in GAME_MODE_PROPS:
|
||||
try:
|
||||
world_settings.set_editor_property(prop_name, None)
|
||||
set_ok = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not set_ok:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": "Failed to clear GameMode. Property name may differ in this UE version."
|
||||
})
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"message": "GameMode cleared.",
|
||||
"game_mode": None
|
||||
})
|
||||
|
||||
# Load the class
|
||||
loaded_class = unreal.load_class(None, game_mode_class_path)
|
||||
if loaded_class is None:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Could not load class: {game_mode_class_path}. "
|
||||
"Ensure the path is correct (Blueprint paths need '_C' suffix)."
|
||||
})
|
||||
|
||||
# Set the GameMode
|
||||
set_ok = False
|
||||
used_prop = None
|
||||
for prop_name in GAME_MODE_PROPS:
|
||||
try:
|
||||
world_settings.set_editor_property(prop_name, loaded_class)
|
||||
set_ok = True
|
||||
used_prop = prop_name
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not set_ok:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": "Failed to set GameMode. Property name may differ in this UE version."
|
||||
})
|
||||
|
||||
# Verify
|
||||
verified_name = None
|
||||
try:
|
||||
current = world_settings.get_editor_property(used_prop)
|
||||
if current:
|
||||
verified_name = str(current.get_name())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"message": f"GameMode set to '{game_mode_class_path}'.",
|
||||
"game_mode": game_mode_class_path,
|
||||
"verified_class_name": verified_name
|
||||
})
|
||||
except Exception as e:
|
||||
tb_str = traceback.format_exc()
|
||||
unreal.log_error(f"Error in ue_set_game_mode: {str(e)}\n{tb_str}")
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
|
||||
|
||||
|
||||
def ue_add_input_action(asset_path: str = None, value_type: str = "Bool") -> str:
|
||||
"""Creates a new Enhanced Input Action asset."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
|
||||
try:
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
|
||||
return json.dumps({"success": False, "message": f"Asset already exists at '{asset_path}'."})
|
||||
|
||||
package_path, asset_name = _split_asset_path(asset_path)
|
||||
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
|
||||
|
||||
# Try to create InputAction asset
|
||||
ia = None
|
||||
|
||||
# Attempt 1: With factory
|
||||
try:
|
||||
factory = unreal.InputActionFactory()
|
||||
ia = asset_tools.create_asset(asset_name, package_path, unreal.InputAction, factory)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Attempt 2: Without factory
|
||||
if ia is None:
|
||||
try:
|
||||
ia = asset_tools.create_asset(asset_name, package_path, unreal.InputAction, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if ia is None:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Failed to create InputAction at '{asset_path}'. "
|
||||
"Enhanced Input plugin may not be enabled or InputAction "
|
||||
"class may not be accessible via Python."
|
||||
})
|
||||
|
||||
# Set value type if not default Bool
|
||||
if value_type and value_type != "Bool":
|
||||
type_set = False
|
||||
# Try multiple approaches to set value type
|
||||
for prop_name in ['value_type', 'ValueType']:
|
||||
for enum_name in [value_type.upper(), value_type, value_type.lower()]:
|
||||
try:
|
||||
enum_val = getattr(unreal.InputActionValueType, enum_name, None)
|
||||
if enum_val is not None:
|
||||
ia.set_editor_property(prop_name, enum_val)
|
||||
type_set = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if type_set:
|
||||
break
|
||||
|
||||
if not type_set:
|
||||
unreal.log_warning(
|
||||
f"InputAction created but could not set value_type to '{value_type}'. "
|
||||
"Defaulting to Bool."
|
||||
)
|
||||
|
||||
unreal.EditorAssetLibrary.save_asset(ia.get_path_name())
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"asset_path": ia.get_path_name(),
|
||||
"value_type": value_type,
|
||||
"message": f"InputAction created at '{ia.get_path_name()}'."
|
||||
})
|
||||
except Exception as e:
|
||||
tb_str = traceback.format_exc()
|
||||
unreal.log_error(f"Error in ue_add_input_action: {str(e)}\n{tb_str}")
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
|
||||
|
||||
|
||||
def ue_add_input_mapping(mapping_context_path: str = None,
|
||||
action_path: str = None,
|
||||
key_name: str = None) -> str:
|
||||
"""Creates/updates an InputMappingContext with a key-to-action mapping."""
|
||||
if mapping_context_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'mapping_context_path' is missing."})
|
||||
if action_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'action_path' is missing."})
|
||||
if key_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'key_name' is missing."})
|
||||
|
||||
try:
|
||||
# Load or create the InputMappingContext
|
||||
imc = None
|
||||
created_imc = False
|
||||
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(mapping_context_path):
|
||||
imc = unreal.EditorAssetLibrary.load_asset(mapping_context_path)
|
||||
if imc is None:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Failed to load asset at '{mapping_context_path}'."
|
||||
})
|
||||
else:
|
||||
# Create new IMC
|
||||
package_path, asset_name = _split_asset_path(mapping_context_path)
|
||||
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
|
||||
|
||||
try:
|
||||
factory = unreal.InputMappingContextFactory()
|
||||
imc = asset_tools.create_asset(asset_name, package_path, unreal.InputMappingContext, factory)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if imc is None:
|
||||
try:
|
||||
imc = asset_tools.create_asset(asset_name, package_path, unreal.InputMappingContext, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if imc is None:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Failed to create InputMappingContext at '{mapping_context_path}'. "
|
||||
"Enhanced Input plugin may not be enabled."
|
||||
})
|
||||
created_imc = True
|
||||
|
||||
# Load the InputAction
|
||||
ia = unreal.EditorAssetLibrary.load_asset(action_path)
|
||||
if ia is None:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"InputAction not found at '{action_path}'."
|
||||
})
|
||||
|
||||
# Create Key object (UE 5.7+: Key() takes no args, set key_name via property)
|
||||
key = unreal.Key()
|
||||
key.set_editor_property('key_name', key_name)
|
||||
|
||||
mapping_added = False
|
||||
|
||||
# Construct EnhancedActionKeyMapping and append to mappings array
|
||||
try:
|
||||
mapping = unreal.EnhancedActionKeyMapping()
|
||||
for prop_name in ['action', 'Action']:
|
||||
try:
|
||||
mapping.set_editor_property(prop_name, ia)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
for prop_name in ['key', 'Key']:
|
||||
try:
|
||||
mapping.set_editor_property(prop_name, key)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try DefaultKeyMappings first (UE 5.7+), then mappings as fallback
|
||||
for mp in ['default_key_mappings', 'DefaultKeyMappings', 'mappings', 'Mappings']:
|
||||
try:
|
||||
mappings = list(imc.get_editor_property(mp) or [])
|
||||
mappings.append(mapping)
|
||||
imc.set_editor_property(mp, mappings)
|
||||
mapping_added = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not mapping_added:
|
||||
if created_imc:
|
||||
unreal.EditorAssetLibrary.save_asset(imc.get_path_name())
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"InputMappingContext created at '{imc.get_path_name()}' "
|
||||
f"but failed to add key mapping '{key_name}' -> '{action_path}'. "
|
||||
f"The Enhanced Input mapping API may not be fully exposed in Python. "
|
||||
f"Use execute_python tool to explore available methods on the IMC object.",
|
||||
"imc_created": True,
|
||||
"imc_path": imc.get_path_name()
|
||||
})
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Failed to add key mapping '{key_name}' -> '{action_path}' "
|
||||
f"to '{mapping_context_path}'. "
|
||||
"The Enhanced Input mapping API may not be fully exposed in Python."
|
||||
})
|
||||
|
||||
unreal.EditorAssetLibrary.save_asset(imc.get_path_name())
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"mapping_context_path": imc.get_path_name(),
|
||||
"action_path": action_path,
|
||||
"key_name": key_name,
|
||||
"imc_created": created_imc,
|
||||
"message": f"Mapped '{key_name}' -> '{action_path}' in '{imc.get_path_name()}'."
|
||||
})
|
||||
except Exception as e:
|
||||
tb_str = traceback.format_exc()
|
||||
unreal.log_error(f"Error in ue_add_input_mapping: {str(e)}\n{tb_str}")
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": tb_str})
|
||||
@@ -0,0 +1,410 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
"""
|
||||
Gameplay Ability System (GAS) authoring: GameplayAbility / GameplayEffect
|
||||
blueprints, effect modifiers, ability tags & costs, and GameplayTags config.
|
||||
|
||||
All actions require the GameplayAbilities plugin (soft runtime dependency —
|
||||
guarded per action). Property-level notes that shape the implementation:
|
||||
|
||||
- GameplayModifierInfo.attribute is edit-defaults-only, so modifiers are
|
||||
assembled through struct import_text (which bypasses per-property edit
|
||||
checks) and assigned to the CDO's `modifiers` array wholesale.
|
||||
- GameplayTag/GameplayTagContainer import_text silently drops tags that are
|
||||
not registered in the project's tag table, so tag setters report which tags
|
||||
actually resolved.
|
||||
- GameplayTagsManager is not exposed to Python; tag registration goes through
|
||||
Config/DefaultGameplayTags.ini and needs an editor restart to take effect.
|
||||
"""
|
||||
import unreal
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import traceback
|
||||
|
||||
_OP_MAP = {
|
||||
"add_base": "AddBase",
|
||||
"add_final": "AddFinal",
|
||||
"multiply_additive": "MultiplyAdditive",
|
||||
"multiply_compound": "MultiplyCompound",
|
||||
"divide_additive": "DivideAdditive",
|
||||
"override": "Override",
|
||||
}
|
||||
|
||||
_DURATION_MAP = {
|
||||
"instant": "INSTANT",
|
||||
"has_duration": "HAS_DURATION",
|
||||
"infinite": "INFINITE",
|
||||
}
|
||||
|
||||
|
||||
def _plugin_missing():
|
||||
if not hasattr(unreal, "GameplayAbility"):
|
||||
return json.dumps({"success": False,
|
||||
"message": "Requires the GameplayAbilities plugin. Enable it in Edit > Plugins and restart."})
|
||||
return None
|
||||
|
||||
|
||||
def _split_asset_path(asset_path: str):
|
||||
asset_path = asset_path.rstrip("/")
|
||||
idx = asset_path.rfind("/")
|
||||
return asset_path[idx + 1:], asset_path[:idx]
|
||||
|
||||
|
||||
def _load_cdo(asset_path: str, expected_class, label: str):
|
||||
bp = unreal.EditorAssetLibrary.load_asset(asset_path)
|
||||
if not bp:
|
||||
raise FileNotFoundError(f"Blueprint not found at path: {asset_path}")
|
||||
if not isinstance(bp, unreal.Blueprint):
|
||||
raise TypeError(f"Asset at {asset_path} is not a Blueprint, but {type(bp).__name__}")
|
||||
gen = bp.generated_class()
|
||||
cdo = unreal.get_default_object(gen)
|
||||
if not isinstance(cdo, expected_class):
|
||||
raise TypeError(f"Blueprint at {asset_path} is not a {label} (CDO is {type(cdo).__name__}).")
|
||||
return bp, cdo
|
||||
|
||||
|
||||
def _create_gas_blueprint(asset_path: str, parent_class):
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
|
||||
raise FileExistsError(f"Asset already exists: {asset_path}")
|
||||
name, package = _split_asset_path(asset_path)
|
||||
factory = unreal.BlueprintFactory()
|
||||
factory.set_editor_property("parent_class", parent_class)
|
||||
bp = unreal.AssetToolsHelpers.get_asset_tools().create_asset(name, package, unreal.Blueprint, factory)
|
||||
if not bp:
|
||||
raise RuntimeError(f"Failed to create blueprint at {asset_path}.")
|
||||
return bp
|
||||
|
||||
|
||||
def _tag_container_from(tags):
|
||||
"""Build a GameplayTagContainer from tag name strings.
|
||||
Returns (container, resolved, unresolved) — unregistered tags do not import."""
|
||||
container = unreal.GameplayTagContainer()
|
||||
resolved, unresolved = [], []
|
||||
for name in tags:
|
||||
tag = unreal.GameplayTag()
|
||||
tag.import_text(str(name))
|
||||
# Unregistered tags import with TagName 'None' (the literal string).
|
||||
imported = str(tag.get_editor_property("tag_name"))
|
||||
if imported and imported != "None":
|
||||
resolved.append(str(name))
|
||||
else:
|
||||
unresolved.append(str(name))
|
||||
if resolved:
|
||||
inner = ",".join(f'(TagName="{t}")' for t in resolved)
|
||||
container.import_text(f"(GameplayTags=({inner}))")
|
||||
return container, resolved, unresolved
|
||||
|
||||
|
||||
def _tags_ini_path():
|
||||
return os.path.join(unreal.Paths.project_config_dir(), "DefaultGameplayTags.ini")
|
||||
|
||||
|
||||
# --- GameplayAbility ------------------------------------------------------------
|
||||
|
||||
def ue_create_ability_blueprint(asset_path: str = None, parent_class_path: str = None) -> str:
|
||||
"""Creates a GameplayAbility blueprint; parent_class_path may point at a custom GA subclass (requires the GameplayAbilities plugin)."""
|
||||
guard = _plugin_missing()
|
||||
if guard:
|
||||
return guard
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
parent = unreal.GameplayAbility
|
||||
if parent_class_path:
|
||||
parent = unreal.load_class(None, parent_class_path)
|
||||
if not parent:
|
||||
return json.dumps({"success": False, "message": f"Could not load class: {parent_class_path}"})
|
||||
bp = _create_gas_blueprint(asset_path, parent)
|
||||
cdo = unreal.get_default_object(bp.generated_class())
|
||||
if not isinstance(cdo, unreal.GameplayAbility):
|
||||
unreal.EditorAssetLibrary.delete_asset(asset_path)
|
||||
return json.dumps({"success": False, "message": f"{parent_class_path} is not a GameplayAbility subclass."})
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(bp)
|
||||
parent_path = (parent.get_path_name() if isinstance(parent, unreal.Class)
|
||||
else parent.static_class().get_path_name())
|
||||
return json.dumps({"success": True, "asset_path": asset_path,
|
||||
"parent_class": parent_path})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_get_ability_info(asset_path: str = None) -> str:
|
||||
"""Returns parent class, ability tags, and cost/cooldown effect classes of a GameplayAbility blueprint (requires the GameplayAbilities plugin)."""
|
||||
guard = _plugin_missing()
|
||||
if guard:
|
||||
return guard
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
bp, cdo = _load_cdo(asset_path, unreal.GameplayAbility, "GameplayAbility")
|
||||
tags = re.findall(r'TagName="([^"]+)"', cdo.get_editor_property("ability_tags").export_text())
|
||||
cost = cdo.get_editor_property("cost_gameplay_effect_class")
|
||||
cooldown = cdo.get_editor_property("cooldown_gameplay_effect_class")
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"asset_path": asset_path,
|
||||
"parent_class": bp.generated_class().get_path_name(),
|
||||
"ability_tags": tags,
|
||||
"cost_effect": cost.get_path_name() if cost else None,
|
||||
"cooldown_effect": cooldown.get_path_name() if cooldown else None,
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_set_ability_tags(asset_path: str = None, tags: list = None) -> str:
|
||||
"""Sets the AbilityTags container on a GameplayAbility; unregistered tags are reported, not silently dropped (requires the GameplayAbilities plugin)."""
|
||||
guard = _plugin_missing()
|
||||
if guard:
|
||||
return guard
|
||||
if asset_path is None or tags is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, tags (list of tag names)."})
|
||||
try:
|
||||
bp, cdo = _load_cdo(asset_path, unreal.GameplayAbility, "GameplayAbility")
|
||||
container, resolved, unresolved = _tag_container_from(tags)
|
||||
cdo.set_editor_property("ability_tags", container)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(bp)
|
||||
result = {"success": True, "asset_path": asset_path,
|
||||
"resolved_tags": resolved, "unresolved_tags": unresolved}
|
||||
if unresolved:
|
||||
result["message"] = ("Some tags are not registered in the project tag table "
|
||||
"(add them via add_gameplay_tag and restart the editor).")
|
||||
return json.dumps(result)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_set_ability_costs(asset_path: str = None, cost_effect_path: str = None,
|
||||
cooldown_effect_path: str = None) -> str:
|
||||
"""Wires cost and/or cooldown GameplayEffect blueprints onto a GameplayAbility (requires the GameplayAbilities plugin)."""
|
||||
guard = _plugin_missing()
|
||||
if guard:
|
||||
return guard
|
||||
if asset_path is None or (cost_effect_path is None and cooldown_effect_path is None):
|
||||
return json.dumps({"success": False,
|
||||
"message": "Required: asset_path and at least one of cost_effect_path / cooldown_effect_path."})
|
||||
try:
|
||||
bp, cdo = _load_cdo(asset_path, unreal.GameplayAbility, "GameplayAbility")
|
||||
applied = {}
|
||||
for prop, path in (("cost_gameplay_effect_class", cost_effect_path),
|
||||
("cooldown_gameplay_effect_class", cooldown_effect_path)):
|
||||
if path:
|
||||
_, effect_cdo = _load_cdo(path, unreal.GameplayEffect, "GameplayEffect")
|
||||
cdo.set_editor_property(prop, effect_cdo.get_class())
|
||||
applied[prop] = path
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(bp)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "applied": applied})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
# --- GameplayEffect -------------------------------------------------------------
|
||||
|
||||
def ue_create_effect_blueprint(asset_path: str = None, duration_policy: str = "instant",
|
||||
duration_seconds: float = None) -> str:
|
||||
"""Creates a GameplayEffect blueprint with a duration policy: instant, has_duration (+seconds), or infinite (requires the GameplayAbilities plugin)."""
|
||||
guard = _plugin_missing()
|
||||
if guard:
|
||||
return guard
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
key = (duration_policy or "instant").lower()
|
||||
if key not in _DURATION_MAP:
|
||||
return json.dumps({"success": False, "message": f"Unknown duration_policy '{duration_policy}'.",
|
||||
"valid_policies": list(_DURATION_MAP)})
|
||||
try:
|
||||
bp = _create_gas_blueprint(asset_path, unreal.GameplayEffect)
|
||||
cdo = unreal.get_default_object(bp.generated_class())
|
||||
cdo.set_editor_property("duration_policy",
|
||||
getattr(unreal.GameplayEffectDurationType, _DURATION_MAP[key]))
|
||||
if key == "has_duration" and duration_seconds is not None:
|
||||
mm = unreal.GameplayEffectModifierMagnitude()
|
||||
mm.import_text(f"(MagnitudeCalculationType=ScalableFloat,"
|
||||
f"ScalableFloatMagnitude=(Value={float(duration_seconds)}))")
|
||||
cdo.set_editor_property("duration_magnitude", mm)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(bp)
|
||||
return json.dumps({"success": True, "asset_path": asset_path,
|
||||
"duration_policy": key, "duration_seconds": duration_seconds})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_get_effect_info(asset_path: str = None) -> str:
|
||||
"""Returns duration policy/seconds and decoded modifiers of a GameplayEffect blueprint (requires the GameplayAbilities plugin)."""
|
||||
guard = _plugin_missing()
|
||||
if guard:
|
||||
return guard
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
bp, cdo = _load_cdo(asset_path, unreal.GameplayEffect, "GameplayEffect")
|
||||
policy = cdo.get_editor_property("duration_policy")
|
||||
policy_name = getattr(policy, "name", str(policy)).lower()
|
||||
duration = None
|
||||
if policy_name == "has_duration":
|
||||
m = re.search(r"Value=([\d.]+)", cdo.get_editor_property("duration_magnitude").export_text())
|
||||
duration = float(m.group(1)) if m else None
|
||||
rev_op = {v: k for k, v in _OP_MAP.items()}
|
||||
modifiers = []
|
||||
for mod in cdo.get_editor_property("modifiers"):
|
||||
text = mod.export_text()
|
||||
attr = re.search(r'AttributeName="([^"]+)"', text)
|
||||
attr_set = re.search(r"Attribute=([^,)]+):", text)
|
||||
op = re.search(r"ModifierOp=(\w+)", text)
|
||||
mag = re.search(r"ScalableFloatMagnitude=\(Value=([\d.-]+)", text)
|
||||
modifiers.append({
|
||||
"attribute": attr.group(1) if attr else None,
|
||||
"attribute_set": attr_set.group(1) if attr_set else None,
|
||||
"op": rev_op.get(op.group(1), op.group(1)) if op else "add_base",
|
||||
"magnitude": float(mag.group(1)) if mag else None,
|
||||
})
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"asset_path": asset_path,
|
||||
"duration_policy": policy_name,
|
||||
"duration_seconds": duration,
|
||||
"modifiers": modifiers,
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_set_effect_duration(asset_path: str = None, duration_policy: str = None,
|
||||
duration_seconds: float = None) -> str:
|
||||
"""Changes a GameplayEffect's duration policy (instant / has_duration+seconds / infinite) (requires the GameplayAbilities plugin)."""
|
||||
guard = _plugin_missing()
|
||||
if guard:
|
||||
return guard
|
||||
if asset_path is None or duration_policy is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, duration_policy."})
|
||||
key = duration_policy.lower()
|
||||
if key not in _DURATION_MAP:
|
||||
return json.dumps({"success": False, "message": f"Unknown duration_policy '{duration_policy}'.",
|
||||
"valid_policies": list(_DURATION_MAP)})
|
||||
try:
|
||||
bp, cdo = _load_cdo(asset_path, unreal.GameplayEffect, "GameplayEffect")
|
||||
cdo.set_editor_property("duration_policy",
|
||||
getattr(unreal.GameplayEffectDurationType, _DURATION_MAP[key]))
|
||||
if key == "has_duration" and duration_seconds is not None:
|
||||
mm = unreal.GameplayEffectModifierMagnitude()
|
||||
mm.import_text(f"(MagnitudeCalculationType=ScalableFloat,"
|
||||
f"ScalableFloatMagnitude=(Value={float(duration_seconds)}))")
|
||||
cdo.set_editor_property("duration_magnitude", mm)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(bp)
|
||||
return json.dumps({"success": True, "asset_path": asset_path,
|
||||
"duration_policy": key, "duration_seconds": duration_seconds})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_add_effect_modifier(asset_path: str = None, attribute_set_path: str = None,
|
||||
attribute_name: str = None, op: str = "add_base",
|
||||
magnitude: float = 1.0) -> str:
|
||||
"""Appends an attribute modifier (e.g. Health add_base +25) to a GameplayEffect. attribute_set_path is the AttributeSet class path (requires the GameplayAbilities plugin)."""
|
||||
guard = _plugin_missing()
|
||||
if guard:
|
||||
return guard
|
||||
if asset_path is None or attribute_set_path is None or attribute_name is None:
|
||||
return json.dumps({"success": False,
|
||||
"message": "Required: asset_path, attribute_set_path, attribute_name."})
|
||||
op_key = (op or "add_base").lower()
|
||||
if op_key not in _OP_MAP:
|
||||
return json.dumps({"success": False, "message": f"Unknown op '{op}'.", "valid_ops": list(_OP_MAP)})
|
||||
try:
|
||||
attr_set_class = unreal.load_class(None, attribute_set_path)
|
||||
if not attr_set_class:
|
||||
return json.dumps({"success": False, "message": f"AttributeSet class not found: {attribute_set_path}"})
|
||||
try:
|
||||
unreal.get_default_object(attr_set_class).get_editor_property(attribute_name)
|
||||
except Exception:
|
||||
return json.dumps({"success": False,
|
||||
"message": f"Attribute '{attribute_name}' not found on {attribute_set_path}."})
|
||||
bp, cdo = _load_cdo(asset_path, unreal.GameplayEffect, "GameplayEffect")
|
||||
# GameplayModifierInfo.attribute is edit-defaults-only, so the whole
|
||||
# struct is assembled via import_text instead of per-property setters.
|
||||
mod = unreal.GameplayModifierInfo()
|
||||
mod.import_text(
|
||||
f'(Attribute=(AttributeName="{attribute_name}",'
|
||||
f'Attribute={attribute_set_path}:{attribute_name}),'
|
||||
f'ModifierOp={_OP_MAP[op_key]},'
|
||||
f'ModifierMagnitude=(MagnitudeCalculationType=ScalableFloat,'
|
||||
f'ScalableFloatMagnitude=(Value={float(magnitude)})))')
|
||||
mods = list(cdo.get_editor_property("modifiers"))
|
||||
mods.append(mod)
|
||||
cdo.set_editor_property("modifiers", mods)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(bp)
|
||||
return json.dumps({"success": True, "asset_path": asset_path,
|
||||
"attribute": attribute_name, "op": op_key,
|
||||
"magnitude": float(magnitude), "modifier_count": len(mods)})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_clear_effect_modifiers(asset_path: str = None) -> str:
|
||||
"""Removes all modifiers from a GameplayEffect blueprint (requires the GameplayAbilities plugin)."""
|
||||
guard = _plugin_missing()
|
||||
if guard:
|
||||
return guard
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
bp, cdo = _load_cdo(asset_path, unreal.GameplayEffect, "GameplayEffect")
|
||||
cdo.set_editor_property("modifiers", [])
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(bp)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "modifier_count": 0})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
# --- GameplayTags ---------------------------------------------------------------
|
||||
|
||||
def ue_list_gameplay_tags(prefix: str = "") -> str:
|
||||
"""Lists gameplay tags registered in Config/DefaultGameplayTags.ini, optionally filtered by prefix (requires the GameplayAbilities plugin)."""
|
||||
guard = _plugin_missing()
|
||||
if guard:
|
||||
return guard
|
||||
try:
|
||||
ini = _tags_ini_path()
|
||||
tags = []
|
||||
if os.path.isfile(ini):
|
||||
with open(ini, "r", encoding="utf-8") as f:
|
||||
tags = re.findall(r'\+GameplayTagList=\(Tag="([^"]+)"', f.read())
|
||||
if prefix:
|
||||
tags = [t for t in tags if t.startswith(prefix)]
|
||||
return json.dumps({"success": True, "count": len(tags), "tags": sorted(tags),
|
||||
"ini_path": ini})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_add_gameplay_tag(tag: str = None, comment: str = "") -> str:
|
||||
"""Registers a gameplay tag in Config/DefaultGameplayTags.ini — takes effect after an editor restart (requires the GameplayAbilities plugin)."""
|
||||
guard = _plugin_missing()
|
||||
if guard:
|
||||
return guard
|
||||
if not tag:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'tag' is missing."})
|
||||
if not re.fullmatch(r"[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)*", tag):
|
||||
return json.dumps({"success": False,
|
||||
"message": f"Invalid tag '{tag}' (use Dot.Separated.Alphanumerics)."})
|
||||
try:
|
||||
ini = _tags_ini_path()
|
||||
section = "[/Script/GameplayTags.GameplayTagsSettings]"
|
||||
entry = f'+GameplayTagList=(Tag="{tag}",DevComment="{comment}")'
|
||||
content = ""
|
||||
if os.path.isfile(ini):
|
||||
with open(ini, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
if f'Tag="{tag}"' in content:
|
||||
return json.dumps({"success": False, "message": f"Tag '{tag}' is already registered."})
|
||||
if section in content:
|
||||
content = content.replace(section, f"{section}\n{entry}", 1)
|
||||
else:
|
||||
content = (content.rstrip() + f"\n\n{section}\n{entry}\n") if content.strip() else f"{section}\n{entry}\n"
|
||||
with open(ini, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
return json.dumps({"success": True, "tag": tag, "ini_path": ini,
|
||||
"message": "Tag registered in config. Restart the editor for it to become usable."})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
@@ -0,0 +1,93 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
"""Python action functions for editor Layers (LayersSubsystem)."""
|
||||
import unreal
|
||||
import json
|
||||
import traceback
|
||||
|
||||
|
||||
def _layers():
|
||||
return unreal.get_editor_subsystem(unreal.LayersSubsystem)
|
||||
|
||||
|
||||
def _actor_by_label(actor_label: str):
|
||||
sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
for a in sub.get_all_level_actors():
|
||||
if a.get_actor_label() == actor_label:
|
||||
return a
|
||||
return None
|
||||
|
||||
|
||||
def ue_list_layers() -> str:
|
||||
"""Lists all layer names in the current world."""
|
||||
try:
|
||||
# add_all_layer_names_to() takes no args and returns the Array[Name].
|
||||
names = _layers().add_all_layer_names_to()
|
||||
return json.dumps({"success": True, "layers": [str(n) for n in names]})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_create_layer(layer_name: str = None) -> str:
|
||||
"""Creates a new (empty) layer."""
|
||||
if layer_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'layer_name' is missing."})
|
||||
try:
|
||||
_layers().create_layer(unreal.Name(layer_name))
|
||||
return json.dumps({"success": True, "layer_name": layer_name})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_delete_layer(layer_name: str = None) -> str:
|
||||
"""Deletes a layer."""
|
||||
if layer_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'layer_name' is missing."})
|
||||
try:
|
||||
ls = _layers()
|
||||
if not ls.is_layer(unreal.Name(layer_name)):
|
||||
return json.dumps({"success": False, "message": f"Layer '{layer_name}' does not exist."})
|
||||
ls.delete_layer(unreal.Name(layer_name))
|
||||
return json.dumps({"success": True, "deleted": layer_name})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_add_actor_to_layer(actor_label: str = None, layer_name: str = None) -> str:
|
||||
"""Adds an actor to a layer (creates the layer if needed)."""
|
||||
if actor_label is None or layer_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: actor_label, layer_name."})
|
||||
try:
|
||||
actor = _actor_by_label(actor_label)
|
||||
if not actor:
|
||||
return json.dumps({"success": False, "message": f"Actor not found: {actor_label}"})
|
||||
ok = _layers().add_actor_to_layer(actor, unreal.Name(layer_name))
|
||||
return json.dumps({"success": bool(ok), "actor_label": actor_label, "layer_name": layer_name})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_remove_actor_from_layer(actor_label: str = None, layer_name: str = None) -> str:
|
||||
"""Removes an actor from a layer."""
|
||||
if actor_label is None or layer_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: actor_label, layer_name."})
|
||||
try:
|
||||
actor = _actor_by_label(actor_label)
|
||||
if not actor:
|
||||
return json.dumps({"success": False, "message": f"Actor not found: {actor_label}"})
|
||||
ok = _layers().remove_actor_from_layer(actor, unreal.Name(layer_name))
|
||||
return json.dumps({"success": bool(ok), "actor_label": actor_label, "layer_name": layer_name})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_get_actors_in_layer(layer_name: str = None) -> str:
|
||||
"""Lists the labels of actors assigned to a layer."""
|
||||
if layer_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'layer_name' is missing."})
|
||||
try:
|
||||
actors = _layers().get_actors_from_layer(unreal.Name(layer_name))
|
||||
return json.dumps({"success": True, "layer_name": layer_name,
|
||||
"actors": [a.get_actor_label() for a in actors]})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
@@ -0,0 +1,144 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
import unreal
|
||||
import json
|
||||
import traceback
|
||||
|
||||
|
||||
|
||||
def ue_create_level(level_path: str = None) -> str:
|
||||
"""Creates a new empty level and saves it at the given content-browser path."""
|
||||
if not level_path:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'level_path' is missing."})
|
||||
try:
|
||||
success = unreal.EditorLevelLibrary.new_level(level_path)
|
||||
if not success:
|
||||
return json.dumps({"success": False, "message": f"EditorLevelLibrary.new_level() returned False for '{level_path}'."})
|
||||
return json.dumps({"success": True, "level_path": level_path, "message": f"Level created at '{level_path}'."})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_load_level(level_path: str = None) -> str:
|
||||
"""Opens (loads) an existing level in the editor."""
|
||||
if not level_path:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'level_path' is missing."})
|
||||
try:
|
||||
success = unreal.EditorLevelLibrary.load_level(level_path)
|
||||
if not success:
|
||||
return json.dumps({"success": False, "message": f"EditorLevelLibrary.load_level() returned False for '{level_path}'."})
|
||||
return json.dumps({"success": True, "level_path": level_path, "message": f"Level '{level_path}' loaded."})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_list_level_actors(class_filter: str = None) -> str:
|
||||
"""
|
||||
Lists all actors in the current level.
|
||||
Optional class_filter is a partial class name (e.g. 'StaticMeshActor', 'BP_Bird').
|
||||
"""
|
||||
try:
|
||||
actors = unreal.EditorLevelLibrary.get_all_level_actors()
|
||||
result = []
|
||||
for actor in actors:
|
||||
class_name = actor.get_class().get_name()
|
||||
if class_filter and class_filter.lower() not in class_name.lower():
|
||||
continue
|
||||
result.append({
|
||||
"name": actor.get_name(),
|
||||
"label": actor.get_actor_label(),
|
||||
"class": class_name,
|
||||
"location": {
|
||||
"x": actor.get_actor_location().x,
|
||||
"y": actor.get_actor_location().y,
|
||||
"z": actor.get_actor_location().z,
|
||||
},
|
||||
"path": actor.get_path_name(),
|
||||
})
|
||||
return json.dumps({"success": True, "actors": result, "count": len(result)})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_set_world_settings(gravity: float = None, time_dilation: float = None) -> str:
|
||||
"""
|
||||
Modifies WorldSettings of the current level.
|
||||
gravity sets GlobalGravityZ (negative = downward, e.g. -980).
|
||||
time_dilation sets the global time dilation multiplier.
|
||||
"""
|
||||
try:
|
||||
world = unreal.EditorLevelLibrary.get_editor_world()
|
||||
if world is None:
|
||||
return json.dumps({"success": False, "message": "No editor world available."})
|
||||
ws = world.get_world_settings()
|
||||
if ws is None:
|
||||
return json.dumps({"success": False, "message": "Could not get WorldSettings."})
|
||||
|
||||
applied = {}
|
||||
|
||||
if gravity is not None:
|
||||
for prop in ['global_gravity_z', 'GlobalGravityZ']:
|
||||
try:
|
||||
# Must also enable custom gravity
|
||||
for flag in ['global_gravity', 'bGlobalGravity', 'override_world_gravity', 'bOverrideWorldGravity']:
|
||||
try:
|
||||
ws.set_editor_property(flag, True)
|
||||
except Exception:
|
||||
pass
|
||||
ws.set_editor_property(prop, gravity)
|
||||
applied['gravity'] = gravity
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if time_dilation is not None:
|
||||
for prop in ['world_to_meters', 'TimeDilation', 'time_dilation']:
|
||||
try:
|
||||
ws.set_editor_property(prop, time_dilation)
|
||||
applied['time_dilation'] = time_dilation
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not applied:
|
||||
return json.dumps({"success": False, "message": "No settings were applied. Check property names for this UE version."})
|
||||
|
||||
unreal.EditorLevelLibrary.save_current_level()
|
||||
return json.dumps({"success": True, "applied": applied, "message": "World settings updated."})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
|
||||
# --- Current level info / saving ----------------------------------------------
|
||||
|
||||
def ue_get_current_level_path() -> str:
|
||||
"""Returns the path of the currently open editor world/level."""
|
||||
try:
|
||||
world = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_editor_world()
|
||||
if not world:
|
||||
return json.dumps({"success": False, "message": "No editor world is open."})
|
||||
return json.dumps({"success": True, "level_path": world.get_path_name(),
|
||||
"level_name": world.get_name()})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_save_current_level() -> str:
|
||||
"""Saves the currently open level. Returns success=False for an unsaved/untitled level."""
|
||||
try:
|
||||
ok = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem).save_current_level()
|
||||
return json.dumps({"success": bool(ok),
|
||||
"message": "Saved current level." if ok else "save_current_level returned False (untitled or unsaved level)."})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_save_all_levels() -> str:
|
||||
"""Saves all dirty levels."""
|
||||
try:
|
||||
ok = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem).save_all_dirty_levels()
|
||||
return json.dumps({"success": bool(ok),
|
||||
"message": "Saved all dirty levels." if ok else "save_all_dirty_levels returned False (nothing to save)."})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
@@ -0,0 +1,399 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
"""
|
||||
Python action functions for Level Sequence (cinematic) editing in Unreal Engine.
|
||||
|
||||
Bindings are referenced by name (the World Outliner / display name). Several
|
||||
LevelSequence methods are deprecated in favor of the Level Sequence Editor
|
||||
Subsystem, but the direct methods work headlessly and return properly-named
|
||||
bindings, whereas the subsystem variants require a focused editor sequence and
|
||||
return unnamed bindings. We use the direct methods and suppress the Python
|
||||
DeprecationWarning so it never corrupts the JSON response.
|
||||
"""
|
||||
import unreal
|
||||
import json
|
||||
import traceback
|
||||
import warnings
|
||||
import contextlib
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _suppress():
|
||||
"""Suppress the DeprecationWarning that some LevelSequence methods emit
|
||||
(it would otherwise corrupt the JSON response on the wire)."""
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
yield
|
||||
|
||||
|
||||
def _load_sequence(asset_path: str):
|
||||
if not asset_path:
|
||||
raise ValueError("Sequence path cannot be empty.")
|
||||
seq = unreal.EditorAssetLibrary.load_asset(asset_path)
|
||||
if not seq:
|
||||
raise FileNotFoundError(f"Level Sequence not found at path: {asset_path}")
|
||||
if not isinstance(seq, unreal.LevelSequence):
|
||||
raise TypeError(f"Asset at {asset_path} is not a LevelSequence, but {type(seq).__name__}")
|
||||
return seq
|
||||
|
||||
|
||||
def _fps(seq) -> float:
|
||||
rate = seq.get_display_rate()
|
||||
return float(rate.numerator) / float(rate.denominator or 1)
|
||||
|
||||
|
||||
def _find_binding(seq, binding_name: str):
|
||||
binding = seq.find_binding_by_name(binding_name)
|
||||
if not binding or not binding.is_valid():
|
||||
names = [b.get_name() for b in seq.get_bindings()]
|
||||
raise ValueError(f"Binding '{binding_name}' not found. Available: {names}")
|
||||
return binding
|
||||
|
||||
|
||||
def _spawnable_names(seq):
|
||||
return {b.get_name() for b in seq.get_spawnables()}
|
||||
|
||||
|
||||
def _split_asset_path(asset_path: str):
|
||||
asset_path = asset_path.rstrip("/")
|
||||
idx = asset_path.rfind("/")
|
||||
return asset_path[idx + 1:], asset_path[:idx]
|
||||
|
||||
|
||||
def _get_or_create_transform_section(seq, binding):
|
||||
"""
|
||||
Return the first MovieScene3DTransformTrack section on a binding, creating it
|
||||
if needed. A freshly created section is zero-length, so ensure it spans at
|
||||
least the sequence's playback range — otherwise keys land outside the section
|
||||
and are invisible/unusable in Sequencer.
|
||||
"""
|
||||
section = None
|
||||
for track in binding.get_tracks():
|
||||
if isinstance(track, unreal.MovieScene3DTransformTrack):
|
||||
sections = track.get_sections()
|
||||
section = sections[0] if sections else track.add_section()
|
||||
break
|
||||
if section is None:
|
||||
track = binding.add_track(unreal.MovieScene3DTransformTrack)
|
||||
section = track.add_section()
|
||||
|
||||
start = seq.get_playback_start_seconds()
|
||||
end = seq.get_playback_end_seconds()
|
||||
if section.get_end_frame_seconds() - section.get_start_frame_seconds() < (end - start):
|
||||
section.set_range_seconds(start, end)
|
||||
return section
|
||||
|
||||
|
||||
# --- Actions ------------------------------------------------------------------
|
||||
|
||||
def ue_create_level_sequence(asset_path: str = None, fps: float = 30.0, duration_seconds: float = 5.0) -> str:
|
||||
"""Creates a Level Sequence asset with the given frame rate and playback duration."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
|
||||
return json.dumps({"success": False, "message": f"Asset already exists: {asset_path}"})
|
||||
name, package = _split_asset_path(asset_path)
|
||||
with _suppress():
|
||||
tools = unreal.AssetToolsHelpers.get_asset_tools()
|
||||
seq = tools.create_asset(name, package, unreal.LevelSequence, unreal.LevelSequenceFactoryNew())
|
||||
if not seq:
|
||||
return json.dumps({"success": False, "message": f"Failed to create level sequence at '{asset_path}'."})
|
||||
seq.set_display_rate(unreal.FrameRate(int(fps), 1))
|
||||
seq.set_playback_start_seconds(0.0)
|
||||
seq.set_playback_end_seconds(float(duration_seconds))
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(seq)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "fps": fps,
|
||||
"duration_seconds": duration_seconds,
|
||||
"message": f"Level Sequence created at '{asset_path}'."})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_get_sequence_info(asset_path: str = None) -> str:
|
||||
"""Returns frame rate, playback range (seconds), and the bindings of a Level Sequence."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
with _suppress():
|
||||
seq = _load_sequence(asset_path)
|
||||
spawnable = _spawnable_names(seq)
|
||||
bindings = [{"name": b.get_name(),
|
||||
"type": "spawnable" if b.get_name() in spawnable else "possessable",
|
||||
"track_count": len(b.get_tracks())}
|
||||
for b in seq.get_bindings()]
|
||||
info = {
|
||||
"success": True,
|
||||
"asset_path": asset_path,
|
||||
"fps": _fps(seq),
|
||||
"playback_start_seconds": seq.get_playback_start_seconds(),
|
||||
"playback_end_seconds": seq.get_playback_end_seconds(),
|
||||
"bindings": bindings,
|
||||
}
|
||||
return json.dumps(info)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_set_playback_range(asset_path: str = None, start_seconds: float = None, end_seconds: float = None) -> str:
|
||||
"""Sets the playback range (in seconds) of a Level Sequence."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if start_seconds is None or end_seconds is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters 'start_seconds' and 'end_seconds' are missing."})
|
||||
try:
|
||||
with _suppress():
|
||||
seq = _load_sequence(asset_path)
|
||||
seq.set_playback_start_seconds(float(start_seconds))
|
||||
seq.set_playback_end_seconds(float(end_seconds))
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(seq)
|
||||
return json.dumps({"success": True, "asset_path": asset_path,
|
||||
"playback_start_seconds": float(start_seconds),
|
||||
"playback_end_seconds": float(end_seconds)})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_add_spawnable_from_class(asset_path: str = None, class_path: str = None) -> str:
|
||||
"""Adds a spawnable binding from a class path (e.g. '/Script/CinematicCamera.CineCameraActor'). Returns the binding name."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if class_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'class_path' is missing."})
|
||||
try:
|
||||
actor_class = unreal.load_class(None, class_path)
|
||||
if not actor_class:
|
||||
return json.dumps({"success": False, "message": f"Could not load class: {class_path}"})
|
||||
with _suppress():
|
||||
seq = _load_sequence(asset_path)
|
||||
binding = seq.add_spawnable_from_class(actor_class)
|
||||
if not binding or not binding.is_valid():
|
||||
return json.dumps({"success": False, "message": f"Failed to add spawnable for {class_path}."})
|
||||
binding_name = binding.get_name()
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(seq)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "binding_name": binding_name,
|
||||
"message": f"Added spawnable '{binding_name}'."})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_add_possessable(asset_path: str = None, actor_label: str = None) -> str:
|
||||
"""Adds a possessable binding for an existing level actor (by World Outliner label). Returns the binding name."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if actor_label is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'actor_label' is missing."})
|
||||
try:
|
||||
es = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
actor = next((a for a in es.get_all_level_actors() if a.get_actor_label() == actor_label), None)
|
||||
if not actor:
|
||||
return json.dumps({"success": False, "message": f"Actor not found in level: '{actor_label}'."})
|
||||
with _suppress():
|
||||
seq = _load_sequence(asset_path)
|
||||
binding = seq.add_possessable(actor)
|
||||
if not binding or not binding.is_valid():
|
||||
return json.dumps({"success": False, "message": f"Failed to add possessable for '{actor_label}'."})
|
||||
binding_name = binding.get_name()
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(seq)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "binding_name": binding_name,
|
||||
"message": f"Added possessable '{binding_name}'."})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_remove_binding(asset_path: str = None, binding_name: str = None) -> str:
|
||||
"""Removes a binding (spawnable or possessable) from a Level Sequence by name."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if binding_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'binding_name' is missing."})
|
||||
try:
|
||||
with _suppress():
|
||||
seq = _load_sequence(asset_path)
|
||||
binding = _find_binding(seq, binding_name)
|
||||
binding.remove()
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(seq)
|
||||
remaining = [b.get_name() for b in seq.get_bindings()]
|
||||
return json.dumps({"success": True, "asset_path": asset_path,
|
||||
"removed": binding_name, "remaining_bindings": remaining})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_add_transform_track(asset_path: str = None, binding_name: str = None) -> str:
|
||||
"""Adds a 3D transform track (with one section) to a binding."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if binding_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'binding_name' is missing."})
|
||||
try:
|
||||
with _suppress():
|
||||
seq = _load_sequence(asset_path)
|
||||
binding = _find_binding(seq, binding_name)
|
||||
_get_or_create_transform_section(seq, binding)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(seq)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "binding_name": binding_name,
|
||||
"message": f"Added transform track to '{binding_name}'."})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_add_transform_keyframe(asset_path: str = None, binding_name: str = None, time_seconds: float = None,
|
||||
location: list = None, rotation: list = None, scale: list = None) -> str:
|
||||
"""
|
||||
Adds a keyframe at time_seconds on a binding's transform track. Provide any of
|
||||
location [x,y,z], rotation [pitch,yaw,roll], scale [x,y,z]. A transform track
|
||||
is created if the binding doesn't have one.
|
||||
"""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if binding_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'binding_name' is missing."})
|
||||
if time_seconds is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'time_seconds' is missing."})
|
||||
if location is None and rotation is None and scale is None:
|
||||
return json.dumps({"success": False, "message": "Provide at least one of location / rotation / scale."})
|
||||
try:
|
||||
with _suppress():
|
||||
seq = _load_sequence(asset_path)
|
||||
binding = _find_binding(seq, binding_name)
|
||||
section = _get_or_create_transform_section(seq, binding)
|
||||
# Extend the section so the key is inside it (otherwise it's not visible).
|
||||
if float(time_seconds) > section.get_end_frame_seconds():
|
||||
section.set_range_seconds(section.get_start_frame_seconds(), float(time_seconds))
|
||||
elif float(time_seconds) < section.get_start_frame_seconds():
|
||||
section.set_range_seconds(float(time_seconds), section.get_end_frame_seconds())
|
||||
frame = unreal.FrameNumber(int(round(float(time_seconds) * _fps(seq))))
|
||||
# Channel order is fixed: 0-2 Location XYZ, 3-5 Rotation XYZ, 6-8 Scale XYZ.
|
||||
chans = section.get_all_channels()
|
||||
keyed = []
|
||||
for offset, values, label in ((0, location, "location"), (3, rotation, "rotation"), (6, scale, "scale")):
|
||||
if values is None:
|
||||
continue
|
||||
if len(values) != 3:
|
||||
return json.dumps({"success": False, "message": f"{label} must be a list of 3 floats."})
|
||||
for i in range(3):
|
||||
chans[offset + i].add_key(frame, float(values[i]))
|
||||
keyed.append(label)
|
||||
section_range = [round(section.get_start_frame_seconds(), 4),
|
||||
round(section.get_end_frame_seconds(), 4)]
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(seq)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "binding_name": binding_name,
|
||||
"frame": int(round(float(time_seconds) * _fps(seq))), "keyed": keyed,
|
||||
"section_range_seconds": section_range})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
# --- Sequencer editor (camera, anim tracks, open/close) ------------------------
|
||||
|
||||
def ue_open_in_sequencer(asset_path: str = None) -> str:
|
||||
"""Opens a Level Sequence in the Sequencer editor (and focuses it)."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
with _suppress():
|
||||
seq = _load_sequence(asset_path)
|
||||
ok = unreal.LevelSequenceEditorBlueprintLibrary.open_level_sequence(seq)
|
||||
return json.dumps({"success": bool(ok), "asset_path": asset_path})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_close_sequencer() -> str:
|
||||
"""Closes the Sequencer editor if one is open."""
|
||||
try:
|
||||
unreal.LevelSequenceEditorBlueprintLibrary.close_level_sequence()
|
||||
return json.dumps({"success": True})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_add_camera(asset_path: str = None, spawnable: bool = True) -> str:
|
||||
"""Adds a CineCamera to a Level Sequence with a Camera Cut track bound to it (official create_camera path)."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
with _suppress():
|
||||
seq = _load_sequence(asset_path)
|
||||
lsb = unreal.LevelSequenceEditorBlueprintLibrary
|
||||
if not lsb.open_level_sequence(seq):
|
||||
return json.dumps({"success": False, "message": "Could not open the sequence in Sequencer (create_camera requires a focused sequence)."})
|
||||
try:
|
||||
sub = unreal.get_editor_subsystem(unreal.LevelSequenceEditorSubsystem)
|
||||
binding, _cam_actor = sub.create_camera(spawnable=bool(spawnable))
|
||||
binding_name = binding.get_name()
|
||||
# Make sure the camera-cut section spans the playback range.
|
||||
start = seq.get_playback_start_seconds()
|
||||
end = seq.get_playback_end_seconds()
|
||||
for track in seq.get_tracks():
|
||||
if isinstance(track, unreal.MovieSceneCameraCutTrack):
|
||||
for sec in track.get_sections():
|
||||
sec.set_range_seconds(start, end)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(seq)
|
||||
finally:
|
||||
lsb.close_level_sequence()
|
||||
return json.dumps({"success": True, "asset_path": asset_path,
|
||||
"camera_binding": binding_name, "camera_cut_track": True,
|
||||
"spawnable": bool(spawnable)})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_add_anim_track(asset_path: str = None, binding_name: str = None, anim_path: str = None,
|
||||
start_seconds: float = None, end_seconds: float = None) -> str:
|
||||
"""Adds a skeletal-animation track playing anim_path on a binding (defaults to the playback range)."""
|
||||
if asset_path is None or binding_name is None or anim_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, binding_name, anim_path."})
|
||||
try:
|
||||
with _suppress():
|
||||
seq = _load_sequence(asset_path)
|
||||
binding = _find_binding(seq, binding_name)
|
||||
anim = unreal.EditorAssetLibrary.load_asset(anim_path)
|
||||
if not anim or not isinstance(anim, unreal.AnimSequenceBase):
|
||||
return json.dumps({"success": False, "message": f"Not an animation asset: {anim_path}"})
|
||||
track = binding.add_track(unreal.MovieSceneSkeletalAnimationTrack)
|
||||
sec = track.add_section()
|
||||
s = seq.get_playback_start_seconds() if start_seconds is None else float(start_seconds)
|
||||
e = seq.get_playback_end_seconds() if end_seconds is None else float(end_seconds)
|
||||
sec.set_range_seconds(s, e)
|
||||
params = sec.get_editor_property("params")
|
||||
params.set_editor_property("animation", anim)
|
||||
sec.set_editor_property("params", params)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(seq)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "binding_name": binding_name,
|
||||
"anim_path": anim_path, "range_seconds": [round(s, 3), round(e, 3)]})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_convert_binding(asset_path: str = None, binding_name: str = None, to: str = "spawnable") -> str:
|
||||
"""Converts a binding between possessable and spawnable. to='spawnable' or 'possessable'."""
|
||||
if asset_path is None or binding_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, binding_name."})
|
||||
mode = (to or "").lower()
|
||||
if mode not in ("spawnable", "possessable"):
|
||||
return json.dumps({"success": False, "message": "Parameter 'to' must be 'spawnable' or 'possessable'."})
|
||||
try:
|
||||
with _suppress():
|
||||
seq = _load_sequence(asset_path)
|
||||
binding = _find_binding(seq, binding_name)
|
||||
lsb = unreal.LevelSequenceEditorBlueprintLibrary
|
||||
if not lsb.open_level_sequence(seq):
|
||||
return json.dumps({"success": False, "message": "Could not open the sequence in Sequencer (conversion requires a focused sequence)."})
|
||||
try:
|
||||
sub = unreal.get_editor_subsystem(unreal.LevelSequenceEditorSubsystem)
|
||||
if mode == "spawnable":
|
||||
result = sub.convert_to_spawnable(binding)
|
||||
names = [b.get_name() for b in (result or [])]
|
||||
else:
|
||||
result = sub.convert_to_possessable(binding)
|
||||
names = [result.get_name()] if result else []
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(seq)
|
||||
finally:
|
||||
lsb.close_level_sequence()
|
||||
if not names:
|
||||
return json.dumps({"success": False, "message": f"Conversion to {mode} returned no binding."})
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "converted_to": mode, "bindings": names})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
@@ -0,0 +1,781 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
"""
|
||||
Defines Python action functions for material editing to be executed within Unreal Engine.
|
||||
"""
|
||||
import unreal
|
||||
import json
|
||||
import traceback
|
||||
from typing import Optional
|
||||
|
||||
# --- Helper Functions for Material Editing ---
|
||||
|
||||
def _get_material_asset(material_path: str):
|
||||
"""Helper to load a material asset."""
|
||||
if not material_path:
|
||||
raise ValueError("Material path cannot be empty.")
|
||||
material = unreal.EditorAssetLibrary.load_asset(material_path)
|
||||
if not material:
|
||||
raise FileNotFoundError(f"Material asset not found at path: {material_path}")
|
||||
if not isinstance(material, unreal.Material):
|
||||
raise TypeError(f"Asset at {material_path} is not a Material, but {type(material).__name__}")
|
||||
return material
|
||||
|
||||
def _get_material_instance_asset(instance_path: str):
|
||||
"""Helper to load a material instance constant asset."""
|
||||
if not instance_path:
|
||||
raise ValueError("Material instance path cannot be empty.")
|
||||
instance = unreal.EditorAssetLibrary.load_asset(instance_path)
|
||||
if not instance:
|
||||
raise FileNotFoundError(f"Material instance asset not found at path: {instance_path}")
|
||||
if not isinstance(instance, unreal.MaterialInstanceConstant):
|
||||
raise TypeError(f"Asset at {instance_path} is not a MaterialInstanceConstant, but {type(instance).__name__}")
|
||||
return instance
|
||||
|
||||
def _get_expression_class(class_name: str):
|
||||
"""Helper to get an Unreal MaterialExpression class by name."""
|
||||
try:
|
||||
# Common prefix for many material expressions if not found directly
|
||||
if not hasattr(unreal, class_name) and not class_name.startswith("MaterialExpression"):
|
||||
full_class_name = f"MaterialExpression{class_name}"
|
||||
else:
|
||||
full_class_name = class_name
|
||||
|
||||
expression_class = getattr(unreal, full_class_name)
|
||||
if not issubclass(expression_class, unreal.MaterialExpression):
|
||||
raise TypeError(f"{full_class_name} is not a MaterialExpression class.")
|
||||
return expression_class
|
||||
except AttributeError:
|
||||
raise ValueError(f"MaterialExpression class like '{class_name}' or '{full_class_name}' not found in 'unreal' module.")
|
||||
|
||||
def _find_material_expression_by_name_or_type(material: unreal.Material, expression_identifier: str, expression_class_name: str = None):
|
||||
"""
|
||||
Finds a material expression within a material by its description (name) or by its class type.
|
||||
"""
|
||||
|
||||
if not material or not isinstance(material, unreal.Material):
|
||||
raise ValueError("Invalid material provided.")
|
||||
|
||||
target_class = None
|
||||
if expression_class_name:
|
||||
try:
|
||||
target_class = _get_expression_class(expression_class_name)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
it = unreal.ObjectIterator()
|
||||
for x in it:
|
||||
if isinstance(x, unreal.MaterialExpression) and x.get_path_name().startswith(material.get_path_name()):
|
||||
if hasattr(x, 'desc') and x.desc == expression_identifier:
|
||||
if target_class and not isinstance(x, target_class):
|
||||
continue
|
||||
return x
|
||||
|
||||
if target_class and isinstance(x, target_class):
|
||||
if expression_identifier == expression_class_name or expression_identifier == target_class.__name__:
|
||||
return x
|
||||
|
||||
if expression_identifier == x.get_name():
|
||||
if target_class and not isinstance(x, target_class):
|
||||
continue
|
||||
return x
|
||||
|
||||
raise ValueError(f"MaterialExpression identified by '{expression_identifier}' (intended class: {expression_class_name or 'any'}) not found in material '{material.get_path_name()}'.")
|
||||
|
||||
# Friendly material-property name → unreal.MaterialProperty enum
|
||||
_MATERIAL_PROPERTY_MAP = {
|
||||
"BaseColor": "MP_BASE_COLOR",
|
||||
"Metallic": "MP_METALLIC",
|
||||
"Specular": "MP_SPECULAR",
|
||||
"Roughness": "MP_ROUGHNESS",
|
||||
"Anisotropy": "MP_ANISOTROPY",
|
||||
"EmissiveColor": "MP_EMISSIVE_COLOR",
|
||||
"Opacity": "MP_OPACITY",
|
||||
"OpacityMask": "MP_OPACITY_MASK",
|
||||
"Normal": "MP_NORMAL",
|
||||
"Tangent": "MP_TANGENT",
|
||||
"WorldPositionOffset": "MP_WORLD_POSITION_OFFSET",
|
||||
"SubsurfaceColor": "MP_SUBSURFACE_COLOR",
|
||||
"AmbientOcclusion": "MP_AMBIENT_OCCLUSION",
|
||||
"Refraction": "MP_REFRACTION",
|
||||
}
|
||||
|
||||
|
||||
def _split_asset_path(asset_path: str):
|
||||
"""'/Game/Foo/MyAsset' → ('MyAsset', '/Game/Foo')."""
|
||||
asset_path = asset_path.rstrip("/")
|
||||
idx = asset_path.rfind("/")
|
||||
return asset_path[idx + 1:], asset_path[:idx]
|
||||
|
||||
|
||||
# --- Material Editing Actions ---
|
||||
|
||||
def ue_create_expression(material_path: str = None, expression_class_name: str = None, node_pos_x: int = 0, node_pos_y: int = 0) -> str:
|
||||
"""
|
||||
Creates a new material expression node within the supplied material.
|
||||
Returns JSON string.
|
||||
"""
|
||||
if material_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'material_path' is missing."})
|
||||
if expression_class_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'expression_class_name' is missing."})
|
||||
|
||||
transaction_description = "MCP: Create Material Expression"
|
||||
try:
|
||||
material = _get_material_asset(material_path)
|
||||
expression_class = _get_expression_class(expression_class_name)
|
||||
|
||||
with unreal.ScopedEditorTransaction(transaction_description) as trans:
|
||||
new_expression = unreal.MaterialEditingLibrary.create_material_expression(
|
||||
material, expression_class, node_pos_x, node_pos_y
|
||||
)
|
||||
if not new_expression:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Failed to create MaterialExpression '{expression_class_name}' in '{material_path}'."
|
||||
})
|
||||
|
||||
if hasattr(new_expression, 'desc') and not new_expression.desc:
|
||||
new_expression.desc = expression_class_name
|
||||
|
||||
unreal.MaterialEditingLibrary.recompile_material(material)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(material)
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"message": f"Successfully created MaterialExpression '{expression_class_name}' in '{material_path}'.",
|
||||
"expression_name": new_expression.get_name(),
|
||||
"expression_desc": new_expression.desc if hasattr(new_expression, 'desc') else "N/A",
|
||||
"expression_class": new_expression.__class__.__name__
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Error creating material expression: {str(e)}",
|
||||
"traceback": traceback.format_exc()
|
||||
})
|
||||
|
||||
def ue_connect_expressions(
|
||||
material_path: str = None,
|
||||
from_expression_identifier: str = None,
|
||||
from_output_name: str = None,
|
||||
to_expression_identifier: str = None,
|
||||
to_input_name: str = None,
|
||||
from_expression_class_name: str = None,
|
||||
to_expression_class_name: str = None
|
||||
) -> str:
|
||||
"""
|
||||
Creates a connection between two material expressions.
|
||||
Returns JSON string.
|
||||
"""
|
||||
if material_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'material_path' is missing."})
|
||||
if from_expression_identifier is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'from_expression_identifier' is missing."})
|
||||
if from_output_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'from_output_name' is missing."})
|
||||
if to_expression_identifier is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'to_expression_identifier' is missing."})
|
||||
if to_input_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'to_input_name' is missing."})
|
||||
|
||||
transaction_description = "MCP: Connect Material Expressions"
|
||||
try:
|
||||
material = _get_material_asset(material_path)
|
||||
|
||||
from_expression = _find_material_expression_by_name_or_type(material, from_expression_identifier, from_expression_class_name)
|
||||
to_expression = _find_material_expression_by_name_or_type(material, to_expression_identifier, to_expression_class_name)
|
||||
|
||||
with unreal.ScopedEditorTransaction(transaction_description) as trans:
|
||||
success = unreal.MaterialEditingLibrary.connect_material_expressions(
|
||||
from_expression, from_output_name, to_expression, to_input_name
|
||||
)
|
||||
if not success:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Failed to connect '{from_expression_identifier}(Output: {from_output_name})' to '{to_expression_identifier}(Input: {to_input_name})' in '{material_path}'. Check pin names and compatibility."
|
||||
})
|
||||
|
||||
unreal.MaterialEditingLibrary.recompile_material(material)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(material)
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"message": f"Successfully connected '{from_expression_identifier}(Output: {from_output_name})' to '{to_expression_identifier}(Input: {to_input_name})' in '{material_path}'."
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Error connecting material expressions: {str(e)}",
|
||||
"traceback": traceback.format_exc()
|
||||
})
|
||||
|
||||
def ue_recompile(material_path: str = None) -> str:
|
||||
"""
|
||||
Triggers a recompile of a material or material instance's parent. Saves the asset.
|
||||
Returns JSON string.
|
||||
"""
|
||||
if material_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'material_path' is missing."})
|
||||
try:
|
||||
asset_to_process = unreal.EditorAssetLibrary.load_asset(material_path)
|
||||
if not asset_to_process:
|
||||
raise FileNotFoundError(f"Asset not found at path: {material_path}")
|
||||
|
||||
target_material_to_recompile = None
|
||||
asset_to_save = asset_to_process
|
||||
message_detail = ""
|
||||
|
||||
if isinstance(asset_to_process, unreal.Material):
|
||||
target_material_to_recompile = asset_to_process
|
||||
message_detail = f"material '{material_path}'"
|
||||
elif isinstance(asset_to_process, unreal.MaterialInstance):
|
||||
parent_material = asset_to_process.parent
|
||||
if parent_material:
|
||||
target_material_to_recompile = parent_material
|
||||
message_detail = f"parent of material instance '{material_path}'"
|
||||
else:
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset_to_process)
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"message": f"Material instance '{material_path}' has no parent to recompile. Instance saved."
|
||||
})
|
||||
else:
|
||||
raise TypeError(f"Asset at {material_path} is not a Material or MaterialInstance, but {type(asset_to_process).__name__}")
|
||||
|
||||
if target_material_to_recompile:
|
||||
unreal.MaterialEditingLibrary.recompile_material(target_material_to_recompile)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(asset_to_save)
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"message": f"Successfully recompiled {message_detail} and saved '{material_path}'."
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Error processing {message_detail} '{material_path}' for recompile: {str(e)}",
|
||||
"traceback": traceback.format_exc()
|
||||
})
|
||||
|
||||
def ue_get_mi_scalar_param(instance_path: str = None, parameter_name: str = None) -> str:
|
||||
"""
|
||||
Gets the current scalar (float) parameter value from a Material Instance Constant.
|
||||
Returns JSON string.
|
||||
"""
|
||||
if instance_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'instance_path' is missing."})
|
||||
if parameter_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'parameter_name' is missing."})
|
||||
try:
|
||||
instance = _get_material_instance_asset(instance_path)
|
||||
ue_parameter_name = unreal.Name(parameter_name)
|
||||
|
||||
param_value = instance.get_scalar_parameter_value(ue_parameter_name)
|
||||
|
||||
if param_value is None:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Scalar parameter '{parameter_name}' not found in instance '{instance_path}'.",
|
||||
"parameter_name": parameter_name,
|
||||
"instance_path": instance_path,
|
||||
"value": None
|
||||
})
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"parameter_name": parameter_name,
|
||||
"value": param_value,
|
||||
"instance_path": instance_path
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Error getting scalar parameter '{parameter_name}' from '{instance_path}': {str(e)}",
|
||||
"traceback": traceback.format_exc()
|
||||
})
|
||||
|
||||
def ue_set_mi_scalar_param(instance_path: str = None, parameter_name: str = None, value: float = None) -> str:
|
||||
"""
|
||||
Sets the scalar (float) parameter value for a Material Instance Constant.
|
||||
Returns JSON string.
|
||||
"""
|
||||
if instance_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'instance_path' is missing."})
|
||||
if parameter_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'parameter_name' is missing."})
|
||||
if value is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'value' is missing."})
|
||||
|
||||
transaction_description = "MCP: Set Material Instance Scalar Parameter"
|
||||
try:
|
||||
instance = _get_material_instance_asset(instance_path)
|
||||
ue_parameter_name = unreal.Name(parameter_name)
|
||||
|
||||
with unreal.ScopedEditorTransaction(transaction_description) as trans:
|
||||
unreal.MaterialEditingLibrary.set_material_instance_scalar_parameter_value(instance, ue_parameter_name, float(value))
|
||||
unreal.MaterialEditingLibrary.update_material_instance(instance)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(instance)
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"message": f"Successfully set scalar parameter '{parameter_name}' to {value} for instance '{instance_path}'.",
|
||||
"instance_path": instance_path,
|
||||
"parameter_name": parameter_name,
|
||||
"new_value": value
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Error setting scalar parameter '{parameter_name}' for '{instance_path}': {str(e)}",
|
||||
"traceback": traceback.format_exc()
|
||||
})
|
||||
|
||||
def ue_get_mi_vector_param(instance_path: str = None, parameter_name: str = None) -> str:
|
||||
"""Gets a vector parameter from a Material Instance. Returns JSON string."""
|
||||
if instance_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'instance_path' is missing."})
|
||||
if parameter_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'parameter_name' is missing."})
|
||||
try:
|
||||
instance = _get_material_instance_asset(instance_path)
|
||||
ue_parameter_name = unreal.Name(parameter_name)
|
||||
|
||||
param_value = unreal.MaterialEditingLibrary.get_material_instance_vector_parameter_value(instance, ue_parameter_name)
|
||||
|
||||
value_list = [param_value.r, param_value.g, param_value.b, param_value.a]
|
||||
return json.dumps({"success": True, "parameter_name": parameter_name, "value": value_list, "instance_path": instance_path})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
def ue_set_mi_vector_param(instance_path: str = None, parameter_name: str = None, value: list = None) -> str:
|
||||
"""Sets a vector parameter on a Material Instance. Expects value as [R,G,B,A]. Returns JSON string."""
|
||||
if instance_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'instance_path' is missing."})
|
||||
if parameter_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'parameter_name' is missing."})
|
||||
if value is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'value' is missing."})
|
||||
|
||||
transaction_description = "MCP: Set Material Instance Vector Parameter"
|
||||
try:
|
||||
instance = _get_material_instance_asset(instance_path)
|
||||
ue_parameter_name = unreal.Name(parameter_name)
|
||||
if not isinstance(value, list) or len(value) != 4:
|
||||
raise ValueError("Vector value must be a list of 4 floats [R, G, B, A].")
|
||||
|
||||
with unreal.ScopedEditorTransaction(transaction_description) as trans:
|
||||
linear_color_value = unreal.LinearColor(float(value[0]), float(value[1]), float(value[2]), float(value[3]))
|
||||
unreal.MaterialEditingLibrary.set_material_instance_vector_parameter_value(instance, ue_parameter_name, linear_color_value)
|
||||
unreal.MaterialEditingLibrary.update_material_instance(instance)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(instance)
|
||||
return json.dumps({"success": True, "message": f"Vector parameter '{parameter_name}' set.", "new_value": value})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
def ue_get_mi_texture_param(instance_path: str = None, parameter_name: str = None) -> str:
|
||||
"""Gets a texture parameter from a Material Instance. Returns JSON string with texture path."""
|
||||
if instance_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'instance_path' is missing."})
|
||||
if parameter_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'parameter_name' is missing."})
|
||||
try:
|
||||
instance = _get_material_instance_asset(instance_path)
|
||||
ue_parameter_name = unreal.Name(parameter_name)
|
||||
|
||||
param_value = unreal.MaterialEditingLibrary.get_material_instance_texture_parameter_value(instance, ue_parameter_name)
|
||||
|
||||
texture_path = param_value.get_path_name() if param_value else None
|
||||
return json.dumps({"success": True, "parameter_name": parameter_name, "value": texture_path, "instance_path": instance_path})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
def _get_mi_texture_param_names(instance_path):
|
||||
"""
|
||||
Gets all texture parameter names for a given material instance
|
||||
"""
|
||||
try:
|
||||
instance = unreal.load_asset(instance_path)
|
||||
texture_param_names = unreal.MaterialEditingLibrary.get_texture_parameter_names(instance)
|
||||
return list(texture_param_names)
|
||||
except Exception as e:
|
||||
return []
|
||||
|
||||
def ue_set_mi_texture_param(instance_path: str = None, parameter_name: str = None, texture_path: Optional[str] = None) -> str:
|
||||
"""Sets a texture parameter on a Material Instance. Provide texture asset path. Returns JSON string."""
|
||||
if instance_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'instance_path' is missing."})
|
||||
|
||||
try:
|
||||
instance = unreal.load_asset(instance_path)
|
||||
available_params = unreal.MaterialEditingLibrary.get_texture_parameter_names(instance)
|
||||
available_params = [str(name) for name in available_params]
|
||||
except Exception as e:
|
||||
available_params = []
|
||||
|
||||
if parameter_name is None:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": "Required parameter 'parameter_name' is missing.",
|
||||
"available_parameters": available_params
|
||||
})
|
||||
|
||||
if parameter_name not in available_params:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Texture parameter '{parameter_name}' does not exist.",
|
||||
"available_parameters": available_params
|
||||
})
|
||||
|
||||
try:
|
||||
instance = _get_material_instance_asset(instance_path)
|
||||
ue_parameter_name = unreal.Name(parameter_name)
|
||||
|
||||
texture_asset = None
|
||||
if texture_path:
|
||||
texture_asset = unreal.EditorAssetLibrary.load_asset(texture_path)
|
||||
if not texture_asset:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Texture asset not found at path: {texture_path}",
|
||||
"available_parameters": available_params
|
||||
})
|
||||
if not isinstance(texture_asset, unreal.Texture):
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Asset at {texture_path} is not a Texture, but {type(texture_asset).__name__}",
|
||||
"available_parameters": available_params
|
||||
})
|
||||
|
||||
with unreal.ScopedEditorTransaction("MCP: Set Material Instance Texture Parameter") as trans:
|
||||
unreal.MaterialEditingLibrary.set_material_instance_texture_parameter_value(instance, ue_parameter_name, texture_asset)
|
||||
unreal.MaterialEditingLibrary.update_material_instance(instance)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(instance)
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"message": f"Texture parameter '{parameter_name}' set.",
|
||||
"new_value": texture_path,
|
||||
"available_parameters": available_params
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": str(e),
|
||||
"traceback": traceback.format_exc(),
|
||||
"available_parameters": available_params
|
||||
})
|
||||
|
||||
def _get_mi_static_switch_params(instance_path):
|
||||
"""
|
||||
Gets all available static switch parameter names for a material instance
|
||||
"""
|
||||
try:
|
||||
instance = _get_material_instance_asset(instance_path)
|
||||
param_names = unreal.MaterialEditingLibrary.get_static_switch_parameter_names(instance)
|
||||
return [str(name) for name in param_names]
|
||||
except Exception as e:
|
||||
print(f"Error getting static switch parameters: {e}")
|
||||
return []
|
||||
|
||||
def ue_get_mi_static_switch(instance_path: str = None, parameter_name: str = None) -> str:
|
||||
"""Gets a static switch parameter from a Material Instance. Returns JSON string."""
|
||||
if instance_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'instance_path' is missing."})
|
||||
if parameter_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'parameter_name' is missing."})
|
||||
|
||||
available_params = _get_mi_static_switch_params(instance_path)
|
||||
|
||||
if parameter_name not in available_params:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Static switch parameter '{parameter_name}' not found.",
|
||||
"available_parameters": available_params
|
||||
})
|
||||
|
||||
try:
|
||||
instance = _get_material_instance_asset(instance_path)
|
||||
ue_parameter_name = unreal.Name(parameter_name)
|
||||
|
||||
param_value = unreal.MaterialEditingLibrary.get_material_instance_static_switch_parameter_value(instance, ue_parameter_name)
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"parameter_name": parameter_name,
|
||||
"value": param_value,
|
||||
"instance_path": instance_path,
|
||||
"available_parameters": available_params
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Error getting static switch parameter '{parameter_name}': {str(e)}",
|
||||
"traceback": traceback.format_exc(),
|
||||
"available_parameters": available_params
|
||||
})
|
||||
|
||||
def ue_set_mi_static_switch(instance_path: str = None, parameter_name: str = None, value: bool = None) -> str:
|
||||
"""Sets a static switch parameter on a Material Instance. Returns JSON string."""
|
||||
if instance_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'instance_path' is missing."})
|
||||
if parameter_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'parameter_name' is missing."})
|
||||
if value is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'value' is missing."})
|
||||
|
||||
available_params = _get_mi_static_switch_params(instance_path)
|
||||
|
||||
if parameter_name not in available_params:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Static switch parameter '{parameter_name}' not found.",
|
||||
"available_parameters": available_params
|
||||
})
|
||||
|
||||
transaction_description = "MCP: Set Material Instance Static Switch Parameter"
|
||||
try:
|
||||
instance = _get_material_instance_asset(instance_path)
|
||||
ue_parameter_name = unreal.Name(parameter_name)
|
||||
|
||||
with unreal.ScopedEditorTransaction(transaction_description) as trans:
|
||||
unreal.MaterialEditingLibrary.set_material_instance_static_switch_parameter_value(instance, ue_parameter_name, bool(value))
|
||||
unreal.MaterialEditingLibrary.update_material_instance(instance)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(instance)
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"message": f"Static switch parameter '{parameter_name}' set to {value}",
|
||||
"new_value": value,
|
||||
"available_parameters": available_params
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Error setting static switch parameter '{parameter_name}': {str(e)}",
|
||||
"traceback": traceback.format_exc(),
|
||||
"available_parameters": available_params
|
||||
})
|
||||
|
||||
|
||||
# --- Asset creation -----------------------------------------------------------
|
||||
|
||||
def ue_create_material(material_path: str = None) -> str:
|
||||
"""Creates a new Material asset at the given content-browser path."""
|
||||
if material_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'material_path' is missing."})
|
||||
try:
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(material_path):
|
||||
return json.dumps({"success": False, "message": f"Asset already exists: {material_path}"})
|
||||
name, package = _split_asset_path(material_path)
|
||||
tools = unreal.AssetToolsHelpers.get_asset_tools()
|
||||
mat = tools.create_asset(name, package, unreal.Material, unreal.MaterialFactoryNew())
|
||||
if not mat:
|
||||
return json.dumps({"success": False, "message": f"Failed to create material at '{material_path}'."})
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(mat)
|
||||
return json.dumps({"success": True, "material_path": material_path,
|
||||
"message": f"Material created at '{material_path}'."})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_create_material_instance(instance_path: str = None, parent_path: str = None) -> str:
|
||||
"""Creates a Material Instance Constant, optionally parented to parent_path."""
|
||||
if instance_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'instance_path' is missing."})
|
||||
try:
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(instance_path):
|
||||
return json.dumps({"success": False, "message": f"Asset already exists: {instance_path}"})
|
||||
name, package = _split_asset_path(instance_path)
|
||||
tools = unreal.AssetToolsHelpers.get_asset_tools()
|
||||
mi = tools.create_asset(name, package, unreal.MaterialInstanceConstant,
|
||||
unreal.MaterialInstanceConstantFactoryNew())
|
||||
if not mi:
|
||||
return json.dumps({"success": False, "message": f"Failed to create material instance at '{instance_path}'."})
|
||||
if parent_path:
|
||||
parent = unreal.EditorAssetLibrary.load_asset(parent_path)
|
||||
if not parent or not isinstance(parent, unreal.MaterialInterface):
|
||||
return json.dumps({"success": False, "message": f"Parent is not a material: {parent_path}"})
|
||||
unreal.MaterialEditingLibrary.set_material_instance_parent(mi, parent)
|
||||
unreal.MaterialEditingLibrary.update_material_instance(mi)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(mi)
|
||||
return json.dumps({"success": True, "instance_path": instance_path, "parent_path": parent_path,
|
||||
"message": f"Material instance created at '{instance_path}'."})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
# --- Graph authoring ----------------------------------------------------------
|
||||
|
||||
def ue_connect_property(material_path: str = None, from_expression_identifier: str = None,
|
||||
from_output_name: str = "", property_name: str = None,
|
||||
from_expression_class_name: str = None) -> str:
|
||||
"""Connects an expression output to a material property (e.g. BaseColor, Metallic, Roughness, Normal)."""
|
||||
if material_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'material_path' is missing."})
|
||||
if from_expression_identifier is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'from_expression_identifier' is missing."})
|
||||
if property_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'property_name' is missing.",
|
||||
"valid_properties": list(_MATERIAL_PROPERTY_MAP)})
|
||||
if property_name not in _MATERIAL_PROPERTY_MAP:
|
||||
return json.dumps({"success": False, "message": f"Unknown property '{property_name}'.",
|
||||
"valid_properties": list(_MATERIAL_PROPERTY_MAP)})
|
||||
try:
|
||||
material = _get_material_asset(material_path)
|
||||
from_expression = _find_material_expression_by_name_or_type(
|
||||
material, from_expression_identifier, from_expression_class_name)
|
||||
prop = getattr(unreal.MaterialProperty, _MATERIAL_PROPERTY_MAP[property_name])
|
||||
with unreal.ScopedEditorTransaction("MCP: Connect Material Property"):
|
||||
ok = unreal.MaterialEditingLibrary.connect_material_property(from_expression, from_output_name, prop)
|
||||
if not ok:
|
||||
return json.dumps({"success": False,
|
||||
"message": f"Failed to connect '{from_expression_identifier}' to '{property_name}'."})
|
||||
unreal.MaterialEditingLibrary.recompile_material(material)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(material)
|
||||
return json.dumps({"success": True,
|
||||
"message": f"Connected '{from_expression_identifier}' (out '{from_output_name}') to '{property_name}'."})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_delete_expression(material_path: str = None, expression_identifier: str = None,
|
||||
expression_class_name: str = None) -> str:
|
||||
"""Deletes a material expression node identified by name/desc/type."""
|
||||
if material_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'material_path' is missing."})
|
||||
if expression_identifier is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'expression_identifier' is missing."})
|
||||
try:
|
||||
material = _get_material_asset(material_path)
|
||||
expression = _find_material_expression_by_name_or_type(material, expression_identifier, expression_class_name)
|
||||
with unreal.ScopedEditorTransaction("MCP: Delete Material Expression"):
|
||||
# delete_material_expression returns None regardless of outcome, so
|
||||
# confirm by comparing the expression count before/after.
|
||||
before = unreal.MaterialEditingLibrary.get_num_material_expressions(material)
|
||||
unreal.MaterialEditingLibrary.delete_material_expression(material, expression)
|
||||
after = unreal.MaterialEditingLibrary.get_num_material_expressions(material)
|
||||
unreal.MaterialEditingLibrary.recompile_material(material)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(material)
|
||||
if after >= before:
|
||||
return json.dumps({"success": False,
|
||||
"message": f"Expression '{expression_identifier}' was not removed (count {before}->{after})."})
|
||||
return json.dumps({"success": True,
|
||||
"message": f"Deleted expression '{expression_identifier}' (count {before}->{after})."})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_set_expression_property(material_path: str = None, expression_identifier: str = None,
|
||||
property_name: str = None, value=None,
|
||||
expression_class_name: str = None) -> str:
|
||||
"""
|
||||
Sets an editor property on a material expression (e.g. 'r' on a Constant,
|
||||
'parameter_name'/'default_value' on a ScalarParameter). A 3/4-element list is
|
||||
coerced to a LinearColor; 'parameter_name' is coerced to an unreal.Name.
|
||||
"""
|
||||
if material_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'material_path' is missing."})
|
||||
if expression_identifier is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'expression_identifier' is missing."})
|
||||
if property_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'property_name' is missing."})
|
||||
try:
|
||||
material = _get_material_asset(material_path)
|
||||
expression = _find_material_expression_by_name_or_type(material, expression_identifier, expression_class_name)
|
||||
coerced = value
|
||||
if property_name == "parameter_name":
|
||||
coerced = unreal.Name(str(value))
|
||||
elif isinstance(value, list) and len(value) in (3, 4):
|
||||
r, g, b = float(value[0]), float(value[1]), float(value[2])
|
||||
a = float(value[3]) if len(value) == 4 else 1.0
|
||||
coerced = unreal.LinearColor(r, g, b, a)
|
||||
with unreal.ScopedEditorTransaction("MCP: Set Material Expression Property"):
|
||||
expression.set_editor_property(property_name, coerced)
|
||||
unreal.MaterialEditingLibrary.recompile_material(material)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(material)
|
||||
return json.dumps({"success": True,
|
||||
"message": f"Set '{property_name}' on '{expression_identifier}'."})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_layout_expressions(material_path: str = None) -> str:
|
||||
"""Auto-lays out all expression nodes in a material graph."""
|
||||
if material_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'material_path' is missing."})
|
||||
try:
|
||||
material = _get_material_asset(material_path)
|
||||
with unreal.ScopedEditorTransaction("MCP: Layout Material Expressions"):
|
||||
unreal.MaterialEditingLibrary.layout_material_expressions(material)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(material)
|
||||
return json.dumps({"success": True, "message": f"Laid out expressions in '{material_path}'."})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
# --- Read / introspection -----------------------------------------------------
|
||||
|
||||
def ue_get_material_info(material_path: str = None) -> str:
|
||||
"""Returns expression count and a list of expressions (name, class, position) for a material."""
|
||||
if material_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'material_path' is missing."})
|
||||
try:
|
||||
material = _get_material_asset(material_path)
|
||||
count = unreal.MaterialEditingLibrary.get_num_material_expressions(material)
|
||||
expressions = []
|
||||
mat_path = material.get_path_name()
|
||||
for x in unreal.ObjectIterator():
|
||||
if isinstance(x, unreal.MaterialExpression) and x.get_path_name().startswith(mat_path):
|
||||
try:
|
||||
pos = unreal.MaterialEditingLibrary.get_material_expression_node_position(x)
|
||||
pos_xy = [int(pos.x), int(pos.y)]
|
||||
except Exception:
|
||||
pos_xy = None
|
||||
expressions.append({
|
||||
"name": x.get_name(),
|
||||
"desc": x.desc if hasattr(x, "desc") else "",
|
||||
"class": x.__class__.__name__,
|
||||
"position": pos_xy,
|
||||
})
|
||||
return json.dumps({"success": True, "material_path": material_path,
|
||||
"expression_count": count, "expressions": expressions})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_list_parameters(material_path: str = None) -> str:
|
||||
"""Lists scalar / vector / texture / static-switch parameter names for a material or material instance."""
|
||||
if material_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'material_path' is missing."})
|
||||
try:
|
||||
asset = unreal.EditorAssetLibrary.load_asset(material_path)
|
||||
if not asset or not isinstance(asset, unreal.MaterialInterface):
|
||||
return json.dumps({"success": False, "message": f"Asset is not a material/instance: {material_path}"})
|
||||
mel = unreal.MaterialEditingLibrary
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"material_path": material_path,
|
||||
"scalar": [str(n) for n in mel.get_scalar_parameter_names(asset)],
|
||||
"vector": [str(n) for n in mel.get_vector_parameter_names(asset)],
|
||||
"texture": [str(n) for n in mel.get_texture_parameter_names(asset)],
|
||||
"static_switch": [str(n) for n in mel.get_static_switch_parameter_names(asset)],
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_set_instance_parent(instance_path: str = None, parent_path: str = None) -> str:
|
||||
"""Reparents a Material Instance Constant to a new parent material/instance."""
|
||||
if instance_path is None or parent_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: instance_path, parent_path."})
|
||||
try:
|
||||
instance = _get_material_instance_asset(instance_path)
|
||||
parent = unreal.EditorAssetLibrary.load_asset(parent_path)
|
||||
if not parent or not isinstance(parent, unreal.MaterialInterface):
|
||||
return json.dumps({"success": False, "message": f"Parent is not a material/instance: {parent_path}"})
|
||||
unreal.MaterialEditingLibrary.set_material_instance_parent(instance, parent)
|
||||
unreal.MaterialEditingLibrary.update_material_instance(instance)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(instance)
|
||||
return json.dumps({"success": True, "instance_path": instance_path, "parent_path": parent_path})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
@@ -0,0 +1,111 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
"""
|
||||
Core dispatcher for executing dynamic Python commands received from the MCP server.
|
||||
All specific action functions have been moved to their respective modules:
|
||||
- util_actions.py
|
||||
- asset_actions.py
|
||||
- actor_actions.py
|
||||
- material_actions.py
|
||||
"""
|
||||
import unreal # type: ignore # Suppress linter warning, 'unreal' module is available in UE Python environment
|
||||
import json
|
||||
import importlib
|
||||
import traceback
|
||||
|
||||
# Core dispatcher for executing dynamic Python commands received from the MCP server
|
||||
def execute_action(module_name: str, function_name: str, params: dict) -> str: # Changed args_list: list to params: dict
|
||||
"""
|
||||
Dynamically imports and executes the specified function from the given module.
|
||||
It reloads the module on each call to ensure the latest version is used.
|
||||
|
||||
Args:
|
||||
module_name (str): Name of the module containing the function (e.g., "util_actions", "actor_actions").
|
||||
function_name (str): Name of the function to call (e.g., "ue_print_message").
|
||||
params (dict): Dictionary of parameters to pass to the target function.
|
||||
|
||||
Returns:
|
||||
str: JSON-formatted string representing the function's result or an error.
|
||||
"""
|
||||
try:
|
||||
# Ensure the module name is valid and does not try to escape the intended directory
|
||||
# This is a basic check; more robust sandboxing might be needed depending on security requirements.
|
||||
if ".." in module_name or "/" in module_name or "\\" in module_name:
|
||||
raise ValueError(f"Invalid module name: {module_name}. Contains restricted characters.")
|
||||
|
||||
# Dynamically import the module.
|
||||
# Assuming these modules are in the Python path accessible by Unreal.
|
||||
# For plugins, this usually means Content/Python or subdirectories.
|
||||
target_module = importlib.import_module(module_name)
|
||||
|
||||
# Reload the module to pick up any changes without restarting Unreal.
|
||||
# This is crucial for development and live updates.
|
||||
importlib.reload(target_module)
|
||||
|
||||
target_function = getattr(target_module, function_name)
|
||||
|
||||
# Execute the function
|
||||
# params is now expected to be a dictionary directly.
|
||||
# Unpack the dictionary as keyword arguments to the target function.
|
||||
result_json_str = target_function(**params)
|
||||
|
||||
# Validate if the result is indeed a JSON string (basic check)
|
||||
try:
|
||||
json.loads(result_json_str) # Try to parse it to ensure it's valid JSON
|
||||
except json.JSONDecodeError as je:
|
||||
# If the function didn't return a valid JSON string, wrap this error.
|
||||
error_detail = f"Function '{module_name}.{function_name}' did not return a valid JSON string. Error: {je}. Returned: {result_json_str[:200]}"
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": error_detail,
|
||||
"traceback": traceback.format_exc(),
|
||||
"type": "InvalidReturnFormat"
|
||||
})
|
||||
except TypeError as te:
|
||||
# If result_json_str is not a string-like object (e.g. None)
|
||||
error_detail = f"Function '{module_name}.{function_name}' returned a non-string type. Error: {te}. Returned type: {type(result_json_str).__name__}"
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": error_detail,
|
||||
"traceback": traceback.format_exc(),
|
||||
"type": "InvalidReturnType"
|
||||
})
|
||||
|
||||
|
||||
return result_json_str # Return the JSON string as is
|
||||
|
||||
except ImportError:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Could not import module '{module_name}'. Ensure it exists and is in Python path.",
|
||||
"traceback": traceback.format_exc(),
|
||||
"type": "ImportError"
|
||||
})
|
||||
except AttributeError:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Function '{function_name}' not found in module '{module_name}'.",
|
||||
"traceback": traceback.format_exc(),
|
||||
"type": "AttributeError"
|
||||
})
|
||||
except ValueError as ve: # Catch specific ValueError from module name check
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": str(ve),
|
||||
"traceback": traceback.format_exc(),
|
||||
"type": "ValueError"
|
||||
})
|
||||
except Exception as e:
|
||||
# Catch all other exceptions during function execution
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Exception during execution of '{module_name}.{function_name}': {str(e)}",
|
||||
"traceback": traceback.format_exc(), # Include traceback for debugging
|
||||
"type": type(e).__name__
|
||||
})
|
||||
|
||||
# All specific ue_... action functions have been moved to their respective files:
|
||||
# - util_actions.py
|
||||
# - asset_actions.py
|
||||
# - actor_actions.py
|
||||
# - material_actions.py
|
||||
@@ -0,0 +1,201 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
"""
|
||||
IK Rig / IK Retargeter authoring and batch animation retargeting.
|
||||
|
||||
All actions require the IKRig plugin (a built-in engine plugin, usually enabled
|
||||
by default). The dependency is soft: each action guards at call time and returns
|
||||
an actionable error when the plugin is disabled.
|
||||
"""
|
||||
import unreal
|
||||
import json
|
||||
import traceback
|
||||
|
||||
|
||||
def _plugin_missing():
|
||||
if not hasattr(unreal, "IKRigController"):
|
||||
return json.dumps({"success": False,
|
||||
"message": "Requires the IKRig plugin. Enable it in Edit > Plugins and restart."})
|
||||
return None
|
||||
|
||||
|
||||
def _load_typed(asset_path, cls, label):
|
||||
asset = unreal.EditorAssetLibrary.load_asset(asset_path)
|
||||
if not asset:
|
||||
raise FileNotFoundError(f"{label} not found at path: {asset_path}")
|
||||
if not isinstance(asset, cls):
|
||||
raise TypeError(f"Asset at {asset_path} is not a {label}, but {type(asset).__name__}")
|
||||
return asset
|
||||
|
||||
|
||||
def _split_asset_path(asset_path: str):
|
||||
asset_path = asset_path.rstrip("/")
|
||||
idx = asset_path.rfind("/")
|
||||
return asset_path[idx + 1:], asset_path[:idx]
|
||||
|
||||
|
||||
def ue_create_ik_rig(asset_path: str = None, skeletal_mesh_path: str = None,
|
||||
retarget_root: str = None) -> str:
|
||||
"""Creates an IK Rig for a skeletal mesh, optionally setting the retarget root bone (requires the IKRig plugin)."""
|
||||
guard = _plugin_missing()
|
||||
if guard:
|
||||
return guard
|
||||
if asset_path is None or skeletal_mesh_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, skeletal_mesh_path."})
|
||||
try:
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
|
||||
return json.dumps({"success": False, "message": f"Asset already exists: {asset_path}"})
|
||||
mesh = _load_typed(skeletal_mesh_path, unreal.SkeletalMesh, "SkeletalMesh")
|
||||
name, package = _split_asset_path(asset_path)
|
||||
rig = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
|
||||
name, package, unreal.IKRigDefinition, unreal.IKRigDefinitionFactory())
|
||||
if not rig:
|
||||
return json.dumps({"success": False, "message": f"Failed to create IK Rig at {asset_path}."})
|
||||
rc = unreal.IKRigController.get_controller(rig)
|
||||
if not rc.set_skeletal_mesh(mesh):
|
||||
return json.dumps({"success": False, "message": "set_skeletal_mesh failed (incompatible mesh?)."})
|
||||
root_set = None
|
||||
if retarget_root:
|
||||
root_set = bool(rc.set_retarget_root(retarget_root))
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(rig)
|
||||
return json.dumps({"success": True, "asset_path": asset_path,
|
||||
"skeletal_mesh": skeletal_mesh_path, "retarget_root_set": root_set})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_add_retarget_chain(ik_rig_path: str = None, chain_name: str = None,
|
||||
start_bone: str = None, end_bone: str = None, goal_name: str = "") -> str:
|
||||
"""Adds a retarget chain (e.g. 'Spine': spine_01..spine_03) to an IK Rig (requires the IKRig plugin)."""
|
||||
guard = _plugin_missing()
|
||||
if guard:
|
||||
return guard
|
||||
if ik_rig_path is None or chain_name is None or start_bone is None or end_bone is None:
|
||||
return json.dumps({"success": False, "message": "Required: ik_rig_path, chain_name, start_bone, end_bone."})
|
||||
try:
|
||||
rig = _load_typed(ik_rig_path, unreal.IKRigDefinition, "IKRigDefinition")
|
||||
rc = unreal.IKRigController.get_controller(rig)
|
||||
created = rc.add_retarget_chain(chain_name, start_bone, end_bone, goal_name or "")
|
||||
if not str(created):
|
||||
return json.dumps({"success": False, "message": "add_retarget_chain returned an empty name (check bone names)."})
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(rig)
|
||||
return json.dumps({"success": True, "ik_rig_path": ik_rig_path, "chain_name": str(created),
|
||||
"chain_count": len(rc.get_retarget_chains())})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_get_ik_rig_info(ik_rig_path: str = None) -> str:
|
||||
"""Returns the skeletal mesh, retarget root, and chains of an IK Rig (requires the IKRig plugin)."""
|
||||
guard = _plugin_missing()
|
||||
if guard:
|
||||
return guard
|
||||
if ik_rig_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'ik_rig_path' is missing."})
|
||||
try:
|
||||
rig = _load_typed(ik_rig_path, unreal.IKRigDefinition, "IKRigDefinition")
|
||||
rc = unreal.IKRigController.get_controller(rig)
|
||||
mesh = rc.get_skeletal_mesh()
|
||||
chains = []
|
||||
for ch in rc.get_retarget_chains():
|
||||
cname = str(getattr(ch, "chain_name", ""))
|
||||
chains.append({
|
||||
"name": cname,
|
||||
"start_bone": str(rc.get_retarget_chain_start_bone(cname)),
|
||||
"end_bone": str(rc.get_retarget_chain_end_bone(cname)),
|
||||
})
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"ik_rig_path": ik_rig_path,
|
||||
"skeletal_mesh": mesh.get_path_name() if mesh else None,
|
||||
"retarget_root": str(rc.get_retarget_root()),
|
||||
"chains": chains,
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_create_retargeter(asset_path: str = None, source_ik_rig_path: str = None,
|
||||
target_ik_rig_path: str = None, auto_map: bool = True) -> str:
|
||||
"""Creates an IK Retargeter wired to source/target IK Rigs, with optional fuzzy chain auto-mapping (requires the IKRig plugin)."""
|
||||
guard = _plugin_missing()
|
||||
if guard:
|
||||
return guard
|
||||
if asset_path is None or source_ik_rig_path is None or target_ik_rig_path is None:
|
||||
return json.dumps({"success": False, "message": "Required: asset_path, source_ik_rig_path, target_ik_rig_path."})
|
||||
try:
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
|
||||
return json.dumps({"success": False, "message": f"Asset already exists: {asset_path}"})
|
||||
src = _load_typed(source_ik_rig_path, unreal.IKRigDefinition, "IKRigDefinition")
|
||||
tgt = _load_typed(target_ik_rig_path, unreal.IKRigDefinition, "IKRigDefinition")
|
||||
name, package = _split_asset_path(asset_path)
|
||||
rtg = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
|
||||
name, package, unreal.IKRetargeter, unreal.IKRetargetFactory())
|
||||
if not rtg:
|
||||
return json.dumps({"success": False, "message": f"Failed to create IK Retargeter at {asset_path}."})
|
||||
tc = unreal.IKRetargeterController.get_controller(rtg)
|
||||
tc.set_ik_rig(unreal.RetargetSourceOrTarget.SOURCE, src)
|
||||
tc.set_ik_rig(unreal.RetargetSourceOrTarget.TARGET, tgt)
|
||||
if auto_map:
|
||||
tc.auto_map_chains(unreal.AutoMapChainType.FUZZY, True)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(rtg)
|
||||
return json.dumps({"success": True, "asset_path": asset_path,
|
||||
"source_ik_rig": source_ik_rig_path, "target_ik_rig": target_ik_rig_path,
|
||||
"auto_mapped": bool(auto_map)})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_auto_map_chains(retargeter_path: str = None, mode: str = "FUZZY", force: bool = True) -> str:
|
||||
"""Re-runs chain mapping on an IK Retargeter. mode: FUZZY, EXACT, or CLEAR (requires the IKRig plugin)."""
|
||||
guard = _plugin_missing()
|
||||
if guard:
|
||||
return guard
|
||||
if retargeter_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'retargeter_path' is missing."})
|
||||
key = (mode or "FUZZY").upper()
|
||||
map_type = getattr(unreal.AutoMapChainType, key, None)
|
||||
if map_type is None:
|
||||
return json.dumps({"success": False, "message": f"Unknown mode '{mode}'.", "valid_modes": ["FUZZY", "EXACT", "CLEAR"]})
|
||||
try:
|
||||
rtg = _load_typed(retargeter_path, unreal.IKRetargeter, "IKRetargeter")
|
||||
tc = unreal.IKRetargeterController.get_controller(rtg)
|
||||
tc.auto_map_chains(map_type, bool(force))
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(rtg)
|
||||
return json.dumps({"success": True, "retargeter_path": retargeter_path, "mode": key})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_batch_retarget(retargeter_path: str = None, anim_paths: list = None,
|
||||
source_mesh_path: str = None, target_mesh_path: str = None,
|
||||
search: str = "", replace: str = "", prefix: str = "",
|
||||
suffix: str = "_Retargeted") -> str:
|
||||
"""Duplicates and retargets animations through an IK Retargeter; returns the new asset paths (requires the IKRig plugin)."""
|
||||
guard = _plugin_missing()
|
||||
if guard:
|
||||
return guard
|
||||
if retargeter_path is None or not anim_paths or source_mesh_path is None or target_mesh_path is None:
|
||||
return json.dumps({"success": False,
|
||||
"message": "Required: retargeter_path, anim_paths (non-empty), source_mesh_path, target_mesh_path."})
|
||||
try:
|
||||
rtg = _load_typed(retargeter_path, unreal.IKRetargeter, "IKRetargeter")
|
||||
src_mesh = _load_typed(source_mesh_path, unreal.SkeletalMesh, "SkeletalMesh")
|
||||
tgt_mesh = _load_typed(target_mesh_path, unreal.SkeletalMesh, "SkeletalMesh")
|
||||
asset_data = []
|
||||
missing = []
|
||||
for p in anim_paths:
|
||||
ad = unreal.EditorAssetLibrary.find_asset_data(p)
|
||||
(asset_data.append(ad) if ad and ad.is_valid() else missing.append(p))
|
||||
if missing:
|
||||
return json.dumps({"success": False, "message": f"Animations not found: {missing}"})
|
||||
out = unreal.IKRetargetBatchOperation.duplicate_and_retarget(
|
||||
asset_data, src_mesh, tgt_mesh, rtg,
|
||||
search=search or "", replace=replace or "", prefix=prefix or "", suffix=suffix or "")
|
||||
paths = [str(a.package_name) for a in (out or [])]
|
||||
if not paths:
|
||||
return json.dumps({"success": False, "message": "duplicate_and_retarget produced no assets (check chain mapping)."})
|
||||
return json.dumps({"success": True, "retargeter_path": retargeter_path,
|
||||
"count": len(paths), "retargeted_assets": paths})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
@@ -0,0 +1,250 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
"""Python action functions for StaticMesh assets (info, materials, collision)."""
|
||||
import unreal
|
||||
import json
|
||||
import traceback
|
||||
|
||||
SMS = unreal.get_editor_subsystem(unreal.StaticMeshEditorSubsystem)
|
||||
|
||||
_SHAPES = {
|
||||
"BOX": unreal.ScriptCollisionShapeType.BOX,
|
||||
"SPHERE": unreal.ScriptCollisionShapeType.SPHERE,
|
||||
"CAPSULE": unreal.ScriptCollisionShapeType.CAPSULE,
|
||||
"NDOP10_X": unreal.ScriptCollisionShapeType.NDOP10_X,
|
||||
"NDOP10_Y": unreal.ScriptCollisionShapeType.NDOP10_Y,
|
||||
"NDOP10_Z": unreal.ScriptCollisionShapeType.NDOP10_Z,
|
||||
"NDOP18": unreal.ScriptCollisionShapeType.NDOP18,
|
||||
"NDOP26": unreal.ScriptCollisionShapeType.NDOP26,
|
||||
}
|
||||
|
||||
|
||||
def _load_static_mesh(asset_path: str):
|
||||
if not asset_path:
|
||||
raise ValueError("StaticMesh path cannot be empty.")
|
||||
sm = unreal.EditorAssetLibrary.load_asset(asset_path)
|
||||
if not sm:
|
||||
raise FileNotFoundError(f"StaticMesh not found at path: {asset_path}")
|
||||
if not isinstance(sm, unreal.StaticMesh):
|
||||
raise TypeError(f"Asset at {asset_path} is not a StaticMesh, but {type(sm).__name__}")
|
||||
return sm
|
||||
|
||||
|
||||
def ue_get_static_mesh_info(asset_path: str = None) -> str:
|
||||
"""Returns LOD/section/triangle/vertex/material counts and Nanite state of a StaticMesh."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
sm = _load_static_mesh(asset_path)
|
||||
try:
|
||||
nanite = bool(SMS.get_nanite_settings(sm).enabled)
|
||||
except Exception:
|
||||
nanite = None
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"asset_path": asset_path,
|
||||
"num_lods": SMS.get_lod_count(sm),
|
||||
"num_materials": SMS.get_number_materials(sm),
|
||||
"num_sections_lod0": sm.get_num_sections(0),
|
||||
"num_triangles_lod0": sm.get_num_triangles(0),
|
||||
"num_vertices_lod0": sm.get_num_vertices(0),
|
||||
"nanite_enabled": nanite,
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_list_static_mesh_materials(asset_path: str = None) -> str:
|
||||
"""Lists the material slots of a StaticMesh (slot index + material path)."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
sm = _load_static_mesh(asset_path)
|
||||
slots = []
|
||||
for i in range(SMS.get_number_materials(sm)):
|
||||
mat = sm.get_material(i)
|
||||
slots.append({"slot": i, "material": mat.get_path_name() if mat else None})
|
||||
return json.dumps({"success": True, "asset_path": asset_path,
|
||||
"num_materials": len(slots), "materials": slots})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_set_static_mesh_material(asset_path: str = None, slot_index: int = None, material_path: str = None) -> str:
|
||||
"""Assigns a material to a StaticMesh material slot."""
|
||||
if asset_path is None or slot_index is None or material_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, slot_index, material_path."})
|
||||
try:
|
||||
sm = _load_static_mesh(asset_path)
|
||||
if slot_index < 0 or slot_index >= SMS.get_number_materials(sm):
|
||||
return json.dumps({"success": False, "message": f"slot_index {slot_index} out of range (0..{SMS.get_number_materials(sm)-1})."})
|
||||
mat = unreal.EditorAssetLibrary.load_asset(material_path)
|
||||
if not mat or not isinstance(mat, unreal.MaterialInterface):
|
||||
return json.dumps({"success": False, "message": f"Not a material: {material_path}"})
|
||||
sm.set_material(slot_index, mat)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(sm)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "slot": slot_index, "material": material_path})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_get_collision_info(asset_path: str = None) -> str:
|
||||
"""Returns collision complexity and simple/convex collision counts of a StaticMesh."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
sm = _load_static_mesh(asset_path)
|
||||
return json.dumps({
|
||||
"success": True, "asset_path": asset_path,
|
||||
"collision_complexity": str(SMS.get_collision_complexity(sm)),
|
||||
"simple_collision_count": SMS.get_simple_collision_count(sm),
|
||||
"convex_collision_count": SMS.get_convex_collision_count(sm),
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_add_simple_collision(asset_path: str = None, shape: str = "BOX") -> str:
|
||||
"""Adds a simple collision primitive to a StaticMesh. shape: BOX, SPHERE, CAPSULE, NDOP10_X/Y/Z, NDOP18, NDOP26."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
shape_key = (shape or "BOX").upper()
|
||||
if shape_key not in _SHAPES:
|
||||
return json.dumps({"success": False, "message": f"Unknown shape '{shape}'.", "valid_shapes": list(_SHAPES)})
|
||||
try:
|
||||
sm = _load_static_mesh(asset_path)
|
||||
before = SMS.get_simple_collision_count(sm)
|
||||
SMS.add_simple_collisions(sm, _SHAPES[shape_key])
|
||||
after = SMS.get_simple_collision_count(sm)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(sm)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "shape": shape_key,
|
||||
"simple_collision_count": after, "added": after - before})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
# --- LODs -----------------------------------------------------------------
|
||||
|
||||
def ue_set_lods(asset_path: str = None, lod_settings: list = None,
|
||||
auto_compute_screen_size: bool = False) -> str:
|
||||
"""Generates LODs from reduction settings: lod_settings=[{percent_triangles, screen_size}, ...] (LOD0 first)."""
|
||||
if asset_path is None or not lod_settings:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, lod_settings (non-empty list)."})
|
||||
try:
|
||||
sm = _load_static_mesh(asset_path)
|
||||
opts = unreal.StaticMeshReductionOptions()
|
||||
settings = []
|
||||
for i, ls in enumerate(lod_settings):
|
||||
s = unreal.StaticMeshReductionSettings()
|
||||
s.percent_triangles = float(ls.get("percent_triangles", 1.0))
|
||||
s.screen_size = float(ls.get("screen_size", 1.0))
|
||||
settings.append(s)
|
||||
opts.reduction_settings = settings
|
||||
opts.auto_compute_lod_screen_size = bool(auto_compute_screen_size)
|
||||
count = SMS.set_lods(sm, opts)
|
||||
if count <= 0:
|
||||
return json.dumps({"success": False, "message": f"set_lods returned {count}."})
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(sm)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "lod_count": SMS.get_lod_count(sm),
|
||||
"screen_sizes": [round(x, 4) for x in SMS.get_lod_screen_sizes(sm)]})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_get_lod_screen_sizes(asset_path: str = None) -> str:
|
||||
"""Returns the screen-size threshold of each LOD on a StaticMesh."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
sm = _load_static_mesh(asset_path)
|
||||
return json.dumps({"success": True, "asset_path": asset_path,
|
||||
"lod_count": SMS.get_lod_count(sm),
|
||||
"screen_sizes": [round(x, 4) for x in SMS.get_lod_screen_sizes(sm)]})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_remove_lods(asset_path: str = None) -> str:
|
||||
"""Removes all LODs except LOD 0 from a StaticMesh."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
sm = _load_static_mesh(asset_path)
|
||||
ok = SMS.remove_lods(sm)
|
||||
if not ok:
|
||||
return json.dumps({"success": False, "message": "remove_lods returned False (mesh may only have LOD 0)."})
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(sm)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "lod_count": SMS.get_lod_count(sm)})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_set_lod_from_static_mesh(asset_path: str = None, lod_index: int = None,
|
||||
source_path: str = None, source_lod_index: int = 0,
|
||||
reuse_existing_material_slots: bool = True) -> str:
|
||||
"""Adds/sets a LOD on a StaticMesh using geometry from another StaticMesh's LOD."""
|
||||
if asset_path is None or lod_index is None or source_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, lod_index, source_path."})
|
||||
try:
|
||||
dst = _load_static_mesh(asset_path)
|
||||
src = _load_static_mesh(source_path)
|
||||
result = SMS.set_lod_from_static_mesh(dst, int(lod_index), src, int(source_lod_index),
|
||||
bool(reuse_existing_material_slots))
|
||||
if result < 0:
|
||||
return json.dumps({"success": False, "message": f"set_lod_from_static_mesh returned {result} (see Output Log)."})
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(dst)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "lod_set_at": result,
|
||||
"lod_count": SMS.get_lod_count(dst)})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
# --- Collision (continued) --------------------------------------------------
|
||||
|
||||
def ue_set_convex_collision(asset_path: str = None, hull_count: int = 4,
|
||||
max_hull_verts: int = 16, hull_precision: int = 100000) -> str:
|
||||
"""Replaces simple collision with auto-generated convex decomposition collision."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
sm = _load_static_mesh(asset_path)
|
||||
ok = SMS.set_convex_decomposition_collisions(sm, int(hull_count), int(max_hull_verts), int(hull_precision))
|
||||
if not ok:
|
||||
return json.dumps({"success": False, "message": "set_convex_decomposition_collisions returned False."})
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(sm)
|
||||
return json.dumps({"success": True, "asset_path": asset_path,
|
||||
"convex_collision_count": SMS.get_convex_collision_count(sm)})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_remove_collisions(asset_path: str = None) -> str:
|
||||
"""Removes all simple/convex collision from a StaticMesh."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
sm = _load_static_mesh(asset_path)
|
||||
ok = SMS.remove_collisions(sm)
|
||||
if not ok:
|
||||
return json.dumps({"success": False, "message": "remove_collisions returned False."})
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(sm)
|
||||
return json.dumps({"success": True, "asset_path": asset_path,
|
||||
"simple_collision_count": SMS.get_simple_collision_count(sm),
|
||||
"convex_collision_count": SMS.get_convex_collision_count(sm)})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_set_lod_for_collision(asset_path: str = None, lod_index: int = None) -> str:
|
||||
"""Sets which LOD's geometry is used for complex collision on a StaticMesh."""
|
||||
if asset_path is None or lod_index is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, lod_index."})
|
||||
try:
|
||||
sm = _load_static_mesh(asset_path)
|
||||
if int(lod_index) < 0 or int(lod_index) >= SMS.get_lod_count(sm):
|
||||
return json.dumps({"success": False, "message": f"lod_index {lod_index} out of range (0..{SMS.get_lod_count(sm)-1})."})
|
||||
sm.set_editor_property("lod_for_collision", int(lod_index))
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(sm)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "lod_for_collision": int(lod_index)})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
@@ -0,0 +1,100 @@
|
||||
# UMG Actions Test Script
|
||||
# Paste each section into the Unreal Python console (Output Log) to verify functionality.
|
||||
# Run sections in order: 1 → 2 → 3 → 4 → 5
|
||||
|
||||
import importlib, json, unreal
|
||||
|
||||
def run(module_func, **kwargs):
|
||||
"""Reload module and call a ue_* function, printing the result."""
|
||||
mod = importlib.import_module("UnrealMCPython.umg_actions")
|
||||
importlib.reload(mod)
|
||||
fn = getattr(mod, module_func)
|
||||
result = fn(**kwargs)
|
||||
parsed = json.loads(result)
|
||||
print(json.dumps(parsed, indent=2))
|
||||
return parsed
|
||||
|
||||
TEST_PATH = "/Game/Tests"
|
||||
TEST_NAME = "TestWidget_MCP"
|
||||
FULL_PATH = f"{TEST_PATH}/{TEST_NAME}.{TEST_NAME}"
|
||||
|
||||
# ─── 1. Create Widget Blueprint ───────────────────────────────────────────────
|
||||
print("=== 1. create_widget_blueprint ===")
|
||||
r = run("ue_create_widget_blueprint", name=TEST_NAME, path=TEST_PATH)
|
||||
assert r["success"], f"FAIL: {r}"
|
||||
print("PASS")
|
||||
|
||||
# ─── 2. Get Info (empty) ──────────────────────────────────────────────────────
|
||||
print("\n=== 2. get_widget_blueprint_info (empty) ===")
|
||||
r = run("ue_get_widget_blueprint_info", asset_path=FULL_PATH)
|
||||
assert r["success"], f"FAIL: {r}"
|
||||
print(f" root: {r['root_widget']}, widgets: {r['widget_count']}")
|
||||
print("PASS")
|
||||
|
||||
# ─── 3. Add CanvasPanel as root ───────────────────────────────────────────────
|
||||
print("\n=== 3. add_widget: CanvasPanel (root) ===")
|
||||
r = run("ue_add_widget", asset_path=FULL_PATH, widget_type="CanvasPanel", widget_name="RootCanvas")
|
||||
assert r["success"], f"FAIL: {r}"
|
||||
assert r["is_root"], "Expected is_root=True"
|
||||
print("PASS")
|
||||
|
||||
# ─── 4. Add TextBlock under CanvasPanel ───────────────────────────────────────
|
||||
print("\n=== 4. add_widget: TextBlock under RootCanvas ===")
|
||||
r = run("ue_add_widget", asset_path=FULL_PATH, widget_type="TextBlock",
|
||||
widget_name="TitleText", parent_name="RootCanvas")
|
||||
assert r["success"], f"FAIL: {r}"
|
||||
print("PASS")
|
||||
|
||||
# ─── 5. Add Button under CanvasPanel ─────────────────────────────────────────
|
||||
print("\n=== 5. add_widget: Button under RootCanvas ===")
|
||||
r = run("ue_add_widget", asset_path=FULL_PATH, widget_type="Button",
|
||||
widget_name="StartButton", parent_name="RootCanvas")
|
||||
assert r["success"], f"FAIL: {r}"
|
||||
print("PASS")
|
||||
|
||||
# ─── 6. Set TextBlock properties ─────────────────────────────────────────────
|
||||
print("\n=== 6. set_widget_properties: TitleText ===")
|
||||
r = run("ue_set_widget_properties", asset_path=FULL_PATH, widget_name="TitleText",
|
||||
properties={
|
||||
"text": "Hello UMG!",
|
||||
"font_size": 32,
|
||||
"color_and_opacity": [1.0, 1.0, 0.0, 1.0],
|
||||
"slot_position": [100.0, 50.0],
|
||||
"slot_size": [400.0, 60.0],
|
||||
})
|
||||
assert r["success"], f"FAIL errors: {r.get('errors')}"
|
||||
print(f" set: {r['set']}")
|
||||
print("PASS")
|
||||
|
||||
# ─── 7. Set Button slot position ─────────────────────────────────────────────
|
||||
print("\n=== 7. set_widget_properties: StartButton slot ===")
|
||||
r = run("ue_set_widget_properties", asset_path=FULL_PATH, widget_name="StartButton",
|
||||
properties={
|
||||
"slot_position": [150.0, 150.0],
|
||||
"slot_size": [200.0, 60.0],
|
||||
})
|
||||
assert r["success"], f"FAIL errors: {r.get('errors')}"
|
||||
print("PASS")
|
||||
|
||||
# ─── 8. Get Info (populated) ─────────────────────────────────────────────────
|
||||
print("\n=== 8. get_widget_blueprint_info (populated) ===")
|
||||
r = run("ue_get_widget_blueprint_info", asset_path=FULL_PATH)
|
||||
assert r["success"], f"FAIL: {r}"
|
||||
assert r["widget_count"] == 3, f"Expected 3 widgets, got {r['widget_count']}"
|
||||
for w in r["widgets"]:
|
||||
print(f" {w['type']}: {w['name']} (parent: {w.get('parent', 'ROOT')})")
|
||||
print("PASS")
|
||||
|
||||
# ─── 9. Remove TextBlock ─────────────────────────────────────────────────────
|
||||
print("\n=== 9. remove_widget: TitleText ===")
|
||||
r = run("ue_remove_widget", asset_path=FULL_PATH, widget_name="TitleText")
|
||||
assert r["success"], f"FAIL: {r}"
|
||||
print("PASS")
|
||||
|
||||
# ─── 10. Compile ─────────────────────────────────────────────────────────────
|
||||
print("\n=== 10. compile_widget_blueprint ===")
|
||||
r = run("ue_compile_widget_blueprint", asset_path=FULL_PATH)
|
||||
assert r["success"], f"FAIL: {r}"
|
||||
print("PASS")
|
||||
|
||||
print("\n=== ALL TESTS PASSED ===")
|
||||
@@ -0,0 +1,37 @@
|
||||
import unittest
|
||||
import importlib
|
||||
import json
|
||||
import unreal
|
||||
|
||||
TEST_ROOT = "/Game/Tests/MCP"
|
||||
|
||||
|
||||
class MCPTestCase(unittest.TestCase):
|
||||
|
||||
def call(self, module_name, func_name, **kwargs):
|
||||
mod = importlib.import_module(f"UnrealMCPython.{module_name}")
|
||||
importlib.reload(mod)
|
||||
return json.loads(getattr(mod, func_name)(**kwargs))
|
||||
|
||||
def assertSuccess(self, result, msg=None):
|
||||
self.assertTrue(result.get("success"), msg or f"Expected success=True: {result}")
|
||||
|
||||
def delete_asset(self, path):
|
||||
try:
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(path):
|
||||
unreal.EditorAssetLibrary.delete_asset(path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def delete_actor_by_label(self, label):
|
||||
try:
|
||||
sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
for actor in sub.get_all_level_actors():
|
||||
if actor.get_actor_label() == label:
|
||||
sub.destroy_actor(actor)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def ensure_test_dir(self):
|
||||
unreal.EditorAssetLibrary.make_directory(TEST_ROOT)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Run all MCP unittest suites inside the Unreal Python environment.
|
||||
|
||||
Usage — single line in the Unreal Python console (Output Log):
|
||||
|
||||
import runpy; runpy.run_module("UnrealMCPython.tests.run_all", run_name="__main__")
|
||||
|
||||
Or via MCP execute_python tool (multi-line is fine there):
|
||||
|
||||
import runpy
|
||||
runpy.run_module("UnrealMCPython.tests.run_all", run_name="__main__")
|
||||
"""
|
||||
import sys
|
||||
import importlib
|
||||
import unittest
|
||||
|
||||
_MODULES = [
|
||||
"UnrealMCPython.tests.test_util",
|
||||
"UnrealMCPython.tests.test_actor",
|
||||
"UnrealMCPython.tests.test_anim_blueprint",
|
||||
"UnrealMCPython.tests.test_animation",
|
||||
"UnrealMCPython.tests.test_asset",
|
||||
"UnrealMCPython.tests.test_level",
|
||||
"UnrealMCPython.tests.test_level_sequence",
|
||||
"UnrealMCPython.tests.test_material",
|
||||
"UnrealMCPython.tests.test_blueprint",
|
||||
"UnrealMCPython.tests.test_behavior_tree",
|
||||
"UnrealMCPython.tests.test_data_table",
|
||||
"UnrealMCPython.tests.test_umg",
|
||||
"UnrealMCPython.tests.test_editor",
|
||||
"UnrealMCPython.tests.test_game",
|
||||
"UnrealMCPython.tests.test_static_mesh",
|
||||
"UnrealMCPython.tests.test_layer",
|
||||
"UnrealMCPython.tests.test_texture",
|
||||
"UnrealMCPython.tests.test_retarget",
|
||||
"UnrealMCPython.tests.test_control_rig",
|
||||
"UnrealMCPython.tests.test_gas",
|
||||
"UnrealMCPython.tests.test_vision",
|
||||
]
|
||||
|
||||
suite = unittest.TestSuite()
|
||||
loader = unittest.TestLoader()
|
||||
|
||||
for mod_name in _MODULES:
|
||||
try:
|
||||
mod = importlib.import_module(mod_name)
|
||||
importlib.reload(mod)
|
||||
suite.addTests(loader.loadTestsFromModule(mod))
|
||||
except Exception as e:
|
||||
print(f"[LOAD ERROR] {mod_name}: {e}")
|
||||
|
||||
runner = unittest.TextTestRunner(verbosity=2, stream=sys.stdout)
|
||||
result = runner.run(suite)
|
||||
|
||||
total = result.testsRun
|
||||
fails = len(result.failures)
|
||||
errors = len(result.errors)
|
||||
skipped = len(result.skipped)
|
||||
passed = total - fails - errors - skipped
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Results: {passed} passed | {fails} failed | {errors} errors | {skipped} skipped / {total} total")
|
||||
print('='*60)
|
||||
@@ -0,0 +1,339 @@
|
||||
import unreal
|
||||
from UnrealMCPython.tests.base import MCPTestCase
|
||||
|
||||
_CLASS_PATH = "/Script/Engine.PointLight"
|
||||
|
||||
|
||||
class TestActorActions(MCPTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self._actor_label = None
|
||||
r = self.call("actor_actions", "ue_spawn_from_class",
|
||||
class_path=_CLASS_PATH, location=[0, 0, 500])
|
||||
if r.get("success"):
|
||||
self._actor_label = r["actor_label"]
|
||||
|
||||
def tearDown(self):
|
||||
if self._actor_label:
|
||||
self.delete_actor_by_label(self._actor_label)
|
||||
|
||||
# ── spawn ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_spawn_from_class(self):
|
||||
self.assertIsNotNone(self._actor_label, "Actor was not spawned in setUp")
|
||||
|
||||
def test_spawn_missing_class(self):
|
||||
r = self.call("actor_actions", "ue_spawn_from_class",
|
||||
class_path="/Script/Engine.NonExistentClass123", location=[0, 0, 0])
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
# ── list / query ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_list_all_with_locations(self):
|
||||
r = self.call("actor_actions", "ue_list_all_with_locations")
|
||||
self.assertSuccess(r)
|
||||
self.assertIsInstance(r["actors"], list)
|
||||
labels = [a["name"] for a in r["actors"]]
|
||||
self.assertIn(self._actor_label, labels)
|
||||
|
||||
def test_get_all_details(self):
|
||||
r = self.call("actor_actions", "ue_get_all_details")
|
||||
self.assertSuccess(r)
|
||||
self.assertIsInstance(r["actors"], list)
|
||||
|
||||
def test_get_in_view_frustum(self):
|
||||
r = self.call("actor_actions", "ue_get_in_view_frustum")
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("visible_actors", r)
|
||||
|
||||
# ── transform ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_set_location(self):
|
||||
r = self.call("actor_actions", "ue_set_location",
|
||||
actor_label=self._actor_label, location=[100, 200, 300])
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_set_rotation(self):
|
||||
r = self.call("actor_actions", "ue_set_rotation",
|
||||
actor_label=self._actor_label, rotation=[0, 45, 0])
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_set_scale(self):
|
||||
r = self.call("actor_actions", "ue_set_scale",
|
||||
actor_label=self._actor_label, scale=[2, 2, 2])
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_set_transform_full(self):
|
||||
r = self.call("actor_actions", "ue_set_transform",
|
||||
actor_label=self._actor_label,
|
||||
location=[50, 50, 50], rotation=[0, 0, 0], scale=[1, 1, 1])
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_set_transform_unknown_actor(self):
|
||||
r = self.call("actor_actions", "ue_set_transform",
|
||||
actor_label="NonExistentActor_XYZ123", location=[0, 0, 0])
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
# ── property ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_get_property(self):
|
||||
r = self.call("actor_actions", "ue_get_property",
|
||||
actor_label=self._actor_label, property_name="can_be_damaged")
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("value", r)
|
||||
|
||||
def test_set_property(self):
|
||||
r = self.call("actor_actions", "ue_set_property",
|
||||
actor_label=self._actor_label,
|
||||
property_name="can_be_damaged", value=False)
|
||||
self.assertSuccess(r)
|
||||
|
||||
# ── selection ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_select_all(self):
|
||||
r = self.call("actor_actions", "ue_select_all")
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_invert_selection(self):
|
||||
self.call("actor_actions", "ue_select_all")
|
||||
r = self.call("actor_actions", "ue_invert_selection")
|
||||
self.assertSuccess(r)
|
||||
|
||||
# ── delete ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_delete_by_label(self):
|
||||
r = self.call("actor_actions", "ue_spawn_from_class",
|
||||
class_path=_CLASS_PATH, location=[9999, 9999, 9999])
|
||||
self.assertSuccess(r)
|
||||
extra_label = r["actor_label"]
|
||||
r = self.call("actor_actions", "ue_delete_by_label", actor_label=extra_label)
|
||||
self.assertSuccess(r)
|
||||
|
||||
# ── raycast ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_line_trace_no_hit(self):
|
||||
r = self.call("actor_actions", "ue_line_trace",
|
||||
ray_start=[0, 0, 100000], ray_end=[0, 0, 200000])
|
||||
self.assertSuccess(r)
|
||||
self.assertFalse(r.get("hit"))
|
||||
|
||||
# ── spawn from object ───────────────────────────────────────────────────────
|
||||
|
||||
def test_spawn_from_object(self):
|
||||
r = self.call("actor_actions", "ue_spawn_from_object",
|
||||
asset_path="/Engine/BasicShapes/Cube", location=[1500, 1500, 0])
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("actor_label", r)
|
||||
self.delete_actor_by_label(r["actor_label"])
|
||||
|
||||
def test_spawn_from_object_missing_asset(self):
|
||||
r = self.call("actor_actions", "ue_spawn_from_object",
|
||||
asset_path="/Game/DoesNotExist_XYZ123", location=[0, 0, 0])
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
# ── duplicate ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _select_setup_actor(self):
|
||||
import unreal
|
||||
sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
actor = next((a for a in sub.get_all_level_actors()
|
||||
if a.get_actor_label() == self._actor_label), None)
|
||||
self.assertIsNotNone(actor, "setUp actor not found in level")
|
||||
sub.set_selected_level_actors([actor])
|
||||
|
||||
def test_duplicate_selected(self):
|
||||
if not self._actor_label:
|
||||
self.skipTest("Actor not spawned in setUp")
|
||||
self._select_setup_actor()
|
||||
r = self.call("actor_actions", "ue_duplicate_selected", offset=[200, 0, 0])
|
||||
self.assertSuccess(r)
|
||||
self.assertIsInstance(r.get("duplicated_actors"), list)
|
||||
self.assertGreaterEqual(len(r["duplicated_actors"]), 1)
|
||||
for label in r["duplicated_actors"]:
|
||||
self.delete_actor_by_label(label)
|
||||
|
||||
def test_duplicate_selected_none_selected(self):
|
||||
import unreal
|
||||
unreal.get_editor_subsystem(unreal.EditorActorSubsystem).set_selected_level_actors([])
|
||||
r = self.call("actor_actions", "ue_duplicate_selected", offset=[0, 0, 0])
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
# ── spawn on surface (raycast) ─────────────────────────────────────────────
|
||||
|
||||
def test_spawn_on_surface_raycast_no_hit(self):
|
||||
# Raycast through empty space high above the level → no surface to hit.
|
||||
r = self.call("actor_actions", "ue_spawn_on_surface_raycast",
|
||||
asset_or_class_path=_CLASS_PATH,
|
||||
ray_start=[0, 0, 500000], ray_end=[0, 0, 600000])
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_spawn_on_surface_raycast_hit(self):
|
||||
# Place a cube as a surface, then raycast straight down onto it.
|
||||
floor = self.call("actor_actions", "ue_spawn_from_object",
|
||||
asset_path="/Engine/BasicShapes/Cube", location=[4000, 4000, 0])
|
||||
if not floor.get("success"):
|
||||
self.skipTest("Could not spawn floor cube for raycast surface")
|
||||
try:
|
||||
r = self.call("actor_actions", "ue_spawn_on_surface_raycast",
|
||||
asset_or_class_path=_CLASS_PATH,
|
||||
ray_start=[4000, 4000, 1000], ray_end=[4000, 4000, -1000])
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("actor_label", r)
|
||||
self.delete_actor_by_label(r["actor_label"])
|
||||
finally:
|
||||
self.delete_actor_by_label(floor["actor_label"])
|
||||
|
||||
# ── folders / tags / components / bounds ─────────────────────────────────────
|
||||
|
||||
def test_set_get_actor_folder(self):
|
||||
self.assertIsNotNone(self._actor_label, "no setUp actor")
|
||||
r = self.call("actor_actions", "ue_set_actor_folder",
|
||||
actor_label=self._actor_label, folder_path="MCP/TestFolder")
|
||||
self.assertSuccess(r)
|
||||
r = self.call("actor_actions", "ue_get_actor_folder", actor_label=self._actor_label)
|
||||
self.assertSuccess(r)
|
||||
self.assertEqual(r["folder_path"], "MCP/TestFolder")
|
||||
|
||||
def test_actor_tags(self):
|
||||
self.assertIsNotNone(self._actor_label, "no setUp actor")
|
||||
r = self.call("actor_actions", "ue_add_actor_tag",
|
||||
actor_label=self._actor_label, tag="MCP_Tag")
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("MCP_Tag", r["tags"])
|
||||
r = self.call("actor_actions", "ue_get_actor_tags", actor_label=self._actor_label)
|
||||
self.assertIn("MCP_Tag", r["tags"])
|
||||
r = self.call("actor_actions", "ue_remove_actor_tag",
|
||||
actor_label=self._actor_label, tag="MCP_Tag")
|
||||
self.assertSuccess(r)
|
||||
self.assertNotIn("MCP_Tag", r["tags"])
|
||||
|
||||
def test_list_actor_components(self):
|
||||
self.assertIsNotNone(self._actor_label, "no setUp actor")
|
||||
r = self.call("actor_actions", "ue_list_actor_components", actor_label=self._actor_label)
|
||||
self.assertSuccess(r)
|
||||
self.assertGreater(r["count"], 0)
|
||||
|
||||
def test_get_actor_bounds(self):
|
||||
self.assertIsNotNone(self._actor_label, "no setUp actor")
|
||||
r = self.call("actor_actions", "ue_get_actor_bounds", actor_label=self._actor_label)
|
||||
self.assertSuccess(r)
|
||||
self.assertEqual(len(r["extent"]), 3)
|
||||
|
||||
def test_get_actor_bounds_unknown(self):
|
||||
r = self.call("actor_actions", "ue_get_actor_bounds", actor_label="NoSuchActor_XYZ")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
# ── attach / detach ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_attach_and_detach(self):
|
||||
self.assertIsNotNone(self._actor_label, "no setUp actor")
|
||||
spawn = self.call("actor_actions", "ue_spawn_from_class",
|
||||
class_path=_CLASS_PATH, location=[300, 300, 300])
|
||||
self.assertSuccess(spawn)
|
||||
child = spawn["actor_label"]
|
||||
try:
|
||||
r = self.call("actor_actions", "ue_attach_actor",
|
||||
child_label=child, parent_label=self._actor_label)
|
||||
self.assertSuccess(r)
|
||||
r = self.call("actor_actions", "ue_get_attached_actors", actor_label=self._actor_label)
|
||||
self.assertSuccess(r)
|
||||
self.assertIn(child, r["attached"])
|
||||
r = self.call("actor_actions", "ue_detach_actor", actor_label=child)
|
||||
self.assertSuccess(r)
|
||||
r = self.call("actor_actions", "ue_get_attached_actors", actor_label=self._actor_label)
|
||||
self.assertNotIn(child, r["attached"])
|
||||
finally:
|
||||
self.delete_actor_by_label(child)
|
||||
|
||||
def test_attach_unknown_parent(self):
|
||||
self.assertIsNotNone(self._actor_label, "no setUp actor")
|
||||
r = self.call("actor_actions", "ue_attach_actor",
|
||||
child_label=self._actor_label, parent_label="NoSuchActor_XYZ")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_get_actors_of_class(self):
|
||||
self.assertIsNotNone(self._actor_label, "no setUp actor")
|
||||
r = self.call("actor_actions", "ue_get_actors_of_class",
|
||||
class_path="/Script/Engine.PointLight")
|
||||
self.assertSuccess(r)
|
||||
self.assertIn(self._actor_label, r["actors"])
|
||||
|
||||
def test_get_actors_of_class_invalid(self):
|
||||
r = self.call("actor_actions", "ue_get_actors_of_class",
|
||||
class_path="/Script/Engine.NopeXYZ123")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_get_selected_actors(self):
|
||||
self.call("actor_actions", "ue_select_all")
|
||||
r = self.call("actor_actions", "ue_get_selected_actors")
|
||||
self.assertSuccess(r)
|
||||
self.assertIsInstance(r["actors"], list)
|
||||
|
||||
def test_rename_actor(self):
|
||||
self.assertIsNotNone(self._actor_label, "no setUp actor")
|
||||
new = self._actor_label + "_Renamed"
|
||||
r = self.call("actor_actions", "ue_rename_actor",
|
||||
actor_label=self._actor_label, new_label=new)
|
||||
self.assertSuccess(r)
|
||||
self.assertEqual(r["new_label"], new)
|
||||
self._actor_label = new # so tearDown deletes the right one
|
||||
|
||||
def test_set_actor_hidden(self):
|
||||
self.assertIsNotNone(self._actor_label, "no setUp actor")
|
||||
r = self.call("actor_actions", "ue_set_actor_hidden",
|
||||
actor_label=self._actor_label, hidden=True)
|
||||
self.assertSuccess(r)
|
||||
self.call("actor_actions", "ue_set_actor_hidden",
|
||||
actor_label=self._actor_label, hidden=False)
|
||||
|
||||
def test_select_actors(self):
|
||||
self.assertIsNotNone(self._actor_label, "no setUp actor")
|
||||
r = self.call("actor_actions", "ue_select_actors", actor_labels=[self._actor_label])
|
||||
self.assertSuccess(r)
|
||||
self.assertIn(self._actor_label, r["selected"])
|
||||
|
||||
def test_select_actors_missing(self):
|
||||
r = self.call("actor_actions", "ue_select_actors", actor_labels=["NoSuchActor_XYZ"])
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("NoSuchActor_XYZ", r["missing"])
|
||||
|
||||
def test_get_transform(self):
|
||||
self.assertIsNotNone(self._actor_label, "no setUp actor")
|
||||
r = self.call("actor_actions", "ue_get_transform", actor_label=self._actor_label)
|
||||
self.assertSuccess(r)
|
||||
self.assertEqual(len(r["location"]), 3)
|
||||
self.assertEqual(len(r["rotation"]), 3)
|
||||
self.assertEqual(len(r["scale"]), 3)
|
||||
|
||||
def test_get_set_component_property(self):
|
||||
self.assertIsNotNone(self._actor_label, "no setUp actor")
|
||||
comps = self.call("actor_actions", "ue_list_actor_components",
|
||||
actor_label=self._actor_label)["components"]
|
||||
light = next((c["name"] for c in comps if "Light" in c["class"]), comps[0]["name"])
|
||||
r = self.call("actor_actions", "ue_set_component_property",
|
||||
actor_label=self._actor_label, component_name=light,
|
||||
property_name="intensity", value=12345.0)
|
||||
self.assertSuccess(r)
|
||||
r = self.call("actor_actions", "ue_get_component_property",
|
||||
actor_label=self._actor_label, component_name=light,
|
||||
property_name="intensity")
|
||||
self.assertSuccess(r)
|
||||
self.assertAlmostEqual(r["value"], 12345.0, places=1)
|
||||
|
||||
def test_get_component_property_unknown(self):
|
||||
self.assertIsNotNone(self._actor_label, "no setUp actor")
|
||||
r = self.call("actor_actions", "ue_get_component_property",
|
||||
actor_label=self._actor_label, component_name="NoSuchComp",
|
||||
property_name="intensity")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_duplicate_actor(self):
|
||||
self.assertIsNotNone(self._actor_label, "no setUp actor")
|
||||
r = self.call("actor_actions", "ue_duplicate_actor",
|
||||
actor_label=self._actor_label, offset=[150, 0, 0])
|
||||
self.assertSuccess(r)
|
||||
self.delete_actor_by_label(r["duplicated"])
|
||||
|
||||
def test_duplicate_actor_unknown(self):
|
||||
r = self.call("actor_actions", "ue_duplicate_actor", actor_label="NoSuchActor_XYZ")
|
||||
self.assertFalse(r.get("success"))
|
||||
@@ -0,0 +1,177 @@
|
||||
import unreal
|
||||
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
|
||||
|
||||
# Engine assets that ship with the editor: a Skeleton to bind, and a SkeletalMesh
|
||||
# used as a deliberately-wrong (non-Skeleton) asset for the type-guard test.
|
||||
_SKELETON = "/Engine/Tutorial/SubEditors/TutorialAssets/Character/TutorialTPP_Skeleton"
|
||||
_NON_SKELETON = "/Engine/Tutorial/SubEditors/TutorialAssets/Character/TutorialTPP"
|
||||
# Two AnimSequences that ship on the TutorialTPP skeleton — used for the AnimGraph builders.
|
||||
_IDLE_ANIM = "/Engine/Tutorial/SubEditors/TutorialAssets/Character/Tutorial_Idle"
|
||||
_WALK_ANIM = "/Engine/Tutorial/SubEditors/TutorialAssets/Character/Tutorial_Walk_Fwd"
|
||||
_ABP_PATH = f"{TEST_ROOT}/MCP_TestAnimBP"
|
||||
|
||||
|
||||
class TestAnimBlueprintActions(MCPTestCase):
|
||||
|
||||
def setUp(self):
|
||||
if not unreal.EditorAssetLibrary.does_asset_exist(_SKELETON):
|
||||
self.skipTest("Engine TutorialTPP_Skeleton not available")
|
||||
self.ensure_test_dir()
|
||||
self.delete_asset(_ABP_PATH)
|
||||
|
||||
def tearDown(self):
|
||||
self.delete_asset(_ABP_PATH)
|
||||
|
||||
# ── create + introspection happy path ─────────────────────────────────────────
|
||||
|
||||
def test_create_binds_skeleton_and_info_reports_it(self):
|
||||
r = self.call("anim_blueprint_actions", "ue_create_anim_blueprint",
|
||||
asset_path=_ABP_PATH, skeleton_path=_SKELETON)
|
||||
self.assertSuccess(r)
|
||||
self.assertTrue(unreal.EditorAssetLibrary.does_asset_exist(_ABP_PATH))
|
||||
|
||||
info = self.call("anim_blueprint_actions", "ue_get_anim_blueprint_info", asset_path=_ABP_PATH)
|
||||
self.assertSuccess(info)
|
||||
# the skeleton must actually be bound (the whole point of an AnimBlueprint)
|
||||
self.assertEqual(info["target_skeleton"].split(".")[0], _SKELETON)
|
||||
# an AnimBlueprint always owns both an AnimGraph and an EventGraph
|
||||
self.assertIn("AnimGraph", info["graphs"])
|
||||
self.assertIn("EventGraph", info["graphs"])
|
||||
self.assertTrue(info["generated_class"].endswith("_C"))
|
||||
|
||||
def test_create_with_custom_anim_instance_parent(self):
|
||||
# AnimInstance itself is a valid explicit parent; proves parent validation accepts it.
|
||||
r = self.call("anim_blueprint_actions", "ue_create_anim_blueprint",
|
||||
asset_path=_ABP_PATH, skeleton_path=_SKELETON,
|
||||
parent_class_path="/Script/Engine.AnimInstance")
|
||||
self.assertSuccess(r)
|
||||
self.assertEqual(r["parent_class"], "/Script/Engine.AnimInstance")
|
||||
|
||||
# ── create guards (each a distinct failure mode) ──────────────────────────────
|
||||
|
||||
def test_create_requires_skeleton_param(self):
|
||||
r = self.call("anim_blueprint_actions", "ue_create_anim_blueprint", asset_path=_ABP_PATH)
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_create_rejects_missing_skeleton_asset(self):
|
||||
r = self.call("anim_blueprint_actions", "ue_create_anim_blueprint",
|
||||
asset_path=_ABP_PATH, skeleton_path=f"{TEST_ROOT}/NoSuchSkeleton_XYZ")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_create_rejects_non_skeleton_asset(self):
|
||||
# pointing skeleton_path at a SkeletalMesh must be caught by the type guard,
|
||||
# not silently accepted (which would produce a broken AnimBlueprint).
|
||||
if not unreal.EditorAssetLibrary.does_asset_exist(_NON_SKELETON):
|
||||
self.skipTest("Engine TutorialTPP mesh not available")
|
||||
r = self.call("anim_blueprint_actions", "ue_create_anim_blueprint",
|
||||
asset_path=_ABP_PATH, skeleton_path=_NON_SKELETON)
|
||||
self.assertFalse(r.get("success"))
|
||||
self.assertIn("Skeleton", r.get("message", ""))
|
||||
|
||||
def test_create_rejects_non_anim_instance_parent(self):
|
||||
r = self.call("anim_blueprint_actions", "ue_create_anim_blueprint",
|
||||
asset_path=_ABP_PATH, skeleton_path=_SKELETON,
|
||||
parent_class_path="/Script/Engine.Actor")
|
||||
self.assertFalse(r.get("success"))
|
||||
self.assertIn("AnimInstance", r.get("message", ""))
|
||||
|
||||
def test_create_rejects_duplicate(self):
|
||||
first = self.call("anim_blueprint_actions", "ue_create_anim_blueprint",
|
||||
asset_path=_ABP_PATH, skeleton_path=_SKELETON)
|
||||
self.assertSuccess(first)
|
||||
again = self.call("anim_blueprint_actions", "ue_create_anim_blueprint",
|
||||
asset_path=_ABP_PATH, skeleton_path=_SKELETON)
|
||||
self.assertFalse(again.get("success"))
|
||||
self.assertIn("already exists", again.get("message", ""))
|
||||
|
||||
# ── info guards ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_info_requires_asset_path(self):
|
||||
r = self.call("anim_blueprint_actions", "ue_get_anim_blueprint_info")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_info_rejects_non_anim_blueprint(self):
|
||||
# the Skeleton asset exists but is not an AnimBlueprint — type guard must reject it.
|
||||
r = self.call("anim_blueprint_actions", "ue_get_anim_blueprint_info", asset_path=_SKELETON)
|
||||
self.assertFalse(r.get("success"))
|
||||
self.assertIn("AnimBlueprint", r.get("message", ""))
|
||||
|
||||
# ── AnimGraph node authoring (C++ helper) ─────────────────────────────────────
|
||||
|
||||
def _make_abp(self):
|
||||
r = self.call("anim_blueprint_actions", "ue_create_anim_blueprint",
|
||||
asset_path=_ABP_PATH, skeleton_path=_SKELETON)
|
||||
self.assertSuccess(r)
|
||||
|
||||
def _anim_graph_node_classes(self):
|
||||
info = self.call("blueprint_actions", "ue_get_blueprint_graph_info",
|
||||
asset_path=_ABP_PATH, graph_name="AnimGraph")
|
||||
self.assertSuccess(info)
|
||||
return [n["node_class"] for n in info["nodes"]]
|
||||
|
||||
def test_add_sequence_player_links_to_output(self):
|
||||
if not unreal.EditorAssetLibrary.does_asset_exist(_IDLE_ANIM):
|
||||
self.skipTest("Engine Tutorial_Idle anim not available")
|
||||
self._make_abp()
|
||||
r = self.call("anim_blueprint_actions", "ue_add_anim_graph_sequence_player",
|
||||
asset_path=_ABP_PATH, anim_sequence_path=_IDLE_ANIM, link_to_output_pose=True)
|
||||
self.assertSuccess(r)
|
||||
self.assertTrue(r["linked_to_output"])
|
||||
# the node must actually exist in the AnimGraph
|
||||
self.assertIn("AnimGraphNode_SequencePlayer", self._anim_graph_node_classes())
|
||||
|
||||
def test_add_sequence_player_rejects_missing_anim(self):
|
||||
self._make_abp()
|
||||
r = self.call("anim_blueprint_actions", "ue_add_anim_graph_sequence_player",
|
||||
asset_path=_ABP_PATH, anim_sequence_path=f"{TEST_ROOT}/NoSuchAnim_XYZ")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
# ── generic spec-driven state machine ─────────────────────────────────────────
|
||||
|
||||
def test_build_generic_three_state_machine(self):
|
||||
for a in (_IDLE_ANIM, _WALK_ANIM):
|
||||
if not unreal.EditorAssetLibrary.does_asset_exist(a):
|
||||
self.skipTest("Engine tutorial locomotion anims not available")
|
||||
self._make_abp()
|
||||
spec = {
|
||||
"machine_name": "Locomotion", "entry": "Idle",
|
||||
"states": [
|
||||
{"name": "Idle", "anim": _IDLE_ANIM},
|
||||
{"name": "Walk", "anim": _WALK_ANIM},
|
||||
{"name": "Run", "anim": _WALK_ANIM},
|
||||
],
|
||||
"transitions": [
|
||||
{"from": "Idle", "to": "Walk", "var": "Speed", "op": ">", "value": 10},
|
||||
{"from": "Walk", "to": "Idle", "var": "Speed", "op": "<", "value": 10},
|
||||
{"from": "Walk", "to": "Run", "var": "Speed", "op": ">", "value": 300},
|
||||
{"from": "Run", "to": "Walk", "var": "Speed", "op": "<", "value": 300},
|
||||
],
|
||||
}
|
||||
r = self.call("anim_blueprint_actions", "ue_build_anim_state_machine",
|
||||
asset_path=_ABP_PATH, spec=spec)
|
||||
self.assertSuccess(r)
|
||||
self.assertEqual(r["states"], ["Idle", "Walk", "Run"])
|
||||
self.assertEqual(r["transition_count"], 4)
|
||||
self.assertEqual(r.get("warnings", []), []) # all 4 speed rules wired, no fallbacks
|
||||
self.assertIn("AnimGraphNode_StateMachine", self._anim_graph_node_classes())
|
||||
|
||||
def test_build_generic_rejects_unknown_transition_state(self):
|
||||
self._make_abp()
|
||||
spec = {"states": [{"name": "A", "anim": _IDLE_ANIM}],
|
||||
"transitions": [{"from": "A", "to": "DoesNotExist"}]}
|
||||
r = self.call("anim_blueprint_actions", "ue_build_anim_state_machine",
|
||||
asset_path=_ABP_PATH, spec=spec)
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_build_generic_rejects_empty_states(self):
|
||||
self._make_abp()
|
||||
r = self.call("anim_blueprint_actions", "ue_build_anim_state_machine",
|
||||
asset_path=_ABP_PATH, spec={"states": []})
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_build_generic_rejects_missing_anim(self):
|
||||
self._make_abp()
|
||||
r = self.call("anim_blueprint_actions", "ue_build_anim_state_machine",
|
||||
asset_path=_ABP_PATH,
|
||||
spec={"states": [{"name": "Idle", "anim": f"{TEST_ROOT}/NoSuchAnim_XYZ"}]})
|
||||
self.assertFalse(r.get("success"))
|
||||
@@ -0,0 +1,202 @@
|
||||
import unreal
|
||||
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
|
||||
|
||||
# A reliably-present engine AnimSequence we duplicate into /Game to edit safely.
|
||||
_SRC_ANIM = "/Engine/Tutorial/SubEditors/TutorialAssets/Character/Tutorial_Idle"
|
||||
_ANIM_NAME = "MCP_TestAnim"
|
||||
_ANIM_PATH = f"{TEST_ROOT}/{_ANIM_NAME}"
|
||||
# Engine skeletal mesh that ships with 7 sockets — used read-only for socket queries.
|
||||
_TPP_MESH = "/Engine/Tutorial/SubEditors/TutorialAssets/Character/TutorialTPP"
|
||||
|
||||
|
||||
class TestAnimationActions(MCPTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self._anim_path = None
|
||||
self.ensure_test_dir()
|
||||
self.delete_asset(_ANIM_PATH)
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(_SRC_ANIM):
|
||||
seq = unreal.EditorAssetLibrary.duplicate_asset(_SRC_ANIM, _ANIM_PATH)
|
||||
if seq:
|
||||
self._anim_path = _ANIM_PATH
|
||||
|
||||
def tearDown(self):
|
||||
if self._anim_path:
|
||||
self.delete_asset(self._anim_path)
|
||||
|
||||
def _skip_if_no_anim(self):
|
||||
if not self._anim_path:
|
||||
self.skipTest("Test AnimSequence not available (engine source missing)")
|
||||
|
||||
# ── introspection ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_get_anim_sequence_info(self):
|
||||
self._skip_if_no_anim()
|
||||
r = self.call("animation_actions", "ue_get_anim_sequence_info", asset_path=self._anim_path)
|
||||
self.assertSuccess(r)
|
||||
self.assertGreater(r["num_frames"], 0)
|
||||
self.assertGreater(r["length_seconds"], 0)
|
||||
self.assertIsNotNone(r["skeleton"])
|
||||
|
||||
def test_list_notify_tracks(self):
|
||||
self._skip_if_no_anim()
|
||||
r = self.call("animation_actions", "ue_list_notify_tracks", asset_path=self._anim_path)
|
||||
self.assertSuccess(r)
|
||||
self.assertIsInstance(r["tracks"], list)
|
||||
|
||||
def test_list_notifies(self):
|
||||
self._skip_if_no_anim()
|
||||
r = self.call("animation_actions", "ue_list_notifies", asset_path=self._anim_path)
|
||||
self.assertSuccess(r)
|
||||
self.assertIsInstance(r["notifies"], list)
|
||||
|
||||
def test_list_curves(self):
|
||||
self._skip_if_no_anim()
|
||||
r = self.call("animation_actions", "ue_list_curves", asset_path=self._anim_path)
|
||||
self.assertSuccess(r)
|
||||
self.assertIsInstance(r["curves"], list)
|
||||
|
||||
def test_list_sync_markers(self):
|
||||
self._skip_if_no_anim()
|
||||
r = self.call("animation_actions", "ue_list_sync_markers", asset_path=self._anim_path)
|
||||
self.assertSuccess(r)
|
||||
self.assertIsInstance(r["sync_markers"], list)
|
||||
|
||||
# ── notify tracks ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_add_and_remove_notify_track(self):
|
||||
self._skip_if_no_anim()
|
||||
r = self.call("animation_actions", "ue_add_notify_track",
|
||||
asset_path=self._anim_path, track_name="MCP_Track")
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("MCP_Track", r["tracks"])
|
||||
r = self.call("animation_actions", "ue_remove_notify_track",
|
||||
asset_path=self._anim_path, track_name="MCP_Track")
|
||||
self.assertSuccess(r)
|
||||
self.assertNotIn("MCP_Track", r["tracks"])
|
||||
|
||||
def test_remove_notify_track_unknown(self):
|
||||
self._skip_if_no_anim()
|
||||
r = self.call("animation_actions", "ue_remove_notify_track",
|
||||
asset_path=self._anim_path, track_name="NoSuchTrack_XYZ")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
# ── sync markers ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_add_sync_marker(self):
|
||||
self._skip_if_no_anim()
|
||||
r = self.call("animation_actions", "ue_add_sync_marker",
|
||||
asset_path=self._anim_path, track_name="MCP_SyncTrack",
|
||||
marker_name="MCP_Marker", time_seconds=0.5)
|
||||
self.assertSuccess(r)
|
||||
markers = self.call("animation_actions", "ue_list_sync_markers", asset_path=self._anim_path)
|
||||
names = [m["name"] for m in markers["sync_markers"]]
|
||||
self.assertIn("MCP_Marker", names)
|
||||
|
||||
# ── curves ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_add_and_remove_float_curve(self):
|
||||
self._skip_if_no_anim()
|
||||
r = self.call("animation_actions", "ue_add_float_curve",
|
||||
asset_path=self._anim_path, curve_name="MCP_Curve",
|
||||
time_seconds=0.0, value=1.0)
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("MCP_Curve", r["curves"])
|
||||
r = self.call("animation_actions", "ue_remove_curve",
|
||||
asset_path=self._anim_path, curve_name="MCP_Curve")
|
||||
self.assertSuccess(r)
|
||||
self.assertNotIn("MCP_Curve", r["curves"])
|
||||
|
||||
def test_remove_curve_unknown(self):
|
||||
self._skip_if_no_anim()
|
||||
r = self.call("animation_actions", "ue_remove_curve",
|
||||
asset_path=self._anim_path, curve_name="NoSuchCurve_XYZ")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
# ── skeletal mesh / socket queries (read-only engine assets) ─────────────────
|
||||
|
||||
def test_get_skeletal_mesh_info(self):
|
||||
if not unreal.EditorAssetLibrary.does_asset_exist(_TPP_MESH):
|
||||
self.skipTest("Engine TutorialTPP mesh not available")
|
||||
r = self.call("animation_actions", "ue_get_skeletal_mesh_info", asset_path=_TPP_MESH)
|
||||
self.assertSuccess(r)
|
||||
self.assertIsNotNone(r["skeleton"])
|
||||
self.assertGreater(r["num_sockets"], 0)
|
||||
|
||||
def test_list_sockets(self):
|
||||
if not unreal.EditorAssetLibrary.does_asset_exist(_TPP_MESH):
|
||||
self.skipTest("Engine TutorialTPP mesh not available")
|
||||
r = self.call("animation_actions", "ue_list_sockets", asset_path=_TPP_MESH)
|
||||
self.assertSuccess(r)
|
||||
self.assertEqual(r["num_sockets"], len(r["sockets"]))
|
||||
self.assertIn("bone", r["sockets"][0])
|
||||
|
||||
def test_find_socket(self):
|
||||
if not unreal.EditorAssetLibrary.does_asset_exist(_TPP_MESH):
|
||||
self.skipTest("Engine TutorialTPP mesh not available")
|
||||
listed = self.call("animation_actions", "ue_list_sockets", asset_path=_TPP_MESH)
|
||||
name = listed["sockets"][0]["name"]
|
||||
r = self.call("animation_actions", "ue_find_socket", asset_path=_TPP_MESH, socket_name=name)
|
||||
self.assertSuccess(r)
|
||||
self.assertEqual(r["name"], name)
|
||||
|
||||
def test_find_socket_unknown(self):
|
||||
if not unreal.EditorAssetLibrary.does_asset_exist(_TPP_MESH):
|
||||
self.skipTest("Engine TutorialTPP mesh not available")
|
||||
r = self.call("animation_actions", "ue_find_socket",
|
||||
asset_path=_TPP_MESH, socket_name="NoSuchSocket_XYZ")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_get_skeleton_info(self):
|
||||
if not self._anim_path:
|
||||
self.skipTest("Test AnimSequence not available")
|
||||
skel_path = self.call("animation_actions", "ue_get_anim_sequence_info",
|
||||
asset_path=self._anim_path)["skeleton"].split(".")[0]
|
||||
r = self.call("animation_actions", "ue_get_skeleton_info", asset_path=skel_path)
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("curve_names", r)
|
||||
|
||||
# ── bones + socket editing (C++ helper; needs the plugin built with these UFUNCTIONs) ──
|
||||
|
||||
def test_list_bones(self):
|
||||
if not unreal.EditorAssetLibrary.does_asset_exist(_TPP_MESH):
|
||||
self.skipTest("Engine TutorialTPP mesh not available")
|
||||
r = self.call("animation_actions", "ue_list_bones", asset_path=_TPP_MESH)
|
||||
self.assertSuccess(r)
|
||||
self.assertGreater(r["bone_count"], 0)
|
||||
self.assertIn("name", r["bones"][0])
|
||||
|
||||
def test_add_and_remove_socket(self):
|
||||
if not unreal.EditorAssetLibrary.does_asset_exist(_TPP_MESH):
|
||||
self.skipTest("Engine TutorialTPP mesh not available")
|
||||
dup = f"{TEST_ROOT}/MCP_TestSkelMesh"
|
||||
self.delete_asset(dup)
|
||||
mesh = unreal.EditorAssetLibrary.duplicate_asset(_TPP_MESH, dup)
|
||||
self.assertIsNotNone(mesh, "could not duplicate skeletal mesh")
|
||||
try:
|
||||
bone = self.call("animation_actions", "ue_list_bones", asset_path=dup)["bones"][0]["name"]
|
||||
r = self.call("animation_actions", "ue_add_socket", asset_path=dup,
|
||||
socket_name="MCP_Socket", bone_name=bone, location=[0, 0, 10])
|
||||
self.assertSuccess(r)
|
||||
socks = self.call("animation_actions", "ue_list_sockets", asset_path=dup)
|
||||
self.assertIn("MCP_Socket", [s["name"] for s in socks["sockets"]])
|
||||
r = self.call("animation_actions", "ue_remove_socket", asset_path=dup, socket_name="MCP_Socket")
|
||||
self.assertSuccess(r)
|
||||
socks = self.call("animation_actions", "ue_list_sockets", asset_path=dup)
|
||||
self.assertNotIn("MCP_Socket", [s["name"] for s in socks["sockets"]])
|
||||
finally:
|
||||
self.delete_asset(dup)
|
||||
|
||||
def test_add_socket_invalid_bone(self):
|
||||
if not unreal.EditorAssetLibrary.does_asset_exist(_TPP_MESH):
|
||||
self.skipTest("Engine TutorialTPP mesh not available")
|
||||
dup = f"{TEST_ROOT}/MCP_TestSkelMesh2"
|
||||
self.delete_asset(dup)
|
||||
mesh = unreal.EditorAssetLibrary.duplicate_asset(_TPP_MESH, dup)
|
||||
self.assertIsNotNone(mesh)
|
||||
try:
|
||||
r = self.call("animation_actions", "ue_add_socket", asset_path=dup,
|
||||
socket_name="MCP_Bad", bone_name="NoSuchBone_XYZ", location=[0, 0, 0])
|
||||
self.assertFalse(r.get("success"))
|
||||
finally:
|
||||
self.delete_asset(dup)
|
||||
@@ -0,0 +1,237 @@
|
||||
from UnrealMCPython.tests.base import MCPTestCase
|
||||
|
||||
|
||||
class TestAssetActions(MCPTestCase):
|
||||
|
||||
def test_find_by_type(self):
|
||||
r = self.call("asset_actions", "ue_find_by_query", asset_type="StaticMesh")
|
||||
self.assertSuccess(r)
|
||||
self.assertIsInstance(r["assets"], list)
|
||||
|
||||
def test_find_by_name(self):
|
||||
r = self.call("asset_actions", "ue_find_by_query", name="Cube")
|
||||
self.assertSuccess(r)
|
||||
self.assertIsInstance(r["assets"], list)
|
||||
|
||||
def test_find_by_name_and_type(self):
|
||||
r = self.call("asset_actions", "ue_find_by_query",
|
||||
name="Cube", asset_type="StaticMesh")
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_find_missing_params_fails(self):
|
||||
r = self.call("asset_actions", "ue_find_by_query")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_get_static_mesh_details_invalid(self):
|
||||
r = self.call("asset_actions", "ue_get_static_mesh_details",
|
||||
asset_path="/Game/DoesNotExist/FakeMesh")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
|
||||
# ── asset management ─────────────────────────────────────────────────────────
|
||||
|
||||
_SRC = "/Engine/BasicShapes/Cube"
|
||||
_DIR = "/Game/Tests/MCP_AssetMgmt"
|
||||
|
||||
def _dup(self, name):
|
||||
import unreal
|
||||
dst = f"{self._DIR}/{name}"
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(dst):
|
||||
unreal.EditorAssetLibrary.delete_asset(dst)
|
||||
return dst
|
||||
|
||||
def test_asset_exists(self):
|
||||
r = self.call("asset_actions", "ue_asset_exists", asset_path=self._SRC)
|
||||
self.assertSuccess(r)
|
||||
self.assertTrue(r["exists"])
|
||||
r = self.call("asset_actions", "ue_asset_exists", asset_path="/Game/Nope_XYZ")
|
||||
self.assertFalse(r["exists"])
|
||||
|
||||
def test_get_asset_info(self):
|
||||
r = self.call("asset_actions", "ue_get_asset_info", asset_path=self._SRC)
|
||||
self.assertSuccess(r)
|
||||
self.assertEqual(r["asset_class"], "StaticMesh")
|
||||
|
||||
def test_duplicate_and_delete_asset(self):
|
||||
import unreal
|
||||
self.call("asset_actions", "ue_make_directory", directory_path=self._DIR)
|
||||
dst = self._dup("DupCube")
|
||||
try:
|
||||
r = self.call("asset_actions", "ue_duplicate_asset", source_path=self._SRC, dest_path=dst)
|
||||
self.assertSuccess(r)
|
||||
self.assertTrue(unreal.EditorAssetLibrary.does_asset_exist(dst))
|
||||
r = self.call("asset_actions", "ue_delete_asset", asset_path=dst)
|
||||
self.assertSuccess(r)
|
||||
self.assertFalse(unreal.EditorAssetLibrary.does_asset_exist(dst))
|
||||
finally:
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(dst):
|
||||
unreal.EditorAssetLibrary.delete_asset(dst)
|
||||
|
||||
def test_rename_asset(self):
|
||||
import unreal
|
||||
self.call("asset_actions", "ue_make_directory", directory_path=self._DIR)
|
||||
src = self._dup("RenameSrc")
|
||||
dst = self._dup("RenameDst")
|
||||
unreal.EditorAssetLibrary.duplicate_asset(self._SRC, src)
|
||||
try:
|
||||
r = self.call("asset_actions", "ue_rename_asset", source_path=src, dest_path=dst)
|
||||
self.assertSuccess(r)
|
||||
self.assertTrue(unreal.EditorAssetLibrary.does_asset_exist(dst))
|
||||
finally:
|
||||
for p in (src, dst):
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(p):
|
||||
unreal.EditorAssetLibrary.delete_asset(p)
|
||||
|
||||
def test_list_assets(self):
|
||||
r = self.call("asset_actions", "ue_list_assets",
|
||||
directory_path="/Engine/BasicShapes", recursive=True)
|
||||
self.assertSuccess(r)
|
||||
self.assertGreater(r["count"], 0)
|
||||
|
||||
def test_find_referencers(self):
|
||||
r = self.call("asset_actions", "ue_find_referencers", asset_path=self._SRC)
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("referencers", r)
|
||||
|
||||
def test_make_and_delete_directory(self):
|
||||
import unreal
|
||||
d = "/Game/Tests/MCP_TempDir"
|
||||
r = self.call("asset_actions", "ue_make_directory", directory_path=d)
|
||||
self.assertSuccess(r)
|
||||
self.assertTrue(unreal.EditorAssetLibrary.does_directory_exist(d))
|
||||
r = self.call("asset_actions", "ue_delete_directory", directory_path=d)
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_delete_asset_missing(self):
|
||||
r = self.call("asset_actions", "ue_delete_asset", asset_path="/Game/Nope_XYZ123")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_save_asset(self):
|
||||
import unreal
|
||||
self.call("asset_actions", "ue_make_directory", directory_path=self._DIR)
|
||||
dst = self._dup("SaveCube")
|
||||
unreal.EditorAssetLibrary.duplicate_asset(self._SRC, dst)
|
||||
try:
|
||||
r = self.call("asset_actions", "ue_save_asset", asset_path=dst)
|
||||
self.assertSuccess(r)
|
||||
finally:
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(dst):
|
||||
unreal.EditorAssetLibrary.delete_asset(dst)
|
||||
|
||||
def test_save_asset_missing(self):
|
||||
r = self.call("asset_actions", "ue_save_asset", asset_path="/Game/Nope_XYZ123")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_get_dependencies(self):
|
||||
r = self.call("asset_actions", "ue_get_dependencies", asset_path=self._SRC)
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("dependencies", r)
|
||||
|
||||
def test_get_dependencies_missing(self):
|
||||
r = self.call("asset_actions", "ue_get_dependencies", asset_path="/Game/Nope_XYZ")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_metadata_tag_roundtrip(self):
|
||||
import unreal
|
||||
self.call("asset_actions", "ue_make_directory", directory_path=self._DIR)
|
||||
dst = self._dup("MetaCube")
|
||||
unreal.EditorAssetLibrary.duplicate_asset(self._SRC, dst)
|
||||
try:
|
||||
r = self.call("asset_actions", "ue_set_metadata_tag",
|
||||
asset_path=dst, tag="MCP_Tag", value="hello")
|
||||
self.assertSuccess(r)
|
||||
r = self.call("asset_actions", "ue_get_metadata_tag", asset_path=dst, tag="MCP_Tag")
|
||||
self.assertEqual(r["value"], "hello")
|
||||
r = self.call("asset_actions", "ue_remove_metadata_tag", asset_path=dst, tag="MCP_Tag")
|
||||
self.assertSuccess(r)
|
||||
r = self.call("asset_actions", "ue_get_metadata_tag", asset_path=dst, tag="MCP_Tag")
|
||||
self.assertEqual(r["value"], "")
|
||||
finally:
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(dst):
|
||||
unreal.EditorAssetLibrary.delete_asset(dst)
|
||||
|
||||
# ── file import / export ─────────────────────────────────────────────────────
|
||||
|
||||
def test_export_import_fbx_roundtrip(self):
|
||||
import unreal, os, tempfile
|
||||
fbx = os.path.join(tempfile.gettempdir(), "mcp_test_roundtrip.fbx")
|
||||
imported = None
|
||||
try:
|
||||
r = self.call("asset_actions", "ue_export_fbx",
|
||||
asset_path="/Engine/BasicShapes/Cube", file_path=fbx)
|
||||
self.assertSuccess(r)
|
||||
self.assertGreater(r["file_size"], 0)
|
||||
r = self.call("asset_actions", "ue_import_fbx",
|
||||
file_path=fbx, destination_path="/Game/Tests/MCP",
|
||||
destination_name="MCP_RoundtripCube")
|
||||
self.assertSuccess(r)
|
||||
imported = r["imported_assets"][0]
|
||||
self.assertTrue(unreal.EditorAssetLibrary.does_asset_exist(imported))
|
||||
finally:
|
||||
if imported:
|
||||
self.delete_asset(imported)
|
||||
if os.path.exists(fbx):
|
||||
os.remove(fbx)
|
||||
|
||||
def test_import_texture(self):
|
||||
import unreal, os, tempfile, struct, zlib
|
||||
def png_bytes():
|
||||
w = h = 2
|
||||
raw = b""
|
||||
for _ in range(h):
|
||||
raw += b"\x00" + (b"\xff\x00\x00") * w
|
||||
def chunk(t, d):
|
||||
c = t + d
|
||||
return struct.pack(">I", len(d)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
|
||||
return (b"\x89PNG\r\n\x1a\n"
|
||||
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
|
||||
+ chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b""))
|
||||
p = os.path.join(tempfile.gettempdir(), "mcp_test_tex.png")
|
||||
with open(p, "wb") as f:
|
||||
f.write(png_bytes())
|
||||
imported = None
|
||||
try:
|
||||
r = self.call("asset_actions", "ue_import_texture",
|
||||
file_path=p, destination_path="/Game/Tests/MCP",
|
||||
destination_name="MCP_ImportedTex")
|
||||
self.assertSuccess(r)
|
||||
imported = r["imported_assets"][0]
|
||||
self.assertTrue(unreal.EditorAssetLibrary.does_asset_exist(imported))
|
||||
finally:
|
||||
if imported:
|
||||
self.delete_asset(imported)
|
||||
os.remove(p)
|
||||
|
||||
def test_export_fbx_unsupported(self):
|
||||
import os, tempfile
|
||||
r = self.call("asset_actions", "ue_export_fbx",
|
||||
asset_path="/Engine/BasicShapes/BasicShapeMaterial",
|
||||
file_path=os.path.join(tempfile.gettempdir(), "nope.fbx"))
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_import_fbx_missing_file(self):
|
||||
r = self.call("asset_actions", "ue_import_fbx",
|
||||
file_path="C:/no/such/file.fbx", destination_path="/Game/Tests/MCP")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
# ── glTF import (deferred-tick; happy path is covered by test_e2e) ────────────
|
||||
|
||||
def test_import_gltf_missing_param(self):
|
||||
r = self.call("asset_actions", "ue_import_gltf", file_path="C:/x.glb")
|
||||
self.assertFalse(r.get("success")) # destination_path missing
|
||||
|
||||
def test_import_gltf_file_not_found(self):
|
||||
r = self.call("asset_actions", "ue_import_gltf",
|
||||
file_path="C:/no/such/model.glb", destination_path="/Game/Tests/MCP/glb_guard")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_get_gltf_import_status_missing_param(self):
|
||||
r = self.call("asset_actions", "ue_get_gltf_import_status")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_get_gltf_import_status_no_import(self):
|
||||
# nothing scheduled for this path → valid response, not done yet (pending)
|
||||
r = self.call("asset_actions", "ue_get_gltf_import_status",
|
||||
destination_path="/Game/Tests/MCP/never_imported_xyz")
|
||||
self.assertSuccess(r)
|
||||
self.assertFalse(r["done"])
|
||||
@@ -0,0 +1,128 @@
|
||||
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
|
||||
|
||||
_BT_PATH = f"{TEST_ROOT}/MCP_TestBT"
|
||||
_BB_PATH = f"{TEST_ROOT}/MCP_TestBB"
|
||||
|
||||
|
||||
class TestBehaviorTreeActions(MCPTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self._bt_path = None
|
||||
self._bb_path = None
|
||||
self.ensure_test_dir()
|
||||
|
||||
r = self.call("behavior_tree_actions", "ue_create_blackboard",
|
||||
asset_path=_BB_PATH)
|
||||
if r.get("success"):
|
||||
self._bb_path = _BB_PATH
|
||||
|
||||
r = self.call("behavior_tree_actions", "ue_create_behavior_tree",
|
||||
asset_path=_BT_PATH)
|
||||
if r.get("success"):
|
||||
self._bt_path = _BT_PATH
|
||||
|
||||
def tearDown(self):
|
||||
if self._bt_path:
|
||||
self.delete_asset(self._bt_path)
|
||||
if self._bb_path:
|
||||
self.delete_asset(self._bb_path)
|
||||
|
||||
def _skip_if_no_bt(self):
|
||||
if not self._bt_path:
|
||||
self.skipTest("BehaviorTree not created in setUp")
|
||||
|
||||
def _skip_if_no_bb(self):
|
||||
if not self._bb_path:
|
||||
self.skipTest("Blackboard not created in setUp")
|
||||
|
||||
# ── list / read ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_list_behavior_trees(self):
|
||||
r = self.call("behavior_tree_actions", "ue_list_behavior_trees")
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_list_bt_node_classes(self):
|
||||
r = self.call("behavior_tree_actions", "ue_list_bt_node_classes")
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_get_behavior_tree_structure(self):
|
||||
self._skip_if_no_bt()
|
||||
# Build a minimal tree first so the BT is non-empty
|
||||
self.call("behavior_tree_actions", "ue_build_behavior_tree",
|
||||
asset_path=self._bt_path,
|
||||
tree_structure={"node_class": "BTComposite_Selector", "children": []})
|
||||
r = self.call("behavior_tree_actions", "ue_get_behavior_tree_structure",
|
||||
asset_path=self._bt_path)
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_get_blackboard_data(self):
|
||||
self._skip_if_no_bb()
|
||||
r = self.call("behavior_tree_actions", "ue_get_blackboard_data",
|
||||
asset_path=self._bb_path)
|
||||
self.assertSuccess(r)
|
||||
|
||||
# ── blackboard keys ───────────────────────────────────────────────────────
|
||||
|
||||
def test_add_and_remove_blackboard_key(self):
|
||||
self._skip_if_no_bb()
|
||||
r = self.call("behavior_tree_actions", "ue_add_blackboard_key",
|
||||
asset_path=self._bb_path,
|
||||
key_name="TestFloat", key_type="Float")
|
||||
self.assertSuccess(r)
|
||||
|
||||
r = self.call("behavior_tree_actions", "ue_remove_blackboard_key",
|
||||
asset_path=self._bb_path, key_name="TestFloat")
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_add_multiple_key_types(self):
|
||||
self._skip_if_no_bb()
|
||||
for key_type in ("Bool", "Int", "Vector", "String"):
|
||||
r = self.call("behavior_tree_actions", "ue_add_blackboard_key",
|
||||
asset_path=self._bb_path,
|
||||
key_name=f"Test_{key_type}", key_type=key_type)
|
||||
self.assertSuccess(r, f"Failed to add {key_type} key")
|
||||
|
||||
# ── link ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_set_blackboard_to_behavior_tree(self):
|
||||
self._skip_if_no_bt()
|
||||
self._skip_if_no_bb()
|
||||
r = self.call("behavior_tree_actions", "ue_set_blackboard_to_behavior_tree",
|
||||
bt_path=self._bt_path, bb_path=self._bb_path)
|
||||
self.assertSuccess(r)
|
||||
|
||||
# ── build ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_build_behavior_tree(self):
|
||||
self._skip_if_no_bt()
|
||||
tree = {
|
||||
"node_class": "BTComposite_Selector",
|
||||
"children": [
|
||||
{"node_class": "BTTask_Wait", "properties": {"wait_time": 1.0}},
|
||||
]
|
||||
}
|
||||
r = self.call("behavior_tree_actions", "ue_build_behavior_tree",
|
||||
asset_path=self._bt_path, tree_structure=tree)
|
||||
self.assertSuccess(r)
|
||||
|
||||
# ── node details / selection ────────────────────────────────────────────────
|
||||
|
||||
def test_get_bt_node_details(self):
|
||||
self._skip_if_no_bt()
|
||||
# Build a tree, then read a node's details by its name from the structure.
|
||||
self.call("behavior_tree_actions", "ue_build_behavior_tree",
|
||||
asset_path=self._bt_path,
|
||||
tree_structure={"node_class": "BTComposite_Selector", "children": []})
|
||||
st = self.call("behavior_tree_actions", "ue_get_behavior_tree_structure",
|
||||
asset_path=self._bt_path)
|
||||
self.assertSuccess(st)
|
||||
node_name = st["tree"][0]["node_name"]
|
||||
r = self.call("behavior_tree_actions", "ue_get_bt_node_details",
|
||||
asset_path=self._bt_path, node_name=node_name)
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_get_selected_bt_nodes(self):
|
||||
# No BT editor open in a headless test run → returns a structured failure.
|
||||
# We only assert the action runs end-to-end and returns a bool success.
|
||||
r = self.call("behavior_tree_actions", "ue_get_selected_bt_nodes")
|
||||
self.assertIsInstance(r.get("success"), bool)
|
||||
@@ -0,0 +1,228 @@
|
||||
import unreal
|
||||
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
|
||||
|
||||
_BP_NAME = "MCP_TestBlueprint"
|
||||
_BP_PATH = f"{TEST_ROOT}/{_BP_NAME}"
|
||||
|
||||
|
||||
class TestBlueprintActions(MCPTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self._bp_path = None
|
||||
self.ensure_test_dir()
|
||||
tools = unreal.AssetToolsHelpers.get_asset_tools()
|
||||
factory = unreal.BlueprintFactory()
|
||||
factory.set_editor_property('parent_class', unreal.Actor)
|
||||
bp = tools.create_asset(_BP_NAME, TEST_ROOT, unreal.Blueprint, factory)
|
||||
if bp:
|
||||
self._bp_path = _BP_PATH
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(bp)
|
||||
|
||||
def tearDown(self):
|
||||
if self._bp_path:
|
||||
self.delete_asset(self._bp_path)
|
||||
|
||||
def _skip_if_no_bp(self):
|
||||
if not self._bp_path:
|
||||
self.skipTest("Blueprint not created in setUp")
|
||||
|
||||
# ── read ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_get_graph_info(self):
|
||||
self._skip_if_no_bp()
|
||||
r = self.call("blueprint_actions", "ue_get_blueprint_graph_info",
|
||||
asset_path=self._bp_path)
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("nodes", r)
|
||||
|
||||
def test_list_callable_functions(self):
|
||||
self._skip_if_no_bp()
|
||||
r = self.call("blueprint_actions", "ue_list_callable_functions",
|
||||
asset_path=self._bp_path)
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_list_blueprint_variables(self):
|
||||
self._skip_if_no_bp()
|
||||
r = self.call("blueprint_actions", "ue_list_blueprint_variables",
|
||||
asset_path=self._bp_path)
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_list_blueprint_components(self):
|
||||
self._skip_if_no_bp()
|
||||
r = self.call("blueprint_actions", "ue_list_blueprint_components",
|
||||
asset_path=self._bp_path)
|
||||
self.assertSuccess(r)
|
||||
|
||||
# ── write ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_add_node_and_compile(self):
|
||||
self._skip_if_no_bp()
|
||||
# K2_GetActorLocation lives in Actor, which is the parent class;
|
||||
# no "target" needed — the code searches the Blueprint's class hierarchy.
|
||||
node_json = {
|
||||
"type": "CallFunction",
|
||||
"function_name": "K2_GetActorLocation"
|
||||
}
|
||||
r = self.call("blueprint_actions", "ue_add_blueprint_node",
|
||||
asset_path=self._bp_path,
|
||||
graph_name="EventGraph",
|
||||
node_json=node_json)
|
||||
self.assertSuccess(r)
|
||||
|
||||
r = self.call("blueprint_actions", "ue_compile_blueprint",
|
||||
asset_path=self._bp_path)
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_add_component(self):
|
||||
self._skip_if_no_bp()
|
||||
r = self.call("blueprint_actions", "ue_add_component_to_blueprint",
|
||||
asset_path=self._bp_path,
|
||||
component_class_path="/Script/Engine.StaticMeshComponent",
|
||||
component_name="TestMeshComp")
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_add_and_remove_component(self):
|
||||
self._skip_if_no_bp()
|
||||
r = self.call("blueprint_actions", "ue_add_component_to_blueprint",
|
||||
asset_path=self._bp_path,
|
||||
component_class_path="/Script/Engine.PointLightComponent",
|
||||
component_name="TestLightComp")
|
||||
self.assertSuccess(r)
|
||||
r = self.call("blueprint_actions", "ue_remove_component_from_blueprint",
|
||||
asset_path=self._bp_path,
|
||||
component_name="TestLightComp")
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_auto_layout_graph(self):
|
||||
self._skip_if_no_bp()
|
||||
r = self.call("blueprint_actions", "ue_auto_layout_graph",
|
||||
asset_path=self._bp_path, graph_name="EventGraph")
|
||||
self.assertSuccess(r)
|
||||
|
||||
# ── selection queries ───────────────────────────────────────────────────────
|
||||
|
||||
def test_get_selected_bp_nodes(self):
|
||||
r = self.call("blueprint_actions", "ue_get_selected_bp_nodes")
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("selected_nodes", r)
|
||||
|
||||
def test_get_selected_bp_node_infos(self):
|
||||
r = self.call("blueprint_actions", "ue_get_selected_bp_node_infos")
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("nodes", r)
|
||||
|
||||
# ── node position / remove ──────────────────────────────────────────────────
|
||||
|
||||
def _add_node(self, **node_json):
|
||||
r = self.call("blueprint_actions", "ue_add_blueprint_node",
|
||||
asset_path=self._bp_path, graph_name="EventGraph",
|
||||
node_json=node_json)
|
||||
self.assertSuccess(r)
|
||||
return r["node_name"]
|
||||
|
||||
def test_set_blueprint_node_position(self):
|
||||
self._skip_if_no_bp()
|
||||
node = self._add_node(type="CallFunction", function_name="K2_GetActorLocation")
|
||||
r = self.call("blueprint_actions", "ue_set_blueprint_node_position",
|
||||
asset_path=self._bp_path, graph_name="EventGraph",
|
||||
node_name=node, pos_x=320.0, pos_y=128.0)
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_remove_blueprint_node(self):
|
||||
self._skip_if_no_bp()
|
||||
node = self._add_node(type="CallFunction", function_name="K2_GetActorLocation")
|
||||
r = self.call("blueprint_actions", "ue_remove_blueprint_node",
|
||||
asset_path=self._bp_path, graph_name="EventGraph",
|
||||
node_name=node)
|
||||
self.assertSuccess(r)
|
||||
|
||||
# ── connect pins ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_connect_blueprint_pins(self):
|
||||
self._skip_if_no_bp()
|
||||
event = self._add_node(type="Event", event_name="ReceiveBeginPlay")
|
||||
setter = self._add_node(type="CallFunction", function_name="K2_SetActorLocation")
|
||||
r = self.call("blueprint_actions", "ue_connect_blueprint_pins",
|
||||
asset_path=self._bp_path, graph_name="EventGraph",
|
||||
source_node=event, source_pin="then",
|
||||
target_node=setter, target_pin="execute")
|
||||
self.assertSuccess(r)
|
||||
|
||||
# ── build whole graph ───────────────────────────────────────────────────────
|
||||
|
||||
def test_build_blueprint_graph(self):
|
||||
self._skip_if_no_bp()
|
||||
structure = {
|
||||
"nodes": [
|
||||
{"id": "evt", "type": "Event", "event_name": "ReceiveBeginPlay"},
|
||||
{"id": "loc", "type": "CallFunction", "function_name": "K2_GetActorLocation"},
|
||||
],
|
||||
"connections": [],
|
||||
}
|
||||
r = self.call("blueprint_actions", "ue_build_blueprint_graph",
|
||||
asset_path=self._bp_path, graph_name="EventGraph",
|
||||
graph_structure=structure)
|
||||
self.assertSuccess(r)
|
||||
|
||||
# ── component property ──────────────────────────────────────────────────────
|
||||
|
||||
def test_set_component_property(self):
|
||||
self._skip_if_no_bp()
|
||||
self.call("blueprint_actions", "ue_add_component_to_blueprint",
|
||||
asset_path=self._bp_path,
|
||||
component_class_path="/Script/Engine.PointLightComponent",
|
||||
component_name="PropLightComp")
|
||||
r = self.call("blueprint_actions", "ue_set_component_property",
|
||||
asset_path=self._bp_path, component_name="PropLightComp",
|
||||
property_name="Intensity", value="5000.0")
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_create_blueprint(self):
|
||||
import unreal
|
||||
from UnrealMCPython.tests.base import TEST_ROOT
|
||||
path = f"{TEST_ROOT}/MCP_CreatedBP"
|
||||
self.delete_asset(path)
|
||||
try:
|
||||
r = self.call("blueprint_actions", "ue_create_blueprint",
|
||||
asset_path=path, parent_class_path="/Script/Engine.Actor")
|
||||
self.assertSuccess(r)
|
||||
self.assertTrue(unreal.EditorAssetLibrary.does_asset_exist(path))
|
||||
finally:
|
||||
self.delete_asset(path)
|
||||
|
||||
def test_create_blueprint_bad_parent(self):
|
||||
from UnrealMCPython.tests.base import TEST_ROOT
|
||||
r = self.call("blueprint_actions", "ue_create_blueprint",
|
||||
asset_path=f"{TEST_ROOT}/MCP_BadBP", parent_class_path="/Script/Engine.NopeXYZ")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_add_variable(self):
|
||||
self._skip_if_no_bp()
|
||||
r = self.call("blueprint_actions", "ue_add_variable",
|
||||
asset_path=self._bp_path, variable_name="MyFloatVar", variable_type="float")
|
||||
self.assertSuccess(r)
|
||||
variables = self.call("blueprint_actions", "ue_list_blueprint_variables",
|
||||
asset_path=self._bp_path)
|
||||
self.assertSuccess(variables)
|
||||
|
||||
def test_add_variable_bad_type(self):
|
||||
self._skip_if_no_bp()
|
||||
r = self.call("blueprint_actions", "ue_add_variable",
|
||||
asset_path=self._bp_path, variable_name="X", variable_type="notatype")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_set_variable_flags(self):
|
||||
self._skip_if_no_bp()
|
||||
self.call("blueprint_actions", "ue_add_variable",
|
||||
asset_path=self._bp_path, variable_name="FlagVar", variable_type="bool")
|
||||
r = self.call("blueprint_actions", "ue_set_variable_flags",
|
||||
asset_path=self._bp_path, variable_name="FlagVar",
|
||||
instance_editable=True, expose_on_spawn=True)
|
||||
self.assertSuccess(r)
|
||||
self.assertTrue(r["applied"]["instance_editable"])
|
||||
|
||||
def test_set_variable_flags_none(self):
|
||||
self._skip_if_no_bp()
|
||||
r = self.call("blueprint_actions", "ue_set_variable_flags",
|
||||
asset_path=self._bp_path, variable_name="X")
|
||||
self.assertFalse(r.get("success"))
|
||||
@@ -0,0 +1,73 @@
|
||||
import unreal
|
||||
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
|
||||
|
||||
_MESH = "/Engine/Tutorial/SubEditors/TutorialAssets/Character/TutorialTPP"
|
||||
_CR_PATH = f"{TEST_ROOT}/MCP_TestCR"
|
||||
|
||||
|
||||
class TestControlRigActions(MCPTestCase):
|
||||
|
||||
def setUp(self):
|
||||
if not hasattr(unreal, "ControlRigBlueprint"):
|
||||
self.skipTest("ControlRig plugin not enabled")
|
||||
self.ensure_test_dir()
|
||||
self.delete_asset(_CR_PATH)
|
||||
|
||||
def tearDown(self):
|
||||
self.delete_asset(_CR_PATH)
|
||||
|
||||
def test_create_empty_and_info(self):
|
||||
r = self.call("control_rig_actions", "ue_create_control_rig", asset_path=_CR_PATH)
|
||||
self.assertSuccess(r)
|
||||
r = self.call("control_rig_actions", "ue_get_control_rig_info", asset_path=_CR_PATH)
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("element_counts", r)
|
||||
|
||||
def test_create_from_mesh_imports_bones(self):
|
||||
if not unreal.EditorAssetLibrary.does_asset_exist(_MESH):
|
||||
self.skipTest("Engine TutorialTPP mesh not available")
|
||||
r = self.call("control_rig_actions", "ue_create_control_rig",
|
||||
asset_path=_CR_PATH, skeletal_mesh_path=_MESH)
|
||||
self.assertSuccess(r)
|
||||
self.assertGreater(r["imported_bones"], 0)
|
||||
info = self.call("control_rig_actions", "ue_get_control_rig_info", asset_path=_CR_PATH)
|
||||
self.assertGreater(info["element_counts"].get("BONE", 0), 0)
|
||||
self.assertIsNotNone(info["preview_mesh"])
|
||||
|
||||
def test_add_bone_and_null(self):
|
||||
self.call("control_rig_actions", "ue_create_control_rig", asset_path=_CR_PATH)
|
||||
r = self.call("control_rig_actions", "ue_add_rig_bone",
|
||||
asset_path=_CR_PATH, bone_name="RootBone", location=[0, 0, 10])
|
||||
self.assertSuccess(r)
|
||||
r = self.call("control_rig_actions", "ue_add_rig_bone",
|
||||
asset_path=_CR_PATH, bone_name="ChildBone",
|
||||
parent_name="RootBone", parent_type="bone")
|
||||
self.assertSuccess(r)
|
||||
r = self.call("control_rig_actions", "ue_add_rig_null",
|
||||
asset_path=_CR_PATH, null_name="GroupNull")
|
||||
self.assertSuccess(r)
|
||||
info = self.call("control_rig_actions", "ue_get_control_rig_info", asset_path=_CR_PATH)
|
||||
self.assertEqual(info["element_counts"].get("BONE"), 2)
|
||||
self.assertEqual(info["element_counts"].get("NULL"), 1)
|
||||
|
||||
def test_add_unit_node_and_recompile(self):
|
||||
self.call("control_rig_actions", "ue_create_control_rig", asset_path=_CR_PATH)
|
||||
r = self.call("control_rig_actions", "ue_add_unit_node",
|
||||
asset_path=_CR_PATH, struct_path="/Script/ControlRig.RigUnit_GetTransform")
|
||||
self.assertSuccess(r)
|
||||
self.assertTrue(r["node"])
|
||||
r = self.call("control_rig_actions", "ue_recompile_control_rig", asset_path=_CR_PATH)
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_add_unit_node_bad_struct(self):
|
||||
self.call("control_rig_actions", "ue_create_control_rig", asset_path=_CR_PATH)
|
||||
r = self.call("control_rig_actions", "ue_add_unit_node",
|
||||
asset_path=_CR_PATH, struct_path="/Script/ControlRig.RigUnit_NopeXYZ")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_bad_parent_type(self):
|
||||
self.call("control_rig_actions", "ue_create_control_rig", asset_path=_CR_PATH)
|
||||
r = self.call("control_rig_actions", "ue_add_rig_bone",
|
||||
asset_path=_CR_PATH, bone_name="X",
|
||||
parent_name="Y", parent_type="nonsense")
|
||||
self.assertFalse(r.get("success"))
|
||||
@@ -0,0 +1,104 @@
|
||||
import unreal
|
||||
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
|
||||
|
||||
# A DataTable that ships with the NNEDenoiser plugin (10 rows) — duplicated into
|
||||
# /Game so write actions never touch read-only plugin assets.
|
||||
_SRC_DT = "/NNEDenoiser/NNEDIM_ColorAlbedoNormal_Default"
|
||||
_DT_NAME = "MCP_TestDataTable"
|
||||
_DT_PATH = f"{TEST_ROOT}/{_DT_NAME}"
|
||||
|
||||
|
||||
class TestDataTableActions(MCPTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self._dt_path = None
|
||||
self.ensure_test_dir()
|
||||
self.delete_asset(_DT_PATH)
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(_SRC_DT):
|
||||
dt = unreal.EditorAssetLibrary.duplicate_asset(_SRC_DT, _DT_PATH)
|
||||
if dt:
|
||||
self._dt_path = _DT_PATH
|
||||
|
||||
def tearDown(self):
|
||||
if self._dt_path:
|
||||
self.delete_asset(self._dt_path)
|
||||
|
||||
def _skip_if_no_dt(self):
|
||||
if not self._dt_path:
|
||||
self.skipTest("Source DataTable (NNEDenoiser) not available")
|
||||
|
||||
def test_get_row_names(self):
|
||||
self._skip_if_no_dt()
|
||||
r = self.call("data_table_actions", "ue_get_row_names", asset_path=self._dt_path)
|
||||
self.assertSuccess(r)
|
||||
self.assertGreater(r["count"], 0)
|
||||
|
||||
def test_get_column_names(self):
|
||||
self._skip_if_no_dt()
|
||||
r = self.call("data_table_actions", "ue_get_column_names", asset_path=self._dt_path)
|
||||
self.assertSuccess(r)
|
||||
self.assertIsInstance(r["columns"], list)
|
||||
self.assertIsNotNone(r["row_struct"])
|
||||
|
||||
def test_get_rows_as_json(self):
|
||||
self._skip_if_no_dt()
|
||||
r = self.call("data_table_actions", "ue_get_rows_as_json", asset_path=self._dt_path)
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("rows", r)
|
||||
self.assertTrue(r["rows"].strip().startswith("["))
|
||||
|
||||
def test_export_to_csv(self):
|
||||
self._skip_if_no_dt()
|
||||
r = self.call("data_table_actions", "ue_export_to_csv", asset_path=self._dt_path)
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("csv", r)
|
||||
|
||||
def test_does_row_exist(self):
|
||||
self._skip_if_no_dt()
|
||||
names = self.call("data_table_actions", "ue_get_row_names", asset_path=self._dt_path)["row_names"]
|
||||
r = self.call("data_table_actions", "ue_does_row_exist",
|
||||
asset_path=self._dt_path, row_name=names[0])
|
||||
self.assertSuccess(r)
|
||||
self.assertTrue(r["exists"])
|
||||
r = self.call("data_table_actions", "ue_does_row_exist",
|
||||
asset_path=self._dt_path, row_name="NoSuchRow_XYZ")
|
||||
self.assertFalse(r["exists"])
|
||||
|
||||
def test_remove_row(self):
|
||||
self._skip_if_no_dt()
|
||||
names = self.call("data_table_actions", "ue_get_row_names", asset_path=self._dt_path)["row_names"]
|
||||
before = len(names)
|
||||
r = self.call("data_table_actions", "ue_remove_row",
|
||||
asset_path=self._dt_path, row_name=names[0])
|
||||
self.assertSuccess(r)
|
||||
after = self.call("data_table_actions", "ue_get_row_names", asset_path=self._dt_path)["count"]
|
||||
self.assertEqual(after, before - 1)
|
||||
|
||||
def test_remove_row_missing(self):
|
||||
self._skip_if_no_dt()
|
||||
r = self.call("data_table_actions", "ue_remove_row",
|
||||
asset_path=self._dt_path, row_name="NoSuchRow_XYZ")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_set_rows_from_json(self):
|
||||
self._skip_if_no_dt()
|
||||
original = self.call("data_table_actions", "ue_get_rows_as_json", asset_path=self._dt_path)["rows"]
|
||||
r = self.call("data_table_actions", "ue_set_rows_from_json",
|
||||
asset_path=self._dt_path, json_string=original)
|
||||
self.assertSuccess(r)
|
||||
self.assertGreater(r["row_count"], 0)
|
||||
|
||||
def test_create_data_table(self):
|
||||
self._skip_if_no_dt()
|
||||
# Reuse the source table's row struct to create a fresh, empty DataTable.
|
||||
struct_path = self.call("data_table_actions", "ue_get_column_names",
|
||||
asset_path=self._dt_path)["row_struct"]
|
||||
new_path = f"{TEST_ROOT}/MCP_CreatedDT"
|
||||
self.delete_asset(new_path)
|
||||
try:
|
||||
r = self.call("data_table_actions", "ue_create_data_table",
|
||||
asset_path=new_path, row_struct_path=struct_path)
|
||||
self.assertSuccess(r)
|
||||
self.assertTrue(unreal.EditorAssetLibrary.does_asset_exist(new_path))
|
||||
finally:
|
||||
self.delete_asset(new_path)
|
||||
@@ -0,0 +1,205 @@
|
||||
import unreal
|
||||
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
|
||||
|
||||
_CLASS_PATH = "/Script/Engine.PointLight"
|
||||
|
||||
|
||||
class TestEditorActions(MCPTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self._actor_label = None
|
||||
self._actor_path = None
|
||||
r = self.call("actor_actions", "ue_spawn_from_class",
|
||||
class_path=_CLASS_PATH, location=[0, 0, 600])
|
||||
if r.get("success"):
|
||||
self._actor_label = r["actor_label"]
|
||||
self._actor_path = r.get("actor_path")
|
||||
|
||||
def tearDown(self):
|
||||
if self._actor_label:
|
||||
self.delete_actor_by_label(self._actor_label)
|
||||
|
||||
# ── get selected assets ───────────────────────────────────────────────────
|
||||
|
||||
def test_get_selected_assets(self):
|
||||
r = self.call("editor_actions", "ue_get_selected_assets")
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("selected_assets", r)
|
||||
self.assertIsInstance(r["selected_assets"], list)
|
||||
|
||||
# ── material replace (specified) ──────────────────────────────────────────
|
||||
|
||||
def test_replace_mtl_on_specified_no_mesh_actor(self):
|
||||
# PointLight has no mesh component — operation should succeed but change 0 slots
|
||||
if not self._actor_path:
|
||||
self.skipTest("Actor not spawned in setUp")
|
||||
r = self.call("editor_actions", "ue_replace_mtl_on_specified",
|
||||
actor_paths=[self._actor_path],
|
||||
material_to_be_replaced_path="/Game/DoesNotExist/OldMat",
|
||||
new_material_path="/Game/DoesNotExist/NewMat")
|
||||
# No mesh to replace on, so result depends on implementation;
|
||||
# at minimum, the call should not crash the editor
|
||||
self.assertIsInstance(r.get("success"), bool)
|
||||
|
||||
# ── mesh replace (specified) ──────────────────────────────────────────────
|
||||
|
||||
def test_replace_mesh_on_specified_no_mesh_actor(self):
|
||||
if not self._actor_path:
|
||||
self.skipTest("Actor not spawned in setUp")
|
||||
r = self.call("editor_actions", "ue_replace_mesh_on_specified",
|
||||
actor_paths=[self._actor_path],
|
||||
mesh_to_be_replaced_path="/Game/DoesNotExist/OldMesh",
|
||||
new_mesh_path="/Game/DoesNotExist/NewMesh")
|
||||
self.assertIsInstance(r.get("success"), bool)
|
||||
|
||||
# ── replace on selected ─────────────────────────────────────────────────────
|
||||
|
||||
def _select_setup_actor(self):
|
||||
import unreal
|
||||
sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
actor = next((a for a in sub.get_all_level_actors()
|
||||
if a.get_actor_label() == self._actor_label), None)
|
||||
self.assertIsNotNone(actor, "setUp actor not found")
|
||||
sub.set_selected_level_actors([actor])
|
||||
|
||||
def test_replace_mtl_on_selected_no_mesh_actor(self):
|
||||
if not self._actor_label:
|
||||
self.skipTest("Actor not spawned in setUp")
|
||||
self._select_setup_actor()
|
||||
r = self.call("editor_actions", "ue_replace_mtl_on_selected",
|
||||
material_to_be_replaced_path="/Game/DoesNotExist/OldMat",
|
||||
new_material_path="/Game/DoesNotExist/NewMat")
|
||||
self.assertIsInstance(r.get("success"), bool)
|
||||
|
||||
def test_replace_mesh_on_selected_no_mesh_actor(self):
|
||||
if not self._actor_label:
|
||||
self.skipTest("Actor not spawned in setUp")
|
||||
self._select_setup_actor()
|
||||
r = self.call("editor_actions", "ue_replace_mesh_on_selected",
|
||||
mesh_to_be_replaced_path="/Game/DoesNotExist/OldMesh",
|
||||
new_mesh_path="/Game/DoesNotExist/NewMesh")
|
||||
self.assertIsInstance(r.get("success"), bool)
|
||||
|
||||
# ── replace selected with blueprint ─────────────────────────────────────────
|
||||
|
||||
def test_replace_selected_with_bp_invalid_path(self):
|
||||
# Use an invalid BP path so the setUp actor is not actually replaced
|
||||
# (keeps the test session/cleanup intact). Proves the action runs.
|
||||
if not self._actor_label:
|
||||
self.skipTest("Actor not spawned in setUp")
|
||||
self._select_setup_actor()
|
||||
r = self.call("editor_actions", "ue_replace_selected_with_bp",
|
||||
blueprint_asset_path="/Game/DoesNotExist/BP_Nope")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
# ── merge / join / proxy ─────────────────────────────────────────────────────
|
||||
|
||||
def _spawn_cubes(self, n=2):
|
||||
labels = []
|
||||
for i in range(n):
|
||||
r = self.call("actor_actions", "ue_spawn_from_object",
|
||||
asset_path="/Engine/BasicShapes/Cube", location=[i * 150, 0, 2200])
|
||||
self.assertSuccess(r)
|
||||
labels.append(r["actor_label"])
|
||||
return labels
|
||||
|
||||
def test_merge_actors(self):
|
||||
import unreal
|
||||
labels = self._spawn_cubes()
|
||||
merged_actor = None
|
||||
mesh_asset = None
|
||||
try:
|
||||
r = self.call("editor_actions", "ue_merge_actors",
|
||||
actor_labels=labels,
|
||||
base_package_name="/Game/Tests/MCP/MCP_Merged")
|
||||
self.assertSuccess(r)
|
||||
merged_actor = r["merged_actor"]
|
||||
mesh_asset = r["mesh_asset"]
|
||||
self.assertIsNotNone(mesh_asset)
|
||||
self.assertTrue(unreal.EditorAssetLibrary.does_asset_exist(mesh_asset))
|
||||
finally:
|
||||
for l in labels + ([merged_actor] if merged_actor else []):
|
||||
self.delete_actor_by_label(l)
|
||||
if mesh_asset:
|
||||
self.delete_asset(mesh_asset)
|
||||
|
||||
def test_join_actors(self):
|
||||
labels = self._spawn_cubes()
|
||||
joined = None
|
||||
try:
|
||||
r = self.call("editor_actions", "ue_join_actors",
|
||||
actor_labels=labels, new_actor_label="MCP_Joined")
|
||||
self.assertSuccess(r)
|
||||
joined = r["joined_actor"]
|
||||
finally:
|
||||
for l in labels + ([joined] if joined else []):
|
||||
self.delete_actor_by_label(l)
|
||||
|
||||
def test_create_proxy_actor(self):
|
||||
import unreal
|
||||
labels = self._spawn_cubes()
|
||||
proxy = None
|
||||
mesh_asset = None
|
||||
try:
|
||||
r = self.call("editor_actions", "ue_create_proxy_actor",
|
||||
actor_labels=labels,
|
||||
base_package_name="/Game/Tests/MCP/MCP_Proxy",
|
||||
screen_size=300)
|
||||
self.assertSuccess(r)
|
||||
proxy = r["proxy_actor"]
|
||||
mesh_asset = r["mesh_asset"]
|
||||
finally:
|
||||
for l in labels + ([proxy] if proxy else []):
|
||||
self.delete_actor_by_label(l)
|
||||
if mesh_asset:
|
||||
self.delete_asset(mesh_asset)
|
||||
|
||||
def test_merge_actors_missing(self):
|
||||
r = self.call("editor_actions", "ue_merge_actors",
|
||||
actor_labels=["NoSuchActor_XYZ"],
|
||||
base_package_name="/Game/Tests/MCP/Nope")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
|
||||
class TestEditorAssetControl(MCPTestCase):
|
||||
|
||||
_ASSET = f"{TEST_ROOT}/MCP_OpenEditorTest"
|
||||
|
||||
def setUp(self):
|
||||
self.ensure_test_dir()
|
||||
self.delete_asset(self._ASSET)
|
||||
factory = unreal.BlueprintFactory()
|
||||
factory.set_editor_property("parent_class", unreal.Actor)
|
||||
name = self._ASSET.rsplit("/", 1)[1]
|
||||
self._bp = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
|
||||
name, TEST_ROOT, unreal.Blueprint, factory)
|
||||
self.assertIsNotNone(self._bp, "could not create test blueprint")
|
||||
|
||||
def tearDown(self):
|
||||
try:
|
||||
self.call("editor_actions", "ue_close_asset_editor", asset_path=self._ASSET)
|
||||
finally:
|
||||
self.delete_asset(self._ASSET)
|
||||
|
||||
def _open_paths(self):
|
||||
opened = self.call("editor_actions", "ue_get_open_assets")
|
||||
self.assertSuccess(opened)
|
||||
return [a["asset_path"].split(".")[0] for a in opened["open_assets"]]
|
||||
|
||||
def test_open_lists_then_close_removes(self):
|
||||
r = self.call("editor_actions", "ue_open_editor_for_asset", asset_path=self._ASSET)
|
||||
self.assertSuccess(r)
|
||||
self.assertIn(self._ASSET, self._open_paths())
|
||||
|
||||
r = self.call("editor_actions", "ue_close_asset_editor", asset_path=self._ASSET)
|
||||
self.assertSuccess(r)
|
||||
self.assertNotIn(self._ASSET, self._open_paths())
|
||||
|
||||
def test_open_editor_rejects_missing_asset(self):
|
||||
r = self.call("editor_actions", "ue_open_editor_for_asset",
|
||||
asset_path=f"{TEST_ROOT}/NoSuchAsset_XYZ")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_open_editor_missing_param(self):
|
||||
r = self.call("editor_actions", "ue_open_editor_for_asset")
|
||||
self.assertFalse(r.get("success"))
|
||||
@@ -0,0 +1,72 @@
|
||||
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
|
||||
|
||||
_IA_PATH = f"{TEST_ROOT}/IA_TestJump"
|
||||
_IMC_PATH = f"{TEST_ROOT}/IMC_TestDefault"
|
||||
|
||||
|
||||
class TestGameActions(MCPTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self._ia_path = None
|
||||
self._imc_path = None
|
||||
self.ensure_test_dir()
|
||||
|
||||
def tearDown(self):
|
||||
# Clear GameMode override
|
||||
self.call("game_actions", "ue_set_game_mode", game_mode_class_path=None)
|
||||
# Clean up created input assets
|
||||
if self._ia_path:
|
||||
self.delete_asset(self._ia_path)
|
||||
if self._imc_path:
|
||||
self.delete_asset(self._imc_path)
|
||||
|
||||
# ── game mode ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_clear_game_mode(self):
|
||||
r = self.call("game_actions", "ue_set_game_mode",
|
||||
game_mode_class_path=None)
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_set_game_mode_invalid_path(self):
|
||||
r = self.call("game_actions", "ue_set_game_mode",
|
||||
game_mode_class_path="/Game/DoesNotExist/BP_FakeMode_C")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_set_game_mode_engine_class(self):
|
||||
r = self.call("game_actions", "ue_set_game_mode",
|
||||
game_mode_class_path="/Script/Engine.GameModeBase")
|
||||
self.assertSuccess(r)
|
||||
|
||||
# ── input action ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_add_input_action_bool(self):
|
||||
r = self.call("game_actions", "ue_add_input_action",
|
||||
asset_path=_IA_PATH, value_type="Bool")
|
||||
self.assertSuccess(r)
|
||||
if r.get("success"):
|
||||
self._ia_path = _IA_PATH
|
||||
|
||||
def test_add_input_action_axis2d(self):
|
||||
ia_path = f"{TEST_ROOT}/IA_TestLook"
|
||||
r = self.call("game_actions", "ue_add_input_action",
|
||||
asset_path=ia_path, value_type="Axis2D")
|
||||
self.assertSuccess(r)
|
||||
if r.get("success"):
|
||||
self.delete_asset(ia_path)
|
||||
|
||||
# ── input mapping ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_add_input_mapping(self):
|
||||
# Create the input action first
|
||||
r = self.call("game_actions", "ue_add_input_action",
|
||||
asset_path=_IA_PATH, value_type="Bool")
|
||||
if r.get("success"):
|
||||
self._ia_path = _IA_PATH
|
||||
|
||||
r = self.call("game_actions", "ue_add_input_mapping",
|
||||
mapping_context_path=_IMC_PATH,
|
||||
action_path=_IA_PATH,
|
||||
key_name="SpaceBar")
|
||||
self.assertSuccess(r)
|
||||
if r.get("success"):
|
||||
self._imc_path = _IMC_PATH
|
||||
@@ -0,0 +1,130 @@
|
||||
import unreal
|
||||
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
|
||||
|
||||
_GA_PATH = f"{TEST_ROOT}/MCP_TestGA"
|
||||
_GE_PATH = f"{TEST_ROOT}/MCP_TestGE"
|
||||
_TEST_ATTR_SET = "/Script/GameplayAbilities.AbilitySystemTestAttributeSet"
|
||||
|
||||
|
||||
class TestGasActions(MCPTestCase):
|
||||
|
||||
def setUp(self):
|
||||
if not hasattr(unreal, "GameplayAbility"):
|
||||
self.skipTest("GameplayAbilities plugin not enabled")
|
||||
self.ensure_test_dir()
|
||||
for p in (_GA_PATH, _GE_PATH):
|
||||
self.delete_asset(p)
|
||||
|
||||
def tearDown(self):
|
||||
for p in (_GA_PATH, _GE_PATH):
|
||||
self.delete_asset(p)
|
||||
|
||||
# ── abilities ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_create_ability_and_info(self):
|
||||
r = self.call("gas_actions", "ue_create_ability_blueprint", asset_path=_GA_PATH)
|
||||
self.assertSuccess(r)
|
||||
r = self.call("gas_actions", "ue_get_ability_info", asset_path=_GA_PATH)
|
||||
self.assertSuccess(r)
|
||||
self.assertEqual(r["ability_tags"], [])
|
||||
self.assertIsNone(r["cost_effect"])
|
||||
|
||||
def test_create_ability_bad_parent(self):
|
||||
r = self.call("gas_actions", "ue_create_ability_blueprint",
|
||||
asset_path=_GA_PATH, parent_class_path="/Script/Engine.Actor")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_set_ability_costs(self):
|
||||
self.call("gas_actions", "ue_create_ability_blueprint", asset_path=_GA_PATH)
|
||||
self.call("gas_actions", "ue_create_effect_blueprint", asset_path=_GE_PATH)
|
||||
r = self.call("gas_actions", "ue_set_ability_costs",
|
||||
asset_path=_GA_PATH, cost_effect_path=_GE_PATH,
|
||||
cooldown_effect_path=_GE_PATH)
|
||||
self.assertSuccess(r)
|
||||
info = self.call("gas_actions", "ue_get_ability_info", asset_path=_GA_PATH)
|
||||
self.assertIsNotNone(info["cost_effect"])
|
||||
self.assertIsNotNone(info["cooldown_effect"])
|
||||
|
||||
def test_set_ability_tags_unregistered(self):
|
||||
self.call("gas_actions", "ue_create_ability_blueprint", asset_path=_GA_PATH)
|
||||
r = self.call("gas_actions", "ue_set_ability_tags",
|
||||
asset_path=_GA_PATH, tags=["MCP.NoSuchTag.Xyz"])
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("MCP.NoSuchTag.Xyz", r["unresolved_tags"])
|
||||
|
||||
# ── effects ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_create_effect_with_duration(self):
|
||||
r = self.call("gas_actions", "ue_create_effect_blueprint",
|
||||
asset_path=_GE_PATH, duration_policy="has_duration",
|
||||
duration_seconds=8.0)
|
||||
self.assertSuccess(r)
|
||||
info = self.call("gas_actions", "ue_get_effect_info", asset_path=_GE_PATH)
|
||||
self.assertSuccess(info)
|
||||
self.assertEqual(info["duration_policy"], "has_duration")
|
||||
self.assertAlmostEqual(info["duration_seconds"], 8.0, places=2)
|
||||
|
||||
def test_set_effect_duration_bad_policy(self):
|
||||
self.call("gas_actions", "ue_create_effect_blueprint", asset_path=_GE_PATH)
|
||||
r = self.call("gas_actions", "ue_set_effect_duration",
|
||||
asset_path=_GE_PATH, duration_policy="nonsense")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_add_and_clear_modifier(self):
|
||||
self.call("gas_actions", "ue_create_effect_blueprint", asset_path=_GE_PATH)
|
||||
r = self.call("gas_actions", "ue_add_effect_modifier",
|
||||
asset_path=_GE_PATH, attribute_set_path=_TEST_ATTR_SET,
|
||||
attribute_name="Health", op="add_base", magnitude=25.0)
|
||||
self.assertSuccess(r)
|
||||
self.assertEqual(r["modifier_count"], 1)
|
||||
info = self.call("gas_actions", "ue_get_effect_info", asset_path=_GE_PATH)
|
||||
self.assertEqual(len(info["modifiers"]), 1)
|
||||
mod = info["modifiers"][0]
|
||||
self.assertEqual(mod["attribute"], "Health")
|
||||
self.assertEqual(mod["op"], "add_base")
|
||||
self.assertAlmostEqual(mod["magnitude"], 25.0, places=2)
|
||||
r = self.call("gas_actions", "ue_clear_effect_modifiers", asset_path=_GE_PATH)
|
||||
self.assertSuccess(r)
|
||||
info = self.call("gas_actions", "ue_get_effect_info", asset_path=_GE_PATH)
|
||||
self.assertEqual(info["modifiers"], [])
|
||||
|
||||
def test_add_modifier_bad_op(self):
|
||||
self.call("gas_actions", "ue_create_effect_blueprint", asset_path=_GE_PATH)
|
||||
r = self.call("gas_actions", "ue_add_effect_modifier",
|
||||
asset_path=_GE_PATH, attribute_set_path=_TEST_ATTR_SET,
|
||||
attribute_name="Health", op="nonsense")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_add_modifier_bad_attribute(self):
|
||||
self.call("gas_actions", "ue_create_effect_blueprint", asset_path=_GE_PATH)
|
||||
r = self.call("gas_actions", "ue_add_effect_modifier",
|
||||
asset_path=_GE_PATH, attribute_set_path=_TEST_ATTR_SET,
|
||||
attribute_name="NoSuchAttr_XYZ")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
# ── tags (ini-based) ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_add_and_list_gameplay_tag(self):
|
||||
import os
|
||||
tag = "MCP.Test.TempTag"
|
||||
ini = os.path.join(unreal.Paths.project_config_dir(), "DefaultGameplayTags.ini")
|
||||
before = open(ini, encoding="utf-8").read() if os.path.isfile(ini) else None
|
||||
try:
|
||||
r = self.call("gas_actions", "ue_add_gameplay_tag", tag=tag, comment="mcp test")
|
||||
self.assertSuccess(r)
|
||||
r = self.call("gas_actions", "ue_list_gameplay_tags", prefix="MCP.Test")
|
||||
self.assertSuccess(r)
|
||||
self.assertIn(tag, r["tags"])
|
||||
r = self.call("gas_actions", "ue_add_gameplay_tag", tag=tag)
|
||||
self.assertFalse(r.get("success")) # duplicate
|
||||
finally:
|
||||
if before is None:
|
||||
if os.path.isfile(ini):
|
||||
os.remove(ini)
|
||||
else:
|
||||
with open(ini, "w", encoding="utf-8") as f:
|
||||
f.write(before)
|
||||
|
||||
def test_add_gameplay_tag_invalid(self):
|
||||
r = self.call("gas_actions", "ue_add_gameplay_tag", tag="bad tag !!")
|
||||
self.assertFalse(r.get("success"))
|
||||
@@ -0,0 +1,57 @@
|
||||
import unreal
|
||||
from UnrealMCPython.tests.base import MCPTestCase
|
||||
|
||||
_LAYER = "MCP_TestLayer"
|
||||
_CLASS_PATH = "/Script/Engine.PointLight"
|
||||
|
||||
|
||||
class TestLayerActions(MCPTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self._actor_label = None
|
||||
r = self.call("actor_actions", "ue_spawn_from_class",
|
||||
class_path=_CLASS_PATH, location=[0, 0, 550])
|
||||
if r.get("success"):
|
||||
self._actor_label = r["actor_label"]
|
||||
|
||||
def tearDown(self):
|
||||
self.call("layer_actions", "ue_delete_layer", layer_name=_LAYER)
|
||||
if self._actor_label:
|
||||
self.delete_actor_by_label(self._actor_label)
|
||||
|
||||
def test_list_layers(self):
|
||||
r = self.call("layer_actions", "ue_list_layers")
|
||||
self.assertSuccess(r)
|
||||
self.assertIsInstance(r["layers"], list)
|
||||
|
||||
def test_create_and_delete_layer(self):
|
||||
r = self.call("layer_actions", "ue_create_layer", layer_name=_LAYER)
|
||||
self.assertSuccess(r)
|
||||
layers = self.call("layer_actions", "ue_list_layers")["layers"]
|
||||
self.assertIn(_LAYER, layers)
|
||||
r = self.call("layer_actions", "ue_delete_layer", layer_name=_LAYER)
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_delete_layer_missing(self):
|
||||
r = self.call("layer_actions", "ue_delete_layer", layer_name="NoSuchLayer_XYZ")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_add_and_remove_actor_in_layer(self):
|
||||
if not self._actor_label:
|
||||
self.skipTest("Actor not spawned in setUp")
|
||||
self.call("layer_actions", "ue_create_layer", layer_name=_LAYER)
|
||||
r = self.call("layer_actions", "ue_add_actor_to_layer",
|
||||
actor_label=self._actor_label, layer_name=_LAYER)
|
||||
self.assertSuccess(r)
|
||||
actors = self.call("layer_actions", "ue_get_actors_in_layer", layer_name=_LAYER)
|
||||
self.assertIn(self._actor_label, actors["actors"])
|
||||
r = self.call("layer_actions", "ue_remove_actor_from_layer",
|
||||
actor_label=self._actor_label, layer_name=_LAYER)
|
||||
self.assertSuccess(r)
|
||||
actors = self.call("layer_actions", "ue_get_actors_in_layer", layer_name=_LAYER)
|
||||
self.assertNotIn(self._actor_label, actors["actors"])
|
||||
|
||||
def test_add_actor_unknown(self):
|
||||
r = self.call("layer_actions", "ue_add_actor_to_layer",
|
||||
actor_label="NoSuchActor_XYZ", layer_name=_LAYER)
|
||||
self.assertFalse(r.get("success"))
|
||||
@@ -0,0 +1,69 @@
|
||||
from UnrealMCPython.tests.base import MCPTestCase
|
||||
|
||||
|
||||
class TestLevelActions(MCPTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self._gravity_changed = False
|
||||
|
||||
def tearDown(self):
|
||||
# Restore default gravity if we changed it
|
||||
if self._gravity_changed:
|
||||
self.call("level_actions", "ue_set_world_settings", gravity=-980.0)
|
||||
|
||||
def test_list_level_actors(self):
|
||||
r = self.call("level_actions", "ue_list_level_actors")
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("actors", r)
|
||||
self.assertIsInstance(r["actors"], list)
|
||||
self.assertIn("count", r)
|
||||
|
||||
def test_list_level_actors_with_class_filter(self):
|
||||
r = self.call("level_actions", "ue_list_level_actors", class_filter="Light")
|
||||
self.assertSuccess(r)
|
||||
for actor in r["actors"]:
|
||||
self.assertIn("Light", actor["class"])
|
||||
|
||||
def test_set_world_gravity(self):
|
||||
self._gravity_changed = True
|
||||
r = self.call("level_actions", "ue_set_world_settings", gravity=-500.0)
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("gravity", r.get("applied", {}))
|
||||
|
||||
def test_set_world_time_dilation(self):
|
||||
r = self.call("level_actions", "ue_set_world_settings", time_dilation=1.0)
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_set_world_settings_no_params(self):
|
||||
r = self.call("level_actions", "ue_set_world_settings")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
# ── create / load (guard paths only) ────────────────────────────────────────
|
||||
#
|
||||
# NOTE: create_level (new_level) and load_level switch/replace the editor's
|
||||
# OPEN level. Exercising their happy path in the shared in-editor suite is
|
||||
# destructive — creating a temp level, then deleting it while it is the open
|
||||
# level, destabilized the editor and reset the TCP server during development.
|
||||
# So we only assert the parameter-guard path here (runs the action through the
|
||||
# full chain without mutating the editor session). The empty-param round-trip
|
||||
# is also covered by the E2E suite.
|
||||
|
||||
def test_create_level_missing_path(self):
|
||||
r = self.call("level_actions", "ue_create_level")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_load_level_missing_path(self):
|
||||
r = self.call("level_actions", "ue_load_level")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
# ── current level ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_get_current_level_path(self):
|
||||
r = self.call("level_actions", "ue_get_current_level_path")
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("level_path", r)
|
||||
self.assertTrue(r["level_path"])
|
||||
|
||||
# save_current_level / save_all_levels are not auto-tested: on an unsaved
|
||||
# (untitled) level save_current_level can raise a modal Save-As dialog that
|
||||
# would hang the headless suite. Verified manually instead (see KNOWN_UNTESTED).
|
||||
@@ -0,0 +1,191 @@
|
||||
import unreal
|
||||
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
|
||||
|
||||
_SEQ_NAME = "MCP_TestSequence"
|
||||
_SEQ_PATH = f"{TEST_ROOT}/{_SEQ_NAME}"
|
||||
_CAM_CLASS = "/Script/CinematicCamera.CineCameraActor"
|
||||
|
||||
|
||||
class TestLevelSequenceActions(MCPTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self._seq_path = None
|
||||
self.ensure_test_dir()
|
||||
self.delete_asset(_SEQ_PATH)
|
||||
r = self.call("level_sequence_actions", "ue_create_level_sequence",
|
||||
asset_path=_SEQ_PATH, fps=30.0, duration_seconds=5.0)
|
||||
if r.get("success"):
|
||||
self._seq_path = _SEQ_PATH
|
||||
|
||||
def tearDown(self):
|
||||
if self._seq_path:
|
||||
self.delete_asset(self._seq_path)
|
||||
|
||||
def _skip_if_no_seq(self):
|
||||
if not self._seq_path:
|
||||
self.skipTest("Level Sequence not created in setUp")
|
||||
|
||||
def _add_camera(self):
|
||||
r = self.call("level_sequence_actions", "ue_add_spawnable_from_class",
|
||||
asset_path=self._seq_path, class_path=_CAM_CLASS)
|
||||
self.assertSuccess(r)
|
||||
return r["binding_name"]
|
||||
|
||||
# ── create / info ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_create_level_sequence(self):
|
||||
self.assertIsNotNone(self._seq_path, "Sequence was not created in setUp")
|
||||
|
||||
def test_get_sequence_info(self):
|
||||
self._skip_if_no_seq()
|
||||
r = self.call("level_sequence_actions", "ue_get_sequence_info", asset_path=self._seq_path)
|
||||
self.assertSuccess(r)
|
||||
self.assertEqual(r["fps"], 30.0)
|
||||
self.assertAlmostEqual(r["playback_end_seconds"], 5.0, places=2)
|
||||
self.assertIsInstance(r["bindings"], list)
|
||||
|
||||
def test_set_playback_range(self):
|
||||
self._skip_if_no_seq()
|
||||
r = self.call("level_sequence_actions", "ue_set_playback_range",
|
||||
asset_path=self._seq_path, start_seconds=1.0, end_seconds=8.0)
|
||||
self.assertSuccess(r)
|
||||
info = self.call("level_sequence_actions", "ue_get_sequence_info", asset_path=self._seq_path)
|
||||
self.assertAlmostEqual(info["playback_end_seconds"], 8.0, places=2)
|
||||
|
||||
# ── bindings ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_add_spawnable_from_class(self):
|
||||
self._skip_if_no_seq()
|
||||
name = self._add_camera()
|
||||
info = self.call("level_sequence_actions", "ue_get_sequence_info", asset_path=self._seq_path)
|
||||
names = [b["name"] for b in info["bindings"]]
|
||||
self.assertIn(name, names)
|
||||
|
||||
def test_add_possessable(self):
|
||||
self._skip_if_no_seq()
|
||||
spawn = self.call("actor_actions", "ue_spawn_from_class",
|
||||
class_path="/Script/Engine.PointLight", location=[0, 0, 400])
|
||||
self.assertSuccess(spawn)
|
||||
label = spawn["actor_label"]
|
||||
try:
|
||||
r = self.call("level_sequence_actions", "ue_add_possessable",
|
||||
asset_path=self._seq_path, actor_label=label)
|
||||
self.assertSuccess(r)
|
||||
finally:
|
||||
self.delete_actor_by_label(label)
|
||||
|
||||
def test_remove_binding(self):
|
||||
self._skip_if_no_seq()
|
||||
name = self._add_camera()
|
||||
r = self.call("level_sequence_actions", "ue_remove_binding",
|
||||
asset_path=self._seq_path, binding_name=name)
|
||||
self.assertSuccess(r)
|
||||
self.assertNotIn(name, r["remaining_bindings"])
|
||||
|
||||
def test_remove_binding_unknown(self):
|
||||
self._skip_if_no_seq()
|
||||
r = self.call("level_sequence_actions", "ue_remove_binding",
|
||||
asset_path=self._seq_path, binding_name="NoSuchBinding_XYZ")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
# ── tracks / keyframes ───────────────────────────────────────────────────────
|
||||
|
||||
def test_add_transform_track(self):
|
||||
self._skip_if_no_seq()
|
||||
name = self._add_camera()
|
||||
r = self.call("level_sequence_actions", "ue_add_transform_track",
|
||||
asset_path=self._seq_path, binding_name=name)
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_add_transform_keyframe(self):
|
||||
self._skip_if_no_seq()
|
||||
name = self._add_camera()
|
||||
r = self.call("level_sequence_actions", "ue_add_transform_keyframe",
|
||||
asset_path=self._seq_path, binding_name=name, time_seconds=2.0,
|
||||
location=[100.0, 200.0, 300.0], rotation=[0.0, 45.0, 0.0])
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("location", r["keyed"])
|
||||
self.assertIn("rotation", r["keyed"])
|
||||
# The section must span the keyed time, or the keys are invisible in Sequencer.
|
||||
start, end = r["section_range_seconds"]
|
||||
self.assertGreater(end, start)
|
||||
self.assertGreaterEqual(end, 2.0)
|
||||
|
||||
def test_keyframe_beyond_range_extends_section(self):
|
||||
self._skip_if_no_seq()
|
||||
name = self._add_camera()
|
||||
# Key past the 5s playback end → section must extend to include it.
|
||||
r = self.call("level_sequence_actions", "ue_add_transform_keyframe",
|
||||
asset_path=self._seq_path, binding_name=name, time_seconds=8.0,
|
||||
location=[0.0, 0.0, 0.0])
|
||||
self.assertSuccess(r)
|
||||
self.assertGreaterEqual(r["section_range_seconds"][1], 8.0)
|
||||
|
||||
def test_add_transform_keyframe_no_channels(self):
|
||||
self._skip_if_no_seq()
|
||||
name = self._add_camera()
|
||||
r = self.call("level_sequence_actions", "ue_add_transform_keyframe",
|
||||
asset_path=self._seq_path, binding_name=name, time_seconds=1.0)
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
# ── camera / anim / sequencer editor ─────────────────────────────────────────
|
||||
|
||||
def test_add_camera_with_cut_track(self):
|
||||
self._skip_if_no_seq()
|
||||
r = self.call("level_sequence_actions", "ue_add_camera",
|
||||
asset_path=self._seq_path, spawnable=True)
|
||||
self.assertSuccess(r)
|
||||
self.assertTrue(r["camera_binding"])
|
||||
self.assertTrue(r["camera_cut_track"])
|
||||
info = self.call("level_sequence_actions", "ue_get_sequence_info", asset_path=self._seq_path)
|
||||
self.assertIn(r["camera_binding"], [b["name"] for b in info["bindings"]])
|
||||
|
||||
def test_add_anim_track(self):
|
||||
self._skip_if_no_seq()
|
||||
r = self.call("level_sequence_actions", "ue_add_spawnable_from_class",
|
||||
asset_path=self._seq_path, class_path="/Script/Engine.SkeletalMeshActor")
|
||||
self.assertSuccess(r)
|
||||
name = r["binding_name"]
|
||||
r = self.call("level_sequence_actions", "ue_add_anim_track",
|
||||
asset_path=self._seq_path, binding_name=name,
|
||||
anim_path="/Engine/Tutorial/SubEditors/TutorialAssets/Character/Tutorial_Idle")
|
||||
self.assertSuccess(r)
|
||||
self.assertEqual(len(r["range_seconds"]), 2)
|
||||
|
||||
def test_add_anim_track_bad_anim(self):
|
||||
self._skip_if_no_seq()
|
||||
name = self._add_camera()
|
||||
r = self.call("level_sequence_actions", "ue_add_anim_track",
|
||||
asset_path=self._seq_path, binding_name=name,
|
||||
anim_path="/Engine/BasicShapes/Cube")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_open_and_close_sequencer(self):
|
||||
self._skip_if_no_seq()
|
||||
r = self.call("level_sequence_actions", "ue_open_in_sequencer", asset_path=self._seq_path)
|
||||
self.assertSuccess(r)
|
||||
r = self.call("level_sequence_actions", "ue_close_sequencer")
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_convert_binding_to_spawnable(self):
|
||||
self._skip_if_no_seq()
|
||||
spawn = self.call("actor_actions", "ue_spawn_from_class",
|
||||
class_path="/Script/Engine.PointLight", location=[0, 0, 450])
|
||||
self.assertSuccess(spawn)
|
||||
label = spawn["actor_label"]
|
||||
try:
|
||||
r = self.call("level_sequence_actions", "ue_add_possessable",
|
||||
asset_path=self._seq_path, actor_label=label)
|
||||
self.assertSuccess(r)
|
||||
r = self.call("level_sequence_actions", "ue_convert_binding",
|
||||
asset_path=self._seq_path, binding_name=r["binding_name"], to="spawnable")
|
||||
self.assertSuccess(r)
|
||||
self.assertTrue(r["bindings"])
|
||||
finally:
|
||||
self.delete_actor_by_label(label)
|
||||
|
||||
def test_convert_binding_bad_mode(self):
|
||||
self._skip_if_no_seq()
|
||||
r = self.call("level_sequence_actions", "ue_convert_binding",
|
||||
asset_path=self._seq_path, binding_name="X", to="nonsense")
|
||||
self.assertFalse(r.get("success"))
|
||||
@@ -0,0 +1,272 @@
|
||||
import unreal
|
||||
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
|
||||
|
||||
_MAT_NAME = "MCP_TestMaterial"
|
||||
_MAT_PATH = f"{TEST_ROOT}/{_MAT_NAME}"
|
||||
_MI_NAME = "MCP_TestMI"
|
||||
_MI_PATH = f"{TEST_ROOT}/{_MI_NAME}"
|
||||
|
||||
|
||||
class TestMaterialActions(MCPTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self._mat_path = None
|
||||
self._mi_path = None
|
||||
self.ensure_test_dir()
|
||||
tools = unreal.AssetToolsHelpers.get_asset_tools()
|
||||
|
||||
mat = tools.create_asset(_MAT_NAME, TEST_ROOT, unreal.Material, unreal.MaterialFactoryNew())
|
||||
if not mat:
|
||||
return
|
||||
self._mat_path = _MAT_PATH
|
||||
|
||||
# Add a ScalarParameter and VectorParameter so the MI has testable params
|
||||
scalar = unreal.MaterialEditingLibrary.create_material_expression(
|
||||
mat, unreal.MaterialExpressionScalarParameter, 0, 0)
|
||||
if scalar:
|
||||
scalar.set_editor_property('parameter_name', unreal.Name('TestScalar'))
|
||||
scalar.set_editor_property('default_value', 0.5)
|
||||
|
||||
vec = unreal.MaterialEditingLibrary.create_material_expression(
|
||||
mat, unreal.MaterialExpressionVectorParameter, 0, 150)
|
||||
if vec:
|
||||
vec.set_editor_property('parameter_name', unreal.Name('TestVector'))
|
||||
|
||||
sw = unreal.MaterialEditingLibrary.create_material_expression(
|
||||
mat, unreal.MaterialExpressionStaticBoolParameter, 0, 300)
|
||||
if sw:
|
||||
sw.set_editor_property('parameter_name', unreal.Name('TestSwitch'))
|
||||
|
||||
tex = unreal.MaterialEditingLibrary.create_material_expression(
|
||||
mat, unreal.MaterialExpressionTextureSampleParameter2D, 0, 450)
|
||||
if tex:
|
||||
tex.set_editor_property('parameter_name', unreal.Name('TestTexture'))
|
||||
|
||||
unreal.MaterialEditingLibrary.recompile_material(mat)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(mat)
|
||||
|
||||
try:
|
||||
mi = tools.create_asset(_MI_NAME, TEST_ROOT,
|
||||
unreal.MaterialInstanceConstant,
|
||||
unreal.MaterialInstanceConstantFactoryNew())
|
||||
if mi:
|
||||
# The factory has no 'initial_parent' in this engine version;
|
||||
# set the parent on the instance directly, then refresh it.
|
||||
mi.set_editor_property('parent', mat)
|
||||
self._mi_path = _MI_PATH
|
||||
unreal.MaterialEditingLibrary.update_material_instance(mi)
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(mi)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def tearDown(self):
|
||||
if self._mi_path:
|
||||
self.delete_asset(self._mi_path)
|
||||
if self._mat_path:
|
||||
self.delete_asset(self._mat_path)
|
||||
|
||||
# ── expressions ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_create_expression(self):
|
||||
if not self._mat_path:
|
||||
self.skipTest("Material not created in setUp")
|
||||
r = self.call("material_actions", "ue_create_expression",
|
||||
material_path=self._mat_path,
|
||||
expression_class_name="Constant", node_pos_x=200, node_pos_y=0)
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("expression_class", r)
|
||||
|
||||
def test_recompile(self):
|
||||
if not self._mat_path:
|
||||
self.skipTest("Material not created in setUp")
|
||||
r = self.call("material_actions", "ue_recompile",
|
||||
material_path=self._mat_path)
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_create_expression_invalid_class(self):
|
||||
if not self._mat_path:
|
||||
self.skipTest("Material not created in setUp")
|
||||
r = self.call("material_actions", "ue_create_expression",
|
||||
material_path=self._mat_path,
|
||||
expression_class_name="NonExistentExpression999")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
# ── MI scalar ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_set_get_mi_scalar_param(self):
|
||||
if not self._mi_path:
|
||||
self.skipTest("MaterialInstance not created in setUp")
|
||||
r = self.call("material_actions", "ue_set_mi_scalar_param",
|
||||
instance_path=self._mi_path,
|
||||
parameter_name="TestScalar", value=0.75)
|
||||
self.assertSuccess(r)
|
||||
r = self.call("material_actions", "ue_get_mi_scalar_param",
|
||||
instance_path=self._mi_path, parameter_name="TestScalar")
|
||||
self.assertSuccess(r)
|
||||
self.assertAlmostEqual(r["value"], 0.75, places=3)
|
||||
|
||||
# ── MI vector ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_set_get_mi_vector_param(self):
|
||||
if not self._mi_path:
|
||||
self.skipTest("MaterialInstance not created in setUp")
|
||||
color = [1.0, 0.0, 0.5, 1.0]
|
||||
r = self.call("material_actions", "ue_set_mi_vector_param",
|
||||
instance_path=self._mi_path,
|
||||
parameter_name="TestVector", value=color)
|
||||
self.assertSuccess(r)
|
||||
r = self.call("material_actions", "ue_get_mi_vector_param",
|
||||
instance_path=self._mi_path, parameter_name="TestVector")
|
||||
self.assertSuccess(r)
|
||||
self.assertEqual(len(r["value"]), 4)
|
||||
|
||||
# ── MI static switch ────────────────────────────────────────────────────────
|
||||
|
||||
def test_set_get_mi_static_switch(self):
|
||||
if not self._mi_path:
|
||||
self.skipTest("MaterialInstance not created in setUp")
|
||||
r = self.call("material_actions", "ue_set_mi_static_switch",
|
||||
instance_path=self._mi_path,
|
||||
parameter_name="TestSwitch", value=True)
|
||||
self.assertSuccess(r)
|
||||
r = self.call("material_actions", "ue_get_mi_static_switch",
|
||||
instance_path=self._mi_path, parameter_name="TestSwitch")
|
||||
self.assertSuccess(r)
|
||||
self.assertEqual(r["value"], True)
|
||||
|
||||
# ── MI texture ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_set_get_mi_texture_param(self):
|
||||
if not self._mi_path:
|
||||
self.skipTest("MaterialInstance not created in setUp")
|
||||
r = self.call("material_actions", "ue_set_mi_texture_param",
|
||||
instance_path=self._mi_path, parameter_name="TestTexture",
|
||||
texture_path="/Engine/EngineResources/DefaultTexture")
|
||||
self.assertSuccess(r)
|
||||
r = self.call("material_actions", "ue_get_mi_texture_param",
|
||||
instance_path=self._mi_path, parameter_name="TestTexture")
|
||||
self.assertSuccess(r)
|
||||
|
||||
# ── connect expressions ─────────────────────────────────────────────────────
|
||||
|
||||
def test_connect_expressions(self):
|
||||
if not self._mat_path:
|
||||
self.skipTest("Material not created in setUp")
|
||||
a = self.call("material_actions", "ue_create_expression",
|
||||
material_path=self._mat_path,
|
||||
expression_class_name="Constant", node_pos_x=-600, node_pos_y=0)
|
||||
self.assertSuccess(a)
|
||||
b = self.call("material_actions", "ue_create_expression",
|
||||
material_path=self._mat_path,
|
||||
expression_class_name="Multiply", node_pos_x=-300, node_pos_y=0)
|
||||
self.assertSuccess(b)
|
||||
r = self.call("material_actions", "ue_connect_expressions",
|
||||
material_path=self._mat_path,
|
||||
from_expression_identifier=a["expression_name"], from_output_name="",
|
||||
to_expression_identifier=b["expression_name"], to_input_name="A")
|
||||
self.assertSuccess(r)
|
||||
|
||||
# ── asset creation ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_create_material(self):
|
||||
import unreal
|
||||
path = f"{TEST_ROOT}/MCP_CreatedMat"
|
||||
try:
|
||||
r = self.call("material_actions", "ue_create_material", material_path=path)
|
||||
self.assertSuccess(r)
|
||||
self.assertTrue(unreal.EditorAssetLibrary.does_asset_exist(path))
|
||||
finally:
|
||||
self.delete_asset(path)
|
||||
|
||||
def test_create_material_instance(self):
|
||||
import unreal
|
||||
path = f"{TEST_ROOT}/MCP_CreatedMI"
|
||||
try:
|
||||
r = self.call("material_actions", "ue_create_material_instance",
|
||||
instance_path=path, parent_path=self._mat_path)
|
||||
self.assertSuccess(r)
|
||||
self.assertTrue(unreal.EditorAssetLibrary.does_asset_exist(path))
|
||||
finally:
|
||||
self.delete_asset(path)
|
||||
|
||||
# ── graph authoring ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_connect_property(self):
|
||||
if not self._mat_path:
|
||||
self.skipTest("Material not created in setUp")
|
||||
c = self.call("material_actions", "ue_create_expression",
|
||||
material_path=self._mat_path,
|
||||
expression_class_name="Constant3Vector", node_pos_x=-400, node_pos_y=0)
|
||||
self.assertSuccess(c)
|
||||
r = self.call("material_actions", "ue_connect_property",
|
||||
material_path=self._mat_path,
|
||||
from_expression_identifier=c["expression_name"],
|
||||
from_output_name="", property_name="BaseColor")
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_connect_property_invalid_name(self):
|
||||
if not self._mat_path:
|
||||
self.skipTest("Material not created in setUp")
|
||||
r = self.call("material_actions", "ue_connect_property",
|
||||
material_path=self._mat_path,
|
||||
from_expression_identifier="whatever", property_name="NotAProperty")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_set_expression_property(self):
|
||||
if not self._mat_path:
|
||||
self.skipTest("Material not created in setUp")
|
||||
c = self.call("material_actions", "ue_create_expression",
|
||||
material_path=self._mat_path,
|
||||
expression_class_name="Constant", node_pos_x=-600, node_pos_y=0)
|
||||
self.assertSuccess(c)
|
||||
r = self.call("material_actions", "ue_set_expression_property",
|
||||
material_path=self._mat_path,
|
||||
expression_identifier=c["expression_name"],
|
||||
property_name="r", value=0.5)
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_delete_expression(self):
|
||||
if not self._mat_path:
|
||||
self.skipTest("Material not created in setUp")
|
||||
c = self.call("material_actions", "ue_create_expression",
|
||||
material_path=self._mat_path,
|
||||
expression_class_name="Constant", node_pos_x=-800, node_pos_y=0)
|
||||
self.assertSuccess(c)
|
||||
r = self.call("material_actions", "ue_delete_expression",
|
||||
material_path=self._mat_path,
|
||||
expression_identifier=c["expression_name"])
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_layout_expressions(self):
|
||||
if not self._mat_path:
|
||||
self.skipTest("Material not created in setUp")
|
||||
r = self.call("material_actions", "ue_layout_expressions",
|
||||
material_path=self._mat_path)
|
||||
self.assertSuccess(r)
|
||||
|
||||
# ── introspection ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_get_material_info(self):
|
||||
if not self._mat_path:
|
||||
self.skipTest("Material not created in setUp")
|
||||
r = self.call("material_actions", "ue_get_material_info",
|
||||
material_path=self._mat_path)
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("expression_count", r)
|
||||
self.assertIsInstance(r["expressions"], list)
|
||||
|
||||
def test_list_parameters(self):
|
||||
if not self._mat_path:
|
||||
self.skipTest("Material not created in setUp")
|
||||
r = self.call("material_actions", "ue_list_parameters",
|
||||
material_path=self._mat_path)
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("TestScalar", r["scalar"])
|
||||
self.assertIn("TestVector", r["vector"])
|
||||
|
||||
def test_set_instance_parent(self):
|
||||
if not self._mi_path or not self._mat_path:
|
||||
self.skipTest("MI/Material not created in setUp")
|
||||
r = self.call("material_actions", "ue_set_instance_parent",
|
||||
instance_path=self._mi_path, parent_path=self._mat_path)
|
||||
self.assertSuccess(r)
|
||||
@@ -0,0 +1,87 @@
|
||||
import unreal
|
||||
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
|
||||
|
||||
_MESH = "/Engine/Tutorial/SubEditors/TutorialAssets/Character/TutorialTPP"
|
||||
_ANIM = "/Engine/Tutorial/SubEditors/TutorialAssets/Character/Tutorial_Idle"
|
||||
_RIG_PATH = f"{TEST_ROOT}/MCP_TestIKRig"
|
||||
_RTG_PATH = f"{TEST_ROOT}/MCP_TestRTG"
|
||||
|
||||
|
||||
class TestRetargetActions(MCPTestCase):
|
||||
|
||||
def setUp(self):
|
||||
if not hasattr(unreal, "IKRigController"):
|
||||
self.skipTest("IKRig plugin not enabled")
|
||||
if not unreal.EditorAssetLibrary.does_asset_exist(_MESH):
|
||||
self.skipTest("Engine TutorialTPP mesh not available")
|
||||
self.ensure_test_dir()
|
||||
for p in (_RTG_PATH, _RIG_PATH):
|
||||
self.delete_asset(p)
|
||||
|
||||
def tearDown(self):
|
||||
for p in (_RTG_PATH, _RIG_PATH):
|
||||
self.delete_asset(p)
|
||||
|
||||
def _make_rig(self):
|
||||
r = self.call("retarget_actions", "ue_create_ik_rig",
|
||||
asset_path=_RIG_PATH, skeletal_mesh_path=_MESH, retarget_root="pelvis")
|
||||
self.assertSuccess(r)
|
||||
r = self.call("retarget_actions", "ue_add_retarget_chain",
|
||||
ik_rig_path=_RIG_PATH, chain_name="Spine",
|
||||
start_bone="spine_01", end_bone="spine_03")
|
||||
self.assertSuccess(r)
|
||||
return _RIG_PATH
|
||||
|
||||
def test_create_ik_rig_and_info(self):
|
||||
self._make_rig()
|
||||
r = self.call("retarget_actions", "ue_get_ik_rig_info", ik_rig_path=_RIG_PATH)
|
||||
self.assertSuccess(r)
|
||||
self.assertEqual(r["retarget_root"], "pelvis")
|
||||
self.assertEqual(r["chains"][0]["name"], "Spine")
|
||||
self.assertEqual(r["chains"][0]["start_bone"], "spine_01")
|
||||
|
||||
def test_create_ik_rig_bad_mesh(self):
|
||||
r = self.call("retarget_actions", "ue_create_ik_rig",
|
||||
asset_path=_RIG_PATH, skeletal_mesh_path="/Engine/BasicShapes/Cube")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_add_chain_missing_params(self):
|
||||
r = self.call("retarget_actions", "ue_add_retarget_chain", ik_rig_path=_RIG_PATH)
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_create_retargeter_and_automap(self):
|
||||
rig = self._make_rig()
|
||||
r = self.call("retarget_actions", "ue_create_retargeter",
|
||||
asset_path=_RTG_PATH, source_ik_rig_path=rig,
|
||||
target_ik_rig_path=rig, auto_map=True)
|
||||
self.assertSuccess(r)
|
||||
r = self.call("retarget_actions", "ue_auto_map_chains",
|
||||
retargeter_path=_RTG_PATH, mode="EXACT", force=True)
|
||||
self.assertSuccess(r)
|
||||
r = self.call("retarget_actions", "ue_auto_map_chains",
|
||||
retargeter_path=_RTG_PATH, mode="NONSENSE")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_batch_retarget(self):
|
||||
rig = self._make_rig()
|
||||
self.call("retarget_actions", "ue_create_retargeter",
|
||||
asset_path=_RTG_PATH, source_ik_rig_path=rig,
|
||||
target_ik_rig_path=rig, auto_map=True)
|
||||
r = self.call("retarget_actions", "ue_batch_retarget",
|
||||
retargeter_path=_RTG_PATH, anim_paths=[_ANIM],
|
||||
source_mesh_path=_MESH, target_mesh_path=_MESH,
|
||||
suffix="_MCPRT")
|
||||
self.assertSuccess(r)
|
||||
self.assertEqual(r["count"], 1)
|
||||
for p in r["retargeted_assets"]:
|
||||
self.delete_asset(p)
|
||||
|
||||
def test_batch_retarget_missing_anim(self):
|
||||
rig = self._make_rig()
|
||||
self.call("retarget_actions", "ue_create_retargeter",
|
||||
asset_path=_RTG_PATH, source_ik_rig_path=rig,
|
||||
target_ik_rig_path=rig)
|
||||
r = self.call("retarget_actions", "ue_batch_retarget",
|
||||
retargeter_path=_RTG_PATH, anim_paths=["/Game/NoSuchAnim_XYZ"],
|
||||
source_mesh_path=_MESH, target_mesh_path=_MESH)
|
||||
self.assertFalse(r.get("success"))
|
||||
@@ -0,0 +1,130 @@
|
||||
import unreal
|
||||
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
|
||||
|
||||
_SRC = "/Engine/BasicShapes/Cube"
|
||||
_DEFAULT_MAT = "/Engine/BasicShapes/BasicShapeMaterial"
|
||||
|
||||
|
||||
class TestStaticMeshActions(MCPTestCase):
|
||||
|
||||
def test_get_static_mesh_info(self):
|
||||
r = self.call("static_mesh_actions", "ue_get_static_mesh_info", asset_path=_SRC)
|
||||
self.assertSuccess(r)
|
||||
self.assertGreaterEqual(r["num_lods"], 1)
|
||||
self.assertGreater(r["num_triangles_lod0"], 0)
|
||||
self.assertGreater(r["num_vertices_lod0"], 0)
|
||||
|
||||
def test_get_static_mesh_info_invalid(self):
|
||||
r = self.call("static_mesh_actions", "ue_get_static_mesh_info",
|
||||
asset_path="/Game/DoesNotExist_XYZ")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_list_materials(self):
|
||||
r = self.call("static_mesh_actions", "ue_list_static_mesh_materials", asset_path=_SRC)
|
||||
self.assertSuccess(r)
|
||||
self.assertGreaterEqual(r["num_materials"], 1)
|
||||
|
||||
def test_get_collision_info(self):
|
||||
r = self.call("static_mesh_actions", "ue_get_collision_info", asset_path=_SRC)
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("simple_collision_count", r)
|
||||
|
||||
def test_set_material(self):
|
||||
self.ensure_test_dir()
|
||||
dst = f"{TEST_ROOT}/MCP_SMCopy"
|
||||
self.delete_asset(dst)
|
||||
unreal.EditorAssetLibrary.duplicate_asset(_SRC, dst)
|
||||
try:
|
||||
r = self.call("static_mesh_actions", "ue_set_static_mesh_material",
|
||||
asset_path=dst, slot_index=0, material_path=_DEFAULT_MAT)
|
||||
self.assertSuccess(r)
|
||||
finally:
|
||||
self.delete_asset(dst)
|
||||
|
||||
def test_add_simple_collision(self):
|
||||
self.ensure_test_dir()
|
||||
dst = f"{TEST_ROOT}/MCP_SMCol"
|
||||
self.delete_asset(dst)
|
||||
unreal.EditorAssetLibrary.duplicate_asset(_SRC, dst)
|
||||
try:
|
||||
before = self.call("static_mesh_actions", "ue_get_collision_info",
|
||||
asset_path=dst)["simple_collision_count"]
|
||||
r = self.call("static_mesh_actions", "ue_add_simple_collision",
|
||||
asset_path=dst, shape="SPHERE")
|
||||
self.assertSuccess(r)
|
||||
self.assertGreater(r["simple_collision_count"], before)
|
||||
finally:
|
||||
self.delete_asset(dst)
|
||||
|
||||
def test_add_simple_collision_bad_shape(self):
|
||||
r = self.call("static_mesh_actions", "ue_add_simple_collision",
|
||||
asset_path=_SRC, shape="NOTASHAPE")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
# ── LODs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _dup_mesh(self, name):
|
||||
self.ensure_test_dir()
|
||||
dst = f"{TEST_ROOT}/{name}"
|
||||
self.delete_asset(dst)
|
||||
unreal.EditorAssetLibrary.duplicate_asset(_SRC, dst)
|
||||
return dst
|
||||
|
||||
def test_set_and_remove_lods(self):
|
||||
dst = self._dup_mesh("MCP_SMLod")
|
||||
try:
|
||||
r = self.call("static_mesh_actions", "ue_set_lods", asset_path=dst,
|
||||
lod_settings=[{"percent_triangles": 1.0, "screen_size": 1.0},
|
||||
{"percent_triangles": 0.5, "screen_size": 0.5}])
|
||||
self.assertSuccess(r)
|
||||
self.assertEqual(r["lod_count"], 2)
|
||||
r = self.call("static_mesh_actions", "ue_get_lod_screen_sizes", asset_path=dst)
|
||||
self.assertSuccess(r)
|
||||
self.assertEqual(len(r["screen_sizes"]), 2)
|
||||
r = self.call("static_mesh_actions", "ue_remove_lods", asset_path=dst)
|
||||
self.assertSuccess(r)
|
||||
self.assertEqual(r["lod_count"], 1)
|
||||
finally:
|
||||
self.delete_asset(dst)
|
||||
|
||||
def test_set_lods_missing(self):
|
||||
r = self.call("static_mesh_actions", "ue_set_lods", asset_path=_SRC)
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_set_lod_from_static_mesh(self):
|
||||
dst = self._dup_mesh("MCP_SMLodCopy")
|
||||
try:
|
||||
r = self.call("static_mesh_actions", "ue_set_lod_from_static_mesh",
|
||||
asset_path=dst, lod_index=1,
|
||||
source_path="/Engine/BasicShapes/Sphere", source_lod_index=0)
|
||||
self.assertSuccess(r)
|
||||
self.assertEqual(r["lod_count"], 2)
|
||||
finally:
|
||||
self.delete_asset(dst)
|
||||
|
||||
# ── convex / collision management ───────────────────────────────────────────
|
||||
|
||||
def test_convex_and_remove_collisions(self):
|
||||
dst = self._dup_mesh("MCP_SMConvex")
|
||||
try:
|
||||
r = self.call("static_mesh_actions", "ue_set_convex_collision",
|
||||
asset_path=dst, hull_count=2, max_hull_verts=16, hull_precision=100000)
|
||||
self.assertSuccess(r)
|
||||
self.assertGreaterEqual(r["convex_collision_count"], 1)
|
||||
r = self.call("static_mesh_actions", "ue_remove_collisions", asset_path=dst)
|
||||
self.assertSuccess(r)
|
||||
self.assertEqual(r["convex_collision_count"], 0)
|
||||
finally:
|
||||
self.delete_asset(dst)
|
||||
|
||||
def test_set_lod_for_collision(self):
|
||||
dst = self._dup_mesh("MCP_SMLodCol")
|
||||
try:
|
||||
r = self.call("static_mesh_actions", "ue_set_lod_for_collision",
|
||||
asset_path=dst, lod_index=0)
|
||||
self.assertSuccess(r)
|
||||
r = self.call("static_mesh_actions", "ue_set_lod_for_collision",
|
||||
asset_path=dst, lod_index=99)
|
||||
self.assertFalse(r.get("success"))
|
||||
finally:
|
||||
self.delete_asset(dst)
|
||||
@@ -0,0 +1,55 @@
|
||||
import unreal
|
||||
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
|
||||
|
||||
_SRC = "/Engine/EngineResources/DefaultTexture"
|
||||
|
||||
|
||||
class TestTextureActions(MCPTestCase):
|
||||
|
||||
def test_get_texture_info(self):
|
||||
if not unreal.EditorAssetLibrary.does_asset_exist(_SRC):
|
||||
self.skipTest("Engine DefaultTexture not available")
|
||||
r = self.call("texture_actions", "ue_get_texture_info", asset_path=_SRC)
|
||||
self.assertSuccess(r)
|
||||
self.assertGreater(r["width"], 0)
|
||||
self.assertGreater(r["height"], 0)
|
||||
self.assertIsInstance(r["srgb"], bool)
|
||||
|
||||
def test_get_texture_info_invalid(self):
|
||||
r = self.call("texture_actions", "ue_get_texture_info", asset_path="/Game/Nope_XYZ")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def _dup(self, name):
|
||||
self.ensure_test_dir()
|
||||
dst = f"{TEST_ROOT}/{name}"
|
||||
self.delete_asset(dst)
|
||||
unreal.EditorAssetLibrary.duplicate_asset(_SRC, dst)
|
||||
return dst
|
||||
|
||||
def test_set_texture_srgb(self):
|
||||
if not unreal.EditorAssetLibrary.does_asset_exist(_SRC):
|
||||
self.skipTest("Engine DefaultTexture not available")
|
||||
dst = self._dup("MCP_TexSrgb")
|
||||
try:
|
||||
r = self.call("texture_actions", "ue_set_texture_srgb", asset_path=dst, srgb=False)
|
||||
self.assertSuccess(r)
|
||||
info = self.call("texture_actions", "ue_get_texture_info", asset_path=dst)
|
||||
self.assertFalse(info["srgb"])
|
||||
finally:
|
||||
self.delete_asset(dst)
|
||||
|
||||
def test_set_texture_compression(self):
|
||||
if not unreal.EditorAssetLibrary.does_asset_exist(_SRC):
|
||||
self.skipTest("Engine DefaultTexture not available")
|
||||
dst = self._dup("MCP_TexComp")
|
||||
try:
|
||||
r = self.call("texture_actions", "ue_set_texture_compression",
|
||||
asset_path=dst, compression="TC_NORMALMAP")
|
||||
self.assertSuccess(r)
|
||||
finally:
|
||||
self.delete_asset(dst)
|
||||
|
||||
def test_set_texture_compression_invalid(self):
|
||||
r = self.call("texture_actions", "ue_set_texture_compression",
|
||||
asset_path=_SRC, compression="NOTACOMPRESSION")
|
||||
self.assertFalse(r.get("success"))
|
||||
@@ -0,0 +1,243 @@
|
||||
from UnrealMCPython.tests.base import MCPTestCase, TEST_ROOT
|
||||
|
||||
_WBP_NAME = "MCP_TestWidget"
|
||||
_WBP_PATH = f"{TEST_ROOT}/{_WBP_NAME}"
|
||||
_WBP_FULL = f"{_WBP_PATH}.{_WBP_NAME}"
|
||||
|
||||
|
||||
class TestUMGActions(MCPTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self._wbp_path = None
|
||||
self.ensure_test_dir()
|
||||
r = self.call("umg_actions", "ue_create_widget_blueprint",
|
||||
name=_WBP_NAME, path=TEST_ROOT)
|
||||
if r.get("success"):
|
||||
self._wbp_path = _WBP_FULL
|
||||
|
||||
def tearDown(self):
|
||||
if self._wbp_path:
|
||||
self.delete_asset(self._wbp_path)
|
||||
|
||||
def _skip_if_no_wbp(self):
|
||||
if not self._wbp_path:
|
||||
self.skipTest("WidgetBlueprint not created in setUp")
|
||||
|
||||
# ── create / info ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_create_widget_blueprint(self):
|
||||
self.assertIsNotNone(self._wbp_path, "Widget Blueprint was not created in setUp")
|
||||
|
||||
def test_get_widget_blueprint_info_empty(self):
|
||||
self._skip_if_no_wbp()
|
||||
r = self.call("umg_actions", "ue_get_widget_blueprint_info",
|
||||
asset_path=self._wbp_path)
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("widget_count", r)
|
||||
|
||||
# ── add widgets ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_add_canvas_panel_root(self):
|
||||
self._skip_if_no_wbp()
|
||||
r = self.call("umg_actions", "ue_add_widget",
|
||||
asset_path=self._wbp_path,
|
||||
widget_type="CanvasPanel", widget_name="RootCanvas")
|
||||
self.assertSuccess(r)
|
||||
self.assertTrue(r.get("is_root"))
|
||||
|
||||
def test_add_text_block_under_canvas(self):
|
||||
self._skip_if_no_wbp()
|
||||
self.call("umg_actions", "ue_add_widget",
|
||||
asset_path=self._wbp_path,
|
||||
widget_type="CanvasPanel", widget_name="RootCanvas")
|
||||
r = self.call("umg_actions", "ue_add_widget",
|
||||
asset_path=self._wbp_path,
|
||||
widget_type="TextBlock", widget_name="TitleText",
|
||||
parent_name="RootCanvas")
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_add_and_remove_widget(self):
|
||||
self._skip_if_no_wbp()
|
||||
self.call("umg_actions", "ue_add_widget",
|
||||
asset_path=self._wbp_path,
|
||||
widget_type="CanvasPanel", widget_name="RootCanvas")
|
||||
self.call("umg_actions", "ue_add_widget",
|
||||
asset_path=self._wbp_path,
|
||||
widget_type="Button", widget_name="RemoveBtn",
|
||||
parent_name="RootCanvas")
|
||||
r = self.call("umg_actions", "ue_remove_widget",
|
||||
asset_path=self._wbp_path, widget_name="RemoveBtn")
|
||||
self.assertSuccess(r)
|
||||
|
||||
# ── properties ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_set_widget_properties(self):
|
||||
self._skip_if_no_wbp()
|
||||
self.call("umg_actions", "ue_add_widget",
|
||||
asset_path=self._wbp_path,
|
||||
widget_type="CanvasPanel", widget_name="RootCanvas")
|
||||
self.call("umg_actions", "ue_add_widget",
|
||||
asset_path=self._wbp_path,
|
||||
widget_type="TextBlock", widget_name="Label",
|
||||
parent_name="RootCanvas")
|
||||
r = self.call("umg_actions", "ue_set_widget_properties",
|
||||
asset_path=self._wbp_path, widget_name="Label",
|
||||
properties={"text": "Hello", "font_size": 24,
|
||||
"slot_position": [100.0, 50.0],
|
||||
"slot_size": [300.0, 60.0]})
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_set_slot_layout(self):
|
||||
self._skip_if_no_wbp()
|
||||
self.call("umg_actions", "ue_add_widget",
|
||||
asset_path=self._wbp_path,
|
||||
widget_type="CanvasPanel", widget_name="RootCanvas")
|
||||
self.call("umg_actions", "ue_add_widget",
|
||||
asset_path=self._wbp_path,
|
||||
widget_type="Image", widget_name="BgImage",
|
||||
parent_name="RootCanvas")
|
||||
r = self.call("umg_actions", "ue_set_slot_layout",
|
||||
asset_path=self._wbp_path, widget_name="BgImage",
|
||||
anchor_min_x=0.0, anchor_min_y=0.0,
|
||||
anchor_max_x=1.0, anchor_max_y=1.0,
|
||||
offset_x=0.0, offset_y=0.0,
|
||||
size_x=800.0, size_y=600.0)
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_set_get_widget_property(self):
|
||||
self._skip_if_no_wbp()
|
||||
self.call("umg_actions", "ue_add_widget",
|
||||
asset_path=self._wbp_path,
|
||||
widget_type="CanvasPanel", widget_name="RootCanvas")
|
||||
self.call("umg_actions", "ue_add_widget",
|
||||
asset_path=self._wbp_path,
|
||||
widget_type="TextBlock", widget_name="PropText",
|
||||
parent_name="RootCanvas")
|
||||
r = self.call("umg_actions", "ue_set_widget_property",
|
||||
asset_path=self._wbp_path, widget_name="PropText",
|
||||
property_name="ToolTipText", value="hello tip")
|
||||
self.assertSuccess(r)
|
||||
r = self.call("umg_actions", "ue_get_widget_property",
|
||||
asset_path=self._wbp_path, widget_name="PropText",
|
||||
property_name="ToolTipText")
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("value", r)
|
||||
|
||||
def test_set_text_style(self):
|
||||
self._skip_if_no_wbp()
|
||||
self.call("umg_actions", "ue_add_widget",
|
||||
asset_path=self._wbp_path,
|
||||
widget_type="CanvasPanel", widget_name="RootCanvas")
|
||||
self.call("umg_actions", "ue_add_widget",
|
||||
asset_path=self._wbp_path,
|
||||
widget_type="TextBlock", widget_name="StyledText",
|
||||
parent_name="RootCanvas")
|
||||
r = self.call("umg_actions", "ue_set_text_style",
|
||||
asset_path=self._wbp_path, widget_name="StyledText",
|
||||
font_size=30, color_r=1.0, color_g=0.0, color_b=0.0, color_a=1.0)
|
||||
self.assertSuccess(r)
|
||||
|
||||
# ── compile ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_compile_widget_blueprint(self):
|
||||
self._skip_if_no_wbp()
|
||||
self.call("umg_actions", "ue_add_widget",
|
||||
asset_path=self._wbp_path,
|
||||
widget_type="CanvasPanel", widget_name="RootCanvas")
|
||||
r = self.call("umg_actions", "ue_compile_widget_blueprint",
|
||||
asset_path=self._wbp_path)
|
||||
self.assertSuccess(r)
|
||||
|
||||
# ── hierarchy ops: reparent / wrap / replace ─────────────────────────────────
|
||||
|
||||
def _root_canvas_with(self, *widgets):
|
||||
self.call("umg_actions", "ue_add_widget", asset_path=self._wbp_path,
|
||||
widget_type="CanvasPanel", widget_name="Root")
|
||||
for wtype, wname in widgets:
|
||||
self.call("umg_actions", "ue_add_widget", asset_path=self._wbp_path,
|
||||
widget_type=wtype, widget_name=wname, parent_name="Root")
|
||||
|
||||
def test_reparent_widget_and_cycle_guard(self):
|
||||
self._skip_if_no_wbp()
|
||||
self._root_canvas_with(("VerticalBox", "VBox"), ("Button", "Btn"))
|
||||
r = self.call("umg_actions", "ue_reparent_widget", asset_path=self._wbp_path,
|
||||
widget_name="Btn", new_parent_name="VBox")
|
||||
self.assertSuccess(r)
|
||||
# Btn now lives under VBox — reparenting VBox into Btn must be rejected (cycle)
|
||||
r2 = self.call("umg_actions", "ue_reparent_widget", asset_path=self._wbp_path,
|
||||
widget_name="VBox", new_parent_name="Btn")
|
||||
self.assertFalse(r2.get("success"))
|
||||
|
||||
def test_reparent_missing_param(self):
|
||||
self._skip_if_no_wbp()
|
||||
r = self.call("umg_actions", "ue_reparent_widget",
|
||||
asset_path=self._wbp_path, widget_name="Btn")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_wrap_widget(self):
|
||||
self._skip_if_no_wbp()
|
||||
self._root_canvas_with(("Button", "Btn"))
|
||||
r = self.call("umg_actions", "ue_wrap_widget", asset_path=self._wbp_path,
|
||||
widget_name="Btn", wrapper_type="VerticalBox", wrapper_name="Wrapper")
|
||||
self.assertSuccess(r)
|
||||
self.assertEqual(r["wrapper_type"], "VerticalBox")
|
||||
# tree must still compile after the structural change
|
||||
c = self.call("umg_actions", "ue_compile_widget_blueprint", asset_path=self._wbp_path)
|
||||
self.assertSuccess(c)
|
||||
|
||||
def test_wrap_rejects_non_panel_wrapper(self):
|
||||
self._skip_if_no_wbp()
|
||||
self._root_canvas_with(("Button", "Btn"))
|
||||
r = self.call("umg_actions", "ue_wrap_widget", asset_path=self._wbp_path,
|
||||
widget_name="Btn", wrapper_type="TextBlock", wrapper_name="BadWrap")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_replace_widget(self):
|
||||
self._skip_if_no_wbp()
|
||||
self._root_canvas_with(("Button", "OldBtn"))
|
||||
r = self.call("umg_actions", "ue_replace_widget", asset_path=self._wbp_path,
|
||||
widget_name="OldBtn", new_type="Image", new_name="NewImg")
|
||||
self.assertSuccess(r)
|
||||
self.assertEqual(r["new_type"], "Image")
|
||||
# the old widget must be gone — operating on it now fails
|
||||
gone = self.call("umg_actions", "ue_reparent_widget", asset_path=self._wbp_path,
|
||||
widget_name="OldBtn", new_parent_name="Root")
|
||||
self.assertFalse(gone.get("success"))
|
||||
|
||||
# ── event binding ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_list_widget_events(self):
|
||||
self._skip_if_no_wbp()
|
||||
self._root_canvas_with(("Button", "Btn"))
|
||||
r = self.call("umg_actions", "ue_list_widget_events",
|
||||
asset_path=self._wbp_path, widget_name="Btn")
|
||||
self.assertSuccess(r)
|
||||
# a Button exposes OnClicked among its multicast delegates
|
||||
self.assertIn("OnClicked", r["events"])
|
||||
|
||||
def test_bind_widget_event(self):
|
||||
self._skip_if_no_wbp()
|
||||
self._root_canvas_with(("Button", "Btn"))
|
||||
r = self.call("umg_actions", "ue_bind_widget_event",
|
||||
asset_path=self._wbp_path, widget_name="Btn", event_name="OnClicked")
|
||||
self.assertSuccess(r)
|
||||
self.assertEqual(r["event"], "OnClicked")
|
||||
self.assertTrue(r["node"])
|
||||
# binding the same event again is idempotent (reports the existing node)
|
||||
again = self.call("umg_actions", "ue_bind_widget_event",
|
||||
asset_path=self._wbp_path, widget_name="Btn", event_name="OnClicked")
|
||||
self.assertSuccess(again)
|
||||
self.assertTrue(again["already_existed"])
|
||||
|
||||
def test_bind_widget_event_unknown_event(self):
|
||||
self._skip_if_no_wbp()
|
||||
self._root_canvas_with(("Button", "Btn"))
|
||||
r = self.call("umg_actions", "ue_bind_widget_event",
|
||||
asset_path=self._wbp_path, widget_name="Btn", event_name="OnNopeEvent_XYZ")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_bind_widget_event_missing_param(self):
|
||||
self._skip_if_no_wbp()
|
||||
r = self.call("umg_actions", "ue_bind_widget_event",
|
||||
asset_path=self._wbp_path, widget_name="Btn")
|
||||
self.assertFalse(r.get("success"))
|
||||
@@ -0,0 +1,175 @@
|
||||
from UnrealMCPython.tests.base import MCPTestCase
|
||||
|
||||
|
||||
class TestUtilActions(MCPTestCase):
|
||||
|
||||
def test_get_output_log_default(self):
|
||||
r = self.call("util_actions", "ue_get_output_log", line_count=10)
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("log", r)
|
||||
self.assertIn("total_lines", r)
|
||||
self.assertIn("returned_lines", r)
|
||||
|
||||
def test_get_output_log_with_keyword(self):
|
||||
r = self.call("util_actions", "ue_get_output_log", line_count=20, keyword="LogMCPython")
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("log", r)
|
||||
|
||||
def test_get_output_log_with_context(self):
|
||||
# keyword guaranteed to exist: every editor log starts with a LogInit banner
|
||||
r = self.call("util_actions", "ue_get_output_log",
|
||||
line_count=5, keyword="LogInit", context_lines=2)
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("log", r)
|
||||
# context expands each match, so returned_lines must exceed the bare match count
|
||||
bare = self.call("util_actions", "ue_get_output_log",
|
||||
line_count=5, keyword="LogInit")
|
||||
self.assertSuccess(bare)
|
||||
self.assertGreaterEqual(r["returned_lines"], bare["returned_lines"])
|
||||
|
||||
def test_get_output_log_tail_matches_line_count(self):
|
||||
r = self.call("util_actions", "ue_get_output_log", line_count=3)
|
||||
self.assertSuccess(r)
|
||||
self.assertEqual(r["returned_lines"], 3)
|
||||
self.assertEqual(len(r["log"].splitlines()), 3)
|
||||
|
||||
def test_print_message(self):
|
||||
r = self.call("util_actions", "ue_print_message", message="MCP unittest ping")
|
||||
self.assertSuccess(r)
|
||||
self.assertEqual(r["received_message"], "MCP unittest ping")
|
||||
|
||||
def test_print_message_missing_param(self):
|
||||
r = self.call("util_actions", "ue_print_message")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
# ── editor control ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_execute_console_command(self):
|
||||
r = self.call("util_actions", "ue_execute_console_command", command="stat none")
|
||||
self.assertSuccess(r)
|
||||
|
||||
def test_execute_console_command_missing(self):
|
||||
r = self.call("util_actions", "ue_execute_console_command")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_save_all_dirty(self):
|
||||
r = self.call("util_actions", "ue_save_all_dirty")
|
||||
self.assertIn("success", r) # may save nothing if clean; must not error
|
||||
|
||||
def test_get_and_set_viewport_camera(self):
|
||||
cur = self.call("util_actions", "ue_get_viewport_camera")
|
||||
if not cur.get("success") and "No active level viewport" in cur.get("message", ""):
|
||||
self.skipTest("No active level viewport (e.g. editor launched without a focused viewport)")
|
||||
self.assertSuccess(cur)
|
||||
self.assertEqual(len(cur["location"]), 3)
|
||||
try:
|
||||
r = self.call("util_actions", "ue_set_viewport_camera",
|
||||
location=[500.0, 500.0, 500.0], rotation=[0.0, 90.0, 0.0])
|
||||
self.assertSuccess(r)
|
||||
finally:
|
||||
self.call("util_actions", "ue_set_viewport_camera",
|
||||
location=cur["location"], rotation=cur["rotation"])
|
||||
|
||||
def test_set_viewport_camera_missing(self):
|
||||
r = self.call("util_actions", "ue_set_viewport_camera")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
# ── world<->screen projection ────────────────────────────────────────────────
|
||||
|
||||
def _skip_if_no_viewport(self, r):
|
||||
if not r.get("success") and "No active level viewport" in r.get("message", ""):
|
||||
self.skipTest("No active level viewport for projection")
|
||||
|
||||
def test_screen_world_round_trips_back_to_pixel(self):
|
||||
# A deprojected world point lies on the view ray of its pixel, so re-projecting it
|
||||
# must return the same pixel exactly — the clean projection invariant.
|
||||
probe = self.call("util_actions", "ue_screen_to_world", x=200.0, y=200.0, distance=500.0)
|
||||
self._skip_if_no_viewport(probe)
|
||||
self.assertSuccess(probe)
|
||||
self.assertEqual(len(probe["location"]), 3)
|
||||
self.assertEqual(len(probe["direction"]), 3)
|
||||
|
||||
back = self.call("util_actions", "ue_world_to_screen", location=probe["location"])
|
||||
self.assertSuccess(back)
|
||||
self.assertTrue(back["visible"])
|
||||
self.assertGreater(back["viewport_width"], 0)
|
||||
self.assertAlmostEqual(back["x"], 200.0, delta=1.0)
|
||||
self.assertAlmostEqual(back["y"], 200.0, delta=1.0)
|
||||
|
||||
def test_world_to_screen_rejects_bad_location(self):
|
||||
r = self.call("util_actions", "ue_world_to_screen", location=[1.0, 2.0])
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_screen_to_world_missing_param(self):
|
||||
r = self.call("util_actions", "ue_screen_to_world", x=10.0)
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_is_in_pie(self):
|
||||
r = self.call("util_actions", "ue_is_in_pie")
|
||||
self.assertSuccess(r)
|
||||
self.assertIsInstance(r["in_pie"], bool)
|
||||
|
||||
def test_list_class_properties(self):
|
||||
r = self.call("util_actions", "ue_list_class_properties",
|
||||
class_path="/Script/Engine.PointLight")
|
||||
self.assertSuccess(r)
|
||||
self.assertGreater(r["count"], 0)
|
||||
|
||||
def test_list_class_properties_invalid(self):
|
||||
r = self.call("util_actions", "ue_list_class_properties",
|
||||
class_path="/Script/Engine.NopeXYZ123")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_get_cvar(self):
|
||||
r = self.call("util_actions", "ue_get_cvar", name="r.ScreenPercentage")
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("value", r)
|
||||
|
||||
def test_get_cvar_missing(self):
|
||||
r = self.call("util_actions", "ue_get_cvar")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_set_cvar_round_trips(self):
|
||||
name = "r.ScreenPercentage"
|
||||
before = self.call("util_actions", "ue_get_cvar", name=name)["value"]
|
||||
try:
|
||||
r = self.call("util_actions", "ue_set_cvar", name=name, value="73")
|
||||
self.assertSuccess(r)
|
||||
got = self.call("util_actions", "ue_get_cvar", name=name)["value"]
|
||||
self.assertEqual(float(got), 73.0)
|
||||
finally:
|
||||
self.call("util_actions", "ue_set_cvar", name=name, value=before)
|
||||
|
||||
def test_set_cvar_missing_param(self):
|
||||
r = self.call("util_actions", "ue_set_cvar", name="r.ScreenPercentage")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_set_log_verbosity(self):
|
||||
try:
|
||||
r = self.call("util_actions", "ue_set_log_verbosity",
|
||||
category="LogMCPython", verbosity="Verbose")
|
||||
self.assertSuccess(r)
|
||||
finally:
|
||||
self.call("util_actions", "ue_set_log_verbosity",
|
||||
category="LogMCPython", verbosity="Log")
|
||||
|
||||
def test_set_log_verbosity_invalid(self):
|
||||
r = self.call("util_actions", "ue_set_log_verbosity",
|
||||
category="LogMCPython", verbosity="NopeLevel")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_get_project_info(self):
|
||||
r = self.call("util_actions", "ue_get_project_info")
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("engine_version", r)
|
||||
self.assertTrue(r["project_dir"])
|
||||
|
||||
def test_list_enum_values(self):
|
||||
r = self.call("util_actions", "ue_list_enum_values",
|
||||
enum_name="TextureCompressionSettings")
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("TC_DEFAULT", r["values"])
|
||||
|
||||
def test_list_enum_values_unknown(self):
|
||||
r = self.call("util_actions", "ue_list_enum_values", enum_name="NopeEnumXYZ")
|
||||
self.assertFalse(r.get("success"))
|
||||
@@ -0,0 +1,64 @@
|
||||
import base64
|
||||
from UnrealMCPython.tests.base import MCPTestCase
|
||||
|
||||
|
||||
class TestVisionActions(MCPTestCase):
|
||||
|
||||
def test_capture_viewport(self):
|
||||
r = self.call("vision_actions", "ue_capture_viewport", width=320, height=180)
|
||||
self.assertSuccess(r)
|
||||
self.assertIn("image_data", r)
|
||||
png = base64.b64decode(r["image_data"])
|
||||
self.assertEqual(png[:4], b"\x89PNG", "image_data is not a PNG")
|
||||
self.assertEqual(r["width"], 320)
|
||||
self.assertEqual(len(r["camera_location"]), 3)
|
||||
|
||||
def test_capture_viewport_default_size(self):
|
||||
r = self.call("vision_actions", "ue_capture_viewport")
|
||||
self.assertSuccess(r)
|
||||
self.assertGreater(len(r["image_data"]), 0)
|
||||
|
||||
def test_capture_from(self):
|
||||
r = self.call("vision_actions", "ue_capture_from",
|
||||
location=[600, 600, 400], rotation=[-20, -135, 0],
|
||||
width=320, height=180)
|
||||
self.assertSuccess(r)
|
||||
import base64
|
||||
self.assertEqual(base64.b64decode(r["image_data"])[:4], b"\x89PNG")
|
||||
|
||||
def test_capture_from_missing(self):
|
||||
r = self.call("vision_actions", "ue_capture_from")
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_capture_actors(self):
|
||||
spawn = self.call("actor_actions", "ue_spawn_from_object",
|
||||
asset_path="/Engine/BasicShapes/Cube", location=[0, 0, 100])
|
||||
self.assertSuccess(spawn)
|
||||
label = spawn["actor_label"]
|
||||
try:
|
||||
r = self.call("vision_actions", "ue_capture_actors",
|
||||
actor_labels=[label], width=320, height=180)
|
||||
self.assertSuccess(r)
|
||||
self.assertIn(label, r["framed_actors"])
|
||||
import base64
|
||||
self.assertEqual(base64.b64decode(r["image_data"])[:4], b"\x89PNG")
|
||||
finally:
|
||||
self.delete_actor_by_label(label)
|
||||
|
||||
def test_capture_actors_unknown(self):
|
||||
r = self.call("vision_actions", "ue_capture_actors", actor_labels=["NoSuchActor_XYZ"])
|
||||
self.assertFalse(r.get("success"))
|
||||
|
||||
def test_capture_actors_no_annotate(self):
|
||||
spawn = self.call("actor_actions", "ue_spawn_from_object",
|
||||
asset_path="/Engine/BasicShapes/Cube", location=[0, 0, 100])
|
||||
self.assertSuccess(spawn)
|
||||
label = spawn["actor_label"]
|
||||
try:
|
||||
r = self.call("vision_actions", "ue_capture_actors",
|
||||
actor_labels=[label], width=320, height=180, annotate=False)
|
||||
self.assertSuccess(r)
|
||||
import base64
|
||||
self.assertEqual(base64.b64decode(r["image_data"])[:4], b"\x89PNG")
|
||||
finally:
|
||||
self.delete_actor_by_label(label)
|
||||
@@ -0,0 +1,75 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
"""Python action functions for Texture assets (info + import settings)."""
|
||||
import unreal
|
||||
import json
|
||||
import traceback
|
||||
|
||||
|
||||
def _load_texture(asset_path: str):
|
||||
if not asset_path:
|
||||
raise ValueError("Texture path cannot be empty.")
|
||||
tex = unreal.EditorAssetLibrary.load_asset(asset_path)
|
||||
if not tex:
|
||||
raise FileNotFoundError(f"Texture not found at path: {asset_path}")
|
||||
if not isinstance(tex, unreal.Texture2D):
|
||||
raise TypeError(f"Asset at {asset_path} is not a Texture2D, but {type(tex).__name__}")
|
||||
return tex
|
||||
|
||||
|
||||
def _save(tex):
|
||||
if hasattr(tex, "update_resource"):
|
||||
tex.update_resource()
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(tex)
|
||||
|
||||
|
||||
def ue_get_texture_info(asset_path: str = None) -> str:
|
||||
"""Returns size, memory, sRGB, and compression settings of a Texture2D."""
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
tex = _load_texture(asset_path)
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"asset_path": asset_path,
|
||||
"width": tex.blueprint_get_size_x(),
|
||||
"height": tex.blueprint_get_size_y(),
|
||||
"memory_size": tex.blueprint_get_memory_size(),
|
||||
"srgb": bool(tex.get_editor_property("srgb")),
|
||||
"compression_settings": str(tex.get_editor_property("compression_settings")).split(".")[-1].split(":")[0].rstrip(">").strip(),
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_set_texture_srgb(asset_path: str = None, srgb: bool = None) -> str:
|
||||
"""Sets the sRGB flag on a Texture2D."""
|
||||
if asset_path is None or srgb is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, srgb."})
|
||||
try:
|
||||
tex = _load_texture(asset_path)
|
||||
tex.set_editor_property("srgb", bool(srgb))
|
||||
_save(tex)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "srgb": bool(srgb)})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_set_texture_compression(asset_path: str = None, compression: str = None) -> str:
|
||||
"""Sets the compression settings of a Texture2D (e.g. 'TC_DEFAULT', 'TC_NORMALMAP', 'TC_MASKS', 'TC_GRAYSCALE')."""
|
||||
if asset_path is None or compression is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, compression."})
|
||||
key = compression.upper()
|
||||
if not key.startswith("TC_"):
|
||||
key = "TC_" + key
|
||||
enum_val = getattr(unreal.TextureCompressionSettings, key, None)
|
||||
if enum_val is None:
|
||||
valid = [v for v in dir(unreal.TextureCompressionSettings) if v.startswith("TC_")]
|
||||
return json.dumps({"success": False, "message": f"Unknown compression '{compression}'.", "valid": valid})
|
||||
try:
|
||||
tex = _load_texture(asset_path)
|
||||
tex.set_editor_property("compression_settings", enum_val)
|
||||
_save(tex)
|
||||
return json.dumps({"success": True, "asset_path": asset_path, "compression": key})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
@@ -0,0 +1,407 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
import unreal
|
||||
import json
|
||||
import traceback
|
||||
|
||||
VISIBILITY_MAP = {
|
||||
"visible": unreal.SlateVisibility.VISIBLE,
|
||||
"collapsed": unreal.SlateVisibility.COLLAPSED,
|
||||
"hidden": unreal.SlateVisibility.HIDDEN,
|
||||
"hit_test_invisible": unreal.SlateVisibility.HIT_TEST_INVISIBLE,
|
||||
"self_hit_test_invisible": unreal.SlateVisibility.SELF_HIT_TEST_INVISIBLE,
|
||||
}
|
||||
|
||||
SUPPORTED_WIDGET_TYPES = [
|
||||
"CanvasPanel", "TextBlock", "Button", "Image",
|
||||
"HorizontalBox", "VerticalBox", "Border", "Overlay",
|
||||
"ScrollBox", "SizeBox", "CheckBox", "EditableText",
|
||||
"EditableTextBox", "ProgressBar", "Slider",
|
||||
]
|
||||
|
||||
|
||||
def _load_widget_blueprint(asset_path):
|
||||
asset = unreal.EditorAssetLibrary.load_asset(asset_path)
|
||||
if asset is None:
|
||||
return None, json.dumps({"success": False, "message": f"Asset not found: {asset_path}"})
|
||||
if not isinstance(asset, unreal.WidgetBlueprint):
|
||||
return None, json.dumps({
|
||||
"success": False,
|
||||
"message": f"Asset at '{asset_path}' is {type(asset).__name__}, expected WidgetBlueprint."
|
||||
})
|
||||
return asset, None
|
||||
|
||||
|
||||
# ─── Actions ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def ue_create_widget_blueprint(name: str = None, path: str = None, parent_class: str = "UserWidget") -> str:
|
||||
if name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'name' is missing."})
|
||||
if path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'path' is missing."})
|
||||
try:
|
||||
parent_cls = unreal.load_class(None, f"/Script/UMG.{parent_class}")
|
||||
if parent_cls is None:
|
||||
parent_cls = unreal.UserWidget
|
||||
|
||||
factory = unreal.WidgetBlueprintFactory()
|
||||
factory.set_editor_property("parent_class", parent_cls)
|
||||
|
||||
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
|
||||
widget_bp = asset_tools.create_asset(name, path, unreal.WidgetBlueprint, factory)
|
||||
|
||||
if widget_bp is None:
|
||||
return json.dumps({"success": False, "message": f"Failed to create Widget Blueprint '{name}' at '{path}'."})
|
||||
|
||||
unreal.EditorAssetLibrary.save_asset(widget_bp.get_path_name())
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"asset_path": widget_bp.get_path_name(),
|
||||
"message": f"Widget Blueprint '{name}' created successfully."
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_get_widget_blueprint_info(asset_path: str = None) -> str:
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
widget_bp, err = _load_widget_blueprint(asset_path)
|
||||
if err:
|
||||
return err
|
||||
|
||||
result_json = unreal.MCPythonHelper.umg_get_widget_info(widget_bp)
|
||||
return result_json
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_add_widget(asset_path: str = None, widget_type: str = None,
|
||||
widget_name: str = None, parent_name: str = None) -> str:
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if widget_type is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'widget_type' is missing."})
|
||||
if widget_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'widget_name' is missing."})
|
||||
|
||||
if widget_type not in SUPPORTED_WIDGET_TYPES:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"message": f"Unknown widget type '{widget_type}'. Supported: {SUPPORTED_WIDGET_TYPES}"
|
||||
})
|
||||
|
||||
try:
|
||||
widget_bp, err = _load_widget_blueprint(asset_path)
|
||||
if err:
|
||||
return err
|
||||
|
||||
result_json = unreal.MCPythonHelper.umg_add_widget(
|
||||
widget_bp, widget_type, widget_name, parent_name or ""
|
||||
)
|
||||
|
||||
# Save after successful add
|
||||
parsed = json.loads(result_json)
|
||||
if parsed.get("success"):
|
||||
unreal.EditorAssetLibrary.save_asset(widget_bp.get_path_name())
|
||||
|
||||
return result_json
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_set_widget_properties(asset_path: str = None, widget_name: str = None, properties: dict = None) -> str:
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if widget_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'widget_name' is missing."})
|
||||
if properties is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'properties' is missing."})
|
||||
|
||||
try:
|
||||
widget_bp, err = _load_widget_blueprint(asset_path)
|
||||
if err:
|
||||
return err
|
||||
|
||||
widget = unreal.MCPythonHelper.umg_find_widget(widget_bp, widget_name)
|
||||
if widget is None:
|
||||
return json.dumps({"success": False, "message": f"Widget '{widget_name}' not found in blueprint."})
|
||||
|
||||
set_ok = {}
|
||||
errors = {}
|
||||
|
||||
for key, value in properties.items():
|
||||
if key.startswith("slot_"):
|
||||
continue
|
||||
try:
|
||||
if key == "text":
|
||||
widget.set_editor_property("text", unreal.Text.cast(str(value)))
|
||||
elif key == "hint_text":
|
||||
widget.set_editor_property("hint_text", unreal.Text.cast(str(value)))
|
||||
elif key == "tool_tip_text":
|
||||
widget.set_editor_property("tool_tip_text", unreal.Text.cast(str(value)))
|
||||
elif key == "visibility":
|
||||
vis = VISIBILITY_MAP.get(str(value).lower())
|
||||
if vis is None:
|
||||
errors[key] = f"Unknown visibility '{value}'. Valid: {list(VISIBILITY_MAP.keys())}"
|
||||
continue
|
||||
widget.set_editor_property("visibility", vis)
|
||||
elif key in ("color_and_opacity", "background_color") and isinstance(value, list):
|
||||
r, g, b = float(value[0]), float(value[1]), float(value[2])
|
||||
a = float(value[3]) if len(value) > 3 else 1.0
|
||||
slate_color = unreal.SlateColor()
|
||||
slate_color.set_editor_property("specified_color", unreal.LinearColor(r=r, g=g, b=b, a=a))
|
||||
widget.set_editor_property(key, slate_color)
|
||||
elif key == "font_size" and isinstance(widget, unreal.TextBlock):
|
||||
font_info = widget.get_editor_property("font")
|
||||
font_info.set_editor_property("size", int(value))
|
||||
widget.set_editor_property("font", font_info)
|
||||
elif key == "percent":
|
||||
widget.set_editor_property("percent", float(value))
|
||||
elif key == "value":
|
||||
widget.set_editor_property("value", float(value))
|
||||
else:
|
||||
widget.set_editor_property(key, value)
|
||||
set_ok[key] = "ok"
|
||||
except Exception as prop_err:
|
||||
errors[key] = str(prop_err)
|
||||
|
||||
# Slot properties
|
||||
slot_props = {k[5:]: v for k, v in properties.items() if k.startswith("slot_")}
|
||||
if slot_props:
|
||||
parent = widget.get_parent()
|
||||
slot = getattr(widget, "slot", None)
|
||||
if slot is None or parent is None:
|
||||
for k in slot_props:
|
||||
errors[f"slot_{k}"] = "Widget has no parent slot."
|
||||
else:
|
||||
is_canvas_slot = isinstance(slot, unreal.CanvasPanelSlot)
|
||||
for slot_key, slot_val in slot_props.items():
|
||||
try:
|
||||
if slot_key in ("position", "size", "alignment", "z_order") and not is_canvas_slot:
|
||||
errors[f"slot_{slot_key}"] = (
|
||||
f"'{slot.get_class().get_name()}' does not support slot_{slot_key}. "
|
||||
"These properties only apply to CanvasPanel children."
|
||||
)
|
||||
continue
|
||||
if slot_key == "position" and isinstance(slot_val, list):
|
||||
slot.set_position(unreal.Vector2D(float(slot_val[0]), float(slot_val[1])))
|
||||
elif slot_key == "size" and isinstance(slot_val, list):
|
||||
slot.set_size(unreal.Vector2D(float(slot_val[0]), float(slot_val[1])))
|
||||
elif slot_key == "alignment" and isinstance(slot_val, list):
|
||||
slot.set_alignment(unreal.Vector2D(float(slot_val[0]), float(slot_val[1])))
|
||||
elif slot_key == "z_order":
|
||||
slot.set_z_order(int(slot_val))
|
||||
else:
|
||||
slot.set_editor_property(slot_key, slot_val)
|
||||
set_ok[f"slot_{slot_key}"] = "ok"
|
||||
except Exception as slot_err:
|
||||
errors[f"slot_{slot_key}"] = str(slot_err)
|
||||
|
||||
if len(errors) == 0:
|
||||
unreal.EditorAssetLibrary.save_asset(widget_bp.get_path_name())
|
||||
|
||||
return json.dumps({
|
||||
"success": len(errors) == 0,
|
||||
"set": set_ok,
|
||||
"errors": errors
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_remove_widget(asset_path: str = None, widget_name: str = None) -> str:
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if widget_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'widget_name' is missing."})
|
||||
try:
|
||||
widget_bp, err = _load_widget_blueprint(asset_path)
|
||||
if err:
|
||||
return err
|
||||
|
||||
result_json = unreal.MCPythonHelper.umg_remove_widget(widget_bp, widget_name)
|
||||
|
||||
parsed = json.loads(result_json)
|
||||
if parsed.get("success"):
|
||||
unreal.EditorAssetLibrary.save_asset(widget_bp.get_path_name())
|
||||
|
||||
return result_json
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_compile_widget_blueprint(asset_path: str = None) -> str:
|
||||
if asset_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
try:
|
||||
widget_bp, err = _load_widget_blueprint(asset_path)
|
||||
if err:
|
||||
return err
|
||||
result_json = unreal.MCPythonHelper.compile_blueprint(widget_bp)
|
||||
return result_json
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def _umg_call_and_save(asset_path, fn):
|
||||
widget_bp, err = _load_widget_blueprint(asset_path)
|
||||
if err:
|
||||
return err
|
||||
result_json = fn(widget_bp)
|
||||
if json.loads(result_json).get("success"):
|
||||
unreal.EditorAssetLibrary.save_asset(widget_bp.get_path_name())
|
||||
return result_json
|
||||
|
||||
|
||||
def ue_reparent_widget(asset_path: str = None, widget_name: str = None, new_parent_name: str = None) -> str:
|
||||
"""Moves a widget under a different panel parent (cycle-guarded)."""
|
||||
if asset_path is None or widget_name is None or new_parent_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, widget_name, new_parent_name."})
|
||||
try:
|
||||
return _umg_call_and_save(asset_path,
|
||||
lambda bp: unreal.MCPythonHelper.umg_reparent_widget(bp, widget_name, new_parent_name))
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_wrap_widget(asset_path: str = None, widget_name: str = None,
|
||||
wrapper_type: str = None, wrapper_name: str = None) -> str:
|
||||
"""Wraps a widget in a new panel (wrapper_type, e.g. 'VerticalBox') that takes its place."""
|
||||
if asset_path is None or widget_name is None or wrapper_type is None or wrapper_name is None:
|
||||
return json.dumps({"success": False,
|
||||
"message": "Required parameters: asset_path, widget_name, wrapper_type, wrapper_name."})
|
||||
try:
|
||||
return _umg_call_and_save(asset_path,
|
||||
lambda bp: unreal.MCPythonHelper.umg_wrap_widget(bp, widget_name, wrapper_type, wrapper_name))
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_replace_widget(asset_path: str = None, widget_name: str = None,
|
||||
new_type: str = None, new_name: str = None) -> str:
|
||||
"""Replaces a widget with a new widget of new_type at the same slot (old subtree discarded)."""
|
||||
if asset_path is None or widget_name is None or new_type is None or new_name is None:
|
||||
return json.dumps({"success": False,
|
||||
"message": "Required parameters: asset_path, widget_name, new_type, new_name."})
|
||||
try:
|
||||
return _umg_call_and_save(asset_path,
|
||||
lambda bp: unreal.MCPythonHelper.umg_replace_widget(bp, widget_name, new_type, new_name))
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_list_widget_events(asset_path: str = None, widget_name: str = None) -> str:
|
||||
"""Lists the bindable multicast-delegate events on a widget (e.g. OnClicked, OnHovered)."""
|
||||
if asset_path is None or widget_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, widget_name."})
|
||||
try:
|
||||
widget_bp, err = _load_widget_blueprint(asset_path)
|
||||
if err:
|
||||
return err
|
||||
return unreal.MCPythonHelper.umg_list_widget_events(widget_bp, widget_name)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_bind_widget_event(asset_path: str = None, widget_name: str = None, event_name: str = None) -> str:
|
||||
"""Creates a bound event node in the widget BP's event graph for a widget delegate (e.g. Button OnClicked)."""
|
||||
if asset_path is None or widget_name is None or event_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: asset_path, widget_name, event_name."})
|
||||
try:
|
||||
return _umg_call_and_save(asset_path,
|
||||
lambda bp: unreal.MCPythonHelper.umg_bind_widget_event(bp, widget_name, event_name))
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_set_slot_layout(asset_path: str = None, widget_name: str = None,
|
||||
anchor_min_x: float = 0.5, anchor_min_y: float = 0.5,
|
||||
anchor_max_x: float = 0.5, anchor_max_y: float = 0.5,
|
||||
offset_x: float = 0.0, offset_y: float = 0.0,
|
||||
size_x: float = 100.0, size_y: float = 40.0) -> str:
|
||||
"""Sets CanvasPanelSlot layout (anchors + offset + size) on a widget."""
|
||||
if not asset_path:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if not widget_name:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'widget_name' is missing."})
|
||||
try:
|
||||
widget_bp, err = _load_widget_blueprint(asset_path)
|
||||
if err:
|
||||
return err
|
||||
result = unreal.MCPythonHelper.umg_set_slot_layout(
|
||||
widget_bp, widget_name,
|
||||
anchor_min_x, anchor_min_y, anchor_max_x, anchor_max_y,
|
||||
offset_x, offset_y, size_x, size_y
|
||||
)
|
||||
unreal.EditorAssetLibrary.save_asset(widget_bp.get_path_name(), only_if_is_dirty=False)
|
||||
return result
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_set_text_style(asset_path: str = None, widget_name: str = None,
|
||||
font_size: int = 24,
|
||||
color_r: float = 1.0, color_g: float = 1.0,
|
||||
color_b: float = 1.0, color_a: float = 1.0,
|
||||
outline_size: int = 0) -> str:
|
||||
"""Sets font size, text color, and outline size on a TextBlock widget."""
|
||||
if not asset_path:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if not widget_name:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'widget_name' is missing."})
|
||||
try:
|
||||
widget_bp, err = _load_widget_blueprint(asset_path)
|
||||
if err:
|
||||
return err
|
||||
result = unreal.MCPythonHelper.umg_set_text_style(
|
||||
widget_bp, widget_name, font_size,
|
||||
color_r, color_g, color_b, color_a,
|
||||
outline_size
|
||||
)
|
||||
unreal.EditorAssetLibrary.save_asset(widget_bp.get_path_name(), only_if_is_dirty=False)
|
||||
return result
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_get_widget_property(asset_path: str = None, widget_name: str = None,
|
||||
property_name: str = None) -> str:
|
||||
"""Gets the value of a C++ UPROPERTY on a named widget."""
|
||||
if not asset_path:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if not widget_name:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'widget_name' is missing."})
|
||||
if not property_name:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'property_name' is missing."})
|
||||
try:
|
||||
widget_bp, err = _load_widget_blueprint(asset_path)
|
||||
if err:
|
||||
return err
|
||||
return unreal.MCPythonHelper.umg_get_widget_property(widget_bp, widget_name, property_name)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_set_widget_property(asset_path: str = None, widget_name: str = None,
|
||||
property_name: str = None, value: str = None) -> str:
|
||||
"""Sets a C++ UPROPERTY on a named widget from a string value."""
|
||||
if not asset_path:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'asset_path' is missing."})
|
||||
if not widget_name:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'widget_name' is missing."})
|
||||
if not property_name:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'property_name' is missing."})
|
||||
if value is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'value' is missing."})
|
||||
try:
|
||||
widget_bp, err = _load_widget_blueprint(asset_path)
|
||||
if err:
|
||||
return err
|
||||
result = unreal.MCPythonHelper.umg_set_widget_property(widget_bp, widget_name, property_name, value)
|
||||
unreal.EditorAssetLibrary.save_asset(widget_bp.get_path_name(), only_if_is_dirty=False)
|
||||
return result
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
@@ -0,0 +1,312 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
import unreal
|
||||
import json
|
||||
import os
|
||||
import glob
|
||||
import traceback
|
||||
from collections import deque
|
||||
|
||||
def ue_print_message(message: str = None) -> str:
|
||||
"""
|
||||
Logs a message to the Unreal log and returns a JSON success response.
|
||||
"""
|
||||
if message is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'message' is missing."})
|
||||
|
||||
unreal.log(f"MCP Message: {message}")
|
||||
return json.dumps({
|
||||
"received_message": message,
|
||||
"success": True,
|
||||
"source": "ue_print_message"
|
||||
})
|
||||
|
||||
def _tail_log(path: str, line_count: int):
|
||||
"""Last line_count lines via backward block reads (never loads the whole file)."""
|
||||
with open(path, 'rb') as f:
|
||||
f.seek(0, os.SEEK_END)
|
||||
file_size = f.tell()
|
||||
pos = file_size
|
||||
data = b""
|
||||
while pos > 0 and data.count(b"\n") <= line_count:
|
||||
step = min(65536, pos)
|
||||
pos -= step
|
||||
f.seek(pos)
|
||||
data = f.read(step) + data
|
||||
# total_lines: cheap forward newline count (no decode, no line list)
|
||||
f.seek(0)
|
||||
total = 0
|
||||
last_byte = b"\n"
|
||||
while True:
|
||||
chunk = f.read(1 << 20)
|
||||
if not chunk:
|
||||
break
|
||||
total += chunk.count(b"\n")
|
||||
last_byte = chunk[-1:]
|
||||
if file_size and last_byte != b"\n":
|
||||
total += 1
|
||||
lines = data.decode('utf-8', errors='replace').splitlines(keepends=True)
|
||||
if pos > 0:
|
||||
lines = lines[1:] # the first decoded line may start mid-line — drop it
|
||||
lines = lines[-line_count:]
|
||||
return "".join(lines), total, len(lines)
|
||||
|
||||
|
||||
def _grep_log(path: str, keyword: str, line_count: int, context_lines: int):
|
||||
"""Stream-scan for keyword; keep the last line_count matched blocks with context."""
|
||||
kw = keyword.lower()
|
||||
prev = deque(maxlen=context_lines) if context_lines else None
|
||||
blocks = deque(maxlen=line_count) # each block: [(line_no, line), ...]
|
||||
pending_after = 0
|
||||
total = 0
|
||||
with open(path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
for i, line in enumerate(f):
|
||||
total = i + 1
|
||||
if kw in line.lower():
|
||||
if pending_after > 0 and blocks:
|
||||
blocks[-1].append((i, line)) # within a previous match's context: merge
|
||||
else:
|
||||
block = list(prev) if prev else []
|
||||
block.append((i, line))
|
||||
blocks.append(block)
|
||||
pending_after = context_lines
|
||||
if prev is not None:
|
||||
prev.clear()
|
||||
elif pending_after > 0:
|
||||
blocks[-1].append((i, line))
|
||||
pending_after -= 1
|
||||
elif prev is not None:
|
||||
prev.append((i, line))
|
||||
|
||||
parts = []
|
||||
returned = 0
|
||||
last_no = None
|
||||
for block in blocks:
|
||||
if context_lines and last_no is not None and block[0][0] > last_no + 1:
|
||||
parts.append("--\n")
|
||||
parts.extend(l for _, l in block)
|
||||
returned += len(block)
|
||||
last_no = block[-1][0]
|
||||
return "".join(parts), total, returned
|
||||
|
||||
|
||||
def ue_get_output_log(line_count: int = 50, keyword: str = None, context_lines: int = 0) -> str:
|
||||
"""Returns recent lines from the UE output log file; optional keyword filter with context_lines around each match."""
|
||||
try:
|
||||
log_dir = unreal.Paths.project_log_dir()
|
||||
log_files = glob.glob(os.path.join(log_dir, "*.log"))
|
||||
if not log_files:
|
||||
return json.dumps({"success": False, "message": "No log files found"})
|
||||
|
||||
latest_log = max(log_files, key=os.path.getmtime)
|
||||
line_count = max(1, int(line_count))
|
||||
context_lines = max(0, int(context_lines))
|
||||
|
||||
if keyword:
|
||||
log_text, total, returned = _grep_log(latest_log, keyword, line_count, context_lines)
|
||||
else:
|
||||
log_text, total, returned = _tail_log(latest_log, line_count)
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"log_file": os.path.basename(latest_log),
|
||||
"total_lines": total,
|
||||
"returned_lines": returned,
|
||||
"log": log_text
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
# --- Editor control -----------------------------------------------------------
|
||||
|
||||
def ue_execute_console_command(command: str = None) -> str:
|
||||
"""Executes an editor console command (e.g. 'stat fps', 'r.ScreenPercentage 50')."""
|
||||
if command is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'command' is missing."})
|
||||
try:
|
||||
world = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_editor_world()
|
||||
unreal.SystemLibrary.execute_console_command(world, command)
|
||||
return json.dumps({"success": True, "command": command})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_save_all_dirty() -> str:
|
||||
"""Saves all dirty packages (modified maps and content)."""
|
||||
try:
|
||||
ok = unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
|
||||
return json.dumps({"success": bool(ok), "message": "Saved dirty packages." if ok else "save_dirty_packages returned False."})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_get_viewport_camera() -> str:
|
||||
"""Returns the level viewport camera location and rotation."""
|
||||
try:
|
||||
info = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_level_viewport_camera_info()
|
||||
if not info:
|
||||
return json.dumps({"success": False, "message": "No active level viewport."})
|
||||
loc, rot = info
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"location": [round(loc.x, 3), round(loc.y, 3), round(loc.z, 3)],
|
||||
"rotation": [round(rot.pitch, 3), round(rot.yaw, 3), round(rot.roll, 3)],
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_set_viewport_camera(location: list = None, rotation: list = None) -> str:
|
||||
"""Sets the level viewport camera. location=[x,y,z], rotation=[pitch,yaw,roll]."""
|
||||
if location is None or rotation is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: location, rotation."})
|
||||
if len(location) != 3 or len(rotation) != 3:
|
||||
return json.dumps({"success": False, "message": "location and rotation must be lists of 3 floats."})
|
||||
try:
|
||||
ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
|
||||
ues.set_level_viewport_camera_info(
|
||||
unreal.Vector(float(location[0]), float(location[1]), float(location[2])),
|
||||
unreal.Rotator(float(rotation[0]), float(rotation[1]), float(rotation[2])))
|
||||
return json.dumps({"success": True, "location": location, "rotation": rotation})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_world_to_screen(location: list = None) -> str:
|
||||
"""Projects a world location to active level-viewport pixel coords (editor viewport, no PIE needed)."""
|
||||
if location is None or len(location) != 3:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'location' must be a list of 3 floats."})
|
||||
try:
|
||||
loc = unreal.Vector(float(location[0]), float(location[1]), float(location[2]))
|
||||
return unreal.MCPythonHelper.world_to_screen(loc)
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_screen_to_world(x: float = None, y: float = None, distance: float = 1000.0) -> str:
|
||||
"""Deprojects a viewport pixel (x, y) to a world location at 'distance' along the view ray."""
|
||||
if x is None or y is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: x, y."})
|
||||
try:
|
||||
return unreal.MCPythonHelper.screen_to_world(float(x), float(y), float(distance))
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_is_in_pie() -> str:
|
||||
"""Returns whether Play-In-Editor is currently active."""
|
||||
try:
|
||||
active = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem).is_in_play_in_editor()
|
||||
return json.dumps({"success": True, "in_pie": bool(active)})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_start_pie() -> str:
|
||||
"""Starts Play-In-Editor (asynchronous; begins on the next frame)."""
|
||||
try:
|
||||
unreal.get_editor_subsystem(unreal.LevelEditorSubsystem).editor_request_begin_play()
|
||||
return json.dumps({"success": True, "message": "Requested PIE begin."})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_stop_pie() -> str:
|
||||
"""Stops Play-In-Editor."""
|
||||
try:
|
||||
unreal.get_editor_subsystem(unreal.LevelEditorSubsystem).editor_request_end_play()
|
||||
return json.dumps({"success": True, "message": "Requested PIE end."})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_list_class_properties(class_path: str = None) -> str:
|
||||
"""Lists the editor-settable property names of a UClass (for discovering what set_property accepts)."""
|
||||
if class_path is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'class_path' is missing."})
|
||||
try:
|
||||
cls = unreal.load_class(None, class_path)
|
||||
if not cls:
|
||||
return json.dumps({"success": False, "message": f"Class not found: {class_path}"})
|
||||
cdo = unreal.get_default_object(cls)
|
||||
props = sorted(p for p in dir(cdo)
|
||||
if not p.startswith("_") and not callable(getattr(type(cdo), p, None)))
|
||||
return json.dumps({"success": True, "class_path": class_path,
|
||||
"count": len(props), "properties": props})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_get_cvar(name: str = None) -> str:
|
||||
"""Reads the current value of a console variable (CVar) as a string, e.g. 'r.ScreenPercentage'."""
|
||||
if name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'name' is missing."})
|
||||
try:
|
||||
value = unreal.SystemLibrary.get_console_variable_string_value(name)
|
||||
return json.dumps({"success": True, "name": name, "value": value})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_set_cvar(name: str = None, value: str = None) -> str:
|
||||
"""Sets a console variable, e.g. name='r.ScreenPercentage', value='75'. Reads it back to confirm."""
|
||||
if name is None or value is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: name, value."})
|
||||
try:
|
||||
world = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_editor_world()
|
||||
unreal.SystemLibrary.execute_console_command(world, f"{name} {value}")
|
||||
new_value = unreal.SystemLibrary.get_console_variable_string_value(name)
|
||||
return json.dumps({"success": True, "name": name, "requested": str(value), "value": new_value})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
_LOG_VERBOSITIES = {"NoLogging", "Fatal", "Error", "Warning", "Display",
|
||||
"Log", "Verbose", "VeryVerbose", "All", "Default"}
|
||||
|
||||
|
||||
def ue_set_log_verbosity(category: str = None, verbosity: str = None) -> str:
|
||||
"""Sets a log category's verbosity via the 'Log' console command (e.g. 'LogBlueprint', 'Verbose')."""
|
||||
if category is None or verbosity is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: category, verbosity."})
|
||||
if verbosity not in _LOG_VERBOSITIES:
|
||||
return json.dumps({"success": False, "message": f"Invalid verbosity '{verbosity}'.",
|
||||
"valid": sorted(_LOG_VERBOSITIES)})
|
||||
try:
|
||||
world = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_editor_world()
|
||||
unreal.SystemLibrary.execute_console_command(world, f"Log {category} {verbosity}")
|
||||
return json.dumps({"success": True, "category": category, "verbosity": verbosity})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_get_project_info() -> str:
|
||||
"""Returns project name, directories, and engine version."""
|
||||
try:
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"project_name": unreal.SystemLibrary.get_game_name(),
|
||||
"project_dir": unreal.Paths.project_dir(),
|
||||
"content_dir": unreal.Paths.project_content_dir(),
|
||||
"engine_version": unreal.SystemLibrary.get_engine_version(),
|
||||
})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_list_enum_values(enum_name: str = None) -> str:
|
||||
"""Lists the values of an Unreal enum by name (e.g. 'TextureCompressionSettings', 'CollisionTraceFlag')."""
|
||||
if enum_name is None:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'enum_name' is missing."})
|
||||
try:
|
||||
short = enum_name.split(".")[-1]
|
||||
cls = getattr(unreal, short, None)
|
||||
if cls is None:
|
||||
return json.dumps({"success": False, "message": f"Enum '{enum_name}' not found in 'unreal' module."})
|
||||
values = [v for v in dir(cls) if not v.startswith("_") and isinstance(getattr(cls, v, None), cls)]
|
||||
if not values:
|
||||
return json.dumps({"success": False, "message": f"'{enum_name}' is not an enum or has no values."})
|
||||
return json.dumps({"success": True, "enum": short, "count": len(values), "values": values})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
@@ -0,0 +1,192 @@
|
||||
# Copyright (c) 2025 GenOrca. All Rights Reserved.
|
||||
|
||||
"""
|
||||
Vision: capture the level viewport / scene as a PNG.
|
||||
|
||||
Captures use a transient SceneCapture2D rendered to an RGBA8 render target and
|
||||
exported to PNG. This works regardless of editor focus (unlike
|
||||
take_high_res_screenshot, which only fires when the viewport renders a frame),
|
||||
and captures the 3D scene only — no editor UI. Because the capture uses its own
|
||||
camera, capture_from / capture_actors can look anywhere without disturbing the
|
||||
user's viewport.
|
||||
|
||||
Actions return the PNG base64-encoded in 'image_data'; the dispatcher's vision
|
||||
handler decodes it into an MCP Image.
|
||||
"""
|
||||
import unreal
|
||||
import json
|
||||
import os
|
||||
import math
|
||||
import base64
|
||||
import tempfile
|
||||
import traceback
|
||||
|
||||
|
||||
def _project(loc, rot, fov, width, height, world_pt):
|
||||
"""Project a world point to (x, y) pixel coords for the given camera, or None if behind."""
|
||||
fwd = unreal.MathLibrary.get_forward_vector(rot)
|
||||
right = unreal.MathLibrary.get_right_vector(rot)
|
||||
up = unreal.MathLibrary.get_up_vector(rot)
|
||||
rel = world_pt - loc
|
||||
cx = rel.x * fwd.x + rel.y * fwd.y + rel.z * fwd.z # depth along view
|
||||
if cx <= 1.0:
|
||||
return None
|
||||
rx = rel.x * right.x + rel.y * right.y + rel.z * right.z
|
||||
ry = rel.x * up.x + rel.y * up.y + rel.z * up.z
|
||||
aspect = float(width) / float(height)
|
||||
tan_h = math.tan(math.radians(fov) / 2.0) # fov_angle is horizontal
|
||||
tan_v = tan_h / aspect
|
||||
sx = ((rx / cx) / tan_h * 0.5 + 0.5) * width
|
||||
sy = (1.0 - ((ry / cx) / tan_v * 0.5 + 0.5)) * height
|
||||
return (sx, sy)
|
||||
|
||||
|
||||
def _capture_scene(world, loc, rot, width, height, fov, annotations=None):
|
||||
"""Render the scene from (loc, rot) to a PNG and return its base64 string.
|
||||
|
||||
annotations: optional list of {"label": str, "world": unreal.Vector} drawn on top.
|
||||
"""
|
||||
width = max(64, min(int(width), 4096))
|
||||
height = max(64, min(int(height), 4096))
|
||||
cap_actor = None
|
||||
out_path = None
|
||||
try:
|
||||
rt = unreal.RenderingLibrary.create_render_target2d(
|
||||
world, width, height, unreal.TextureRenderTargetFormat.RTF_RGBA8)
|
||||
eas = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
cap_actor = eas.spawn_actor_from_class(unreal.SceneCapture2D, loc, rot)
|
||||
comp = cap_actor.capture_component2d
|
||||
comp.set_editor_property("texture_target", rt)
|
||||
comp.set_editor_property("capture_source", unreal.SceneCaptureSource.SCS_FINAL_COLOR_LDR)
|
||||
comp.set_editor_property("fov_angle", float(fov))
|
||||
comp.capture_scene()
|
||||
|
||||
if annotations:
|
||||
font = unreal.EditorAssetLibrary.load_asset("/Engine/EngineFonts/Roboto")
|
||||
canvas, _size, ctx = unreal.RenderingLibrary.begin_draw_canvas_to_render_target(world, rt)
|
||||
yellow = unreal.LinearColor(1.0, 0.95, 0.1, 1.0)
|
||||
for ann in annotations:
|
||||
scr = _project(loc, rot, fov, width, height, ann["world"])
|
||||
if not scr:
|
||||
continue
|
||||
pos = unreal.Vector2D(scr[0], scr[1])
|
||||
canvas.draw_box(unreal.Vector2D(scr[0] - 5, scr[1] - 5), unreal.Vector2D(10, 10), 2.0, yellow)
|
||||
canvas.draw_text(font, ann["label"], unreal.Vector2D(scr[0], scr[1] + 10),
|
||||
scale=unreal.Vector2D(1.3, 1.3),
|
||||
render_color=yellow, centre_x=True, outlined=True)
|
||||
unreal.RenderingLibrary.end_draw_canvas_to_render_target(world, ctx)
|
||||
|
||||
out_dir = tempfile.gettempdir()
|
||||
out_name = f"mcp_viewport_{os.getpid()}.png"
|
||||
unreal.RenderingLibrary.export_render_target(world, rt, out_dir, out_name)
|
||||
out_path = os.path.join(out_dir, out_name)
|
||||
with open(out_path, "rb") as f:
|
||||
return base64.b64encode(f.read()).decode("utf-8"), width, height
|
||||
finally:
|
||||
try:
|
||||
if cap_actor:
|
||||
unreal.get_editor_subsystem(unreal.EditorActorSubsystem).destroy_actor(cap_actor)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if out_path and os.path.exists(out_path):
|
||||
os.remove(out_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _actor_by_label(label):
|
||||
sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||||
for a in sub.get_all_level_actors():
|
||||
if a.get_actor_label() == label:
|
||||
return a
|
||||
return None
|
||||
|
||||
|
||||
def ue_capture_viewport(width: int = 1280, height: int = 720, fov: float = 90.0) -> str:
|
||||
"""Captures the active level viewport (3D scene only) as a PNG, returned base64 in 'image_data'."""
|
||||
try:
|
||||
ues = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
|
||||
world = ues.get_editor_world()
|
||||
if not world:
|
||||
return json.dumps({"success": False, "message": "No editor world is open."})
|
||||
info = ues.get_level_viewport_camera_info()
|
||||
loc, rot = info if info else (unreal.Vector(0, 0, 300), unreal.Rotator(0, 0, 0))
|
||||
img, w, h = _capture_scene(world, loc, rot, width, height, fov)
|
||||
return json.dumps({"success": True, "image_data": img, "width": w, "height": h,
|
||||
"camera_location": [round(loc.x, 2), round(loc.y, 2), round(loc.z, 2)],
|
||||
"camera_rotation": [round(rot.pitch, 2), round(rot.yaw, 2), round(rot.roll, 2)]})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_capture_from(location: list = None, rotation: list = None,
|
||||
width: int = 1280, height: int = 720, fov: float = 90.0) -> str:
|
||||
"""Captures the scene from an explicit camera pose. location=[x,y,z], rotation=[pitch,yaw,roll]."""
|
||||
if location is None or rotation is None:
|
||||
return json.dumps({"success": False, "message": "Required parameters: location, rotation."})
|
||||
if len(location) != 3 or len(rotation) != 3:
|
||||
return json.dumps({"success": False, "message": "location and rotation must be lists of 3 floats."})
|
||||
try:
|
||||
world = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_editor_world()
|
||||
if not world:
|
||||
return json.dumps({"success": False, "message": "No editor world is open."})
|
||||
loc = unreal.Vector(float(location[0]), float(location[1]), float(location[2]))
|
||||
rot = unreal.Rotator(float(rotation[0]), float(rotation[1]), float(rotation[2]))
|
||||
img, w, h = _capture_scene(world, loc, rot, width, height, fov)
|
||||
return json.dumps({"success": True, "image_data": img, "width": w, "height": h,
|
||||
"camera_location": location, "camera_rotation": rotation})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
|
||||
|
||||
def ue_capture_actors(actor_labels: list = None, width: int = 1280, height: int = 720,
|
||||
fov: float = 60.0, padding: float = 1.6, annotate: bool = True) -> str:
|
||||
"""Frames the given actors (by label) from an elevated 3/4 view and captures them.
|
||||
|
||||
With annotate=True, each actor's label is drawn on the image so it can be identified.
|
||||
"""
|
||||
if not actor_labels:
|
||||
return json.dumps({"success": False, "message": "Required parameter 'actor_labels' is missing."})
|
||||
try:
|
||||
world = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_editor_world()
|
||||
if not world:
|
||||
return json.dumps({"success": False, "message": "No editor world is open."})
|
||||
|
||||
mins = [None, None, None]
|
||||
maxs = [None, None, None]
|
||||
found = []
|
||||
annotations = []
|
||||
for label in actor_labels:
|
||||
actor = _actor_by_label(label)
|
||||
if not actor:
|
||||
continue
|
||||
found.append(label)
|
||||
origin, extent = actor.get_actor_bounds(False)
|
||||
annotations.append({"label": label, "world": unreal.Vector(origin.x, origin.y, origin.z)})
|
||||
lo = [origin.x - extent.x, origin.y - extent.y, origin.z - extent.z]
|
||||
hi = [origin.x + extent.x, origin.y + extent.y, origin.z + extent.z]
|
||||
for i in range(3):
|
||||
mins[i] = lo[i] if mins[i] is None else min(mins[i], lo[i])
|
||||
maxs[i] = hi[i] if maxs[i] is None else max(maxs[i], hi[i])
|
||||
if not found:
|
||||
return json.dumps({"success": False, "message": f"No actors found: {actor_labels}"})
|
||||
|
||||
center = unreal.Vector((mins[0] + maxs[0]) / 2, (mins[1] + maxs[1]) / 2, (mins[2] + maxs[2]) / 2)
|
||||
radius = max(8.0, 0.5 * math.sqrt(
|
||||
(maxs[0] - mins[0]) ** 2 + (maxs[1] - mins[1]) ** 2 + (maxs[2] - mins[2]) ** 2))
|
||||
dist = (radius / math.tan(math.radians(fov) / 2.0)) * float(padding)
|
||||
|
||||
d = unreal.Vector(1.0, -1.0, 0.7)
|
||||
d = d.normal()
|
||||
cam = unreal.Vector(center.x + d.x * dist, center.y + d.y * dist, center.z + d.z * dist)
|
||||
rot = unreal.MathLibrary.find_look_at_rotation(cam, center)
|
||||
|
||||
img, w, h = _capture_scene(world, cam, rot, width, height, fov,
|
||||
annotations=annotations if annotate else None)
|
||||
return json.dumps({"success": True, "image_data": img, "width": w, "height": h,
|
||||
"framed_actors": found,
|
||||
"camera_location": [round(cam.x, 2), round(cam.y, 2), round(cam.z, 2)],
|
||||
"camera_rotation": [round(rot.pitch, 2), round(rot.yaw, 2), round(rot.roll, 2)]})
|
||||
except Exception as e:
|
||||
return json.dumps({"success": False, "message": str(e), "traceback": traceback.format_exc()})
|
||||
Reference in New Issue
Block a user