Files
unreal-engine-mcp-system-pl…/ue_side/materials.py
Bonchellon eba71c4ca8 Unified, portable Unreal Engine MCP system plugin
Merge of the two project copies into one self-contained plugin (the superset:
variable_op + variables.py, full pcg_op runtime/declarative/preset ops, the
CreateWidgetFloatAnimation widget tool, and full Voxel graph authoring).

Made fully project- and machine-agnostic — no hardcoded paths:
- New src/projectPaths.js auto-detects the host .uproject (walk-up), project
  name, Editor build target, log file, and engine install (EngineAssociation
  via launcher manifest/registry, else installed-engine scan). All overridable
  via UE_* env vars.
- Rewired buildOrchestrator/insights/launcher/insightsExporter/server.js and the
  Python workers (cpp_scaffold, live_coding, apply_graph, console) off the old
  C:/Github/ihy, IHY*, E:/UE_Versions and UE_5.7 literals onto the resolver.
- Voxel made optional: Build.cs auto-detects the Voxel plugin (env
  UE_MCP_WITH_VOXEL override) and the C++ compiles to stubs under WITH_MCP_VOXEL,
  so the module builds in projects without Voxel; .uplugin marks Voxel optional.
- De-branded the agent-gateway and docs; scrubbed a leaked API key; excluded
  node_modules/Binaries/Intermediate/__pycache__/secrets from the repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:07:24 +03:00

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