Files
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

228 lines
8.4 KiB
Python

"""Cull-distance ops — batch set/inspect Desired Max Draw Distance on ISM/HISM/StaticMesh components.
Encapsulates the gotchas learned the hard way:
* CachedMaxDrawDistance is READ-ONLY -> we only write LDMaxDrawDistance (+ SetCullDistance on live instances).
* SubobjectDataHandle must be unwrapped via k2_find_subobject_data_from_handle (UE 5.7).
* target='asset_template' edits the Blueprint asset (default for ALL instances, survives reconstruct);
target='level_instance' edits placed actors only (fast, but lost on BP recompile / PLA repack).
* only_zero keeps it idempotent and never clobbers hand-tuned non-zero distances.
Ops:
audit {scope?, asset_paths?, component_types?, target?} — read-only report, no mutation
apply {distance, scope?, asset_paths?, component_types?, only_zero?, target?, save?:true}
verify {...same as audit} — alias of audit (post-apply check)
scope: 'level' (default) | 'assets' | 'selection'
target: 'asset_template' (default) | 'level_instance'
component_types: subset of ['ISM','HISM','StaticMesh','Primitive'] (default ['ISM'])
distance: cm; 0 disables culling
"""
from __future__ import annotations
import json
import os
import traceback
try:
import unreal # type: ignore
except ImportError:
unreal = None
def _eas(): return unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
def _sub(): return unreal.get_engine_subsystem(unreal.SubobjectDataSubsystem)
def _type_classes(args):
types = args.get("component_types") or ["ISM"]
mapping = {
"ISM": unreal.InstancedStaticMeshComponent,
"HISM": unreal.HierarchicalInstancedStaticMeshComponent,
"StaticMesh": unreal.StaticMeshComponent,
"Primitive": unreal.PrimitiveComponent,
}
out = []
for t in types:
cls = mapping.get(t)
if cls is None:
raise ValueError(f"unknown component_type {t!r}; allowed: {list(mapping)}")
out.append(cls)
return out
def _matches(obj, classes):
return any(isinstance(obj, c) for c in classes)
def _asset_path_from_actor(actor):
gen = actor.get_class().get_path_name()
asset = gen.split(".")[0]
return asset[:-2] if asset.endswith("_C") else asset
# ---- target='asset_template' : edit Blueprint component templates -----------
def _template_components(bp, classes):
"""Returns deduped list of (name, component_template) for matching types."""
handles = _sub().k2_gather_subobject_data_for_blueprint(bp)
seen, out = set(), []
for h in handles:
data = _sub().k2_find_subobject_data_from_handle(h)
obj = unreal.SubobjectDataBlueprintFunctionLibrary.get_object(data)
if obj is None or not _matches(obj, classes):
continue
nm = obj.get_name()
if nm in seen:
continue
seen.add(nm)
out.append((nm, obj))
return out
def _collect_assets(args):
"""Returns dict {asset_path: blueprint_asset} for the requested scope."""
ael = unreal.EditorAssetLibrary
paths = {}
scope = args.get("scope") or "level"
if scope == "assets":
for p in (args.get("asset_paths") or []):
paths[p.split(".")[0]] = None
else: # level / selection -> discover from placed actors
classes = _type_classes(args)
actors = (_eas().get_selected_level_actors() if scope == "selection"
else _eas().get_all_level_actors())
for a in actors:
if any(a.get_components_by_class(c) for c in classes):
paths[_asset_path_from_actor(a)] = None
result = {}
for p in paths:
result[p] = ael.load_asset(p)
return result
def _run_assets(args, mutate):
classes = _type_classes(args)
distance = float(args.get("distance", 0.0))
only_zero = args.get("only_zero", True)
save = args.get("save", True)
rows, total_set = [], 0
for path, bp in _collect_assets(args).items():
if bp is None:
rows.append({"asset": path, "error": "load failed"}); continue
comps = _template_components(bp, classes)
vals, set_n = [], 0
for _nm, c in comps:
cur = float(c.get_editor_property("ld_max_draw_distance"))
if mutate:
if not (only_zero and abs(cur) > 0.001):
c.set_editor_property("ld_max_draw_distance", distance)
set_n += 1; cur = distance
vals.append(round(cur, 1))
if mutate and set_n:
unreal.BlueprintEditorLibrary.compile_blueprint(bp)
if save:
unreal.EditorAssetLibrary.save_asset(path)
total_set += 1
rows.append({
"asset": path, "components": len(comps),
"zeros": sum(1 for v in vals if abs(v) < 0.001),
"distinct": sorted(set(vals)), "set": set_n,
})
return {"ok": True, "target": "asset_template",
"assets_modified": total_set, "rows": rows}
# ---- target='level_instance' : edit placed actors --------------------------
def _run_instances(args, mutate):
classes = _type_classes(args)
distance = float(args.get("distance", 0.0))
only_zero = args.get("only_zero", True)
scope = args.get("scope") or "level"
actors = (_eas().get_selected_level_actors() if scope == "selection"
else _eas().get_all_level_actors())
rows, changed_actors, changed_comps = [], 0, 0
for a in actors:
comps, seen = [], set()
for c in classes:
for comp in a.get_components_by_class(c):
if comp.get_name() not in seen:
seen.add(comp.get_name()); comps.append(comp)
if not comps:
continue
vals, set_n = [], 0
for comp in comps:
cur = float(comp.get_editor_property("ld_max_draw_distance"))
if mutate and not (only_zero and abs(cur) > 0.001):
comp.modify()
comp.set_editor_property("ld_max_draw_distance", distance)
comp.set_cull_distance(distance) # updates cached + re-registers
set_n += 1; cur = distance
vals.append(round(cur, 1))
if mutate and set_n:
a.modify(); changed_actors += 1; changed_comps += set_n
rows.append({"actor": a.get_actor_label(), "components": len(comps),
"zeros": sum(1 for v in vals if abs(v) < 0.001),
"distinct": sorted(set(vals)), "set": set_n})
return {"ok": True, "target": "level_instance",
"actors_modified": changed_actors, "components_set": changed_comps,
"rows": rows, "note": "instance edits are lost on BP recompile / PLA repack"}
# ---- op table --------------------------------------------------------------
def _dispatch(args, mutate):
target = args.get("target") or "asset_template"
if target == "asset_template":
return _run_assets(args, mutate)
if target == "level_instance":
return _run_instances(args, mutate)
return {"ok": False, "error": f"unknown target {target!r}; use 'asset_template' or 'level_instance'"}
def audit(args):
return _dispatch(args, mutate=False)
def verify(args):
return _dispatch(args, mutate=False)
def apply(args):
if "distance" not in args:
return {"ok": False, "error": "apply requires 'distance' (cm)"}
return _dispatch(args, mutate=True)
OPS = {"audit": audit, "verify": verify, "apply": apply}
def entrypoint(payload_json):
try:
p = json.loads(payload_json)
except Exception as e:
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
op = p.get("op"); fn = OPS.get(op)
if not fn:
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
try:
return _tee(fn(p), p)
except Exception as e:
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
def _tee(result, p):
txt = json.dumps(result)
try:
if unreal is not None:
sd = unreal.Paths.project_saved_dir() + "MCP/"
os.makedirs(sd, exist_ok=True)
rid = p.get("__rid") or "last"
with open(sd + "last.json", "w", encoding="utf-8") as f: f.write(txt)
if rid != "last":
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f: f.write(txt)
unreal.log("[MCP-CULL] " + txt[:1500])
except Exception:
pass
return txt