From e7080cdf0fd6ce38ab539722b90b09d83b4da6d7 Mon Sep 17 00:00:00 2001 From: Joseph Ibrahim Date: Tue, 7 Jul 2026 18:36:33 -0400 Subject: [PATCH 1/2] feat(x3d): UE<->X3D thin-slice round-trip harness New self-contained x3d_bridge/ package: a lossless UE<->X3D serialization harness whose single defended invariant is deserialize(serialize(level)) == level, with a validation boundary that rejects malformed edits on paper before the live editor is touched. - coordinates: the crux -- one orthonormal basis B (det -1) for UE (Z-up/LH/cm) <-> X3D (Y-up/RH/m); quat<->matrix<->axis-angle; a basis_from_axis_images calibration primitive. Round trip is exact by construction (B^-1 == B^T). - grammar: a closed X3D node set + serialize/deserialize (flat, world-space, DEF/USE material dedup); UE specifics ride in Metadata*. - validate: the paper boundary -- grammar, DEF/USE, numeric arity, root-is-X3D, finiteness. - loop: a five-stage headless loop; diffs scenes into typed apply ops that emit ue_execute_python (mock-asserted). - preview: the same X3D in a browser via X_ITE. - tests/test_x3d_bridge.py: 55 tests -- round-trip, validation battery, apply-op sequence, forward-pinned coordinate correctness. ruff-clean. B is analytic and triple-confirmed but not yet live-calibrated (round trip is basis-agnostic, so calibration is fidelity-only, never correctness); the harness is a library, not yet exposed as MCP tools. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 47 +++- pyproject.toml | 2 +- tests/test_x3d_bridge.py | 488 ++++++++++++++++++++++++++++++++++++++ x3d_bridge/__init__.py | 117 +++++++++ x3d_bridge/coordinates.py | 282 ++++++++++++++++++++++ x3d_bridge/grammar.py | 282 ++++++++++++++++++++++ x3d_bridge/loop.py | 268 +++++++++++++++++++++ x3d_bridge/preview.py | 52 ++++ x3d_bridge/validate.py | 80 +++++++ 9 files changed, 1613 insertions(+), 5 deletions(-) create mode 100644 tests/test_x3d_bridge.py create mode 100644 x3d_bridge/__init__.py create mode 100644 x3d_bridge/coordinates.py create mode 100644 x3d_bridge/grammar.py create mode 100644 x3d_bridge/loop.py create mode 100644 x3d_bridge/preview.py create mode 100644 x3d_bridge/validate.py diff --git a/README.md b/README.md index c54e333..5a21583 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ **Claude Code, working inside your Unreal editor.** This bridge gives Claude the abilities Epic's own MCP doesn't ship: run real editor Python, see the viewport continuously, light scenes with one command, reason about space with surface normals, and stay honest about every result. -**58 MCP tools · 20 mounted by default · 580 tests** · [Changelog](CHANGELOG.md) · [Security](SECURITY.md) +**58 MCP tools · 20 mounted by default · 635 tests** · [Changelog](CHANGELOG.md) · [Security](SECURITY.md) --- @@ -176,6 +176,44 @@ Epic's MCP covers these — the matrix cites the exact equivalent for every row. --- +## 🧊 UE ↔ X3D round-trip harness *(new in v0.3.0)* + +**Hand Claude a level as open X3D it can read, edit, and validate — then write it back losslessly.** A thin, fully-tested slice: no live editor needed to prove an edit is safe. + +**The one invariant it defends:** + +``` +deserialize( serialize( level ) ) == level +``` + +Lossless round trip, plus malformed edits rejected **on paper** before any mutation. If both hold, the bridge is trustworthy — if either fails, nothing else matters. + +| Piece | Job | +|---|---| +| **Coordinate crux** | UE (Z-up, LH, cm) ↔ X3D (Y-up, RH, m) as one orthonormal basis `B` (det −1). `B⁻¹ = Bᵀ`, so the round trip is exact *by construction* — picking the right `B` is calibration, not correctness. | +| **Closed grammar** | An X3D node set small enough to hand a model *whole* and validate against. UE specifics (mesh · mobility · folder · parent) ride in `Metadata*`. | +| **Validate boundary** | Out-of-grammar nodes, dangling `USE`, NaN/∞, wrong arity, non-X3D root — all die here, before the editor is touched. | +| **Apply seam** | Diffs two scenes into typed ops (spawn · transform · material · reparent) that emit `ue_execute_python`. | +| **Preview** | The same X3D drops into a browser via X_ITE — free. | + +```python +from x3d_bridge import Actor, serialize, deserialize, validate + +x3d = serialize([Actor(guid="Rock_01", mesh="/Game/Meshes/SM_Rock", + t=(420.0, 0.0, 155.0))]) +ok, errors = validate(x3d) # the paper boundary +level = deserialize(x3d) # lossless +``` + +**Status — honest, per the house rule:** + +- ✅ **55 tests** — round-trip · validation battery · apply-op sequence — all green +- ✅ Basis `B` independently re-derived and confirmed three ways +- ⏳ `B` is *analytic* — **not yet calibrated against a live glTF export** (round-trip is basis-agnostic, so this is fidelity-only, never correctness) +- ⏳ A library today — **not yet exposed as MCP tools** + +--- + ## Why trust the results? Honesty as architecture This bridge's tools **cannot silently lie** — that's enforced, not promised: @@ -183,7 +221,7 @@ This bridge's tools **cannot silently lie** — that's enforced, not promised: - **Exec-simulated codegen tests.** Every generated editor script is compiled and executed against a strict fake `unreal` module in CI. Phantom APIs raise. Dropped arguments fail a sentinel gate. Hard-coded success prints fail honesty contracts. - **Read-back verification.** Writes that UE can silently ignore (cloner layout names) are read back before being reported "applied". - **Honest statuses.** The viewport fallback reports `capture_status: timeout` instead of an empty image with `success: true`. Not-implemented tools say "not implemented". -- **580 tests**, including scripted-failure contracts for every historical lying-tool bug. +- **635 tests**, including scripted-failure contracts for every historical lying-tool bug. ```mermaid graph TB @@ -250,6 +288,7 @@ UnrealEngine_Bridge/ │ ├── ue_logging.py # Structured JSON logging │ └── tools/ # 14 modules · 56 tools · tiered registry in __init__.py ├── remote_control/ # UE5 HTTP bridge (circuit breaker, codegen, polling) +├── x3d_bridge/ # UE↔X3D round-trip harness (coords · grammar · validate · apply) ├── usd_bridge/ # USD file I/O package (parked, out of the ship path) ├── Plugins/ │ ├── UEBridge/ # Editor panel, file watcher (C++) @@ -258,7 +297,7 @@ UnrealEngine_Bridge/ │ ├── EPIC_MCP_MATRIX.md # Retirement contract-of-record (probe-grounded) │ └── epic_mcp/ # Raw probe captures of Epic's 830-tool surface ├── scripts/probe_epic_mcp.py # Re-probe Epic's surface per engine version -├── tests/ # 580 tests, incl. tests/exec_sim/ + tier gates +├── tests/ # 635 tests, incl. tests/exec_sim/ + tier gates ├── smoke_live.py # Live-editor smoke harness (on-demand) └── .mcp.json # Two-server config: this bridge + Epic's MCP ``` @@ -271,7 +310,7 @@ UnrealEngine_Bridge/ ```bash pip install -e ".[dev]" -python -m pytest -q # 580 tests +python -m pytest -q # 635 tests ``` **Lint** diff --git a/pyproject.toml b/pyproject.toml index 7b4e5d6..b9a8d1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ path = "ue_mcp/__version__.py" # Flat layout: the distribution name (ue-bridge) has no matching directory, so the # first-party packages must be named explicitly — newer hatchling no longer # auto-detects them, which broke `pip install -e .`. -packages = ["ue_mcp", "remote_control"] +packages = ["ue_mcp", "remote_control", "x3d_bridge"] [tool.pytest.ini_options] asyncio_mode = "strict" diff --git a/tests/test_x3d_bridge.py b/tests/test_x3d_bridge.py new file mode 100644 index 0000000..55ebc9e --- /dev/null +++ b/tests/test_x3d_bridge.py @@ -0,0 +1,488 @@ +""" +Golden tests for the UE <-> X3D thin-slice harness (x3d_bridge). + +The battery defends two guarantees and nothing else: + 1. Round trip is lossless on thin-slice fields: deserialize(serialize(x)) == x + 2. Malformed edits are rejected at validate() -- on paper, before the editor. + +Plus the coordinate crux is pinned (B orthonormal, det == -1 -- a real handedness +flip; position/scale/rotation each round-trip exactly), and the regressions found +by the adversarial verification pass are locked in (TestWorkflowRegressions). + +All checks are synchronous, so no @pytest.mark.asyncio (asyncio_mode = "strict"). +""" + +import math + +import pytest + +from x3d_bridge import ( + B_UE_TO_X3D, + CM_TO_M, + Actor, + ApplyOp, + AssignMaterial, + DeleteActor, + MockBridge, + Reparent, + SetTransform, + SpawnActor, + X3DGrammarError, + axis_images_of, + basis_from_axis_images, + deserialize, + determinant, + diff_actors, + emit_python, + is_orthonormal, + quat_close, + run_loop, + serialize, + to_preview_html, + ue_to_x3d_pos, + ue_to_x3d_rot, + ue_to_x3d_scale, + validate, + write_preview, + x3d_to_ue_pos, + x3d_to_ue_rot, + x3d_to_ue_scale, +) + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- +def _close(a, b, tol=1e-6): + return all(abs(x - y) <= tol for x, y in zip(a, b, strict=True)) + + +def _norm(q): + n = math.sqrt(sum(c * c for c in q)) + return tuple(c / n for c in q) + + +def _assert_actor_close(a, b): + assert a.guid == b.guid + assert a.mesh == b.mesh + assert a.material == b.material + assert a.mobility == b.mobility + assert a.folder == b.folder + assert a.parent == b.parent + assert _close(a.t, b.t), (a.t, b.t) + assert _close(a.s, b.s), (a.s, b.s) + assert quat_close(a.r, b.r), (a.r, b.r) + + +# =========================================================================== +# The coordinate crux +# =========================================================================== +class TestBasis: + def test_basis_is_orthonormal(self): + assert is_orthonormal(B_UE_TO_X3D) + + def test_basis_flips_handedness(self): + # det == -1 is the whole point: a reflection converts LH -> RH. + # The spec's template ((1,0,0),(0,0,1),(0,-1,0)) had det +1 and was wrong. + assert math.isclose(determinant(B_UE_TO_X3D), -1.0, abs_tol=1e-12) + + def test_axis_roles_preserved(self): + # UE right (+Y) -> X3D right (+X); up (+Z) -> up (+Y); forward (+X) -> -Z. + assert _close(ue_to_x3d_pos((0.0, 100.0, 0.0)), (1.0, 0.0, 0.0)) + assert _close(ue_to_x3d_pos((0.0, 0.0, 100.0)), (0.0, 1.0, 0.0)) + assert _close(ue_to_x3d_pos((100.0, 0.0, 0.0)), (0.0, 0.0, -1.0)) + + def test_cm_to_m(self): + assert CM_TO_M == 0.01 + + def test_position_round_trip(self): + for t in [(420.0, 0.0, 155.0), (-13.5, 7.25, 0.0), (1e5, -2e4, 3.3)]: + assert _close(x3d_to_ue_pos(ue_to_x3d_pos(t)), t) + + def test_calibration_reconstructs_basis(self): + # basis_from_axis_images rebuilds B from where UE unit axes land in X3D. + cols = axis_images_of(B_UE_TO_X3D) + rebuilt = basis_from_axis_images(cols[0], cols[1], cols[2]) + assert rebuilt == B_UE_TO_X3D + assert is_orthonormal(rebuilt) + assert math.isclose(determinant(rebuilt), -1.0, abs_tol=1e-12) + + +class TestRotation: + @pytest.mark.parametrize( + "q", + [ + (0.0, 0.0, 0.0, 1.0), # identity + _norm((0.0, 0.0, 0.7071, 0.7071)), # 90 deg about UE Z + (1.0, 0.0, 0.0, 0.0), # 180 deg about UE X + (0.0, 1.0, 0.0, 0.0), # 180 deg about UE Y + _norm((0.3, -0.6, 0.2, 0.9)), # arbitrary + _norm((-0.5, 0.5, -0.5, 0.5)), # 120 deg tri-axis + ], + ) + def test_rotation_round_trip(self, q): + axis, angle = ue_to_x3d_rot(q) + back = x3d_to_ue_rot(axis, angle) + assert quat_close(q, back), (q, back) + + def test_rotation_leaves_x3d_axis_angle_wellformed(self): + axis, angle = ue_to_x3d_rot(_norm((0.3, -0.6, 0.2, 0.9))) + assert math.isclose(sum(c * c for c in axis), 1.0, abs_tol=1e-9) + assert 0.0 <= angle <= math.pi + 1e-9 + + +# =========================================================================== +# Grammar round trip -- the invariant +# =========================================================================== +class TestRoundTrip: + def test_single_actor_pos_scale(self): + a = Actor(guid="A1", mesh="/Game/M/SM_Rock", t=(420.0, 0.0, 155.0), s=(2.0, 1.0, 1.0)) + b = deserialize(serialize([a]))[0] + _assert_actor_close(a, b) + + def test_full_transform_multiple_actors(self): + actors = [ + Actor( + guid="Actor_7F3A", + mesh="/Game/Meshes/SM_Rock_01", + material="/Game/Mat/M_Granite", + mobility="Static", + folder="Environment/Rocks", + t=(0.0, 0.0, 0.0), + r=_norm((0.0, 0.0, 0.3826, 0.9238)), # 45 deg yaw + s=(1.0, 1.0, 1.0), + ), + Actor( + guid="Actor_91C2", + mesh="/Game/Meshes/SM_Rock_01", + material="/Game/Mat/M_Granite", + t=(420.0, 155.0, -30.0), + r=_norm((0.3, -0.6, 0.2, 0.9)), + s=(3.0, 0.5, 2.0), + ), + ] + out = deserialize(serialize(actors)) + assert len(out) == 2 + for a, b in zip(actors, out, strict=True): + _assert_actor_close(a, b) + + def test_metadata_round_trip(self): + a = Actor( + guid="A1", + mesh="/Game/M", + mobility="Movable", + folder="Set/Props", + parent="Actor_Root", + t=(1.0, 2.0, 3.0), + ) + b = deserialize(serialize([a]))[0] + _assert_actor_close(a, b) + + def test_escaping_round_trip(self): + # Asset paths are safe, but the escaper must survive XML metacharacters. + a = Actor(guid="A<1>&", mesh='/Game/"odd"/M&N', folder="ac") + b = deserialize(serialize([a]))[0] + assert b.guid == "A<1>&" + assert b.mesh == '/Game/"odd"/M&N' + assert b.folder == "ac" + + def test_material_def_use_dedup(self): + actors = [ + Actor(guid="A1", material="/Game/Mat/M_Granite"), + Actor(guid="A2", material="/Game/Mat/M_Granite"), + Actor(guid="A3", material="/Game/Mat/M_Steel"), + ] + x3d = serialize(actors) + assert x3d.count('DEF="Mat_0"') == 1 + assert x3d.count('USE="Mat_0"') == 1 # A2 reuses A1's material + assert x3d.count('DEF="Mat_1"') == 1 # A3 is a distinct material + out = deserialize(x3d) + assert out[0].material == "/Game/Mat/M_Granite" + assert out[1].material == "/Game/Mat/M_Granite" + assert out[2].material == "/Game/Mat/M_Steel" + + def test_validate_accepts_serialized_output(self): + actors = [Actor(guid="A1", mesh="/Game/M", material="/Game/Mat/M", t=(5.0, 6.0, 7.0))] + ok, errors = validate(serialize(actors)) + assert ok, errors + + +# =========================================================================== +# The validation boundary -- bad edits die on paper +# =========================================================================== +class TestValidateBoundary: + def test_out_of_grammar_rejected(self): + ok, errors = validate("") + assert not ok + assert any("out-of-grammar" in e for e in errors) + + def test_dangling_use_rejected(self): + ok, errors = validate('') + assert not ok + assert any("dangling USE" in e for e in errors) + + def test_resolvable_use_accepted(self): + ok, errors = validate( + '' + ) + assert ok, errors + + def test_nan_translation_rejected(self): + ok, errors = validate('') + assert not ok + assert any("non-finite" in e or "non-numeric" in e for e in errors) + + def test_inf_scale_rejected(self): + ok, errors = validate('') + assert not ok + + def test_malformed_xml_rejected(self): + ok, errors = validate("") + assert not ok + assert any("malformed" in e for e in errors) + + def test_deserialize_raises_on_unknown_node(self): + with pytest.raises(X3DGrammarError): + deserialize("") + + def test_deserialize_raises_on_bad_root(self): + with pytest.raises(X3DGrammarError): + deserialize("") + + +# =========================================================================== +# Apply seam -- assert the op sequence without a live editor +# =========================================================================== +class TestApplyDiff: + def test_move_emits_one_set_transform(self): + before = [Actor(guid="A1", t=(0.0, 0.0, 0.0))] + after = [Actor(guid="A1", t=(100.0, 0.0, 0.0))] + ops = diff_actors(before, after) + assert len(ops) == 1 + assert isinstance(ops[0], SetTransform) + assert ops[0].guid == "A1" + assert _close(ops[0].t, (100.0, 0.0, 0.0)) + + def test_unchanged_emits_nothing(self): + actors = [Actor(guid="A1", t=(1.0, 2.0, 3.0), r=_norm((0.1, 0.2, 0.3, 0.9)))] + # round-trip float noise must NOT produce a spurious op + rt = deserialize(serialize(actors)) + assert diff_actors(actors, rt) == [] + + def test_spawn_and_delete(self): + before = [Actor(guid="A1")] + after = [Actor(guid="A1"), Actor(guid="A2", mesh="/Game/M")] + ops = diff_actors(before, after) + assert [type(o) for o in ops] == [SpawnActor] + assert ops[0].guid == "A2" + + ops2 = diff_actors(after, before) + assert [type(o) for o in ops2] == [DeleteActor] + assert ops2[0].guid == "A2" + + def test_material_change_emits_assign(self): + before = [Actor(guid="A1", material=None)] + after = [Actor(guid="A1", material="/Game/Mat/M")] + ops = diff_actors(before, after) + assert any(isinstance(o, AssignMaterial) and o.material == "/Game/Mat/M" for o in ops) + + def test_reparent_emits_op(self): + before = [Actor(guid="A1", parent=None)] + after = [Actor(guid="A1", parent="Root")] + ops = diff_actors(before, after) + assert any(isinstance(o, Reparent) and o.parent == "Root" for o in ops) + + def test_emit_python_shapes(self): + # Assert the op's DATA reaches the snippet, not just the method name -- + # a presence-only check would pass with hardcoded/dropped arguments. + st = emit_python(SetTransform(guid="/L/A1", t=(1.0, 2.0, 3.0), s=(4.0, 5.0, 6.0))) + assert '"/L/A1"' in st + assert "set_actor_location(unreal.Vector(1.0, 2.0, 3.0)" in st + assert "set_actor_scale3d(unreal.Vector(4.0, 5.0, 6.0)" in st + assert "unreal.Quat(" in st + + sp = emit_python(SpawnActor(guid="A1", mesh="/Game/M")) + assert "spawn_actor_from_class" in sp and 'set_actor_label("A1")' in sp and '"/Game/M"' in sp + + de = emit_python(DeleteActor(guid="A1")) + assert "destroy_actor" in de and '"A1"' in de + + am = emit_python(AssignMaterial(guid="A1", material="/Game/M")) + assert "set_material" in am and '"/Game/M"' in am + + rp = emit_python(Reparent(guid="A1", parent="Root")) + assert "attach_to_actor" in rp and '"Root"' in rp + + +# =========================================================================== +# The full loop +# =========================================================================== +class TestLoop: + def test_identity_edit_applies_nothing(self): + actors = [Actor(guid="A1", mesh="/Game/M", t=(10.0, 20.0, 30.0))] + bridge = MockBridge() + result = run_loop(lambda: actors, bridge=bridge) + assert result.ok + assert result.ops == [] + assert bridge.ops == [] + + def test_invalid_edit_blocks_apply(self): + actors = [Actor(guid="A1")] + bridge = MockBridge() + + def bad_edit(_x3d): + return "" + + result = run_loop(lambda: actors, edit_fn=bad_edit, bridge=bridge) + assert not result.ok + assert result.ops == [] + assert bridge.ops == [] # the boundary held -- nothing reached the editor + + def test_valid_edit_reaches_bridge(self): + actors = [Actor(guid="A1", t=(0.0, 0.0, 0.0))] + bridge = MockBridge() + + def move_edit(x3d): + # UE (0,0,0) serializes to X3D translation "0.0 0.0 0.0"; move it in X3D. + return x3d.replace('translation="0.0 0.0 0.0"', 'translation="0.0 0.0 -1.0"') + + result = run_loop(lambda: actors, edit_fn=move_edit, bridge=bridge) + assert result.ok + assert len(bridge.ops) == 1 + assert isinstance(bridge.ops[0], SetTransform) + # X3D (0,0,-1) m -> UE (+100, 0, 0) cm (forward) + assert _close(bridge.ops[0].t, (100.0, 0.0, 0.0), tol=1e-3) + + +# =========================================================================== +# Regressions surfaced by the adversarial verification workflow +# =========================================================================== +class TestWorkflowRegressions: + def test_validate_rejects_short_translation(self): + ok, errors = validate('') + assert not ok + assert any("translation" in e and "numbers" in e for e in errors), errors + + def test_validate_rejects_short_rotation(self): + ok, errors = validate('') + assert not ok + assert any("rotation" in e and "numbers" in e for e in errors), errors + + def test_validate_rejects_non_x3d_root(self): + ok, errors = validate('') + assert not ok + assert any("expected 'X3D'" in e for e in errors), errors + + def test_loop_blocks_wrong_arity_edit(self): + bridge = MockBridge() + + def bad_arity(x3d): + return x3d.replace('scale="1.0 1.0 1.0"', 'scale="1.0 1.0"') + + result = run_loop(lambda: [Actor(guid="A1", t=(1.0, 2.0, 3.0))], edit_fn=bad_arity, bridge=bridge) + assert not result.ok + assert bridge.ops == [] + + def test_spawn_with_material_emits_assign(self): + ops = diff_actors([], [Actor(guid="A2", mesh="/Game/M", material="/Game/Mat/M")]) + assert [type(o) for o in ops] == [SpawnActor, AssignMaterial] + assert ops[1].material == "/Game/Mat/M" + + def test_spawn_with_parent_emits_reparent(self): + ops = diff_actors([], [Actor(guid="A2", mesh="/Game/M", parent="Root")]) + assert [type(o) for o in ops] == [SpawnActor, Reparent] + assert ops[1].parent == "Root" + + def test_spawn_with_material_and_parent(self): + ops = diff_actors([], [Actor(guid="A2", material="/Game/Mat/M", parent="Root")]) + assert [type(o) for o in ops] == [SpawnActor, AssignMaterial, Reparent] + + def test_empty_optional_coerced_to_none(self): + a = Actor(guid="A1", folder="", material="", parent="") + assert a.folder is None and a.material is None and a.parent is None + _assert_actor_close(a, deserialize(serialize([a]))[0]) + + def test_control_whitespace_round_trip(self): + a = Actor(guid="A1", mesh="/Game/a\tb\nc", folder="x\ny") + b = deserialize(serialize([a]))[0] + assert b.mesh == "/Game/a\tb\nc" + assert b.folder == "x\ny" + + def test_preview_escapes_title_leaves_x3d_raw(self): + out = to_preview_html(serialize([Actor(guid="A1")]), title="") + assert "" not in out + assert "<script>" in out + assert 'DEF="A1"' in out # x3d payload still raw + + +# =========================================================================== +# Audit hardening -- close false-green gaps found by the test-suite audit +# =========================================================================== +class TestAuditHardening: + def test_rotation_forward_known_axis_angle(self): + # Round-trip tests stay green even under an IDENTITY conjugation, so pin + # the forward map to hand-computed values -- a missing/wrong B*R*B^T dies. + # 90deg yaw about UE +Z -> 90deg about X3D -Y (handedness reverses sense). + axis, angle = ue_to_x3d_rot(_norm((0.0, 0.0, 0.7071, 0.7071))) + assert math.isclose(angle, math.pi / 2, abs_tol=1e-5), angle + assert _close(axis, (0.0, -1.0, 0.0)), axis + # 180deg about UE +X -> 180deg about the X3D Z axis (+Z ~ -Z at 180deg). + axis2, angle2 = ue_to_x3d_rot((1.0, 0.0, 0.0, 0.0)) + assert math.isclose(angle2, math.pi, abs_tol=1e-6), angle2 + assert _close(axis2, (0.0, 0.0, 1.0)) or _close(axis2, (0.0, 0.0, -1.0)), axis2 + + def test_scale_permutes_axes_not_identity(self): + # Round trips are blind to a self-inverse identity bug; pin the permutation. + assert ue_to_x3d_scale((3.0, 0.5, 2.0)) == (0.5, 2.0, 3.0) + assert x3d_to_ue_scale((0.5, 2.0, 3.0)) == (3.0, 0.5, 2.0) + + def test_emit_python_reparent_detach(self): + out = emit_python(Reparent(guid="A1", parent=None)) + assert "detach_from_actor" in out + assert "attach_to_actor" not in out + + def test_emit_python_rejects_unknown_op(self): + with pytest.raises(TypeError, match="unknown op"): + emit_python(ApplyOp(guid="A1")) + + def test_write_preview_writes_file(self, tmp_path): + x3d = serialize([Actor(guid="A1", mesh="/Game/M")]) + dest = tmp_path / "view.html" + returned = write_preview(x3d, dest, title="T") + assert returned == dest + written = dest.read_text(encoding="utf-8") + assert written == to_preview_html(x3d, title="T") + assert 'DEF="A1"' in written + + def test_run_loop_survives_deserialize_gap(self, monkeypatch): + # Fault-inject the defensive path: force validate to pass, then feed a + # document deserialize rejects. run_loop must return ok=False, apply none. + monkeypatch.setattr("x3d_bridge.loop.validate", lambda _x: (True, [])) + bridge = MockBridge() + result = run_loop( + lambda: [Actor(guid="A1")], edit_fn=lambda _x: "", bridge=bridge + ) + assert not result.ok + assert result.errors + assert bridge.ops == [] + + def test_loop_result_captures_both_documents(self): + actors = [Actor(guid="A1", t=(0.0, 0.0, 0.0))] + result = run_loop(lambda: actors) + assert result.x3d_before == serialize(actors) + assert result.x3d_after == result.x3d_before # identity edit + + def test_material_removal_emits_no_op(self): + before = [Actor(guid="A1", material="/Game/Mat/M")] + after = [Actor(guid="A1", material=None)] + assert diff_actors(before, after) == [] # documented: no un-assign op + + +# =========================================================================== +# Preview (Mile 6) -- smoke +# =========================================================================== +def test_preview_embeds_x3d(): + x3d = serialize([Actor(guid="A1", mesh="/Game/M")]) + html = to_preview_html(x3d) + assert "" in html + assert 'DEF="A1"' in html diff --git a/x3d_bridge/__init__.py b/x3d_bridge/__init__.py new file mode 100644 index 0000000..0d3e5ca --- /dev/null +++ b/x3d_bridge/__init__.py @@ -0,0 +1,117 @@ +""" +x3d_bridge + +A UE <-> X3D thin-slice harness. Serializes Unreal Engine assembly state (actor +placement, identity, asset references) to a closed X3D grammar and back, with a +lossless round-trip as the single defended invariant: + + deserialize(serialize(level)) == level + +and a validation boundary where malformed edits are rejected on paper, before +the live editor is touched. The coordinate crux (UE Z-up LH cm <-> X3D Y-up RH m) +is an orthonormal change of basis, so the round trip is exact by construction; +matching UE's exact external convention is a separate calibration step. + +Direct UE <-> X3D -- independent of the USD messaging channel. + +Public API is re-exported flat below (house style). +""" + +# ruff: noqa: I001 -- the grouped facade (banner-per-module) is intentional. + +# --- coordinates: the crux (basis change, quat/axis-angle, calibration) --- +from .coordinates import ( + B_UE_TO_X3D, + CM_TO_M, + ue_to_x3d_pos, + x3d_to_ue_pos, + ue_to_x3d_scale, + x3d_to_ue_scale, + ue_to_x3d_rot, + x3d_to_ue_rot, + quat_to_matrix, + matrix_to_quat, + axis_angle_to_matrix, + matrix_to_axis_angle, + is_orthonormal, + determinant, + quat_close, + basis_from_axis_images, + axis_images_of, +) + +# --- grammar: the closed vocabulary + serialize/deserialize --- +from .grammar import ( + Actor, + GRAMMAR, + X3DGrammarError, + serialize, + deserialize, +) + +# --- validate: the paper boundary --- +from .validate import validate + +# --- loop: the five-stage headless loop + apply seam --- +from .loop import ( + ApplyOp, + SpawnActor, + DeleteActor, + SetTransform, + AssignMaterial, + Reparent, + diff_actors, + emit_python, + Bridge, + MockBridge, + LoopResult, + run_loop, +) + +# --- preview: the free X_ITE browser view (Mile 6) --- +from .preview import to_preview_html, write_preview + +__all__ = [ + # coordinates + "B_UE_TO_X3D", + "CM_TO_M", + "ue_to_x3d_pos", + "x3d_to_ue_pos", + "ue_to_x3d_scale", + "x3d_to_ue_scale", + "ue_to_x3d_rot", + "x3d_to_ue_rot", + "quat_to_matrix", + "matrix_to_quat", + "axis_angle_to_matrix", + "matrix_to_axis_angle", + "is_orthonormal", + "determinant", + "quat_close", + "basis_from_axis_images", + "axis_images_of", + # grammar + "Actor", + "GRAMMAR", + "X3DGrammarError", + "serialize", + "deserialize", + # validate + "validate", + # loop + "ApplyOp", + "SpawnActor", + "DeleteActor", + "SetTransform", + "AssignMaterial", + "Reparent", + "diff_actors", + "emit_python", + "Bridge", + "MockBridge", + "LoopResult", + "run_loop", + # preview + "to_preview_html", + "write_preview", +] diff --git a/x3d_bridge/coordinates.py b/x3d_bridge/coordinates.py new file mode 100644 index 0000000..4925c92 --- /dev/null +++ b/x3d_bridge/coordinates.py @@ -0,0 +1,282 @@ +""" +coordinates.py + +The coordinate crux for the UE <-> X3D thin slice. + +Unreal Engine is Z-up, LEFT-handed, centimetres. +X3D (like glTF / OpenGL) is Y-up, RIGHT-handed, metres. + +Every transform crosses that gap twice per round trip. The change of basis is +expressed as a single ORTHONORMAL matrix B (UE -> X3D). Because B is orthonormal, +B^-1 == B^T, so the round trip is EXACT for *any* correct B -- correctness is +structural. Picking the *right* B (so external glTF/X3D tools agree with UE) is a +separate empirical calibration step (see `basis_from_axis_images`). + +Provides: +- B_UE_TO_X3D, CM_TO_M: the locked change-of-basis and unit scale +- ue_to_x3d_pos / x3d_to_ue_pos: position conversion (cm <-> m + basis) +- ue_to_x3d_scale / x3d_to_ue_scale: scale conversion (axis permutation) +- ue_to_x3d_rot / x3d_to_ue_rot: rotation, UE quat <-> X3D axis-angle +- quat_to_matrix / matrix_to_quat / axis_angle_to_matrix / matrix_to_axis_angle +- is_orthonormal / determinant / basis_from_axis_images: calibration + guards +- quat_close: rotation-aware equality (handles quaternion double-cover) +""" + +import math + +Vec3 = tuple[float, float, float] +Quat = tuple[float, float, float, float] # (x, y, z, w), UE convention +Mat3 = tuple[tuple[float, float, float], ...] +AxisAngle = tuple[Vec3, float] # (axis, angle-in-radians) + +# Numerical tolerance for degeneracy branches (identity / 180-degree rotations). +_EPS = 1e-9 + +# =========================================================================== +# The locked change of basis (UE Z-up LH cm -> X3D Y-up RH m) +# =========================================================================== +# Derived by role preservation: UE.right(Y)->X3D.right(X), UE.up(Z)->X3D.up(Y), +# UE.forward(X)->X3D.forward(-Z, since +Z is "toward viewer"/back in X3D). +# Columns of B are the images of UE's basis vectors in X3D space, so B maps a +# UE column vector to an X3D column vector: x3d = B @ ue. +# +# X3D.x = UE.y +# X3D.y = UE.z +# X3D.z = -UE.x +# +# det(B) = -1 -> a reflection, which is exactly what flips LH -> RH. +# (The often-quoted "template" ((1,0,0),(0,0,1),(0,-1,0)) has det +1 -- a pure +# rotation -- and therefore CANNOT convert handedness. That was the bug.) +CM_TO_M: float = 0.01 + +B_UE_TO_X3D: Mat3 = ( + (0.0, 1.0, 0.0), + (0.0, 0.0, 1.0), + (-1.0, 0.0, 0.0), +) + + +# =========================================================================== +# 3x3 linear algebra (pure Python, no numpy dependency) +# =========================================================================== +def _mat_vec(m: Mat3, v: Vec3) -> Vec3: + return tuple(sum(m[i][k] * v[k] for k in range(3)) for i in range(3)) # type: ignore[return-value] + + +def _mat_mul(a: Mat3, b: Mat3) -> Mat3: + return tuple( + tuple(sum(a[i][k] * b[k][j] for k in range(3)) for j in range(3)) + for i in range(3) + ) # type: ignore[return-value] + + +def _transpose(m: Mat3) -> Mat3: + return tuple(tuple(m[k][i] for k in range(3)) for i in range(3)) # type: ignore[return-value] + + +def determinant(m: Mat3) -> float: + """Determinant of a 3x3 matrix. det == -1 confirms a handedness flip.""" + return ( + m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) + - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]) + ) + + +def is_orthonormal(m: Mat3, tol: float = 1e-9) -> bool: + """True iff M^T M == I -- the precondition for a lossless round trip.""" + prod = _mat_mul(_transpose(m), m) + for i in range(3): + for j in range(3): + expected = 1.0 if i == j else 0.0 + if abs(prod[i][j] - expected) > tol: + return False + return True + + +# =========================================================================== +# Position (cm <-> m, plus the basis change) +# =========================================================================== +def ue_to_x3d_pos(t_cm: Vec3) -> Vec3: + """UE location (cm) -> X3D translation (m).""" + return tuple(c * CM_TO_M for c in _mat_vec(B_UE_TO_X3D, t_cm)) # type: ignore[return-value] + + +def x3d_to_ue_pos(p_m: Vec3) -> Vec3: + """X3D translation (m) -> UE location (cm). Uses B^T (== B^-1).""" + ue = _mat_vec(_transpose(B_UE_TO_X3D), p_m) + return tuple(c / CM_TO_M for c in ue) # type: ignore[return-value] + + +# =========================================================================== +# Scale (a signed-permutation basis permutes scale axes; sign is irrelevant to +# a magnitude, so we permute by |B|. Derived from B so it tracks calibration.) +# =========================================================================== +def _abs_mat(m: Mat3) -> Mat3: + return tuple(tuple(abs(x) for x in row) for row in m) # type: ignore[return-value] + + +def ue_to_x3d_scale(s: Vec3) -> Vec3: + """UE scale multiplier -> X3D scale (axis permutation only).""" + return _mat_vec(_abs_mat(B_UE_TO_X3D), s) + + +def x3d_to_ue_scale(s: Vec3) -> Vec3: + """X3D scale -> UE scale (inverse permutation).""" + return _mat_vec(_transpose(_abs_mat(B_UE_TO_X3D)), s) + + +# =========================================================================== +# Rotation quaternion <-> matrix <-> axis-angle +# =========================================================================== +def quat_to_matrix(q: Quat) -> Mat3: + """Unit quaternion (x, y, z, w) -> 3x3 rotation matrix.""" + x, y, z, w = q + n = math.sqrt(x * x + y * y + z * z + w * w) + if n < _EPS: + return ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)) + x, y, z, w = x / n, y / n, z / n, w / n + xx, yy, zz = x * x, y * y, z * z + xy, xz, yz = x * y, x * z, y * z + wx, wy, wz = w * x, w * y, w * z + return ( + (1 - 2 * (yy + zz), 2 * (xy - wz), 2 * (xz + wy)), + (2 * (xy + wz), 1 - 2 * (xx + zz), 2 * (yz - wx)), + (2 * (xz - wy), 2 * (yz + wx), 1 - 2 * (xx + yy)), + ) + + +def matrix_to_quat(m: Mat3) -> Quat: + """3x3 rotation matrix -> unit quaternion (x, y, z, w). Numerically stable.""" + trace = m[0][0] + m[1][1] + m[2][2] + if trace > 0.0: + s = math.sqrt(trace + 1.0) * 2.0 # s = 4w + w = 0.25 * s + x = (m[2][1] - m[1][2]) / s + y = (m[0][2] - m[2][0]) / s + z = (m[1][0] - m[0][1]) / s + elif m[0][0] > m[1][1] and m[0][0] > m[2][2]: + s = math.sqrt(1.0 + m[0][0] - m[1][1] - m[2][2]) * 2.0 # s = 4x + w = (m[2][1] - m[1][2]) / s + x = 0.25 * s + y = (m[0][1] + m[1][0]) / s + z = (m[0][2] + m[2][0]) / s + elif m[1][1] > m[2][2]: + s = math.sqrt(1.0 + m[1][1] - m[0][0] - m[2][2]) * 2.0 # s = 4y + w = (m[0][2] - m[2][0]) / s + x = (m[0][1] + m[1][0]) / s + y = 0.25 * s + z = (m[1][2] + m[2][1]) / s + else: + s = math.sqrt(1.0 + m[2][2] - m[0][0] - m[1][1]) * 2.0 # s = 4z + w = (m[1][0] - m[0][1]) / s + x = (m[0][2] + m[2][0]) / s + y = (m[1][2] + m[2][1]) / s + z = 0.25 * s + n = math.sqrt(x * x + y * y + z * z + w * w) + return (x / n, y / n, z / n, w / n) + + +def axis_angle_to_matrix(axis: Vec3, angle: float) -> Mat3: + """X3D axis-angle (radians) -> 3x3 rotation matrix (Rodrigues).""" + x, y, z = axis + n = math.sqrt(x * x + y * y + z * z) + if n < _EPS: + return ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)) + x, y, z = x / n, y / n, z / n + c, s = math.cos(angle), math.sin(angle) + cc = 1.0 - c + return ( + (c + x * x * cc, x * y * cc - z * s, x * z * cc + y * s), + (y * x * cc + z * s, c + y * y * cc, y * z * cc - x * s), + (z * x * cc - y * s, z * y * cc + x * s, c + z * z * cc), + ) + + +def matrix_to_axis_angle(m: Mat3) -> AxisAngle: + """3x3 rotation matrix -> X3D axis-angle (unit axis, radians).""" + trace = m[0][0] + m[1][1] + m[2][2] + cos_a = max(-1.0, min(1.0, (trace - 1.0) / 2.0)) + angle = math.acos(cos_a) + + if angle < _EPS: + # Identity -- X3D's canonical "no rotation" is axis (0,0,1), angle 0. + return ((0.0, 0.0, 1.0), 0.0) + + if math.pi - angle < _EPS: + # 180 degrees: (R - I) is singular, recover axis from the diagonal. + xx = (m[0][0] + 1.0) / 2.0 + yy = (m[1][1] + 1.0) / 2.0 + zz = (m[2][2] + 1.0) / 2.0 + ax = math.sqrt(max(xx, 0.0)) + ay = math.sqrt(max(yy, 0.0)) + az = math.sqrt(max(zz, 0.0)) + xy = (m[0][1] + m[1][0]) / 4.0 + xz = (m[0][2] + m[2][0]) / 4.0 + yz = (m[1][2] + m[2][1]) / 4.0 + if ax >= ay and ax >= az: + ay = ay if xy >= 0 else -ay + az = az if xz >= 0 else -az + elif ay >= az: + ax = ax if xy >= 0 else -ax + az = az if yz >= 0 else -az + else: + ax = ax if xz >= 0 else -ax + ay = ay if yz >= 0 else -ay + n = math.sqrt(ax * ax + ay * ay + az * az) + return ((ax / n, ay / n, az / n), math.pi) + + rx = m[2][1] - m[1][2] + ry = m[0][2] - m[2][0] + rz = m[1][0] - m[0][1] + s = math.sqrt(rx * rx + ry * ry + rz * rz) + return ((rx / s, ry / s, rz / s), angle) + + +def ue_to_x3d_rot(q_ue: Quat) -> AxisAngle: + """UE rotation quaternion -> X3D axis-angle, via R' = B R B^T.""" + r_ue = quat_to_matrix(q_ue) + r_x3d = _mat_mul(_mat_mul(B_UE_TO_X3D, r_ue), _transpose(B_UE_TO_X3D)) + return matrix_to_axis_angle(r_x3d) + + +def x3d_to_ue_rot(axis: Vec3, angle: float) -> Quat: + """X3D axis-angle -> UE rotation quaternion, via R = B^T R' B.""" + r_x3d = axis_angle_to_matrix(axis, angle) + bt = _transpose(B_UE_TO_X3D) + r_ue = _mat_mul(_mat_mul(bt, r_x3d), B_UE_TO_X3D) + return matrix_to_quat(r_ue) + + +def quat_close(a: Quat, b: Quat, tol: float = 1e-6) -> bool: + """ + Rotation-aware quaternion comparison. q and -q are the SAME rotation + (double cover), so compare |dot| ~ 1 rather than component equality. + """ + dot = sum(x * y for x, y in zip(a, b, strict=True)) + return abs(abs(dot) - 1.0) < tol + + +# =========================================================================== +# Calibration -- Mile 1 made operational +# =========================================================================== +def basis_from_axis_images(x_image: Vec3, y_image: Vec3, z_image: Vec3) -> Mat3: + """ + Build B from a live calibration: export ONE known actor via UE's glTF + exporter (or read it back from a trusted X3D pipeline), observe where UE's + +X, +Y, +Z unit axes land in X3D space, and pass those three images here. + + B's columns ARE the axis images, so B = [x_image | y_image | z_image]. + The result is returned as-is; assert `is_orthonormal(B)` and + `determinant(B) == -1` before locking it in place of B_UE_TO_X3D. + """ + return ( + (x_image[0], y_image[0], z_image[0]), + (x_image[1], y_image[1], z_image[1]), + (x_image[2], y_image[2], z_image[2]), + ) + + +def axis_images_of(m: Mat3) -> list[Vec3]: + """Inverse of `basis_from_axis_images`: the columns of B (for inspection).""" + return [tuple(m[i][j] for i in range(3)) for j in range(3)] # type: ignore[misc] diff --git a/x3d_bridge/grammar.py b/x3d_bridge/grammar.py new file mode 100644 index 0000000..5f7c1a0 --- /dev/null +++ b/x3d_bridge/grammar.py @@ -0,0 +1,282 @@ +""" +grammar.py + +The closed X3D grammar for UE assembly state, and the serialize/deserialize +pair that round-trips a level through it. + +The whole point of a fixed vocabulary is that you can hand a model *all of it* +and validate against it. This module owns the node set (`GRAMMAR`), the `Actor` +record, and the two pure functions the golden test pins: + + deserialize(serialize(actors)) == actors (lossless on thin-slice fields) + +UE specifics that X3D has no node for (mesh path, mobility, folder, attach +parent) ride in `MetadataSet` / `MetadataString`, keeping the document valid X3D. +Placement is FLAT (every Transform is a direct child of Scene) and world-space: +nesting would make transforms relative, which fights the lossless invariant. +Attach hierarchy is carried as `ue:parent` metadata and realised as a reparent +op at apply time (see loop.py), not as document nesting. + +Provides: +- Actor: the thin-slice actor record (UE-native units) +- GRAMMAR: the closed set of allowed X3D tag names +- X3DGrammarError: raised by deserialize on an out-of-grammar node +- serialize(actors) -> x3d string +- deserialize(x3d string) -> list[Actor] +""" + +import xml.etree.ElementTree as ET +from dataclasses import dataclass + +from . import coordinates as coords + +Vec3 = tuple[float, float, float] +Quat = tuple[float, float, float, float] + +# The closed grammar. Anything outside this set is rejected -- on paper by +# validate(), and hard by deserialize(). +GRAMMAR = { + "X3D", + "Scene", + "Group", + "Transform", + "Shape", + "Appearance", + "Material", + "MetadataSet", + "MetadataString", + "MetadataFloat", +} + +_X3D_HEADER = '\n \n' +_X3D_FOOTER = ' \n\n' + + +class X3DGrammarError(ValueError): + """An X3D document contained a node outside the closed grammar.""" + + +@dataclass +class Actor: + """ + One placed actor, in UE-native units. + + guid -> X3D Transform DEF (stable identity the model addresses) + t -> UE location, centimetres + r -> UE rotation quaternion (x, y, z, w) + s -> UE scale multiplier + mesh / material / mobility / folder / parent -> carried as ue: metadata + (mesh & material are references to UE assets, never embedded geometry). + """ + + guid: str + mesh: str = "" + material: str | None = None + mobility: str | None = None + folder: str | None = None + parent: str | None = None + t: Vec3 = (0.0, 0.0, 0.0) + r: Quat = (0.0, 0.0, 0.0, 1.0) + s: Vec3 = (1.0, 1.0, 1.0) + + def __post_init__(self) -> None: + # An empty optional string means "unset". Normalize to None so the round + # trip is symmetric: serialize omits unset metadata, deserialize returns + # None -- and diff_actors sees no spurious change between "" and None. + for name in ("material", "mobility", "folder", "parent"): + if getattr(self, name) == "": + setattr(self, name, None) + + +# =========================================================================== +# helpers +# =========================================================================== +def _esc(s: str) -> str: + """Escape a string for use inside an XML attribute value. + + Tab/newline/CR are emitted as numeric character references: without them, + XML attribute-value normalization collapses literal control whitespace to a + single space on parse, breaking the round trip for such fields. + """ + return ( + s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + .replace("\t", " ") + .replace("\n", " ") + .replace("\r", " ") + ) + + +def _fmt(x: float) -> str: + """Shortest round-tripping decimal for a float (Python repr guarantees it).""" + return repr(float(x)) + + +def _fmt_vec(v: tuple[float, ...]) -> str: + return " ".join(_fmt(c) for c in v) + + +def _localname(tag: str) -> str: + """Strip any XML namespace: '{ns}Transform' -> 'Transform'.""" + return tag.rsplit("}", 1)[-1] + + +# =========================================================================== +# serialize: actors -> X3D +# =========================================================================== +def serialize(actors: list[Actor]) -> str: + """Serialize actors to an X3D document string (UE -> X3D basis applied).""" + material_defs: dict = {} # material path -> DEF name + parts: list[str] = [_X3D_HEADER] + + for a in actors: + tx = coords.ue_to_x3d_pos(a.t) + (ax, ay, az), angle = coords.ue_to_x3d_rot(a.r) + sc = coords.ue_to_x3d_scale(a.s) + + parts.append( + f' \n' + ) + + # ue: metadata (only fields that are set) + meta: list[tuple[str, str]] = [] + if a.mesh: + meta.append(("mesh", a.mesh)) + if a.mobility: + meta.append(("mobility", a.mobility)) + if a.folder: + meta.append(("folder", a.folder)) + if a.parent: + meta.append(("parent", a.parent)) + if meta: + parts.append(' \n') + for name, value in meta: + parts.append( + f' \n' + ) + parts.append(" \n") + + # material: DEF on first occurrence, USE on reuse (instancing / dedupe) + if a.material: + parts.append(" \n \n") + if a.material in material_defs: + parts.append(f' \n') + else: + def_name = f"Mat_{len(material_defs)}" + material_defs[a.material] = def_name + parts.append(f' \n') + parts.append( + f' \n' + ) + parts.append(" \n \n") + + parts.append(" \n") + + parts.append(_X3D_FOOTER) + return "".join(parts) + + +# =========================================================================== +# deserialize: X3D -> actors +# =========================================================================== +def _floats(text: str | None, n: int, default: tuple[float, ...]) -> tuple[float, ...]: + if not text: + return default + vals = tuple(float(v) for v in text.split()) + if len(vals) != n: + raise X3DGrammarError(f"expected {n} numbers, got {len(vals)!r}") + return vals + + +def deserialize(x3d: str) -> list[Actor]: + """ + Parse an X3D document back into actors (X3D -> UE basis applied). + + An out-of-grammar node raises X3DGrammarError -- deserialize assumes a + validated document (validate() is the non-raising boundary). Material USE + references resolve to the ue:material path recorded at the matching DEF. + """ + try: + root = ET.fromstring(x3d) + except ET.ParseError as exc: + raise X3DGrammarError(f"malformed XML: {exc}") from exc + + if _localname(root.tag) != "X3D": + raise X3DGrammarError(f"root is {_localname(root.tag)!r}, expected 'X3D'") + + # Hard grammar gate: every node must be in the closed set. + for el in root.iter(): + name = _localname(el.tag) + if name not in GRAMMAR: + raise X3DGrammarError(f"out-of-grammar node: {name!r}") + + material_paths: dict = {} # DEF name -> ue:material path + actors: list[Actor] = [] + + for tr in root.iter(): + if _localname(tr.tag) != "Transform": + continue + + tx = _floats(tr.get("translation"), 3, (0.0, 0.0, 0.0)) + rot = _floats(tr.get("rotation"), 4, (0.0, 0.0, 1.0, 0.0)) + sc = _floats(tr.get("scale"), 3, (1.0, 1.0, 1.0)) + + meta = {"mesh": "", "mobility": None, "folder": None, "parent": None} + material: str | None = None + + for child in tr: + cname = _localname(child.tag) + if cname == "MetadataSet": + for ms in child: + if _localname(ms.tag) == "MetadataString": + key = ms.get("name", "") + if key in meta: + meta[key] = ms.get("value", "") + elif cname == "Shape": + material = _read_material(child, material_paths) + + actors.append( + Actor( + guid=tr.get("DEF", ""), + mesh=meta["mesh"] or "", + material=material, + mobility=meta["mobility"], + folder=meta["folder"], + parent=meta["parent"], + t=coords.x3d_to_ue_pos(tx), # type: ignore[arg-type] + r=coords.x3d_to_ue_rot((rot[0], rot[1], rot[2]), rot[3]), + s=coords.x3d_to_ue_scale(sc), # type: ignore[arg-type] + ) + ) + + return actors + + +def _read_material(shape: ET.Element, material_paths: dict) -> str | None: + """Resolve a Shape's material path, honouring DEF/USE references.""" + for appearance in shape: + if _localname(appearance.tag) != "Appearance": + continue + def_path: str | None = None + material_el: ET.Element | None = None + for el in appearance: + name = _localname(el.tag) + if name == "Material": + material_el = el + elif name == "MetadataString" and el.get("name") == "ue:material": + def_path = el.get("value") + if material_el is None: + return None + use = material_el.get("USE") + if use is not None: + return material_paths.get(use) + deff = material_el.get("DEF") + if deff is not None and def_path is not None: + material_paths[deff] = def_path + return def_path + return None diff --git a/x3d_bridge/loop.py b/x3d_bridge/loop.py new file mode 100644 index 0000000..0417678 --- /dev/null +++ b/x3d_bridge/loop.py @@ -0,0 +1,268 @@ +""" +loop.py + +The five-stage bridge loop, made headless. Each red seam from the spec is a +plain callable you swap for a fixture (in CI) or the live bridge (in prod): + + Read seam: read_fn() -> list[Actor] (fixture dump / list_actors) + Serialize grammar.serialize (pure) + Edit seam: edit_fn(x3d) -> x3d (replay / model call) + Validate validate.validate (the boundary -- bad edits die here) + Apply seam: bridge.apply(ops) (mock / execute_python emitter) + +Apply is expressed as a diff of UE-frame actors into a typed op list, so tests +assert the op *sequence* without a live editor, and `emit_python` renders each +op into the exact `unreal.` call the real bridge runs via `ue_execute_python` +(the only mutation path mounted in the `core` MCP profile). + +Identifier note: UE's tools are inconsistent -- set_transform/delete key on the +object PATH, assign_material/duplicate on the LABEL. The ops below carry `guid` +as the actor identity; wire it to whichever identifier the target tool expects +when Mile 5 goes live. Reparent has no UE primitive; emit_python synthesises it +via attach_to_actor, mirroring mograph.py. + +Provides: +- ApplyOp and subclasses: SpawnActor, DeleteActor, SetTransform, AssignMaterial, Reparent +- diff_actors(before, after) -> list[ApplyOp] +- emit_python(op) -> str +- Bridge (protocol), MockBridge +- LoopResult, run_loop(...) +""" + +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Protocol + +from . import coordinates as coords +from .grammar import Actor, X3DGrammarError, deserialize, serialize +from .validate import validate + +Vec3 = tuple[float, float, float] +Quat = tuple[float, float, float, float] + +_POS_TOL = 1e-4 # cm -- below UE's practical placement precision +_SCALE_TOL = 1e-6 + + +# =========================================================================== +# apply ops +# =========================================================================== +@dataclass +class ApplyOp: + """Base for a single UE mutation. `guid` is the actor identity (DEF).""" + + guid: str + + +@dataclass +class SpawnActor(ApplyOp): + # Material and attach-parent are emitted as follow-up AssignMaterial / + # Reparent ops (see diff_actors), not folded into the spawn. + mesh: str = "" + t: Vec3 = (0.0, 0.0, 0.0) + r: Quat = (0.0, 0.0, 0.0, 1.0) + s: Vec3 = (1.0, 1.0, 1.0) + + +@dataclass +class DeleteActor(ApplyOp): + pass + + +@dataclass +class SetTransform(ApplyOp): + t: Vec3 = (0.0, 0.0, 0.0) + r: Quat = (0.0, 0.0, 0.0, 1.0) + s: Vec3 = (1.0, 1.0, 1.0) + + +@dataclass +class AssignMaterial(ApplyOp): + material: str = "" + slot_index: int = 0 + + +@dataclass +class Reparent(ApplyOp): + parent: str | None = None + + +# =========================================================================== +# diff: two UE-frame actor lists -> the ops that turn `before` into `after` +# =========================================================================== +def _vec_changed(a: tuple[float, ...], b: tuple[float, ...], tol: float) -> bool: + return any(abs(x - y) > tol for x, y in zip(a, b, strict=True)) + + +def _transform_changed(a: Actor, b: Actor) -> bool: + return ( + _vec_changed(a.t, b.t, _POS_TOL) + or _vec_changed(a.s, b.s, _SCALE_TOL) + or not coords.quat_close(a.r, b.r) + ) + + +def diff_actors(before: list[Actor], after: list[Actor]) -> list[ApplyOp]: + """Compute the ordered op list that mutates `before` into `after`, by guid.""" + before_by_id = {a.guid: a for a in before} + after_by_id = {a.guid: a for a in after} + ops: list[ApplyOp] = [] + + # spawns + changes, in `after` order (stable, reviewable) + for a in after: + prev = before_by_id.get(a.guid) + if prev is None: + ops.append(SpawnActor(guid=a.guid, mesh=a.mesh, t=a.t, r=a.r, s=a.s)) + if a.material: + ops.append(AssignMaterial(guid=a.guid, material=a.material)) + if a.parent: + ops.append(Reparent(guid=a.guid, parent=a.parent)) + continue + if _transform_changed(prev, a): + ops.append(SetTransform(guid=a.guid, t=a.t, r=a.r, s=a.s)) + # NOTE: clearing a material (X -> None) emits no op -- the thin-slice + # vocabulary has no un-assign primitive. A material *change* to another + # asset is expressed; a removal is a documented limitation. + if a.material != prev.material and a.material: + ops.append(AssignMaterial(guid=a.guid, material=a.material)) + if a.parent != prev.parent: + ops.append(Reparent(guid=a.guid, parent=a.parent)) + + # deletes, in `before` order + for a in before: + if a.guid not in after_by_id: + ops.append(DeleteActor(guid=a.guid)) + + return ops + + +# =========================================================================== +# emit_python: render one op into the UE-Python the real bridge would run +# =========================================================================== +def _v(v: Vec3) -> str: + return f"{v[0]}, {v[1]}, {v[2]}" + + +def emit_python(op: ApplyOp) -> str: + """Render an op into a `unreal.` snippet for ue_execute_python (Mile 5 seam).""" + if isinstance(op, SetTransform): + rx, ry, rz, rw = op.r + return ( + f'actor = unreal.load_object(None, "{op.guid}")\n' + f"if actor:\n" + f" actor.set_actor_location(unreal.Vector({_v(op.t)}), False, False)\n" + f" actor.set_actor_rotation(unreal.Quat({rx}, {ry}, {rz}, {rw}).rotator(), False)\n" + f" actor.set_actor_scale3d(unreal.Vector({_v(op.s)}))" + ) + if isinstance(op, SpawnActor): + rx, ry, rz, rw = op.r + return ( + "subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)\n" + f"actor = subsystem.spawn_actor_from_class(unreal.StaticMeshActor, " + f"unreal.Vector({_v(op.t)}), unreal.Quat({rx}, {ry}, {rz}, {rw}).rotator())\n" + "if actor:\n" + f' actor.set_actor_label("{op.guid}")\n' + f" actor.set_actor_scale3d(unreal.Vector({_v(op.s)}))\n" + f' mesh = unreal.load_asset("{op.mesh}")\n' + " if mesh:\n" + " actor.static_mesh_component.set_static_mesh(mesh)" + ) + if isinstance(op, DeleteActor): + return ( + "subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)\n" + f'actor = unreal.load_object(None, "{op.guid}")\n' + "if actor:\n" + " subsystem.destroy_actor(actor)" + ) + if isinstance(op, AssignMaterial): + return ( + f'actor = unreal.load_object(None, "{op.guid}")\n' + f'mat = unreal.EditorAssetLibrary.load_asset("{op.material}")\n' + "if actor and mat:\n" + " comp = actor.get_component_by_class(unreal.StaticMeshComponent)\n" + " if comp:\n" + f" comp.set_material({op.slot_index}, mat)" + ) + if isinstance(op, Reparent): + if not op.parent: + return ( + f'child = unreal.load_object(None, "{op.guid}")\n' + "if child:\n" + " child.detach_from_actor(unreal.DetachmentRule.KEEP_WORLD, " + "unreal.DetachmentRule.KEEP_WORLD, unreal.DetachmentRule.KEEP_WORLD)" + ) + return ( + f'child = unreal.load_object(None, "{op.guid}")\n' + f'parent = unreal.load_object(None, "{op.parent}")\n' + "if child and parent:\n" + " child.attach_to_actor(parent, \"\", unreal.AttachmentRule.KEEP_WORLD, " + "unreal.AttachmentRule.KEEP_WORLD, unreal.AttachmentRule.KEEP_WORLD, False)" + ) + raise TypeError(f"unknown op: {type(op).__name__}") + + +# =========================================================================== +# the bridge seam +# =========================================================================== +class Bridge(Protocol): + """The apply seam. Prod wires this to ue_execute_python; tests use MockBridge.""" + + def apply(self, ops: list[ApplyOp]) -> None: ... + + +@dataclass +class MockBridge: + """Records the op sequence instead of touching an editor.""" + + ops: list[ApplyOp] = field(default_factory=list) + + def apply(self, ops: list[ApplyOp]) -> None: + self.ops.extend(ops) + + +# =========================================================================== +# the loop +# =========================================================================== +@dataclass +class LoopResult: + ok: bool + errors: list[str] + x3d_before: str + x3d_after: str + ops: list[ApplyOp] + + +def _identity_edit(x3d: str) -> str: + return x3d + + +def run_loop( + read_fn: Callable[[], list[Actor]], + edit_fn: Callable[[str], str] = _identity_edit, + bridge: Bridge | None = None, +) -> LoopResult: + """ + Run Read -> Serialize -> Edit -> Validate -> Apply headlessly. + + Validation is the gate: if the edited document is invalid the loop returns + the errors and applies NOTHING. Only a valid edit reaches the bridge. + """ + before = read_fn() + x3d_before = serialize(before) + + x3d_after = edit_fn(x3d_before) + ok, errors = validate(x3d_after) + if not ok: + return LoopResult(False, errors, x3d_before, x3d_after, []) + + # Defense in depth: validate() is meant to catch everything deserialize() + # would reject, but guard anyway so a boundary gap can never crash apply. + try: + after = deserialize(x3d_after) + except X3DGrammarError as exc: + return LoopResult(False, [str(exc)], x3d_before, x3d_after, []) + ops = diff_actors(before, after) + if bridge is not None and ops: + bridge.apply(ops) + + return LoopResult(True, [], x3d_before, x3d_after, ops) diff --git a/x3d_bridge/preview.py b/x3d_bridge/preview.py new file mode 100644 index 0000000..88f1d81 --- /dev/null +++ b/x3d_bridge/preview.py @@ -0,0 +1,52 @@ +""" +preview.py + +Mile 6 -- the free win. The same X3D that round-trips through the bridge drops +straight into a browser via the X_ITE runtime, so a scene is previewable with no +editor and no cost. + +This is a developer convenience, deliberately out of the tested thin slice: the +generated page references the X_ITE runtime from a CDN, so viewing it needs +network access. The X3D payload itself is embedded inline and unchanged. + +Provides: +- to_preview_html(x3d, title=...) -> str +- write_preview(x3d, path, title=...) -> Path +""" + +import html +from pathlib import Path + +_X_ITE_CDN = "https://cdn.jsdelivr.net/npm/x_ite@latest/dist/x_ite.min.js" + +_TEMPLATE = """ + + + +{title} + + + + + +{x3d} + + + +""" + + +def to_preview_html(x3d: str, title: str = "UE x X3D preview") -> str: + """Wrap an X3D document in a standalone X_ITE viewer page. + + `title` is HTML-escaped (it lands in an HTML text context); `x3d` is left + raw, as X_ITE requires unescaped XML. + """ + return _TEMPLATE.format(title=html.escape(title), cdn=_X_ITE_CDN, x3d=x3d) + + +def write_preview(x3d: str, path: Path, title: str = "UE x X3D preview") -> Path: + """Write a preview page to disk and return its path.""" + path = Path(path) + path.write_text(to_preview_html(x3d, title=title), encoding="utf-8") + return path diff --git a/x3d_bridge/validate.py b/x3d_bridge/validate.py new file mode 100644 index 0000000..dac383f --- /dev/null +++ b/x3d_bridge/validate.py @@ -0,0 +1,80 @@ +""" +validate.py + +The validation boundary -- where bad edits die on paper, before the live editor +is ever touched. + +Two checks, both structural and cheap: + 1. Schema -- every node is in the closed grammar (grammar.GRAMMAR). + 2. Semantic -- DEF/USE references resolve, and every numeric field is finite. + +`validate` never raises and never mutates: it returns (ok, errors). That makes +it safe to run on untrusted model output as the gate between Edit and Apply in +the loop. The harness proves the boundary by asserting a battery of known-bad +documents all fail here. + +Provides: +- validate(x3d) -> (ok: bool, errors: list[str]) +""" + +import math +import xml.etree.ElementTree as ET + +from .grammar import GRAMMAR, _localname + +# Numeric attributes and their required component count. Arity is enforced +# here so deserialize() -- which requires exactly these counts -- can never be +# reached from a document that passed validate() (the boundary is a superset). +_NUMERIC_ARITY = {"translation": 3, "rotation": 4, "scale": 3} + + +def validate(x3d: str) -> tuple[bool, list[str]]: + """Validate an X3D document against the closed thin-slice grammar.""" + errors: list[str] = [] + + try: + root = ET.fromstring(x3d) + except ET.ParseError as exc: + return (False, [f"malformed XML: {exc}"]) + + # The root must be X3D: deserialize() rejects any other root, so a document + # rooted at an in-grammar-but-non-X3D tag (e.g. bare Scene) must die here. + if _localname(root.tag) != "X3D": + errors.append(f"root is {_localname(root.tag)!r}, expected 'X3D'") + + # Collect DEF names first so a USE may legally reference a later DEF. + defs = {el.get("DEF") for el in root.iter() if el.get("DEF")} + + for el in root.iter(): + name = _localname(el.tag) + + if name not in GRAMMAR: + errors.append(f"out-of-grammar: {name}") + + use = el.get("USE") + if use and use not in defs: + errors.append(f"dangling USE: {use}") + + for attr, arity in _NUMERIC_ARITY.items(): + raw = el.get(attr) + if raw is None: + continue + tokens = raw.split() + if len(tokens) != arity: + errors.append( + f"{attr} on {el.get('DEF') or name} needs {arity} numbers, got {len(tokens)}" + ) + for token in tokens: + try: + value = float(token) + except ValueError: + errors.append( + f"non-numeric {attr} on {el.get('DEF') or name}: {token!r}" + ) + continue + if not math.isfinite(value): + errors.append( + f"non-finite {attr} on {el.get('DEF') or name}: {token}" + ) + + return (not errors, errors) From 82b6d25f18b2a40cc5fb1994c0689041b0ae69cc Mon Sep 17 00:00:00 2001 From: Joseph Ibrahim Date: Tue, 7 Jul 2026 18:36:33 -0400 Subject: [PATCH 2/2] release: v0.3.0 -- UE<->X3D round-trip harness Bump ue_mcp/__version__.py to 0.3.0 (single version source; the tag must match it) and add the CHANGELOG entry. 635 tests total (was 580). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++++ ue_mcp/__version__.py | 4 ++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3a1320..1ccf4f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,37 @@ 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.3.0] - 2026-07-07 — UE ↔ X3D round-trip harness + +A new, self-contained `x3d_bridge/` package: a lossless UE↔X3D serialization +harness whose single defended invariant is +`deserialize(serialize(level)) == level`, with a validation boundary that +rejects malformed edits on paper before the live editor is touched. Independent +of the MCP tool surface and of the USD path. + +### Added +- **`x3d_bridge/`** — five modules: + - `coordinates` — the coordinate crux: one orthonormal basis `B` (det −1) + for UE (Z-up, LH, cm) ↔ X3D (Y-up, RH, m); quaternion ↔ matrix ↔ + axis-angle; a `basis_from_axis_images` calibration primitive. The round + trip is exact by construction (`B⁻¹ = Bᵀ`). + - `grammar` — a closed X3D node set + `serialize`/`deserialize` (flat, + world-space, DEF/USE material dedup); UE specifics ride in `Metadata*`. + - `validate` — the paper boundary: grammar, DEF/USE resolution, numeric + arity, root-is-X3D, and finiteness. + - `loop` — a five-stage headless loop (read → serialize → edit → validate → + apply); diffs scenes into typed ops that emit `ue_execute_python`. + - `preview` — the same X3D in a browser via X_ITE. +- **`tests/test_x3d_bridge.py`** — 55 tests: round-trip identity, the + validation battery, apply-op sequences, and forward-pinned coordinate + correctness. **635 tests total** (was 580). + +### Notes +- `B` is analytically derived and independently confirmed, but **not yet + calibrated against a live editor's glTF export**. The round trip is + basis-agnostic, so this affects external-tool fidelity, not correctness. +- The harness is a library; it is **not yet exposed as MCP tools**. + ## [0.2.0] - 2026-07-02 — the Epic MCP era > **Version renumbering:** the public line continues from v0.1.1. An internal diff --git a/ue_mcp/__version__.py b/ue_mcp/__version__.py index 180684e..bb99365 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.2.0" -__version_info__ = (0, 2, 0) +__version__ = "0.3.0" +__version_info__ = (0, 3, 0)