Merge of the two project copies into one self-contained plugin (the superset: variable_op + variables.py, full pcg_op runtime/declarative/preset ops, the CreateWidgetFloatAnimation widget tool, and full Voxel graph authoring). Made fully project- and machine-agnostic — no hardcoded paths: - New src/projectPaths.js auto-detects the host .uproject (walk-up), project name, Editor build target, log file, and engine install (EngineAssociation via launcher manifest/registry, else installed-engine scan). All overridable via UE_* env vars. - Rewired buildOrchestrator/insights/launcher/insightsExporter/server.js and the Python workers (cpp_scaffold, live_coding, apply_graph, console) off the old C:/Github/ihy, IHY*, E:/UE_Versions and UE_5.7 literals onto the resolver. - Voxel made optional: Build.cs auto-detects the Voxel plugin (env UE_MCP_WITH_VOXEL override) and the C++ compiles to stubs under WITH_MCP_VOXEL, so the module builds in projects without Voxel; .uplugin marks Voxel optional. - De-branded the agent-gateway and docs; scrubbed a leaked API key; excluded node_modules/Binaries/Intermediate/__pycache__/secrets from the repo. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
777 lines
29 KiB
Python
777 lines
29 KiB
Python
"""Material graph and material instance operations for UEBlueprintMCP.
|
|
|
|
Ops:
|
|
create_material{path}
|
|
set_material_settings{asset_path,properties}
|
|
inspect_material_settings{asset_path,properties:[...]}
|
|
create_material_instance{path,parent?}
|
|
read_graph{asset_path}
|
|
apply_graph{asset_path,clear_existing?,layout?,save?,nodes:[...],edges:[...],outputs:{...}}
|
|
add_node{asset_path,id?,class,position?,properties?}
|
|
delete_node{asset_path,guid|name}
|
|
move_nodes{asset_path,moves:[{guid|name,x,y}]}
|
|
set_node_property{asset_path,guid|name,property,value}
|
|
connect{asset_path,from:[guid|name,output],to:[guid|name,input]}
|
|
connect_output{asset_path,from:[guid|name,output],property}
|
|
layout{asset_path}
|
|
recompile_save{asset_path}
|
|
set_instance_parent{asset_path,parent}
|
|
set_instance_scalar{asset_path,name,value}
|
|
set_instance_vector{asset_path,name,value}
|
|
set_instance_texture{asset_path,name,value}
|
|
list_parameters{asset_path}
|
|
open_asset{asset_path}
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import traceback
|
|
|
|
import graph_layout
|
|
|
|
try:
|
|
import unreal # type: ignore
|
|
except ImportError:
|
|
unreal = None
|
|
|
|
|
|
MATERIAL_PROPERTY_MAP = {
|
|
"basecolor": "MP_BASE_COLOR",
|
|
"base_color": "MP_BASE_COLOR",
|
|
"metallic": "MP_METALLIC",
|
|
"specular": "MP_SPECULAR",
|
|
"roughness": "MP_ROUGHNESS",
|
|
"emissive": "MP_EMISSIVE_COLOR",
|
|
"emissivecolor": "MP_EMISSIVE_COLOR",
|
|
"emissive_color": "MP_EMISSIVE_COLOR",
|
|
"opacity": "MP_OPACITY",
|
|
"opacitymask": "MP_OPACITY_MASK",
|
|
"opacity_mask": "MP_OPACITY_MASK",
|
|
"normal": "MP_NORMAL",
|
|
"worldpositionoffset": "MP_WORLD_POSITION_OFFSET",
|
|
"world_position_offset": "MP_WORLD_POSITION_OFFSET",
|
|
"ambientocclusion": "MP_AMBIENT_OCCLUSION",
|
|
"ambient_occlusion": "MP_AMBIENT_OCCLUSION",
|
|
"refraction": "MP_REFRACTION",
|
|
"tangent": "MP_TANGENT",
|
|
"displacement": "MP_DISPLACEMENT",
|
|
"subsurfacecolor": "MP_SUBSURFACE_COLOR",
|
|
"subsurface_color": "MP_SUBSURFACE_COLOR",
|
|
"customizeduvs0": "MP_CUSTOMIZED_UV_0",
|
|
"customizeduv0": "MP_CUSTOMIZED_UV_0",
|
|
"customizeduvs1": "MP_CUSTOMIZED_UV_1",
|
|
"customizeduv1": "MP_CUSTOMIZED_UV_1",
|
|
"customizeduvs2": "MP_CUSTOMIZED_UV_2",
|
|
"customizeduv2": "MP_CUSTOMIZED_UV_2",
|
|
"customizeduvs3": "MP_CUSTOMIZED_UV_3",
|
|
"customizeduv3": "MP_CUSTOMIZED_UV_3",
|
|
"customizeduvs4": "MP_CUSTOMIZED_UV_4",
|
|
"customizeduv4": "MP_CUSTOMIZED_UV_4",
|
|
"customizeduvs5": "MP_CUSTOMIZED_UV_5",
|
|
"customizeduv5": "MP_CUSTOMIZED_UV_5",
|
|
"customizeduvs6": "MP_CUSTOMIZED_UV_6",
|
|
"customizeduv6": "MP_CUSTOMIZED_UV_6",
|
|
"customizeduvs7": "MP_CUSTOMIZED_UV_7",
|
|
"customizeduv7": "MP_CUSTOMIZED_UV_7",
|
|
}
|
|
|
|
|
|
def _asset_tools():
|
|
return unreal.AssetToolsHelpers.get_asset_tools()
|
|
|
|
|
|
def _mel():
|
|
lib = getattr(unreal, "MaterialEditingLibrary", None)
|
|
if lib is None:
|
|
raise RuntimeError("unreal.MaterialEditingLibrary is unavailable. Enable the Material Editor scripting support in UE.")
|
|
return lib
|
|
|
|
|
|
def _split_pkg(path: str):
|
|
if "/" not in path:
|
|
raise RuntimeError(f"bad asset path {path!r}")
|
|
return path.rsplit("/", 1)
|
|
|
|
|
|
def _load_asset(path: str):
|
|
asset = unreal.EditorAssetLibrary.load_asset(path)
|
|
if asset is None:
|
|
try:
|
|
asset = unreal.load_asset(path)
|
|
except Exception:
|
|
asset = None
|
|
if asset is None and "." not in path.rsplit("/", 1)[-1]:
|
|
try:
|
|
object_path = path + "." + path.rsplit("/", 1)[-1]
|
|
asset = unreal.load_asset(object_path)
|
|
except Exception:
|
|
asset = None
|
|
if asset is None:
|
|
try:
|
|
for selected_asset in unreal.EditorUtilityLibrary.get_selected_assets():
|
|
if selected_asset.get_path_name().startswith(path):
|
|
asset = selected_asset
|
|
break
|
|
except Exception:
|
|
pass
|
|
if asset is None:
|
|
raise RuntimeError(f"asset not found: {path}")
|
|
return asset
|
|
|
|
|
|
def _load_material(path: str):
|
|
asset = _load_asset(path)
|
|
if not isinstance(asset, unreal.Material):
|
|
raise RuntimeError(f"{path} is not a Material ({type(asset).__name__})")
|
|
return asset
|
|
|
|
|
|
def _load_material_function(path: str):
|
|
asset = _load_asset(path)
|
|
if not isinstance(asset, unreal.MaterialFunction):
|
|
raise RuntimeError(f"{path} is not a MaterialFunction ({type(asset).__name__})")
|
|
return asset
|
|
|
|
|
|
def _load_material_interface(path: str):
|
|
asset = _load_asset(path)
|
|
if not isinstance(asset, unreal.MaterialInterface):
|
|
raise RuntimeError(f"{path} is not a MaterialInterface ({type(asset).__name__})")
|
|
return asset
|
|
|
|
|
|
def _load_expression_class(path_or_name: str):
|
|
if not path_or_name:
|
|
raise RuntimeError("expression class is required")
|
|
candidates = [path_or_name]
|
|
if "/" not in path_or_name:
|
|
name = path_or_name
|
|
if not name.startswith("MaterialExpression"):
|
|
candidates.append("MaterialExpression" + name)
|
|
candidates.extend([
|
|
"/Script/Engine." + name,
|
|
"/Script/Engine.MaterialExpression" + name,
|
|
])
|
|
for candidate in candidates:
|
|
cls = unreal.load_class(None, candidate) if "/" in candidate else None
|
|
if cls is not None:
|
|
return cls
|
|
raise RuntimeError(f"material expression class not found: {path_or_name!r}")
|
|
|
|
|
|
def _material_expressions(asset):
|
|
lib = getattr(unreal, "MCPMaterialLibrary", None)
|
|
if lib is not None:
|
|
try:
|
|
return list(lib.get_material_expressions(asset) or []), "MCPMaterialLibrary"
|
|
except Exception:
|
|
pass
|
|
for prop in ("expressions", "function_expressions"):
|
|
try:
|
|
expressions = asset.get_editor_property(prop)
|
|
if expressions is not None:
|
|
return list(expressions), prop
|
|
except Exception:
|
|
pass
|
|
return [], ""
|
|
|
|
|
|
def _find_expression(asset, ident: str):
|
|
if not ident:
|
|
raise RuntimeError("node guid/name is required")
|
|
lib = getattr(unreal, "MCPMaterialLibrary", None)
|
|
if lib is not None:
|
|
try:
|
|
expr = lib.find_material_expression(asset, ident)
|
|
if expr is not None:
|
|
return expr
|
|
except Exception:
|
|
pass
|
|
expressions, _ = _material_expressions(asset)
|
|
for expr in expressions:
|
|
if _expr_guid(expr) == ident or expr.get_name() == ident:
|
|
return expr
|
|
raise RuntimeError(f"material expression not found: {ident}")
|
|
|
|
|
|
def _connect_material_expressions(asset, from_expr, from_output: str, to_expr, to_input: str) -> dict:
|
|
try:
|
|
ok = _mel().connect_material_expressions(from_expr, str(from_output or ""), to_expr, str(to_input or ""))
|
|
if ok:
|
|
return {"ok": True, "method": "MaterialEditingLibrary"}
|
|
except Exception as e:
|
|
mel_error = str(e)
|
|
else:
|
|
mel_error = "MaterialEditingLibrary.connect_material_expressions returned false"
|
|
|
|
lib = getattr(unreal, "MCPMaterialLibrary", None)
|
|
if lib is None or not hasattr(lib, "connect_material_expressions_raw"):
|
|
return {"ok": False, "error": mel_error}
|
|
|
|
try:
|
|
raw = lib.connect_material_expressions_raw(asset, from_expr, str(from_output or ""), to_expr, str(to_input or ""))
|
|
result = json.loads(raw) if isinstance(raw, str) else dict(raw)
|
|
if result.get("ok"):
|
|
result.setdefault("method", "MCPMaterialLibrary.ConnectMaterialExpressionsRaw")
|
|
return result
|
|
result.setdefault("error", mel_error)
|
|
return result
|
|
except Exception as e:
|
|
return {"ok": False, "error": f"{mel_error}; raw fallback failed: {e}"}
|
|
|
|
|
|
def _expr_guid(expr) -> str:
|
|
try:
|
|
guid = expr.get_editor_property("material_expression_editor_x_guid")
|
|
if guid:
|
|
return str(guid)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
return str(expr.get_outer().get_path_name()) + ":" + expr.get_name()
|
|
except Exception:
|
|
return expr.get_name()
|
|
|
|
|
|
def _expr_pos(expr):
|
|
try:
|
|
x, y = 0, 0
|
|
_mel().get_material_expression_node_position(expr, x, y)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
return [int(expr.get_editor_property("material_expression_editor_x")),
|
|
int(expr.get_editor_property("material_expression_editor_y"))]
|
|
except Exception:
|
|
return [0, 0]
|
|
|
|
|
|
def _material_property(name: str):
|
|
enum_name = MATERIAL_PROPERTY_MAP.get(str(name).replace(" ", "").lower(), str(name))
|
|
try:
|
|
return getattr(unreal.MaterialProperty, enum_name)
|
|
except Exception:
|
|
pass
|
|
if not str(enum_name).startswith("MP_"):
|
|
try:
|
|
return getattr(unreal.MaterialProperty, "MP_" + str(enum_name).upper())
|
|
except Exception:
|
|
pass
|
|
raise RuntimeError(f"unknown material property {name!r}")
|
|
|
|
|
|
def _coerce_value(value, current=None):
|
|
# The loosely-typed "value" tool field arrives JSON-stringified; unwrap it.
|
|
if isinstance(value, str):
|
|
s = value.strip()
|
|
if current is not None and isinstance(current, bool):
|
|
if s.lower() in ("true", "1"):
|
|
return True
|
|
if s.lower() in ("false", "0"):
|
|
return False
|
|
if current is not None and isinstance(current, float):
|
|
try:
|
|
return float(s)
|
|
except Exception:
|
|
pass
|
|
if current is not None and isinstance(current, int) and not isinstance(current, bool):
|
|
try:
|
|
return int(float(s))
|
|
except Exception:
|
|
pass
|
|
if s[:1] in ("[", "{"):
|
|
try:
|
|
import json
|
|
return _coerce_value(json.loads(s), current)
|
|
except Exception:
|
|
pass
|
|
if s[:1] == "(" and "=" in s:
|
|
import re
|
|
low = {k.lower(): float(v) for k, v in re.findall(r"([A-Za-z]+)\s*=\s*(-?[0-9.eE+]+)", s)}
|
|
if {"r", "g", "b"}.issubset(low):
|
|
return unreal.LinearColor(low["r"], low["g"], low["b"], low.get("a", 1.0))
|
|
if {"x", "y", "z"}.issubset(low):
|
|
return unreal.Vector(low["x"], low["y"], low["z"])
|
|
if {"x", "y"}.issubset(low):
|
|
return unreal.Vector2D(low["x"], low["y"])
|
|
if current is not None:
|
|
enum_class = getattr(unreal, type(current).__name__, None) or getattr(current, "__class__", None)
|
|
if enum_class is not None and hasattr(enum_class, s):
|
|
return getattr(enum_class, s)
|
|
try:
|
|
return float(s)
|
|
except Exception:
|
|
return value
|
|
if isinstance(value, dict):
|
|
if "asset" in value:
|
|
return _load_asset(value["asset"])
|
|
low = {str(k).lower(): v for k, v in value.items()}
|
|
if {"r", "g", "b"}.issubset(low):
|
|
return unreal.LinearColor(float(low["r"]), float(low["g"]), float(low["b"]), float(low.get("a", 1.0)))
|
|
if {"x", "y", "z"}.issubset(low):
|
|
return unreal.Vector(float(low["x"]), float(low["y"]), float(low["z"]))
|
|
if {"x", "y"}.issubset(low):
|
|
return unreal.Vector2D(float(low["x"]), float(low["y"]))
|
|
if isinstance(value, list):
|
|
if len(value) in (3, 4):
|
|
if current is not None and type(current).__name__ in ("LinearColor", "Color"):
|
|
return unreal.LinearColor(float(value[0]), float(value[1]), float(value[2]), float(value[3]) if len(value) > 3 else 1.0)
|
|
if current is not None and type(current).__name__ in ("Vector", "Vector3f"):
|
|
return unreal.Vector(float(value[0]), float(value[1]), float(value[2]))
|
|
return unreal.LinearColor(float(value[0]), float(value[1]), float(value[2]), float(value[3]) if len(value) > 3 else 1.0)
|
|
if len(value) == 2:
|
|
return unreal.Vector2D(float(value[0]), float(value[1]))
|
|
if isinstance(value, str) and current is not None:
|
|
enum_class = getattr(unreal, type(current).__name__, None) or getattr(current, "__class__", None)
|
|
if enum_class is not None and hasattr(enum_class, value):
|
|
return getattr(enum_class, value)
|
|
if isinstance(value, int) and current is not None and hasattr(current, "__class__"):
|
|
try:
|
|
return current.__class__(value)
|
|
except Exception:
|
|
pass
|
|
return value
|
|
|
|
|
|
def _set_expr_properties(expr, properties: dict):
|
|
changed = []
|
|
for key, value in (properties or {}).items():
|
|
if key.lower() == "inputs" and isinstance(expr, unreal.MaterialExpressionCustom):
|
|
custom_inputs = []
|
|
for item in value:
|
|
input_name = item.get("name") if isinstance(item, dict) else str(item)
|
|
custom_input = unreal.CustomInput()
|
|
custom_input.set_editor_property("input_name", input_name)
|
|
custom_inputs.append(custom_input)
|
|
expr.set_editor_property("inputs", custom_inputs)
|
|
changed.append(key)
|
|
continue
|
|
current = None
|
|
try:
|
|
current = expr.get_editor_property(key)
|
|
except Exception:
|
|
pass
|
|
expr.set_editor_property(key, _coerce_value(value, current))
|
|
changed.append(key)
|
|
return changed
|
|
|
|
|
|
def _create_expression(asset, spec: dict):
|
|
cls = _load_expression_class(spec.get("class") or spec.get("target") or "")
|
|
pos = spec.get("position") or [0, 0]
|
|
x = int(pos[0]) if len(pos) > 0 else 0
|
|
y = int(pos[1]) if len(pos) > 1 else 0
|
|
if isinstance(asset, unreal.Material):
|
|
expr = _mel().create_material_expression(asset, cls, x, y)
|
|
elif isinstance(asset, unreal.MaterialFunction):
|
|
expr = _mel().create_material_expression_in_function(asset, cls, x, y)
|
|
else:
|
|
raise RuntimeError(f"unsupported graph asset type {type(asset).__name__}")
|
|
if expr is None:
|
|
raise RuntimeError("create_material_expression returned None")
|
|
if spec.get("name"):
|
|
try:
|
|
expr.rename(str(spec["name"]))
|
|
except Exception:
|
|
pass
|
|
_set_expr_properties(expr, spec.get("properties") or spec.get("params") or {})
|
|
return expr
|
|
|
|
|
|
def create_material(args: dict) -> dict:
|
|
folder, name = _split_pkg(args["path"])
|
|
asset = _asset_tools().create_asset(name, folder, unreal.Material, unreal.MaterialFactoryNew())
|
|
if asset is None:
|
|
return {"ok": False, "error": "create_asset returned None"}
|
|
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
|
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
|
|
|
|
|
def set_material_settings(args: dict) -> dict:
|
|
asset = _load_material(args["asset_path"])
|
|
changed = []
|
|
for key, value in (args.get("properties") or {}).items():
|
|
current = None
|
|
try:
|
|
current = asset.get_editor_property(key)
|
|
except Exception:
|
|
pass
|
|
asset.set_editor_property(key, _coerce_value(value, current))
|
|
changed.append(key)
|
|
_update_asset(asset)
|
|
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
|
return {"ok": True, "changed": changed}
|
|
|
|
|
|
def inspect_material_settings(args: dict) -> dict:
|
|
asset = _load_material(args["asset_path"])
|
|
result = {"ok": True, "properties": {}}
|
|
for key in args.get("properties") or []:
|
|
value = asset.get_editor_property(key)
|
|
result["properties"][key] = {
|
|
"type": type(value).__name__,
|
|
"repr": repr(value),
|
|
"str": str(value),
|
|
"members": list(getattr(value.__class__, "__members__", {}).keys()),
|
|
"unreal_class": repr(getattr(unreal, type(value).__name__, None)),
|
|
"class_dir": [n for n in dir(value.__class__) if n.startswith("BL_") or n.startswith("MD_")],
|
|
}
|
|
return result
|
|
|
|
|
|
def create_material_instance(args: dict) -> dict:
|
|
folder, name = _split_pkg(args["path"])
|
|
asset = _asset_tools().create_asset(name, folder, unreal.MaterialInstanceConstant, unreal.MaterialInstanceConstantFactoryNew())
|
|
if asset is None:
|
|
return {"ok": False, "error": "create_asset returned None"}
|
|
parent = args.get("parent")
|
|
if parent:
|
|
_mel().set_material_instance_parent(asset, _load_material_interface(parent))
|
|
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
|
return {"ok": True, "path": asset.get_path_name().split(".")[0], "parent": parent or None}
|
|
|
|
|
|
def read_graph(args: dict) -> dict:
|
|
asset_path = args["asset_path"]
|
|
asset = _load_asset(asset_path)
|
|
lib = getattr(unreal, "MCPMaterialLibrary", None)
|
|
if lib is not None:
|
|
try:
|
|
return json.loads(lib.dump_material_graph_to_json(asset))
|
|
except Exception:
|
|
pass
|
|
expressions, _ = _material_expressions(asset)
|
|
nodes = []
|
|
edges = []
|
|
for expr in expressions:
|
|
node = {
|
|
"guid": _expr_guid(expr),
|
|
"name": expr.get_name(),
|
|
"class": expr.get_class().get_path_name(),
|
|
"position": _expr_pos(expr),
|
|
"inputs": [],
|
|
}
|
|
try:
|
|
node["input_names"] = list(_mel().get_material_expression_input_names(expr))
|
|
except Exception:
|
|
node["input_names"] = []
|
|
nodes.append(node)
|
|
if isinstance(asset, unreal.Material):
|
|
try:
|
|
inputs = list(_mel().get_inputs_for_material_expression(asset, expr))
|
|
for input_expr in inputs:
|
|
out_name = ""
|
|
try:
|
|
out_name = str(_mel().get_input_node_output_name_for_material_expression(expr, input_expr))
|
|
except Exception:
|
|
pass
|
|
edges.append({
|
|
"from": [_expr_guid(input_expr), out_name],
|
|
"to": [_expr_guid(expr), ""],
|
|
})
|
|
except Exception:
|
|
pass
|
|
outputs = {}
|
|
if isinstance(asset, unreal.Material):
|
|
for pretty, enum_name in MATERIAL_PROPERTY_MAP.items():
|
|
if pretty != pretty.replace("_", ""):
|
|
continue
|
|
try:
|
|
prop = getattr(unreal.MaterialProperty, enum_name)
|
|
input_node = _mel().get_material_property_input_node(asset, prop)
|
|
if input_node is not None:
|
|
outputs[pretty] = {
|
|
"from": [_expr_guid(input_node), str(_mel().get_material_property_input_node_output_name(asset, prop))],
|
|
}
|
|
except Exception:
|
|
pass
|
|
return {"ok": True, "asset_path": asset_path, "nodes": nodes, "edges": edges, "outputs": outputs}
|
|
|
|
|
|
def apply_graph(args: dict) -> dict:
|
|
asset_path = args["asset_path"]
|
|
asset = _load_asset(asset_path)
|
|
if not isinstance(asset, (unreal.Material, unreal.MaterialFunction)):
|
|
return {"ok": False, "error": f"{asset_path} is not a Material or MaterialFunction"}
|
|
result = {"ok": False, "asset_path": asset_path, "spawned": {}, "errors": [], "warnings": []}
|
|
if args.get("clear_existing"):
|
|
if isinstance(asset, unreal.Material):
|
|
_mel().delete_all_material_expressions(asset)
|
|
else:
|
|
_mel().delete_all_material_expressions_in_function(asset)
|
|
local_map = {}
|
|
node_specs = graph_layout.normalize_specs(
|
|
args.get("nodes") or [],
|
|
args.get("edges") or [],
|
|
domain="material",
|
|
) if args.get("auto_layout", True) else list(args.get("nodes") or [])
|
|
|
|
for spec in node_specs:
|
|
try:
|
|
expr = _create_expression(asset, spec)
|
|
local_id = spec.get("id") or expr.get_name()
|
|
guid = _expr_guid(expr)
|
|
local_map[local_id] = expr
|
|
result["spawned"][local_id] = {"guid": guid, "name": expr.get_name(), "class": expr.get_class().get_path_name()}
|
|
except Exception as e:
|
|
result["errors"].append(f"node {spec.get('id') or spec.get('class')}: {e}")
|
|
|
|
def resolve_node(ref):
|
|
if ref in local_map:
|
|
return local_map[ref]
|
|
return _find_expression(asset, ref)
|
|
|
|
for edge in args.get("edges") or []:
|
|
try:
|
|
from_ref, from_output = edge["from"]
|
|
to_ref, to_input = edge["to"]
|
|
connect_result = _connect_material_expressions(asset, resolve_node(from_ref), str(from_output or ""), resolve_node(to_ref), str(to_input or ""))
|
|
if not connect_result.get("ok"):
|
|
result["warnings"].append(f"connect returned false: {edge}: {connect_result.get('error') or connect_result}")
|
|
except Exception as e:
|
|
result["errors"].append(f"edge {edge}: {e}")
|
|
if isinstance(asset, unreal.Material):
|
|
for prop_name, out_ref in (args.get("outputs") or {}).items():
|
|
try:
|
|
if isinstance(out_ref, dict):
|
|
out_ref = out_ref.get("from")
|
|
from_ref, from_output = out_ref
|
|
ok = _mel().connect_material_property(resolve_node(from_ref), str(from_output or ""), _material_property(prop_name))
|
|
if not ok:
|
|
result["warnings"].append(f"connect output returned false: {prop_name}")
|
|
except Exception as e:
|
|
result["errors"].append(f"output {prop_name}: {e}")
|
|
if args.get("layout", False):
|
|
_layout_asset(asset)
|
|
_update_asset(asset)
|
|
if args.get("save", True):
|
|
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
|
result["ok"] = not result["errors"]
|
|
return result
|
|
|
|
|
|
def add_node(args: dict) -> dict:
|
|
asset = _load_asset(args["asset_path"])
|
|
expr = _create_expression(asset, args)
|
|
_update_asset(asset)
|
|
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
|
return {"ok": True, "guid": _expr_guid(expr), "name": expr.get_name(), "class": expr.get_class().get_path_name()}
|
|
|
|
|
|
def delete_node(args: dict) -> dict:
|
|
asset = _load_asset(args["asset_path"])
|
|
expr = _find_expression(asset, args.get("guid") or args.get("name"))
|
|
if isinstance(asset, unreal.Material):
|
|
_mel().delete_material_expression(asset, expr)
|
|
elif isinstance(asset, unreal.MaterialFunction):
|
|
_mel().delete_material_expression_in_function(asset, expr)
|
|
else:
|
|
raise RuntimeError("asset is not Material or MaterialFunction")
|
|
_update_asset(asset)
|
|
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
|
return {"ok": True}
|
|
|
|
|
|
def move_nodes(args: dict) -> dict:
|
|
asset = _load_asset(args["asset_path"])
|
|
moved = []
|
|
for move in args.get("moves") or []:
|
|
expr = _find_expression(asset, move.get("guid") or move.get("name"))
|
|
expr.set_editor_property("material_expression_editor_x", int(move["x"]))
|
|
expr.set_editor_property("material_expression_editor_y", int(move["y"]))
|
|
moved.append(_expr_guid(expr))
|
|
_update_asset(asset)
|
|
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
|
return {"ok": True, "moved": moved}
|
|
|
|
|
|
def set_node_property(args: dict) -> dict:
|
|
asset = _load_asset(args["asset_path"])
|
|
expr = _find_expression(asset, args.get("guid") or args.get("name"))
|
|
changed = _set_expr_properties(expr, {args["property"]: args.get("value")})
|
|
_update_asset(asset)
|
|
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
|
return {"ok": True, "changed": changed}
|
|
|
|
|
|
def connect(args: dict) -> dict:
|
|
asset = _load_asset(args["asset_path"])
|
|
from_ref, from_output = args["from"]
|
|
to_ref, to_input = args["to"]
|
|
result = _connect_material_expressions(
|
|
asset,
|
|
_find_expression(asset, from_ref),
|
|
str(from_output or ""),
|
|
_find_expression(asset, to_ref),
|
|
str(to_input or ""),
|
|
)
|
|
_update_asset(asset)
|
|
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
|
return result
|
|
|
|
|
|
def connect_output(args: dict) -> dict:
|
|
asset = _load_material(args["asset_path"])
|
|
from_ref, from_output = args["from"]
|
|
ok = _mel().connect_material_property(_find_expression(asset, from_ref), str(from_output or ""), _material_property(args["property"]))
|
|
_update_asset(asset)
|
|
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
|
return {"ok": bool(ok)}
|
|
|
|
|
|
def layout(args: dict) -> dict:
|
|
asset = _load_asset(args["asset_path"])
|
|
_layout_asset(asset)
|
|
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
|
return {"ok": True}
|
|
|
|
|
|
def tidy_graph(args: dict) -> dict:
|
|
asset = _load_asset(args["asset_path"])
|
|
graph = read_graph({"asset_path": args["asset_path"]})
|
|
if not graph.get("ok"):
|
|
return graph
|
|
moves = graph_layout.tidy_existing_nodes(graph.get("nodes") or [], graph.get("edges") or [], "material")
|
|
moved = []
|
|
for move in moves:
|
|
expr = _find_expression(asset, move["guid"])
|
|
expr.set_editor_property("material_expression_editor_x", int(move["x"]))
|
|
expr.set_editor_property("material_expression_editor_y", int(move["y"]))
|
|
moved.append(move)
|
|
_update_asset(asset)
|
|
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
|
return {"ok": True, "moved": moved, "count": len(moved)}
|
|
|
|
|
|
def recompile_save(args: dict) -> dict:
|
|
asset = _load_asset(args["asset_path"])
|
|
_update_asset(asset)
|
|
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
|
return {"ok": True}
|
|
|
|
|
|
def set_instance_parent(args: dict) -> dict:
|
|
asset = _load_asset(args["asset_path"])
|
|
if not isinstance(asset, unreal.MaterialInstanceConstant):
|
|
return {"ok": False, "error": "asset is not MaterialInstanceConstant"}
|
|
_mel().set_material_instance_parent(asset, _load_material_interface(args["parent"]))
|
|
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
|
return {"ok": True}
|
|
|
|
|
|
def set_instance_scalar(args: dict) -> dict:
|
|
asset = _load_asset(args["asset_path"])
|
|
ok = _mel().set_material_instance_scalar_parameter_value(asset, str(args["name"]), float(args["value"]))
|
|
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
|
return {"ok": bool(ok)}
|
|
|
|
|
|
def set_instance_vector(args: dict) -> dict:
|
|
asset = _load_asset(args["asset_path"])
|
|
ok = _mel().set_material_instance_vector_parameter_value(asset, str(args["name"]), _coerce_value(args["value"]))
|
|
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
|
return {"ok": bool(ok)}
|
|
|
|
|
|
def set_instance_texture(args: dict) -> dict:
|
|
asset = _load_asset(args["asset_path"])
|
|
ok = _mel().set_material_instance_texture_parameter_value(asset, str(args["name"]), _load_asset(args["value"]))
|
|
unreal.EditorAssetLibrary.save_loaded_asset(asset)
|
|
return {"ok": bool(ok)}
|
|
|
|
|
|
def list_parameters(args: dict) -> dict:
|
|
asset = _load_material_interface(args["asset_path"])
|
|
out = {"ok": True, "asset_path": args["asset_path"], "scalar": [], "vector": [], "texture": [], "static_switch": []}
|
|
for key, fn_name in (
|
|
("scalar", "get_scalar_parameter_names"),
|
|
("vector", "get_vector_parameter_names"),
|
|
("texture", "get_texture_parameter_names"),
|
|
("static_switch", "get_static_switch_parameter_names"),
|
|
):
|
|
try:
|
|
out[key] = [str(x) for x in getattr(_mel(), fn_name)(asset)]
|
|
except Exception as e:
|
|
out[key + "_error"] = str(e)
|
|
return out
|
|
|
|
|
|
def open_asset(args: dict) -> dict:
|
|
asset = _load_asset(args["asset_path"])
|
|
ok = unreal.AssetEditorSubsystem().open_editor_for_assets([asset])
|
|
return {"ok": bool(ok), "asset_path": args["asset_path"]}
|
|
|
|
|
|
def _layout_asset(asset):
|
|
if isinstance(asset, unreal.Material):
|
|
_mel().layout_material_expressions(asset)
|
|
elif isinstance(asset, unreal.MaterialFunction):
|
|
_mel().layout_material_function_expressions(asset)
|
|
|
|
|
|
def _update_asset(asset):
|
|
if isinstance(asset, unreal.Material):
|
|
_mel().recompile_material(asset)
|
|
elif isinstance(asset, unreal.MaterialFunction):
|
|
_mel().update_material_function(asset, None)
|
|
elif isinstance(asset, unreal.MaterialInstanceConstant):
|
|
_mel().update_material_instance(asset)
|
|
|
|
|
|
OPS = {
|
|
"create_material": create_material,
|
|
"set_material_settings": set_material_settings,
|
|
"inspect_material_settings": inspect_material_settings,
|
|
"create_material_instance": create_material_instance,
|
|
"read_graph": read_graph,
|
|
"apply_graph": apply_graph,
|
|
"add_node": add_node,
|
|
"delete_node": delete_node,
|
|
"move_nodes": move_nodes,
|
|
"set_node_property": set_node_property,
|
|
"connect": connect,
|
|
"connect_output": connect_output,
|
|
"layout": layout,
|
|
"tidy_graph": tidy_graph,
|
|
"recompile_save": recompile_save,
|
|
"set_instance_parent": set_instance_parent,
|
|
"set_instance_scalar": set_instance_scalar,
|
|
"set_instance_vector": set_instance_vector,
|
|
"set_instance_texture": set_instance_texture,
|
|
"list_parameters": list_parameters,
|
|
"open_asset": open_asset,
|
|
}
|
|
|
|
|
|
def entrypoint(payload_json: str) -> str:
|
|
try:
|
|
p = json.loads(payload_json)
|
|
except Exception as e:
|
|
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
|
fn = OPS.get(p.get("op"))
|
|
if not fn:
|
|
return _tee({"ok": False, "error": f"unknown op {p.get('op')!r}", "available": list(OPS)}, p)
|
|
try:
|
|
return _tee(fn(p), p)
|
|
except Exception as e:
|
|
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
|
|
|
|
|
def _tee(result: dict, p: dict) -> str:
|
|
txt = json.dumps(result)
|
|
try:
|
|
if unreal is not None:
|
|
sd = unreal.Paths.project_saved_dir() + "MCP/"
|
|
os.makedirs(sd, exist_ok=True)
|
|
rid = p.get("__rid") or "last"
|
|
with open(sd + "last.json", "w", encoding="utf-8") as f:
|
|
f.write(txt)
|
|
if rid != "last":
|
|
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
|
|
f.write(txt)
|
|
unreal.log("[MCP-MATERIALS] " + txt[:1500])
|
|
except Exception:
|
|
pass
|
|
return txt
|