diff --git a/CHANGELOG.md b/CHANGELOG.md index 36f12a6..5bfae2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,35 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.0] - 2026-07-08 — ue_x3d_* : the harness on the wire + +The v0.3.0 UE↔X3D harness (`x3d_bridge`) is now exposed as MCP tools, so Claude +can export a live level to X3D, validate it, apply transform edits back, and +preview it — inside the editor. All four are **CORE-tier** (mounted by default); +Epic's MCP has no X3D surface. + +### Added +- **`ue_x3d_export`** — serialize the current level to X3D. Reads each actor's + quaternion transform, static mesh, material, and attach parent; converts UE + (Z-up, cm) → X3D (Y-up, m). `mesh_only` by default. +- **`ue_x3d_validate`** — the paper boundary as a tool: rejects out-of-grammar + nodes, dangling `USE`, wrong arity, non-X3D roots, NaN/inf — without touching + the editor. +- **`ue_x3d_apply`** — apply an X3D document's actor transforms back to the live + level (validated, guids sanitized via `sanitize_object_path`, emitted through + `ue_execute_python`). Transforms in v0.4.0; spawn / material / reparent apply + are documented follow-ups. +- **`ue_x3d_preview`** — render X3D to a standalone X_ITE browser page. +- `tests/test_x3d_async.py` + exec-sim coverage for the two codegen tools (stub + extended with `Quat` / `Transform` / `get_actor_transform`). Tier table pinned + at 60 tools / 22 CORE. + +### Notes +- Editor-independent and CI-green; the **live** round-trip against a real 5.8 + scene is gated on `ue_preflight` reading green (i.e. `bAllowAnyRemoteFunctionCall` + applied). `B` remains analytic (not glTF-calibrated) — the round trip is + basis-agnostic, so that is fidelity-only. + ## [0.3.1] - 2026-07-08 — 5.8 survival: the Capability Ladder The bridge was **silently broken on UE 5.8**: 5.8 defaults diff --git a/tests/exec_sim/registry.py b/tests/exec_sim/registry.py index d063648..8d25cd4 100644 --- a/tests/exec_sim/registry.py +++ b/tests/exec_sim/registry.py @@ -56,6 +56,17 @@ class ToolEntry: notes: str = "" +# Minimal valid X3D for the DIRECT x3d tools, and an apply sentinel whose DEF is a +# seeded stub actor so the generated SetTransform resolves to a real actor. +_X3D_MIN = '' +_X3D_APPLY_SENTINEL = ( + '' + '' + "" +) + + _ENTRIES = [ # ------------------------------------------------------------- actors.py ToolEntry( @@ -375,6 +386,29 @@ class ToolEntry: expect_error=True, notes="honest not-implemented report: an error RESULT is the CORRECT behavior", ), + # ----------------------------------------------------------------- x3d.py + ToolEntry( + "ue_x3d_export", CODEGEN, + kwargs=dict(mesh_only=True), + notes="reads every level actor via a static script; no user args interpolated", + ), + ToolEntry( + "ue_x3d_validate", DIRECT, + kwargs=dict(x3d=_X3D_MIN), + notes="pure x3d_bridge.validate; never touches the editor", + ), + ToolEntry( + "ue_x3d_apply", CODEGEN, + kwargs=dict(x3d=_X3D_APPLY_SENTINEL), + # translation/rotation/scale pass through x3d_bridge.coordinates (X3D->UE + # basis) before emission, so they are NOT literal in the generated source. + notes="applies transforms; DEF must be a real actor path (SENTINEL_ACTOR_PATH)", + ), + ToolEntry( + "ue_x3d_preview", DIRECT, + kwargs=dict(x3d=_X3D_MIN), + notes="renders X3D to HTML in-process; never touches the editor", + ), ] REGISTRY: dict[str, ToolEntry] = {e.tool_name: e for e in _ENTRIES} diff --git a/tests/exec_sim/unreal_stub.py b/tests/exec_sim/unreal_stub.py index 429e3a7..1b4d77c 100644 --- a/tests/exec_sim/unreal_stub.py +++ b/tests/exec_sim/unreal_stub.py @@ -66,6 +66,21 @@ def __init__(self, pitch=0.0, yaw=0.0, roll=0.0): self.pitch, self.yaw, self.roll = float(pitch), float(yaw), float(roll) +class Quat: + def __init__(self, x=0.0, y=0.0, z=0.0, w=1.0): + self.x, self.y, self.z, self.w = float(x), float(y), float(z), float(w) + + def rotator(self): + return Rotator() + + +class Transform: + def __init__(self, translation=None, rotation=None, scale3d=None): + self.translation = translation if translation is not None else Vector() + self.rotation = rotation if rotation is not None else Quat() + self.scale3d = scale3d if scale3d is not None else Vector(1.0, 1.0, 1.0) + + class LinearColor: def __init__(self, r=0.0, g=0.0, b=0.0, a=1.0): self.r, self.g, self.b, self.a = float(r), float(g), float(b), float(a) @@ -354,6 +369,9 @@ def set_actor_scale3d(self, v): self._scale = v return True + def get_actor_transform(self): + return Transform(self._location, Quat(), self._scale) + def get_actor_bounds(self, only_colliding, *args): return (Vector(0.0, 0.0, 100.0), Vector(50.0, 50.0, 100.0)) @@ -702,6 +720,7 @@ def find_object(outer, path): exported = { "Vector": Vector, "Vector4": Vector4, "Rotator": Rotator, + "Quat": Quat, "Transform": Transform, "LinearColor": LinearColor, "Color": Color, "Name": Name, "EditorActorSubsystem": EditorActorSubsystem, "UnrealEditorSubsystem": UnrealEditorSubsystem, diff --git a/tests/test_registry_tiers.py b/tests/test_registry_tiers.py index 1856caf..482c0ae 100644 --- a/tests/test_registry_tiers.py +++ b/tests/test_registry_tiers.py @@ -54,9 +54,9 @@ def test_matrix_arithmetic_is_pinned(): """docs/EPIC_MCP_MATRIX.md §4: 36 RETIRE; KEEP/KEEP-PARTIAL = 18 CORE here (ue_status/ue_health_check live in mcp_server.py, outside this registry); undo/redo are the two honest not-implemented EXPERIMENTAL slots.""" - assert len(TIERS) == 56 + assert len(TIERS) == 60 assert len(LEGACY_NAMES) == 36 - assert len(CORE_NAMES) == 18 + assert len(CORE_NAMES) == 22 assert EXP_NAMES == {"ue_undo", "ue_redo"} diff --git a/tests/test_x3d_async.py b/tests/test_x3d_async.py new file mode 100644 index 0000000..aeb1eeb --- /dev/null +++ b/tests/test_x3d_async.py @@ -0,0 +1,135 @@ +"""Async tests for ue_mcp/tools/x3d.py -- the ue_x3d_* MCP tools. + +Mocked bridge; the exec-sim harness proves the generated read/apply scripts run +against the strict fake `unreal`. Live round-trip is proven by ue_preflight + +smoke_live against a real editor. +""" + +import ast +import json + +import pytest +from mcp.server.fastmcp import FastMCP + +from ue_mcp.tools.x3d import register + + +@pytest.fixture +def server(mock_ue): + s = FastMCP("test") + register(s, mock_ue) + return s + + +def _fn(server, name): + return server._tool_manager._tools[name].fn + + +_ACTOR_ROW = { + "guid": "/Game/Maps/M.M:PersistentLevel.Rock_1", + "mesh": "/Game/Meshes/SM_Rock", + "material": "/Game/Mat/M_Granite", + "t": [420.0, 0.0, 155.0], + "r": [0.0, 0.0, 0.0, 1.0], + "s": [1.0, 1.0, 1.0], + "parent": None, +} + +_APPLY_X3D = ( + '' + '' +) + + +class TestExport: + @pytest.mark.asyncio + async def test_serializes_scene(self, server, mock_ue): + mock_ue.execute_python.return_value = {"result": [_ACTOR_ROW], "output": "", "error": None} + data = json.loads(await _fn(server, "ue_x3d_export")(mesh_only=True)) + assert "error" not in data + assert data["actor_count"] == 1 + assert 'DEF="/Game/Maps/M.M:PersistentLevel.Rock_1"' in data["x3d"] + assert "/Game/Meshes/SM_Rock" in data["x3d"] + mock_ue.execute_python.assert_awaited_once() + + @pytest.mark.asyncio + async def test_read_script_is_valid_python(self, server, mock_ue): + mock_ue.execute_python.return_value = {"result": [], "output": "", "error": None} + await _fn(server, "ue_x3d_export")() + code = mock_ue.execute_python.call_args[0][0] + ast.parse(code) # generated UE Python must compile + assert "get_all_level_actors" in code + assert "get_actor_transform" in code + + @pytest.mark.asyncio + async def test_mesh_only_filters_meshless(self, server, mock_ue): + rows = [_ACTOR_ROW, {**_ACTOR_ROW, "guid": "Light_1", "mesh": ""}] + mock_ue.execute_python.return_value = {"result": rows, "output": "", "error": None} + data = json.loads(await _fn(server, "ue_x3d_export")(mesh_only=True)) + assert data["actor_count"] == 1 + + @pytest.mark.asyncio + async def test_reports_read_failure(self, server, mock_ue): + mock_ue.execute_python.return_value = {"result": None, "output": "", "error": "boom"} + data = json.loads(await _fn(server, "ue_x3d_export")()) + assert "error" in data + + +class TestValidate: + @pytest.mark.asyncio + async def test_accepts_good(self, server, mock_ue): + x3d = '' + data = json.loads(await _fn(server, "ue_x3d_validate")(x3d=x3d)) + assert data["ok"] is True + mock_ue.execute_python.assert_not_awaited() + + @pytest.mark.asyncio + async def test_rejects_out_of_grammar(self, server, mock_ue): + data = json.loads(await _fn(server, "ue_x3d_validate")(x3d="")) + assert data["ok"] is False + assert data["errors"] + + +class TestApply: + @pytest.mark.asyncio + async def test_emits_set_transform(self, server, mock_ue): + mock_ue.execute_python.return_value = {"result": {"applied": 1}, "output": "", "error": None} + await _fn(server, "ue_x3d_apply")(x3d=_APPLY_X3D) + mock_ue.execute_python.assert_awaited_once() + code = mock_ue.execute_python.call_args[0][0] + ast.parse(code) + assert "set_actor_location" in code + assert "RESULT:" in code # the emitted ops are wrapped with a result line + + @pytest.mark.asyncio + async def test_rejects_invalid_x3d_before_editor(self, server, mock_ue): + data = json.loads(await _fn(server, "ue_x3d_apply")(x3d="")) + assert "error" in data + mock_ue.execute_python.assert_not_awaited() + + @pytest.mark.asyncio + async def test_dangerous_guid_never_reaches_codegen(self, server, mock_ue): + # a DEF carrying an injection attempt must be rejected, or at minimum not + # appear verbatim in the generated Python. + x3d = ( + '' + '' + ) + result = await _fn(server, "ue_x3d_apply")(x3d=x3d) + data = json.loads(result) + if "error" not in data: + code = mock_ue.execute_python.call_args[0][0] + assert "import os" not in code + + +class TestPreview: + @pytest.mark.asyncio + async def test_embeds_x3d_no_editor(self, server, mock_ue): + x3d = '' + data = json.loads(await _fn(server, "ue_x3d_preview")(x3d=x3d)) + assert data["chars"] > 0 + assert "" in data["html"] + assert 'DEF="A"' in data["html"] + mock_ue.execute_python.assert_not_awaited() diff --git a/ue_mcp/__version__.py b/ue_mcp/__version__.py index 77e23f2..7172027 100644 --- a/ue_mcp/__version__.py +++ b/ue_mcp/__version__.py @@ -5,5 +5,5 @@ numbering was never released and was retired at the Epic-MCP-era reset. """ -__version__ = "0.3.1" -__version_info__ = (0, 3, 1) +__version__ = "0.4.0" +__version_info__ = (0, 4, 0) diff --git a/ue_mcp/tools/__init__.py b/ue_mcp/tools/__init__.py index 06fefc8..6b948a8 100644 --- a/ue_mcp/tools/__init__.py +++ b/ue_mcp/tools/__init__.py @@ -31,6 +31,7 @@ from .scene import register as register_scene from .sequencer import register as register_sequencer from .spatial import register as register_spatial +from .x3d import register as register_x3d class Tier(StrEnum): @@ -116,6 +117,11 @@ class Tier(StrEnum): "ue_play_sequence": _LEGACY, "ue_add_actor_to_sequence": _LEGACY, "ue_add_keyframe": _LEGACY, + # x3d.py — UE<->X3D thin-slice harness (no Epic counterpart) + "ue_x3d_export": _CORE, + "ue_x3d_validate": _CORE, + "ue_x3d_apply": _CORE, + "ue_x3d_preview": _CORE, } PROFILES: dict[str, set[Tier]] = { @@ -172,6 +178,7 @@ def tool(self, *, name: str, description: str, annotations: dict | None = None): register_materials, register_editor, register_sequencer, + register_x3d, ) diff --git a/ue_mcp/tools/x3d.py b/ue_mcp/tools/x3d.py new file mode 100644 index 0000000..7b9e678 --- /dev/null +++ b/ue_mcp/tools/x3d.py @@ -0,0 +1,186 @@ +""" +x3d.py + +MCP tools exposing the x3d_bridge UE<->X3D harness inside the editor. + +The harness (x3d_bridge/) round-trips a level's assembly state through a closed +X3D grammar with a lossless invariant and a validation boundary. These tools put +it on the wire: + +- ue_x3d_export Read + Serialize : the live level -> X3D text +- ue_x3d_validate Validate : the paper boundary, as a tool +- ue_x3d_apply Apply : X3D transforms -> the editor via execute_python +- ue_x3d_preview Preview : X3D -> a standalone X_ITE browser page + +Coordinate conversion, the grammar, and op emission all live in x3d_bridge; this +module is a thin, validated adapter between it and the Remote Control bridge. + +v0.4.0 scope: apply sets actor transforms (spawn / delete / material / reparent +apply are a documented follow-up). Export captures mesh/material/parent so the +X3D is complete even though apply currently acts on transforms only. +""" + +from __future__ import annotations + +import json +import logging + +from x3d_bridge import ( + Actor, + SetTransform, + X3DGrammarError, + deserialize, + emit_python, + serialize, + to_preview_html, +) +from x3d_bridge import validate as x3d_validate + +from ._types import MCPServer, UEBridge +from ._validation import make_error, sanitize_object_path + +logger = logging.getLogger("ue5-mcp.tools.x3d") + +# Read every level actor's identity + UE-native transform (rotation as a +# QUATERNION, read straight off the actor transform so we never reconstruct UE's +# Euler convention), mesh path, first material, and attach parent. Static: no +# user input is interpolated, so no escaping is required. +_READ_ACTORS_CODE = """ +import unreal, json +sub = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) +out = [] +for a in sub.get_all_level_actors(): + try: + t = a.get_actor_transform() + loc = t.translation; rot = t.rotation; scl = t.scale3d + mesh_path = "" + mat_path = None + comp = a.get_component_by_class(unreal.StaticMeshComponent) + if comp: + try: + sm = comp.static_mesh + except Exception: + sm = None + if sm: + mesh_path = sm.get_path_name() + try: + if comp.get_num_materials() > 0: + m = comp.get_material(0) + if m: + mat_path = m.get_path_name() + except Exception: + pass + parent = a.get_attach_parent_actor() + out.append({ + "guid": a.get_path_name(), + "mesh": mesh_path, + "material": mat_path, + "t": [loc.x, loc.y, loc.z], + "r": [rot.x, rot.y, rot.z, rot.w], + "s": [scl.x, scl.y, scl.z], + "parent": parent.get_path_name() if parent else None, + }) + except Exception: + pass +print("RESULT:" + json.dumps(out)) +""" + + +def register(server: MCPServer, ue: UEBridge) -> None: + @server.tool( + name="ue_x3d_export", + description=( + "Serialize the current level to X3D text (the x3d_bridge thin-slice " + "grammar). Reads each actor's quaternion transform, static mesh, " + "material, and attach parent; converts UE (Z-up, cm) to X3D " + "(Y-up, m). mesh_only=True (default) exports only actors with a " + "static mesh. Returns {actor_count, x3d}." + ), + annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}, + ) + async def x3d_export(mesh_only: bool = True) -> str: + result = await ue.execute_python(_READ_ACTORS_CODE) + rows = result.get("result") + if not isinstance(rows, list): + detail = result.get("error") or result.get("output") or "no result" + return make_error(f"could not read level actors: {detail}") + actors = [] + for row in rows: + if mesh_only and not row.get("mesh"): + continue + actors.append( + Actor( + guid=row["guid"], + mesh=row.get("mesh") or "", + material=row.get("material"), + parent=row.get("parent"), + t=tuple(row["t"]), + r=tuple(row["r"]), + s=tuple(row["s"]), + ) + ) + return json.dumps({"actor_count": len(actors), "x3d": serialize(actors)}, indent=2) + + @server.tool( + name="ue_x3d_validate", + description=( + "Validate an X3D document against the closed thin-slice grammar " + "without touching the editor: rejects out-of-grammar nodes, dangling " + "USE references, wrong numeric arity, non-X3D roots, and NaN/inf. " + "Returns {ok, errors}. Run this on model-edited X3D before apply." + ), + annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}, + ) + async def x3d_validate_doc(x3d: str) -> str: + ok, errors = x3d_validate(x3d) + return json.dumps({"ok": ok, "errors": errors}, indent=2) + + @server.tool( + name="ue_x3d_apply", + description=( + "Apply an X3D document's actor transforms back to the live level. " + "Validates the document, then for each Transform (addressed by its " + "DEF = the actor's object path) sets location/rotation/scale in the " + "editor. Actors must already exist. Returns {applied} or an error. " + "v0.4.0 applies transforms only." + ), + annotations={"readOnlyHint": False, "destructiveHint": True, "idempotentHint": False}, + ) + async def x3d_apply(x3d: str) -> str: + ok, errors = x3d_validate(x3d) + if not ok: + return make_error("invalid X3D: " + "; ".join(errors[:5])) + try: + actors = deserialize(x3d) + except X3DGrammarError as exc: + return make_error(f"X3D parse error: {exc}") + + ops = [] + for a in actors: + if err := sanitize_object_path(a.guid, "guid (X3D DEF)"): + return make_error(err) + ops.append(SetTransform(guid=a.guid, t=a.t, r=a.r, s=a.s)) + if not ops: + return json.dumps({"applied": 0, "note": "no actors in document"}, indent=2) + + code = ( + "import unreal, json\n" + + "\n".join(emit_python(op) for op in ops) + + f'\nprint("RESULT:" + json.dumps({{"applied": {len(ops)}}}))\n' + ) + result = await ue.execute_python(code) + return json.dumps(result, indent=2) + + @server.tool( + name="ue_x3d_preview", + description=( + "Render an X3D document to a standalone HTML page that views it in a " + "browser via the X_ITE runtime (no editor needed). Returns " + "{chars, html}. The page references X_ITE from a CDN, so viewing it " + "needs network access; the X3D payload is embedded inline." + ), + annotations={"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}, + ) + async def x3d_preview(x3d: str, title: str = "UE x X3D preview") -> str: + html = to_preview_html(x3d, title=title) + return json.dumps({"chars": len(html), "html": html}, indent=2)