diff --git a/packages/tools/README.md b/packages/tools/README.md new file mode 100644 index 00000000000..8434c25ec85 --- /dev/null +++ b/packages/tools/README.md @@ -0,0 +1,57 @@ +# Babylon.js MCP Tools + +This directory contains the Babylon.js Model Context Protocol tooling packages used to expose Babylon.js authoring workflows to MCP-compatible clients. + +## Packages + +| Package | Purpose | +| -------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `mcp-server-core` | Shared internal helpers for MCP response shaping, schema fragments, validation, and file handoff behavior. | +| `nme-mcp-server` | Node Material graph authoring and import/export workflows. | +| `flow-graph-mcp-server` | Flow Graph authoring and coordinator JSON export/import workflows. | +| `gui-mcp-server` | Babylon.js GUI authoring, layout, export/import, and snippet flows. | +| `nge-mcp-server` | Node Geometry graph authoring and export/import workflows. | +| `nrge-mcp-server` | Node Render Graph authoring and render-pipeline export/import workflows. | +| `npe-mcp-server` | Node Particle graph authoring and export/import workflows. | +| `gltf-mcp-server` | glTF/glb asset authoring, inspection, validation, and export. | +| `smart-filters-mcp-server` | Smart Filters graph authoring and export/import workflows. | + +## How The Packages Fit Together + +The MCP packages are organized as specialized graph or authoring servers, each managing one Babylon.js subsystem in memory. Each server can independently create, edit, validate, and export its graph format. + +A future Scene MCP server will act as an orchestrator, consuming exported JSON from these servers to produce runnable scenes. + +## Typical Workflow + +Each server follows the same general pattern: + +```text +1. Create a graph/document in memory +2. Add blocks/controls/nodes and configure them +3. Connect ports or set properties +4. Validate the graph +5. Export to JSON (inline or to a file via outputFile) +``` + +## Common Development Flow + +Most MCP server packages in this folder support the same development commands: + +```bash +npm run build -w @tools/ +npm run start -w @tools/ +``` + +The MCP servers are built with Rollup and consume the shared helpers from `@tools/mcp-server-core`. + +## Shared Conventions + +- JSON export tools generally support `outputFile` +- JSON import tools generally support `json` and `jsonFile` +- snippet-enabled servers generally support `snippetId` +- shared schema, validation, and response helpers live in `mcp-server-core` + +## Workspace MCP Configuration + +The workspace-level MCP server command mapping lives in `.vscode/mcp.json` at the repository root. That file is useful when testing the servers locally from VS Code or another MCP-aware client. diff --git a/packages/tools/flow-graph-mcp-server/README.md b/packages/tools/flow-graph-mcp-server/README.md new file mode 100644 index 00000000000..0bac7b4000d --- /dev/null +++ b/packages/tools/flow-graph-mcp-server/README.md @@ -0,0 +1,42 @@ +# @tools/flow-graph-mcp-server + +MCP server for AI-driven Babylon.js Flow Graph authoring. + +## Provides + +- create, inspect, validate, and delete flow graphs +- add blocks and connect data or signal ports +- update block properties and context variables +- export coordinator JSON or graph-only JSON +- import previously exported flow graph JSON + +## Typical Workflow + +```text +create_graph -> add_block -> connect_data/connect_signal -> set_block_properties -> validate_graph -> export_graph_json +``` + +Use the full coordinator JSON when handing the result to Scene MCP. + +## Binary + +```bash +babylonjs-flow-graph +``` + +## Build And Run + +```bash +npm run build -w @tools/flow-graph-mcp-server +npm run start -w @tools/flow-graph-mcp-server +``` + +## Integration + +The exported coordinator JSON can be attached to the Scene MCP server through `attach_flow_graph`, either inline or via `coordinatorJsonFile`. + +## Related Files + +- `src/index.ts`: MCP tool registration +- `src/flowGraphManager.ts`: graph manager and export/import logic +- `src/blockRegistry.ts`: Flow Graph block catalog diff --git a/packages/tools/flow-graph-mcp-server/examples/AnimateOnReady.flowgraph.json b/packages/tools/flow-graph-mcp-server/examples/AnimateOnReady.flowgraph.json new file mode 100644 index 00000000000..5c980c6ae18 --- /dev/null +++ b/packages/tools/flow-graph-mcp-server/examples/AnimateOnReady.flowgraph.json @@ -0,0 +1,229 @@ +{ + "_flowGraphs": [ + { + "allBlocks": [ + { + "className": "FlowGraphSceneReadyEventBlock", + "config": {}, + "uniqueId": "fg-00000002", + "dataInputs": [], + "dataOutputs": [], + "signalInputs": [ + { + "uniqueId": "fg-00000003", + "name": "in", + "_connectionType": 0, + "connectedPointIds": [] + } + ], + "signalOutputs": [ + { + "uniqueId": "fg-00000004", + "name": "out", + "_connectionType": 1, + "connectedPointIds": [] + }, + { + "uniqueId": "fg-00000005", + "name": "done", + "_connectionType": 1, + "connectedPointIds": [ + "fg-00000008" + ] + }, + { + "uniqueId": "fg-00000006", + "name": "error", + "_connectionType": 1, + "connectedPointIds": [] + } + ], + "metadata": { + "displayName": "onReady" + } + }, + { + "className": "FlowGraphPlayAnimationBlock", + "config": { + "targetMesh": "hero" + }, + "uniqueId": "fg-00000007", + "dataInputs": [ + { + "uniqueId": "fg-0000000f", + "name": "speed", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "number", + "defaultValue": 0 + }, + "optional": false + }, + { + "uniqueId": "fg-00000010", + "name": "loop", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "boolean", + "defaultValue": false + }, + "optional": false + }, + { + "uniqueId": "fg-00000011", + "name": "from", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "number", + "defaultValue": 0 + }, + "optional": false + }, + { + "uniqueId": "fg-00000012", + "name": "to", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "number", + "defaultValue": 0 + }, + "optional": false + }, + { + "uniqueId": "fg-00000013", + "name": "animationGroup", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false + }, + { + "uniqueId": "fg-00000014", + "name": "animation", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + }, + { + "uniqueId": "fg-00000015", + "name": "object", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + } + ], + "dataOutputs": [ + { + "uniqueId": "fg-00000016", + "name": "currentFrame", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "number", + "defaultValue": 0 + } + }, + { + "uniqueId": "fg-00000017", + "name": "currentTime", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "number", + "defaultValue": 0 + } + }, + { + "uniqueId": "fg-00000018", + "name": "currentAnimationGroup", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + } + } + ], + "signalInputs": [ + { + "uniqueId": "fg-00000008", + "name": "in", + "_connectionType": 0, + "connectedPointIds": [] + } + ], + "signalOutputs": [ + { + "uniqueId": "fg-00000009", + "name": "out", + "_connectionType": 1, + "connectedPointIds": [] + }, + { + "uniqueId": "fg-0000000a", + "name": "done", + "_connectionType": 1, + "connectedPointIds": [] + }, + { + "uniqueId": "fg-0000000b", + "name": "error", + "_connectionType": 1, + "connectedPointIds": [] + }, + { + "uniqueId": "fg-0000000c", + "name": "animationLoopEvent", + "_connectionType": 1, + "connectedPointIds": [] + }, + { + "uniqueId": "fg-0000000d", + "name": "animationEndEvent", + "_connectionType": 1, + "connectedPointIds": [] + }, + { + "uniqueId": "fg-0000000e", + "name": "animationGroupLoopEvent", + "_connectionType": 1, + "connectedPointIds": [] + } + ], + "metadata": { + "displayName": "playAnim" + } + } + ], + "executionContexts": [ + { + "uniqueId": "fg-00000001", + "_userVariables": {}, + "_connectionValues": {} + } + ] + } + ], + "dispatchEventsSynchronously": false +} \ No newline at end of file diff --git a/packages/tools/flow-graph-mcp-server/examples/ClickLogger.flowgraph.json b/packages/tools/flow-graph-mcp-server/examples/ClickLogger.flowgraph.json new file mode 100644 index 00000000000..4e6d95609b3 --- /dev/null +++ b/packages/tools/flow-graph-mcp-server/examples/ClickLogger.flowgraph.json @@ -0,0 +1,197 @@ +{ + "_flowGraphs": [ + { + "allBlocks": [ + { + "className": "FlowGraphMeshPickEventBlock", + "config": { + "targetMesh": "clickTarget" + }, + "uniqueId": "fg-00000002", + "dataInputs": [ + { + "uniqueId": "fg-00000007", + "name": "asset", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + }, + { + "uniqueId": "fg-00000008", + "name": "pointerType", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + } + ], + "dataOutputs": [ + { + "uniqueId": "fg-00000009", + "name": "pickedPoint", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "Vector3", + "defaultValue": { + "value": [ + 0, + 0, + 0 + ], + "className": "Vector3" + } + } + }, + { + "uniqueId": "fg-0000000a", + "name": "pickOrigin", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "Vector3", + "defaultValue": { + "value": [ + 0, + 0, + 0 + ], + "className": "Vector3" + } + } + }, + { + "uniqueId": "fg-0000000b", + "name": "pointerId", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "number", + "defaultValue": 0 + } + }, + { + "uniqueId": "fg-0000000c", + "name": "pickedMesh", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + } + } + ], + "signalInputs": [ + { + "uniqueId": "fg-00000003", + "name": "in", + "_connectionType": 0, + "connectedPointIds": [] + } + ], + "signalOutputs": [ + { + "uniqueId": "fg-00000004", + "name": "out", + "_connectionType": 1, + "connectedPointIds": [] + }, + { + "uniqueId": "fg-00000005", + "name": "done", + "_connectionType": 1, + "connectedPointIds": [ + "fg-0000000e" + ] + }, + { + "uniqueId": "fg-00000006", + "name": "error", + "_connectionType": 1, + "connectedPointIds": [] + } + ], + "metadata": { + "displayName": "onPick" + } + }, + { + "className": "FlowGraphConsoleLogBlock", + "config": { + "message": "Clicked!" + }, + "uniqueId": "fg-0000000d", + "dataInputs": [ + { + "uniqueId": "fg-00000011", + "name": "message", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false, + "defaultValue": "Clicked!" + }, + { + "uniqueId": "fg-00000012", + "name": "logType", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + } + ], + "dataOutputs": [], + "signalInputs": [ + { + "uniqueId": "fg-0000000e", + "name": "in", + "_connectionType": 0, + "connectedPointIds": [] + } + ], + "signalOutputs": [ + { + "uniqueId": "fg-0000000f", + "name": "out", + "_connectionType": 1, + "connectedPointIds": [] + }, + { + "uniqueId": "fg-00000010", + "name": "error", + "_connectionType": 1, + "connectedPointIds": [] + } + ], + "metadata": { + "displayName": "logger" + } + } + ], + "executionContexts": [ + { + "uniqueId": "fg-00000001", + "_userVariables": {}, + "_connectionValues": {} + } + ] + } + ], + "dispatchEventsSynchronously": false +} \ No newline at end of file diff --git a/packages/tools/flow-graph-mcp-server/examples/SequentialSetup.flowgraph.json b/packages/tools/flow-graph-mcp-server/examples/SequentialSetup.flowgraph.json new file mode 100644 index 00000000000..059e64c3f6f --- /dev/null +++ b/packages/tools/flow-graph-mcp-server/examples/SequentialSetup.flowgraph.json @@ -0,0 +1,429 @@ +{ + "_flowGraphs": [ + { + "allBlocks": [ + { + "className": "FlowGraphSceneReadyEventBlock", + "config": {}, + "uniqueId": "fg-00000002", + "dataInputs": [], + "dataOutputs": [], + "signalInputs": [ + { + "uniqueId": "fg-00000003", + "name": "in", + "_connectionType": 0, + "connectedPointIds": [] + } + ], + "signalOutputs": [ + { + "uniqueId": "fg-00000004", + "name": "out", + "_connectionType": 1, + "connectedPointIds": [] + }, + { + "uniqueId": "fg-00000005", + "name": "done", + "_connectionType": 1, + "connectedPointIds": [ + "fg-00000008" + ] + }, + { + "uniqueId": "fg-00000006", + "name": "error", + "_connectionType": 1, + "connectedPointIds": [] + } + ], + "metadata": { + "displayName": "onReady" + } + }, + { + "className": "FlowGraphSequenceBlock", + "config": { + "outputSignalCount": 3 + }, + "uniqueId": "fg-00000007", + "dataInputs": [], + "dataOutputs": [], + "signalInputs": [ + { + "uniqueId": "fg-00000008", + "name": "in", + "_connectionType": 0, + "connectedPointIds": [] + } + ], + "signalOutputs": [ + { + "uniqueId": "fg-00000009", + "name": "error", + "_connectionType": 1, + "connectedPointIds": [] + }, + { + "uniqueId": "fg-0000000a", + "name": "out_0", + "_connectionType": 1, + "connectedPointIds": [ + "fg-0000000e" + ] + }, + { + "uniqueId": "fg-0000000b", + "name": "out_1", + "_connectionType": 1, + "connectedPointIds": [ + "fg-00000016" + ] + }, + { + "uniqueId": "fg-0000000c", + "name": "out_2", + "_connectionType": 1, + "connectedPointIds": [ + "fg-0000001e" + ] + } + ], + "metadata": { + "displayName": "seq" + } + }, + { + "className": "FlowGraphSetPropertyBlock", + "config": { + "propertyPath": "box.position.x" + }, + "uniqueId": "fg-0000000d", + "dataInputs": [ + { + "uniqueId": "fg-00000011", + "name": "object", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false + }, + { + "uniqueId": "fg-00000012", + "name": "value", + "_connectionType": 0, + "connectedPointIds": [ + "fg-00000026" + ], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false + }, + { + "uniqueId": "fg-00000013", + "name": "propertyName", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + }, + { + "uniqueId": "fg-00000014", + "name": "customSetFunction", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + } + ], + "dataOutputs": [], + "signalInputs": [ + { + "uniqueId": "fg-0000000e", + "name": "in", + "_connectionType": 0, + "connectedPointIds": [] + } + ], + "signalOutputs": [ + { + "uniqueId": "fg-0000000f", + "name": "out", + "_connectionType": 1, + "connectedPointIds": [] + }, + { + "uniqueId": "fg-00000010", + "name": "error", + "_connectionType": 1, + "connectedPointIds": [] + } + ], + "metadata": { + "displayName": "setPosX" + } + }, + { + "className": "FlowGraphSetPropertyBlock", + "config": { + "propertyPath": "box.position.y" + }, + "uniqueId": "fg-00000015", + "dataInputs": [ + { + "uniqueId": "fg-00000019", + "name": "object", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false + }, + { + "uniqueId": "fg-0000001a", + "name": "value", + "_connectionType": 0, + "connectedPointIds": [ + "fg-00000028" + ], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false + }, + { + "uniqueId": "fg-0000001b", + "name": "propertyName", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + }, + { + "uniqueId": "fg-0000001c", + "name": "customSetFunction", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + } + ], + "dataOutputs": [], + "signalInputs": [ + { + "uniqueId": "fg-00000016", + "name": "in", + "_connectionType": 0, + "connectedPointIds": [] + } + ], + "signalOutputs": [ + { + "uniqueId": "fg-00000017", + "name": "out", + "_connectionType": 1, + "connectedPointIds": [] + }, + { + "uniqueId": "fg-00000018", + "name": "error", + "_connectionType": 1, + "connectedPointIds": [] + } + ], + "metadata": { + "displayName": "setPosY" + } + }, + { + "className": "FlowGraphSetPropertyBlock", + "config": { + "propertyPath": "box.position.z" + }, + "uniqueId": "fg-0000001d", + "dataInputs": [ + { + "uniqueId": "fg-00000021", + "name": "object", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false + }, + { + "uniqueId": "fg-00000022", + "name": "value", + "_connectionType": 0, + "connectedPointIds": [ + "fg-0000002a" + ], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false + }, + { + "uniqueId": "fg-00000023", + "name": "propertyName", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + }, + { + "uniqueId": "fg-00000024", + "name": "customSetFunction", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + } + ], + "dataOutputs": [], + "signalInputs": [ + { + "uniqueId": "fg-0000001e", + "name": "in", + "_connectionType": 0, + "connectedPointIds": [] + } + ], + "signalOutputs": [ + { + "uniqueId": "fg-0000001f", + "name": "out", + "_connectionType": 1, + "connectedPointIds": [] + }, + { + "uniqueId": "fg-00000020", + "name": "error", + "_connectionType": 1, + "connectedPointIds": [] + } + ], + "metadata": { + "displayName": "setPosZ" + } + }, + { + "className": "FlowGraphConstantBlock", + "config": { + "value": 1, + "type": "number" + }, + "uniqueId": "fg-00000025", + "dataInputs": [], + "dataOutputs": [ + { + "uniqueId": "fg-00000026", + "name": "output", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + } + } + ], + "signalInputs": [], + "signalOutputs": [], + "metadata": { + "displayName": "v1" + } + }, + { + "className": "FlowGraphConstantBlock", + "config": { + "value": 2, + "type": "number" + }, + "uniqueId": "fg-00000027", + "dataInputs": [], + "dataOutputs": [ + { + "uniqueId": "fg-00000028", + "name": "output", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + } + } + ], + "signalInputs": [], + "signalOutputs": [], + "metadata": { + "displayName": "v2" + } + }, + { + "className": "FlowGraphConstantBlock", + "config": { + "value": 3, + "type": "number" + }, + "uniqueId": "fg-00000029", + "dataInputs": [], + "dataOutputs": [ + { + "uniqueId": "fg-0000002a", + "name": "output", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + } + } + ], + "signalInputs": [], + "signalOutputs": [], + "metadata": { + "displayName": "v3" + } + } + ], + "executionContexts": [ + { + "uniqueId": "fg-00000001", + "_userVariables": {}, + "_connectionValues": {} + } + ] + } + ], + "dispatchEventsSynchronously": false +} \ No newline at end of file diff --git a/packages/tools/flow-graph-mcp-server/examples/SphereClickRotateGround.flowgraph.json b/packages/tools/flow-graph-mcp-server/examples/SphereClickRotateGround.flowgraph.json new file mode 100644 index 00000000000..198e25a3932 --- /dev/null +++ b/packages/tools/flow-graph-mcp-server/examples/SphereClickRotateGround.flowgraph.json @@ -0,0 +1,828 @@ +{ + "_flowGraphs": [ + { + "allBlocks": [ + { + "className": "FlowGraphMeshPickEventBlock", + "config": { + "targetMesh": { + "className": "Mesh", + "name": "sphere" + } + }, + "uniqueId": "fg-00000039-19c91619f39", + "dataInputs": [ + { + "uniqueId": "fg-0000003e-19c91619f39", + "name": "asset", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + }, + { + "uniqueId": "fg-0000003f-19c91619f39", + "name": "pointerType", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + } + ], + "dataOutputs": [ + { + "uniqueId": "fg-00000040-19c91619f39", + "name": "pickedPoint", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "Vector3", + "defaultValue": { + "value": [ + 0, + 0, + 0 + ], + "className": "Vector3" + } + } + }, + { + "uniqueId": "fg-00000041-19c91619f39", + "name": "pickOrigin", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "Vector3", + "defaultValue": { + "value": [ + 0, + 0, + 0 + ], + "className": "Vector3" + } + } + }, + { + "uniqueId": "fg-00000042-19c91619f39", + "name": "pointerId", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "number", + "defaultValue": 0 + } + }, + { + "uniqueId": "fg-00000043-19c91619f39", + "name": "pickedMesh", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + } + } + ], + "signalInputs": [ + { + "uniqueId": "fg-0000003a-19c91619f39", + "name": "in", + "_connectionType": 0, + "connectedPointIds": [] + } + ], + "signalOutputs": [ + { + "uniqueId": "fg-0000003b-19c91619f39", + "name": "out", + "_connectionType": 1, + "connectedPointIds": [] + }, + { + "uniqueId": "fg-0000003c-19c91619f39", + "name": "done", + "_connectionType": 1, + "connectedPointIds": [ + "fg-00000052-19c91619f3a" + ] + }, + { + "uniqueId": "fg-0000003d-19c91619f39", + "name": "error", + "_connectionType": 1, + "connectedPointIds": [] + } + ], + "metadata": { + "displayName": "MeshPickEvent_1" + } + }, + { + "className": "FlowGraphGetPropertyBlock", + "config": { + "propertyName": "rotation.y", + "object": { + "className": "Mesh", + "name": "ground" + } + }, + "uniqueId": "fg-00000044-19c91619f3a", + "dataInputs": [ + { + "uniqueId": "fg-00000045-19c91619f3a", + "name": "object", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false + }, + { + "uniqueId": "fg-00000046-19c91619f3a", + "name": "propertyName", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + }, + { + "uniqueId": "fg-00000047-19c91619f3a", + "name": "customGetFunction", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + } + ], + "dataOutputs": [ + { + "uniqueId": "fg-00000048-19c91619f3a", + "name": "value", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + } + }, + { + "uniqueId": "fg-00000049-19c91619f3a", + "name": "isValid", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "boolean", + "defaultValue": false + } + } + ], + "signalInputs": [], + "signalOutputs": [], + "metadata": { + "displayName": "GetProperty_2" + } + }, + { + "className": "FlowGraphConstantBlock", + "config": { + "value": 0.4363323129985824 + }, + "uniqueId": "fg-0000004a-19c91619f3a", + "dataInputs": [], + "dataOutputs": [ + { + "uniqueId": "fg-0000004b-19c91619f3a", + "name": "output", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + } + } + ], + "signalInputs": [], + "signalOutputs": [], + "metadata": { + "displayName": "Constant_3" + } + }, + { + "className": "FlowGraphAddBlock", + "config": {}, + "uniqueId": "fg-0000004c-19c91619f3a", + "dataInputs": [ + { + "uniqueId": "fg-0000004d-19c91619f3a", + "name": "a", + "_connectionType": 0, + "connectedPointIds": [ + "fg-00000048-19c91619f3a" + ], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false + }, + { + "uniqueId": "fg-0000004e-19c91619f3a", + "name": "b", + "_connectionType": 0, + "connectedPointIds": [ + "fg-0000004b-19c91619f3a" + ], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false + } + ], + "dataOutputs": [ + { + "uniqueId": "fg-0000004f-19c91619f3a", + "name": "value", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + } + }, + { + "uniqueId": "fg-00000050-19c91619f3a", + "name": "isValid", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "boolean", + "defaultValue": false + } + } + ], + "signalInputs": [], + "signalOutputs": [], + "metadata": { + "displayName": "Add_4" + } + }, + { + "className": "FlowGraphSetPropertyBlock", + "config": { + "propertyName": "rotation.y", + "target": { + "className": "Mesh", + "name": "ground" + } + }, + "uniqueId": "fg-00000051-19c91619f3a", + "dataInputs": [ + { + "uniqueId": "fg-00000055-19c91619f3a", + "name": "object", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false + }, + { + "uniqueId": "fg-00000056-19c91619f3a", + "name": "value", + "_connectionType": 0, + "connectedPointIds": [ + "fg-0000004f-19c91619f3a" + ], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false + }, + { + "uniqueId": "fg-00000057-19c91619f3a", + "name": "propertyName", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + }, + { + "uniqueId": "fg-00000058-19c91619f3a", + "name": "customSetFunction", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + } + ], + "dataOutputs": [], + "signalInputs": [ + { + "uniqueId": "fg-00000052-19c91619f3a", + "name": "in", + "_connectionType": 0, + "connectedPointIds": [] + } + ], + "signalOutputs": [ + { + "uniqueId": "fg-00000053-19c91619f3a", + "name": "out", + "_connectionType": 1, + "connectedPointIds": [ + "fg-00000069-19c9168e4f1" + ] + }, + { + "uniqueId": "fg-00000054-19c91619f3a", + "name": "error", + "_connectionType": 1, + "connectedPointIds": [] + } + ], + "metadata": { + "displayName": "SetProperty_5" + } + }, + { + "className": "FlowGraphRandomBlock", + "config": {}, + "uniqueId": "fg-00000059-19c9168e4f1", + "dataInputs": [ + { + "uniqueId": "fg-0000005a-19c9168e4f1", + "name": "min", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "number", + "defaultValue": 0 + }, + "optional": true + }, + { + "uniqueId": "fg-0000005b-19c9168e4f1", + "name": "max", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "number", + "defaultValue": 0 + }, + "optional": true + } + ], + "dataOutputs": [ + { + "uniqueId": "fg-0000005c-19c9168e4f1", + "name": "value", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "number", + "defaultValue": 0 + } + }, + { + "uniqueId": "fg-0000005d-19c9168e4f1", + "name": "isValid", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "boolean", + "defaultValue": false + } + } + ], + "signalInputs": [], + "signalOutputs": [], + "metadata": { + "displayName": "Random_6" + } + }, + { + "className": "FlowGraphRandomBlock", + "config": {}, + "uniqueId": "fg-0000005e-19c9168e4f1", + "dataInputs": [ + { + "uniqueId": "fg-0000005f-19c9168e4f1", + "name": "min", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "number", + "defaultValue": 0 + }, + "optional": true + }, + { + "uniqueId": "fg-00000060-19c9168e4f1", + "name": "max", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "number", + "defaultValue": 0 + }, + "optional": true + } + ], + "dataOutputs": [ + { + "uniqueId": "fg-00000061-19c9168e4f1", + "name": "value", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "number", + "defaultValue": 0 + } + }, + { + "uniqueId": "fg-00000062-19c9168e4f1", + "name": "isValid", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "boolean", + "defaultValue": false + } + } + ], + "signalInputs": [], + "signalOutputs": [], + "metadata": { + "displayName": "Random_7" + } + }, + { + "className": "FlowGraphRandomBlock", + "config": {}, + "uniqueId": "fg-00000063-19c9168e4f1", + "dataInputs": [ + { + "uniqueId": "fg-00000064-19c9168e4f1", + "name": "min", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "number", + "defaultValue": 0 + }, + "optional": true + }, + { + "uniqueId": "fg-00000065-19c9168e4f1", + "name": "max", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "number", + "defaultValue": 0 + }, + "optional": true + } + ], + "dataOutputs": [ + { + "uniqueId": "fg-00000066-19c9168e4f1", + "name": "value", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "number", + "defaultValue": 0 + } + }, + { + "uniqueId": "fg-00000067-19c9168e4f1", + "name": "isValid", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "boolean", + "defaultValue": false + } + } + ], + "signalInputs": [], + "signalOutputs": [], + "metadata": { + "displayName": "Random_8" + } + }, + { + "className": "FlowGraphSetPropertyBlock", + "config": { + "propertyName": "material.diffuseColor.r", + "target": { + "className": "Mesh", + "name": "ground" + } + }, + "uniqueId": "fg-00000068-19c9168e4f1", + "dataInputs": [ + { + "uniqueId": "fg-0000006c-19c9168e4f1", + "name": "object", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false + }, + { + "uniqueId": "fg-0000006d-19c9168e4f1", + "name": "value", + "_connectionType": 0, + "connectedPointIds": [ + "fg-0000005c-19c9168e4f1" + ], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false + }, + { + "uniqueId": "fg-0000006e-19c9168e4f1", + "name": "propertyName", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + }, + { + "uniqueId": "fg-0000006f-19c9168e4f1", + "name": "customSetFunction", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + } + ], + "dataOutputs": [], + "signalInputs": [ + { + "uniqueId": "fg-00000069-19c9168e4f1", + "name": "in", + "_connectionType": 0, + "connectedPointIds": [] + } + ], + "signalOutputs": [ + { + "uniqueId": "fg-0000006a-19c9168e4f1", + "name": "out", + "_connectionType": 1, + "connectedPointIds": [ + "fg-00000071-19c9168e4f1" + ] + }, + { + "uniqueId": "fg-0000006b-19c9168e4f1", + "name": "error", + "_connectionType": 1, + "connectedPointIds": [] + } + ], + "metadata": { + "displayName": "SetProperty_9" + } + }, + { + "className": "FlowGraphSetPropertyBlock", + "config": { + "propertyName": "material.diffuseColor.g", + "target": { + "className": "Mesh", + "name": "ground" + } + }, + "uniqueId": "fg-00000070-19c9168e4f1", + "dataInputs": [ + { + "uniqueId": "fg-00000074-19c9168e4f1", + "name": "object", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false + }, + { + "uniqueId": "fg-00000075-19c9168e4f1", + "name": "value", + "_connectionType": 0, + "connectedPointIds": [ + "fg-00000061-19c9168e4f1" + ], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false + }, + { + "uniqueId": "fg-00000076-19c9168e4f1", + "name": "propertyName", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + }, + { + "uniqueId": "fg-00000077-19c9168e4f1", + "name": "customSetFunction", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + } + ], + "dataOutputs": [], + "signalInputs": [ + { + "uniqueId": "fg-00000071-19c9168e4f1", + "name": "in", + "_connectionType": 0, + "connectedPointIds": [] + } + ], + "signalOutputs": [ + { + "uniqueId": "fg-00000072-19c9168e4f1", + "name": "out", + "_connectionType": 1, + "connectedPointIds": [ + "fg-00000079-19c9168e4f1" + ] + }, + { + "uniqueId": "fg-00000073-19c9168e4f1", + "name": "error", + "_connectionType": 1, + "connectedPointIds": [] + } + ], + "metadata": { + "displayName": "SetProperty_10" + } + }, + { + "className": "FlowGraphSetPropertyBlock", + "config": { + "propertyName": "material.diffuseColor.b", + "target": { + "className": "Mesh", + "name": "ground" + } + }, + "uniqueId": "fg-00000078-19c9168e4f1", + "dataInputs": [ + { + "uniqueId": "fg-0000007c-19c9168e4f1", + "name": "object", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false + }, + { + "uniqueId": "fg-0000007d-19c9168e4f1", + "name": "value", + "_connectionType": 0, + "connectedPointIds": [ + "fg-00000066-19c9168e4f1" + ], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false + }, + { + "uniqueId": "fg-0000007e-19c9168e4f1", + "name": "propertyName", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + }, + { + "uniqueId": "fg-0000007f-19c9168e4f1", + "name": "customSetFunction", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + } + ], + "dataOutputs": [], + "signalInputs": [ + { + "uniqueId": "fg-00000079-19c9168e4f1", + "name": "in", + "_connectionType": 0, + "connectedPointIds": [] + } + ], + "signalOutputs": [ + { + "uniqueId": "fg-0000007a-19c9168e4f1", + "name": "out", + "_connectionType": 1, + "connectedPointIds": [] + }, + { + "uniqueId": "fg-0000007b-19c9168e4f1", + "name": "error", + "_connectionType": 1, + "connectedPointIds": [] + } + ], + "metadata": { + "displayName": "SetProperty_11" + } + } + ], + "executionContexts": [ + { + "uniqueId": "fg-00000038-19c91617498", + "_userVariables": {}, + "_connectionValues": {} + } + ] + } + ], + "dispatchEventsSynchronously": false +} \ No newline at end of file diff --git a/packages/tools/flow-graph-mcp-server/examples/TickCounter.flowgraph.json b/packages/tools/flow-graph-mcp-server/examples/TickCounter.flowgraph.json new file mode 100644 index 00000000000..f23be41695b --- /dev/null +++ b/packages/tools/flow-graph-mcp-server/examples/TickCounter.flowgraph.json @@ -0,0 +1,300 @@ +{ + "_flowGraphs": [ + { + "allBlocks": [ + { + "className": "FlowGraphSceneTickEventBlock", + "config": {}, + "uniqueId": "fg-00000002", + "dataInputs": [], + "dataOutputs": [ + { + "uniqueId": "fg-00000007", + "name": "timeSinceStart", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "number", + "defaultValue": 0 + } + }, + { + "uniqueId": "fg-00000008", + "name": "deltaTime", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "number", + "defaultValue": 0 + } + } + ], + "signalInputs": [ + { + "uniqueId": "fg-00000003", + "name": "in", + "_connectionType": 0, + "connectedPointIds": [] + } + ], + "signalOutputs": [ + { + "uniqueId": "fg-00000004", + "name": "out", + "_connectionType": 1, + "connectedPointIds": [] + }, + { + "uniqueId": "fg-00000005", + "name": "done", + "_connectionType": 1, + "connectedPointIds": [ + "fg-00000013" + ] + }, + { + "uniqueId": "fg-00000006", + "name": "error", + "_connectionType": 1, + "connectedPointIds": [] + } + ], + "metadata": { + "displayName": "onTick" + } + }, + { + "className": "FlowGraphGetVariableBlock", + "config": { + "variable": "counter" + }, + "uniqueId": "fg-00000009", + "dataInputs": [], + "dataOutputs": [ + { + "uniqueId": "fg-0000000a", + "name": "value", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + } + } + ], + "signalInputs": [], + "signalOutputs": [], + "metadata": { + "displayName": "getCounter" + } + }, + { + "className": "FlowGraphConstantBlock", + "config": { + "value": 1, + "type": "number" + }, + "uniqueId": "fg-0000000b", + "dataInputs": [], + "dataOutputs": [ + { + "uniqueId": "fg-0000000c", + "name": "output", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + } + } + ], + "signalInputs": [], + "signalOutputs": [], + "metadata": { + "displayName": "one" + } + }, + { + "className": "FlowGraphAddBlock", + "config": {}, + "uniqueId": "fg-0000000d", + "dataInputs": [ + { + "uniqueId": "fg-0000000e", + "name": "a", + "_connectionType": 0, + "connectedPointIds": [ + "fg-0000000a" + ], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false + }, + { + "uniqueId": "fg-0000000f", + "name": "b", + "_connectionType": 0, + "connectedPointIds": [ + "fg-0000000c" + ], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false + } + ], + "dataOutputs": [ + { + "uniqueId": "fg-00000010", + "name": "value", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + } + }, + { + "uniqueId": "fg-00000011", + "name": "isValid", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "boolean", + "defaultValue": false + } + } + ], + "signalInputs": [], + "signalOutputs": [], + "metadata": { + "displayName": "add" + } + }, + { + "className": "FlowGraphSetVariableBlock", + "config": { + "variable": "counter" + }, + "uniqueId": "fg-00000012", + "dataInputs": [ + { + "uniqueId": "fg-00000016", + "name": "value", + "_connectionType": 0, + "connectedPointIds": [ + "fg-00000010" + ], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false + } + ], + "dataOutputs": [], + "signalInputs": [ + { + "uniqueId": "fg-00000013", + "name": "in", + "_connectionType": 0, + "connectedPointIds": [] + } + ], + "signalOutputs": [ + { + "uniqueId": "fg-00000014", + "name": "out", + "_connectionType": 1, + "connectedPointIds": [ + "fg-00000018" + ] + }, + { + "uniqueId": "fg-00000015", + "name": "error", + "_connectionType": 1, + "connectedPointIds": [] + } + ], + "metadata": { + "displayName": "setCounter" + } + }, + { + "className": "FlowGraphConsoleLogBlock", + "config": {}, + "uniqueId": "fg-00000017", + "dataInputs": [ + { + "uniqueId": "fg-0000001b", + "name": "message", + "_connectionType": 0, + "connectedPointIds": [ + "fg-00000010" + ], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false + }, + { + "uniqueId": "fg-0000001c", + "name": "logType", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + } + ], + "dataOutputs": [], + "signalInputs": [ + { + "uniqueId": "fg-00000018", + "name": "in", + "_connectionType": 0, + "connectedPointIds": [] + } + ], + "signalOutputs": [ + { + "uniqueId": "fg-00000019", + "name": "out", + "_connectionType": 1, + "connectedPointIds": [] + }, + { + "uniqueId": "fg-0000001a", + "name": "error", + "_connectionType": 1, + "connectedPointIds": [] + } + ], + "metadata": { + "displayName": "logCounter" + } + } + ], + "executionContexts": [ + { + "uniqueId": "fg-00000001", + "_userVariables": { + "counter": 0 + }, + "_connectionValues": {} + } + ] + } + ], + "dispatchEventsSynchronously": false +} \ No newline at end of file diff --git a/packages/tools/flow-graph-mcp-server/examples/ToggleVisibility.flowgraph.json b/packages/tools/flow-graph-mcp-server/examples/ToggleVisibility.flowgraph.json new file mode 100644 index 00000000000..858f3a12025 --- /dev/null +++ b/packages/tools/flow-graph-mcp-server/examples/ToggleVisibility.flowgraph.json @@ -0,0 +1,477 @@ +{ + "_flowGraphs": [ + { + "allBlocks": [ + { + "className": "FlowGraphMeshPickEventBlock", + "config": { + "targetMesh": "box" + }, + "uniqueId": "fg-00000002", + "dataInputs": [ + { + "uniqueId": "fg-00000007", + "name": "asset", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + }, + { + "uniqueId": "fg-00000008", + "name": "pointerType", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + } + ], + "dataOutputs": [ + { + "uniqueId": "fg-00000009", + "name": "pickedPoint", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "Vector3", + "defaultValue": { + "value": [ + 0, + 0, + 0 + ], + "className": "Vector3" + } + } + }, + { + "uniqueId": "fg-0000000a", + "name": "pickOrigin", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "Vector3", + "defaultValue": { + "value": [ + 0, + 0, + 0 + ], + "className": "Vector3" + } + } + }, + { + "uniqueId": "fg-0000000b", + "name": "pointerId", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "number", + "defaultValue": 0 + } + }, + { + "uniqueId": "fg-0000000c", + "name": "pickedMesh", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + } + } + ], + "signalInputs": [ + { + "uniqueId": "fg-00000003", + "name": "in", + "_connectionType": 0, + "connectedPointIds": [] + } + ], + "signalOutputs": [ + { + "uniqueId": "fg-00000004", + "name": "out", + "_connectionType": 1, + "connectedPointIds": [] + }, + { + "uniqueId": "fg-00000005", + "name": "done", + "_connectionType": 1, + "connectedPointIds": [ + "fg-00000010" + ] + }, + { + "uniqueId": "fg-00000006", + "name": "error", + "_connectionType": 1, + "connectedPointIds": [] + } + ], + "metadata": { + "displayName": "onPick" + } + }, + { + "className": "FlowGraphGetVariableBlock", + "config": { + "variable": "isVisible" + }, + "uniqueId": "fg-0000000d", + "dataInputs": [], + "dataOutputs": [ + { + "uniqueId": "fg-0000000e", + "name": "value", + "_connectionType": 1, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + } + } + ], + "signalInputs": [], + "signalOutputs": [], + "metadata": { + "displayName": "getIsVisible" + } + }, + { + "className": "FlowGraphBranchBlock", + "config": {}, + "uniqueId": "fg-0000000f", + "dataInputs": [ + { + "uniqueId": "fg-00000014", + "name": "condition", + "_connectionType": 0, + "connectedPointIds": [ + "fg-0000000e" + ], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "boolean", + "defaultValue": false + }, + "optional": false + } + ], + "dataOutputs": [], + "signalInputs": [ + { + "uniqueId": "fg-00000010", + "name": "in", + "_connectionType": 0, + "connectedPointIds": [] + } + ], + "signalOutputs": [ + { + "uniqueId": "fg-00000011", + "name": "onTrue", + "_connectionType": 1, + "connectedPointIds": [ + "fg-00000016" + ] + }, + { + "uniqueId": "fg-00000012", + "name": "onFalse", + "_connectionType": 1, + "connectedPointIds": [ + "fg-0000001e" + ] + }, + { + "uniqueId": "fg-00000013", + "name": "error", + "_connectionType": 1, + "connectedPointIds": [] + } + ], + "metadata": { + "displayName": "check" + } + }, + { + "className": "FlowGraphSetPropertyBlock", + "config": { + "propertyPath": "box.visibility" + }, + "uniqueId": "fg-00000015", + "dataInputs": [ + { + "uniqueId": "fg-00000019", + "name": "object", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false + }, + { + "uniqueId": "fg-0000001a", + "name": "value", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false + }, + { + "uniqueId": "fg-0000001b", + "name": "propertyName", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + }, + { + "uniqueId": "fg-0000001c", + "name": "customSetFunction", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + } + ], + "dataOutputs": [], + "signalInputs": [ + { + "uniqueId": "fg-00000016", + "name": "in", + "_connectionType": 0, + "connectedPointIds": [] + } + ], + "signalOutputs": [ + { + "uniqueId": "fg-00000017", + "name": "out", + "_connectionType": 1, + "connectedPointIds": [ + "fg-00000026" + ] + }, + { + "uniqueId": "fg-00000018", + "name": "error", + "_connectionType": 1, + "connectedPointIds": [] + } + ], + "metadata": { + "displayName": "hide" + } + }, + { + "className": "FlowGraphSetPropertyBlock", + "config": { + "propertyPath": "box.visibility" + }, + "uniqueId": "fg-0000001d", + "dataInputs": [ + { + "uniqueId": "fg-00000021", + "name": "object", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false + }, + { + "uniqueId": "fg-00000022", + "name": "value", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false + }, + { + "uniqueId": "fg-00000023", + "name": "propertyName", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + }, + { + "uniqueId": "fg-00000024", + "name": "customSetFunction", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": true + } + ], + "dataOutputs": [], + "signalInputs": [ + { + "uniqueId": "fg-0000001e", + "name": "in", + "_connectionType": 0, + "connectedPointIds": [] + } + ], + "signalOutputs": [ + { + "uniqueId": "fg-0000001f", + "name": "out", + "_connectionType": 1, + "connectedPointIds": [ + "fg-0000002b" + ] + }, + { + "uniqueId": "fg-00000020", + "name": "error", + "_connectionType": 1, + "connectedPointIds": [] + } + ], + "metadata": { + "displayName": "show" + } + }, + { + "className": "FlowGraphSetVariableBlock", + "config": { + "variable": "isVisible" + }, + "uniqueId": "fg-00000025", + "dataInputs": [ + { + "uniqueId": "fg-00000029", + "name": "value", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false + } + ], + "dataOutputs": [], + "signalInputs": [ + { + "uniqueId": "fg-00000026", + "name": "in", + "_connectionType": 0, + "connectedPointIds": [] + } + ], + "signalOutputs": [ + { + "uniqueId": "fg-00000027", + "name": "out", + "_connectionType": 1, + "connectedPointIds": [] + }, + { + "uniqueId": "fg-00000028", + "name": "error", + "_connectionType": 1, + "connectedPointIds": [] + } + ], + "metadata": { + "displayName": "setFalse" + } + }, + { + "className": "FlowGraphSetVariableBlock", + "config": { + "variable": "isVisible" + }, + "uniqueId": "fg-0000002a", + "dataInputs": [ + { + "uniqueId": "fg-0000002e", + "name": "value", + "_connectionType": 0, + "connectedPointIds": [], + "className": "FlowGraphDataConnection", + "richType": { + "typeName": "any" + }, + "optional": false + } + ], + "dataOutputs": [], + "signalInputs": [ + { + "uniqueId": "fg-0000002b", + "name": "in", + "_connectionType": 0, + "connectedPointIds": [] + } + ], + "signalOutputs": [ + { + "uniqueId": "fg-0000002c", + "name": "out", + "_connectionType": 1, + "connectedPointIds": [] + }, + { + "uniqueId": "fg-0000002d", + "name": "error", + "_connectionType": 1, + "connectedPointIds": [] + } + ], + "metadata": { + "displayName": "setTrue" + } + } + ], + "executionContexts": [ + { + "uniqueId": "fg-00000001", + "_userVariables": { + "isVisible": true + }, + "_connectionValues": {} + } + ] + } + ], + "dispatchEventsSynchronously": false +} \ No newline at end of file diff --git a/packages/tools/flow-graph-mcp-server/package.json b/packages/tools/flow-graph-mcp-server/package.json new file mode 100644 index 00000000000..b42fc5cc89d --- /dev/null +++ b/packages/tools/flow-graph-mcp-server/package.json @@ -0,0 +1,29 @@ +{ + "name": "@tools/flow-graph-mcp-server", + "version": "1.0.0", + "private": true, + "description": "MCP server for AI-driven Flow Graph operations in Babylon.js", + "type": "module", + "main": "dist/index.js", + "scripts": { + "clean": "rimraf dist", + "build": "rollup -c", + "start": "node dist/index.js", + "dev": "tsc --watch" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "@tools/mcp-server-core": "1.0.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^26.0.1", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@types/node": "^20.11.0", + "esbuild": "^0.25.0", + "rollup": "^4.59.0", + "typescript": "^5.3.3" + } +} diff --git a/packages/tools/flow-graph-mcp-server/rollup.config.mjs b/packages/tools/flow-graph-mcp-server/rollup.config.mjs new file mode 100644 index 00000000000..b6a4d32812a --- /dev/null +++ b/packages/tools/flow-graph-mcp-server/rollup.config.mjs @@ -0,0 +1,3 @@ +import { createConfig } from "../rollup.config.mcp.mjs"; + +export default createConfig(); diff --git a/packages/tools/flow-graph-mcp-server/src/blockRegistry.ts b/packages/tools/flow-graph-mcp-server/src/blockRegistry.ts new file mode 100644 index 00000000000..5b9f441ea4d --- /dev/null +++ b/packages/tools/flow-graph-mcp-server/src/blockRegistry.ts @@ -0,0 +1,1826 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * Complete registry of all Flow Graph block types available in Babylon.js. + * Each entry describes the block's className, category, and its signal/data connections. + * + * This is a static catalog — the MCP server has **no Babylon.js runtime dependency**. + */ + +// ─── Types ──────────────────────────────────────────────────────────────── + +/** + * Describes a signal connection point (execution flow) on a block. + */ +export interface ISignalConnectionInfo { + /** Name of the signal connection (e.g. "in", "out", "onTrue") */ + name: string; + /** Brief description of what the signal does */ + description?: string; +} + +/** + * Describes a data connection point on a block. + */ +export interface IDataConnectionInfo { + /** Name of the data connection (e.g. "message", "condition") */ + name: string; + /** The rich type name (e.g. "any", "number", "boolean", "Vector3") */ + type: string; + /** Whether this connection is optional */ + isOptional?: boolean; + /** Brief description */ + description?: string; +} + +/** + * Describes a block type in the Flow Graph catalog. + */ +export interface IFlowGraphBlockTypeInfo { + /** The serialized class name (e.g. "FlowGraphBranchBlock") */ + className: string; + /** Category for grouping */ + category: "Event" | "Execution" | "ControlFlow" | "Animation" | "Data" | "Math" | "Vector" | "Matrix" | "Combine" | "Extract" | "Conversion" | "Utility"; + /** Human-readable description */ + description: string; + /** Signal input connection points */ + signalInputs: ISignalConnectionInfo[]; + /** Signal output connection points */ + signalOutputs: ISignalConnectionInfo[]; + /** Data input connection points */ + dataInputs: IDataConnectionInfo[]; + /** Data output connection points */ + dataOutputs: IDataConnectionInfo[]; + /** Configurable properties (config object keys) */ + config?: Record; +} + +// ─── Block Registry ─────────────────────────────────────────────────────── + +export const FlowGraphBlockRegistry: Record = { + // ═══════════════════════════════════════════════════════════════════ + // EVENT BLOCKS + // ═══════════════════════════════════════════════════════════════════ + + SceneReadyEvent: { + className: "FlowGraphSceneReadyEventBlock", + category: "Event", + description: "Triggers when the scene is ready (all assets loaded). This is the most common entry point for a flow graph.", + signalInputs: [{ name: "in", description: "Inherited signal input (not typically used for events)" }], + signalOutputs: [ + { name: "out", description: "Fires once at graph startup (use for initialization logic)" }, + { name: "done", description: "Fires when the event actually triggers (scene ready). USE THIS for event-driven logic." }, + { name: "error", description: "Fires on error" }, + ], + dataInputs: [], + dataOutputs: [], + }, + + SceneTickEvent: { + className: "FlowGraphSceneTickEventBlock", + category: "Event", + description: "Triggers every frame (scene render loop). Provides elapsed time and delta time.", + signalInputs: [{ name: "in" }], + signalOutputs: [ + { name: "out", description: "Fires once at graph startup (initialization)" }, + { name: "done", description: "Fires every frame when the tick event occurs. USE THIS for per-frame logic." }, + { name: "error", description: "Fires on error" }, + ], + dataInputs: [], + dataOutputs: [ + { name: "timeSinceStart", type: "number", description: "Total time since the scene started (seconds)" }, + { name: "deltaTime", type: "number", description: "Time since last frame (seconds)" }, + ], + }, + + MeshPickEvent: { + className: "FlowGraphMeshPickEventBlock", + category: "Event", + description: "Triggers when a mesh is picked (clicked) by the user.", + signalInputs: [{ name: "in" }], + signalOutputs: [ + { name: "out", description: "Fires once at graph startup (initialization). NOT on each pick." }, + { name: "done", description: "Fires each time the mesh is picked. USE THIS to react to clicks." }, + { name: "error" }, + ], + dataInputs: [ + { name: "asset", type: "any", description: "The target mesh to listen for picks on", isOptional: true }, + { name: "pointerType", type: "any", description: "PointerEventTypes filter (default: POINTERPICK)", isOptional: true }, + ], + dataOutputs: [ + { name: "pickedPoint", type: "Vector3", description: "World-space pick point" }, + { name: "pickOrigin", type: "Vector3", description: "Ray origin" }, + { name: "pointerId", type: "number", description: "Pointer identifier" }, + { name: "pickedMesh", type: "any", description: "The mesh that was picked" }, + ], + config: { + stopPropagation: "boolean — whether to stop event propagation", + targetMesh: "AbstractMesh reference — default target mesh", + }, + }, + + PointerOverEvent: { + className: "FlowGraphPointerOverEventBlock", + category: "Event", + description: "Triggers when the pointer moves over a mesh.", + signalInputs: [{ name: "in" }], + signalOutputs: [ + { name: "out", description: "Fires once at graph startup (initialization)" }, + { name: "done", description: "Fires each time the pointer enters the mesh. USE THIS for hover logic." }, + { name: "error" }, + ], + dataInputs: [{ name: "targetMesh", type: "any", description: "The mesh to watch", isOptional: true }], + dataOutputs: [ + { name: "pointerId", type: "number" }, + { name: "meshUnderPointer", type: "any", description: "The mesh under the pointer" }, + ], + config: { stopPropagation: "boolean", targetMesh: "AbstractMesh reference" }, + }, + + PointerOutEvent: { + className: "FlowGraphPointerOutEventBlock", + category: "Event", + description: "Triggers when the pointer moves off a mesh.", + signalInputs: [{ name: "in" }], + signalOutputs: [ + { name: "out", description: "Fires once at graph startup (initialization)" }, + { name: "done", description: "Fires each time the pointer leaves the mesh. USE THIS for hover-out logic." }, + { name: "error" }, + ], + dataInputs: [{ name: "targetMesh", type: "any", description: "The mesh to watch", isOptional: true }], + dataOutputs: [ + { name: "pointerId", type: "number" }, + { name: "meshOutOfPointer", type: "any", description: "The mesh the pointer left" }, + ], + config: { stopPropagation: "boolean", targetMesh: "AbstractMesh reference" }, + }, + + // NOTE: PointerEvent is defined in FlowGraphBlockNames but has no standalone implementation; + // use MeshPickEvent or the specific pointer event blocks below. + + PointerDownEvent: { + className: "FlowGraphPointerDownEventBlock", + category: "Event", + description: "Triggers when a pointer button is pressed down on a mesh.", + signalInputs: [{ name: "in" }], + signalOutputs: [ + { name: "out", description: "Fires once at graph startup (initialization)" }, + { name: "done", description: "Fires each time the pointer is pressed. USE THIS for press logic." }, + { name: "error" }, + ], + dataInputs: [{ name: "targetMesh", type: "any", description: "The mesh to watch", isOptional: true }], + dataOutputs: [ + { name: "pointerId", type: "number" }, + { name: "pickedMesh", type: "any", description: "The mesh that was pressed" }, + { name: "pickedPoint", type: "any", description: "The 3D point where the press occurred" }, + ], + config: { stopPropagation: "boolean", targetMesh: "AbstractMesh reference" }, + }, + + PointerUpEvent: { + className: "FlowGraphPointerUpEventBlock", + category: "Event", + description: "Triggers when a pointer button is released on a mesh.", + signalInputs: [{ name: "in" }], + signalOutputs: [ + { name: "out", description: "Fires once at graph startup (initialization)" }, + { name: "done", description: "Fires each time the pointer is released. USE THIS for release logic." }, + { name: "error" }, + ], + dataInputs: [{ name: "targetMesh", type: "any", description: "The mesh to watch", isOptional: true }], + dataOutputs: [ + { name: "pointerId", type: "number" }, + { name: "pickedMesh", type: "any", description: "The mesh that was released" }, + { name: "pickedPoint", type: "any", description: "The 3D point where the release occurred" }, + ], + config: { stopPropagation: "boolean", targetMesh: "AbstractMesh reference" }, + }, + + PointerMoveEvent: { + className: "FlowGraphPointerMoveEventBlock", + category: "Event", + description: "Triggers when the pointer moves over a mesh.", + signalInputs: [{ name: "in" }], + signalOutputs: [ + { name: "out", description: "Fires once at graph startup (initialization)" }, + { name: "done", description: "Fires each time the pointer moves. USE THIS for move logic." }, + { name: "error" }, + ], + dataInputs: [{ name: "targetMesh", type: "any", description: "The mesh to watch", isOptional: true }], + dataOutputs: [ + { name: "pointerId", type: "number" }, + { name: "meshUnderPointer", type: "any", description: "The mesh under the pointer" }, + { name: "pickedPoint", type: "any", description: "The 3D point under the pointer" }, + ], + config: { stopPropagation: "boolean", targetMesh: "AbstractMesh reference" }, + }, + + PhysicsCollisionEvent: { + className: "FlowGraphPhysicsCollisionEventBlock", + category: "Event", + description: "Triggers when a physics body collides with another body.", + signalInputs: [{ name: "in" }], + signalOutputs: [ + { name: "out", description: "Fires once at graph startup (initialization)" }, + { name: "done", description: "Fires each time a collision occurs. USE THIS for collision logic." }, + { name: "error" }, + ], + dataInputs: [{ name: "body", type: "any", description: "The PhysicsBody to watch for collisions" }], + dataOutputs: [ + { name: "otherBody", type: "any", description: "The other body in the collision" }, + { name: "point", type: "Vector3", description: "Collision contact point" }, + { name: "normal", type: "Vector3", description: "Collision normal" }, + { name: "impulse", type: "number", description: "Collision impulse magnitude" }, + { name: "distance", type: "number", description: "Penetration distance" }, + ], + }, + + AudioSoundEndedEvent: { + className: "FlowGraphSoundEndedEventBlock", + category: "Event", + description: "Triggers when a sound finishes playing.", + signalInputs: [{ name: "in" }], + signalOutputs: [ + { name: "out", description: "Fires once at graph startup (initialization)" }, + { name: "done", description: "Fires each time the sound ends. USE THIS for sound-ended logic." }, + { name: "error" }, + ], + dataInputs: [{ name: "sound", type: "any", description: "The sound to watch" }], + dataOutputs: [], + }, + + SendCustomEvent: { + className: "FlowGraphSendCustomEventBlock", + category: "Event", + description: "Sends a custom event that can be received by ReceiveCustomEvent blocks. Execution block with signal flow.", + signalInputs: [{ name: "in" }], + signalOutputs: [{ name: "out" }, { name: "error" }], + dataInputs: [], + dataOutputs: [], + config: { + eventId: "string — the custom event identifier", + eventData: "Record — dynamic data inputs are created from this", + }, + }, + + ReceiveCustomEvent: { + className: "FlowGraphReceiveCustomEventBlock", + category: "Event", + description: "Receives a custom event sent by SendCustomEvent blocks. Creates dynamic data outputs from eventData config.", + signalInputs: [{ name: "in" }], + signalOutputs: [ + { name: "out", description: "Fires once at graph startup (initialization)" }, + { name: "done", description: "Fires each time the custom event is received. USE THIS for event handling." }, + { name: "error" }, + ], + dataInputs: [], + dataOutputs: [], + config: { + eventId: "string — must match the sender's eventId", + eventData: "Record — dynamic data outputs are created from this", + }, + }, + + // ═══════════════════════════════════════════════════════════════════ + // EXECUTION BLOCKS — General + // ═══════════════════════════════════════════════════════════════════ + + ConsoleLog: { + className: "FlowGraphConsoleLogBlock", + category: "Execution", + description: "Logs a message to the browser console. Supports template strings with {placeholder} syntax.", + signalInputs: [{ name: "in" }], + signalOutputs: [{ name: "out" }, { name: "error" }], + dataInputs: [ + { name: "message", type: "any", description: "The message to log" }, + { name: "logType", type: "any", description: 'Log level: "log", "warn", or "error"', isOptional: true }, + ], + dataOutputs: [], + config: { messageTemplate: "string — template with {placeholder} names that become additional data inputs" }, + }, + + SetProperty: { + className: "FlowGraphSetPropertyBlock", + category: "Execution", + description: "Sets a property on a scene object (e.g. mesh.position, light.intensity). Generic and powerful.", + signalInputs: [{ name: "in" }], + signalOutputs: [{ name: "out" }, { name: "error" }], + dataInputs: [ + { name: "object", type: "any", description: "The target object (mesh, light, camera, etc.)" }, + { name: "value", type: "any", description: "The value to set" }, + { name: "propertyName", type: "any", description: "The property path (e.g. 'position', 'intensity')", isOptional: true }, + { name: "customSetFunction", type: "any", description: "Custom setter function", isOptional: true }, + ], + dataOutputs: [], + config: { + propertyName: "string — the property name to set", + target: "any — default target object", + }, + }, + + SetVariable: { + className: "FlowGraphSetVariableBlock", + category: "Execution", + description: "Sets a context variable that can be read by GetVariable blocks. Variables persist across executions.", + signalInputs: [{ name: "in" }], + signalOutputs: [{ name: "out" }, { name: "error" }], + dataInputs: [{ name: "value", type: "any", description: "The value to store in the variable" }], + dataOutputs: [], + config: { + variable: "string — the variable name to set (mutually exclusive with 'variables')", + variables: "string[] — multiple variable names to set (creates one input per name)", + }, + }, + + // ═══════════════════════════════════════════════════════════════════ + // CONTROL FLOW BLOCKS + // ═══════════════════════════════════════════════════════════════════ + + Branch: { + className: "FlowGraphBranchBlock", + category: "ControlFlow", + description: "If/else branching. Routes execution to onTrue or onFalse based on a boolean condition.", + signalInputs: [{ name: "in" }], + signalOutputs: [{ name: "onTrue", description: "Fires when condition is true" }, { name: "onFalse", description: "Fires when condition is false" }, { name: "error" }], + dataInputs: [{ name: "condition", type: "boolean", description: "The branching condition" }], + dataOutputs: [], + }, + + ForLoop: { + className: "FlowGraphForLoopBlock", + category: "ControlFlow", + description: "Executes a loop body for each index from startIndex to endIndex.", + signalInputs: [{ name: "in" }], + signalOutputs: [{ name: "executionFlow", description: "Fires for each iteration" }, { name: "completed", description: "Fires when the loop finishes" }, { name: "error" }], + dataInputs: [ + { name: "startIndex", type: "any", description: "Loop start index (default 0)" }, + { name: "endIndex", type: "any", description: "Loop end index (exclusive)" }, + { name: "step", type: "number", description: "Step increment (default 1)", isOptional: true }, + ], + dataOutputs: [{ name: "index", type: "FlowGraphInteger", description: "Current loop index" }], + config: { + initialIndex: "number — initial index override", + incrementIndexWhenLoopDone: "boolean — whether to increment the index when the loop is done", + }, + }, + + WhileLoop: { + className: "FlowGraphWhileLoopBlock", + category: "ControlFlow", + description: "Executes the loop body while the condition is true.", + signalInputs: [{ name: "in" }], + signalOutputs: [{ name: "executionFlow", description: "Fires for each iteration" }, { name: "completed", description: "Fires when the loop exits" }, { name: "error" }], + dataInputs: [{ name: "condition", type: "boolean", description: "Loop condition — continues while true" }], + dataOutputs: [], + config: { doWhile: "boolean — if true, executes the body at least once before checking the condition" }, + }, + + Sequence: { + className: "FlowGraphSequenceBlock", + category: "ControlFlow", + description: "Executes multiple output signals in order (out_0, out_1, ...). Like a sequential pipeline.", + signalInputs: [{ name: "in" }], + signalOutputs: [{ name: "error" }], + dataInputs: [], + dataOutputs: [], + config: { outputSignalCount: "number — how many sequential outputs to create (default 1). Creates out_0, out_1, ..." }, + }, + + Switch: { + className: "FlowGraphSwitchBlock", + category: "ControlFlow", + description: "Routes execution based on a case value. Like a switch/case statement with a default.", + signalInputs: [{ name: "in" }], + signalOutputs: [{ name: "default", description: "Fires when no case matches" }, { name: "error" }], + dataInputs: [{ name: "case", type: "any", description: "The value to switch on" }], + dataOutputs: [], + config: { cases: "T[] — array of case values. Creates signal output 'out_{value}' for each case" }, + }, + + MultiGate: { + className: "FlowGraphMultiGateBlock", + category: "ControlFlow", + description: "Routes execution to one of N outputs each time it is triggered. Can be sequential, random, or looping.", + signalInputs: [{ name: "in" }, { name: "reset", description: "Resets the gate index" }], + signalOutputs: [{ name: "error" }], + dataInputs: [], + dataOutputs: [{ name: "lastIndex", type: "FlowGraphInteger", description: "Index of the last output fired" }], + config: { + outputSignalCount: "number — how many outputs to create (out_0, out_1, ...)", + isRandom: "boolean — if true, picks outputs randomly", + isLoop: "boolean — if true, loops back to the first output after the last", + }, + }, + + WaitAll: { + className: "FlowGraphWaitAllBlock", + category: "ControlFlow", + description: "Waits for all N signal inputs to fire before triggering the output. Useful for synchronization.", + signalInputs: [{ name: "reset", description: "Resets all input states" }], + signalOutputs: [{ name: "out", description: "Fires when all inputs have triggered" }, { name: "completed" }, { name: "error" }], + dataInputs: [], + dataOutputs: [{ name: "remainingInputs", type: "FlowGraphInteger", description: "How many inputs are still pending" }], + config: { inputSignalCount: "number — how many signal inputs to create (in_0, in_1, ...)" }, + }, + + SetDelay: { + className: "FlowGraphSetDelayBlock", + category: "ControlFlow", + description: "Triggers the 'done' signal after a specified duration (in seconds). The 'out' signal fires immediately.", + signalInputs: [{ name: "in" }, { name: "cancel", description: "Cancels the pending delay" }], + signalOutputs: [{ name: "out", description: "Fires immediately" }, { name: "done", description: "Fires after the delay" }, { name: "error" }], + dataInputs: [{ name: "duration", type: "number", description: "Delay duration in seconds" }], + dataOutputs: [{ name: "lastDelayIndex", type: "FlowGraphInteger", description: "Index of the last delay set" }], + }, + + CancelDelay: { + className: "FlowGraphCancelDelayBlock", + category: "ControlFlow", + description: "Cancels a pending delay created by SetDelay.", + signalInputs: [{ name: "in" }], + signalOutputs: [{ name: "out" }, { name: "error" }], + dataInputs: [{ name: "delayIndex", type: "FlowGraphInteger", description: "Index of the delay to cancel" }], + dataOutputs: [], + }, + + CallCounter: { + className: "FlowGraphCallCounterBlock", + category: "ControlFlow", + description: "Counts how many times it has been triggered. Can be reset.", + signalInputs: [{ name: "in" }, { name: "reset", description: "Resets the counter to 0" }], + signalOutputs: [{ name: "out" }, { name: "error" }], + dataInputs: [], + dataOutputs: [{ name: "count", type: "number", description: "Current call count" }], + }, + + Debounce: { + className: "FlowGraphDebounceBlock", + category: "ControlFlow", + description: "Only fires the output after the input has been triggered N times (debounce count). Then resets.", + signalInputs: [{ name: "in" }, { name: "reset", description: "Resets the debounce counter" }], + signalOutputs: [{ name: "out" }, { name: "error" }], + dataInputs: [{ name: "count", type: "number", description: "Number of triggers before firing" }], + dataOutputs: [{ name: "currentCount", type: "number", description: "Current trigger count" }], + }, + + Throttle: { + className: "FlowGraphThrottleBlock", + category: "ControlFlow", + description: "Limits execution to at most once per duration period (in seconds).", + signalInputs: [{ name: "in" }, { name: "reset", description: "Resets the throttle timer" }], + signalOutputs: [{ name: "out" }, { name: "error" }], + dataInputs: [{ name: "duration", type: "number", description: "Minimum time between executions (seconds)" }], + dataOutputs: [{ name: "lastRemainingTime", type: "number", description: "Time remaining until next allowed execution" }], + }, + + DoN: { + className: "FlowGraphDoNBlock", + category: "ControlFlow", + description: "Fires the output only the first N times it is triggered, then stops. Can be reset.", + signalInputs: [{ name: "in" }, { name: "reset", description: "Resets the execution counter" }], + signalOutputs: [{ name: "out" }, { name: "error" }], + dataInputs: [{ name: "maxExecutions", type: "FlowGraphInteger", description: "Maximum number of times to fire" }], + dataOutputs: [{ name: "executionCount", type: "FlowGraphInteger", description: "How many times it has fired" }], + config: { startIndex: "FlowGraphInteger — initial count value" }, + }, + + FlipFlop: { + className: "FlowGraphFlipFlopBlock", + category: "ControlFlow", + description: "Alternates between onOn and onOff signals each time it is triggered. Like a toggle switch.", + signalInputs: [{ name: "in" }], + signalOutputs: [{ name: "onOn", description: "Fires on odd triggers" }, { name: "onOff", description: "Fires on even triggers" }, { name: "error" }], + dataInputs: [], + dataOutputs: [{ name: "value", type: "boolean", description: "Current toggle state" }], + config: { startValue: "boolean — initial toggle state (default false)" }, + }, + + // ═══════════════════════════════════════════════════════════════════ + // ANIMATION BLOCKS + // ═══════════════════════════════════════════════════════════════════ + + PlayAnimation: { + className: "FlowGraphPlayAnimationBlock", + category: "Animation", + description: "Plays an animation or animation group. Provides current frame and event outputs.", + signalInputs: [{ name: "in" }], + signalOutputs: [ + { name: "out", description: "Fires immediately when play starts" }, + { name: "done", description: "Fires when the animation finishes" }, + { name: "error" }, + { name: "animationLoopEvent", description: "Fires each time the animation loops" }, + { name: "animationEndEvent", description: "Fires when the animation ends" }, + { name: "animationGroupLoopEvent", description: "Fires when an animation group loops" }, + ], + dataInputs: [ + { name: "speed", type: "number", description: "Playback speed (default 0)" }, + { name: "loop", type: "boolean", description: "Whether to loop (default false)" }, + { name: "from", type: "number", description: "Start frame (default 0)" }, + { name: "to", type: "number", description: "End frame (default 0 = full range)" }, + { name: "animationGroup", type: "any", description: "AnimationGroup to play" }, + { name: "animation", type: "any", description: "Animation or Animation[] to play", isOptional: true }, + { name: "object", type: "any", description: "Target object for the animation", isOptional: true }, + ], + dataOutputs: [ + { name: "currentFrame", type: "number", description: "Current animation frame" }, + { name: "currentTime", type: "number", description: "Current animation time" }, + { name: "currentAnimationGroup", type: "any", description: "The active animation group" }, + ], + config: { animationGroup: "AnimationGroup — default animation group" }, + }, + + StopAnimation: { + className: "FlowGraphStopAnimationBlock", + category: "Animation", + description: "Stops a playing animation group.", + signalInputs: [{ name: "in" }], + signalOutputs: [{ name: "out" }, { name: "done" }, { name: "error" }], + dataInputs: [ + { name: "animationGroup", type: "any", description: "The animation group to stop" }, + { name: "stopAtFrame", type: "number", description: "Frame to stop at (-1 = current)", isOptional: true }, + ], + dataOutputs: [], + }, + + PauseAnimation: { + className: "FlowGraphPauseAnimationBlock", + category: "Animation", + description: "Pauses a playing animation group.", + signalInputs: [{ name: "in" }], + signalOutputs: [{ name: "out" }, { name: "error" }], + dataInputs: [{ name: "animationToPause", type: "any", description: "The animation group to pause" }], + dataOutputs: [], + }, + + ValueInterpolation: { + className: "FlowGraphInterpolationBlock", + category: "Animation", + description: "Creates an Animation object from keyframe values. Use with PlayAnimation to animate properties. " + "Takes duration/value pairs for each keyframe.", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "easingFunction", type: "any", description: "Optional easing function from Easing block", isOptional: true }, + { name: "propertyName", type: "any", description: "Property name(s) to animate", isOptional: true }, + { name: "customBuildAnimation", type: "any", description: "Custom animation builder function", isOptional: true }, + ], + dataOutputs: [{ name: "animation", type: "any", description: "The generated Animation object" }], + config: { + keyFramesCount: "number — number of keyframes (creates duration_N and value_N inputs for each)", + duration: "number — default duration per keyframe", + propertyName: "string|string[] — property path(s) to animate", + animationType: "number|FlowGraphTypes — type of animated value (e.g. BABYLON.Animation.ANIMATIONTYPE_VECTOR3)", + }, + }, + + Easing: { + className: "FlowGraphEasingBlock", + category: "Animation", + description: "Creates an easing function for use with ValueInterpolation. Supports all Babylon.js easing types.", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "type", type: "any", description: "EasingFunctionType enum value (default 11 = BezierCurve)" }, + { name: "mode", type: "number", description: "Easing mode: 0=EaseIn, 1=EaseOut, 2=EaseInOut" }, + { name: "parameters", type: "any", description: "Easing parameters as number array (default [1,0,0,1])", isOptional: true }, + ], + dataOutputs: [{ name: "easingFunction", type: "any", description: "The easing function object" }], + }, + + BezierCurveEasing: { + className: "FlowGraphBezierCurveEasing", + category: "Animation", + description: "Creates a bezier curve easing function with two control points.", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "mode", type: "number", description: "Easing mode: 0=EaseIn, 1=EaseOut, 2=EaseInOut" }, + { name: "controlPoint1", type: "Vector2", description: "First control point" }, + { name: "controlPoint2", type: "Vector2", description: "Second control point" }, + ], + dataOutputs: [{ name: "easingFunction", type: "any", description: "The bezier easing function object" }], + }, + + // ═══════════════════════════════════════════════════════════════════ + // DATA BLOCKS — General + // ═══════════════════════════════════════════════════════════════════ + + Constant: { + className: "FlowGraphConstantBlock", + category: "Data", + description: "Outputs a constant value. The type is deduced from the config value. Supports numbers, strings, booleans, vectors, colors, etc.", + signalInputs: [], + signalOutputs: [], + dataInputs: [], + dataOutputs: [{ name: "output", type: "any", description: "The constant value" }], + config: { value: "any — the constant value (e.g. 42, 'hello', { value: [1,2,3], className: 'Vector3' })" }, + }, + + GetVariable: { + className: "FlowGraphGetVariableBlock", + category: "Data", + description: "Reads a context variable set by SetVariable. Variables persist across executions of the graph.", + signalInputs: [], + signalOutputs: [], + dataInputs: [], + dataOutputs: [{ name: "value", type: "any", description: "The variable's current value" }], + config: { + variable: "string — the variable name to read", + initialValue: "any — default value if the variable hasn't been set", + }, + }, + + GetProperty: { + className: "FlowGraphGetPropertyBlock", + category: "Data", + description: "Reads a property from a scene object (e.g. mesh.position, light.intensity).", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "object", type: "any", description: "The target object" }, + { name: "propertyName", type: "any", description: "The property name to read", isOptional: true }, + { name: "customGetFunction", type: "any", description: "Custom getter function", isOptional: true }, + ], + dataOutputs: [ + { name: "value", type: "any", description: "The property value" }, + { name: "isValid", type: "boolean", description: "Whether the property was found" }, + ], + config: { + propertyName: "string — property path to read", + object: "any — default target object", + resetToDefaultWhenUndefined: "boolean — reset to default when undefined", + }, + }, + + GetAsset: { + className: "FlowGraphGetAssetBlock", + category: "Data", + description: "Retrieves an asset (mesh, material, texture, etc.) from the scene's asset context by type and index.", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "type", type: "any", description: "FlowGraphAssetType — the kind of asset to retrieve" }, + { name: "index", type: "any", description: "Index of the asset in the collection", isOptional: true }, + ], + dataOutputs: [{ name: "value", type: "any", description: "The retrieved asset" }], + config: { + type: "FlowGraphAssetType — asset type enum", + index: "number — asset index in the collection", + useIndexAsUniqueId: "boolean — whether to use index as uniqueId lookup", + }, + }, + + Conditional: { + className: "FlowGraphConditionalBlock", + category: "Data", + description: "Ternary operator — returns onTrue if condition is true, onFalse otherwise. Pure data block (no signals).", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "condition", type: "boolean", description: "The condition to evaluate" }, + { name: "onTrue", type: "any", description: "Value returned when condition is true" }, + { name: "onFalse", type: "any", description: "Value returned when condition is false" }, + ], + dataOutputs: [{ name: "output", type: "any", description: "The selected value" }], + }, + + TransformCoordinatesSystem: { + className: "FlowGraphTransformCoordinatesSystemBlock", + category: "Data", + description: "Transforms coordinates from one coordinate system to another using two TransformNodes.", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "sourceSystem", type: "any", description: "Source TransformNode" }, + { name: "destinationSystem", type: "any", description: "Destination TransformNode" }, + { name: "inputCoordinates", type: "Vector3", description: "Coordinates to transform" }, + ], + dataOutputs: [{ name: "outputCoordinates", type: "Vector3", description: "Transformed coordinates" }], + }, + + JsonPointerParser: { + className: "FlowGraphJsonPointerParserBlock", + category: "Data", + description: "Resolves a JSON pointer path to an object and property. Used internally for glTF interop.", + signalInputs: [], + signalOutputs: [], + dataInputs: [], + dataOutputs: [ + { name: "value", type: "any" }, + { name: "isValid", type: "boolean" }, + { name: "object", type: "any" }, + { name: "propertyName", type: "any" }, + ], + config: { jsonPointer: "string — the JSON pointer path" }, + }, + + DataSwitch: { + className: "FlowGraphDataSwitchBlock", + category: "Data", + description: "Selects a data value based on a case. Like a data-only switch/case.", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "case", type: "any", description: "The case value to match" }, + { name: "default", type: "any", description: "Default value if no case matches" }, + ], + dataOutputs: [{ name: "value", type: "any", description: "The selected value" }], + config: { + cases: "number[] — case values. Creates data input 'in_{value}' for each case", + treatCasesAsIntegers: "boolean — whether to treat case values as integers", + }, + }, + + // ═══════════════════════════════════════════════════════════════════ + // UTILITY DATA BLOCKS + // ═══════════════════════════════════════════════════════════════════ + + Context: { + className: "FlowGraphContextBlock", + category: "Utility", + description: "Provides access to the flow graph execution context (user variables, execution ID).", + signalInputs: [], + signalOutputs: [], + dataInputs: [], + dataOutputs: [ + { name: "userVariables", type: "any", description: "All user variables as a dictionary" }, + { name: "executionId", type: "number", description: "Current execution ID" }, + ], + }, + + ArrayIndex: { + className: "FlowGraphArrayIndexBlock", + category: "Utility", + description: "Retrieves an element from an array by index.", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "array", type: "any", description: "The source array" }, + { name: "index", type: "any", description: "The index to retrieve" }, + ], + dataOutputs: [{ name: "value", type: "any", description: "The element at the specified index" }], + }, + + IndexOf: { + className: "FlowGraphIndexOfBlock", + category: "Utility", + description: "Finds the index of an element in an array.", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "object", type: "any", description: "The element to search for" }, + { name: "array", type: "any", description: "The array to search in" }, + ], + dataOutputs: [{ name: "index", type: "FlowGraphInteger", description: "The index of the element (-1 if not found)" }], + }, + + CodeExecution: { + className: "FlowGraphCodeExecutionBlock", + category: "Utility", + description: + "Executes an arbitrary JavaScript function inside the flow graph — the 'escape hatch' for any logic " + + "not covered by dedicated blocks. This is a DATA block (no signal inputs/outputs); it evaluates " + + "lazily when its 'result' output is read by a downstream block. " + + "The function signature is: (value: any, context: FlowGraphContext) => any. " + + "The 'value' input can carry any data (a mesh, a number, an object with multiple fields, etc.), " + + "and 'context' is the FlowGraphContext giving access to the scene via context.assetsContext. " + + "Use the FunctionReference block to create the function from an object + method name, " + + "or provide the function via the scene's glue code / code generator. " + + "Common use cases: physics (applyImpulse, setLinearVelocity), mesh cloning (mesh.clone), " + + "reading/writing complex properties, invoking any Babylon.js API not exposed as a dedicated block.", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { + name: "function", + type: "any", + description: + "A CodeExecutionFunction: (value, context) => result. " + + "Can come from a FunctionReference block or be set in glue code. " + + "For physics impulse example: (v, ctx) => { v.mesh.physicsBody.applyImpulse(v.impulse, v.point); return true; }", + }, + { + name: "value", + type: "any", + description: "The input value passed as the first argument to the function. " + "Can be any type — a mesh, a Vector3, or a composite object with multiple fields.", + }, + ], + dataOutputs: [{ name: "result", type: "any", description: "The return value of the function" }], + }, + + FunctionReference: { + className: "FlowGraphFunctionReference", + category: "Utility", + description: + "Creates a callable function reference. Two modes of use:\n" + + "MODE A — Object method lookup: connect 'functionName' and 'object' data inputs. " + + "The block does object[functionName].bind(context) and outputs the bound function.\n" + + "MODE B — Inline code (config.code): put arbitrary JavaScript in config.code. " + + "The code generator will compile it into a real function and inject it at runtime. " + + "Leave 'functionName' and 'object' data inputs UNCONNECTED when using Mode B. " + + "The code has access to 'scene' (the Babylon.js scene) and 'BABYLON' (the namespace). " + + "The function signature is (value, fgContext) => result. Return a value if needed.\n" + + "Connect the 'output' to a CodeExecution block's 'function' input. " + + "This is a DATA block — it evaluates lazily when its output is read.", + signalInputs: [], + signalOutputs: [], + config: { + code: + "string — Optional. Arbitrary JavaScript code compiled into a function by the code generator. " + + "Has access to 'scene' (via closure) and any BABYLON API. " + + "Example: const ball = scene.getMeshByName('ball'); ball.physicsBody.applyImpulse(...); return 1;", + }, + dataInputs: [ + { + name: "functionName", + type: "string", + description: "Dot-separated path to the method on the object (e.g. 'physicsBody.applyImpulse', 'clone'). " + "Leave unconnected when using config.code.", + }, + { name: "object", type: "any", description: "The object containing the function (e.g. a mesh from GetAsset). Leave unconnected when using config.code." }, + { name: "context", type: "any", description: "Optional 'this' context for the function call", isOptional: true }, + ], + dataOutputs: [{ name: "output", type: "any", description: "The bound function reference, ready to be called or connected to CodeExecution" }], + }, + + // ═══════════════════════════════════════════════════════════════════ + // MATH BLOCKS — Constants + // ═══════════════════════════════════════════════════════════════════ + + E: { + className: "FlowGraphEBlock", + category: "Math", + description: "Outputs the mathematical constant e (≈ 2.718).", + signalInputs: [], + signalOutputs: [], + dataInputs: [], + dataOutputs: [ + { name: "value", type: "number" }, + { name: "isValid", type: "boolean" }, + ], + }, + + PI: { + className: "FlowGraphPIBlock", + category: "Math", + description: "Outputs the mathematical constant π (≈ 3.14159).", + signalInputs: [], + signalOutputs: [], + dataInputs: [], + dataOutputs: [ + { name: "value", type: "number" }, + { name: "isValid", type: "boolean" }, + ], + }, + + Inf: { + className: "FlowGraphInfBlock", + category: "Math", + description: "Outputs positive infinity.", + signalInputs: [], + signalOutputs: [], + dataInputs: [], + dataOutputs: [ + { name: "value", type: "number" }, + { name: "isValid", type: "boolean" }, + ], + }, + + NaN: { + className: "FlowGraphNaNBlock", + category: "Math", + description: "Outputs NaN (Not a Number).", + signalInputs: [], + signalOutputs: [], + dataInputs: [], + dataOutputs: [ + { name: "value", type: "number" }, + { name: "isValid", type: "boolean" }, + ], + }, + + Random: { + className: "FlowGraphRandomBlock", + category: "Math", + description: "Outputs a random number between min and max.", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "min", type: "number", description: "Minimum value (default 0)", isOptional: true }, + { name: "max", type: "number", description: "Maximum value (default 1)", isOptional: true }, + ], + dataOutputs: [ + { name: "value", type: "number" }, + { name: "isValid", type: "boolean" }, + ], + config: { min: "number", max: "number", seed: "number — random seed for deterministic results" }, + }, + + // ═══════════════════════════════════════════════════════════════════ + // MATH BLOCKS — Arithmetic (Unary) + // ═══════════════════════════════════════════════════════════════════ + + ...makeUnaryMathBlock("Abs", "FlowGraphAbsBlock", "Absolute value of the input."), + ...makeUnaryMathBlock("Sign", "FlowGraphSignBlock", "Returns -1, 0, or 1 based on the sign of the input."), + ...makeUnaryMathBlock("Trunc", "FlowGraphTruncBlock", "Truncates the input to an integer (removes decimal part)."), + ...makeUnaryMathBlock("Floor", "FlowGraphFloorBlock", "Rounds the input down to the nearest integer."), + ...makeUnaryMathBlock("Ceil", "FlowGraphCeilBlock", "Rounds the input up to the nearest integer."), + ...makeUnaryMathBlock("Round", "FlowGraphRoundBlock", "Rounds the input to the nearest integer."), + ...makeUnaryMathBlock("Fraction", "FlowGraphFractBlock", "Returns the fractional part of the input."), + ...makeUnaryMathBlock("Negation", "FlowGraphNegationBlock", "Negates the input value."), + ...makeUnaryMathBlock("Saturate", "FlowGraphSaturateBlock", "Clamps the input to the range [0, 1]."), + + // ─── Checks ─────────────────────────────────────────────────────── + ...makeUnaryMathBlock("IsNaN", "FlowGraphIsNaNBlock", "Returns true if the input is NaN.", "boolean"), + ...makeUnaryMathBlock("IsInfinity", "FlowGraphIsInfBlock", "Returns true if the input is infinite.", "boolean"), + + // ─── Angle Conversion ───────────────────────────────────────────── + ...makeUnaryMathBlock("DegToRad", "FlowGraphDegToRadBlock", "Converts degrees to radians."), + ...makeUnaryMathBlock("RadToDeg", "FlowGraphRadToDegBlock", "Converts radians to degrees."), + + // ─── Trigonometry ───────────────────────────────────────────────── + ...makeUnaryMathBlock("Sin", "FlowGraphSinBlock", "Sine of the input (in radians)."), + ...makeUnaryMathBlock("Cos", "FlowGraphCosBlock", "Cosine of the input (in radians)."), + ...makeUnaryMathBlock("Tan", "FlowGraphTanBlock", "Tangent of the input (in radians)."), + ...makeUnaryMathBlock("Asin", "FlowGraphASinBlock", "Arcsine (inverse sine) of the input."), + ...makeUnaryMathBlock("Acos", "FlowGraphACosBlock", "Arccosine (inverse cosine) of the input."), + ...makeUnaryMathBlock("Atan", "FlowGraphATanBlock", "Arctangent (inverse tangent) of the input."), + ...makeUnaryMathBlock("Sinh", "FlowGraphSinhBlock", "Hyperbolic sine."), + ...makeUnaryMathBlock("Cosh", "FlowGraphCoshBlock", "Hyperbolic cosine."), + ...makeUnaryMathBlock("Tanh", "FlowGraphTanhBlock", "Hyperbolic tangent."), + ...makeUnaryMathBlock("Asinh", "FlowGraphASinhBlock", "Inverse hyperbolic sine."), + ...makeUnaryMathBlock("Acosh", "FlowGraphACoshBlock", "Inverse hyperbolic cosine."), + ...makeUnaryMathBlock("Atanh", "FlowGraphATanhBlock", "Inverse hyperbolic tangent."), + + // ─── Logarithmic & Power ────────────────────────────────────────── + ...makeUnaryMathBlock("Exponential", "FlowGraphExponentialBlock", "e raised to the power of the input (e^x)."), + ...makeUnaryMathBlock("Log", "FlowGraphLogBlock", "Natural logarithm (base e)."), + ...makeUnaryMathBlock("Log2", "FlowGraphLog2Block", "Base-2 logarithm."), + ...makeUnaryMathBlock("Log10", "FlowGraphLog10Block", "Base-10 logarithm."), + ...makeUnaryMathBlock("SquareRoot", "FlowGraphSquareRootBlock", "Square root of the input."), + ...makeUnaryMathBlock("CubeRoot", "FlowGraphCubeRootBlock", "Cube root of the input."), + + // ─── Bitwise Unary ──────────────────────────────────────────────── + ...makeUnaryMathBlock("BitwiseNot", "FlowGraphBitwiseNotBlock", "Bitwise NOT of the input.", "FlowGraphInteger", "FlowGraphInteger"), + ...makeUnaryMathBlock("LeadingZeros", "FlowGraphLeadingZerosBlock", "Count of leading zero bits.", "FlowGraphInteger", "FlowGraphInteger"), + ...makeUnaryMathBlock("TrailingZeros", "FlowGraphTrailingZerosBlock", "Count of trailing zero bits.", "FlowGraphInteger", "FlowGraphInteger"), + ...makeUnaryMathBlock("OneBitsCounter", "FlowGraphOneBitsCounterBlock", "Count of set (1) bits.", "FlowGraphInteger", "FlowGraphInteger"), + + // ═══════════════════════════════════════════════════════════════════ + // MATH BLOCKS — Arithmetic (Binary) + // ═══════════════════════════════════════════════════════════════════ + + ...makeBinaryMathBlock("Add", "FlowGraphAddBlock", "Adds two values (a + b). Works with numbers, vectors, and matrices."), + ...makeBinaryMathBlock("Subtract", "FlowGraphSubtractBlock", "Subtracts b from a (a - b)."), + ...makeBinaryMathBlock("Multiply", "FlowGraphMultiplyBlock", "Multiplies two values (a * b)."), + ...makeBinaryMathBlock("Divide", "FlowGraphDivideBlock", "Divides a by b (a / b)."), + ...makeBinaryMathBlock("Modulo", "FlowGraphModuloBlock", "Remainder of a / b."), + ...makeBinaryMathBlock("Min", "FlowGraphMinBlock", "Returns the smaller of a and b."), + ...makeBinaryMathBlock("Max", "FlowGraphMaxBlock", "Returns the larger of a and b."), + ...makeBinaryMathBlock("Power", "FlowGraphPowerBlock", "Raises a to the power of b (a^b)."), + ...makeBinaryMathBlock("Atan2", "FlowGraphATan2Block", "Two-argument arctangent atan2(a, b)."), + + // ─── Comparison ─────────────────────────────────────────────────── + ...makeBinaryMathBlock("Equality", "FlowGraphEqualityBlock", "Returns true if a equals b.", "boolean"), + ...makeBinaryMathBlock("LessThan", "FlowGraphLessThanBlock", "Returns true if a < b.", "boolean"), + ...makeBinaryMathBlock("LessThanOrEqual", "FlowGraphLessThanOrEqualBlock", "Returns true if a <= b.", "boolean"), + ...makeBinaryMathBlock("GreaterThan", "FlowGraphGreaterThanBlock", "Returns true if a > b.", "boolean"), + ...makeBinaryMathBlock("GreaterThanOrEqual", "FlowGraphGreaterThanOrEqualBlock", "Returns true if a >= b.", "boolean"), + + // ─── Bitwise Binary ─────────────────────────────────────────────── + ...makeBinaryMathBlock("BitwiseAnd", "FlowGraphBitwiseAndBlock", "Bitwise AND.", "FlowGraphInteger", "FlowGraphInteger", "FlowGraphInteger"), + ...makeBinaryMathBlock("BitwiseOr", "FlowGraphBitwiseOrBlock", "Bitwise OR.", "FlowGraphInteger", "FlowGraphInteger", "FlowGraphInteger"), + ...makeBinaryMathBlock("BitwiseXor", "FlowGraphBitwiseXorBlock", "Bitwise XOR.", "FlowGraphInteger", "FlowGraphInteger", "FlowGraphInteger"), + ...makeBinaryMathBlock("BitwiseLeftShift", "FlowGraphBitwiseLeftShiftBlock", "Left bit shift.", "FlowGraphInteger", "FlowGraphInteger", "FlowGraphInteger"), + ...makeBinaryMathBlock("BitwiseRightShift", "FlowGraphBitwiseRightShiftBlock", "Right bit shift.", "FlowGraphInteger", "FlowGraphInteger", "FlowGraphInteger"), + + // ─── Ternary Math ───────────────────────────────────────────────── + Clamp: { + className: "FlowGraphClampBlock", + category: "Math", + description: "Clamps value a between b (min) and c (max).", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "a", type: "any", description: "Value to clamp" }, + { name: "b", type: "any", description: "Minimum" }, + { name: "c", type: "any", description: "Maximum" }, + ], + dataOutputs: [ + { name: "value", type: "any" }, + { name: "isValid", type: "boolean" }, + ], + }, + + MathInterpolation: { + className: "FlowGraphMathInterpolationBlock", + category: "Math", + description: "Linear interpolation: lerp(a, b, c) — returns a + (b - a) * c.", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "a", type: "any", description: "Start value" }, + { name: "b", type: "any", description: "End value" }, + { name: "c", type: "any", description: "Interpolation factor (0-1)" }, + ], + dataOutputs: [ + { name: "value", type: "any" }, + { name: "isValid", type: "boolean" }, + ], + }, + + // ═══════════════════════════════════════════════════════════════════ + // VECTOR / QUATERNION BLOCKS + // ═══════════════════════════════════════════════════════════════════ + + ...makeUnaryMathBlock("Length", "FlowGraphLengthBlock", "Returns the length (magnitude) of a vector.", "number"), + ...makeUnaryMathBlock("Normalize", "FlowGraphNormalizeBlock", "Returns a normalized (unit-length) version of the vector."), + ...makeUnaryMathBlock("Conjugate", "FlowGraphConjugateBlock", "Returns the conjugate of a quaternion.", "Quaternion", "Quaternion"), + + Dot: { + className: "FlowGraphDotBlock", + category: "Vector", + description: "Computes the dot product of two vectors.", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "a", type: "any", description: "First vector" }, + { name: "b", type: "any", description: "Second vector" }, + ], + dataOutputs: [ + { name: "value", type: "number" }, + { name: "isValid", type: "boolean" }, + ], + }, + + Cross: { + className: "FlowGraphCrossBlock", + category: "Vector", + description: "Computes the cross product of two Vector3 values.", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "a", type: "Vector3" }, + { name: "b", type: "Vector3" }, + ], + dataOutputs: [ + { name: "value", type: "Vector3" }, + { name: "isValid", type: "boolean" }, + ], + }, + + Rotate2D: { + className: "FlowGraphRotate2DBlock", + category: "Vector", + description: "Rotates a 2D vector by an angle (in radians).", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "a", type: "Vector2", description: "The vector to rotate" }, + { name: "b", type: "number", description: "Angle in radians" }, + ], + dataOutputs: [ + { name: "value", type: "Vector2" }, + { name: "isValid", type: "boolean" }, + ], + }, + + Rotate3D: { + className: "FlowGraphRotate3DBlock", + category: "Vector", + description: "Rotates a 3D vector by a quaternion rotation.", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "a", type: "Vector3", description: "The vector to rotate" }, + { name: "b", type: "Quaternion", description: "The rotation quaternion" }, + ], + dataOutputs: [ + { name: "value", type: "Vector3" }, + { name: "isValid", type: "boolean" }, + ], + }, + + TransformVector: { + className: "FlowGraphTransformVectorBlock", + category: "Vector", + description: "Transforms a vector by a matrix.", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "a", type: "any", description: "The vector to transform" }, + { name: "b", type: "any", description: "The transformation matrix" }, + ], + dataOutputs: [ + { name: "value", type: "any" }, + { name: "isValid", type: "boolean" }, + ], + }, + + TransformCoordinates: { + className: "FlowGraphTransformCoordinatesBlock", + category: "Vector", + description: "Transforms a Vector3 position by a Matrix (like Vector3.TransformCoordinates).", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "a", type: "Vector3", description: "The position to transform" }, + { name: "b", type: "Matrix", description: "The transformation matrix" }, + ], + dataOutputs: [ + { name: "value", type: "Vector3" }, + { name: "isValid", type: "boolean" }, + ], + }, + + AngleBetween: { + className: "FlowGraphAngleBetweenBlock", + category: "Vector", + description: "Computes the angle between two quaternions.", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "a", type: "Quaternion" }, + { name: "b", type: "Quaternion" }, + ], + dataOutputs: [ + { name: "value", type: "number" }, + { name: "isValid", type: "boolean" }, + ], + }, + + QuaternionFromAxisAngle: { + className: "FlowGraphQuaternionFromAxisAngleBlock", + category: "Vector", + description: "Creates a quaternion from an axis and angle.", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "a", type: "Vector3", description: "Rotation axis" }, + { name: "b", type: "number", description: "Rotation angle in radians" }, + ], + dataOutputs: [ + { name: "value", type: "Quaternion" }, + { name: "isValid", type: "boolean" }, + ], + }, + + AxisAngleFromQuaternion: { + className: "FlowGraphAxisAngleFromQuaternionBlock", + category: "Vector", + description: "Decomposes a quaternion into an axis and angle.", + signalInputs: [], + signalOutputs: [], + dataInputs: [{ name: "a", type: "Quaternion" }], + dataOutputs: [ + { name: "axis", type: "Vector3" }, + { name: "angle", type: "number" }, + { name: "isValid", type: "boolean" }, + ], + }, + + QuaternionFromDirections: { + className: "FlowGraphQuaternionFromDirectionsBlock", + category: "Vector", + description: "Creates a quaternion that rotates one direction to another.", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "a", type: "Vector3", description: "From direction" }, + { name: "b", type: "Vector3", description: "To direction" }, + ], + dataOutputs: [ + { name: "value", type: "Quaternion" }, + { name: "isValid", type: "boolean" }, + ], + }, + + // ═══════════════════════════════════════════════════════════════════ + // MATRIX BLOCKS + // ═══════════════════════════════════════════════════════════════════ + + ...makeUnaryMathBlock("Transpose", "FlowGraphTransposeBlock", "Transposes a matrix.", "Matrix", "Matrix"), + ...makeUnaryMathBlock("Determinant", "FlowGraphDeterminantBlock", "Computes the determinant of a matrix.", "number", "Matrix"), + ...makeUnaryMathBlock("InvertMatrix", "FlowGraphInvertMatrixBlock", "Inverts a matrix.", "Matrix", "Matrix"), + + MatrixMultiplication: { + className: "FlowGraphMatrixMultiplicationBlock", + category: "Matrix", + description: "Multiplies two matrices together.", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "a", type: "Matrix" }, + { name: "b", type: "Matrix" }, + ], + dataOutputs: [ + { name: "value", type: "Matrix" }, + { name: "isValid", type: "boolean" }, + ], + }, + + MatrixDecompose: { + className: "FlowGraphMatrixDecompose", + category: "Matrix", + description: "Decomposes a matrix into position, rotation (quaternion), and scale.", + signalInputs: [], + signalOutputs: [], + dataInputs: [{ name: "input", type: "Matrix" }], + dataOutputs: [ + { name: "position", type: "Vector3" }, + { name: "rotationQuaternion", type: "Quaternion" }, + { name: "scaling", type: "Vector3" }, + { name: "isValid", type: "boolean" }, + ], + }, + + MatrixCompose: { + className: "FlowGraphMatrixCompose", + category: "Matrix", + description: "Composes a matrix from position, rotation (quaternion), and scale.", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "position", type: "Vector3" }, + { name: "rotationQuaternion", type: "Quaternion" }, + { name: "scaling", type: "Vector3" }, + ], + dataOutputs: [{ name: "value", type: "Matrix" }], + }, + + // ═══════════════════════════════════════════════════════════════════ + // COMBINE BLOCKS + // ═══════════════════════════════════════════════════════════════════ + + CombineVector2: { + className: "FlowGraphCombineVector2Block", + category: "Combine", + description: "Combines two numbers into a Vector2.", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "input_0", type: "number", description: "X component" }, + { name: "input_1", type: "number", description: "Y component" }, + ], + dataOutputs: [ + { name: "value", type: "Vector2" }, + { name: "isValid", type: "boolean" }, + ], + }, + + CombineVector3: { + className: "FlowGraphCombineVector3Block", + category: "Combine", + description: "Combines three numbers into a Vector3.", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "input_0", type: "number", description: "X component" }, + { name: "input_1", type: "number", description: "Y component" }, + { name: "input_2", type: "number", description: "Z component" }, + ], + dataOutputs: [ + { name: "value", type: "Vector3" }, + { name: "isValid", type: "boolean" }, + ], + }, + + CombineVector4: { + className: "FlowGraphCombineVector4Block", + category: "Combine", + description: "Combines four numbers into a Vector4.", + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "input_0", type: "number", description: "X component" }, + { name: "input_1", type: "number", description: "Y component" }, + { name: "input_2", type: "number", description: "Z component" }, + { name: "input_3", type: "number", description: "W component" }, + ], + dataOutputs: [ + { name: "value", type: "Vector4" }, + { name: "isValid", type: "boolean" }, + ], + }, + + CombineMatrix: { + className: "FlowGraphCombineMatrixBlock", + category: "Combine", + description: "Combines 16 numbers into a 4x4 Matrix.", + signalInputs: [], + signalOutputs: [], + dataInputs: Array.from({ length: 16 }, (_, i) => ({ + name: `input_${i}`, + type: "number", + description: `Matrix element [${Math.floor(i / 4)}][${i % 4}]`, + })), + dataOutputs: [ + { name: "value", type: "Matrix" }, + { name: "isValid", type: "boolean" }, + ], + config: { inputIsColumnMajor: "boolean — whether inputs are in column-major order" }, + }, + + CombineMatrix2D: { + className: "FlowGraphCombineMatrix2DBlock", + category: "Combine", + description: "Combines 4 float values into a 2x2 Matrix2D.", + signalInputs: [], + signalOutputs: [], + dataInputs: Array.from({ length: 4 }, (_, i) => ({ + name: `input_${i}`, + type: "number", + description: `Matrix element ${i}`, + })), + dataOutputs: [ + { name: "value", type: "Matrix2D" }, + { name: "isValid", type: "boolean" }, + ], + config: { inputIsColumnMajor: "boolean — whether inputs are in column-major order" }, + }, + + CombineMatrix3D: { + className: "FlowGraphCombineMatrix3DBlock", + category: "Combine", + description: "Combines 9 float values into a 3x3 Matrix3D.", + signalInputs: [], + signalOutputs: [], + dataInputs: Array.from({ length: 9 }, (_, i) => ({ + name: `input_${i}`, + type: "number", + description: `Matrix element [${Math.floor(i / 3)}][${i % 3}]`, + })), + dataOutputs: [ + { name: "value", type: "Matrix3D" }, + { name: "isValid", type: "boolean" }, + ], + config: { inputIsColumnMajor: "boolean — whether inputs are in column-major order" }, + }, + + // ═══════════════════════════════════════════════════════════════════ + // EXTRACT BLOCKS + // ═══════════════════════════════════════════════════════════════════ + + ExtractVector2: { + className: "FlowGraphExtractVector2Block", + category: "Extract", + description: "Extracts the X and Y components from a Vector2.", + signalInputs: [], + signalOutputs: [], + dataInputs: [{ name: "input", type: "Vector2" }], + dataOutputs: [ + { name: "output_0", type: "number", description: "X" }, + { name: "output_1", type: "number", description: "Y" }, + ], + }, + + ExtractVector3: { + className: "FlowGraphExtractVector3Block", + category: "Extract", + description: "Extracts the X, Y, and Z components from a Vector3.", + signalInputs: [], + signalOutputs: [], + dataInputs: [{ name: "input", type: "Vector3" }], + dataOutputs: [ + { name: "output_0", type: "number", description: "X" }, + { name: "output_1", type: "number", description: "Y" }, + { name: "output_2", type: "number", description: "Z" }, + ], + }, + + ExtractVector4: { + className: "FlowGraphExtractVector4Block", + category: "Extract", + description: "Extracts the X, Y, Z, and W components from a Vector4.", + signalInputs: [], + signalOutputs: [], + dataInputs: [{ name: "input", type: "Vector4" }], + dataOutputs: [ + { name: "output_0", type: "number", description: "X" }, + { name: "output_1", type: "number", description: "Y" }, + { name: "output_2", type: "number", description: "Z" }, + { name: "output_3", type: "number", description: "W" }, + ], + }, + + ExtractMatrix: { + className: "FlowGraphExtractMatrixBlock", + category: "Extract", + description: "Extracts all 16 elements from a 4x4 Matrix.", + signalInputs: [], + signalOutputs: [], + dataInputs: [{ name: "input", type: "Matrix" }], + dataOutputs: Array.from({ length: 16 }, (_, i) => ({ + name: `output_${i}`, + type: "number", + description: `Matrix element [${Math.floor(i / 4)}][${i % 4}]`, + })), + }, + + ExtractMatrix2D: { + className: "FlowGraphExtractMatrix2DBlock", + category: "Extract", + description: "Extracts all 4 elements from a 2x2 Matrix2D.", + signalInputs: [], + signalOutputs: [], + dataInputs: [{ name: "input", type: "Matrix2D" }], + dataOutputs: Array.from({ length: 4 }, (_, i) => ({ + name: `output_${i}`, + type: "number", + description: `Matrix element ${i}`, + })), + }, + + ExtractMatrix3D: { + className: "FlowGraphExtractMatrix3DBlock", + category: "Extract", + description: "Extracts all 9 elements from a 3x3 Matrix3D.", + signalInputs: [], + signalOutputs: [], + dataInputs: [{ name: "input", type: "Matrix3D" }], + dataOutputs: Array.from({ length: 9 }, (_, i) => ({ + name: `output_${i}`, + type: "number", + description: `Matrix element [${Math.floor(i / 3)}][${i % 3}]`, + })), + }, + + // ═══════════════════════════════════════════════════════════════════ + // TYPE CONVERSION BLOCKS + // ═══════════════════════════════════════════════════════════════════ + + BooleanToFloat: { + className: "FlowGraphBooleanToFloat", + category: "Conversion", + description: "Converts a boolean to a float (true → 1.0, false → 0.0).", + signalInputs: [], + signalOutputs: [], + dataInputs: [{ name: "a", type: "boolean" }], + dataOutputs: [ + { name: "value", type: "number" }, + { name: "isValid", type: "boolean" }, + ], + }, + + BooleanToInt: { + className: "FlowGraphBooleanToInt", + category: "Conversion", + description: "Converts a boolean to an integer (true → 1, false → 0).", + signalInputs: [], + signalOutputs: [], + dataInputs: [{ name: "a", type: "boolean" }], + dataOutputs: [ + { name: "value", type: "FlowGraphInteger" }, + { name: "isValid", type: "boolean" }, + ], + }, + + FloatToBoolean: { + className: "FlowGraphFloatToBoolean", + category: "Conversion", + description: "Converts a float to a boolean (0 → false, nonzero → true).", + signalInputs: [], + signalOutputs: [], + dataInputs: [{ name: "a", type: "number" }], + dataOutputs: [ + { name: "value", type: "boolean" }, + { name: "isValid", type: "boolean" }, + ], + }, + + IntToBoolean: { + className: "FlowGraphIntToBoolean", + category: "Conversion", + description: "Converts an integer to a boolean (0 → false, nonzero → true).", + signalInputs: [], + signalOutputs: [], + dataInputs: [{ name: "a", type: "FlowGraphInteger" }], + dataOutputs: [ + { name: "value", type: "boolean" }, + { name: "isValid", type: "boolean" }, + ], + }, + + IntToFloat: { + className: "FlowGraphIntToFloat", + category: "Conversion", + description: "Converts an integer to a float.", + signalInputs: [], + signalOutputs: [], + dataInputs: [{ name: "a", type: "FlowGraphInteger" }], + dataOutputs: [ + { name: "value", type: "number" }, + { name: "isValid", type: "boolean" }, + ], + }, + + FloatToInt: { + className: "FlowGraphFloatToInt", + category: "Conversion", + description: "Converts a float to an integer.", + signalInputs: [], + signalOutputs: [], + dataInputs: [{ name: "a", type: "number" }], + dataOutputs: [ + { name: "value", type: "FlowGraphInteger" }, + { name: "isValid", type: "boolean" }, + ], + config: { roundingMode: '"floor" | "ceil" | "round" — how to round the float value' }, + }, + + // ═══════════════════════════════════════════════════════════════════ + // PHYSICS — EXECUTION BLOCKS + // ═══════════════════════════════════════════════════════════════════ + + PhysicsApplyForce: { + className: "FlowGraphApplyForceBlock", + category: "Execution", + description: "Applies a force to a physics body at a given location.", + signalInputs: [{ name: "in" }], + signalOutputs: [{ name: "out" }, { name: "error" }], + dataInputs: [ + { name: "body", type: "any", description: "The PhysicsBody to apply force to" }, + { name: "force", type: "Vector3", description: "Force vector" }, + { name: "location", type: "Vector3", description: "World-space location to apply force at" }, + ], + dataOutputs: [], + }, + + PhysicsApplyImpulse: { + className: "FlowGraphApplyImpulseBlock", + category: "Execution", + description: "Applies an impulse to a physics body at a given location.", + signalInputs: [{ name: "in" }], + signalOutputs: [{ name: "out" }, { name: "error" }], + dataInputs: [ + { name: "body", type: "any", description: "The PhysicsBody to apply impulse to" }, + { name: "impulse", type: "Vector3", description: "Impulse vector" }, + { name: "location", type: "Vector3", description: "World-space location to apply impulse at" }, + ], + dataOutputs: [], + }, + + PhysicsSetLinearVelocity: { + className: "FlowGraphSetLinearVelocityBlock", + category: "Execution", + description: "Sets the linear velocity of a physics body.", + signalInputs: [{ name: "in" }], + signalOutputs: [{ name: "out" }, { name: "error" }], + dataInputs: [ + { name: "body", type: "any", description: "The PhysicsBody" }, + { name: "velocity", type: "Vector3", description: "New linear velocity" }, + ], + dataOutputs: [], + }, + + PhysicsSetAngularVelocity: { + className: "FlowGraphSetAngularVelocityBlock", + category: "Execution", + description: "Sets the angular velocity of a physics body.", + signalInputs: [{ name: "in" }], + signalOutputs: [{ name: "out" }, { name: "error" }], + dataInputs: [ + { name: "body", type: "any", description: "The PhysicsBody" }, + { name: "velocity", type: "Vector3", description: "New angular velocity" }, + ], + dataOutputs: [], + }, + + PhysicsSetMotionType: { + className: "FlowGraphSetPhysicsMotionTypeBlock", + category: "Execution", + description: "Sets the motion type of a physics body (Static=0, Animated=1, Dynamic=2).", + signalInputs: [{ name: "in" }], + signalOutputs: [{ name: "out" }, { name: "error" }], + dataInputs: [ + { name: "body", type: "any", description: "The PhysicsBody" }, + { name: "motionType", type: "number", description: "Motion type: Static (0), Animated (1), Dynamic (2)" }, + ], + dataOutputs: [], + }, + + // ═══════════════════════════════════════════════════════════════════ + // PHYSICS — DATA BLOCKS + // ═══════════════════════════════════════════════════════════════════ + + PhysicsGetLinearVelocity: { + className: "FlowGraphGetLinearVelocityBlock", + category: "Data", + description: "Gets the linear velocity of a physics body.", + signalInputs: [], + signalOutputs: [], + dataInputs: [{ name: "body", type: "any", description: "The PhysicsBody" }], + dataOutputs: [ + { name: "value", type: "Vector3" }, + { name: "isValid", type: "boolean" }, + ], + }, + + PhysicsGetAngularVelocity: { + className: "FlowGraphGetAngularVelocityBlock", + category: "Data", + description: "Gets the angular velocity of a physics body.", + signalInputs: [], + signalOutputs: [], + dataInputs: [{ name: "body", type: "any", description: "The PhysicsBody" }], + dataOutputs: [ + { name: "value", type: "Vector3" }, + { name: "isValid", type: "boolean" }, + ], + }, + + PhysicsGetMassProperties: { + className: "FlowGraphGetPhysicsMassPropertiesBlock", + category: "Data", + description: "Gets mass, center of mass, and inertia of a physics body.", + signalInputs: [], + signalOutputs: [], + dataInputs: [{ name: "body", type: "any", description: "The PhysicsBody" }], + dataOutputs: [ + { name: "mass", type: "number" }, + { name: "centerOfMass", type: "Vector3" }, + { name: "inertia", type: "Vector3" }, + ], + }, + + // ═══════════════════════════════════════════════════════════════════ + // AUDIO — EXECUTION BLOCKS + // ═══════════════════════════════════════════════════════════════════ + + AudioPlaySound: { + className: "FlowGraphPlaySoundBlock", + category: "Execution", + description: "Plays a sound with optional volume, offset, and loop control.", + signalInputs: [{ name: "in" }], + signalOutputs: [{ name: "out" }, { name: "error" }], + dataInputs: [ + { name: "sound", type: "any", description: "The sound to play" }, + { name: "volume", type: "number", description: "Playback volume (default: 1)" }, + { name: "startOffset", type: "number", description: "Start offset in seconds (default: 0)" }, + { name: "loop", type: "boolean", description: "Whether to loop (default: false)" }, + ], + dataOutputs: [], + }, + + AudioStopSound: { + className: "FlowGraphStopSoundBlock", + category: "Execution", + description: "Stops a currently playing sound.", + signalInputs: [{ name: "in" }], + signalOutputs: [{ name: "out" }, { name: "error" }], + dataInputs: [{ name: "sound", type: "any", description: "The sound to stop" }], + dataOutputs: [], + }, + + AudioPauseSound: { + className: "FlowGraphPauseSoundBlock", + category: "Execution", + description: "Pauses a currently playing sound.", + signalInputs: [{ name: "in" }], + signalOutputs: [{ name: "out" }, { name: "error" }], + dataInputs: [{ name: "sound", type: "any", description: "The sound to pause" }], + dataOutputs: [], + }, + + AudioSetVolume: { + className: "FlowGraphSetSoundVolumeBlock", + category: "Execution", + description: "Sets the volume of a sound.", + signalInputs: [{ name: "in" }], + signalOutputs: [{ name: "out" }, { name: "error" }], + dataInputs: [ + { name: "sound", type: "any", description: "The sound" }, + { name: "volume", type: "number", description: "Volume level (default: 1)" }, + ], + dataOutputs: [], + }, + + // ═══════════════════════════════════════════════════════════════════ + // AUDIO — DATA BLOCKS + // ═══════════════════════════════════════════════════════════════════ + + AudioGetVolume: { + className: "FlowGraphGetSoundVolumeBlock", + category: "Data", + description: "Gets the current volume of a sound.", + signalInputs: [], + signalOutputs: [], + dataInputs: [{ name: "sound", type: "any", description: "The sound" }], + dataOutputs: [ + { name: "value", type: "number" }, + { name: "isValid", type: "boolean" }, + ], + }, + + AudioIsSoundPlaying: { + className: "FlowGraphIsSoundPlayingBlock", + category: "Data", + description: "Returns whether a sound is currently playing.", + signalInputs: [], + signalOutputs: [], + dataInputs: [{ name: "sound", type: "any", description: "The sound" }], + dataOutputs: [ + { name: "value", type: "boolean" }, + { name: "isValid", type: "boolean" }, + ], + }, + + // ═══════════════════════════════════════════════════════════════════ + // UTILITY / DEBUG + // ═══════════════════════════════════════════════════════════════════ + + DebugBlock: { + className: "FlowGraphDebugBlock", + category: "Utility", + description: "Pass-through block that logs its input value for debugging. Output equals input.", + signalInputs: [], + signalOutputs: [], + dataInputs: [{ name: "input", type: "any" }], + dataOutputs: [{ name: "output", type: "any" }], + }, +}; + +// ─── Helper factory functions ───────────────────────────────────────────── + +function makeUnaryMathBlock(name: string, className: string, description: string, outputType: string = "any", inputType: string = "any"): Record { + return { + [name]: { + className, + category: "Math", + description, + signalInputs: [], + signalOutputs: [], + dataInputs: [{ name: "a", type: inputType, description: "Input value" }], + dataOutputs: [ + { name: "value", type: outputType }, + { name: "isValid", type: "boolean" }, + ], + }, + }; +} + +function makeBinaryMathBlock( + name: string, + className: string, + description: string, + outputType: string = "any", + inputAType: string = "any", + inputBType: string = "any" +): Record { + return { + [name]: { + className, + category: "Math", + description, + signalInputs: [], + signalOutputs: [], + dataInputs: [ + { name: "a", type: inputAType, description: "First operand" }, + { name: "b", type: inputBType, description: "Second operand" }, + ], + dataOutputs: [ + { name: "value", type: outputType }, + { name: "isValid", type: "boolean" }, + ], + }, + }; +} + +// ─── Catalog Helpers ────────────────────────────────────────────────────── + +/** + * Returns a Markdown summary of all block types grouped by category. + * @returns A Markdown-formatted string listing every block type grouped by category. + */ +export function GetBlockCatalogSummary(): string { + const byCategory = new Map(); + for (const [key, info] of Object.entries(FlowGraphBlockRegistry)) { + if (!byCategory.has(info.category)) { + byCategory.set(info.category, []); + } + byCategory.get(info.category)!.push(` ${key} (${info.className}): ${info.description.split(".")[0]}`); + } + + const lines: string[] = []; + for (const [cat, entries] of byCategory) { + lines.push(`\n## ${cat}\n`); + lines.push(...entries); + } + return lines.join("\n"); +} + +/** + * Returns detailed info about a specific block type. + * @param blockType - The block type key or className to look up. + * @returns The block type info, or undefined if not found. + */ +export function GetBlockTypeDetails(blockType: string): IFlowGraphBlockTypeInfo | undefined { + // Try exact match first + if (FlowGraphBlockRegistry[blockType]) { + return FlowGraphBlockRegistry[blockType]; + } + // Try by className + for (const info of Object.values(FlowGraphBlockRegistry)) { + if (info.className === blockType) { + return info; + } + } + return undefined; +} diff --git a/packages/tools/flow-graph-mcp-server/src/flowGraphManager.ts b/packages/tools/flow-graph-mcp-server/src/flowGraphManager.ts new file mode 100644 index 00000000000..024e234f384 --- /dev/null +++ b/packages/tools/flow-graph-mcp-server/src/flowGraphManager.ts @@ -0,0 +1,1107 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * FlowGraphManager – holds an in-memory representation of a Flow Graph + * that the MCP tools build up incrementally. When the user is satisfied, + * the graph can be exported to the Flow Graph JSON format that Babylon.js understands. + * + * Design goals + * ──────────── + * 1. **No Babylon.js runtime dependency** – the MCP server must remain a light, + * standalone process. We work purely with a JSON data model that mirrors + * FlowGraphCoordinator.serialize() output. + * 2. **Idempotent & stateful** – the manager stores the current graph in memory + * so an AI agent can add blocks, connect them, tweak configs, and finally export. + * Multiple graphs can coexist (keyed by graph name). + */ + +import { ValidateFlowGraphAttachmentPayload } from "../../mcp-server-core/src/index.js"; + +import { FlowGraphBlockRegistry, type IFlowGraphBlockTypeInfo } from "./blockRegistry.js"; + +// ─── Types matching Babylon.js serialization format ─────────────────────── + +/** + * Serialized form of a single connection point. + */ +export interface ISerializedConnection { + /** Globally unique identifier for this connection point. */ + uniqueId: string; + /** The name of this connection point (e.g. "value", "in", "out"). */ + name: string; + /** Connection direction: 0 = Input, 1 = Output. */ + _connectionType: number; + /** Unique ids of connected points on other blocks. */ + connectedPointIds: string[]; + /** Connection class name, present only for data connections (e.g. "FlowGraphDataConnection"). */ + className?: string; + /** Rich type metadata including the type name and default value. */ + richType?: { typeName: string; defaultValue: unknown }; + /** Whether this connection is optional. */ + optional?: boolean; + /** Instance-level default value (overrides richType.defaultValue during deserialization). */ + defaultValue?: unknown; +} + +/** + * Serialized form of a single Flow Graph block. + */ +export interface ISerializedBlock { + /** The block's runtime class name (e.g. "FlowGraphAddBlock"). */ + className: string; + /** Configuration values that parameterize the block. */ + config: Record; + /** Globally unique identifier for this block. */ + uniqueId: string; + /** Data input connection points. */ + dataInputs: ISerializedConnection[]; + /** Data output connection points. */ + dataOutputs: ISerializedConnection[]; + /** Signal input connection points. */ + signalInputs: ISerializedConnection[]; + /** Signal output connection points. */ + signalOutputs: ISerializedConnection[]; + /** Optional metadata such as display name and editor position. */ + metadata?: Record; +} + +/** + * Serialized form of a single execution context. + */ +export interface ISerializedContext { + /** Globally unique identifier for this execution context. */ + uniqueId: string; + /** User-defined variables stored in this context. */ + _userVariables: Record; + /** Cached connection values for data connections. */ + _connectionValues: Record; +} + +/** + * Serialized form of a single Flow Graph. + */ +export interface ISerializedFlowGraph { + /** All blocks contained in this flow graph. */ + allBlocks: ISerializedBlock[]; + /** Execution contexts associated with this flow graph. */ + executionContexts: ISerializedContext[]; +} + +/** + * Top-level serialized form (coordinator level). + */ +export interface ISerializedCoordinator { + /** Array of serialized flow graphs managed by this coordinator. */ + _flowGraphs: ISerializedFlowGraph[]; + /** Whether events are dispatched synchronously. */ + dispatchEventsSynchronously: boolean; +} + +// ─── Default values for rich types ──────────────────────────────────────── + +const DEFAULT_VALUES: Record = { + any: undefined, + string: "", + number: 0, + boolean: false, + FlowGraphInteger: { value: 0, className: "FlowGraphInteger" }, + Vector2: { value: [0, 0], className: "Vector2" }, + Vector3: { value: [0, 0, 0], className: "Vector3" }, + Vector4: { value: [0, 0, 0, 0], className: "Vector4" }, + Quaternion: { value: [0, 0, 0, 1], className: "Quaternion" }, + Matrix: { value: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], className: "Matrix" }, + Color3: { value: [0, 0, 0], className: "Color3" }, + Color4: { value: [0, 0, 0, 0], className: "Color4" }, + Matrix2D: { value: [1, 0, 0, 1], className: "FlowGraphMatrix2D" }, + Matrix3D: { value: [1, 0, 0, 0, 1, 0, 0, 0, 1], className: "FlowGraphMatrix3D" }, +}; + +function getDefaultValue(typeName: string): unknown { + return DEFAULT_VALUES[typeName] ?? undefined; +} + +// ─── UUID helper ────────────────────────────────────────────────────────── + +let _idCounter = 0; +function generateUniqueId(): string { + _idCounter++; + const hex = _idCounter.toString(16).padStart(8, "0"); + return `fg-${hex}`; +} + +/** Reset the internal unique-ID counter (useful for deterministic tests). */ +export function resetUniqueIdCounter(): void { + _idCounter = 0; +} + +// ─── In-memory block representation ────────────────────────────────────── + +interface InMemoryBlock { + /** Numeric id for user-facing references */ + id: number; + /** The serialized block data */ + serialized: ISerializedBlock; + /** Block type info from registry */ + typeInfo: IFlowGraphBlockTypeInfo; + /** User-given name for this block instance */ + displayName: string; +} + +interface InMemoryGraph { + name: string; + blocks: InMemoryBlock[]; + contexts: ISerializedContext[]; + nextBlockId: number; +} + +// ─── Manager ────────────────────────────────────────────────────────────── + +/** + * Manages in-memory Flow Graph representations that can be incrementally + * built up via MCP tools and exported to Babylon.js-compatible JSON. + */ +export class FlowGraphManager { + private _graphs = new Map(); + + // ── Lifecycle ────────────────────────────────────────────────────── + + /** + * Creates a new in-memory graph with the given name. + * @param name - The graph name. + * @returns The newly created graph. + */ + public createGraph(name: string): InMemoryGraph { + const graph: InMemoryGraph = { + name, + blocks: [], + contexts: [ + { + uniqueId: generateUniqueId(), + _userVariables: {}, + _connectionValues: {}, + }, + ], + nextBlockId: 1, + }; + this._graphs.set(name, graph); + return graph; + } + + /** + * Retrieves an in-memory graph by name. + * @param name - The graph name. + * @returns The graph, or undefined if not found. + */ + public getGraph(name: string): InMemoryGraph | undefined { + return this._graphs.get(name); + } + + /** + * Lists the names of all graphs currently held in memory. + * @returns An array of graph names. + */ + public listGraphs(): string[] { + return Array.from(this._graphs.keys()); + } + + /** + * Deletes a graph by name. + * @param name - The graph name. + * @returns True if the graph was deleted, false if it did not exist. + */ + public deleteGraph(name: string): boolean { + return this._graphs.delete(name); + } + + /** + * Remove all flow graphs from memory, resetting the manager to its initial state. + */ + public clearAll(): void { + this._graphs.clear(); + } + + // ── Block operations ─────────────────────────────────────────────── + + /** + * Adds a new block to the graph. + * @param graphName - The name of the graph. + * @param blockType - The block type key or className. + * @param blockName - An optional display name for the block. + * @param config - Optional configuration for the block. + * @returns An object with the block id and name, or a string error message. + */ + public addBlock( + graphName: string, + blockType: string, + blockName?: string, + config?: Record + ): { id: number; name: string; uniqueId: string; warnings?: string[] } | string { + const graph = this._graphs.get(graphName); + if (!graph) { + return `Graph "${graphName}" not found. Create it first with create_graph.`; + } + + const typeInfo = this._resolveBlockType(blockType); + if (!typeInfo) { + return `Unknown block type "${blockType}". Use list_block_types to see available blocks.`; + } + + const id = graph.nextBlockId++; + const name = blockName ?? `${blockType}_${id}`; + const blockUniqueId = generateUniqueId(); + + // Build signal connections + const signalInputs: ISerializedConnection[] = typeInfo.signalInputs.map((si) => ({ + uniqueId: generateUniqueId(), + name: si.name, + _connectionType: 0, + connectedPointIds: [], + })); + + const signalOutputs: ISerializedConnection[] = typeInfo.signalOutputs.map((so) => ({ + uniqueId: generateUniqueId(), + name: so.name, + _connectionType: 1, + connectedPointIds: [], + })); + + // Add config-driven dynamic signal outputs for blocks like Sequence, MultiGate, Switch, WaitAll + if (config) { + const outputCount = config.outputSignalCount ?? config.outputCount; + if (typeof outputCount === "number" && outputCount > 0) { + for (let i = 0; i < outputCount; i++) { + const outName = `out_${i}`; + if (!signalOutputs.find((so) => so.name === outName)) { + signalOutputs.push({ + uniqueId: generateUniqueId(), + name: outName, + _connectionType: 1, + connectedPointIds: [], + }); + } + } + } + // Switch block: generate case_N outputs based on cases array + if (Array.isArray(config.cases)) { + for (let i = 0; i < config.cases.length; i++) { + const caseName = `case_${i}`; + if (!signalOutputs.find((so) => so.name === caseName)) { + signalOutputs.push({ + uniqueId: generateUniqueId(), + name: caseName, + _connectionType: 1, + connectedPointIds: [], + }); + } + } + } + // WaitAll block: generate in_N signal inputs based on inputCount + if (typeof config.inputSignalCount === "number" && config.inputSignalCount > 0) { + for (let i = 0; i < config.inputSignalCount; i++) { + const inName = `in_${i}`; + if (!signalInputs.find((si) => si.name === inName)) { + signalInputs.push({ + uniqueId: generateUniqueId(), + name: inName, + _connectionType: 0, + connectedPointIds: [], + }); + } + } + } + } + + // Build data connections + const dataInputs: ISerializedConnection[] = typeInfo.dataInputs.map((di) => ({ + uniqueId: generateUniqueId(), + name: di.name, + _connectionType: 0, + connectedPointIds: [], + className: "FlowGraphDataConnection", + richType: { typeName: di.type, defaultValue: getDefaultValue(di.type) }, + optional: di.isOptional ?? false, + })); + + const dataOutputs: ISerializedConnection[] = typeInfo.dataOutputs.map((dout) => ({ + uniqueId: generateUniqueId(), + name: dout.name, + _connectionType: 1, + connectedPointIds: [], + className: "FlowGraphDataConnection", + richType: { typeName: dout.type, defaultValue: getDefaultValue(dout.type) }, + })); + + // Gap 34 fix: Propagate config values to matching data input defaults. + // When a config key name matches a data input name (e.g. config.duration for SetDelay), + // set the instance-level defaultValue on the data input so the engine uses it + // instead of the type-level default (e.g. 0 for number). + if (config) { + for (const di of dataInputs) { + if (di.name in config && config[di.name] !== undefined) { + di.defaultValue = config[di.name]; + } + } + } + + // Normalize common config key aliases to canonical names + this._normalizeConfigAliases(config, typeInfo); + + // Validate config keys against the block type's known config schema + const configWarnings: string[] = []; + if (config && typeInfo.config) { + const knownKeys = new Set(Object.keys(typeInfo.config)); + for (const key of Object.keys(config)) { + if (!knownKeys.has(key)) { + const hint = ` Known keys: ${[...knownKeys].join(", ")}`; + configWarnings.push(`Unknown config key "${key}" for ${typeInfo.className}.${hint}`); + } + } + } + + const serialized: ISerializedBlock = { + className: typeInfo.className, + config: config ?? {}, + uniqueId: blockUniqueId, + dataInputs, + dataOutputs, + signalInputs, + signalOutputs, + metadata: { displayName: name }, + }; + + const memBlock: InMemoryBlock = { + id, + serialized, + typeInfo, + displayName: name, + }; + + graph.blocks.push(memBlock); + const result: { id: number; name: string; uniqueId: string; warnings?: string[] } = { id, name, uniqueId: blockUniqueId }; + if (configWarnings.length > 0) { + result.warnings = configWarnings; + } + return result; + } + + /** + * Removes a block and all of its connections from the graph. + * @param graphName - The name of the graph. + * @param blockId - The numeric id of the block to remove. + * @returns "OK" on success, or an error message. + */ + public removeBlock(graphName: string, blockId: number): string { + const graph = this._graphs.get(graphName); + if (!graph) { + return `Graph "${graphName}" not found.`; + } + + const idx = graph.blocks.findIndex((b) => b.id === blockId); + if (idx === -1) { + return `Block ${blockId} not found.`; + } + + const block = graph.blocks[idx]; + + // Remove all connections referencing this block's connection points + const allPointIds = new Set(); + for (const conn of [...block.serialized.dataInputs, ...block.serialized.dataOutputs, ...block.serialized.signalInputs, ...block.serialized.signalOutputs]) { + allPointIds.add(conn.uniqueId); + } + + // Clean up references in other blocks + for (const otherBlock of graph.blocks) { + if (otherBlock.id === blockId) { + continue; + } + for (const conn of [ + ...otherBlock.serialized.dataInputs, + ...otherBlock.serialized.dataOutputs, + ...otherBlock.serialized.signalInputs, + ...otherBlock.serialized.signalOutputs, + ]) { + conn.connectedPointIds = conn.connectedPointIds.filter((id) => !allPointIds.has(id)); + } + } + + graph.blocks.splice(idx, 1); + return "OK"; + } + + /** + * Merges additional configuration into an existing block. + * @param graphName - The name of the graph. + * @param blockId - The numeric id of the block. + * @param config - Key/value pairs to merge into the block config. + * @returns "OK" on success, or an error message. + */ + public setBlockConfig(graphName: string, blockId: number, config: Record): string { + const graph = this._graphs.get(graphName); + if (!graph) { + return `Graph "${graphName}" not found.`; + } + + const block = graph.blocks.find((b) => b.id === blockId); + if (!block) { + return `Block ${blockId} not found.`; + } + + // Normalize aliases before merging (Gap 35 fix) + this._normalizeConfigAliases(config, block.typeInfo); + Object.assign(block.serialized.config, config); + return "OK"; + } + + // ── Connections ──────────────────────────────────────────────────── + + /** + * Connects a data output of one block to a data input of another. + * @param graphName - The name of the graph. + * @param sourceBlockId - The numeric id of the source block. + * @param outputName - The name of the data output. + * @param targetBlockId - The numeric id of the target block. + * @param inputName - The name of the data input. + * @returns "OK" on success, or an error message. + */ + public connectData(graphName: string, sourceBlockId: number, outputName: string, targetBlockId: number, inputName: string): string { + const graph = this._graphs.get(graphName); + if (!graph) { + return `Graph "${graphName}" not found.`; + } + + const sourceBlock = graph.blocks.find((b) => b.id === sourceBlockId); + if (!sourceBlock) { + return `Source block ${sourceBlockId} not found.`; + } + + const targetBlock = graph.blocks.find((b) => b.id === targetBlockId); + if (!targetBlock) { + return `Target block ${targetBlockId} not found.`; + } + + const output = sourceBlock.serialized.dataOutputs.find((o) => o.name === outputName); + if (!output) { + // Gap 28: Try common port name aliases before failing + const PORT_OUTPUT_ALIASES: Record = { + value: ["output"], // Constant block uses "output" but LLMs try "value" + output: ["value"], // Reverse mapping + }; + const aliases = PORT_OUTPUT_ALIASES[outputName]; + const aliasMatch = aliases ? sourceBlock.serialized.dataOutputs.find((o) => aliases.includes(o.name)) : undefined; + if (aliasMatch) { + // Found via alias — use the actual port + const input = targetBlock.serialized.dataInputs.find((i) => i.name === inputName); + if (!input) { + const available = targetBlock.serialized.dataInputs.map((i) => i.name).join(", "); + return `Input "${inputName}" not found on block ${targetBlockId} (${targetBlock.displayName}). Available inputs: ${available}`; + } + if (!input.connectedPointIds.includes(aliasMatch.uniqueId)) { + input.connectedPointIds.push(aliasMatch.uniqueId); + } + return "OK"; + } + const available = sourceBlock.serialized.dataOutputs.map((o) => o.name).join(", "); + return `Output "${outputName}" not found on block ${sourceBlockId} (${sourceBlock.displayName}). Available outputs: ${available}`; + } + + const input = targetBlock.serialized.dataInputs.find((i) => i.name === inputName); + if (!input) { + // Gap 28: Try common port name aliases for inputs too + const PORT_INPUT_ALIASES: Record = { + value: ["input"], + input: ["value"], + }; + const aliases = PORT_INPUT_ALIASES[inputName]; + const aliasMatch = aliases ? targetBlock.serialized.dataInputs.find((i) => aliases.includes(i.name)) : undefined; + if (aliasMatch) { + if (!aliasMatch.connectedPointIds.includes(output.uniqueId)) { + aliasMatch.connectedPointIds.push(output.uniqueId); + } + return "OK"; + } + const available = targetBlock.serialized.dataInputs.map((i) => i.name).join(", "); + return `Input "${inputName}" not found on block ${targetBlockId} (${targetBlock.displayName}). Available inputs: ${available}`; + } + + // Data connections: the input stores the output's uniqueId + if (!input.connectedPointIds.includes(output.uniqueId)) { + input.connectedPointIds.push(output.uniqueId); + } + + return "OK"; + } + + /** + * Connects a signal output of one block to a signal input of another. + * @param graphName - The name of the graph. + * @param sourceBlockId - The numeric id of the source block. + * @param signalOutputName - The name of the signal output. + * @param targetBlockId - The numeric id of the target block. + * @param signalInputName - The name of the signal input. + * @returns "OK" on success, or an error message. + */ + public connectSignal(graphName: string, sourceBlockId: number, signalOutputName: string, targetBlockId: number, signalInputName: string): string { + const graph = this._graphs.get(graphName); + if (!graph) { + return `Graph "${graphName}" not found.`; + } + + const sourceBlock = graph.blocks.find((b) => b.id === sourceBlockId); + if (!sourceBlock) { + return `Source block ${sourceBlockId} not found.`; + } + + const targetBlock = graph.blocks.find((b) => b.id === targetBlockId); + if (!targetBlock) { + return `Target block ${targetBlockId} not found.`; + } + + // Gap 32: Auto-remap "out" → "done" for event blocks that have a "done" output. + // Event blocks (ReceiveCustomEvent, SceneReady, MeshPicked, etc.) fire "out" on startup + // and "done" when the event actually triggers. LLMs almost always mean "done". + let resolvedOutputName = signalOutputName; + if (signalOutputName === "out" && sourceBlock.typeInfo.category === "Event") { + const hasDone = sourceBlock.serialized.signalOutputs.some((o) => o.name === "done"); + if (hasDone) { + resolvedOutputName = "done"; + } + } + + const output = sourceBlock.serialized.signalOutputs.find((o) => o.name === resolvedOutputName); + if (!output) { + const available = sourceBlock.serialized.signalOutputs.map((o) => o.name).join(", "); + return `Signal output "${signalOutputName}" not found on block ${sourceBlockId} (${sourceBlock.displayName}). Available: ${available}`; + } + + const input = targetBlock.serialized.signalInputs.find((i) => i.name === signalInputName); + if (!input) { + const available = targetBlock.serialized.signalInputs.map((i) => i.name).join(", "); + return `Signal input "${signalInputName}" not found on block ${targetBlockId} (${targetBlock.displayName}). Available: ${available}`; + } + + // Signal connections: the output stores the input's uniqueId + if (!output.connectedPointIds.includes(input.uniqueId)) { + output.connectedPointIds.push(input.uniqueId); + } + + return "OK"; + } + + /** + * Disconnects all data sources from a block's data input. + * @param graphName - The name of the graph. + * @param blockId - The numeric id of the block. + * @param inputName - The name of the data input to disconnect. + * @returns "OK" on success, or an error message. + */ + public disconnectData(graphName: string, blockId: number, inputName: string): string { + const graph = this._graphs.get(graphName); + if (!graph) { + return `Graph "${graphName}" not found.`; + } + + const block = graph.blocks.find((b) => b.id === blockId); + if (!block) { + return `Block ${blockId} not found.`; + } + + const input = block.serialized.dataInputs.find((i) => i.name === inputName); + if (!input) { + return `Data input "${inputName}" not found on block ${blockId}.`; + } + + input.connectedPointIds = []; + return "OK"; + } + + /** + * Disconnects all targets from a block's signal output. + * @param graphName - The name of the graph. + * @param blockId - The numeric id of the block. + * @param signalOutputName - The name of the signal output to disconnect. + * @returns "OK" on success, or an error message. + */ + public disconnectSignal(graphName: string, blockId: number, signalOutputName: string): string { + const graph = this._graphs.get(graphName); + if (!graph) { + return `Graph "${graphName}" not found.`; + } + + const block = graph.blocks.find((b) => b.id === blockId); + if (!block) { + return `Block ${blockId} not found.`; + } + + const output = block.serialized.signalOutputs.find((o) => o.name === signalOutputName); + if (!output) { + return `Signal output "${signalOutputName}" not found on block ${blockId}.`; + } + + output.connectedPointIds = []; + return "OK"; + } + + // ── Context variables ────────────────────────────────────────────── + + /** + * Sets or updates a user-defined context variable on the graph. + * @param graphName - The name of the graph. + * @param variableName - The variable name. + * @param value - The value to set. + * @returns "OK" on success, or an error message. + */ + public setVariable(graphName: string, variableName: string, value: unknown): string { + const graph = this._graphs.get(graphName); + if (!graph) { + return `Graph "${graphName}" not found.`; + } + + if (graph.contexts.length === 0) { + graph.contexts.push({ + uniqueId: generateUniqueId(), + _userVariables: {}, + _connectionValues: {}, + }); + } + + graph.contexts[0]._userVariables[variableName] = value; + return "OK"; + } + + // ── Query ────────────────────────────────────────────────────────── + + /** + * Returns a Markdown description of the entire graph, including blocks and connections. + * @param graphName - The name of the graph. + * @returns A Markdown-formatted string describing the graph. + */ + public describeGraph(graphName: string): string { + const graph = this._graphs.get(graphName); + if (!graph) { + return `Graph "${graphName}" not found.`; + } + + if (graph.blocks.length === 0) { + return `Graph "${graphName}" is empty. Add blocks with add_block.`; + } + + const lines: string[] = [`# Flow Graph: ${graphName}`, `Blocks: ${graph.blocks.length}`, ""]; + + // Group by category + const byCategory = new Map(); + for (const block of graph.blocks) { + const cat = block.typeInfo.category; + if (!byCategory.has(cat)) { + byCategory.set(cat, []); + } + byCategory.get(cat)!.push(block); + } + + for (const [cat, blocks] of byCategory) { + lines.push(`## ${cat}`); + for (const block of blocks) { + lines.push(` [${block.id}] ${block.displayName} (${block.serialized.className})`); + + // Show data connections + for (const di of block.serialized.dataInputs) { + if (di.connectedPointIds.length > 0) { + const source = this._findConnectionSource(graph, di.connectedPointIds[0]); + lines.push(` ← ${di.name}: connected from ${source}`); + } + } + + // Show signal connections + for (const so of block.serialized.signalOutputs) { + if (so.connectedPointIds.length > 0) { + const target = this._findSignalTarget(graph, so.connectedPointIds[0]); + lines.push(` → ${so.name}: connected to ${target}`); + } + } + } + lines.push(""); + } + + // Show context variables + if (graph.contexts.length > 0 && Object.keys(graph.contexts[0]._userVariables).length > 0) { + lines.push("## Context Variables"); + for (const [k, v] of Object.entries(graph.contexts[0]._userVariables)) { + lines.push(` ${k} = ${JSON.stringify(v)}`); + } + } + + return lines.join("\n"); + } + + /** + * Returns a detailed Markdown description of a single block. + * @param graphName - The name of the graph. + * @param blockId - The numeric id of the block. + * @returns A Markdown string describing the block, or an error message. + */ + public describeBlock(graphName: string, blockId: number): string { + const graph = this._graphs.get(graphName); + if (!graph) { + return `Graph "${graphName}" not found.`; + } + + const block = graph.blocks.find((b) => b.id === blockId); + if (!block) { + return `Block ${blockId} not found.`; + } + + const lines: string[] = [ + `## [${block.id}] ${block.displayName}`, + `Class: ${block.serialized.className}`, + `Category: ${block.typeInfo.category}`, + `Description: ${block.typeInfo.description}`, + `Config: ${JSON.stringify(block.serialized.config)}`, + ]; + + if (block.serialized.signalInputs.length > 0) { + lines.push("\n### Signal Inputs:"); + for (const si of block.serialized.signalInputs) { + lines.push(` • ${si.name} (id: ${si.uniqueId})`); + } + } + + if (block.serialized.signalOutputs.length > 0) { + lines.push("\n### Signal Outputs:"); + for (const so of block.serialized.signalOutputs) { + const target = so.connectedPointIds.length > 0 ? `→ ${this._findSignalTarget(graph, so.connectedPointIds[0])}` : "(not connected)"; + lines.push(` • ${so.name} ${target}`); + } + } + + if (block.serialized.dataInputs.length > 0) { + lines.push("\n### Data Inputs:"); + for (const di of block.serialized.dataInputs) { + const source = di.connectedPointIds.length > 0 ? `← ${this._findConnectionSource(graph, di.connectedPointIds[0])}` : "(not connected)"; + const type = di.richType?.typeName ?? "any"; + const opt = di.optional ? " (optional)" : ""; + lines.push(` • ${di.name}: ${type}${opt} ${source}`); + } + } + + if (block.serialized.dataOutputs.length > 0) { + lines.push("\n### Data Outputs:"); + for (const dout of block.serialized.dataOutputs) { + const type = dout.richType?.typeName ?? "any"; + lines.push(` • ${dout.name}: ${type} (id: ${dout.uniqueId})`); + } + } + + return lines.join("\n"); + } + + // ── Validation ───────────────────────────────────────────────────── + + /** + * Validates the graph and returns a list of issues found. + * @param graphName - The name of the graph. + * @returns An array of issue strings (empty if the graph is valid). + */ + public validateGraph(graphName: string): string[] { + const graph = this._graphs.get(graphName); + if (!graph) { + return [`ERROR: Graph "${graphName}" not found.`]; + } + + const issues: string[] = []; + + if (graph.blocks.length === 0) { + issues.push("WARNING: Graph is empty."); + return issues; + } + + // Check for at least one event block (entry point) + const eventBlocks = graph.blocks.filter((b) => b.typeInfo.category === "Event"); + if (eventBlocks.length === 0) { + issues.push("WARNING: No event blocks found. The graph needs at least one event block (e.g. SceneReadyEvent) to start execution."); + } + + // Check for unconnected required data inputs + for (const block of graph.blocks) { + for (const di of block.serialized.dataInputs) { + if (!di.optional && di.connectedPointIds.length === 0) { + // Check if there's a default in config that might satisfy this + const configKeys = Object.keys(block.serialized.config); + const hasConfigDefault = configKeys.some((k) => k.toLowerCase() === di.name.toLowerCase() || k.toLowerCase().includes(di.name.toLowerCase())); + if (!hasConfigDefault) { + issues.push(`WARNING: [${block.id}] ${block.displayName} — data input "${di.name}" is not connected and has no config default.`); + } + } + } + + // Check for unconnected signal inputs on non-event execution blocks + if (block.typeInfo.category !== "Event" && block.serialized.signalInputs.length > 0) { + const hasIncomingSignal = block.serialized.signalInputs.some((si) => { + // Check if any other block's signal output points to this input + for (const otherBlock of graph.blocks) { + for (const so of otherBlock.serialized.signalOutputs) { + if (so.connectedPointIds.includes(si.uniqueId)) { + return true; + } + } + } + return false; + }); + + if (!hasIncomingSignal && block.typeInfo.signalInputs.length > 0) { + // Only warn for execution blocks (not data-only blocks) + if (block.typeInfo.signalOutputs.length > 0) { + issues.push(`WARNING: [${block.id}] ${block.displayName} — execution block has no incoming signal connection. It may never execute.`); + } + } + } + } + + // Check for signal outputs pointing to non-existent targets + for (const block of graph.blocks) { + for (const so of block.serialized.signalOutputs) { + for (const targetId of so.connectedPointIds) { + const found = graph.blocks.some((b) => b.serialized.signalInputs.some((si) => si.uniqueId === targetId)); + if (!found) { + issues.push(`ERROR: [${block.id}] ${block.displayName} — signal output "${so.name}" references missing target ${targetId}.`); + } + } + } + } + + // Check data connections + for (const block of graph.blocks) { + for (const di of block.serialized.dataInputs) { + for (const sourceId of di.connectedPointIds) { + const found = graph.blocks.some((b) => b.serialized.dataOutputs.some((dout) => dout.uniqueId === sourceId)); + if (!found) { + issues.push(`ERROR: [${block.id}] ${block.displayName} — data input "${di.name}" references missing source ${sourceId}.`); + } + } + } + } + + // Check event blocks that need a target mesh (MeshPickEvent, PointerOverEvent, PointerOutEvent) + const meshTargetEventClassNames = new Set(["FlowGraphMeshPickEventBlock", "FlowGraphPointerOverEventBlock", "FlowGraphPointerOutEventBlock"]); + for (const block of graph.blocks) { + if (meshTargetEventClassNames.has(block.serialized.className)) { + const config = block.serialized.config as Record; + const hasTargetMesh = config && "targetMesh" in config; + const assetInput = block.serialized.dataInputs.find((di) => di.name === "asset" || di.name === "targetMesh"); + const assetConnected = assetInput && assetInput.connectedPointIds.length > 0; + if (!hasTargetMesh && !assetConnected) { + issues.push( + `WARNING: [${block.id}] ${block.displayName} — no target mesh configured. ` + + `Set config.targetMesh (e.g. { type: "Mesh", name: "myMesh" }) or connect the "asset" data input. ` + + `Without a target, events will silently never fire.` + ); + } + } + } + + // Check for likely "out" vs "done" signal misuse on event blocks + // Event blocks with a "done" signal: if "out" is connected but "done" is not, + // the agent probably meant to use "done" (per-event) instead of "out" (startup-only). + for (const block of eventBlocks) { + const outSignal = block.serialized.signalOutputs.find((so) => so.name === "out"); + const doneSignal = block.serialized.signalOutputs.find((so) => so.name === "done"); + if (outSignal && doneSignal) { + const outConnected = outSignal.connectedPointIds.length > 0; + const doneConnected = doneSignal.connectedPointIds.length > 0; + if (outConnected && !doneConnected) { + // SceneReadyEvent is the exception — "out" is correct there + if (block.serialized.className !== "FlowGraphSceneReadyEventBlock") { + issues.push( + `WARNING: [${block.id}] ${block.displayName} — signal "out" is connected but "done" is not. ` + + `"out" fires once at startup; "done" fires each time the event occurs (e.g. each click). ` + + `Did you mean to connect "done" instead?` + ); + } + } + } + } + + if (issues.length === 0) { + issues.push("OK: No issues found."); + } + return issues; + } + + // ── Export / Import ──────────────────────────────────────────────── + + /** + * Exports the graph as coordinator-level JSON (wraps the graph in a _flowGraphs array). + * @param graphName - The name of the graph. + * @returns The JSON string, or null if the graph was not found. + */ + public exportJSON(graphName: string): string | null { + const graph = this._graphs.get(graphName); + if (!graph) { + return null; + } + + const serializedGraph: ISerializedFlowGraph = { + allBlocks: graph.blocks.map((b) => b.serialized), + executionContexts: graph.contexts, + }; + + const coordinator: ISerializedCoordinator = { + _flowGraphs: [serializedGraph], + dispatchEventsSynchronously: false, + }; + + return JSON.stringify(coordinator, null, 2); + } + + /** + * Exports a single graph as JSON (graph-level, without coordinator wrapper). + * @param graphName - The name of the graph. + * @returns The JSON string, or null if the graph was not found. + */ + public exportGraphJSON(graphName: string): string | null { + const graph = this._graphs.get(graphName); + if (!graph) { + return null; + } + + const serializedGraph: ISerializedFlowGraph = { + allBlocks: graph.blocks.map((b) => b.serialized), + executionContexts: graph.contexts, + }; + + return JSON.stringify(serializedGraph, null, 2); + } + + /** + * Imports a flow graph from a JSON string (accepts coordinator or graph-level format). + * @param graphName - The name to assign to the imported graph. + * @param json - The JSON string to parse. + * @returns "OK" on success, or an error message. + */ + public importJSON(graphName: string, json: string): string { + try { + const validated = ValidateFlowGraphAttachmentPayload(json); + const flowGraphData = validated.graphs[0] as unknown as ISerializedFlowGraph; + + const graph: InMemoryGraph = { + name: graphName, + blocks: [], + contexts: flowGraphData.executionContexts ?? [], + nextBlockId: 1, + }; + + for (const serializedBlock of flowGraphData.allBlocks) { + const typeInfo = this._resolveBlockType(serializedBlock.className); + const id = graph.nextBlockId++; + + // Normalize config key aliases on import (Gap 35 fix) + const resolvedTypeInfo = typeInfo ?? this._makeUnknownTypeInfo(serializedBlock); + if (serializedBlock.config) { + this._normalizeConfigAliases(serializedBlock.config as Record, resolvedTypeInfo); + } + + const memBlock: InMemoryBlock = { + id, + serialized: serializedBlock, + typeInfo: resolvedTypeInfo, + displayName: (serializedBlock.metadata?.displayName as string) ?? serializedBlock.className, + }; + + graph.blocks.push(memBlock); + } + + this._graphs.set(graphName, graph); + return "OK"; + } catch (e) { + return `Failed to parse JSON: ${e instanceof Error ? e.message : String(e)}`; + } + } + + // ── Private helpers ──────────────────────────────────────────────── + + /** + * Normalize common config key aliases to their canonical engine names. + * This handles LLM-generated config keys that don't match the engine's expected names + * (e.g. "variableName" → "variable", "eventName" → "eventId"). + * Mutates the config object in place. + * @param config configuration + * @param typeInfo + */ + private _normalizeConfigAliases(config: Record | undefined, typeInfo: IFlowGraphBlockTypeInfo): void { + // Explicit alias map: maps common LLM-generated config key names to their canonical engine names + const CONFIG_ALIASES: Record = { + variableName: "variable", + variableNames: "variables", + varName: "variable", + eventName: "eventId", + }; + if (config && typeInfo.config) { + const knownKeys = new Set(Object.keys(typeInfo.config)); + const keysToRename: Array<[string, string]> = []; + for (const key of Object.keys(config)) { + if (!knownKeys.has(key)) { + // 1. Check explicit alias map + const aliased = CONFIG_ALIASES[key]; + if (aliased && knownKeys.has(aliased)) { + keysToRename.push([key, aliased]); + } else { + // 2. Try case-insensitive match + const canonical = [...knownKeys].find((k) => k.toLowerCase() === key.toLowerCase()); + if (canonical) { + keysToRename.push([key, canonical]); + } + } + } + } + for (const [oldKey, newKey] of keysToRename) { + config[newKey] = config[oldKey]; + delete config[oldKey]; + } + } + } + + private _resolveBlockType(blockType: string): IFlowGraphBlockTypeInfo | undefined { + // Try exact key match + if (FlowGraphBlockRegistry[blockType]) { + return FlowGraphBlockRegistry[blockType]; + } + // Try by className + for (const info of Object.values(FlowGraphBlockRegistry)) { + if (info.className === blockType) { + return info; + } + } + return undefined; + } + + private _makeUnknownTypeInfo(block: ISerializedBlock): IFlowGraphBlockTypeInfo { + return { + className: block.className, + category: "Utility", + description: `Unknown block type: ${block.className}`, + signalInputs: block.signalInputs?.map((si) => ({ name: si.name })) ?? [], + signalOutputs: block.signalOutputs?.map((so) => ({ name: so.name })) ?? [], + dataInputs: block.dataInputs?.map((di) => ({ name: di.name, type: di.richType?.typeName ?? "any" })) ?? [], + dataOutputs: block.dataOutputs?.map((dout) => ({ name: dout.name, type: dout.richType?.typeName ?? "any" })) ?? [], + }; + } + + private _findConnectionSource(graph: InMemoryGraph, outputUniqueId: string): string { + for (const block of graph.blocks) { + for (const dout of block.serialized.dataOutputs) { + if (dout.uniqueId === outputUniqueId) { + return `[${block.id}] ${block.displayName}.${dout.name}`; + } + } + } + return `(unknown: ${outputUniqueId})`; + } + + private _findSignalTarget(graph: InMemoryGraph, inputUniqueId: string): string { + for (const block of graph.blocks) { + for (const si of block.serialized.signalInputs) { + if (si.uniqueId === inputUniqueId) { + return `[${block.id}] ${block.displayName}.${si.name}`; + } + } + } + return `(unknown: ${inputUniqueId})`; + } +} diff --git a/packages/tools/flow-graph-mcp-server/src/index.ts b/packages/tools/flow-graph-mcp-server/src/index.ts new file mode 100644 index 00000000000..629f92385a9 --- /dev/null +++ b/packages/tools/flow-graph-mcp-server/src/index.ts @@ -0,0 +1,993 @@ +#!/usr/bin/env node +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable no-console */ +/** + * Flow Graph MCP Server + * ───────────────────── + * A Model Context Protocol server that exposes tools for building Babylon.js + * Flow Graphs programmatically. An AI agent (or any MCP client) can: + * + * • Create / manage flow graph instances + * • Add blocks from the full Flow Graph block catalog (~165 block types) + * • Connect blocks with signal connections (execution flow) and data connections + * • Set block configuration + * • Set context variables + * • Validate the graph + * • Export the final JSON (loadable by FlowGraphCoordinator.parse()) + * • Import existing Flow Graph JSON for editing + * • Query block type info and the catalog + * + * Transport: stdio (the standard MCP transport for local tool servers) + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod/v4"; +import { + CreateInlineJsonSchema, + CreateJsonExportResponse, + CreateJsonFileSchema, + CreateJsonImportResponse, + CreateOutputFileSchema, + ResolveDefinedInput, +} from "../../mcp-server-core/src/index.js"; + +import { FlowGraphBlockRegistry, GetBlockCatalogSummary, GetBlockTypeDetails } from "./blockRegistry.js"; +import { FlowGraphManager } from "./flowGraphManager.js"; +const manager = new FlowGraphManager(); + +// ─── MCP Server ─────────────────────────────────────────────────────────── +const server = new McpServer( + { + name: "babylonjs-flow-graph", + version: "1.0.0", + }, + { + instructions: [ + "You build Babylon.js Flow Graphs (visual scripting). Workflow: create_graph → add event blocks (entry points) → add action/logic blocks → connect signals (execution flow) and data (typed values) → validate_graph → export_graph_json.", + "Signal connections drive execution order; data connections carry values. Every graph needs at least one event block as an entry point.", + "For MeshPickEvent, targetMesh config is required or clicks silently never fire. Use the 'done' signal output (not 'out') for per-event firing.", + "Output JSON can be consumed by the Scene MCP via attach_flow_graph.", + ].join(" "), + } +); + +// ═══════════════════════════════════════════════════════════════════════════ +// Resources (read-only reference data) +// ═══════════════════════════════════════════════════════════════════════════ + +server.registerResource("block-catalog", "flow-graph://block-catalog", {}, async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: "text/markdown", + text: `# Flow Graph Block Catalog\n${GetBlockCatalogSummary()}`, + }, + ], +})); + +server.registerResource("rich-types", "flow-graph://rich-types", {}, async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: "text/markdown", + text: [ + "# Flow Graph Rich Types Reference", + "", + "These are the data types used in Flow Graph data connections:", + "", + "| Type | Default Value | Description |", + "|------|---------------|-------------|", + "| `any` | undefined | Generic type, accepts any value |", + '| `string` | "" | Text string |', + "| `number` | 0 | Floating-point number |", + "| `boolean` | false | True/false |", + "| `FlowGraphInteger` | 0 | Integer value |", + "| `Vector2` | (0, 0) | 2D vector |", + "| `Vector3` | (0, 0, 0) | 3D vector |", + "| `Vector4` | (0, 0, 0, 0) | 4D vector |", + "| `Quaternion` | (0, 0, 0, 1) | Rotation quaternion |", + "| `Matrix` | Identity 4x4 | 4x4 transformation matrix |", + "| `Color3` | (0, 0, 0) | RGB color |", + "| `Color4` | (0, 0, 0, 0) | RGBA color |", + "", + "## Serialized Value Formats", + "", + "When providing values in config, use these JSON formats:", + "- **number**: `42`, `3.14`", + "- **boolean**: `true`, `false`", + '- **string**: `"hello"`', + '- **Vector3**: `{ "value": [1, 2, 3], "className": "Vector3" }`', + '- **Color3**: `{ "value": [1, 0, 0], "className": "Color3" }`', + '- **Quaternion**: `{ "value": [0, 0, 0, 1], "className": "Quaternion" }`', + '- **Matrix**: `{ "value": [1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1], "className": "Matrix" }`', + '- **Mesh reference**: `{ "name": "myMesh", "className": "Mesh", "id": "mesh-id" }`', + "", + "## Connection Types", + "", + "Flow Graphs have two types of connections:", + "1. **Signal connections** — control execution flow (like wires in a circuit). Signal outputs connect to signal inputs.", + "2. **Data connections** — carry typed values between blocks. Data inputs connect FROM data outputs.", + ].join("\n"), + }, + ], +})); + +server.registerResource("concepts", "flow-graph://concepts", {}, async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: "text/markdown", + text: [ + "# Flow Graph Concepts", + "", + "## What is a Flow Graph?", + "A Flow Graph is a visual scripting system in Babylon.js that defines scene interactions", + "using an action-block-based graph. It uses an event-driven execution model where:", + "", + "1. **Event blocks** (e.g. SceneReady, MeshPick, SceneTick) serve as entry points", + "2. **Execution blocks** process logic when triggered by signals (Branch, ForLoop, SetProperty, etc.)", + "3. **Data blocks** provide values (constants, variables, math operations) that feed into execution blocks", + "", + "## Signal Flow vs Data Flow", + "- **Signal flow** (execution): Event → Execution Block → Execution Block → ...", + " - Connected via `connect_signal`: source signal output → target signal input", + " - Controls WHEN blocks execute", + "- **Data flow** (values): Data Block output → Execution Block input", + " - Connected via `connect_data`: source data output → target data input", + " - Controls WHAT values blocks use", + "", + "## Common Patterns", + "", + "### On scene ready, log a message:", + "SceneReadyEvent.out → ConsoleLog.in, with message data input", + "(SceneReadyEvent.out fires once at startup — correct for initialization.)", + "", + "### On click, toggle visibility:", + "MeshPickEvent.done → Branch.in ⚠ Use 'done', NOT 'out'! 'out' fires once at startup.", + "GetProperty(visible).value → Branch.condition", + "Branch.onTrue → SetProperty(visible=false).in", + "Branch.onFalse → SetProperty(visible=true).in", + "config.targetMesh must be set: { type: 'Mesh', name: 'myMeshName' }", + "", + "### Animate on click:", + "MeshPickEvent.done → PlayAnimation.in ⚠ Use 'done', NOT 'out'!", + "ValueInterpolation.animation → PlayAnimation.animation", + "", + "## ⚠ Event Block Signal Gotcha", + "Event blocks have TWO signal outputs with very different meanings:", + " • 'out' — fires ONCE at graph startup (initialization). Use for setup logic.", + " • 'done' — fires EACH TIME the event occurs (click, tick, etc). Use for reactions.", + "For MeshPickEvent, PointerOverEvent, PointerOutEvent, SceneTickEvent:", + " → Always connect 'done' (not 'out') to your reaction logic.", + "For SceneReadyEvent: 'out' is correct (scene ready fires once).", + "", + "## Context Variables", + "Variables persist across graph executions and can be shared between blocks:", + "- SetVariable stores a value", + "- GetVariable retrieves a value", + "- Use set_variable tool to initialize values before export", + ].join("\n"), + }, + ], +})); + +// ═══════════════════════════════════════════════════════════════════════════ +// Prompts (reusable prompt templates) +// ═══════════════════════════════════════════════════════════════════════════ + +server.registerPrompt("create-click-handler", { description: "Create a flow graph that responds to mesh clicks" }, () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + "Create a flow graph that responds when a mesh is clicked. Steps:", + "1. create_graph with name 'ClickHandler'", + "2. Add MeshPickEvent block with config: { targetMesh: { type: 'Mesh', name: 'TARGET_MESH_NAME' } }", + " ⚠ targetMesh is REQUIRED — without it, click events silently never fire.", + "3. Add ConsoleLog block to log the picked point", + "4. Connect MeshPickEvent.done → ConsoleLog.in ⚠ Use 'done', NOT 'out'!", + " ('out' fires once at startup; 'done' fires on each click)", + "5. Connect data: connect_data MeshPickEvent.pickedPoint → ConsoleLog.message", + "6. validate_graph, then export_graph_json", + ].join("\n"), + }, + }, + ], +})); + +server.registerPrompt("create-toggle-visibility", { description: "Create a flow graph that toggles mesh visibility on click" }, () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + "Create a flow graph that toggles a mesh's visibility when clicked. Steps:", + "1. create_graph 'ToggleVisibility'", + "2. Add MeshPickEvent block with config: { targetMesh: { type: 'Mesh', name: 'TARGET_MESH_NAME' } }", + " ⚠ targetMesh is REQUIRED — without it, click events silently never fire.", + "3. Add GetProperty block with config { propertyName: 'isVisible' }", + "4. Connect MeshPickEvent.pickedMesh → GetProperty.object", + "5. Add Branch block", + "6. Connect MeshPickEvent.done → Branch.in (signal) ⚠ Use 'done', NOT 'out'!", + "7. Connect GetProperty.value → Branch.condition (data)", + "8. Add two SetProperty blocks: one for visible=false, one for visible=true", + " - First SetProperty config: { propertyName: 'isVisible' }, with Constant(false) for value", + " - Second SetProperty config: { propertyName: 'isVisible' }, with Constant(true) for value", + "9. Connect Branch.onTrue → SetProperty(false).in", + "10. Connect Branch.onFalse → SetProperty(true).in", + "11. Connect MeshPickEvent.pickedMesh to both SetProperty.object inputs", + "12. validate_graph, then export_graph_json", + ].join("\n"), + }, + }, + ], +})); + +server.registerPrompt("create-animation-on-ready", { description: "Create a flow graph that plays an animation when the scene is ready" }, () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + "Create a flow graph that plays an animation when the scene is ready. Steps:", + "1. create_graph 'AnimateOnReady'", + "2. Add SceneReadyEvent block (entry point)", + "3. Add PlayAnimation block", + "4. Connect SceneReadyEvent.out → PlayAnimation.in (signal)", + "5. Add GetAsset block to get an animation group, with appropriate config", + "6. Connect GetAsset.value → PlayAnimation.animationGroup (data)", + "7. Add Constant block for speed (e.g. config { value: 1 })", + "8. Connect Constant.output → PlayAnimation.speed (data)", + "9. Add Constant block for loop (config { value: true })", + "10. Connect loop Constant.output → PlayAnimation.loop (data)", + "11. validate_graph, then export_graph_json", + ].join("\n"), + }, + }, + ], +})); + +server.registerPrompt("create-tick-counter", { description: "Create a flow graph that counts frames using SceneTick" }, () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + "Create a flow graph that counts frames and logs every 60 frames. Steps:", + "1. create_graph 'TickCounter'", + "2. set_variable 'frameCount' to 0", + "3. Add SceneTickEvent block", + "4. Add GetVariable block (config { variable: 'frameCount' })", + "5. Add Constant block with value 1", + "6. Add Add block — GetVariable.value + Constant.output", + "7. Add SetVariable block (config { variable: 'frameCount' })", + "8. Connect SceneTickEvent.out → SetVariable.in (signal)", + "9. Connect Add.value → SetVariable.value (data)", + "10. Add Modulo block — Add.value % 60", + "11. Add Equality block — Modulo.value == 0", + "12. Add Branch block", + "13. Connect SetVariable.out → Branch.in (signal)", + "14. Connect Equality.value → Branch.condition (data)", + "15. Add ConsoleLog block", + "16. Connect Branch.onTrue → ConsoleLog.in (signal)", + "17. Connect Add.value → ConsoleLog.message (data)", + "18. validate_graph, then export_graph_json", + ].join("\n"), + }, + }, + ], +})); + +server.registerPrompt("create-state-machine", { description: "Create a flow graph that uses variables to track state and switch behavior" }, () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + "Create a flow graph that tracks an on/off state via a variable and toggles it on mesh click.", + "This pattern is useful for doors, switches, lamps, or any togglable object.", + "", + "Steps:", + "1. create_graph 'StateMachine'", + "2. set_variable 'isActive' to false", + "", + "## Read state on click", + "3. Add MeshPickEvent block with config { targetMesh: { type: 'Mesh', name: 'TARGET_MESH_NAME' } }", + " ⚠ targetMesh is REQUIRED — without it, click events silently never fire.", + "4. Add GetVariable block (config { variable: 'isActive' })", + "", + "## Branch on current state", + "5. Add Branch block", + "6. Connect MeshPickEvent.done → Branch.in ⚠ Use 'done', NOT 'out'!", + "7. Connect GetVariable.value → Branch.condition", + "", + "## Turn OFF path (isActive was true → set to false)", + "8. Add Constant block with value false", + "9. Add SetVariable block (config { variable: 'isActive' })", + "10. Connect Branch.onTrue → SetVariable.in (signal)", + "11. Connect Constant(false).output → SetVariable.value (data)", + "12. Add ConsoleLog block — connect SetVariable.out → ConsoleLog.in", + " Connect a Constant('Deactivated') → ConsoleLog.message", + "", + "## Turn ON path (isActive was false → set to true)", + "13. Add Constant block with value true", + "14. Add SetVariable block (config { variable: 'isActive' })", + "15. Connect Branch.onFalse → SetVariable.in (signal)", + "16. Connect Constant(true).output → SetVariable.value (data)", + "17. Add ConsoleLog block — connect SetVariable.out → ConsoleLog.in", + " Connect a Constant('Activated') → ConsoleLog.message", + "", + "18. validate_graph, then export_graph_json", + ].join("\n"), + }, + }, + ], +})); + +// ═══════════════════════════════════════════════════════════════════════════ +// Tools +// ═══════════════════════════════════════════════════════════════════════════ + +// ── Graph lifecycle ─────────────────────────────────────────────────────── + +server.registerTool( + "create_graph", + { + description: "Create a new empty Flow Graph in memory. This is always the first step.", + inputSchema: { + name: z.string().describe("Unique name for the flow graph (e.g. 'ClickHandler', 'AnimationController')"), + }, + }, + async ({ name }) => { + manager.createGraph(name); + return { + content: [ + { + type: "text", + text: `Created flow graph "${name}". Now add blocks with add_block, connect them with connect_signal/connect_data, then export with export_graph_json.`, + }, + ], + }; + } +); + +server.registerTool( + "delete_graph", + { + description: "Delete a flow graph from memory.", + inputSchema: { + name: z.string().describe("Name of the flow graph to delete"), + }, + }, + async ({ name }) => { + const ok = manager.deleteGraph(name); + return { + content: [{ type: "text", text: ok ? `Deleted "${name}".` : `Graph "${name}" not found.` }], + }; + } +); + +server.registerTool("clear_all", { description: "Remove all flow graphs from memory, resetting the server to a clean state." }, async () => { + const names = manager.listGraphs(); + manager.clearAll(); + return { + content: [{ type: "text", text: names.length > 0 ? `Cleared ${names.length} flow graph(s): ${names.join(", ")}` : "Nothing to clear — memory was already empty." }], + }; +}); + +server.registerTool("list_graphs", { description: "List all flow graphs currently in memory." }, async () => { + const names = manager.listGraphs(); + return { + content: [ + { + type: "text", + text: names.length > 0 ? `Flow graphs in memory:\n${names.map((n) => ` • ${n}`).join("\n")}` : "No flow graphs in memory.", + }, + ], + }; +}); + +// ── Block operations ──────────────────────────────────────────────────── + +server.registerTool( + "add_block", + { + description: "Add a new block to a flow graph. Returns the block's id for use in connect_signal/connect_data.", + inputSchema: { + graphName: z.string().describe("Name of the flow graph to add the block to"), + blockType: z + .string() + .describe( + "The block type from the registry (e.g. 'SceneReadyEvent', 'Branch', 'ConsoleLog', 'Add', 'SetProperty'). " + "Use list_block_types to see all available types." + ), + name: z.string().optional().describe("Human-friendly name for this block instance (e.g. 'checkCondition', 'logResult')"), + config: z + .record(z.string(), z.unknown()) + .optional() + .describe( + "Block-specific configuration. Examples:\n" + + ' - Constant: { value: 42 } or { value: { "value": [1,2,3], "className": "Vector3" } }\n' + + ' - GetVariable: { variable: "myVar" }\n' + + ' - SetVariable: { variable: "myVar" }\n' + + ' - SetProperty: { propertyName: "position" }\n' + + ' - GetProperty: { propertyName: "isVisible" }\n' + + " - Sequence: { outputSignalCount: 3 }\n" + + " - Switch: { cases: [0, 1, 2] }\n" + + ' - SendCustomEvent/ReceiveCustomEvent: { eventId: "myEvent" }' + ), + }, + }, + async ({ graphName, blockType, name, config }) => { + const result = manager.addBlock(graphName, blockType, name, config as Record); + if (typeof result === "string") { + return { content: [{ type: "text", text: `Error: ${result}` }], isError: true }; + } + + let msg = `Added block [${result.id}] "${result.name}" (${blockType}). Use id ${result.id} in connect_signal/connect_data.`; + + // Surface config warnings from the manager + if (result.warnings && result.warnings.length > 0) { + msg += `\n⚠ ${result.warnings.join("\n⚠ ")}`; + } + + // Warn about event blocks that silently fail without a mesh target + const meshTargetEventTypes = ["MeshPickEvent", "PointerOverEvent", "PointerOutEvent"]; + if (meshTargetEventTypes.includes(blockType)) { + const cfg = config as Record | undefined; + if (!cfg || !("targetMesh" in cfg)) { + msg += + `\n⚠ "${blockType}" requires a target mesh to fire events. ` + + `Set config.targetMesh to a mesh reference, e.g.: { type: "Mesh", name: "myMeshName" }. ` + + `Without it, events will silently never fire.`; + } + } + + return { content: [{ type: "text", text: msg }] }; + } +); + +server.registerTool( + "remove_block", + { + description: "Remove a block from a flow graph. Also removes all connections to/from it.", + inputSchema: { + graphName: z.string().describe("Name of the flow graph"), + blockId: z.number().describe("The block id to remove"), + }, + }, + async ({ graphName, blockId }) => { + const result = manager.removeBlock(graphName, blockId); + return { + content: [{ type: "text", text: result === "OK" ? `Removed block ${blockId}.` : `Error: ${result}` }], + isError: result !== "OK", + }; + } +); + +server.registerTool( + "set_block_config", + { + description: + "Set or update configuration on an existing block. Config keys depend on the block type — " + + "use get_block_type_info to discover available config for a given block type.\n\n" + + "Common config patterns:\n" + + "- Constant: { value: } — the constant value to output\n" + + "- GetVariable / SetVariable: { variable: 'varName' } — FlowGraph context variable name\n" + + "- MeshPickEvent: { targetMesh: { type: 'Mesh', name: 'meshName' } } — REQUIRED or clicks silently fail\n" + + "- GetProperty / SetProperty: { propertyName: 'propName' } — e.g. 'position', 'isVisible', 'rotation'\n" + + "- FunctionReference: { code: 'function(params) { ... }' } — inline JS function body. " + + "Connect inputs via CodeExecution block, read results via GetProperty on the outputs.\n" + + "- ConsoleLog: no config needed (message received via data input)\n" + + "- PlayAnimation: { loop: true/false } or receive animationGroup via data input\n\n" + + "TIP: Rich-type values like Mesh references use { type: 'Mesh', name: 'meshName' } format. " + + "Read the rich-types resource for the full list.", + inputSchema: { + graphName: z.string().describe("Name of the flow graph"), + blockId: z.number().describe("The block id to modify"), + config: z + .record(z.string(), z.unknown()) + .describe("Configuration key-value pairs to set or update. Keys are block-specific — use get_block_type_info to discover them."), + }, + }, + async ({ graphName, blockId, config }) => { + const result = manager.setBlockConfig(graphName, blockId, config as Record); + return { + content: [{ type: "text", text: result === "OK" ? `Updated block ${blockId} config.` : `Error: ${result}` }], + isError: result !== "OK", + }; + } +); + +// ── Signal connections ────────────────────────────────────────────────── + +server.registerTool( + "connect_signal", + { + description: + "Connect a signal output of one block to a signal input of another. " + + "Signal connections control execution flow (WHEN blocks execute). " + + "Flow: source block's signal output → target block's signal input.", + inputSchema: { + graphName: z.string().describe("Name of the flow graph"), + sourceBlockId: z.number().describe("Block id with the signal output (e.g. the event or execution block)"), + signalOutputName: z + .string() + .optional() + .describe("Name of the signal output on the source block (e.g. 'out', 'onTrue', 'onFalse', 'executionFlow', 'completed', 'done')"), + outputName: z.string().optional().describe("Alias for signalOutputName"), + signalOut: z.string().optional().describe("Alias for signalOutputName"), + outName: z.string().optional().describe("Alias for signalOutputName"), + targetBlockId: z.number().describe("Block id with the signal input (the block to trigger)"), + signalInputName: z.string().optional().describe("Name of the signal input on the target block (usually 'in')"), + inputName: z.string().optional().describe("Alias for signalInputName"), + signalIn: z.string().optional().describe("Alias for signalInputName"), + inName: z.string().optional().describe("Alias for signalInputName"), + }, + }, + async ({ graphName, sourceBlockId, signalOutputName, outputName: outputNameAlias, signalOut, outName, targetBlockId, signalInputName, inputName, signalIn, inName }) => { + const resolvedSignalOutputName = signalOutputName ?? outputNameAlias ?? signalOut ?? outName ?? "out"; + const resolvedSignalInputName = signalInputName ?? inputName ?? signalIn ?? inName ?? "in"; + const result = manager.connectSignal(graphName, sourceBlockId, resolvedSignalOutputName, targetBlockId, resolvedSignalInputName); + // Gap 32: Detect if the manager auto-remapped "out" → "done" for event blocks + let note = ""; + if (result === "OK" && resolvedSignalOutputName === "out") { + const graph = manager.getGraph(graphName); + const block = graph?.blocks.find((b) => b.id === sourceBlockId); + if (block?.typeInfo.category === "Event" && block.serialized.signalOutputs.some((o) => o.name === "done")) { + note = ` (Note: auto-remapped "out" → "done" for event block — "done" fires on event trigger, "out" fires on startup)`; + } + } + return { + content: [ + { + type: "text", + text: + result === "OK" + ? `Connected signal: [${sourceBlockId}].${resolvedSignalOutputName} → [${targetBlockId}].${resolvedSignalInputName}${note}` + : `Error: ${result}`, + }, + ], + isError: result !== "OK", + }; + } +); + +server.registerTool( + "disconnect_signal", + { + description: "Disconnect a signal output from its target(s).", + inputSchema: { + graphName: z.string().describe("Name of the flow graph"), + blockId: z.number().describe("Block id that has the signal output"), + signalOutputName: z.string().describe("Name of the signal output to disconnect"), + }, + }, + async ({ graphName, blockId, signalOutputName }) => { + const result = manager.disconnectSignal(graphName, blockId, signalOutputName); + return { + content: [{ type: "text", text: result === "OK" ? `Disconnected signal [${blockId}].${signalOutputName}` : `Error: ${result}` }], + isError: result !== "OK", + }; + } +); + +// ── Data connections ──────────────────────────────────────────────────── + +server.registerTool( + "connect_data", + { + description: + "Connect a data output of one block to a data input of another. " + + "Data connections carry typed values (WHAT blocks process). " + + "Flow: source block's data output → target block's data input.", + inputSchema: { + graphName: z.string().describe("Name of the flow graph"), + sourceBlockId: z.number().describe("Block id with the data output (the value provider)"), + outputName: z.string().describe("Name of the data output on the source block (e.g. 'value', 'output', 'pickedPoint')"), + targetBlockId: z.number().describe("Block id with the data input (the value consumer)"), + inputName: z.string().describe("Name of the data input on the target block (e.g. 'message', 'condition', 'a', 'b')"), + }, + }, + async ({ graphName, sourceBlockId, outputName, targetBlockId, inputName }) => { + const result = manager.connectData(graphName, sourceBlockId, outputName, targetBlockId, inputName); + return { + content: [ + { + type: "text", + text: result === "OK" ? `Connected data: [${sourceBlockId}].${outputName} → [${targetBlockId}].${inputName}` : `Error: ${result}`, + }, + ], + isError: result !== "OK", + }; + } +); + +server.registerTool( + "disconnect_data", + { + description: "Disconnect a data input from its source.", + inputSchema: { + graphName: z.string().describe("Name of the flow graph"), + blockId: z.number().describe("Block id that has the data input"), + inputName: z.string().describe("Name of the data input to disconnect"), + }, + }, + async ({ graphName, blockId, inputName }) => { + const result = manager.disconnectData(graphName, blockId, inputName); + return { + content: [{ type: "text", text: result === "OK" ? `Disconnected data [${blockId}].${inputName}` : `Error: ${result}` }], + isError: result !== "OK", + }; + } +); + +// ── Context variables ─────────────────────────────────────────────────── + +server.registerTool( + "set_variable", + { + description: "Set a context variable on the flow graph. Variables can be read by GetVariable blocks and written by SetVariable blocks.", + inputSchema: { + graphName: z.string().describe("Name of the flow graph"), + variableName: z.string().describe("Name of the variable"), + value: z + .unknown() + .describe( + "The variable value. For complex types, use serialized format:\n" + + ' - number: 42\n - string: "hello"\n - boolean: true\n' + + ' - Vector3: { "value": [1, 2, 3], "className": "Vector3" }' + ), + }, + }, + async ({ graphName, variableName, value }) => { + const result = manager.setVariable(graphName, variableName, value); + return { + content: [ + { + type: "text", + text: result === "OK" ? `Set variable "${variableName}" = ${JSON.stringify(value)}` : `Error: ${result}`, + }, + ], + isError: result !== "OK", + }; + } +); + +// ── Query tools ───────────────────────────────────────────────────────── + +server.registerTool( + "describe_graph", + { + description: "Get a human-readable description of a flow graph, including all blocks, their connections, and context variables.", + inputSchema: { + graphName: z.string().describe("Name of the flow graph to describe"), + }, + }, + async ({ graphName }) => { + const desc = manager.describeGraph(graphName); + return { content: [{ type: "text", text: desc }] }; + } +); + +server.registerTool( + "describe_block", + { + description: "Get detailed information about a specific block instance, including all its connections and configuration.", + inputSchema: { + graphName: z.string().describe("Name of the flow graph"), + blockId: z.number().describe("The block id to describe"), + }, + }, + async ({ graphName, blockId }) => { + const desc = manager.describeBlock(graphName, blockId); + return { content: [{ type: "text", text: desc }] }; + } +); + +server.registerTool( + "list_block_types", + { + description: "List all available Flow Graph block types, grouped by category. Use this to discover which blocks you can add.", + inputSchema: { + category: z + .string() + .optional() + .describe("Optionally filter by category (Event, Execution, ControlFlow, Animation, Data, Math, Vector, Matrix, Combine, Extract, Conversion, Utility)"), + }, + }, + async ({ category }) => { + if (category) { + const matching = Object.entries(FlowGraphBlockRegistry) + .filter(([, info]) => info.category.toLowerCase() === category.toLowerCase()) + .map(([key, info]) => ` ${key} (${info.className}): ${info.description.split(".")[0]}`) + .join("\n"); + return { + content: [ + { + type: "text", + text: matching.length > 0 ? `## ${category} Blocks\n${matching}` : `No blocks found in category "${category}".`, + }, + ], + }; + } + return { content: [{ type: "text", text: GetBlockCatalogSummary() }] }; + } +); + +server.registerTool( + "get_block_type_info", + { + description: "Get detailed info about a specific block type — its signal/data connections, config options, and description.", + inputSchema: { + blockType: z.string().describe("The block type name (e.g. 'Branch', 'SetProperty', 'FlowGraphBranchBlock')"), + }, + }, + async ({ blockType }) => { + const info = GetBlockTypeDetails(blockType); + if (!info) { + return { + content: [{ type: "text", text: `Block type "${blockType}" not found. Use list_block_types to see available types.` }], + isError: true, + }; + } + + const lines: string[] = []; + lines.push(`## ${blockType} (${info.className})`); + lines.push(`Category: ${info.category}`); + lines.push(`Description: ${info.description}`); + + lines.push("\n### Signal Inputs:"); + if (info.signalInputs.length === 0) { + lines.push(" (none — this is a data-only block)"); + } + for (const si of info.signalInputs) { + lines.push(` • ${si.name}${si.description ? ` — ${si.description}` : ""}`); + } + + lines.push("\n### Signal Outputs:"); + if (info.signalOutputs.length === 0) { + lines.push(" (none — this is a data-only block)"); + } + for (const so of info.signalOutputs) { + lines.push(` • ${so.name}${so.description ? ` — ${so.description}` : ""}`); + } + + lines.push("\n### Data Inputs:"); + if (info.dataInputs.length === 0) { + lines.push(" (none)"); + } + for (const di of info.dataInputs) { + const opt = di.isOptional ? " (optional)" : ""; + lines.push(` • ${di.name}: ${di.type}${opt}${di.description ? ` — ${di.description}` : ""}`); + } + + lines.push("\n### Data Outputs:"); + if (info.dataOutputs.length === 0) { + lines.push(" (none)"); + } + for (const dout of info.dataOutputs) { + lines.push(` • ${dout.name}: ${dout.type}${dout.description ? ` — ${dout.description}` : ""}`); + } + + if (info.config) { + lines.push("\n### Configuration (config object):"); + for (const [k, v] of Object.entries(info.config)) { + lines.push(` • ${k}: ${v}`); + } + } + + return { content: [{ type: "text", text: lines.join("\n") }] }; + } +); + +// ── Validation ────────────────────────────────────────────────────────── + +server.registerTool( + "validate_graph", + { + description: "Run validation checks on a flow graph. Reports missing connections, unreachable blocks, and broken references.", + inputSchema: { + graphName: z.string().describe("Name of the flow graph to validate"), + }, + }, + async ({ graphName }) => { + const issues = manager.validateGraph(graphName); + return { + content: [{ type: "text", text: issues.join("\n") }], + isError: issues.some((i) => i.startsWith("ERROR")), + }; + } +); + +// ── Export / Import ───────────────────────────────────────────────────── + +server.registerTool( + "export_graph_json", + { + description: + "Export the flow graph as Babylon.js-compatible JSON at the coordinator level. " + + "This JSON can be loaded via FlowGraphCoordinator.parse() at runtime. " + + "When outputFile is provided, the JSON is written to disk and only the file path is returned " + + "(avoids large JSON payloads in the conversation context).", + inputSchema: { + graphName: z.string().describe("Name of the flow graph to export"), + graphOnly: z + .boolean() + .default(false) + .describe("If true, exports only the graph-level JSON (without the coordinator wrapper). Useful for embedding in glTF or other formats."), + outputFile: CreateOutputFileSchema(z), + }, + }, + async ({ graphName, graphOnly, outputFile }) => { + return CreateJsonExportResponse({ + jsonText: graphOnly ? manager.exportGraphJSON(graphName) : manager.exportJSON(graphName), + outputFile, + missingMessage: `Graph "${graphName}" not found.`, + fileLabel: "Flow Graph JSON", + }); + } +); + +server.registerTool( + "import_graph_json", + { + description: + "Import an existing Flow Graph JSON into memory for editing. Accepts either coordinator-level or graph-level JSON. " + + "Provide either the inline json string OR a jsonFile path (not both).", + inputSchema: { + graphName: z.string().describe("Name to give the imported flow graph"), + json: CreateInlineJsonSchema(z, "The Flow Graph JSON string to import"), + jsonFile: CreateJsonFileSchema(z, "Absolute path to a file containing the Flow Graph JSON to import (alternative to inline json)"), + }, + }, + async ({ graphName, json, jsonFile }) => { + return CreateJsonImportResponse({ + json, + jsonFile, + fileDescription: "Flow Graph JSON file", + importJson: (jsonText) => manager.importJSON(graphName, jsonText), + describeImported: () => manager.describeGraph(graphName), + }); + } +); + +// ── Batch operations ──────────────────────────────────────────────────── + +server.registerTool( + "add_blocks_batch", + { + description: "Add multiple blocks at once. More efficient than calling add_block repeatedly. Returns all created block ids.", + inputSchema: { + graphName: z.string().describe("Name of the flow graph"), + blocks: z + .array( + z.object({ + blockType: z.string().optional().describe("Block type name from the registry"), + type: z.string().optional().describe("Alias for blockType"), + name: z.string().optional().describe("Instance name for the block"), + config: z.record(z.string(), z.unknown()).optional().describe("Block configuration"), + }) + ) + .describe("Array of blocks to add"), + }, + }, + async ({ graphName, blocks }) => { + const results: string[] = []; + for (const blockDef of blocks) { + // Gap 18 — resolve type alias for blockType + const resolvedBlockType = blockDef.blockType ?? blockDef.type; + if (!resolvedBlockType) { + results.push(`Error: block definition missing blockType (or type alias)`); + continue; + } + const result = manager.addBlock(graphName, resolvedBlockType, blockDef.name, blockDef.config as Record); + if (typeof result === "string") { + results.push(`Error adding ${resolvedBlockType}: ${result}`); + } else { + results.push(`[${result.id}] ${result.name} (${resolvedBlockType})`); + } + } + return { content: [{ type: "text", text: `Added blocks:\n${results.join("\n")}` }] }; + } +); + +server.registerTool( + "connect_signals_batch", + { + description: "Connect multiple signal pairs at once.", + inputSchema: { + graphName: z.string().optional().describe("Name of the flow graph"), + name: z.string().optional().describe("Alias for graphName"), + connections: z + .array( + z.object({ + sourceBlockId: z.number(), + signalOutputName: z.string().optional().describe("Signal output name on source block"), + signalOut: z.string().optional().describe("Alias for signalOutputName"), + outputName: z.string().optional().describe("Alias for signalOutputName"), + targetBlockId: z.number(), + signalInputName: z.string().optional().describe("Signal input name on target block (default: 'in')"), + signalIn: z.string().optional().describe("Alias for signalInputName"), + inName: z.string().optional().describe("Alias for signalInputName"), + inputName: z.string().optional().describe("Alias for signalInputName"), + graphName: z.string().optional().describe("Ignored here — use top-level graphName"), + }) + ) + .describe("Array of signal connections to make"), + }, + }, + async ({ graphName, name: nameAlias, connections }) => { + let resolvedGraphName: string; + try { + resolvedGraphName = ResolveDefinedInput({ + candidates: [ + { label: "'graphName'", value: graphName }, + { label: "'name'", value: nameAlias }, + ], + }); + } catch (e) { + return { content: [{ type: "text", text: (e as Error).message }], isError: true }; + } + const results: string[] = []; + for (const conn of connections) { + // Gap 18 / Gap 50 — resolve output and input name aliases + const resolvedOutputName = conn.signalOutputName ?? conn.signalOut ?? conn.outputName ?? "out"; + const resolvedInputName = conn.signalInputName ?? conn.signalIn ?? conn.inName ?? conn.inputName ?? "in"; + const result = manager.connectSignal(resolvedGraphName, conn.sourceBlockId, resolvedOutputName, conn.targetBlockId, resolvedInputName); + results.push(result === "OK" ? `[${conn.sourceBlockId}].${resolvedOutputName} → [${conn.targetBlockId}].${resolvedInputName}` : `Error: ${result}`); + } + return { content: [{ type: "text", text: `Signal connections:\n${results.join("\n")}` }] }; + } +); + +server.registerTool( + "connect_data_batch", + { + description: "Connect multiple data pairs at once.", + inputSchema: { + graphName: z.string().describe("Name of the flow graph"), + connections: z + .array( + z.object({ + sourceBlockId: z.number(), + outputName: z.string(), + targetBlockId: z.number(), + inputName: z.string(), + }) + ) + .describe("Array of data connections to make"), + }, + }, + async ({ graphName, connections }) => { + const results: string[] = []; + for (const conn of connections) { + const result = manager.connectData(graphName, conn.sourceBlockId, conn.outputName, conn.targetBlockId, conn.inputName); + results.push(result === "OK" ? `[${conn.sourceBlockId}].${conn.outputName} → [${conn.targetBlockId}].${conn.inputName}` : `Error: ${result}`); + } + return { content: [{ type: "text", text: `Data connections:\n${results.join("\n")}` }] }; + } +); + +// ═══════════════════════════════════════════════════════════════════════════ +// Start the server +// ═══════════════════════════════════════════════════════════════════════════ + +async function Main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("Babylon.js Flow Graph MCP Server running on stdio"); +} + +try { + await Main(); +} catch (err) { + console.error("Fatal error:", err); + process.exit(1); +} diff --git a/packages/tools/flow-graph-mcp-server/test/unit/flowGraphManager.test.ts b/packages/tools/flow-graph-mcp-server/test/unit/flowGraphManager.test.ts new file mode 100644 index 00000000000..ae21747118a --- /dev/null +++ b/packages/tools/flow-graph-mcp-server/test/unit/flowGraphManager.test.ts @@ -0,0 +1,875 @@ +/** + * Flow Graph MCP Server – FlowGraphManager Validation Tests + * + * Creates flow graphs via FlowGraphManager, exports them to JSON, + * validates the JSON structure, and exercises core operations. + */ + +import { FlowGraphManager, resetUniqueIdCounter } from "../../src/flowGraphManager"; +import { FlowGraphBlockRegistry } from "../../src/blockRegistry"; + +// ─── Test Helpers ───────────────────────────────────────────────────────── + +function getBlockId(result: ReturnType): number { + if (typeof result === "string") { + throw new Error(result); + } + return result.id; +} + +function getBlockResult(result: ReturnType): { id: number; name: string; uniqueId: string; warnings?: string[] } { + if (typeof result === "string") { + throw new Error(result); + } + return result; +} + +function validateCoordinatorJSON(json: string, label: string): any { + let parsed: any; + try { + parsed = JSON.parse(json); + } catch { + throw new Error(`${label}: invalid JSON`); + } + expect(parsed._flowGraphs).toBeDefined(); + expect(Array.isArray(parsed._flowGraphs)).toBe(true); + expect(parsed._flowGraphs.length).toBe(1); + expect(Array.isArray(parsed._flowGraphs[0].allBlocks)).toBe(true); + expect(Array.isArray(parsed._flowGraphs[0].executionContexts)).toBe(true); + expect(typeof parsed.dispatchEventsSynchronously).toBe("boolean"); + return parsed; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Tests +// ═══════════════════════════════════════════════════════════════════════════ + +describe("Flow Graph MCP Server – FlowGraphManager Validation", () => { + beforeEach(() => { + resetUniqueIdCounter(); + }); + + // ── Test 1: Basic lifecycle ───────────────────────────────────────── + + it("supports create, list, delete lifecycle", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("a"); + mgr.createGraph("b"); + + const list = mgr.listGraphs(); + expect(list).toContain("a"); + expect(list).toContain("b"); + + expect(mgr.deleteGraph("a")).toBe(true); + expect(mgr.listGraphs()).not.toContain("a"); + expect(mgr.deleteGraph("nonexistent")).toBe(false); + }); + + // ── Test 2: Create graph with default context ─────────────────────── + + it("creates graph with one default execution context", () => { + const mgr = new FlowGraphManager(); + const graph = mgr.createGraph("test"); + expect(graph.contexts.length).toBe(1); + expect(graph.contexts[0]._userVariables).toEqual({}); + expect(graph.contexts[0].uniqueId).toBeDefined(); + }); + + // ── Test 3: Add blocks ──────────────────────────────────────────── + + it("adds blocks with auto-generated and custom names", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + const r1 = getBlockResult(mgr.addBlock("g", "SceneReadyEvent")); + expect(r1.id).toBe(1); + expect(r1.name).toContain("SceneReadyEvent"); + + const r2 = getBlockResult(mgr.addBlock("g", "ConsoleLog", "myLogger")); + expect(r2.id).toBe(2); + expect(r2.name).toBe("myLogger"); + }); + + // ── Test 4: Unknown block type ────────────────────────────────────── + + it("rejects unknown block types", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + const result = mgr.addBlock("g", "NonExistentBlock"); + expect(typeof result).toBe("string"); + expect(result as string).toContain("Unknown block type"); + }); + + // ── Test 5: Missing graph error ───────────────────────────────────── + + it("returns errors when graph not found", () => { + const mgr = new FlowGraphManager(); + + expect(typeof mgr.addBlock("nope", "ConsoleLog")).toBe("string"); + expect(mgr.removeBlock("nope", 1)).toContain("not found"); + expect(mgr.setBlockConfig("nope", 1, {})).toContain("not found"); + expect(mgr.connectSignal("nope", 1, "out", 2, "in")).toContain("not found"); + expect(mgr.connectData("nope", 1, "out", 2, "in")).toContain("not found"); + expect(mgr.exportJSON("nope")).toBeNull(); + expect(mgr.validateGraph("nope")[0]).toContain("not found"); + }); + + // ── Test 6: Signal connections ─────────────────────────────────────── + + it("connects signal output to signal input", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + const eventId = getBlockId(mgr.addBlock("g", "SceneReadyEvent")); + const logId = getBlockId(mgr.addBlock("g", "ConsoleLog", "log", { message: "Hello!" })); + + // SceneReadyEvent has both "out" and "done"; connecting "out" auto-remaps to "done" + expect(mgr.connectSignal("g", eventId, "out", logId, "in")).toBe("OK"); + + // Verify the connection + const graph = mgr.getGraph("g")!; + const event = graph.blocks.find((b) => b.id === eventId)!; + const log = graph.blocks.find((b) => b.id === logId)!; + const doneSignal = event.serialized.signalOutputs.find((o) => o.name === "done")!; + const inSignal = log.serialized.signalInputs.find((i) => i.name === "in")!; + + // Signal connections: output stores input's uniqueId + expect(doneSignal.connectedPointIds).toContain(inSignal.uniqueId); + }); + + // ── Test 7: Data connections ──────────────────────────────────────── + + it("connects data output to data input", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + const constId = getBlockId(mgr.addBlock("g", "Constant", "num42", { value: 42 })); + const addId = getBlockId(mgr.addBlock("g", "Add", "adder")); + + expect(mgr.connectData("g", constId, "output", addId, "a")).toBe("OK"); + + // Data connections: input stores output's uniqueId + const graph = mgr.getGraph("g")!; + const constBlock = graph.blocks.find((b) => b.id === constId)!; + const addBlock = graph.blocks.find((b) => b.id === addId)!; + const output = constBlock.serialized.dataOutputs.find((o) => o.name === "output")!; + const input = addBlock.serialized.dataInputs.find((i) => i.name === "a")!; + + expect(input.connectedPointIds).toContain(output.uniqueId); + }); + + // ── Test 8: Signal output→done auto-remap for event blocks ────────── + + it("auto-remaps 'out' to 'done' for event blocks with done output", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + const pickId = getBlockId(mgr.addBlock("g", "MeshPickEvent")); + const logId = getBlockId(mgr.addBlock("g", "ConsoleLog")); + + // Connecting "out" should actually connect "done" for MeshPickEvent + expect(mgr.connectSignal("g", pickId, "out", logId, "in")).toBe("OK"); + + const graph = mgr.getGraph("g")!; + const pick = graph.blocks.find((b) => b.id === pickId)!; + const log = graph.blocks.find((b) => b.id === logId)!; + const doneSignal = pick.serialized.signalOutputs.find((o) => o.name === "done")!; + const inSignal = log.serialized.signalInputs.find((i) => i.name === "in")!; + + expect(doneSignal.connectedPointIds).toContain(inSignal.uniqueId); + }); + + // ── Test 9: Data port alias resolution ────────────────────────────── + + it("resolves data port aliases (value↔output, value↔input)", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + const constId = getBlockId(mgr.addBlock("g", "Constant", "c")); + const logId = getBlockId(mgr.addBlock("g", "ConsoleLog")); + + // "value" is an alias for "output" on Constant + expect(mgr.connectData("g", constId, "value", logId, "message")).toBe("OK"); + }); + + // ── Test 10: Disconnect signal ────────────────────────────────────── + + it("disconnects signal output", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + const eventId = getBlockId(mgr.addBlock("g", "SceneReadyEvent")); + const logId = getBlockId(mgr.addBlock("g", "ConsoleLog")); + + mgr.connectSignal("g", eventId, "out", logId, "in"); + expect(mgr.disconnectSignal("g", eventId, "out")).toBe("OK"); + + const graph = mgr.getGraph("g")!; + const event = graph.blocks.find((b) => b.id === eventId)!; + const outSignal = event.serialized.signalOutputs.find((o) => o.name === "out")!; + expect(outSignal.connectedPointIds.length).toBe(0); + }); + + // ── Test 11: Disconnect data ──────────────────────────────────────── + + it("disconnects data input", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + const constId = getBlockId(mgr.addBlock("g", "Constant")); + const logId = getBlockId(mgr.addBlock("g", "ConsoleLog")); + + mgr.connectData("g", constId, "output", logId, "message"); + expect(mgr.disconnectData("g", logId, "message")).toBe("OK"); + + const graph = mgr.getGraph("g")!; + const log = graph.blocks.find((b) => b.id === logId)!; + const msgInput = log.serialized.dataInputs.find((i) => i.name === "message")!; + expect(msgInput.connectedPointIds.length).toBe(0); + }); + + // ── Test 12: Remove block cleans up connections ───────────────────── + + it("removes block and cleans up all connections", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + const eventId = getBlockId(mgr.addBlock("g", "SceneReadyEvent")); + const logId = getBlockId(mgr.addBlock("g", "ConsoleLog")); + + mgr.connectSignal("g", eventId, "out", logId, "in"); + expect(mgr.removeBlock("g", logId)).toBe("OK"); + + const graph = mgr.getGraph("g")!; + expect(graph.blocks.length).toBe(1); + + // The event's signal output should be cleaned up + const event = graph.blocks[0]; + const outSignal = event.serialized.signalOutputs.find((o) => o.name === "out")!; + expect(outSignal.connectedPointIds.length).toBe(0); + }); + + // ── Test 13: Set block config ─────────────────────────────────────── + + it("merges config into existing block", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + const logId = getBlockId(mgr.addBlock("g", "ConsoleLog", "log", { message: "initial" })); + expect(mgr.setBlockConfig("g", logId, { message: "updated" })).toBe("OK"); + + const graph = mgr.getGraph("g")!; + const log = graph.blocks.find((b) => b.id === logId)!; + expect(log.serialized.config.message).toBe("updated"); + }); + + // ── Test 14: Config alias normalization ────────────────────────────── + + it("normalizes config aliases (variableName→variable, eventName→eventId)", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + const setVarId = getBlockId(mgr.addBlock("g", "SetVariable", "sv", { variableName: "counter" })); + const graph = mgr.getGraph("g")!; + const setVar = graph.blocks.find((b) => b.id === setVarId)!; + expect(setVar.serialized.config.variable).toBe("counter"); + expect(setVar.serialized.config.variableName).toBeUndefined(); + }); + + // ── Test 15: Context variables ────────────────────────────────────── + + it("sets and retrieves context variables", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + expect(mgr.setVariable("g", "score", 0)).toBe("OK"); + expect(mgr.setVariable("g", "playerName", "Alice")).toBe("OK"); + + const graph = mgr.getGraph("g")!; + expect(graph.contexts[0]._userVariables.score).toBe(0); + expect(graph.contexts[0]._userVariables.playerName).toBe("Alice"); + }); + + // ── Test 16: Dynamic signal outputs (Sequence) ────────────────────── + + it("generates dynamic signal outputs for Sequence block", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + const seqId = getBlockId(mgr.addBlock("g", "Sequence", "seq", { outputSignalCount: 3 })); + const graph = mgr.getGraph("g")!; + const seq = graph.blocks.find((b) => b.id === seqId)!; + + const outNames = seq.serialized.signalOutputs.map((o) => o.name); + expect(outNames).toContain("out_0"); + expect(outNames).toContain("out_1"); + expect(outNames).toContain("out_2"); + }); + + // ── Test 17: Dynamic signal outputs (Switch with cases) ───────────── + + it("generates case signal outputs for Switch block", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + const switchId = getBlockId(mgr.addBlock("g", "Switch", "sw", { cases: ["a", "b", "c"] })); + const graph = mgr.getGraph("g")!; + const sw = graph.blocks.find((b) => b.id === switchId)!; + + const outNames = sw.serialized.signalOutputs.map((o) => o.name); + expect(outNames).toContain("case_0"); + expect(outNames).toContain("case_1"); + expect(outNames).toContain("case_2"); + }); + + // ── Test 18: Dynamic signal inputs (WaitAll) ──────────────────────── + + it("generates dynamic signal inputs for WaitAll block", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + const waitId = getBlockId(mgr.addBlock("g", "WaitAll", "wait", { inputSignalCount: 2 })); + const graph = mgr.getGraph("g")!; + const wait = graph.blocks.find((b) => b.id === waitId)!; + + const inNames = wait.serialized.signalInputs.map((i) => i.name); + expect(inNames).toContain("in_0"); + expect(inNames).toContain("in_1"); + }); + + // ── Test 19: Config-to-data-input propagation ─────────────────────── + + it("propagates config values to matching data input defaults", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + const delayId = getBlockId(mgr.addBlock("g", "SetDelay", "delay", { duration: 2000 })); + const graph = mgr.getGraph("g")!; + const delay = graph.blocks.find((b) => b.id === delayId)!; + + const durationInput = delay.serialized.dataInputs.find((i) => i.name === "duration"); + expect(durationInput).toBeDefined(); + expect(durationInput!.defaultValue).toBe(2000); + }); + + // ── Test 20: Config key warning for unknown keys ──────────────────── + + it("warns about unknown config keys", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + const result = getBlockResult(mgr.addBlock("g", "ConsoleLog", "log", { message: "ok", bogusKey: 123 })); + expect(result.warnings).toBeDefined(); + expect(result.warnings!.some((w) => w.includes("bogusKey"))).toBe(true); + }); + + // ── Test 21: Export coordinator JSON ───────────────────────────────── + + it("exports valid coordinator-level JSON", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + getBlockId(mgr.addBlock("g", "SceneReadyEvent", "event")); + getBlockId(mgr.addBlock("g", "ConsoleLog", "log", { message: "hello" })); + + const json = mgr.exportJSON("g")!; + const parsed = validateCoordinatorJSON(json, "coordinator export"); + + expect(parsed._flowGraphs[0].allBlocks.length).toBe(2); + + // Verify block structure + const block0 = parsed._flowGraphs[0].allBlocks[0]; + expect(block0.className).toBeDefined(); + expect(block0.uniqueId).toBeDefined(); + expect(Array.isArray(block0.dataInputs)).toBe(true); + expect(Array.isArray(block0.dataOutputs)).toBe(true); + expect(Array.isArray(block0.signalInputs)).toBe(true); + expect(Array.isArray(block0.signalOutputs)).toBe(true); + }); + + // ── Test 22: Export graph-level JSON ───────────────────────────────── + + it("exports graph-level JSON (without coordinator wrapper)", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + getBlockId(mgr.addBlock("g", "SceneReadyEvent")); + + const json = mgr.exportGraphJSON("g")!; + const parsed = JSON.parse(json); + + expect(parsed.allBlocks).toBeDefined(); + expect(parsed.executionContexts).toBeDefined(); + expect(parsed._flowGraphs).toBeUndefined(); + }); + + // ── Test 23: Import coordinator JSON round-trip ────────────────────── + + it("round-trips through coordinator-level export and import", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("original"); + + const eventId = getBlockId(mgr.addBlock("original", "SceneReadyEvent", "event")); + const logId = getBlockId(mgr.addBlock("original", "ConsoleLog", "log", { message: "test" })); + mgr.connectSignal("original", eventId, "out", logId, "in"); + + const json1 = mgr.exportJSON("original")!; + expect(mgr.importJSON("copy", json1)).toBe("OK"); + + const json2 = mgr.exportJSON("copy")!; + const parsed1 = JSON.parse(json1); + const parsed2 = JSON.parse(json2); + + expect(parsed2._flowGraphs[0].allBlocks.length).toBe(parsed1._flowGraphs[0].allBlocks.length); + }); + + // ── Test 24: Import graph-level JSON ───────────────────────────────── + + it("imports graph-level JSON (without coordinator wrapper)", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("src"); + + getBlockId(mgr.addBlock("src", "SceneReadyEvent")); + const graphJson = mgr.exportGraphJSON("src")!; + + expect(mgr.importJSON("dest", graphJson)).toBe("OK"); + const graph = mgr.getGraph("dest"); + expect(graph).toBeDefined(); + expect(graph!.blocks.length).toBe(1); + }); + + // ── Test 25: Validation - empty graph ─────────────────────────────── + + it("validation warns on empty graph", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + const issues = mgr.validateGraph("g"); + expect(issues.some((i) => i.includes("empty"))).toBe(true); + }); + + // ── Test 26: Validation - no event blocks ─────────────────────────── + + it("validation warns when no event blocks present", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + getBlockId(mgr.addBlock("g", "ConsoleLog")); + + const issues = mgr.validateGraph("g"); + expect(issues.some((i) => i.includes("No event blocks"))).toBe(true); + }); + + // ── Test 27: Validation - unconnected signal input ────────────────── + + it("validation warns about execution blocks with no incoming signal", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + getBlockId(mgr.addBlock("g", "SceneReadyEvent")); + getBlockId(mgr.addBlock("g", "ConsoleLog", "orphanedLog")); + + const issues = mgr.validateGraph("g"); + expect(issues.some((i) => i.includes("orphanedLog") && i.includes("no incoming signal"))).toBe(true); + }); + + // ── Test 28: Validation - valid graph passes ──────────────────────── + + it("validation passes on a well-formed graph", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + const eventId = getBlockId(mgr.addBlock("g", "SceneReadyEvent", "event")); + const logId = getBlockId(mgr.addBlock("g", "ConsoleLog", "log", { message: "hello" })); + mgr.connectSignal("g", eventId, "out", logId, "in"); + + const issues = mgr.validateGraph("g"); + expect(issues.some((i) => i.includes("No issues found"))).toBe(true); + }); + + // ── Test 29: Validation - mesh event without target ───────────────── + + it("validation warns about MeshPickEvent without target mesh", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + getBlockId(mgr.addBlock("g", "MeshPickEvent", "pick")); + + const issues = mgr.validateGraph("g"); + expect(issues.some((i) => i.includes("no target mesh"))).toBe(true); + }); + + // ── Test 30: Describe graph ───────────────────────────────────────── + + it("describeGraph returns useful Markdown output", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + const eventId = getBlockId(mgr.addBlock("g", "SceneReadyEvent", "event")); + const logId = getBlockId(mgr.addBlock("g", "ConsoleLog", "log")); + mgr.connectSignal("g", eventId, "out", logId, "in"); + + const desc = mgr.describeGraph("g"); + expect(desc).toContain("Flow Graph: g"); + expect(desc).toContain("event"); + expect(desc).toContain("log"); + expect(desc).toContain("connected"); + }); + + // ── Test 31: Describe block ───────────────────────────────────────── + + it("describeBlock returns detailed block information", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + const logId = getBlockId(mgr.addBlock("g", "ConsoleLog", "myLog", { message: "test" })); + + const desc = mgr.describeBlock("g", logId); + expect(desc).toContain("myLog"); + expect(desc).toContain("ConsoleLog"); + expect(desc).toContain("message"); + expect(desc).toContain("Signal Inputs"); + }); + + // ── Test 32: Block registry completeness ──────────────────────────── + + it("block registry has all major categories", () => { + const categories = new Set(); + for (const info of Object.values(FlowGraphBlockRegistry)) { + categories.add(info.category); + } + + for (const expected of ["Event", "Execution", "ControlFlow", "Animation", "Data", "Math", "Vector", "Matrix", "Combine", "Extract", "Conversion", "Utility"]) { + expect(categories.has(expected)).toBe(true); + } + }); + + // ── Test 33: Event blocks have correct signal structure ───────────── + + it("event blocks have out/done signal outputs", () => { + const eventTypes = ["SceneReadyEvent", "SceneTickEvent", "MeshPickEvent"]; + for (const type of eventTypes) { + const info = FlowGraphBlockRegistry[type]; + expect(info).toBeDefined(); + expect(info.category).toBe("Event"); + const outNames = info.signalOutputs.map((o) => o.name); + expect(outNames).toContain("out"); + expect(outNames).toContain("done"); + } + }); + + // ── Test 34: Branch block structure ────────────────────────────────── + + it("Branch block has correct signal and data structure", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + const branchId = getBlockId(mgr.addBlock("g", "Branch")); + const graph = mgr.getGraph("g")!; + const branch = graph.blocks.find((b) => b.id === branchId)!; + + // Signal: in, onTrue, onFalse + const sigInNames = branch.serialized.signalInputs.map((i) => i.name); + expect(sigInNames).toContain("in"); + + const sigOutNames = branch.serialized.signalOutputs.map((o) => o.name); + expect(sigOutNames).toContain("onTrue"); + expect(sigOutNames).toContain("onFalse"); + + // Data: condition input + const dataInNames = branch.serialized.dataInputs.map((i) => i.name); + expect(dataInNames).toContain("condition"); + }); + + // ── Test 35: Data connection has rich type metadata ────────────────── + + it("data connections have rich type metadata", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + const addId = getBlockId(mgr.addBlock("g", "Add")); + const graph = mgr.getGraph("g")!; + const add = graph.blocks.find((b) => b.id === addId)!; + + const inputA = add.serialized.dataInputs.find((i) => i.name === "a")!; + expect(inputA.richType).toBeDefined(); + expect(inputA.richType!.typeName).toBeDefined(); + expect(inputA.className).toBe("FlowGraphDataConnection"); + }); + + // ── Test 36: Connection error handling ─────────────────────────────── + + it("returns errors for invalid connections", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + const eventId = getBlockId(mgr.addBlock("g", "SceneReadyEvent")); + const logId = getBlockId(mgr.addBlock("g", "ConsoleLog")); + + // Non-existent output + expect(mgr.connectSignal("g", eventId, "nonexistent", logId, "in")).toContain("not found"); + // Non-existent input + expect(mgr.connectSignal("g", eventId, "out", logId, "nonexistent")).toContain("not found"); + // Non-existent block + expect(mgr.connectSignal("g", 999, "out", logId, "in")).toContain("not found"); + + // Data connection errors + expect(mgr.connectData("g", eventId, "nonexistent", logId, "message")).toContain("not found"); + }); + + // ── Test 37: Disconnect error handling ─────────────────────────────── + + it("returns errors for invalid disconnect operations", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + expect(mgr.disconnectSignal("g", 999, "out")).toContain("not found"); + expect(mgr.disconnectData("g", 999, "in")).toContain("not found"); + + const logId = getBlockId(mgr.addBlock("g", "ConsoleLog")); + expect(mgr.disconnectSignal("g", logId, "nonexistent")).toContain("not found"); + expect(mgr.disconnectData("g", logId, "nonexistent")).toContain("not found"); + }); + + // ── Test 38: Complex flow (Branch + ConsoleLog) ───────────────────── + + it("builds a complete branching flow", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + const eventId = getBlockId(mgr.addBlock("g", "SceneReadyEvent", "start")); + const branchId = getBlockId(mgr.addBlock("g", "Branch", "check")); + const trueLogId = getBlockId(mgr.addBlock("g", "ConsoleLog", "trueLog", { message: "True!" })); + const falseLogId = getBlockId(mgr.addBlock("g", "ConsoleLog", "falseLog", { message: "False!" })); + + mgr.connectSignal("g", eventId, "out", branchId, "in"); + mgr.connectSignal("g", branchId, "onTrue", trueLogId, "in"); + mgr.connectSignal("g", branchId, "onFalse", falseLogId, "in"); + + const json = mgr.exportJSON("g")!; + const parsed = validateCoordinatorJSON(json, "branch flow"); + expect(parsed._flowGraphs[0].allBlocks.length).toBe(4); + + const issues = mgr.validateGraph("g"); + // Should only warn about unconnected "condition" data input + expect(issues.every((i) => !i.startsWith("ERROR"))).toBe(true); + }); + + // ── Test 39: ForLoop block structure ───────────────────────────────── + + it("ForLoop block has correct signal/data structure", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + const loopId = getBlockId(mgr.addBlock("g", "ForLoop")); + const graph = mgr.getGraph("g")!; + const loop = graph.blocks.find((b) => b.id === loopId)!; + + const sigInNames = loop.serialized.signalInputs.map((i) => i.name); + expect(sigInNames).toContain("in"); + + const sigOutNames = loop.serialized.signalOutputs.map((o) => o.name); + expect(sigOutNames).toContain("executionFlow"); + expect(sigOutNames).toContain("completed"); + + // Data inputs for loop bounds + const dataInNames = loop.serialized.dataInputs.map((i) => i.name); + expect(dataInNames).toContain("startIndex"); + expect(dataInNames).toContain("endIndex"); + expect(dataInNames).toContain("step"); + + // Data output for index + const dataOutNames = loop.serialized.dataOutputs.map((o) => o.name); + expect(dataOutNames).toContain("index"); + }); + + // ── Test 40: Idempotent signal connections ────────────────────────── + + it("does not duplicate signal connections on repeated connect", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + const eventId = getBlockId(mgr.addBlock("g", "SceneReadyEvent")); + const logId = getBlockId(mgr.addBlock("g", "ConsoleLog")); + + // SceneReadyEvent auto-remaps "out" to "done" + mgr.connectSignal("g", eventId, "out", logId, "in"); + mgr.connectSignal("g", eventId, "out", logId, "in"); // duplicate + + const graph = mgr.getGraph("g")!; + const event = graph.blocks[0]; + const doneSignal = event.serialized.signalOutputs.find((o) => o.name === "done")!; + expect(doneSignal.connectedPointIds.length).toBe(1); + }); + + // ── Test 41: Block resolution by className ────────────────────────── + + it("resolves blocks by registry key or className", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + // By registry key + getBlockId(mgr.addBlock("g", "ConsoleLog")); + // By className + getBlockId(mgr.addBlock("g", "FlowGraphConsoleLogBlock")); + + const graph = mgr.getGraph("g")!; + expect(graph.blocks[0].serialized.className).toBe("FlowGraphConsoleLogBlock"); + expect(graph.blocks[1].serialized.className).toBe("FlowGraphConsoleLogBlock"); + }); + + // ── Test 42: Import invalid JSON ──────────────────────────────────── + + it("rejects invalid JSON on import", () => { + const mgr = new FlowGraphManager(); + + expect(mgr.importJSON("g", "not json")).toContain("Failed to parse"); + expect(mgr.importJSON("g", '{"random":"data"}')).toContain("Invalid Flow Graph JSON"); + expect(mgr.importJSON("g", '{"_flowGraphs":[]}')).toContain("Invalid Flow Graph JSON"); + expect(mgr.importJSON("g", '{"_flowGraphs":[{"name":"bad"}]}')).toContain("Invalid Flow Graph JSON"); + }); + + // ── Test 43: SetBlockConfig on missing block ──────────────────────── + + it("setBlockConfig returns error on missing block", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + expect(mgr.setBlockConfig("g", 999, { test: true })).toContain("not found"); + }); + + // ── Test 44: SetVariable on missing graph ─────────────────────────── + + it("setVariable returns error on missing graph", () => { + const mgr = new FlowGraphManager(); + + expect(mgr.setVariable("nope", "x", 1)).toContain("not found"); + }); + + // ── Test 45: Math blocks have correct structure ───────────────────── + + it("math blocks have expected inputs and outputs", () => { + // Test unary: Abs + const absInfo = FlowGraphBlockRegistry["Abs"]; + expect(absInfo).toBeDefined(); + expect(absInfo.dataInputs.length).toBe(1); + expect(absInfo.dataOutputs.length).toBeGreaterThanOrEqual(1); + + // Test binary: Add + const addInfo = FlowGraphBlockRegistry["Add"]; + expect(addInfo).toBeDefined(); + expect(addInfo.dataInputs.length).toBe(2); + expect(addInfo.dataOutputs.length).toBeGreaterThanOrEqual(1); + + // Test ternary: Clamp + const clampInfo = FlowGraphBlockRegistry["Clamp"]; + expect(clampInfo).toBeDefined(); + expect(clampInfo.dataInputs.length).toBe(3); + expect(clampInfo.dataOutputs.length).toBeGreaterThanOrEqual(1); + }); + + // ── Test 46: Export includes context variables ────────────────────── + + it("export includes context variables", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + mgr.setVariable("g", "health", 100); + mgr.setVariable("g", "name", "Player1"); + + const json = mgr.exportJSON("g")!; + const parsed = JSON.parse(json); + + const ctx = parsed._flowGraphs[0].executionContexts[0]; + expect(ctx._userVariables.health).toBe(100); + expect(ctx._userVariables.name).toBe("Player1"); + }); + + // ── Test 47: Animation blocks have correct structure ──────────────── + + it("PlayAnimation block has correct signal/data structure", () => { + const info = FlowGraphBlockRegistry["PlayAnimation"]; + expect(info).toBeDefined(); + expect(info.category).toBe("Animation"); + const sigInNames = info.signalInputs.map((i) => i.name); + expect(sigInNames).toContain("in"); + const sigOutNames = info.signalOutputs.map((o) => o.name); + expect(sigOutNames).toContain("out"); + expect(sigOutNames).toContain("done"); + }); + + // ── Test 48: Connection type values ───────────────────────────────── + + it("connections have correct _connectionType values (0=Input, 1=Output)", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + + getBlockId(mgr.addBlock("g", "ConsoleLog")); + const graph = mgr.getGraph("g")!; + const log = graph.blocks[0]; + + for (const si of log.serialized.signalInputs) { + expect(si._connectionType).toBe(0); + } + for (const so of log.serialized.signalOutputs) { + expect(so._connectionType).toBe(1); + } + for (const di of log.serialized.dataInputs) { + expect(di._connectionType).toBe(0); + } + for (const dout of log.serialized.dataOutputs) { + expect(dout._connectionType).toBe(1); + } + }); + + // ── Test 49: Conversion blocks ────────────────────────────────────── + + it("conversion blocks exist and have correct types", () => { + const conversions = ["BooleanToFloat", "BooleanToInt", "FloatToBoolean", "IntToBoolean", "IntToFloat", "FloatToInt"]; + for (const name of conversions) { + const info = FlowGraphBlockRegistry[name]; + expect(info).toBeDefined(); + expect(info.category).toBe("Conversion"); + expect(info.dataInputs.length).toBeGreaterThanOrEqual(1); + expect(info.dataOutputs.length).toBeGreaterThanOrEqual(1); + } + }); + + // ── Test 50: Combine/Extract blocks ───────────────────────────────── + + it("Combine/Extract blocks exist for Vector2/3/4 and Matrix", () => { + for (const dim of ["Vector2", "Vector3", "Vector4", "Matrix"]) { + const combine = FlowGraphBlockRegistry[`Combine${dim}`]; + const extract = FlowGraphBlockRegistry[`Extract${dim}`]; + expect(combine).toBeDefined(); + expect(extract).toBeDefined(); + expect(combine.category).toBe("Combine"); + expect(extract.category).toBe("Extract"); + } + }); + + // ── clearAll ──────────────────────────────────────────────────────── + + it("clearAll removes all graphs and resets state", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("a"); + mgr.createGraph("b"); + expect(mgr.listGraphs().length).toBe(2); + + mgr.clearAll(); + expect(mgr.listGraphs()).toEqual([]); + expect(mgr.getGraph("a")).toBeUndefined(); + expect(mgr.getGraph("b")).toBeUndefined(); + + // Can create new graphs after clear + mgr.createGraph("c"); + expect(mgr.listGraphs()).toEqual(["c"]); + }); + + it("clearAll on empty manager is a no-op", () => { + const mgr = new FlowGraphManager(); + mgr.clearAll(); + expect(mgr.listGraphs()).toEqual([]); + }); +}); diff --git a/packages/tools/flow-graph-mcp-server/test/unit/flowGraphParse.test.ts b/packages/tools/flow-graph-mcp-server/test/unit/flowGraphParse.test.ts new file mode 100644 index 00000000000..2ac5ab31f54 --- /dev/null +++ b/packages/tools/flow-graph-mcp-server/test/unit/flowGraphParse.test.ts @@ -0,0 +1,371 @@ +/** + * Flow Graph MCP Server – Parse-Ready Structural Validation Tests + * + * Validates that JSON exported by FlowGraphManager has the exact structure + * that Babylon.js ParseCoordinatorAsync/ParseFlowGraphAsync expects at runtime. + * Verifies classNames, connection shapes, config keys, and context layout. + */ + +import { FlowGraphManager, resetUniqueIdCounter } from "../../src/flowGraphManager"; +import * as fs from "fs"; +import * as path from "path"; + +// ─── helpers ────────────────────────────────────────────────────────────── + +function getBlockId(result: ReturnType): number { + if (typeof result === "string") throw new Error(result); + return result.id; +} + +function parseCoordinator(mgr: FlowGraphManager, name: string): any { + const json = mgr.exportJSON(name)!; + expect(json).toBeTruthy(); + return JSON.parse(json); +} + +function expectValidConnection(conn: any, type: 0 | 1): void { + expect(typeof conn.uniqueId).toBe("string"); + expect(typeof conn.name).toBe("string"); + expect(conn._connectionType).toBe(type); + expect(Array.isArray(conn.connectedPointIds)).toBe(true); +} + +// ═══════════════════════════════════════════════════════════════════════════ + +describe("Flow Graph MCP Server – Parse-Ready Validation", () => { + beforeEach(() => { + resetUniqueIdCounter(); + }); + + // ── Test 1: Coordinator envelope ───────────────────────────────────── + + it("coordinator JSON has _flowGraphs array and dispatchEventsSynchronously flag", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + getBlockId(mgr.addBlock("g", "SceneReadyEvent")); + + const parsed = parseCoordinator(mgr, "g"); + + expect(typeof parsed.dispatchEventsSynchronously).toBe("boolean"); + expect(Array.isArray(parsed._flowGraphs)).toBe(true); + expect(parsed._flowGraphs.length).toBe(1); + }); + + // ── Test 2: Flow graph has allBlocks + executionContexts ───────────── + + it("flow graph has allBlocks array and executionContexts array", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + getBlockId(mgr.addBlock("g", "SceneReadyEvent")); + + const parsed = parseCoordinator(mgr, "g"); + const fg = parsed._flowGraphs[0]; + + expect(Array.isArray(fg.allBlocks)).toBe(true); + expect(fg.allBlocks.length).toBe(1); + expect(Array.isArray(fg.executionContexts)).toBe(true); + expect(fg.executionContexts.length).toBe(1); + }); + + // ── Test 3: Block className follows FlowGraph*Block convention ─────── + + it("all block classNames start with FlowGraph and end with Block", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + getBlockId(mgr.addBlock("g", "SceneReadyEvent")); + getBlockId(mgr.addBlock("g", "ConsoleLog")); + getBlockId(mgr.addBlock("g", "Branch")); + getBlockId(mgr.addBlock("g", "Add")); + getBlockId(mgr.addBlock("g", "Constant")); + + const parsed = parseCoordinator(mgr, "g"); + for (const block of parsed._flowGraphs[0].allBlocks) { + expect(block.className).toMatch(/^FlowGraph.*Block$/); + } + }); + + // ── Test 4: Block has all required serialization keys ──────────────── + + it("each block has className, uniqueId, config, and 4 connection arrays", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + getBlockId(mgr.addBlock("g", "ConsoleLog", "log", { message: "hello" })); + + const parsed = parseCoordinator(mgr, "g"); + const block = parsed._flowGraphs[0].allBlocks[0]; + + expect(typeof block.className).toBe("string"); + expect(typeof block.uniqueId).toBe("string"); + expect(typeof block.config).toBe("object"); + expect(Array.isArray(block.dataInputs)).toBe(true); + expect(Array.isArray(block.dataOutputs)).toBe(true); + expect(Array.isArray(block.signalInputs)).toBe(true); + expect(Array.isArray(block.signalOutputs)).toBe(true); + }); + + // ── Test 5: Connection shape validation ────────────────────────────── + + it("connections have uniqueId, name, _connectionType, and connectedPointIds", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + const eventId = getBlockId(mgr.addBlock("g", "SceneReadyEvent")); + const logId = getBlockId(mgr.addBlock("g", "ConsoleLog")); + mgr.connectSignal("g", eventId, "out", logId, "in"); + + const parsed = parseCoordinator(mgr, "g"); + for (const block of parsed._flowGraphs[0].allBlocks) { + for (const si of block.signalInputs) expectValidConnection(si, 0); + for (const so of block.signalOutputs) expectValidConnection(so, 1); + for (const di of block.dataInputs) expectValidConnection(di, 0); + for (const dout of block.dataOutputs) expectValidConnection(dout, 1); + } + }); + + // ── Test 6: Signal connections reference valid counterpart ──────────── + + it("signal connections reference uniqueIds that exist in the graph", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + const eventId = getBlockId(mgr.addBlock("g", "SceneReadyEvent")); + const branchId = getBlockId(mgr.addBlock("g", "Branch")); + const log1Id = getBlockId(mgr.addBlock("g", "ConsoleLog", "trueLog")); + const log2Id = getBlockId(mgr.addBlock("g", "ConsoleLog", "falseLog")); + + mgr.connectSignal("g", eventId, "out", branchId, "in"); + mgr.connectSignal("g", branchId, "onTrue", log1Id, "in"); + mgr.connectSignal("g", branchId, "onFalse", log2Id, "in"); + + const parsed = parseCoordinator(mgr, "g"); + const blocks = parsed._flowGraphs[0].allBlocks; + + // Collect all signal input uniqueIds + const allSignalInIds = new Set(); + for (const block of blocks) { + for (const si of block.signalInputs) { + allSignalInIds.add(si.uniqueId); + } + } + + // Every signal output's connectedPointIds should reference a signal input + for (const block of blocks) { + for (const so of block.signalOutputs) { + for (const ref of so.connectedPointIds) { + expect(allSignalInIds.has(ref)).toBe(true); + } + } + } + }); + + // ── Test 7: Data connections reference valid counterpart ───────────── + + it("data connections reference uniqueIds that exist in the graph", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + const constId = getBlockId(mgr.addBlock("g", "Constant", "num", { value: 42 })); + const addId = getBlockId(mgr.addBlock("g", "Add")); + mgr.connectData("g", constId, "output", addId, "a"); + + const parsed = parseCoordinator(mgr, "g"); + const blocks = parsed._flowGraphs[0].allBlocks; + + // Collect all data output uniqueIds + const allDataOutIds = new Set(); + for (const block of blocks) { + for (const dout of block.dataOutputs) { + allDataOutIds.add(dout.uniqueId); + } + } + + // Every data input's connectedPointIds should reference a data output + for (const block of blocks) { + for (const di of block.dataInputs) { + for (const ref of di.connectedPointIds) { + expect(allDataOutIds.has(ref)).toBe(true); + } + } + } + }); + + // ── Test 8: Execution context structure ────────────────────────────── + + it("execution context has uniqueId and _userVariables", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + mgr.setVariable("g", "score", 100); + getBlockId(mgr.addBlock("g", "SceneReadyEvent")); + + const parsed = parseCoordinator(mgr, "g"); + const ctx = parsed._flowGraphs[0].executionContexts[0]; + + expect(typeof ctx.uniqueId).toBe("string"); + expect(typeof ctx._userVariables).toBe("object"); + expect(ctx._userVariables.score).toBe(100); + }); + + // ── Test 9: Config values survive serialization ────────────────────── + + it("config values are preserved in exported JSON", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + getBlockId(mgr.addBlock("g", "Constant", "myConst", { value: 3.14, type: "number" })); + getBlockId(mgr.addBlock("g", "MeshPickEvent", "pick", { targetMesh: "myMesh" })); + getBlockId(mgr.addBlock("g", "ReceiveCustomEvent", "rcv", { eventId: "onDamage" })); + + const parsed = parseCoordinator(mgr, "g"); + const blocks = parsed._flowGraphs[0].allBlocks; + + const constBlock = blocks.find((b: any) => b.className === "FlowGraphConstantBlock"); + expect(constBlock.config.value).toBe(3.14); + expect(constBlock.config.type).toBe("number"); + + const pickBlock = blocks.find((b: any) => b.className === "FlowGraphMeshPickEventBlock"); + expect(pickBlock.config.targetMesh).toBe("myMesh"); + + const rcvBlock = blocks.find((b: any) => b.className === "FlowGraphReceiveCustomEventBlock"); + expect(rcvBlock.config.eventId).toBe("onDamage"); + }); + + // ── Test 10: Data connections include richType metadata ────────────── + + it("data connections include richType and className metadata", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + getBlockId(mgr.addBlock("g", "Add")); + + const parsed = parseCoordinator(mgr, "g"); + const block = parsed._flowGraphs[0].allBlocks[0]; + + for (const di of block.dataInputs) { + expect(typeof di.className).toBe("string"); + expect(di.richType).toBeDefined(); + expect(typeof di.richType.typeName).toBe("string"); + } + for (const dout of block.dataOutputs) { + expect(typeof dout.className).toBe("string"); + } + }); + + // ── Test 11: Existing example file has valid structure ─────────────── + + it("SphereClickRotateGround.flowgraph.json has valid coordinator structure", () => { + const examplePath = path.resolve(__dirname, "..", "..", "SphereClickRotateGround.flowgraph.json"); + if (!fs.existsSync(examplePath)) { + // Skip if the example file doesn't exist + return; + } + const json = fs.readFileSync(examplePath, "utf-8"); + const parsed = JSON.parse(json); + + expect(parsed._flowGraphs).toBeDefined(); + expect(Array.isArray(parsed._flowGraphs)).toBe(true); + expect(parsed._flowGraphs.length).toBeGreaterThan(0); + + const fg = parsed._flowGraphs[0]; + expect(Array.isArray(fg.allBlocks)).toBe(true); + expect(Array.isArray(fg.executionContexts)).toBe(true); + + for (const block of fg.allBlocks) { + expect(typeof block.className).toBe("string"); + expect(block.className).toMatch(/^FlowGraph/); + expect(typeof block.uniqueId).toBe("string"); + } + }); + + // ── Test 12: Import existing example and re-export matches ─────────── + + it("imports SphereClickRotateGround example and re-exports with same block count", () => { + const examplePath = path.resolve(__dirname, "..", "..", "SphereClickRotateGround.flowgraph.json"); + if (!fs.existsSync(examplePath)) { + return; + } + const json = fs.readFileSync(examplePath, "utf-8"); + const original = JSON.parse(json); + + const mgr = new FlowGraphManager(); + const result = mgr.importJSON("imported", json); + expect(result).toBe("OK"); + + const reexported = mgr.exportJSON("imported")!; + const parsed = JSON.parse(reexported); + + expect(parsed._flowGraphs[0].allBlocks.length).toBe(original._flowGraphs[0].allBlocks.length); + }); + + // ── Test 13: Generated examples have valid structure ───────────────── + + it("all generated example files have valid coordinator structure", () => { + const examplesDir = path.resolve(__dirname, "..", "..", "examples"); + if (!fs.existsSync(examplesDir)) { + return; + } + + const files = fs.readdirSync(examplesDir).filter((f) => f.endsWith(".json")); + expect(files.length).toBeGreaterThan(0); + + for (const file of files) { + const json = fs.readFileSync(path.join(examplesDir, file), "utf-8"); + const parsed = JSON.parse(json); + + expect(parsed._flowGraphs).toBeDefined(); + expect(parsed._flowGraphs.length).toBe(1); + + const fg = parsed._flowGraphs[0]; + expect(Array.isArray(fg.allBlocks)).toBe(true); + expect(Array.isArray(fg.executionContexts)).toBe(true); + + for (const block of fg.allBlocks) { + expect(block.className).toMatch(/^FlowGraph.*Block$/); + expect(typeof block.uniqueId).toBe("string"); + expect(Array.isArray(block.dataInputs)).toBe(true); + expect(Array.isArray(block.dataOutputs)).toBe(true); + expect(Array.isArray(block.signalInputs)).toBe(true); + expect(Array.isArray(block.signalOutputs)).toBe(true); + } + } + }); + + // ── Test 14: UniqueIds are unique across graph ────────────────────── + + it("all uniqueIds across blocks and connections are unique", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + const eventId = getBlockId(mgr.addBlock("g", "SceneReadyEvent")); + const branchId = getBlockId(mgr.addBlock("g", "Branch")); + const constId = getBlockId(mgr.addBlock("g", "Constant", "num", { value: 1 })); + const logId = getBlockId(mgr.addBlock("g", "ConsoleLog")); + + mgr.connectSignal("g", eventId, "out", branchId, "in"); + mgr.connectSignal("g", branchId, "onTrue", logId, "in"); + mgr.connectData("g", constId, "output", branchId, "condition"); + + const parsed = parseCoordinator(mgr, "g"); + const allIds = new Set(); + + for (const block of parsed._flowGraphs[0].allBlocks) { + expect(allIds.has(block.uniqueId)).toBe(false); + allIds.add(block.uniqueId); + + for (const conn of [...block.signalInputs, ...block.signalOutputs, ...block.dataInputs, ...block.dataOutputs]) { + expect(allIds.has(conn.uniqueId)).toBe(false); + allIds.add(conn.uniqueId); + } + } + }); + + // ── Test 15: Graph-level export has same blocks as coordinator ─────── + + it("graph-level export has same block structure as coordinator export", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("g"); + getBlockId(mgr.addBlock("g", "SceneReadyEvent")); + getBlockId(mgr.addBlock("g", "ConsoleLog")); + + const coordJson = JSON.parse(mgr.exportJSON("g")!); + const graphJson = JSON.parse(mgr.exportGraphJSON("g")!); + + // Graph-level should have allBlocks and executionContexts directly + expect(graphJson.allBlocks.length).toBe(coordJson._flowGraphs[0].allBlocks.length); + expect(graphJson.executionContexts.length).toBe(coordJson._flowGraphs[0].executionContexts.length); + expect(graphJson._flowGraphs).toBeUndefined(); + }); +}); diff --git a/packages/tools/flow-graph-mcp-server/test/unit/generateExamples.test.ts b/packages/tools/flow-graph-mcp-server/test/unit/generateExamples.test.ts new file mode 100644 index 00000000000..cf865ebb7d2 --- /dev/null +++ b/packages/tools/flow-graph-mcp-server/test/unit/generateExamples.test.ts @@ -0,0 +1,206 @@ +/** + * Flow Graph MCP Server – Example Flow Graph Generation Tests + * + * Builds 5 complete flow graphs via FlowGraphManager, exports them, + * and validates the coordinator-level JSON structure. + */ + +import { FlowGraphManager, resetUniqueIdCounter } from "../../src/flowGraphManager"; + +// ─── helpers ────────────────────────────────────────────────────────────── + +function getBlockId(result: ReturnType): number { + if (typeof result === "string") throw new Error(result); + return result.id; +} + +function ok(result: string): void { + expect(result).toBe("OK"); +} + +function validateCoordinator(json: string): any { + const parsed = JSON.parse(json); + expect(parsed._flowGraphs).toBeDefined(); + expect(parsed._flowGraphs.length).toBe(1); + const fg = parsed._flowGraphs[0]; + expect(Array.isArray(fg.allBlocks)).toBe(true); + expect(Array.isArray(fg.executionContexts)).toBe(true); + return parsed; +} + +// ═══════════════════════════════════════════════════════════════════════════ + +describe("Flow Graph MCP Server – Example Flow Graphs", () => { + beforeEach(() => { + resetUniqueIdCounter(); + }); + + // ── Example 1: Click → Console Log ─────────────────────────────────── + // Scenario: When a mesh is picked, log "Clicked!" to the console. + + it("Example 1 – Click Logger", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("ClickLogger"); + + const pickId = getBlockId(mgr.addBlock("ClickLogger", "MeshPickEvent", "onPick", { targetMesh: "clickTarget" })); + const logId = getBlockId(mgr.addBlock("ClickLogger", "ConsoleLog", "logger", { message: "Clicked!" })); + + ok(mgr.connectSignal("ClickLogger", pickId, "out", logId, "in")); + + const json = mgr.exportJSON("ClickLogger")!; + const parsed = validateCoordinator(json); + + expect(parsed._flowGraphs[0].allBlocks.length).toBe(2); + expect(mgr.validateGraph("ClickLogger").some((i) => i.includes("No issues"))).toBe(true); + + // Write example + const fs = require("fs"); + const path = require("path"); + const dir = path.resolve(__dirname, "..", "..", "examples"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "ClickLogger.flowgraph.json"), json, "utf-8"); + }); + + // ── Example 2: Toggle Visibility ───────────────────────────────────── + // Scenario: On mesh pick, branch on a boolean variable. If true, set + // visibility to 0; if false, set visibility to 1. Toggle var. + + it("Example 2 – Toggle Visibility", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("ToggleVisibility"); + + mgr.setVariable("ToggleVisibility", "isVisible", true); + + const pickId = getBlockId(mgr.addBlock("ToggleVisibility", "MeshPickEvent", "onPick", { targetMesh: "box" })); + const getVarId = getBlockId(mgr.addBlock("ToggleVisibility", "GetVariable", "getIsVisible", { variable: "isVisible" })); + const branchId = getBlockId(mgr.addBlock("ToggleVisibility", "Branch", "check")); + const hideId = getBlockId(mgr.addBlock("ToggleVisibility", "SetProperty", "hide", { propertyPath: "box.visibility" })); + const showId = getBlockId(mgr.addBlock("ToggleVisibility", "SetProperty", "show", { propertyPath: "box.visibility" })); + const setFalseId = getBlockId(mgr.addBlock("ToggleVisibility", "SetVariable", "setFalse", { variable: "isVisible" })); + const setTrueId = getBlockId(mgr.addBlock("ToggleVisibility", "SetVariable", "setTrue", { variable: "isVisible" })); + + // Signal flow + ok(mgr.connectSignal("ToggleVisibility", pickId, "out", branchId, "in")); + ok(mgr.connectSignal("ToggleVisibility", branchId, "onTrue", hideId, "in")); + ok(mgr.connectSignal("ToggleVisibility", branchId, "onFalse", showId, "in")); + ok(mgr.connectSignal("ToggleVisibility", hideId, "out", setFalseId, "in")); + ok(mgr.connectSignal("ToggleVisibility", showId, "out", setTrueId, "in")); + + // Data flow + ok(mgr.connectData("ToggleVisibility", getVarId, "output", branchId, "condition")); + + const json = mgr.exportJSON("ToggleVisibility")!; + const parsed = validateCoordinator(json); + expect(parsed._flowGraphs[0].allBlocks.length).toBe(7); + + const fs = require("fs"); + const path = require("path"); + const dir = path.resolve(__dirname, "..", "..", "examples"); + fs.writeFileSync(path.join(dir, "ToggleVisibility.flowgraph.json"), json, "utf-8"); + }); + + // ── Example 3: Animate on Scene Ready ──────────────────────────────── + // Scenario: When scene loads, play animation on a mesh. + + it("Example 3 – Animate on Ready", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("AnimateOnReady"); + + const readyId = getBlockId(mgr.addBlock("AnimateOnReady", "SceneReadyEvent", "onReady")); + const playId = getBlockId(mgr.addBlock("AnimateOnReady", "PlayAnimation", "playAnim", { targetMesh: "hero" })); + + ok(mgr.connectSignal("AnimateOnReady", readyId, "out", playId, "in")); + + const json = mgr.exportJSON("AnimateOnReady")!; + const parsed = validateCoordinator(json); + expect(parsed._flowGraphs[0].allBlocks.length).toBe(2); + + const fs = require("fs"); + const path = require("path"); + const dir = path.resolve(__dirname, "..", "..", "examples"); + fs.writeFileSync(path.join(dir, "AnimateOnReady.flowgraph.json"), json, "utf-8"); + }); + + // ── Example 4: Counter with Logging ────────────────────────────────── + // Scenario: Each scene tick, increment a counter variable, then log its value. + + it("Example 4 – Tick Counter", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("TickCounter"); + + mgr.setVariable("TickCounter", "counter", 0); + + const tickId = getBlockId(mgr.addBlock("TickCounter", "SceneTickEvent", "onTick")); + const getVarId = getBlockId(mgr.addBlock("TickCounter", "GetVariable", "getCounter", { variable: "counter" })); + const constOneId = getBlockId(mgr.addBlock("TickCounter", "Constant", "one", { value: 1, type: "number" })); + const addId = getBlockId(mgr.addBlock("TickCounter", "Add", "add")); + const setVarId = getBlockId(mgr.addBlock("TickCounter", "SetVariable", "setCounter", { variable: "counter" })); + const logId = getBlockId(mgr.addBlock("TickCounter", "ConsoleLog", "logCounter")); + + // Signal flow + ok(mgr.connectSignal("TickCounter", tickId, "out", setVarId, "in")); + ok(mgr.connectSignal("TickCounter", setVarId, "out", logId, "in")); + + // Data flow: getCounter + 1 → setCounter.value, also pipe to log + ok(mgr.connectData("TickCounter", getVarId, "output", addId, "a")); + ok(mgr.connectData("TickCounter", constOneId, "output", addId, "b")); + ok(mgr.connectData("TickCounter", addId, "output", setVarId, "value")); + ok(mgr.connectData("TickCounter", addId, "output", logId, "message")); + + const json = mgr.exportJSON("TickCounter")!; + const parsed = validateCoordinator(json); + expect(parsed._flowGraphs[0].allBlocks.length).toBe(6); + + const issues = mgr.validateGraph("TickCounter"); + // No error-level issues + expect(issues.every((i) => !i.startsWith("ERROR"))).toBe(true); + + const fs = require("fs"); + const path = require("path"); + const dir = path.resolve(__dirname, "..", "..", "examples"); + fs.writeFileSync(path.join(dir, "TickCounter.flowgraph.json"), json, "utf-8"); + }); + + // ── Example 5: Sequence → Multiple SetProperty ────────────────────── + // Scenario: When scene is ready, run a sequence that sets 3 different + // mesh properties in order. + + it("Example 5 – Sequential Property Setup", () => { + const mgr = new FlowGraphManager(); + mgr.createGraph("SequentialSetup"); + + const readyId = getBlockId(mgr.addBlock("SequentialSetup", "SceneReadyEvent", "onReady")); + const seqId = getBlockId(mgr.addBlock("SequentialSetup", "Sequence", "seq", { outputSignalCount: 3 })); + const set1Id = getBlockId(mgr.addBlock("SequentialSetup", "SetProperty", "setPosX", { propertyPath: "box.position.x" })); + const set2Id = getBlockId(mgr.addBlock("SequentialSetup", "SetProperty", "setPosY", { propertyPath: "box.position.y" })); + const set3Id = getBlockId(mgr.addBlock("SequentialSetup", "SetProperty", "setPosZ", { propertyPath: "box.position.z" })); + + // Constants for values + const c1Id = getBlockId(mgr.addBlock("SequentialSetup", "Constant", "v1", { value: 1, type: "number" })); + const c2Id = getBlockId(mgr.addBlock("SequentialSetup", "Constant", "v2", { value: 2, type: "number" })); + const c3Id = getBlockId(mgr.addBlock("SequentialSetup", "Constant", "v3", { value: 3, type: "number" })); + + // Signal flow + ok(mgr.connectSignal("SequentialSetup", readyId, "out", seqId, "in")); + ok(mgr.connectSignal("SequentialSetup", seqId, "out_0", set1Id, "in")); + ok(mgr.connectSignal("SequentialSetup", seqId, "out_1", set2Id, "in")); + ok(mgr.connectSignal("SequentialSetup", seqId, "out_2", set3Id, "in")); + + // Data flow + ok(mgr.connectData("SequentialSetup", c1Id, "output", set1Id, "value")); + ok(mgr.connectData("SequentialSetup", c2Id, "output", set2Id, "value")); + ok(mgr.connectData("SequentialSetup", c3Id, "output", set3Id, "value")); + + const json = mgr.exportJSON("SequentialSetup")!; + const parsed = validateCoordinator(json); + expect(parsed._flowGraphs[0].allBlocks.length).toBe(8); + + const issues = mgr.validateGraph("SequentialSetup"); + expect(issues.every((i) => !i.startsWith("ERROR"))).toBe(true); + + const fs = require("fs"); + const path = require("path"); + const dir = path.resolve(__dirname, "..", "..", "examples"); + fs.writeFileSync(path.join(dir, "SequentialSetup.flowgraph.json"), json, "utf-8"); + }); +}); diff --git a/packages/tools/flow-graph-mcp-server/tsconfig.json b/packages/tools/flow-graph-mcp-server/tsconfig.json new file mode 100644 index 00000000000..21d8320697f --- /dev/null +++ b/packages/tools/flow-graph-mcp-server/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/tools/gltf-mcp-server/README.md b/packages/tools/gltf-mcp-server/README.md new file mode 100644 index 00000000000..df0e9c81b71 --- /dev/null +++ b/packages/tools/gltf-mcp-server/README.md @@ -0,0 +1,198 @@ +# @tools/gltf-mcp-server + +MCP server for glTF 2.0 asset authoring, inspection, validation, and live preview in Babylon.js. + +## Overview + +This server provides a comprehensive tool surface for working with glTF/GLB assets entirely in memory. It supports loading, creating, inspecting, editing, validating, previewing, and exporting glTF documents through the Model Context Protocol (MCP). + +The server operates **entirely on raw glTF JSON** — no Babylon.js engine, scene, or loader runs server-side. All edits are direct mutations on the `IGLTF` document object (from `glTF2Interface`). The only Babylon.js dependency is in the optional built-in viewer page, which loads Babylon from CDN in the browser for rendering. + +**Binary name:** `babylonjs-gltf` + +## Build & Run + +```bash +# Build +npm run build -w @tools/gltf-mcp-server + +# Run +npm start -w @tools/gltf-mcp-server + +# Or directly +npx babylonjs-gltf +``` + +## Tool Categories + +### 1. Lifecycle (6 tools) + +- `create_gltf` — Create a minimal valid glTF 2.0 document +- `load_gltf` — Load from inline JSON or file path +- `list_gltfs` — List all documents in memory +- `delete_gltf` — Remove a document from memory +- `clone_gltf` — Deep-clone a document under a new name + +### 2. Inspection & Data Access (20 tools) + +- `describe_gltf` — Full document summary: metadata, counts, extensions, warnings +- `describe_scene`, `describe_node`, `describe_mesh`, `describe_material`, `describe_animation`, `describe_skin`, `describe_texture`, `describe_image`, `describe_accessor`, `describe_sampler` — Detailed per-object descriptions +- `read_accessor_data` — Decode binary accessor data into a flat float array (handles stride, normalization, and component type conversion using Babylon.js core buffer utilities) +- `write_accessor_data` — Write float data back into an accessor's binary buffer (handles conversion to native component type, normalization, byte stride). Enables manipulation of vertex positions, normals, UVs, animation keyframes, weights, and any other accessor-backed data. +- `list_scenes`, `list_nodes`, `list_meshes`, `list_materials`, `list_animations`, `list_textures`, `list_extensions` — Listing/summary tools + +### 3. Node & Scene Editing (11 tools) + +- `add_scene`, `rename_scene`, `set_active_scene` +- `add_node`, `rename_node`, `add_child_node` +- `set_node_transform` (TRS), `set_node_matrix` (4×4), `clear_node_transform` +- `reparent_node` (with cycle detection) +- `remove_node` + +### 4. Mesh & Primitive Editing (7 tools) + +- `add_mesh`, `remove_mesh` +- `assign_mesh_to_node`, `unassign_mesh_from_node` +- `describe_mesh_primitives` +- `set_primitive_material`, `remove_primitive_material` + +### 5. Material Editing (9 tools) + +- `add_material`, `remove_material`, `rename_material` +- `set_material_pbr` (baseColorFactor, metallicFactor, roughnessFactor, textures) +- `set_material_alpha_mode` (OPAQUE/MASK/BLEND) +- `set_material_double_sided` +- `set_material_emissive` +- `assign_material_to_mesh_primitive` + +### 6. Texture/Image/Sampler (7 tools) + +- `add_image_reference`, `remove_image` +- `add_texture`, `remove_texture`, `set_texture_sampler` +- `add_sampler`, `remove_sampler` + +### 7. Animation & Skin (5 tools) + +- `list_animation_channels`, `describe_animation_channel` +- `rename_animation`, `remove_animation` +- `remove_skin` + +### 8. Extension Handling (8 tools) + +- `get_extension_data`, `set_extension_data`, `remove_extension_data` +- `add_extension_to_used`, `add_extension_to_required` +- `remove_extension_from_used`, `remove_extension_from_required` + +Extension data supports targets: root, scene, node, mesh, material, texture, image, animation. + +### 9. Validation (1 tool) + +- `validate_gltf` — Checks broken indices, invalid hierarchy, orphaned references, extension consistency, TRS/matrix conflicts, duplicate names + +### 10. Import/Export (6 tools) + +- `export_gltf_json` — Export as JSON (inline or to file) +- `import_gltf_json` — Import from JSON (inline or file) +- `import_glb` — Import a binary `.glb` file from disk +- `export_glb` — Export as binary GLB (with proper BIN chunk for buffers/images) +- `save_to_file` — Save as .gltf or .glb based on extension +- `compact_indices` — Renumber all indices after removing elements to close gaps + +When loading `.gltf` files from disk, external buffers (`.bin`) and images (`.png`, `.jpg`) are automatically resolved and embedded as base64 data URIs, so the document is fully self-contained in memory. + +### 11. Search/Discovery (4 tools) + +- `find_nodes`, `find_materials`, `find_meshes` — Name search (substring or exact) +- `find_extensions` — Search used extensions + +### 12. Live Preview (4 tools) + +- `start_preview` — Start a local HTTP server with a built-in 3D viewer +- `stop_preview` — Stop the preview server +- `get_preview_url` — Get the current viewer and Sandbox URLs +- `set_preview_scene` — Switch which document is being previewed + +The preview server at `http://localhost:8766/` serves a self-contained viewer page that loads Babylon.js from CDN and fetches the model from the same server (same-origin). It also provides direct GLB/JSON download endpoints and an "Open in Sandbox" link. The model is re-exported on every request, so refreshing the page always shows the latest state. + +**Total: 89 tools** + +## Example Workflows + +### Create and export a scene + +``` +1. create_gltf(name: "MyScene") +2. add_node(name: "MyScene", nodeName: "Cube") +3. add_mesh(name: "MyScene", meshName: "CubeMesh") +4. assign_mesh_to_node(name: "MyScene", nodeIndex: 0, meshIndex: 0) +5. add_material(name: "MyScene", materialName: "BlueMetal") +6. set_material_pbr(name: "MyScene", materialIndex: 0, baseColorFactor: [0.2, 0.4, 0.8, 1], metallicFactor: 0.9, roughnessFactor: 0.3) +7. set_primitive_material(name: "MyScene", meshIndex: 0, primitiveIndex: 0, materialIndex: 0) +8. set_node_transform(name: "MyScene", nodeIndex: 0, translation: [0, 1, 0]) +9. validate_gltf(name: "MyScene") +10. export_gltf_json(name: "MyScene", outputFile: "/path/to/scene.gltf") +``` + +### Load, edit, and preview an existing model + +``` +1. load_gltf(name: "Helmet", jsonFile: "/path/to/FlightHelmet.gltf") + — External .bin and image files are auto-resolved +2. list_materials(name: "Helmet") +3. set_material_pbr(name: "Helmet", materialIndex: 0, baseColorFactor: [0.1, 0.95, 0.1, 1]) +4. start_preview(name: "Helmet") + — Opens built-in viewer at http://localhost:8766/ +5. set_material_emissive(name: "Helmet", materialIndex: 2, emissiveFactor: [1, 0, 0]) + — Refresh the viewer to see changes +6. export_glb(name: "Helmet", outputFile: "/path/to/modified.glb") +``` + +### Manipulate geometry + +``` +1. load_gltf(name: "Model", jsonFile: "/path/to/model.gltf") +2. describe_mesh(name: "Model", meshIndex: 0) + — Shows primitive attributes, e.g. POSITION: accessor 0, NORMAL: accessor 1 +3. read_accessor_data(name: "Model", accessorIndex: 0) + — Returns vertex positions as [x,y,z, x,y,z, ...] +4. write_accessor_data(name: "Model", accessorIndex: 0, data: [modified positions...]) + — Scale, translate, or deform the mesh +5. start_preview(name: "Model") — See the result +``` + +### Manipulate animation keyframes + +``` +1. load_gltf(name: "Anim", jsonFile: "/path/to/animated.gltf") +2. describe_animation(name: "Anim", animationIndex: 0) + — Shows channels with input (time) and output (value) accessor indices +3. read_accessor_data(name: "Anim", accessorIndex: ) + — Returns keyframe timestamps +4. read_accessor_data(name: "Anim", accessorIndex: ) + — Returns keyframe values (translation, rotation, scale, or weights) +5. write_accessor_data(name: "Anim", accessorIndex: , data: [modified values...]) + — Edit animation curves +6. start_preview(name: "Anim") — Play the modified animation +``` + +## Limitations & Follow-Up Items + +- **Binary buffer data**: Accessor data can be read and written via `read_accessor_data`/`write_accessor_data` (positions, normals, UVs, indices, animation keyframes, weights, etc.). Writing preserves the accessor's element count — changing the number of vertices or keyframes requires creating new accessors. +- **Animation authoring**: Animation keyframes can be read and modified via accessor data tools. Creating entirely new animations (new channels/samplers) from scratch is not yet supported. +- **Skin authoring**: Skins can be inspected and removed but not created from scratch. + +## Architecture + +- `src/gltfManager.ts` — Core in-memory document manager (~2200 lines). Stores `IGLTF` documents in a `Map`, provides all CRUD operations, handles external buffer/image resolution on load, and assembles GLB binary output. +- `src/previewServer.ts` — Singleton HTTP server for live preview. Serves a built-in Babylon.js viewer page, GLB/JSON model endpoints, and a Sandbox redirect URL. Handles cache busting and session cleanup. +- `src/index.ts` — MCP server entrypoint. Registers 87 tools, wires up transport lifecycle handlers (cleanup on close/exit). +- `test/unit/gltfManager.test.ts` — Unit tests for the document manager +- `test/unit/previewServer.test.ts` — Unit tests for the preview server + +### Key Design Decisions + +- **Minimal engine dependency**: The server never instantiates a Babylon `Engine`, `Scene`, or `SceneSerializer`. All operations are pure JSON manipulation on `IGLTF` objects. The only import from `@dev/core` is the `bufferUtils` module (and its lightweight dependencies `Constants` and `Logger`), which provides binary accessor data encoding/decoding — type-aware TypedArray construction, byte stride handling, normalization, and float-to-native-type conversion. Rollup tree-shakes the rest of core out of the bundle. +- **ESM module**: Uses dynamic `await import("node:fs")` / `await import("node:path")` for file operations (no `require()`). +- **External buffer resolution**: When loading a `.gltf` from disk, all referenced `.bin` buffers and image files are read and converted to `data:` URIs so the document is fully self-contained. +- **GLB export**: Manually assembles the GLB binary (12-byte header + JSON chunk + BIN chunk with 4-byte alignment padding). Decodes data-URI buffers into raw bytes for the BIN chunk. +- **Preview architecture**: The viewer page loads Babylon.js + glTF loader from CDN, fetches `/model.glb` from the same local server (same-origin, no CORS issues). A refresh button and "Open in Sandbox" link are embedded in the page. diff --git a/packages/tools/gltf-mcp-server/examples/MinimalScene.json b/packages/tools/gltf-mcp-server/examples/MinimalScene.json new file mode 100644 index 00000000000..94f2cc49ab3 --- /dev/null +++ b/packages/tools/gltf-mcp-server/examples/MinimalScene.json @@ -0,0 +1,33 @@ +{ + "asset": { + "version": "2.0", + "generator": "babylonjs-gltf-mcp" + }, + "scene": 0, + "scenes": [ + { + "name": "Scene", + "nodes": [ + 0 + ] + } + ], + "nodes": [ + { + "name": "RootNode" + } + ], + "meshes": [], + "materials": [], + "textures": [], + "images": [], + "samplers": [], + "accessors": [], + "bufferViews": [], + "buffers": [], + "animations": [], + "skins": [], + "cameras": [], + "extensionsUsed": [], + "extensionsRequired": [] +} \ No newline at end of file diff --git a/packages/tools/gltf-mcp-server/examples/NodeHierarchy.json b/packages/tools/gltf-mcp-server/examples/NodeHierarchy.json new file mode 100644 index 00000000000..10eafc7b44b --- /dev/null +++ b/packages/tools/gltf-mcp-server/examples/NodeHierarchy.json @@ -0,0 +1,119 @@ +{ + "asset": { + "version": "2.0", + "generator": "babylonjs-gltf-mcp" + }, + "scene": 0, + "scenes": [ + { + "name": "Scene", + "nodes": [ + 0 + ] + } + ], + "nodes": [ + { + "name": "Parent", + "mesh": 0, + "translation": { + "translation": [ + 0, + 0, + 0 + ], + "scale": [ + 2, + 2, + 2 + ] + }, + "children": [ + 1 + ] + }, + { + "name": "Child", + "mesh": 1, + "translation": { + "translation": [ + 1.5, + 0, + 0 + ], + "scale": [ + 0.5, + 0.5, + 0.5 + ] + } + } + ], + "meshes": [ + { + "name": "ParentMesh", + "primitives": [ + { + "attributes": {}, + "material": 0 + } + ] + }, + { + "name": "ChildMesh", + "primitives": [ + { + "attributes": {}, + "material": 1 + } + ] + } + ], + "materials": [ + { + "name": "BlueMaterial", + "pbrMetallicRoughness": { + "baseColorFactor": { + "baseColorFactor": [ + 0.2, + 0.4, + 0.9, + 1 + ], + "metallicFactor": 0, + "roughnessFactor": 1 + }, + "metallicFactor": 1, + "roughnessFactor": 1 + } + }, + { + "name": "GreenMaterial", + "pbrMetallicRoughness": { + "baseColorFactor": { + "baseColorFactor": [ + 0.2, + 0.8, + 0.3, + 1 + ], + "metallicFactor": 0, + "roughnessFactor": 1 + }, + "metallicFactor": 1, + "roughnessFactor": 1 + } + } + ], + "textures": [], + "images": [], + "samplers": [], + "accessors": [], + "bufferViews": [], + "buffers": [], + "animations": [], + "skins": [], + "cameras": [], + "extensionsUsed": [], + "extensionsRequired": [] +} \ No newline at end of file diff --git a/packages/tools/gltf-mcp-server/examples/RedBox.json b/packages/tools/gltf-mcp-server/examples/RedBox.json new file mode 100644 index 00000000000..b215b25a0a9 --- /dev/null +++ b/packages/tools/gltf-mcp-server/examples/RedBox.json @@ -0,0 +1,69 @@ +{ + "asset": { + "version": "2.0", + "generator": "babylonjs-gltf-mcp" + }, + "scene": 0, + "scenes": [ + { + "name": "Scene", + "nodes": [ + 0 + ] + } + ], + "nodes": [ + { + "name": "BoxNode", + "mesh": 0, + "translation": { + "translation": [ + 0, + 1, + 0 + ] + } + } + ], + "meshes": [ + { + "name": "BoxMesh", + "primitives": [ + { + "attributes": {}, + "material": 0 + } + ] + } + ], + "materials": [ + { + "name": "RedMaterial", + "pbrMetallicRoughness": { + "baseColorFactor": { + "baseColorFactor": [ + 1, + 0, + 0, + 1 + ], + "metallicFactor": 0.3, + "roughnessFactor": 0.7 + }, + "metallicFactor": 1, + "roughnessFactor": 1 + } + } + ], + "textures": [], + "images": [], + "samplers": [], + "accessors": [], + "bufferViews": [], + "buffers": [], + "animations": [], + "skins": [], + "cameras": [], + "extensionsUsed": [], + "extensionsRequired": [] +} \ No newline at end of file diff --git a/packages/tools/gltf-mcp-server/package.json b/packages/tools/gltf-mcp-server/package.json new file mode 100644 index 00000000000..24819773b39 --- /dev/null +++ b/packages/tools/gltf-mcp-server/package.json @@ -0,0 +1,30 @@ +{ + "name": "@tools/gltf-mcp-server", + "version": "1.0.0", + "private": true, + "description": "MCP server for glTF/glb asset authoring, inspection, validation, and export in Babylon.js", + "type": "module", + "main": "dist/index.js", + "scripts": { + "clean": "rimraf dist", + "build": "rollup -c", + "start": "node dist/index.js", + "dev": "tsc --watch" + }, + "dependencies": { + "@dev/core": "1.0.0", + "@modelcontextprotocol/sdk": "^1.12.1", + "@tools/mcp-server-core": "1.0.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@rollup/plugin-commonjs": "^26.0.1", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@types/node": "^20.11.0", + "esbuild": "^0.25.0", + "rollup": "^4.59.0", + "typescript": "^5.3.3" + } +} diff --git a/packages/tools/gltf-mcp-server/rollup.config.mjs b/packages/tools/gltf-mcp-server/rollup.config.mjs new file mode 100644 index 00000000000..751cfb9b632 --- /dev/null +++ b/packages/tools/gltf-mcp-server/rollup.config.mjs @@ -0,0 +1,28 @@ +import { createConfig } from "../rollup.config.mcp.mjs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** + * Resolve bare `@dev/core/src/…` imports to the pre-compiled JS files in + * core's dist/ folder so rollup can tree-shake them into the bundle without + * needing an `exports` map in core's package.json. + */ +function resolveDevCore() { + return { + name: "resolve-dev-core", + resolveId(source) { + if (source.startsWith("@dev/core/")) { + const subpath = source.slice("@dev/core/".length); + return resolve(__dirname, "../../dev/core/dist", subpath + ".js"); + } + return null; + }, + }; +} + +const config = createConfig(); +config.plugins = [resolveDevCore(), ...config.plugins]; + +export default config; diff --git a/packages/tools/gltf-mcp-server/src/gltfManager.ts b/packages/tools/gltf-mcp-server/src/gltfManager.ts new file mode 100644 index 00000000000..831077348a3 --- /dev/null +++ b/packages/tools/gltf-mcp-server/src/gltfManager.ts @@ -0,0 +1,2599 @@ +/** + * In-memory glTF document manager. + * Provides lifecycle, inspection, editing, validation, and export operations + * on named glTF 2.0 documents held entirely in memory. + */ + +import { + type IGltfDocument, + type IGltfScene, + type IGltfNode, + type IGltfMesh, + type IGltfMaterial, + type IGltfTexture, + type IGltfImage, + type IGltfSampler, + type IGltfAnimation, + type IGltfSkin, + type IGltfExtensible, + type GltfExtensionTargetType, +} from "./gltfTypes.js"; + +import { GetTypeByteLength, GetFloatData, EnumerateFloatValues } from "@dev/core/Buffers/bufferUtils"; + +/* ------------------------------------------------------------------ */ +/* Helper utilities */ +/* ------------------------------------------------------------------ */ + +function DeepClone(obj: T): T { + return JSON.parse(JSON.stringify(obj)) as T; +} + +function ArrayOrEmpty(arr: T[] | undefined): T[] { + return arr ?? []; +} + +function EnsureArray(doc: Record, key: string): T[] { + if (!Array.isArray(doc[key])) { + (doc as Record)[key] = []; + } + return doc[key] as T[]; +} + +/** + * Resolve a target object by type string and index. + * @param doc - The glTF document. + * @param targetType - Extension target type. + * @param targetIndex - Optional 0-based index for the target. + * @returns The resolved extensible object, or null if not found. + */ +function ResolveExtensionTarget(doc: IGltfDocument, targetType: GltfExtensionTargetType, targetIndex?: number): IGltfExtensible | null { + if (targetType === "root") { + return doc; + } + if (targetIndex === undefined || targetIndex < 0) { + return null; + } + const collectionMap: Record = { + scene: doc.scenes, + node: doc.nodes, + mesh: doc.meshes, + material: doc.materials, + texture: doc.textures, + image: doc.images, + animation: doc.animations, + }; + const collection = collectionMap[targetType]; + if (!collection || targetIndex >= collection.length) { + return null; + } + return collection[targetIndex] as IGltfExtensible; +} + +/* ------------------------------------------------------------------ */ +/* Validation issue type */ +/* ------------------------------------------------------------------ */ + +/** + * + */ +export interface IGltfValidationIssue { + /** + * + */ + severity: "error" | "warning" | "info"; + /** + * + */ + message: string; + /** + * + */ + path?: string; +} + +/* ------------------------------------------------------------------ */ +/* Manager class */ +/* ------------------------------------------------------------------ */ + +/** + * + */ +export class GltfManager { + private _documents: Map = new Map(); + + /* ======================== Lifecycle ========================== */ + + createGltf(name: string): string { + if (this._documents.has(name)) { + return `Error: A glTF document named "${name}" already exists.`; + } + const doc: IGltfDocument = { + asset: { version: "2.0", generator: "babylonjs-gltf-mcp" }, + scene: 0, + scenes: [{ name: "Scene", nodes: [] }], + nodes: [], + meshes: [], + materials: [], + textures: [], + images: [], + samplers: [], + accessors: [], + bufferViews: [], + buffers: [], + animations: [], + skins: [], + cameras: [], + extensionsUsed: [], + extensionsRequired: [], + }; + this._documents.set(name, doc); + return this.describeGltf(name); + } + + loadGltf(name: string, jsonText: string): string { + let doc: IGltfDocument; + try { + doc = JSON.parse(jsonText) as IGltfDocument; + } catch { + return "Error: Invalid JSON."; + } + if (!doc.asset || !doc.asset.version) { + return "Error: Not a valid glTF document — missing asset.version."; + } + if (this._documents.has(name)) { + return `Error: A glTF document named "${name}" already exists. Use a different name or delete the existing one first.`; + } + this._documents.set(name, doc); + return this.describeGltf(name); + } + + /** + * Resolve external buffer and image URIs by reading the referenced files + * and converting them to base64 data URIs. This makes the in-memory + * document fully self-contained so it can be exported as GLB. + * + * @param name Name of the loaded document. + * @param baseDir Directory containing the .gltf file (for resolving + * relative URIs). + * @returns A status message. + */ + async resolveExternalBuffersAsync(name: string, baseDir: string): Promise { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + + const fs = await import("node:fs"); + const path = await import("node:path"); + + let resolved = 0; + + // Resolve buffer URIs + for (const buf of ArrayOrEmpty(doc.buffers)) { + if (buf.uri && !buf.uri.startsWith("data:")) { + const filePath = path.resolve(baseDir, buf.uri); + if (fs.existsSync(filePath)) { + const data = fs.readFileSync(filePath); + buf.uri = `data:application/octet-stream;base64,${data.toString("base64")}`; + buf.byteLength = data.length; + resolved++; + } + } + } + + // Resolve image URIs + for (const img of ArrayOrEmpty(doc.images)) { + if (img.uri && !img.uri.startsWith("data:")) { + const filePath = path.resolve(baseDir, img.uri); + if (fs.existsSync(filePath)) { + const data = fs.readFileSync(filePath); + const ext = path.extname(img.uri).toLowerCase(); + const mime = ext === ".png" ? "image/png" : ext === ".jpg" || ext === ".jpeg" ? "image/jpeg" : "application/octet-stream"; + img.uri = `data:${mime};base64,${data.toString("base64")}`; + resolved++; + } + } + } + + return resolved > 0 ? `Resolved ${resolved} external URI(s) into embedded data.` : "No external URIs to resolve."; + } + + listGltfs(): string { + if (this._documents.size === 0) { + return "No glTF documents in memory."; + } + const lines: string[] = ["## glTF Documents in Memory\n"]; + for (const [name, doc] of this._documents) { + lines.push( + `- **${name}** — v${doc.asset.version}, ${ArrayOrEmpty(doc.scenes).length} scene(s), ${ArrayOrEmpty(doc.nodes).length} node(s), ${ArrayOrEmpty(doc.meshes).length} mesh(es), ${ArrayOrEmpty(doc.materials).length} material(s)` + ); + } + return lines.join("\n"); + } + + deleteGltf(name: string): string { + if (!this._documents.has(name)) { + return `Error: No glTF document named "${name}".`; + } + this._documents.delete(name); + return `Deleted "${name}".`; + } + + cloneGltf(sourceName: string, newName: string): string { + const doc = this._documents.get(sourceName); + if (!doc) { + return `Error: No glTF document named "${sourceName}".`; + } + if (this._documents.has(newName)) { + return `Error: A glTF document named "${newName}" already exists.`; + } + this._documents.set(newName, DeepClone(doc)); + return `Cloned "${sourceName}" → "${newName}".\n\n${this.describeGltf(newName)}`; + } + + /* ======================== Inspection ======================== */ + + describeGltf(name: string): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + + const scenes = ArrayOrEmpty(doc.scenes); + const nodes = ArrayOrEmpty(doc.nodes); + const meshes = ArrayOrEmpty(doc.meshes); + const materials = ArrayOrEmpty(doc.materials); + const textures = ArrayOrEmpty(doc.textures); + const images = ArrayOrEmpty(doc.images); + const animations = ArrayOrEmpty(doc.animations); + const skins = ArrayOrEmpty(doc.skins); + const cameras = ArrayOrEmpty(doc.cameras); + const accessors = ArrayOrEmpty(doc.accessors); + const samplers = ArrayOrEmpty(doc.samplers); + const extUsed = ArrayOrEmpty(doc.extensionsUsed); + const extReq = ArrayOrEmpty(doc.extensionsRequired); + + const lines: string[] = [ + `## glTF: ${name}`, + `- **Version**: ${doc.asset.version}${doc.asset.generator ? ` (generator: ${doc.asset.generator})` : ""}`, + `- **Active scene**: ${doc.scene !== undefined ? doc.scene : "none"}`, + `- **Scenes**: ${scenes.length}`, + `- **Nodes**: ${nodes.length}`, + `- **Meshes**: ${meshes.length}`, + `- **Materials**: ${materials.length}`, + `- **Textures**: ${textures.length}`, + `- **Images**: ${images.length}`, + `- **Samplers**: ${samplers.length}`, + `- **Accessors**: ${accessors.length}`, + `- **Animations**: ${animations.length}`, + `- **Skins**: ${skins.length}`, + `- **Cameras**: ${cameras.length}`, + ]; + + if (extUsed.length > 0) { + lines.push(`- **extensionsUsed**: ${extUsed.join(", ")}`); + } + if (extReq.length > 0) { + lines.push(`- **extensionsRequired**: ${extReq.join(", ")}`); + } + + // Structural warnings + const warnings: string[] = []; + if (scenes.length === 0) { + warnings.push("No scenes defined"); + } + if (doc.scene !== undefined && (doc.scene < 0 || doc.scene >= scenes.length)) { + warnings.push(`Active scene index ${doc.scene} is out of range`); + } + if (warnings.length > 0) { + lines.push(`\n**Warnings**: ${warnings.join("; ")}`); + } + + return lines.join("\n"); + } + + describeScene(name: string, sceneIndex: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const scenes = ArrayOrEmpty(doc.scenes); + if (sceneIndex < 0 || sceneIndex >= scenes.length) { + return `Error: Scene index ${sceneIndex} out of range (0..${scenes.length - 1}).`; + } + const s = scenes[sceneIndex]; + const nodeIndices = ArrayOrEmpty(s.nodes); + const lines: string[] = [ + `## Scene ${sceneIndex}: ${s.name ?? "(unnamed)"}`, + `- **Root nodes**: ${nodeIndices.length}${nodeIndices.length > 0 ? ` [${nodeIndices.join(", ")}]` : ""}`, + `- **Is active scene**: ${doc.scene === sceneIndex ? "yes" : "no"}`, + ]; + if (s.extensions && Object.keys(s.extensions).length > 0) { + lines.push(`- **Extensions**: ${Object.keys(s.extensions).join(", ")}`); + } + return lines.join("\n"); + } + + describeNode(name: string, nodeIndex: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const nodes = ArrayOrEmpty(doc.nodes); + if (nodeIndex < 0 || nodeIndex >= nodes.length) { + return `Error: Node index ${nodeIndex} out of range (0..${nodes.length - 1}).`; + } + const n = nodes[nodeIndex]; + const children = ArrayOrEmpty(n.children); + const parent = this._findParentNode(doc, nodeIndex); + + const lines: string[] = [ + `## Node ${nodeIndex}: ${n.name ?? "(unnamed)"}`, + `- **Parent**: ${parent !== -1 ? `node ${parent}` : "scene root"}`, + `- **Children**: ${children.length > 0 ? children.join(", ") : "none"}`, + ]; + if (n.mesh !== undefined) { + lines.push(`- **Mesh**: ${n.mesh}`); + } + if (n.skin !== undefined) { + lines.push(`- **Skin**: ${n.skin}`); + } + if (n.camera !== undefined) { + lines.push(`- **Camera**: ${n.camera}`); + } + if (n.translation) { + lines.push(`- **Translation**: [${n.translation.join(", ")}]`); + } + if (n.rotation) { + lines.push(`- **Rotation**: [${n.rotation.join(", ")}]`); + } + if (n.scale) { + lines.push(`- **Scale**: [${n.scale.join(", ")}]`); + } + if (n.matrix) { + lines.push(`- **Matrix**: [${n.matrix.join(", ")}]`); + } + if (n.extensions && Object.keys(n.extensions).length > 0) { + lines.push(`- **Extensions**: ${Object.keys(n.extensions).join(", ")}`); + } + return lines.join("\n"); + } + + describeMesh(name: string, meshIndex: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const meshes = ArrayOrEmpty(doc.meshes); + if (meshIndex < 0 || meshIndex >= meshes.length) { + return `Error: Mesh index ${meshIndex} out of range (0..${meshes.length - 1}).`; + } + const m = meshes[meshIndex]; + const lines: string[] = [`## Mesh ${meshIndex}: ${m.name ?? "(unnamed)"}`, `- **Primitives**: ${m.primitives.length}`]; + m.primitives.forEach((p, i) => { + const attrs = Object.keys(p.attributes).join(", "); + lines.push(` - Primitive ${i}: attributes=[${attrs}], material=${p.material ?? "none"}, mode=${p.mode ?? 4}`); + }); + if (m.extensions && Object.keys(m.extensions).length > 0) { + lines.push(`- **Extensions**: ${Object.keys(m.extensions).join(", ")}`); + } + return lines.join("\n"); + } + + describeMaterial(name: string, materialIndex: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const materials = ArrayOrEmpty(doc.materials); + if (materialIndex < 0 || materialIndex >= materials.length) { + return `Error: Material index ${materialIndex} out of range (0..${materials.length - 1}).`; + } + const mat = materials[materialIndex]; + const pbr = mat.pbrMetallicRoughness; + const lines: string[] = [ + `## Material ${materialIndex}: ${mat.name ?? "(unnamed)"}`, + `- **alphaMode**: ${mat.alphaMode ?? "OPAQUE"}`, + `- **doubleSided**: ${mat.doubleSided ?? false}`, + ]; + if (mat.alphaCutoff !== undefined) { + lines.push(`- **alphaCutoff**: ${mat.alphaCutoff}`); + } + if (mat.emissiveFactor) { + lines.push(`- **emissiveFactor**: [${mat.emissiveFactor.join(", ")}]`); + } + if (mat.emissiveTexture) { + lines.push(`- **emissiveTexture**: texture ${mat.emissiveTexture.index}`); + } + if (mat.normalTexture) { + lines.push(`- **normalTexture**: texture ${mat.normalTexture.index}${mat.normalTexture.scale !== undefined ? `, scale=${mat.normalTexture.scale}` : ""}`); + } + if (mat.occlusionTexture) { + lines.push( + `- **occlusionTexture**: texture ${mat.occlusionTexture.index}${mat.occlusionTexture.strength !== undefined ? `, strength=${mat.occlusionTexture.strength}` : ""}` + ); + } + if (pbr) { + lines.push(`- **PBR metallic-roughness**:`); + if (pbr.baseColorFactor) { + lines.push(` - baseColorFactor: [${pbr.baseColorFactor.join(", ")}]`); + } + if (pbr.metallicFactor !== undefined) { + lines.push(` - metallicFactor: ${pbr.metallicFactor}`); + } + if (pbr.roughnessFactor !== undefined) { + lines.push(` - roughnessFactor: ${pbr.roughnessFactor}`); + } + if (pbr.baseColorTexture) { + lines.push(` - baseColorTexture: texture ${pbr.baseColorTexture.index}`); + } + if (pbr.metallicRoughnessTexture) { + lines.push(` - metallicRoughnessTexture: texture ${pbr.metallicRoughnessTexture.index}`); + } + } + if (mat.extensions && Object.keys(mat.extensions).length > 0) { + lines.push(`- **Extensions**: ${Object.keys(mat.extensions).join(", ")}`); + } + return lines.join("\n"); + } + + describeAnimation(name: string, animIndex: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const anims = ArrayOrEmpty(doc.animations); + if (animIndex < 0 || animIndex >= anims.length) { + return `Error: Animation index ${animIndex} out of range (0..${anims.length - 1}).`; + } + const a = anims[animIndex]; + const lines: string[] = [`## Animation ${animIndex}: ${a.name ?? "(unnamed)"}`, `- **Channels**: ${a.channels.length}`, `- **Samplers**: ${a.samplers.length}`]; + a.channels.forEach((ch, i) => { + lines.push(` - Channel ${i}: node=${ch.target.node ?? "?"}, path=${ch.target.path}, sampler=${ch.sampler}`); + }); + return lines.join("\n"); + } + + describeSkin(name: string, skinIndex: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const skins = ArrayOrEmpty(doc.skins); + if (skinIndex < 0 || skinIndex >= skins.length) { + return `Error: Skin index ${skinIndex} out of range (0..${skins.length - 1}).`; + } + const sk = skins[skinIndex]; + const lines: string[] = [ + `## Skin ${skinIndex}: ${sk.name ?? "(unnamed)"}`, + `- **Joints**: ${sk.joints.length} [${sk.joints.join(", ")}]`, + `- **Skeleton root**: ${sk.skeleton ?? "none"}`, + `- **Inverse bind matrices accessor**: ${sk.inverseBindMatrices ?? "none"}`, + ]; + return lines.join("\n"); + } + + describeTexture(name: string, texIndex: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const textures = ArrayOrEmpty(doc.textures); + if (texIndex < 0 || texIndex >= textures.length) { + return `Error: Texture index ${texIndex} out of range (0..${textures.length - 1}).`; + } + const t = textures[texIndex]; + const lines: string[] = [ + `## Texture ${texIndex}: ${t.name ?? "(unnamed)"}`, + `- **Source image**: ${t.source !== undefined ? t.source : "none"}`, + `- **Sampler**: ${t.sampler !== undefined ? t.sampler : "none"}`, + ]; + // Show image info if source is set + if (t.source !== undefined) { + const images = ArrayOrEmpty(doc.images); + if (t.source >= 0 && t.source < images.length) { + const img = images[t.source]; + lines.push(`- **Image URI**: ${img.uri ?? "(buffer-backed)"}`); + if (img.mimeType) { + lines.push(`- **Image MIME**: ${img.mimeType}`); + } + } + } + if (t.extensions && Object.keys(t.extensions).length > 0) { + lines.push(`- **Extensions**: ${Object.keys(t.extensions).join(", ")}`); + } + return lines.join("\n"); + } + + describeImage(name: string, imageIndex: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const images = ArrayOrEmpty(doc.images); + if (imageIndex < 0 || imageIndex >= images.length) { + return `Error: Image index ${imageIndex} out of range (0..${images.length - 1}).`; + } + const img = images[imageIndex]; + const lines: string[] = [ + `## Image ${imageIndex}: ${img.name ?? "(unnamed)"}`, + `- **URI**: ${img.uri ?? "(none)"}`, + `- **MIME type**: ${img.mimeType ?? "(not set)"}`, + `- **Buffer view**: ${img.bufferView !== undefined ? img.bufferView : "(none)"}`, + ]; + return lines.join("\n"); + } + + describeAccessor(name: string, accessorIndex: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const accessors = ArrayOrEmpty(doc.accessors); + if (accessorIndex < 0 || accessorIndex >= accessors.length) { + return `Error: Accessor index ${accessorIndex} out of range (0..${accessors.length - 1}).`; + } + const acc = accessors[accessorIndex]; + const lines: string[] = [ + `## Accessor ${accessorIndex}: ${acc.name ?? "(unnamed)"}`, + `- **Type**: ${acc.type}`, + `- **Component type**: ${acc.componentType}`, + `- **Count**: ${acc.count}`, + `- **Buffer view**: ${acc.bufferView !== undefined ? acc.bufferView : "(none)"}`, + ]; + if (acc.min) { + lines.push(`- **Min**: [${acc.min.join(", ")}]`); + } + if (acc.max) { + lines.push(`- **Max**: [${acc.max.join(", ")}]`); + } + return lines.join("\n"); + } + + /** + * Returns the number of components for a glTF accessor type string. + * @param type - The glTF accessor type string. + * @returns The number of components. + */ + private static _GetNumComponents(type: string): number { + switch (type) { + case "SCALAR": + return 1; + case "VEC2": + return 2; + case "VEC3": + return 3; + case "VEC4": + case "MAT2": + return 4; + case "MAT3": + return 9; + case "MAT4": + return 16; + default: + throw new Error(`Unknown accessor type: ${type}`); + } + } + + /** + * Decodes the raw binary buffer for a given buffer index from its data URI. + * Returns null if the buffer has no data URI. + * @param doc - The glTF document. + * @param bufferIndex - The buffer index. + * @returns The decoded bytes, or null if no data URI. + */ + private _getBufferBytes(doc: IGltfDocument, bufferIndex: number): Uint8Array | null { + const buf = ArrayOrEmpty(doc.buffers)[bufferIndex]; + if (!buf?.uri) { + return null; + } + const m = buf.uri.match(/^data:[^;]*;base64,(.*)$/); + if (!m) { + return null; + } + const raw = atob(m[1]); + const bytes = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) { + bytes[i] = raw.charCodeAt(i); + } + return bytes; + } + + /** + * Encodes a Uint8Array back into the buffer's data URI. + * @param doc - The glTF document. + * @param bufferIndex - The buffer index. + * @param bytes - The bytes to encode. + */ + private _setBufferBytes(doc: IGltfDocument, bufferIndex: number, bytes: Uint8Array): void { + const buf = ArrayOrEmpty(doc.buffers)[bufferIndex]; + if (!buf) { + return; + } + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + buf.uri = `data:application/octet-stream;base64,${btoa(binary)}`; + buf.byteLength = bytes.length; + } + + /** + * Reads the data of an accessor as a flat Float32Array. + * Handles byte stride, normalization, and component type conversion + * using the utilities from @dev/core bufferUtils. + * @param name - The document name. + * @param accessorIndex - The accessor index. + * @returns The accessor data or an error string. + */ + readAccessorData(name: string, accessorIndex: number): { data: number[]; componentCount: number; count: number } | string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const accessors = ArrayOrEmpty(doc.accessors); + if (accessorIndex < 0 || accessorIndex >= accessors.length) { + return `Error: Accessor index ${accessorIndex} out of range (0..${accessors.length - 1}).`; + } + const acc = accessors[accessorIndex]; + + if (acc.bufferView === undefined) { + // Accessor with no bufferView — all zeros (sparse-only or placeholder) + const numComponents = GltfManager._GetNumComponents(acc.type); + return { data: new Array(acc.count * numComponents).fill(0), componentCount: numComponents, count: acc.count }; + } + + const bufferViews = ArrayOrEmpty(doc.bufferViews); + const bv = bufferViews[acc.bufferView]; + if (!bv) { + return `Error: Buffer view ${acc.bufferView} not found.`; + } + + const bufferBytes = this._getBufferBytes(doc, bv.buffer); + if (!bufferBytes) { + return `Error: Cannot read buffer ${bv.buffer} — no data URI present.`; + } + + const numComponents = GltfManager._GetNumComponents(acc.type); + const componentByteLength = GetTypeByteLength(acc.componentType); + const defaultStride = numComponents * componentByteLength; + const byteStride = bv.byteStride ?? defaultStride; + const byteOffset = (bv.byteOffset ?? 0) + (acc.byteOffset ?? 0); + + const floatData = GetFloatData(bufferBytes, numComponents, acc.componentType, byteOffset, byteStride, acc.normalized ?? false, acc.count); + + return { + data: Array.from(floatData), + componentCount: numComponents, + count: acc.count, + }; + } + + /** + * Writes float data back into an accessor's binary buffer. + * Converts from float values to the accessor's native component type, + * handling normalization and byte stride. + * The data array length must equal accessor.count * numComponents. + * @param name - The document name. + * @param accessorIndex - The accessor index. + * @param data - The float data to write. + * @returns An error string, or null on success. + */ + writeAccessorData(name: string, accessorIndex: number, data: number[]): string | null { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const accessors = ArrayOrEmpty(doc.accessors); + if (accessorIndex < 0 || accessorIndex >= accessors.length) { + return `Error: Accessor index ${accessorIndex} out of range (0..${accessors.length - 1}).`; + } + const acc = accessors[accessorIndex]; + const numComponents = GltfManager._GetNumComponents(acc.type); + const expectedLength = acc.count * numComponents; + + if (data.length !== expectedLength) { + return `Error: Expected ${expectedLength} values (${acc.count} elements × ${numComponents} components), got ${data.length}.`; + } + + if (acc.bufferView === undefined) { + // Create a new buffer and bufferView for this accessor + const componentByteLength = GetTypeByteLength(acc.componentType); + const byteLength = acc.count * numComponents * componentByteLength; + const bytes = new Uint8Array(byteLength); + const buffers = EnsureArray<{ uri?: string; byteLength: number }>(doc as unknown as Record, "buffers"); + const bufferIndex = buffers.length; + buffers.push({ byteLength, uri: "" }); + this._setBufferBytes(doc, bufferIndex, bytes); + + const bufferViews = EnsureArray<{ buffer: number; byteOffset: number; byteLength: number }>(doc as unknown as Record, "bufferViews"); + const bvIndex = bufferViews.length; + bufferViews.push({ buffer: bufferIndex, byteOffset: 0, byteLength }); + acc.bufferView = bvIndex; + acc.byteOffset = 0; + } + + const bufferViews = ArrayOrEmpty(doc.bufferViews); + const bv = bufferViews[acc.bufferView]; + if (!bv) { + return `Error: Buffer view ${acc.bufferView} not found.`; + } + + const bufferBytes = this._getBufferBytes(doc, bv.buffer); + if (!bufferBytes) { + return `Error: Cannot read buffer ${bv.buffer} — no data URI present.`; + } + + const componentByteLength = GetTypeByteLength(acc.componentType); + const defaultStride = numComponents * componentByteLength; + const byteStride = bv.byteStride ?? defaultStride; + const byteOffset = (bv.byteOffset ?? 0) + (acc.byteOffset ?? 0); + + // Use EnumerateFloatValues to write data back — the callback replaces + // values in-place, and the function handles the conversion from float + // to the native component type (including normalization). + let dataIdx = 0; + EnumerateFloatValues(bufferBytes, byteOffset, byteStride, numComponents, acc.componentType, expectedLength, acc.normalized ?? false, (values) => { + for (let i = 0; i < numComponents; i++) { + values[i] = data[dataIdx++]; + } + }); + + // Write the modified bytes back to the buffer's data URI + this._setBufferBytes(doc, bv.buffer, bufferBytes); + + return null; + } + + describeSampler(name: string, samplerIndex: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const samplers = ArrayOrEmpty(doc.samplers); + if (samplerIndex < 0 || samplerIndex >= samplers.length) { + return `Error: Sampler index ${samplerIndex} out of range (0..${samplers.length - 1}).`; + } + const s = samplers[samplerIndex]; + const lines: string[] = [ + `## Sampler ${samplerIndex}: ${s.name ?? "(unnamed)"}`, + `- **magFilter**: ${s.magFilter ?? "(default)"}`, + `- **minFilter**: ${s.minFilter ?? "(default)"}`, + `- **wrapS**: ${s.wrapS ?? 10497}`, + `- **wrapT**: ${s.wrapT ?? 10497}`, + ]; + return lines.join("\n"); + } + + /* ==================== List operations ======================= */ + + listScenes(name: string): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const scenes = ArrayOrEmpty(doc.scenes); + if (scenes.length === 0) { + return "No scenes."; + } + return scenes.map((s, i) => `${i}: ${s.name ?? "(unnamed)"}${doc.scene === i ? " [active]" : ""} — ${ArrayOrEmpty(s.nodes).length} root node(s)`).join("\n"); + } + + listNodes(name: string): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const nodes = ArrayOrEmpty(doc.nodes); + if (nodes.length === 0) { + return "No nodes."; + } + return nodes + .map((n, i) => { + const parts = [`${i}: ${n.name ?? "(unnamed)"}`]; + if (n.mesh !== undefined) { + parts.push(`mesh=${n.mesh}`); + } + if (n.children && n.children.length > 0) { + parts.push(`children=[${n.children.join(",")}]`); + } + return parts.join(" "); + }) + .join("\n"); + } + + listMeshes(name: string): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const meshes = ArrayOrEmpty(doc.meshes); + if (meshes.length === 0) { + return "No meshes."; + } + return meshes.map((m, i) => `${i}: ${m.name ?? "(unnamed)"} — ${m.primitives.length} primitive(s)`).join("\n"); + } + + listMaterials(name: string): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const materials = ArrayOrEmpty(doc.materials); + if (materials.length === 0) { + return "No materials."; + } + return materials + .map((m, i) => { + const pbr = m.pbrMetallicRoughness; + const parts = [`${i}: ${m.name ?? "(unnamed)"}`]; + if (pbr?.metallicFactor !== undefined) { + parts.push(`metallic=${pbr.metallicFactor}`); + } + if (pbr?.roughnessFactor !== undefined) { + parts.push(`roughness=${pbr.roughnessFactor}`); + } + parts.push(`alpha=${m.alphaMode ?? "OPAQUE"}`); + return parts.join(" "); + }) + .join("\n"); + } + + listAnimations(name: string): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const anims = ArrayOrEmpty(doc.animations); + if (anims.length === 0) { + return "No animations."; + } + return anims.map((a, i) => `${i}: ${a.name ?? "(unnamed)"} — ${a.channels.length} channel(s), ${a.samplers.length} sampler(s)`).join("\n"); + } + + listTextures(name: string): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const textures = ArrayOrEmpty(doc.textures); + if (textures.length === 0) { + return "No textures."; + } + return textures.map((t, i) => `${i}: ${t.name ?? "(unnamed)"} — source=${t.source ?? "none"}, sampler=${t.sampler ?? "none"}`).join("\n"); + } + + listExtensions(name: string): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const used = ArrayOrEmpty(doc.extensionsUsed); + const required = ArrayOrEmpty(doc.extensionsRequired); + if (used.length === 0 && required.length === 0) { + return "No extensions declared."; + } + const lines: string[] = []; + if (used.length > 0) { + lines.push(`**extensionsUsed**: ${used.join(", ")}`); + } + if (required.length > 0) { + lines.push(`**extensionsRequired**: ${required.join(", ")}`); + } + return lines.join("\n"); + } + + /* ================ Node and scene editing ==================== */ + + addScene(name: string, sceneName?: string): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const scenes = EnsureArray(doc as unknown as Record, "scenes"); + const idx = scenes.length; + scenes.push({ name: sceneName ?? `Scene_${idx}`, nodes: [] }); + return `Added scene ${idx}: "${scenes[idx].name}".`; + } + + renameScene(name: string, sceneIndex: number, newName: string): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const scenes = ArrayOrEmpty(doc.scenes); + if (sceneIndex < 0 || sceneIndex >= scenes.length) { + return `Error: Scene index ${sceneIndex} out of range.`; + } + const old = scenes[sceneIndex].name; + scenes[sceneIndex].name = newName; + return `Renamed scene ${sceneIndex} from "${old ?? "(unnamed)"}" to "${newName}".`; + } + + setActiveScene(name: string, sceneIndex: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const scenes = ArrayOrEmpty(doc.scenes); + if (sceneIndex < 0 || sceneIndex >= scenes.length) { + return `Error: Scene index ${sceneIndex} out of range.`; + } + doc.scene = sceneIndex; + return `Active scene set to ${sceneIndex}: "${scenes[sceneIndex].name ?? "(unnamed)"}".`; + } + + addNode(name: string, nodeName?: string, parentNodeIndex?: number, sceneIndex?: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const nodes = EnsureArray(doc as unknown as Record, "nodes"); + const idx = nodes.length; + nodes.push({ name: nodeName ?? `Node_${idx}` }); + + if (parentNodeIndex !== undefined) { + if (parentNodeIndex < 0 || parentNodeIndex >= idx) { + return `Error: Parent node index ${parentNodeIndex} out of range.`; + } + const parent = nodes[parentNodeIndex]; + if (!parent.children) { + parent.children = []; + } + parent.children.push(idx); + } else { + // Add to scene root + const si = sceneIndex ?? doc.scene ?? 0; + const scenes = ArrayOrEmpty(doc.scenes); + if (si >= 0 && si < scenes.length) { + if (!scenes[si].nodes) { + scenes[si].nodes = []; + } + scenes[si].nodes!.push(idx); + } + } + return `Added node ${idx}: "${nodes[idx].name}".`; + } + + renameNode(name: string, nodeIndex: number, newName: string): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const nodes = ArrayOrEmpty(doc.nodes); + if (nodeIndex < 0 || nodeIndex >= nodes.length) { + return `Error: Node index ${nodeIndex} out of range.`; + } + const old = nodes[nodeIndex].name; + nodes[nodeIndex].name = newName; + return `Renamed node ${nodeIndex} from "${old ?? "(unnamed)"}" to "${newName}".`; + } + + setNodeTransform( + name: string, + nodeIndex: number, + translation?: [number, number, number], + rotation?: [number, number, number, number], + scale?: [number, number, number] + ): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const nodes = ArrayOrEmpty(doc.nodes); + if (nodeIndex < 0 || nodeIndex >= nodes.length) { + return `Error: Node index ${nodeIndex} out of range.`; + } + const n = nodes[nodeIndex]; + // TRS and matrix are mutually exclusive per spec — clear matrix if present + delete n.matrix; + if (translation) { + n.translation = translation; + } + if (rotation) { + n.rotation = rotation; + } + if (scale) { + n.scale = scale; + } + return `Updated transform on node ${nodeIndex} "${n.name ?? "(unnamed)"}".`; + } + + setNodeMatrix(name: string, nodeIndex: number, matrix: number[]): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const nodes = ArrayOrEmpty(doc.nodes); + if (nodeIndex < 0 || nodeIndex >= nodes.length) { + return `Error: Node index ${nodeIndex} out of range.`; + } + if (matrix.length !== 16) { + return "Error: Matrix must have exactly 16 elements."; + } + const n = nodes[nodeIndex]; + // Matrix and TRS are mutually exclusive per spec + delete n.translation; + delete n.rotation; + delete n.scale; + n.matrix = matrix; + return `Set matrix on node ${nodeIndex} "${n.name ?? "(unnamed)"}".`; + } + + clearNodeTransform(name: string, nodeIndex: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const nodes = ArrayOrEmpty(doc.nodes); + if (nodeIndex < 0 || nodeIndex >= nodes.length) { + return `Error: Node index ${nodeIndex} out of range.`; + } + const n = nodes[nodeIndex]; + delete n.translation; + delete n.rotation; + delete n.scale; + delete n.matrix; + return `Cleared transform on node ${nodeIndex} "${n.name ?? "(unnamed)"}".`; + } + + reparentNode(name: string, nodeIndex: number, newParentIndex?: number, sceneIndex?: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const nodes = ArrayOrEmpty(doc.nodes); + if (nodeIndex < 0 || nodeIndex >= nodes.length) { + return `Error: Node index ${nodeIndex} out of range.`; + } + if (newParentIndex !== undefined && (newParentIndex < 0 || newParentIndex >= nodes.length)) { + return `Error: New parent node index ${newParentIndex} out of range.`; + } + if (newParentIndex === nodeIndex) { + return "Error: A node cannot be its own parent."; + } + // Check for cycle + if (newParentIndex !== undefined && this._isDescendant(doc, newParentIndex, nodeIndex)) { + return "Error: Reparenting would create a cycle."; + } + + // Remove from current parent(s) and scene roots + this._removeNodeFromAllParents(doc, nodeIndex); + + if (newParentIndex !== undefined) { + const parent = nodes[newParentIndex]; + if (!parent.children) { + parent.children = []; + } + parent.children.push(nodeIndex); + } else { + // Move to scene root + const si = sceneIndex ?? doc.scene ?? 0; + const scenes = ArrayOrEmpty(doc.scenes); + if (si >= 0 && si < scenes.length) { + if (!scenes[si].nodes) { + scenes[si].nodes = []; + } + scenes[si].nodes!.push(nodeIndex); + } + } + return `Reparented node ${nodeIndex} "${nodes[nodeIndex].name ?? "(unnamed)"}" to ${newParentIndex !== undefined ? `node ${newParentIndex}` : "scene root"}.`; + } + + removeNode(name: string, nodeIndex: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const nodes = ArrayOrEmpty(doc.nodes); + if (nodeIndex < 0 || nodeIndex >= nodes.length) { + return `Error: Node index ${nodeIndex} out of range.`; + } + const nodeName = nodes[nodeIndex].name; + // Remove from parent references + this._removeNodeFromAllParents(doc, nodeIndex); + // Nullify the slot (to preserve indices) + (nodes as (IGltfNode | null)[])[nodeIndex] = null as unknown as IGltfNode; + // Clean children references that point to this node + for (const n of nodes) { + if (n && n.children) { + n.children = n.children.filter((c) => c !== nodeIndex); + if (n.children.length === 0) { + delete n.children; + } + } + } + return `Removed node ${nodeIndex} "${nodeName ?? "(unnamed)"}" (slot nullified to preserve indices).`; + } + + addChildNode(name: string, parentIndex: number, childName?: string): string { + return this.addNode(name, childName, parentIndex); + } + + /* ================ Mesh / primitive editing ================== */ + + addMesh(name: string, meshName?: string): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const meshes = EnsureArray(doc as unknown as Record, "meshes"); + const idx = meshes.length; + meshes.push({ + name: meshName ?? `Mesh_${idx}`, + primitives: [{ attributes: {} }], + }); + return `Added mesh ${idx}: "${meshes[idx].name}" with 1 empty primitive.`; + } + + removeMesh(name: string, meshIndex: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const meshes = ArrayOrEmpty(doc.meshes); + if (meshIndex < 0 || meshIndex >= meshes.length) { + return `Error: Mesh index ${meshIndex} out of range.`; + } + const meshName = meshes[meshIndex].name; + // Nullify slot + (meshes as (IGltfMesh | null)[])[meshIndex] = null as unknown as IGltfMesh; + // Remove references from nodes + for (const n of ArrayOrEmpty(doc.nodes)) { + if (n && n.mesh === meshIndex) { + delete n.mesh; + } + } + return `Removed mesh ${meshIndex} "${meshName ?? "(unnamed)"}" and cleared node references.`; + } + + assignMeshToNode(name: string, nodeIndex: number, meshIndex: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const nodes = ArrayOrEmpty(doc.nodes); + const meshes = ArrayOrEmpty(doc.meshes); + if (nodeIndex < 0 || nodeIndex >= nodes.length) { + return `Error: Node index ${nodeIndex} out of range.`; + } + if (meshIndex < 0 || meshIndex >= meshes.length) { + return `Error: Mesh index ${meshIndex} out of range.`; + } + nodes[nodeIndex].mesh = meshIndex; + return `Assigned mesh ${meshIndex} to node ${nodeIndex}.`; + } + + unassignMeshFromNode(name: string, nodeIndex: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const nodes = ArrayOrEmpty(doc.nodes); + if (nodeIndex < 0 || nodeIndex >= nodes.length) { + return `Error: Node index ${nodeIndex} out of range.`; + } + delete nodes[nodeIndex].mesh; + return `Unassigned mesh from node ${nodeIndex}.`; + } + + describeMeshPrimitives(name: string, meshIndex: number): string { + return this.describeMesh(name, meshIndex); + } + + setPrimitiveMaterial(name: string, meshIndex: number, primitiveIndex: number, materialIndex: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const meshes = ArrayOrEmpty(doc.meshes); + if (meshIndex < 0 || meshIndex >= meshes.length) { + return `Error: Mesh index ${meshIndex} out of range.`; + } + const prims = meshes[meshIndex].primitives; + if (primitiveIndex < 0 || primitiveIndex >= prims.length) { + return `Error: Primitive index ${primitiveIndex} out of range.`; + } + const materials = ArrayOrEmpty(doc.materials); + if (materialIndex < 0 || materialIndex >= materials.length) { + return `Error: Material index ${materialIndex} out of range.`; + } + prims[primitiveIndex].material = materialIndex; + return `Set material ${materialIndex} on mesh ${meshIndex} primitive ${primitiveIndex}.`; + } + + removePrimitiveMaterial(name: string, meshIndex: number, primitiveIndex: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const meshes = ArrayOrEmpty(doc.meshes); + if (meshIndex < 0 || meshIndex >= meshes.length) { + return `Error: Mesh index ${meshIndex} out of range.`; + } + const prims = meshes[meshIndex].primitives; + if (primitiveIndex < 0 || primitiveIndex >= prims.length) { + return `Error: Primitive index ${primitiveIndex} out of range.`; + } + delete prims[primitiveIndex].material; + return `Removed material from mesh ${meshIndex} primitive ${primitiveIndex}.`; + } + + /* ================ Material editing ========================== */ + + addMaterial(name: string, materialName?: string): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const materials = EnsureArray(doc as unknown as Record, "materials"); + const idx = materials.length; + materials.push({ + name: materialName ?? `Material_${idx}`, + pbrMetallicRoughness: { + baseColorFactor: [1, 1, 1, 1], + metallicFactor: 1.0, + roughnessFactor: 1.0, + }, + }); + return `Added material ${idx}: "${materials[idx].name}".`; + } + + removeMaterial(name: string, materialIndex: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const materials = ArrayOrEmpty(doc.materials); + if (materialIndex < 0 || materialIndex >= materials.length) { + return `Error: Material index ${materialIndex} out of range.`; + } + const matName = materials[materialIndex].name; + (materials as (IGltfMaterial | null)[])[materialIndex] = null as unknown as IGltfMaterial; + // Clear refs from mesh primitives + for (const m of ArrayOrEmpty(doc.meshes)) { + if (!m) { + continue; + } + for (const p of m.primitives) { + if (p.material === materialIndex) { + delete p.material; + } + } + } + return `Removed material ${materialIndex} "${matName ?? "(unnamed)"}" and cleared primitive references.`; + } + + renameMaterial(name: string, materialIndex: number, newName: string): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const materials = ArrayOrEmpty(doc.materials); + if (materialIndex < 0 || materialIndex >= materials.length) { + return `Error: Material index ${materialIndex} out of range.`; + } + const old = materials[materialIndex].name; + materials[materialIndex].name = newName; + return `Renamed material ${materialIndex} from "${old ?? "(unnamed)"}" to "${newName}".`; + } + + setMaterialPbr( + name: string, + materialIndex: number, + baseColorFactor?: [number, number, number, number], + metallicFactor?: number, + roughnessFactor?: number, + baseColorTexture?: number, + metallicRoughnessTexture?: number + ): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const materials = ArrayOrEmpty(doc.materials); + if (materialIndex < 0 || materialIndex >= materials.length) { + return `Error: Material index ${materialIndex} out of range.`; + } + const mat = materials[materialIndex]; + if (!mat.pbrMetallicRoughness) { + mat.pbrMetallicRoughness = {}; + } + const pbr = mat.pbrMetallicRoughness; + if (baseColorFactor !== undefined) { + pbr.baseColorFactor = baseColorFactor; + } + if (metallicFactor !== undefined) { + pbr.metallicFactor = metallicFactor; + } + if (roughnessFactor !== undefined) { + pbr.roughnessFactor = roughnessFactor; + } + if (baseColorTexture !== undefined) { + pbr.baseColorTexture = { index: baseColorTexture }; + } + if (metallicRoughnessTexture !== undefined) { + pbr.metallicRoughnessTexture = { index: metallicRoughnessTexture }; + } + return `Updated PBR properties on material ${materialIndex} "${mat.name ?? "(unnamed)"}".`; + } + + setMaterialAlphaMode(name: string, materialIndex: number, alphaMode: string, alphaCutoff?: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const materials = ArrayOrEmpty(doc.materials); + if (materialIndex < 0 || materialIndex >= materials.length) { + return `Error: Material index ${materialIndex} out of range.`; + } + const valid = ["OPAQUE", "MASK", "BLEND"]; + if (!valid.includes(alphaMode)) { + return `Error: alphaMode must be one of ${valid.join(", ")}.`; + } + materials[materialIndex].alphaMode = alphaMode as "OPAQUE" | "MASK" | "BLEND"; + if (alphaCutoff !== undefined) { + materials[materialIndex].alphaCutoff = alphaCutoff; + } + return `Set alphaMode=${alphaMode} on material ${materialIndex}.`; + } + + setMaterialDoubleSided(name: string, materialIndex: number, doubleSided: boolean): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const materials = ArrayOrEmpty(doc.materials); + if (materialIndex < 0 || materialIndex >= materials.length) { + return `Error: Material index ${materialIndex} out of range.`; + } + materials[materialIndex].doubleSided = doubleSided; + return `Set doubleSided=${doubleSided} on material ${materialIndex}.`; + } + + setMaterialEmissive(name: string, materialIndex: number, emissiveFactor?: [number, number, number], emissiveTexture?: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const materials = ArrayOrEmpty(doc.materials); + if (materialIndex < 0 || materialIndex >= materials.length) { + return `Error: Material index ${materialIndex} out of range.`; + } + if (emissiveFactor) { + materials[materialIndex].emissiveFactor = emissiveFactor; + } + if (emissiveTexture !== undefined) { + materials[materialIndex].emissiveTexture = { index: emissiveTexture }; + } + return `Updated emissive properties on material ${materialIndex}.`; + } + + setMaterialTexture(name: string, materialIndex: number, slot: string, textureIndex: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const materials = ArrayOrEmpty(doc.materials); + if (materialIndex < 0 || materialIndex >= materials.length) { + return `Error: Material index ${materialIndex} out of range.`; + } + const textures = ArrayOrEmpty(doc.textures); + if (textureIndex < 0 || textureIndex >= textures.length) { + return `Error: Texture index ${textureIndex} out of range.`; + } + const mat = materials[materialIndex]; + const pbrSlots = ["baseColorTexture", "metallicRoughnessTexture"]; + const topSlots = ["normalTexture", "occlusionTexture", "emissiveTexture"]; + if (pbrSlots.includes(slot)) { + if (!mat.pbrMetallicRoughness) { + mat.pbrMetallicRoughness = {}; + } + (mat.pbrMetallicRoughness as Record)[slot] = { index: textureIndex }; + } else if (topSlots.includes(slot)) { + (mat as Record)[slot] = { index: textureIndex }; + } else { + return `Error: Unknown texture slot "${slot}". Valid: ${[...pbrSlots, ...topSlots].join(", ")}.`; + } + return `Assigned texture ${textureIndex} to ${slot} on material ${materialIndex}.`; + } + + assignMaterialToMeshPrimitive(name: string, meshIndex: number, primitiveIndex: number, materialIndex: number): string { + return this.setPrimitiveMaterial(name, meshIndex, primitiveIndex, materialIndex); + } + + /* ============= Texture / image / sampler editing ============ */ + + addImageReference(name: string, uri: string, imageName?: string, mimeType?: string): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const images = EnsureArray(doc as unknown as Record, "images"); + const idx = images.length; + const img: IGltfImage = { uri, name: imageName }; + if (mimeType) { + img.mimeType = mimeType; + } + images.push(img); + return `Added image ${idx}: "${imageName ?? uri}".`; + } + + removeImage(name: string, imageIndex: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const images = ArrayOrEmpty(doc.images); + if (imageIndex < 0 || imageIndex >= images.length) { + return `Error: Image index ${imageIndex} out of range.`; + } + (images as (IGltfImage | null)[])[imageIndex] = null as unknown as IGltfImage; + // Clear texture source references + for (const t of ArrayOrEmpty(doc.textures)) { + if (t && t.source === imageIndex) { + delete t.source; + } + } + return `Removed image ${imageIndex} and cleared texture references.`; + } + + addTexture(name: string, sourceImage?: number, sampler?: number, textureName?: string): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const textures = EnsureArray(doc as unknown as Record, "textures"); + const idx = textures.length; + const tex: IGltfTexture = { name: textureName }; + if (sourceImage !== undefined) { + tex.source = sourceImage; + } + if (sampler !== undefined) { + tex.sampler = sampler; + } + textures.push(tex); + return `Added texture ${idx}${textureName ? `: "${textureName}"` : ""}.`; + } + + removeTexture(name: string, textureIndex: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const textures = ArrayOrEmpty(doc.textures); + if (textureIndex < 0 || textureIndex >= textures.length) { + return `Error: Texture index ${textureIndex} out of range.`; + } + (textures as (IGltfTexture | null)[])[textureIndex] = null as unknown as IGltfTexture; + return `Removed texture ${textureIndex}.`; + } + + setTextureSampler(name: string, textureIndex: number, samplerIndex: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const textures = ArrayOrEmpty(doc.textures); + if (textureIndex < 0 || textureIndex >= textures.length) { + return `Error: Texture index ${textureIndex} out of range.`; + } + const samplers = ArrayOrEmpty(doc.samplers); + if (samplerIndex < 0 || samplerIndex >= samplers.length) { + return `Error: Sampler index ${samplerIndex} out of range.`; + } + textures[textureIndex].sampler = samplerIndex; + return `Set sampler ${samplerIndex} on texture ${textureIndex}.`; + } + + addSampler(name: string, magFilter?: number, minFilter?: number, wrapS?: number, wrapT?: number, samplerName?: string): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const samplers = EnsureArray(doc as unknown as Record, "samplers"); + const idx = samplers.length; + const s: IGltfSampler = { name: samplerName }; + if (magFilter !== undefined) { + s.magFilter = magFilter; + } + if (minFilter !== undefined) { + s.minFilter = minFilter; + } + if (wrapS !== undefined) { + s.wrapS = wrapS; + } + if (wrapT !== undefined) { + s.wrapT = wrapT; + } + samplers.push(s); + return `Added sampler ${idx}${samplerName ? `: "${samplerName}"` : ""}.`; + } + + removeSampler(name: string, samplerIndex: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const samplers = ArrayOrEmpty(doc.samplers); + if (samplerIndex < 0 || samplerIndex >= samplers.length) { + return `Error: Sampler index ${samplerIndex} out of range.`; + } + (samplers as (IGltfSampler | null)[])[samplerIndex] = null as unknown as IGltfSampler; + // Clear texture sampler refs + for (const t of ArrayOrEmpty(doc.textures)) { + if (t && t.sampler === samplerIndex) { + delete t.sampler; + } + } + return `Removed sampler ${samplerIndex} and cleared texture references.`; + } + + /* ============= Animation / skin editing ===================== */ + + listAnimationChannels(name: string, animIndex: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const anims = ArrayOrEmpty(doc.animations); + if (animIndex < 0 || animIndex >= anims.length) { + return `Error: Animation index ${animIndex} out of range.`; + } + const a = anims[animIndex]; + if (a.channels.length === 0) { + return "No channels."; + } + return a.channels.map((ch, i) => `${i}: node=${ch.target.node ?? "?"}, path=${ch.target.path}, sampler=${ch.sampler}`).join("\n"); + } + + describeAnimationChannel(name: string, animIndex: number, channelIndex: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const anims = ArrayOrEmpty(doc.animations); + if (animIndex < 0 || animIndex >= anims.length) { + return `Error: Animation index ${animIndex} out of range.`; + } + const a = anims[animIndex]; + if (channelIndex < 0 || channelIndex >= a.channels.length) { + return `Error: Channel index ${channelIndex} out of range.`; + } + const ch = a.channels[channelIndex]; + const samp = a.samplers[ch.sampler]; + const lines = [ + `## Animation ${animIndex} Channel ${channelIndex}`, + `- **Target node**: ${ch.target.node ?? "undefined"}`, + `- **Target path**: ${ch.target.path}`, + `- **Sampler**: ${ch.sampler}`, + ]; + if (samp) { + lines.push(`- **Interpolation**: ${samp.interpolation ?? "LINEAR"}`); + lines.push(`- **Input accessor**: ${samp.input}`); + lines.push(`- **Output accessor**: ${samp.output}`); + } + return lines.join("\n"); + } + + renameAnimation(name: string, animIndex: number, newName: string): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const anims = ArrayOrEmpty(doc.animations); + if (animIndex < 0 || animIndex >= anims.length) { + return `Error: Animation index ${animIndex} out of range.`; + } + const old = anims[animIndex].name; + anims[animIndex].name = newName; + return `Renamed animation ${animIndex} from "${old ?? "(unnamed)"}" to "${newName}".`; + } + + removeAnimation(name: string, animIndex: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const anims = ArrayOrEmpty(doc.animations); + if (animIndex < 0 || animIndex >= anims.length) { + return `Error: Animation index ${animIndex} out of range.`; + } + const animName = anims[animIndex].name; + (anims as (IGltfAnimation | null)[])[animIndex] = null as unknown as IGltfAnimation; + return `Removed animation ${animIndex} "${animName ?? "(unnamed)"}".`; + } + + removeSkin(name: string, skinIndex: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const skins = ArrayOrEmpty(doc.skins); + if (skinIndex < 0 || skinIndex >= skins.length) { + return `Error: Skin index ${skinIndex} out of range.`; + } + const skinName = skins[skinIndex].name; + (skins as (IGltfSkin | null)[])[skinIndex] = null as unknown as IGltfSkin; + for (const n of ArrayOrEmpty(doc.nodes)) { + if (n && n.skin === skinIndex) { + delete n.skin; + } + } + return `Removed skin ${skinIndex} "${skinName ?? "(unnamed)"}" and cleared node references.`; + } + + /* ================ Extension handling ======================== */ + + getExtensionData(name: string, extensionName: string, targetType: GltfExtensionTargetType, targetIndex?: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const target = ResolveExtensionTarget(doc, targetType, targetIndex); + if (!target) { + return `Error: Invalid target ${targetType}${targetIndex !== undefined ? `[${targetIndex}]` : ""}.`; + } + const data = target.extensions?.[extensionName]; + if (data === undefined) { + return `No extension data for "${extensionName}" on ${targetType}${targetIndex !== undefined ? `[${targetIndex}]` : ""}.`; + } + return JSON.stringify(data, null, 2); + } + + setExtensionData(name: string, extensionName: string, data: unknown, targetType: GltfExtensionTargetType, targetIndex?: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const target = ResolveExtensionTarget(doc, targetType, targetIndex); + if (!target) { + return `Error: Invalid target ${targetType}${targetIndex !== undefined ? `[${targetIndex}]` : ""}.`; + } + if (!target.extensions) { + target.extensions = {}; + } + target.extensions[extensionName] = data; + return `Set extension "${extensionName}" on ${targetType}${targetIndex !== undefined ? `[${targetIndex}]` : ""}.`; + } + + removeExtensionData(name: string, extensionName: string, targetType: GltfExtensionTargetType, targetIndex?: number): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const target = ResolveExtensionTarget(doc, targetType, targetIndex); + if (!target) { + return `Error: Invalid target ${targetType}${targetIndex !== undefined ? `[${targetIndex}]` : ""}.`; + } + if (!target.extensions || !(extensionName in target.extensions)) { + return `No extension data for "${extensionName}" to remove.`; + } + delete target.extensions[extensionName]; + if (Object.keys(target.extensions).length === 0) { + delete target.extensions; + } + return `Removed extension "${extensionName}" from ${targetType}${targetIndex !== undefined ? `[${targetIndex}]` : ""}.`; + } + + addExtensionToUsed(name: string, extensionName: string): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + if (!doc.extensionsUsed) { + doc.extensionsUsed = []; + } + if (!doc.extensionsUsed.includes(extensionName)) { + doc.extensionsUsed.push(extensionName); + } + return `"${extensionName}" is in extensionsUsed.`; + } + + addExtensionToRequired(name: string, extensionName: string): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + if (!doc.extensionsRequired) { + doc.extensionsRequired = []; + } + if (!doc.extensionsRequired.includes(extensionName)) { + doc.extensionsRequired.push(extensionName); + } + // Also add to used if not already + if (!doc.extensionsUsed) { + doc.extensionsUsed = []; + } + if (!doc.extensionsUsed.includes(extensionName)) { + doc.extensionsUsed.push(extensionName); + } + return `"${extensionName}" is in extensionsRequired (and extensionsUsed).`; + } + + removeExtensionFromUsed(name: string, extensionName: string): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + if (!doc.extensionsUsed) { + return `"${extensionName}" was not in extensionsUsed.`; + } + doc.extensionsUsed = doc.extensionsUsed.filter((e) => e !== extensionName); + if (doc.extensionsUsed.length === 0) { + delete doc.extensionsUsed; + } + // Also remove from required + if (doc.extensionsRequired) { + doc.extensionsRequired = doc.extensionsRequired.filter((e) => e !== extensionName); + if (doc.extensionsRequired.length === 0) { + delete doc.extensionsRequired; + } + } + return `Removed "${extensionName}" from extensionsUsed (and extensionsRequired if present).`; + } + + removeExtensionFromRequired(name: string, extensionName: string): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + if (!doc.extensionsRequired) { + return `"${extensionName}" was not in extensionsRequired.`; + } + doc.extensionsRequired = doc.extensionsRequired.filter((e) => e !== extensionName); + if (doc.extensionsRequired.length === 0) { + delete doc.extensionsRequired; + } + return `Removed "${extensionName}" from extensionsRequired.`; + } + + /* ================ Validation ================================ */ + + validateGltf(name: string): IGltfValidationIssue[] { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return [{ severity: "error", message: doc }]; + } + + const issues: IGltfValidationIssue[] = []; + const nodes = ArrayOrEmpty(doc.nodes); + const meshes = ArrayOrEmpty(doc.meshes); + const materials = ArrayOrEmpty(doc.materials); + const textures = ArrayOrEmpty(doc.textures); + const images = ArrayOrEmpty(doc.images); + const samplers = ArrayOrEmpty(doc.samplers); + const accessors = ArrayOrEmpty(doc.accessors); + const scenes = ArrayOrEmpty(doc.scenes); + const animations = ArrayOrEmpty(doc.animations); + const skins = ArrayOrEmpty(doc.skins); + + // Asset check + if (!doc.asset?.version) { + issues.push({ severity: "error", message: "Missing asset.version.", path: "asset" }); + } + + // Active scene check + if (doc.scene !== undefined && (doc.scene < 0 || doc.scene >= scenes.length)) { + issues.push({ severity: "error", message: `Active scene index ${doc.scene} out of range (${scenes.length} scenes).`, path: "scene" }); + } + + // Scene node refs + scenes.forEach((s, si) => { + for (const ni of ArrayOrEmpty(s.nodes)) { + if (ni < 0 || ni >= nodes.length || !nodes[ni]) { + issues.push({ severity: "error", message: `Scene ${si} references invalid node ${ni}.`, path: `scenes[${si}].nodes` }); + } + } + }); + + // Node children refs + nodes.forEach((n, ni) => { + if (!n) { + return; + } + for (const ci of ArrayOrEmpty(n.children)) { + if (ci < 0 || ci >= nodes.length || !nodes[ci]) { + issues.push({ severity: "error", message: `Node ${ni} references invalid child ${ci}.`, path: `nodes[${ni}].children` }); + } + } + if (n.mesh !== undefined && (n.mesh < 0 || n.mesh >= meshes.length || !meshes[n.mesh])) { + issues.push({ severity: "error", message: `Node ${ni} references invalid mesh ${n.mesh}.`, path: `nodes[${ni}].mesh` }); + } + if (n.skin !== undefined && (n.skin < 0 || n.skin >= skins.length || !skins[n.skin])) { + issues.push({ severity: "error", message: `Node ${ni} references invalid skin ${n.skin}.`, path: `nodes[${ni}].skin` }); + } + // TRS + matrix mutual exclusion + if (n.matrix && (n.translation || n.rotation || n.scale)) { + issues.push({ severity: "warning", message: `Node ${ni} has both matrix and TRS properties.`, path: `nodes[${ni}]` }); + } + }); + + // Mesh primitive material refs + meshes.forEach((m, mi) => { + if (!m) { + return; + } + m.primitives.forEach((p, pi) => { + if (p.material !== undefined && (p.material < 0 || p.material >= materials.length || !materials[p.material])) { + issues.push({ + severity: "error", + message: `Mesh ${mi} primitive ${pi} references invalid material ${p.material}.`, + path: `meshes[${mi}].primitives[${pi}].material`, + }); + } + }); + }); + + // Material texture refs + materials.forEach((mat, mi) => { + if (!mat) { + return; + } + const checkTex = ( + texInfo: + | { + /** + * + */ + index: number; + } + | undefined, + label: string + ) => { + if (texInfo && (texInfo.index < 0 || texInfo.index >= textures.length || !textures[texInfo.index])) { + issues.push({ severity: "error", message: `Material ${mi} ${label} references invalid texture ${texInfo.index}.`, path: `materials[${mi}].${label}` }); + } + }; + checkTex(mat.pbrMetallicRoughness?.baseColorTexture, "baseColorTexture"); + checkTex(mat.pbrMetallicRoughness?.metallicRoughnessTexture, "metallicRoughnessTexture"); + checkTex(mat.normalTexture, "normalTexture"); + checkTex(mat.occlusionTexture, "occlusionTexture"); + checkTex(mat.emissiveTexture, "emissiveTexture"); + }); + + // Texture image/sampler refs + textures.forEach((t, ti) => { + if (!t) { + return; + } + if (t.source !== undefined && (t.source < 0 || t.source >= images.length || !images[t.source])) { + issues.push({ severity: "error", message: `Texture ${ti} references invalid image ${t.source}.`, path: `textures[${ti}].source` }); + } + if (t.sampler !== undefined && (t.sampler < 0 || t.sampler >= samplers.length || !samplers[t.sampler])) { + issues.push({ severity: "error", message: `Texture ${ti} references invalid sampler ${t.sampler}.`, path: `textures[${ti}].sampler` }); + } + }); + + // Animation refs + animations.forEach((a, ai) => { + if (!a) { + return; + } + a.channels.forEach((ch, ci) => { + if (ch.target.node !== undefined && (ch.target.node < 0 || ch.target.node >= nodes.length || !nodes[ch.target.node])) { + issues.push({ + severity: "error", + message: `Animation ${ai} channel ${ci} targets invalid node ${ch.target.node}.`, + path: `animations[${ai}].channels[${ci}].target.node`, + }); + } + if (ch.sampler < 0 || ch.sampler >= a.samplers.length) { + issues.push({ + severity: "error", + message: `Animation ${ai} channel ${ci} references invalid sampler ${ch.sampler}.`, + path: `animations[${ai}].channels[${ci}].sampler`, + }); + } + }); + a.samplers.forEach((s, si) => { + if (s.input < 0 || s.input >= accessors.length) { + issues.push({ + severity: "error", + message: `Animation ${ai} sampler ${si} input references invalid accessor ${s.input}.`, + path: `animations[${ai}].samplers[${si}].input`, + }); + } + if (s.output < 0 || s.output >= accessors.length) { + issues.push({ + severity: "error", + message: `Animation ${ai} sampler ${si} output references invalid accessor ${s.output}.`, + path: `animations[${ai}].samplers[${si}].output`, + }); + } + }); + }); + + // Skin refs + skins.forEach((sk, si) => { + if (!sk) { + return; + } + for (const ji of sk.joints) { + if (ji < 0 || ji >= nodes.length || !nodes[ji]) { + issues.push({ severity: "error", message: `Skin ${si} references invalid joint node ${ji}.`, path: `skins[${si}].joints` }); + } + } + if (sk.skeleton !== undefined && (sk.skeleton < 0 || sk.skeleton >= nodes.length || !nodes[sk.skeleton])) { + issues.push({ severity: "error", message: `Skin ${si} references invalid skeleton node ${sk.skeleton}.`, path: `skins[${si}].skeleton` }); + } + }); + + // Extension consistency + const referencedExtensions = this._collectUsedExtensions(doc); + const declaredUsed = new Set(ArrayOrEmpty(doc.extensionsUsed)); + const declaredRequired = new Set(ArrayOrEmpty(doc.extensionsRequired)); + + for (const ext of referencedExtensions) { + if (!declaredUsed.has(ext)) { + issues.push({ severity: "warning", message: `Extension "${ext}" is used in the document but not listed in extensionsUsed.`, path: "extensionsUsed" }); + } + } + for (const ext of declaredRequired) { + if (!declaredUsed.has(ext)) { + issues.push({ severity: "warning", message: `Extension "${ext}" is in extensionsRequired but not in extensionsUsed.`, path: "extensionsRequired" }); + } + } + + // Duplicate names (warnings) + this._checkDuplicateNames(nodes, "node", issues); + this._checkDuplicateNames(meshes, "mesh", issues); + this._checkDuplicateNames(materials, "material", issues); + + if (issues.length === 0) { + issues.push({ severity: "info", message: "No issues found." }); + } + + return issues; + } + + summarizeIssues(issues: IGltfValidationIssue[]): string { + const errors = issues.filter((i) => i.severity === "error"); + const warnings = issues.filter((i) => i.severity === "warning"); + const infos = issues.filter((i) => i.severity === "info"); + + const lines: string[] = [`## Validation Summary`, `- Errors: ${errors.length}`, `- Warnings: ${warnings.length}`]; + if (infos.length > 0 && errors.length === 0 && warnings.length === 0) { + lines.push("\nNo issues found."); + } + for (const e of errors) { + lines.push(`\n**ERROR** ${e.path ? `(${e.path})` : ""}: ${e.message}`); + } + for (const w of warnings) { + lines.push(`\n**WARNING** ${w.path ? `(${w.path})` : ""}: ${w.message}`); + } + return lines.join("\n"); + } + + /* ================ Export / Import =========================== */ + + exportJson(name: string): string | null { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return null; + } + return JSON.stringify(doc, null, 2); + } + + importJson(name: string, jsonText: string): string { + return this.loadGltf(name, jsonText); + } + + exportGlb(name: string): Buffer | null { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return null; + } + + // Collect binary buffer data (decode data URIs → raw bytes) + const buffers = ArrayOrEmpty(doc.buffers); + const binChunks: Buffer[] = []; + let totalBinLength = 0; + + for (const buf of buffers) { + if (buf.uri && buf.uri.startsWith("data:")) { + const commaIdx = buf.uri.indexOf(","); + if (commaIdx !== -1) { + const raw = Buffer.from(buf.uri.substring(commaIdx + 1), "base64"); + binChunks.push(raw); + totalBinLength += raw.length; + continue; + } + } + // No data or external URI — push empty placeholder of declared size + binChunks.push(Buffer.alloc(buf.byteLength || 0)); + totalBinLength += buf.byteLength || 0; + } + + // Build a single merged BIN buffer (GLB spec: one BIN chunk for all buffers) + // For multi-buffer glTFs we concatenate (this is correct for single-buffer, + // which covers the vast majority of real glTFs). + const hasBin = totalBinLength > 0; + const binPaddedLength = hasBin ? totalBinLength + ((4 - (totalBinLength % 4)) % 4) : 0; + const binBuffer = hasBin ? Buffer.alloc(binPaddedLength) : null; + + if (binBuffer) { + let offset = 0; + for (const chunk of binChunks) { + chunk.copy(binBuffer, offset); + offset += chunk.length; + } + // Remainder is already zero-filled (proper GLB BIN padding) + } + + // Build the JSON chunk — modify buffer entries to point at the GLB BIN chunk + const exportDoc = DeepClone(doc); + if (hasBin && exportDoc.buffers && exportDoc.buffers.length > 0) { + // GLB spec: first buffer has no URI and its byteLength = total BIN chunk size + exportDoc.buffers[0].uri = undefined; + exportDoc.buffers[0].byteLength = totalBinLength; + // Remove extra buffers (all merged into the single BIN chunk) + if (exportDoc.buffers.length > 1) { + exportDoc.buffers.length = 1; + } + } + + const jsonString = JSON.stringify(exportDoc); + const jsonPadded = jsonString + " ".repeat((4 - (jsonString.length % 4)) % 4); + const jsonChunkData = Buffer.from(jsonPadded, "utf-8"); + + // GLB layout: Header (12) + JSON chunk header (8) + JSON data + [BIN chunk header (8) + BIN data] + const totalLength = 12 + 8 + jsonChunkData.length + (hasBin ? 8 + binPaddedLength : 0); + + const glb = Buffer.alloc(totalLength); + let offset = 0; + + // Header + glb.writeUInt32LE(0x46546c67, offset); + offset += 4; // "glTF" magic + glb.writeUInt32LE(2, offset); + offset += 4; // version + glb.writeUInt32LE(totalLength, offset); + offset += 4; // total length + + // JSON chunk + glb.writeUInt32LE(jsonChunkData.length, offset); + offset += 4; + glb.writeUInt32LE(0x4e4f534a, offset); + offset += 4; // "JSON" + jsonChunkData.copy(glb, offset); + offset += jsonChunkData.length; + + // BIN chunk + if (binBuffer) { + glb.writeUInt32LE(binPaddedLength, offset); + offset += 4; + glb.writeUInt32LE(0x004e4942, offset); + offset += 4; // "BIN\0" + binBuffer.copy(glb, offset); + } + + return glb; + } + + importGlb(name: string, buffer: Buffer): string { + if (this._documents.has(name)) { + return `Error: A document named "${name}" already exists.`; + } + + if (buffer.length < 12) { + return "Error: Buffer too small to be a valid GLB file."; + } + + const magic = buffer.readUInt32LE(0); + if (magic !== 0x46546c67) { + return "Error: Invalid GLB magic number."; + } + + const version = buffer.readUInt32LE(4); + if (version !== 2) { + return `Error: Unsupported GLB version ${version}. Only version 2 is supported.`; + } + + const totalLength = buffer.readUInt32LE(8); + if (buffer.length < totalLength) { + return `Error: Buffer length (${buffer.length}) is less than declared total length (${totalLength}).`; + } + + if (buffer.length < 20) { + return "Error: GLB file too small to contain a JSON chunk header."; + } + + const chunkLength = buffer.readUInt32LE(12); + const chunkType = buffer.readUInt32LE(16); + + if (chunkType !== 0x4e4f534a) { + return "Error: First GLB chunk is not JSON."; + } + + if (buffer.length < 20 + chunkLength) { + return "Error: GLB buffer too small for declared JSON chunk length."; + } + + const jsonString = buffer + .subarray(20, 20 + chunkLength) + .toString("utf-8") + .trim(); + + let parsed: unknown; + try { + parsed = JSON.parse(jsonString); + } catch { + return "Error: Failed to parse JSON chunk from GLB."; + } + + const doc = parsed as IGltfDocument; + if (!doc.asset?.version) { + return "Error: GLB JSON chunk is missing required asset.version field."; + } + + this._documents.set(name, doc); + return `Loaded GLB as "${name}". Asset version: ${doc.asset.version}.`; + } + + /* ================ Index compaction ========================== */ + + compactIndices(name: string): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + + let totalRemoved = 0; + const messages: string[] = []; + + // Compact each array type that supports nullification + totalRemoved += this._compactArray(doc, "nodes", messages); + totalRemoved += this._compactArray(doc, "meshes", messages); + totalRemoved += this._compactArray(doc, "materials", messages); + totalRemoved += this._compactArray(doc, "textures", messages); + totalRemoved += this._compactArray(doc, "images", messages); + totalRemoved += this._compactArray(doc, "samplers", messages); + totalRemoved += this._compactArray(doc, "animations", messages); + totalRemoved += this._compactArray(doc, "skins", messages); + totalRemoved += this._compactArray(doc, "accessors", messages); + totalRemoved += this._compactArray(doc, "cameras", messages); + + if (totalRemoved === 0) { + return "No null slots found. Document indices are already compact."; + } + + return `Compacted ${totalRemoved} null slot(s).\n${messages.join("\n")}`; + } + + /* ================ Search / discovery ======================== */ + + findNodes(name: string, query: string, exact: boolean = false): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const nodes = ArrayOrEmpty(doc.nodes); + const results: string[] = []; + const lq = query.toLowerCase(); + nodes.forEach((n, i) => { + if (!n) { + return; + } + const nn = (n.name ?? "").toLowerCase(); + if (exact ? nn === lq : nn.includes(lq)) { + results.push(`${i}: ${n.name ?? "(unnamed)"}`); + } + }); + return results.length > 0 ? results.join("\n") : "No matching nodes found."; + } + + findMaterials(name: string, query: string, exact: boolean = false): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const materials = ArrayOrEmpty(doc.materials); + const results: string[] = []; + const lq = query.toLowerCase(); + materials.forEach((m, i) => { + if (!m) { + return; + } + const mn = (m.name ?? "").toLowerCase(); + if (exact ? mn === lq : mn.includes(lq)) { + results.push(`${i}: ${m.name ?? "(unnamed)"}`); + } + }); + return results.length > 0 ? results.join("\n") : "No matching materials found."; + } + + findMeshes(name: string, query: string, exact: boolean = false): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const meshes = ArrayOrEmpty(doc.meshes); + const results: string[] = []; + const lq = query.toLowerCase(); + meshes.forEach((m, i) => { + if (!m) { + return; + } + const mn = (m.name ?? "").toLowerCase(); + if (exact ? mn === lq : mn.includes(lq)) { + results.push(`${i}: ${m.name ?? "(unnamed)"}`); + } + }); + return results.length > 0 ? results.join("\n") : "No matching meshes found."; + } + + findExtensions(name: string, query: string): string { + const doc = this._getDoc(name); + if (typeof doc === "string") { + return doc; + } + const all = this._collectUsedExtensions(doc); + const lq = query.toLowerCase(); + const results = [...all].filter((e) => e.toLowerCase().includes(lq)); + return results.length > 0 ? results.join("\n") : "No matching extensions found."; + } + + /* ================ Internal document access ================== */ + + /** + * Exposed for testing. + * @param name - The document name. + * @returns The document or undefined. + */ + _getDocumentForTest(name: string): IGltfDocument | undefined { + return this._documents.get(name); + } + + /** + * Get the raw glTF document object by name, or undefined if not found. + * @param name - The document name. + * @returns The document or undefined. + */ + getDoc(name: string): IGltfDocument | undefined { + return this._documents.get(name); + } + + /* ================ Private helpers =========================== */ + + private _getDoc(name: string): IGltfDocument | string { + const doc = this._documents.get(name); + if (!doc) { + return `Error: No glTF document named "${name}".`; + } + return doc; + } + + private _findParentNode(doc: IGltfDocument, nodeIndex: number): number { + const nodes = ArrayOrEmpty(doc.nodes); + for (let i = 0; i < nodes.length; i++) { + if (nodes[i] && nodes[i].children?.includes(nodeIndex)) { + return i; + } + } + return -1; + } + + private _removeNodeFromAllParents(doc: IGltfDocument, nodeIndex: number): void { + // Remove from node children + for (const n of ArrayOrEmpty(doc.nodes)) { + if (n && n.children) { + n.children = n.children.filter((c) => c !== nodeIndex); + if (n.children.length === 0) { + delete n.children; + } + } + } + // Remove from scene roots + for (const s of ArrayOrEmpty(doc.scenes)) { + if (s.nodes) { + s.nodes = s.nodes.filter((n) => n !== nodeIndex); + } + } + } + + private _isDescendant(doc: IGltfDocument, potentialDescendant: number, ancestor: number): boolean { + const nodes = ArrayOrEmpty(doc.nodes); + const visited = new Set(); + const stack = [ancestor]; + while (stack.length > 0) { + const current = stack.pop()!; + if (visited.has(current)) { + continue; + } + visited.add(current); + const children = nodes[current]?.children; + if (children) { + for (const c of children) { + if (c === potentialDescendant) { + return true; + } + stack.push(c); + } + } + } + return false; + } + + private _collectUsedExtensions(doc: IGltfDocument): Set { + const extensions = new Set(); + // Collect from extensionsUsed/extensionsRequired arrays + if (doc.extensionsUsed) { + for (const e of doc.extensionsUsed) { + extensions.add(e); + } + } + if (doc.extensionsRequired) { + for (const e of doc.extensionsRequired) { + extensions.add(e); + } + } + // Collect from inline extension blocks on all entities + const collect = (obj: IGltfExtensible | null | undefined) => { + if (obj?.extensions) { + for (const key of Object.keys(obj.extensions)) { + extensions.add(key); + } + } + }; + collect(doc); + for (const s of ArrayOrEmpty(doc.scenes)) { + collect(s); + } + for (const n of ArrayOrEmpty(doc.nodes)) { + collect(n); + } + for (const m of ArrayOrEmpty(doc.meshes)) { + collect(m); + if (m) { + for (const p of m.primitives) { + collect(p as unknown as IGltfExtensible); + } + } + } + for (const mat of ArrayOrEmpty(doc.materials)) { + collect(mat); + } + for (const t of ArrayOrEmpty(doc.textures)) { + collect(t); + } + for (const img of ArrayOrEmpty(doc.images)) { + collect(img); + } + for (const a of ArrayOrEmpty(doc.animations)) { + collect(a); + } + return extensions; + } + + private _checkDuplicateNames( + items: ({ + /** + * + */ + name?: string; + } | null)[], + label: string, + issues: IGltfValidationIssue[] + ): void { + const seen = new Map(); + items.forEach((item, i) => { + if (!item?.name) { + return; + } + const arr = seen.get(item.name); + if (arr) { + arr.push(i); + } else { + seen.set(item.name, [i]); + } + }); + for (const [itemName, indices] of seen) { + if (indices.length > 1) { + issues.push({ severity: "warning", message: `Duplicate ${label} name "${itemName}" at indices [${indices.join(", ")}].`, path: `${label}s` }); + } + } + } + + private _compactArray(doc: IGltfDocument, key: string, messages: string[]): number { + const arr = (doc as unknown as Record)[key] as (unknown | null)[] | undefined; + if (!arr) { + return 0; + } + + const nullIndices: number[] = []; + for (let i = 0; i < arr.length; i++) { + if (arr[i] === null) { + nullIndices.push(i); + } + } + if (nullIndices.length === 0) { + return 0; + } + + // Build old→new index map + const remap = new Map(); + let newIndex = 0; + for (let old = 0; old < arr.length; old++) { + if (arr[old] !== null) { + remap.set(old, newIndex); + newIndex++; + } + } + + // Remove null entries from the array + (doc as unknown as Record)[key] = arr.filter((item) => item !== null); + + // Remap references throughout the document + this._remapReferences(doc, key, remap); + + messages.push(`${key}: removed ${nullIndices.length} null slot(s), remapped ${remap.size} indices.`); + return nullIndices.length; + } + + private _remapReferences(doc: IGltfDocument, key: string, remap: Map): void { + const remapIndex = (old: number | undefined): number | undefined => { + if (old === undefined) { + return undefined; + } + return remap.get(old); + }; + + switch (key) { + case "nodes": + // Scene roots + for (const scene of ArrayOrEmpty(doc.scenes)) { + if (scene?.nodes) { + scene.nodes = scene.nodes.map((n) => remap.get(n)!).filter((n) => n !== undefined); + } + } + // Node children + for (const node of ArrayOrEmpty(doc.nodes)) { + if (node?.children) { + node.children = node.children.map((c) => remap.get(c)!).filter((c) => c !== undefined); + if (node.children.length === 0) { + delete node.children; + } + } + // Skin joints reference nodes + } + // Animation channel targets + for (const anim of ArrayOrEmpty(doc.animations)) { + if (anim?.channels) { + for (const ch of anim.channels) { + if (ch.target?.node !== undefined) { + ch.target.node = remapIndex(ch.target.node) ?? ch.target.node; + } + } + } + } + // Skins + for (const skin of ArrayOrEmpty(doc.skins)) { + if (skin) { + if (skin.skeleton !== undefined) { + skin.skeleton = remapIndex(skin.skeleton) ?? skin.skeleton; + } + if (skin.joints) { + skin.joints = skin.joints.map((j) => remap.get(j) ?? j); + } + } + } + break; + + case "meshes": + for (const node of ArrayOrEmpty(doc.nodes)) { + if (node?.mesh !== undefined) { + node.mesh = remapIndex(node.mesh) ?? node.mesh; + } + } + break; + + case "materials": + for (const mesh of ArrayOrEmpty(doc.meshes)) { + if (mesh?.primitives) { + for (const prim of mesh.primitives) { + if (prim.material !== undefined) { + prim.material = remapIndex(prim.material) ?? prim.material; + } + } + } + } + break; + + case "textures": + for (const mat of ArrayOrEmpty(doc.materials)) { + if (mat?.pbrMetallicRoughness) { + const pbr = mat.pbrMetallicRoughness; + if (pbr.baseColorTexture?.index !== undefined) { + pbr.baseColorTexture.index = remapIndex(pbr.baseColorTexture.index) ?? pbr.baseColorTexture.index; + } + if (pbr.metallicRoughnessTexture?.index !== undefined) { + pbr.metallicRoughnessTexture.index = remapIndex(pbr.metallicRoughnessTexture.index) ?? pbr.metallicRoughnessTexture.index; + } + } + if (mat?.normalTexture?.index !== undefined) { + mat.normalTexture.index = remapIndex(mat.normalTexture.index) ?? mat.normalTexture.index; + } + if (mat?.occlusionTexture?.index !== undefined) { + mat.occlusionTexture.index = remapIndex(mat.occlusionTexture.index) ?? mat.occlusionTexture.index; + } + if (mat?.emissiveTexture?.index !== undefined) { + mat.emissiveTexture.index = remapIndex(mat.emissiveTexture.index) ?? mat.emissiveTexture.index; + } + } + break; + + case "images": + for (const tex of ArrayOrEmpty(doc.textures)) { + if (tex?.source !== undefined) { + tex.source = remapIndex(tex.source) ?? tex.source; + } + } + break; + + case "samplers": + for (const tex of ArrayOrEmpty(doc.textures)) { + if (tex?.sampler !== undefined) { + tex.sampler = remapIndex(tex.sampler) ?? tex.sampler; + } + } + break; + + case "accessors": + // Animation samplers reference accessors + for (const anim of ArrayOrEmpty(doc.animations)) { + if (anim?.samplers) { + for (const s of anim.samplers) { + if (s.input !== undefined) { + s.input = remapIndex(s.input) ?? s.input; + } + if (s.output !== undefined) { + s.output = remapIndex(s.output) ?? s.output; + } + } + } + } + // Mesh primitives reference accessors + for (const mesh of ArrayOrEmpty(doc.meshes)) { + if (mesh?.primitives) { + for (const prim of mesh.primitives) { + if (prim.indices !== undefined) { + prim.indices = remapIndex(prim.indices) ?? prim.indices; + } + for (const attrKey of Object.keys(prim.attributes)) { + prim.attributes[attrKey] = remapIndex(prim.attributes[attrKey]) ?? prim.attributes[attrKey]; + } + } + } + } + // Skins reference accessors for inverseBindMatrices + for (const skin of ArrayOrEmpty(doc.skins)) { + if (skin?.inverseBindMatrices !== undefined) { + skin.inverseBindMatrices = remapIndex(skin.inverseBindMatrices) ?? skin.inverseBindMatrices; + } + } + break; + + case "cameras": + for (const node of ArrayOrEmpty(doc.nodes)) { + if (node?.camera !== undefined) { + node.camera = remapIndex(node.camera) ?? node.camera; + } + } + break; + + case "skins": + for (const node of ArrayOrEmpty(doc.nodes)) { + if (node?.skin !== undefined) { + node.skin = remapIndex(node.skin) ?? node.skin; + } + } + break; + + case "animations": + // Animations are not referenced by index from other places + break; + } + } +} diff --git a/packages/tools/gltf-mcp-server/src/gltfTypes.ts b/packages/tools/gltf-mcp-server/src/gltfTypes.ts new file mode 100644 index 00000000000..c3a49ac8535 --- /dev/null +++ b/packages/tools/gltf-mcp-server/src/gltfTypes.ts @@ -0,0 +1,776 @@ +/** + * Minimal glTF 2.0 type definitions for the in-memory document model. + * These mirror the glTF 2.0 specification structures needed by the manager. + */ + +/* ------------------------------------------------------------------ */ +/* glTF top-level types */ +/* ------------------------------------------------------------------ */ + +/** + * + */ +export interface IGltfAsset { + /** + * + */ + version: string; + /** + * + */ + generator?: string; + /** + * + */ + copyright?: string; + /** + * + */ + minVersion?: string; + /** + * + */ + extensions?: Record; + /** + * + */ + extras?: unknown; +} + +/** + * + */ +export interface IGltfScene { + /** + * + */ + name?: string; + /** + * + */ + nodes?: number[]; + /** + * + */ + extensions?: Record; + /** + * + */ + extras?: unknown; +} + +/** + * + */ +export interface IGltfNode { + /** + * + */ + name?: string; + /** + * + */ + children?: number[]; + /** + * + */ + mesh?: number; + /** + * + */ + skin?: number; + /** + * + */ + camera?: number; + /** + * + */ + translation?: [number, number, number]; + /** + * + */ + rotation?: [number, number, number, number]; + /** + * + */ + scale?: [number, number, number]; + /** + * + */ + matrix?: number[]; + /** + * + */ + weights?: number[]; + /** + * + */ + extensions?: Record; + /** + * + */ + extras?: unknown; +} + +/** + * + */ +export interface IGltfPrimitive { + /** + * + */ + attributes: Record; + /** + * + */ + indices?: number; + /** + * + */ + material?: number; + /** + * + */ + mode?: number; + /** + * + */ + targets?: Record[]; + /** + * + */ + extensions?: Record; + /** + * + */ + extras?: unknown; +} + +/** + * + */ +export interface IGltfMesh { + /** + * + */ + name?: string; + /** + * + */ + primitives: IGltfPrimitive[]; + /** + * + */ + weights?: number[]; + /** + * + */ + extensions?: Record; + /** + * + */ + extras?: unknown; +} + +/** + * + */ +export interface IGltfTextureInfo { + /** + * + */ + index: number; + /** + * + */ + texCoord?: number; + /** + * + */ + extensions?: Record; + /** + * + */ + extras?: unknown; +} + +/** + * + */ +export interface IGltfNormalTextureInfo extends IGltfTextureInfo { + /** + * + */ + scale?: number; +} + +/** + * + */ +export interface IGltfOcclusionTextureInfo extends IGltfTextureInfo { + /** + * + */ + strength?: number; +} + +/** + * + */ +export interface IGltfPbrMetallicRoughness { + /** + * + */ + baseColorFactor?: [number, number, number, number]; + /** + * + */ + baseColorTexture?: IGltfTextureInfo; + /** + * + */ + metallicFactor?: number; + /** + * + */ + roughnessFactor?: number; + /** + * + */ + metallicRoughnessTexture?: IGltfTextureInfo; + /** + * + */ + extensions?: Record; + /** + * + */ + extras?: unknown; +} + +/** + * + */ +export interface IGltfMaterial { + /** + * + */ + name?: string; + /** + * + */ + pbrMetallicRoughness?: IGltfPbrMetallicRoughness; + /** + * + */ + normalTexture?: IGltfNormalTextureInfo; + /** + * + */ + occlusionTexture?: IGltfOcclusionTextureInfo; + /** + * + */ + emissiveTexture?: IGltfTextureInfo; + /** + * + */ + emissiveFactor?: [number, number, number]; + /** + * + */ + alphaMode?: "OPAQUE" | "MASK" | "BLEND"; + /** + * + */ + alphaCutoff?: number; + /** + * + */ + doubleSided?: boolean; + /** + * + */ + extensions?: Record; + /** + * + */ + extras?: unknown; +} + +/** + * + */ +export interface IGltfTexture { + /** + * + */ + name?: string; + /** + * + */ + sampler?: number; + /** + * + */ + source?: number; + /** + * + */ + extensions?: Record; + /** + * + */ + extras?: unknown; +} + +/** + * + */ +export interface IGltfImage { + /** + * + */ + name?: string; + /** + * + */ + uri?: string; + /** + * + */ + mimeType?: string; + /** + * + */ + bufferView?: number; + /** + * + */ + extensions?: Record; + /** + * + */ + extras?: unknown; +} + +/** + * + */ +export interface IGltfSampler { + /** + * + */ + name?: string; + /** + * + */ + magFilter?: number; + /** + * + */ + minFilter?: number; + /** + * + */ + wrapS?: number; + /** + * + */ + wrapT?: number; + /** + * + */ + extensions?: Record; + /** + * + */ + extras?: unknown; +} + +/** + * + */ +export interface IGltfAccessor { + /** + * + */ + name?: string; + /** + * + */ + bufferView?: number; + /** + * + */ + byteOffset?: number; + /** + * + */ + componentType: number; + /** + * + */ + normalized?: boolean; + /** + * + */ + count: number; + /** + * + */ + type: string; + /** + * + */ + max?: number[]; + /** + * + */ + min?: number[]; + /** + * + */ + sparse?: unknown; + /** + * + */ + extensions?: Record; + /** + * + */ + extras?: unknown; +} + +/** + * + */ +export interface IGltfBufferView { + /** + * + */ + buffer: number; + /** + * + */ + byteOffset?: number; + /** + * + */ + byteLength: number; + /** + * + */ + byteStride?: number; + /** + * + */ + target?: number; + /** + * + */ + name?: string; + /** + * + */ + extensions?: Record; + /** + * + */ + extras?: unknown; +} + +/** + * + */ +export interface IGltfBuffer { + /** + * + */ + uri?: string; + /** + * + */ + byteLength: number; + /** + * + */ + name?: string; + /** + * + */ + extensions?: Record; + /** + * + */ + extras?: unknown; +} + +/** + * + */ +export interface IGltfAnimationChannelTarget { + /** + * + */ + node?: number; + /** + * + */ + path: string; +} + +/** + * + */ +export interface IGltfAnimationChannel { + /** + * + */ + sampler: number; + /** + * + */ + target: IGltfAnimationChannelTarget; +} + +/** + * + */ +export interface IGltfAnimationSampler { + /** + * + */ + input: number; + /** + * + */ + interpolation?: string; + /** + * + */ + output: number; +} + +/** + * + */ +export interface IGltfAnimation { + /** + * + */ + name?: string; + /** + * + */ + channels: IGltfAnimationChannel[]; + /** + * + */ + samplers: IGltfAnimationSampler[]; + /** + * + */ + extensions?: Record; + /** + * + */ + extras?: unknown; +} + +/** + * + */ +export interface IGltfSkin { + /** + * + */ + name?: string; + /** + * + */ + inverseBindMatrices?: number; + /** + * + */ + skeleton?: number; + /** + * + */ + joints: number[]; + /** + * + */ + extensions?: Record; + /** + * + */ + extras?: unknown; +} + +/** + * + */ +export interface IGltfCamera { + /** + * + */ + name?: string; + /** + * + */ + type: string; + /** + * + */ + orthographic?: { + /** + * + */ + xmag: number; + /** + * + */ + ymag: number; + /** + * + */ + zfar: number; + /** + * + */ + znear: number; + }; + /** + * + */ + perspective?: { + /** + * + */ + aspectRatio?: number; + /** + * + */ + yfov: number; + /** + * + */ + zfar?: number; + /** + * + */ + znear: number; + }; + /** + * + */ + extensions?: Record; + /** + * + */ + extras?: unknown; +} + +/* ------------------------------------------------------------------ */ +/* Top-level glTF document */ +/* ------------------------------------------------------------------ */ + +/** + * + */ +export interface IGltfDocument { + /** + * + */ + asset: IGltfAsset; + /** + * + */ + scene?: number; + /** + * + */ + scenes?: IGltfScene[]; + /** + * + */ + nodes?: IGltfNode[]; + /** + * + */ + meshes?: IGltfMesh[]; + /** + * + */ + materials?: IGltfMaterial[]; + /** + * + */ + textures?: IGltfTexture[]; + /** + * + */ + images?: IGltfImage[]; + /** + * + */ + samplers?: IGltfSampler[]; + /** + * + */ + accessors?: IGltfAccessor[]; + /** + * + */ + bufferViews?: IGltfBufferView[]; + /** + * + */ + buffers?: IGltfBuffer[]; + /** + * + */ + animations?: IGltfAnimation[]; + /** + * + */ + skins?: IGltfSkin[]; + /** + * + */ + cameras?: IGltfCamera[]; + /** + * + */ + extensionsUsed?: string[]; + /** + * + */ + extensionsRequired?: string[]; + /** + * + */ + extensions?: Record; + /** + * + */ + extras?: unknown; +} + +/* ------------------------------------------------------------------ */ +/* Extension target types */ +/* ------------------------------------------------------------------ */ + +export type GltfExtensionTargetType = "root" | "scene" | "node" | "mesh" | "material" | "texture" | "image" | "animation"; + +/** + * + */ +export interface IGltfExtensible { + /** + * + */ + extensions?: Record; +} diff --git a/packages/tools/gltf-mcp-server/src/index.ts b/packages/tools/gltf-mcp-server/src/index.ts new file mode 100644 index 00000000000..3aa7b32d1a3 --- /dev/null +++ b/packages/tools/gltf-mcp-server/src/index.ts @@ -0,0 +1,1547 @@ +#!/usr/bin/env node + +/** + * Babylon.js glTF MCP Server + * + * Provides a rich MCP tool surface for loading, inspecting, editing, + * validating, and exporting glTF 2.0 assets in memory. + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod/v4"; +import { + CreateTextResponse, + CreateErrorResponse, + CreateJsonExportResponse, + CreateJsonImportSummaryResponse, + CreateOutputFileSchema, + CreateInlineJsonSchema, + CreateJsonFileSchema, + WriteTextFileEnsuringDirectory, +} from "@tools/mcp-server-core"; + +import { GltfManager } from "./gltfManager.js"; +import { startPreview, stopPreview, isPreviewRunning, getPreviewServerUrl, getSandboxUrl, getPreviewDocName, setPreviewDocument } from "./previewServer.js"; + +/* ------------------------------------------------------------------ */ +/* Singleton manager */ +/* ------------------------------------------------------------------ */ + +const Manager = new GltfManager(); + +/* ------------------------------------------------------------------ */ +/* Server setup */ +/* ------------------------------------------------------------------ */ + +const Server = new McpServer( + { name: "babylonjs-gltf", version: "1.0.0" }, + { + instructions: [ + "You build and edit glTF 2.0 assets in memory.", + "Workflow: create_gltf or load_gltf → inspect with describe/list tools → edit nodes, meshes, materials, textures → validate_gltf → export_gltf_json or export_glb.", + "All documents are identified by a unique name string.", + "Node/mesh/material indices are 0-based and match the glTF arrays.", + "Use set_extension_data for arbitrary extension payloads on any target type.", + "Always validate before exporting to catch broken references.", + ].join(" "), + } +); + +/* ------------------------------------------------------------------ */ +/* Shared schemas */ +/* ------------------------------------------------------------------ */ + +const NameSchema = z.string().describe("Name of the glTF document in memory."); +const NodeIndexSchema = z.number().describe("0-based node index."); +const MeshIndexSchema = z.number().describe("0-based mesh index."); +const MaterialIndexSchema = z.number().describe("0-based material index."); +const TextureIndexSchema = z.number().describe("0-based texture index."); +const ImageIndexSchema = z.number().describe("0-based image index."); +const SamplerIndexSchema = z.number().describe("0-based sampler index."); +const SceneIndexSchema = z.number().describe("0-based scene index."); +const AnimIndexSchema = z.number().describe("0-based animation index."); +const SkinIndexSchema = z.number().describe("0-based skin index."); +const AccessorIndexSchema = z.number().describe("0-based accessor index."); +const ExtensionTargetSchema = z.enum(["root", "scene", "node", "mesh", "material", "texture", "image", "animation"]).describe("Extension target type."); + +/* ================================================================== */ +/* 1. Lifecycle tools */ +/* ================================================================== */ + +Server.registerTool( + "create_gltf", + { + description: "Create a new minimal valid glTF 2.0 document in memory.", + inputSchema: { name: NameSchema }, + }, + async ({ name }) => CreateTextResponse(Manager.createGltf(name)) +); + +Server.registerTool( + "load_gltf", + { + description: "Load a glTF JSON document into memory from inline JSON or a file path.", + inputSchema: { + name: NameSchema, + json: CreateInlineJsonSchema(z, "Inline glTF JSON text."), + jsonFile: CreateJsonFileSchema(z, "Absolute path to a .gltf JSON file."), + }, + }, + async ({ name, json, jsonFile }) => { + const result = CreateJsonImportSummaryResponse({ + json, + jsonFile, + fileDescription: "glTF file", + importJson: (text) => { + Manager.loadGltf(name, text); + return name; + }, + createSuccessText: () => Manager.describeGltf(name), + }); + + // If loaded from a file, resolve external buffer/image URIs + if (jsonFile && !result.content[0]?.text?.startsWith("Error")) { + const { dirname } = await import("node:path"); + await Manager.resolveExternalBuffersAsync(name, dirname(jsonFile)); + } + + return result; + } +); + +Server.registerTool( + "list_gltfs", + { + description: "List all glTF documents currently in memory.", + inputSchema: {}, + }, + async () => CreateTextResponse(Manager.listGltfs()) +); + +Server.registerTool( + "delete_gltf", + { + description: "Delete a glTF document from memory.", + inputSchema: { name: NameSchema }, + }, + async ({ name }) => { + const result = Manager.deleteGltf(name); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "clone_gltf", + { + description: "Deep-clone a glTF document under a new name.", + inputSchema: { + sourceName: z.string().describe("Name of the document to clone."), + newName: z.string().describe("Name for the cloned document."), + }, + }, + async ({ sourceName, newName }) => { + const result = Manager.cloneGltf(sourceName, newName); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +/* ================================================================== */ +/* 2. Inspection tools */ +/* ================================================================== */ + +Server.registerTool( + "describe_gltf", + { + description: "Summarize a glTF document: asset metadata, counts, extensions, and structural warnings.", + inputSchema: { name: NameSchema }, + }, + async ({ name }) => { + const result = Manager.describeGltf(name); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "describe_scene", + { + description: "Describe a scene by index: root nodes, active status, extensions.", + inputSchema: { name: NameSchema, sceneIndex: SceneIndexSchema }, + }, + async ({ name, sceneIndex }) => { + const result = Manager.describeScene(name, sceneIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "describe_node", + { + description: "Describe a node: parent, children, mesh, transform, extensions.", + inputSchema: { name: NameSchema, nodeIndex: NodeIndexSchema }, + }, + async ({ name, nodeIndex }) => { + const result = Manager.describeNode(name, nodeIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "describe_mesh", + { + description: "Describe a mesh: primitives, attributes, material assignments.", + inputSchema: { name: NameSchema, meshIndex: MeshIndexSchema }, + }, + async ({ name, meshIndex }) => { + const result = Manager.describeMesh(name, meshIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "describe_material", + { + description: "Describe a material: PBR properties, textures, alpha mode, extensions.", + inputSchema: { name: NameSchema, materialIndex: MaterialIndexSchema }, + }, + async ({ name, materialIndex }) => { + const result = Manager.describeMaterial(name, materialIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "describe_animation", + { + description: "Describe an animation: channels, samplers, target nodes and paths.", + inputSchema: { name: NameSchema, animationIndex: AnimIndexSchema }, + }, + async ({ name, animationIndex }) => { + const result = Manager.describeAnimation(name, animationIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "describe_skin", + { + description: "Describe a skin: joints, skeleton root, inverse bind matrices.", + inputSchema: { name: NameSchema, skinIndex: SkinIndexSchema }, + }, + async ({ name, skinIndex }) => { + const result = Manager.describeSkin(name, skinIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "describe_texture", + { + description: "Describe a texture: source image, sampler, image details.", + inputSchema: { name: NameSchema, textureIndex: TextureIndexSchema }, + }, + async ({ name, textureIndex }) => { + const result = Manager.describeTexture(name, textureIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "describe_image", + { + description: "Describe an image: URI, MIME type, buffer view reference.", + inputSchema: { name: NameSchema, imageIndex: ImageIndexSchema }, + }, + async ({ name, imageIndex }) => { + const result = Manager.describeImage(name, imageIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "describe_accessor", + { + description: "Describe an accessor: type, component type, count, min/max.", + inputSchema: { name: NameSchema, accessorIndex: AccessorIndexSchema }, + }, + async ({ name, accessorIndex }) => { + const result = Manager.describeAccessor(name, accessorIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "read_accessor_data", + { + description: + "Read the binary data of an accessor as a flat array of float values. " + + "Handles byte stride, normalization, and component type conversion. " + + "Returns the decoded data array, component count per element, and total element count. " + + "Requires buffer data to be embedded as base64 data URIs (automatically done when loading .gltf files from disk).", + inputSchema: { name: NameSchema, accessorIndex: AccessorIndexSchema }, + }, + async ({ name, accessorIndex }) => { + const result = Manager.readAccessorData(name, accessorIndex); + if (typeof result === "string") { + return CreateErrorResponse(result); + } + return CreateTextResponse( + `Accessor ${accessorIndex}: ${result.count} elements × ${result.componentCount} components\n` + `Data (${result.data.length} floats): [${result.data.join(", ")}]` + ); + } +); + +Server.registerTool( + "write_accessor_data", + { + description: + "Write float data back into an accessor's binary buffer. " + + "Converts from float values to the accessor's native component type (BYTE, SHORT, INT, FLOAT, etc.), handling normalization and byte stride. " + + "The data array length must equal accessor.count × componentCount. " + + "Use read_accessor_data first to inspect the current values, then modify and write back. " + + "This enables manipulation of vertex positions, normals, UVs, animation keyframes, weights, and any other accessor-backed data.", + inputSchema: { + name: NameSchema, + accessorIndex: AccessorIndexSchema, + data: z.array(z.number()).describe("Flat array of float values to write. Length must equal count × componentCount of the accessor."), + }, + }, + async ({ name, accessorIndex, data }) => { + const result = Manager.writeAccessorData(name, accessorIndex, data); + if (result) { + return CreateErrorResponse(result); + } + return CreateTextResponse(`Successfully wrote ${data.length} values to accessor ${accessorIndex}.`); + } +); + +Server.registerTool( + "describe_sampler", + { + description: "Describe a sampler: mag/min filter, wrap modes.", + inputSchema: { name: NameSchema, samplerIndex: SamplerIndexSchema }, + }, + async ({ name, samplerIndex }) => { + const result = Manager.describeSampler(name, samplerIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +/* ---------------------- List tools ------------------------------ */ + +Server.registerTool( + "list_scenes", + { + description: "List all scenes with names, node counts, and active status.", + inputSchema: { name: NameSchema }, + }, + async ({ name }) => { + const result = Manager.listScenes(name); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "list_nodes", + { + description: "List all nodes with names, mesh references, and children.", + inputSchema: { name: NameSchema }, + }, + async ({ name }) => { + const result = Manager.listNodes(name); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "list_meshes", + { + description: "List all meshes with names and primitive counts.", + inputSchema: { name: NameSchema }, + }, + async ({ name }) => { + const result = Manager.listMeshes(name); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "list_materials", + { + description: "List all materials with names and key PBR properties.", + inputSchema: { name: NameSchema }, + }, + async ({ name }) => { + const result = Manager.listMaterials(name); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "list_animations", + { + description: "List all animations with names, channel counts, and sampler counts.", + inputSchema: { name: NameSchema }, + }, + async ({ name }) => { + const result = Manager.listAnimations(name); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "list_textures", + { + description: "List all textures with source image and sampler references.", + inputSchema: { name: NameSchema }, + }, + async ({ name }) => { + const result = Manager.listTextures(name); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "list_extensions", + { + description: "List extensionsUsed and extensionsRequired for a document.", + inputSchema: { name: NameSchema }, + }, + async ({ name }) => { + const result = Manager.listExtensions(name); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +/* ================================================================== */ +/* 3. Node and scene editing tools */ +/* ================================================================== */ + +Server.registerTool( + "add_scene", + { + description: "Add a new empty scene to the document.", + inputSchema: { + name: NameSchema, + sceneName: z.string().optional().describe("Name for the new scene."), + }, + }, + async ({ name, sceneName }) => { + const result = Manager.addScene(name, sceneName); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "rename_scene", + { + description: "Rename a scene by index.", + inputSchema: { name: NameSchema, sceneIndex: SceneIndexSchema, newName: z.string().describe("New scene name.") }, + }, + async ({ name, sceneIndex, newName }) => { + const result = Manager.renameScene(name, sceneIndex, newName); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "set_active_scene", + { + description: "Set the active (default) scene index.", + inputSchema: { name: NameSchema, sceneIndex: SceneIndexSchema }, + }, + async ({ name, sceneIndex }) => { + const result = Manager.setActiveScene(name, sceneIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "add_node", + { + description: "Add a new node. Optionally parent it under another node or add to a scene root.", + inputSchema: { + name: NameSchema, + nodeName: z.string().optional().describe("Name for the new node."), + parentNodeIndex: z.number().optional().describe("Parent node index. If omitted, node is added to the scene root."), + sceneIndex: z.number().optional().describe("Scene to add the root node to (default: active scene)."), + }, + }, + async ({ name, nodeName, parentNodeIndex, sceneIndex }) => { + const result = Manager.addNode(name, nodeName, parentNodeIndex, sceneIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "rename_node", + { + description: "Rename a node by index.", + inputSchema: { name: NameSchema, nodeIndex: NodeIndexSchema, newName: z.string().describe("New node name.") }, + }, + async ({ name, nodeIndex, newName }) => { + const result = Manager.renameNode(name, nodeIndex, newName); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "set_node_transform", + { + description: "Set TRS transform on a node. Clears matrix if present.", + inputSchema: { + name: NameSchema, + nodeIndex: NodeIndexSchema, + translation: z.tuple([z.number(), z.number(), z.number()]).optional().describe("[x, y, z] translation."), + rotation: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional().describe("[x, y, z, w] rotation quaternion."), + scale: z.tuple([z.number(), z.number(), z.number()]).optional().describe("[x, y, z] scale."), + }, + }, + async ({ name, nodeIndex, translation, rotation, scale }) => { + const result = Manager.setNodeTransform(name, nodeIndex, translation, rotation, scale); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "set_node_matrix", + { + description: "Set a 4x4 column-major matrix on a node. Clears TRS if present.", + inputSchema: { + name: NameSchema, + nodeIndex: NodeIndexSchema, + matrix: z.array(z.number()).length(16).describe("16-element column-major matrix."), + }, + }, + async ({ name, nodeIndex, matrix }) => { + const result = Manager.setNodeMatrix(name, nodeIndex, matrix); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "clear_node_transform", + { + description: "Remove all transform properties (TRS and matrix) from a node.", + inputSchema: { name: NameSchema, nodeIndex: NodeIndexSchema }, + }, + async ({ name, nodeIndex }) => { + const result = Manager.clearNodeTransform(name, nodeIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "reparent_node", + { + description: "Move a node under a new parent node, or to the scene root. Prevents cycles.", + inputSchema: { + name: NameSchema, + nodeIndex: NodeIndexSchema, + newParentIndex: z.number().optional().describe("New parent node index. Omit to move to scene root."), + sceneIndex: z.number().optional().describe("Scene root to use if moving to scene root (default: active scene)."), + }, + }, + async ({ name, nodeIndex, newParentIndex, sceneIndex }) => { + const result = Manager.reparentNode(name, nodeIndex, newParentIndex, sceneIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "remove_node", + { + description: "Remove a node (nullifies slot to preserve indices).", + inputSchema: { name: NameSchema, nodeIndex: NodeIndexSchema }, + }, + async ({ name, nodeIndex }) => { + const result = Manager.removeNode(name, nodeIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "add_child_node", + { + description: "Add a new child node under a given parent node.", + inputSchema: { + name: NameSchema, + parentIndex: z.number().describe("Parent node index."), + childName: z.string().optional().describe("Name for the new child node."), + }, + }, + async ({ name, parentIndex, childName }) => { + const result = Manager.addChildNode(name, parentIndex, childName); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +/* ================================================================== */ +/* 4. Mesh and primitive editing tools */ +/* ================================================================== */ + +Server.registerTool( + "add_mesh", + { + description: "Add a new mesh with one empty primitive.", + inputSchema: { + name: NameSchema, + meshName: z.string().optional().describe("Name for the new mesh."), + }, + }, + async ({ name, meshName }) => { + const result = Manager.addMesh(name, meshName); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "remove_mesh", + { + description: "Remove a mesh and clear node references to it.", + inputSchema: { name: NameSchema, meshIndex: MeshIndexSchema }, + }, + async ({ name, meshIndex }) => { + const result = Manager.removeMesh(name, meshIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "assign_mesh_to_node", + { + description: "Assign a mesh to a node.", + inputSchema: { name: NameSchema, nodeIndex: NodeIndexSchema, meshIndex: MeshIndexSchema }, + }, + async ({ name, nodeIndex, meshIndex }) => { + const result = Manager.assignMeshToNode(name, nodeIndex, meshIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "unassign_mesh_from_node", + { + description: "Remove the mesh assignment from a node.", + inputSchema: { name: NameSchema, nodeIndex: NodeIndexSchema }, + }, + async ({ name, nodeIndex }) => { + const result = Manager.unassignMeshFromNode(name, nodeIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "describe_mesh_primitives", + { + description: "Describe primitives of a mesh: attributes, material, mode.", + inputSchema: { name: NameSchema, meshIndex: MeshIndexSchema }, + }, + async ({ name, meshIndex }) => { + const result = Manager.describeMeshPrimitives(name, meshIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "set_primitive_material", + { + description: "Set the material on a specific mesh primitive.", + inputSchema: { + name: NameSchema, + meshIndex: MeshIndexSchema, + primitiveIndex: z.number().describe("0-based primitive index within the mesh."), + materialIndex: MaterialIndexSchema, + }, + }, + async ({ name, meshIndex, primitiveIndex, materialIndex }) => { + const result = Manager.setPrimitiveMaterial(name, meshIndex, primitiveIndex, materialIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "remove_primitive_material", + { + description: "Remove the material assignment from a mesh primitive.", + inputSchema: { + name: NameSchema, + meshIndex: MeshIndexSchema, + primitiveIndex: z.number().describe("0-based primitive index within the mesh."), + }, + }, + async ({ name, meshIndex, primitiveIndex }) => { + const result = Manager.removePrimitiveMaterial(name, meshIndex, primitiveIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +/* ================================================================== */ +/* 5. Material editing tools */ +/* ================================================================== */ + +Server.registerTool( + "add_material", + { + description: "Add a new PBR material with default metallic-roughness properties.", + inputSchema: { + name: NameSchema, + materialName: z.string().optional().describe("Name for the new material."), + }, + }, + async ({ name, materialName }) => { + const result = Manager.addMaterial(name, materialName); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "remove_material", + { + description: "Remove a material and clear primitive references to it.", + inputSchema: { name: NameSchema, materialIndex: MaterialIndexSchema }, + }, + async ({ name, materialIndex }) => { + const result = Manager.removeMaterial(name, materialIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "rename_material", + { + description: "Rename a material by index.", + inputSchema: { name: NameSchema, materialIndex: MaterialIndexSchema, newName: z.string().describe("New material name.") }, + }, + async ({ name, materialIndex, newName }) => { + const result = Manager.renameMaterial(name, materialIndex, newName); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "set_material_pbr", + { + description: "Set PBR metallic-roughness properties on a material.", + inputSchema: { + name: NameSchema, + materialIndex: MaterialIndexSchema, + baseColorFactor: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional().describe("[r, g, b, a] base color factor."), + metallicFactor: z.number().optional().describe("Metallic factor (0-1)."), + roughnessFactor: z.number().optional().describe("Roughness factor (0-1)."), + baseColorTexture: z.number().optional().describe("Texture index for base color."), + metallicRoughnessTexture: z.number().optional().describe("Texture index for metallic-roughness."), + }, + }, + async ({ name, materialIndex, baseColorFactor, metallicFactor, roughnessFactor, baseColorTexture, metallicRoughnessTexture }) => { + const result = Manager.setMaterialPbr(name, materialIndex, baseColorFactor, metallicFactor, roughnessFactor, baseColorTexture, metallicRoughnessTexture); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "set_material_alpha_mode", + { + description: "Set alpha mode (OPAQUE, MASK, BLEND) and optional cutoff on a material.", + inputSchema: { + name: NameSchema, + materialIndex: MaterialIndexSchema, + alphaMode: z.enum(["OPAQUE", "MASK", "BLEND"]).describe("Alpha mode."), + alphaCutoff: z.number().optional().describe("Alpha cutoff (only meaningful for MASK)."), + }, + }, + async ({ name, materialIndex, alphaMode, alphaCutoff }) => { + const result = Manager.setMaterialAlphaMode(name, materialIndex, alphaMode, alphaCutoff); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "set_material_double_sided", + { + description: "Set whether a material is double-sided.", + inputSchema: { name: NameSchema, materialIndex: MaterialIndexSchema, doubleSided: z.boolean().describe("Whether the material is double-sided.") }, + }, + async ({ name, materialIndex, doubleSided }) => { + const result = Manager.setMaterialDoubleSided(name, materialIndex, doubleSided); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "set_material_emissive", + { + description: "Set emissive factor and/or emissive texture on a material.", + inputSchema: { + name: NameSchema, + materialIndex: MaterialIndexSchema, + emissiveFactor: z.tuple([z.number(), z.number(), z.number()]).optional().describe("[r, g, b] emissive factor."), + emissiveTexture: z.number().optional().describe("Texture index for emissive."), + }, + }, + async ({ name, materialIndex, emissiveFactor, emissiveTexture }) => { + const result = Manager.setMaterialEmissive(name, materialIndex, emissiveFactor, emissiveTexture); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "assign_material_to_mesh_primitive", + { + description: "Assign a material to a specific mesh primitive (alias for set_primitive_material).", + inputSchema: { + name: NameSchema, + meshIndex: MeshIndexSchema, + primitiveIndex: z.number().describe("0-based primitive index."), + materialIndex: MaterialIndexSchema, + }, + }, + async ({ name, meshIndex, primitiveIndex, materialIndex }) => { + const result = Manager.assignMaterialToMeshPrimitive(name, meshIndex, primitiveIndex, materialIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +/* ================================================================== */ +/* 6. Texture / image / sampler tools */ +/* ================================================================== */ + +Server.registerTool( + "add_image_reference", + { + description: "Add a URI-backed image reference.", + inputSchema: { + name: NameSchema, + uri: z.string().describe("Image URI (relative path or data URI)."), + imageName: z.string().optional().describe("Name for the image."), + mimeType: z.string().optional().describe("MIME type (e.g. image/png)."), + }, + }, + async ({ name, uri, imageName, mimeType }) => { + const result = Manager.addImageReference(name, uri, imageName, mimeType); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "remove_image", + { + description: "Remove an image and clear texture source references.", + inputSchema: { name: NameSchema, imageIndex: ImageIndexSchema }, + }, + async ({ name, imageIndex }) => { + const result = Manager.removeImage(name, imageIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "add_texture", + { + description: "Add a texture with optional source image and sampler references.", + inputSchema: { + name: NameSchema, + sourceImage: z.number().optional().describe("Image index for this texture."), + sampler: z.number().optional().describe("Sampler index for this texture."), + textureName: z.string().optional().describe("Name for the texture."), + }, + }, + async ({ name, sourceImage, sampler, textureName }) => { + const result = Manager.addTexture(name, sourceImage, sampler, textureName); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "remove_texture", + { + description: "Remove a texture by index.", + inputSchema: { name: NameSchema, textureIndex: TextureIndexSchema }, + }, + async ({ name, textureIndex }) => { + const result = Manager.removeTexture(name, textureIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "set_texture_sampler", + { + description: "Set the sampler on a texture.", + inputSchema: { name: NameSchema, textureIndex: TextureIndexSchema, samplerIndex: SamplerIndexSchema }, + }, + async ({ name, textureIndex, samplerIndex }) => { + const result = Manager.setTextureSampler(name, textureIndex, samplerIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "add_sampler", + { + description: "Add a texture sampler with optional filter and wrap settings.", + inputSchema: { + name: NameSchema, + magFilter: z.number().optional().describe("Magnification filter (9728=NEAREST, 9729=LINEAR)."), + minFilter: z.number().optional().describe("Minification filter."), + wrapS: z.number().optional().describe("S wrapping mode (10497=REPEAT, 33071=CLAMP_TO_EDGE, 33648=MIRRORED_REPEAT)."), + wrapT: z.number().optional().describe("T wrapping mode."), + samplerName: z.string().optional().describe("Name for the sampler."), + }, + }, + async ({ name, magFilter, minFilter, wrapS, wrapT, samplerName }) => { + const result = Manager.addSampler(name, magFilter, minFilter, wrapS, wrapT, samplerName); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "remove_sampler", + { + description: "Remove a sampler and clear texture references.", + inputSchema: { name: NameSchema, samplerIndex: SamplerIndexSchema }, + }, + async ({ name, samplerIndex }) => { + const result = Manager.removeSampler(name, samplerIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +/* ================================================================== */ +/* 7. Animation and skin tools */ +/* ================================================================== */ + +Server.registerTool( + "list_animation_channels", + { + description: "List all channels of an animation.", + inputSchema: { name: NameSchema, animationIndex: AnimIndexSchema }, + }, + async ({ name, animationIndex }) => { + const result = Manager.listAnimationChannels(name, animationIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "describe_animation_channel", + { + description: "Describe a specific animation channel: target, sampler, interpolation.", + inputSchema: { + name: NameSchema, + animationIndex: AnimIndexSchema, + channelIndex: z.number().describe("0-based channel index."), + }, + }, + async ({ name, animationIndex, channelIndex }) => { + const result = Manager.describeAnimationChannel(name, animationIndex, channelIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "rename_animation", + { + description: "Rename an animation by index.", + inputSchema: { name: NameSchema, animationIndex: AnimIndexSchema, newName: z.string().describe("New animation name.") }, + }, + async ({ name, animationIndex, newName }) => { + const result = Manager.renameAnimation(name, animationIndex, newName); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "remove_animation", + { + description: "Remove an animation by index.", + inputSchema: { name: NameSchema, animationIndex: AnimIndexSchema }, + }, + async ({ name, animationIndex }) => { + const result = Manager.removeAnimation(name, animationIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "remove_skin", + { + description: "Remove a skin and clear node references.", + inputSchema: { name: NameSchema, skinIndex: SkinIndexSchema }, + }, + async ({ name, skinIndex }) => { + const result = Manager.removeSkin(name, skinIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +/* ================================================================== */ +/* 8. Extension handling tools */ +/* ================================================================== */ + +Server.registerTool( + "get_extension_data", + { + description: "Get extension data from a target (root, scene, node, mesh, material, texture, image, animation).", + inputSchema: { + name: NameSchema, + extensionName: z.string().describe("Extension name (e.g. KHR_materials_unlit)."), + targetType: ExtensionTargetSchema, + targetIndex: z.number().optional().describe("Index of target object (not needed for root)."), + }, + }, + async ({ name, extensionName, targetType, targetIndex }) => { + const result = Manager.getExtensionData(name, extensionName, targetType, targetIndex); + return result.startsWith("Error") || result.startsWith("No extension") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "set_extension_data", + { + description: "Set extension data on a target. Creates the extensions object if needed.", + inputSchema: { + name: NameSchema, + extensionName: z.string().describe("Extension name."), + data: z.any().describe("Extension data (JSON object)."), + targetType: ExtensionTargetSchema, + targetIndex: z.number().optional().describe("Index of target object (not needed for root)."), + }, + }, + async ({ name, extensionName, data, targetType, targetIndex }) => { + const result = Manager.setExtensionData(name, extensionName, data, targetType, targetIndex); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "remove_extension_data", + { + description: "Remove extension data from a target.", + inputSchema: { + name: NameSchema, + extensionName: z.string().describe("Extension name."), + targetType: ExtensionTargetSchema, + targetIndex: z.number().optional().describe("Index of target object (not needed for root)."), + }, + }, + async ({ name, extensionName, targetType, targetIndex }) => { + const result = Manager.removeExtensionData(name, extensionName, targetType, targetIndex); + return result.startsWith("Error") || result.startsWith("No extension") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "add_extension_to_used", + { + description: "Add an extension name to extensionsUsed.", + inputSchema: { name: NameSchema, extensionName: z.string().describe("Extension name.") }, + }, + async ({ name, extensionName }) => { + const result = Manager.addExtensionToUsed(name, extensionName); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "add_extension_to_required", + { + description: "Add an extension to extensionsRequired (and extensionsUsed).", + inputSchema: { name: NameSchema, extensionName: z.string().describe("Extension name.") }, + }, + async ({ name, extensionName }) => { + const result = Manager.addExtensionToRequired(name, extensionName); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "remove_extension_from_used", + { + description: "Remove an extension from extensionsUsed (and extensionsRequired).", + inputSchema: { name: NameSchema, extensionName: z.string().describe("Extension name.") }, + }, + async ({ name, extensionName }) => { + const result = Manager.removeExtensionFromUsed(name, extensionName); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "remove_extension_from_required", + { + description: "Remove an extension from extensionsRequired only.", + inputSchema: { name: NameSchema, extensionName: z.string().describe("Extension name.") }, + }, + async ({ name, extensionName }) => { + const result = Manager.removeExtensionFromRequired(name, extensionName); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +/* ================================================================== */ +/* 9. Validation tools */ +/* ================================================================== */ + +Server.registerTool( + "validate_gltf", + { + description: "Validate a glTF document for broken references, invalid hierarchy, extension consistency, and structural issues.", + inputSchema: { name: NameSchema }, + }, + async ({ name }) => { + const issues = Manager.validateGltf(name); + if (issues.length === 1 && issues[0].severity === "error" && issues[0].message.startsWith("Error:")) { + return CreateErrorResponse(issues[0].message); + } + return CreateTextResponse(Manager.summarizeIssues(issues)); + } +); + +/* ================================================================== */ +/* 10. Import/Export tools */ +/* ================================================================== */ + +Server.registerTool( + "export_gltf_json", + { + description: "Export a glTF document as JSON text, or write it to a file.", + inputSchema: { + name: NameSchema, + outputFile: CreateOutputFileSchema(z), + }, + }, + async ({ name, outputFile }) => { + return CreateJsonExportResponse({ + jsonText: Manager.exportJson(name), + outputFile, + missingMessage: `No glTF document named "${name}".`, + fileLabel: "glTF JSON", + }); + } +); + +Server.registerTool( + "import_gltf_json", + { + description: "Import a glTF document from inline JSON or a JSON file.", + inputSchema: { + name: NameSchema, + json: CreateInlineJsonSchema(z, "Inline glTF JSON text."), + jsonFile: CreateJsonFileSchema(z, "Absolute path to a .gltf JSON file."), + }, + }, + async ({ name, json, jsonFile }) => { + const result = CreateJsonImportSummaryResponse({ + json, + jsonFile, + fileDescription: "glTF file", + importJson: (text) => { + const r = Manager.loadGltf(name, text); + if (r.startsWith("Error")) { + throw new Error(r); + } + return name; + }, + createSuccessText: () => `Imported.\n\n${Manager.describeGltf(name)}`, + }); + + // If loaded from a file, resolve external buffer/image URIs + if (jsonFile && !result.content[0]?.text?.startsWith("Error")) { + const { dirname } = await import("node:path"); + await Manager.resolveExternalBuffersAsync(name, dirname(jsonFile)); + } + + return result; + } +); + +Server.registerTool( + "export_glb", + { + description: "Export a glTF document as a binary .glb file. Supports JSON-only content (no embedded binary buffers).", + inputSchema: { + name: NameSchema, + outputFile: z.string().describe("Absolute file path for the .glb output."), + }, + }, + async ({ name, outputFile }) => { + const glb = Manager.exportGlb(name); + if (!glb) { + return CreateErrorResponse(`No glTF document named "${name}".`); + } + try { + const { mkdirSync, writeFileSync } = await import("node:fs"); + const { dirname } = await import("node:path"); + mkdirSync(dirname(outputFile), { recursive: true }); + writeFileSync(outputFile, glb); + return CreateTextResponse(`GLB written to: ${outputFile} (${glb.length} bytes)`); + } catch (error) { + return CreateErrorResponse(`Error writing GLB: ${(error as Error).message}`); + } + } +); + +Server.registerTool( + "import_glb", + { + description: "Import a binary .glb file from disk into memory as a glTF document.", + inputSchema: { + name: NameSchema, + filePath: z.string().describe("Absolute path to the .glb file to import."), + }, + }, + async ({ name, filePath }) => { + try { + const { readFileSync } = await import("node:fs"); + const buffer = readFileSync(filePath); + const result = Manager.importGlb(name, buffer); + if (result.startsWith("Error")) { + return CreateErrorResponse(result); + } + return CreateTextResponse(result); + } catch (error) { + return CreateErrorResponse(`Error reading GLB file: ${(error as Error).message}`); + } + } +); + +Server.registerTool( + "compact_indices", + { + description: "Remove null slots from document arrays and remap all cross-references. Use after removing nodes, meshes, materials, etc. to reclaim clean 0-based indices.", + inputSchema: { name: NameSchema }, + }, + async ({ name }) => { + const result = Manager.compactIndices(name); + if (result.startsWith("Error")) { + return CreateErrorResponse(result); + } + return CreateTextResponse(result); + } +); + +Server.registerTool( + "save_to_file", + { + description: "Save a glTF document to a .gltf JSON file or .glb binary file based on the file extension.", + inputSchema: { + name: NameSchema, + filePath: z.string().describe("Absolute path to write the file to. Use .gltf for JSON, .glb for binary."), + }, + }, + async ({ name, filePath }) => { + if (filePath.endsWith(".glb")) { + const glb = Manager.exportGlb(name); + if (!glb) { + return CreateErrorResponse(`No glTF document named "${name}".`); + } + try { + const { mkdirSync, writeFileSync } = await import("node:fs"); + const { dirname } = await import("node:path"); + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, glb); + return CreateTextResponse(`GLB saved to: ${filePath} (${glb.length} bytes)`); + } catch (error) { + return CreateErrorResponse(`Error writing file: ${(error as Error).message}`); + } + } + const json = Manager.exportJson(name); + if (!json) { + return CreateErrorResponse(`No glTF document named "${name}".`); + } + try { + WriteTextFileEnsuringDirectory(filePath, json); + return CreateTextResponse(`glTF JSON saved to: ${filePath}`); + } catch (error) { + return CreateErrorResponse(`Error writing file: ${(error as Error).message}`); + } + } +); + +/* ================================================================== */ +/* 10b. Preview tools */ +/* ================================================================== */ + +Server.registerTool( + "start_preview", + { + description: + "Start a local preview server and return a Babylon.js Sandbox URL to view the glTF document in a browser. " + + "The server re-exports the document on every request, so the preview always reflects the latest state. " + + "Refresh the page after making edits to see changes. Open the server root URL for a built-in viewer, or use the Sandbox URL directly.", + inputSchema: { + name: NameSchema, + port: z.number().int().min(1024).max(65535).optional().describe("Port for the local server (default: 8766)."), + }, + }, + async ({ name, port }) => { + try { + const sandboxUrl = await startPreview(Manager, name, port ?? 8766); + const serverUrl = getPreviewServerUrl(); + return CreateTextResponse( + [ + `Preview server started!`, + ``, + ` Document: ${name}`, + ` Viewer: ${serverUrl}/`, + ` Sandbox: ${sandboxUrl}`, + ``, + `Open the Viewer URL for a built-in 3D viewer (recommended).`, + `The model is served live — refresh the page after edits.`, + ``, + `Direct links:`, + ` GLB: ${serverUrl}/model.glb`, + ` JSON: ${serverUrl}/model.gltf`, + ` Info: ${serverUrl}/api/info`, + ].join("\n") + ); + } catch (error) { + return CreateErrorResponse(`Failed to start preview: ${(error as Error).message}`); + } + } +); + +Server.registerTool( + "stop_preview", + { + description: "Stop the running glTF preview server.", + inputSchema: {}, + }, + async () => { + if (!isPreviewRunning()) { + return CreateTextResponse("No preview server is running."); + } + await stopPreview(); + return CreateTextResponse("Preview server stopped."); + } +); + +Server.registerTool( + "get_preview_url", + { + description: "Get the current preview server URL and Sandbox link.", + inputSchema: {}, + }, + async () => { + if (!isPreviewRunning()) { + return CreateTextResponse("No preview server is running. Use start_preview to start one."); + } + const serverUrl = getPreviewServerUrl(); + const sandboxUrl = getSandboxUrl(); + const docName = getPreviewDocName(); + return CreateTextResponse([`Document: ${docName}`, `Viewer: ${serverUrl}/`, `Sandbox: ${sandboxUrl}`].join("\n")); + } +); + +Server.registerTool( + "set_preview_scene", + { + description: "Switch which glTF document the preview server is serving (without restarting).", + inputSchema: { + name: NameSchema, + }, + }, + async ({ name }) => { + if (!isPreviewRunning()) { + return CreateErrorResponse("No preview server is running. Use start_preview first."); + } + setPreviewDocument(name); + return CreateTextResponse(`Preview now serving "${name}". Refresh the Sandbox page to see it.`); + } +); + +/* ================================================================== */ +/* 11. Search/discovery tools */ +/* ================================================================== */ + +Server.registerTool( + "find_nodes", + { + description: "Search for nodes by name (substring or exact match).", + inputSchema: { + name: NameSchema, + query: z.string().describe("Name search query."), + exact: z.boolean().optional().describe("If true, match name exactly (case-insensitive). Default: substring match."), + }, + }, + async ({ name, query, exact }) => { + const result = Manager.findNodes(name, query, exact); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "find_materials", + { + description: "Search for materials by name (substring or exact match).", + inputSchema: { + name: NameSchema, + query: z.string().describe("Name search query."), + exact: z.boolean().optional().describe("If true, match name exactly (case-insensitive). Default: substring match."), + }, + }, + async ({ name, query, exact }) => { + const result = Manager.findMaterials(name, query, exact); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "find_meshes", + { + description: "Search for meshes by name (substring or exact match).", + inputSchema: { + name: NameSchema, + query: z.string().describe("Name search query."), + exact: z.boolean().optional().describe("If true, match name exactly (case-insensitive). Default: substring match."), + }, + }, + async ({ name, query, exact }) => { + const result = Manager.findMeshes(name, query, exact); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +Server.registerTool( + "find_extensions", + { + description: "Search for extensions referenced anywhere in the document.", + inputSchema: { + name: NameSchema, + query: z.string().describe("Extension name search query (substring)."), + }, + }, + async ({ name, query }) => { + const result = Manager.findExtensions(name, query); + return result.startsWith("Error") ? CreateErrorResponse(result) : CreateTextResponse(result); + } +); + +/* ================================================================== */ +/* Resources */ +/* ================================================================== */ + +Server.registerResource("gltf-concepts", "gltf://concepts", { description: "Overview of glTF 2.0 concepts and structure." }, async (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: "text/markdown", + text: [ + "# glTF 2.0 Concepts", + "", + "## Document Structure", + "A glTF document has: asset metadata, scenes, nodes (scene graph), meshes (geometry), materials (PBR), textures, images, samplers, accessors, buffer views, buffers, animations, skins, cameras.", + "", + "## Key Relationships", + "- Scenes contain root node indices", + "- Nodes can have children (forming a hierarchy), a mesh, a skin, a camera, and a TRS/matrix transform", + "- Meshes have primitives, each with attributes (accessors), optional indices, and optional material", + "- Materials use PBR metallic-roughness workflow with texture references", + "- Textures reference an image source and a sampler", + "- Animations have channels (targeting node properties) and samplers (keyframe data via accessors)", + "", + "## Extensions", + "- extensionsUsed: extensions used anywhere in the document", + "- extensionsRequired: extensions that must be supported to load the document", + "- Extension data can be attached to most objects via an extensions property", + "", + "## Common Extensions", + "- KHR_materials_unlit: unlit material", + "- KHR_materials_clearcoat: clearcoat layer", + "- KHR_materials_transmission: transmission/transparency", + "- KHR_materials_emissive_strength: HDR emissive", + "- KHR_texture_transform: UV transform", + "- KHR_draco_mesh_compression: Draco compression", + "- KHR_mesh_quantization: quantized attributes", + "- KHR_lights_punctual: point/spot/directional lights", + "- MSFT_lod: level of detail", + ].join("\n"), + }, + ], +})); + +/* ================================================================== */ +/* Prompts */ +/* ================================================================== */ + +Server.registerPrompt("create-scene-with-materials", { description: "Create a glTF scene with nodes and PBR materials step by step." }, () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + "Create a glTF document. Add a scene, then add several nodes.',", + "Add meshes and assign them to nodes.", + "Create PBR materials with different base colors and roughness values.',", + "Assign materials to mesh primitives.", + "Validate the result and export as JSON.", + ].join("\n"), + }, + }, + ], +})); + +Server.registerPrompt("inspect-and-optimize", { description: "Load, inspect, and optimize an existing glTF asset." }, () => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: [ + "Load a glTF file, inspect its structure.", + "List all nodes, meshes, and materials.", + "Check for unused materials or broken references.", + "Validate the document and report issues.", + ].join("\n"), + }, + }, + ], +})); + +/* ================================================================== */ +/* Start */ +/* ================================================================== */ + +async function Cleanup() { + if (isPreviewRunning()) { + await stopPreview(); + } +} + +async function Main() { + const transport = new StdioServerTransport(); + + // Stop the preview server when the transport closes (session ends) + transport.onclose = () => { + void Cleanup(); + }; + + // Stop the preview server on process signals (restart / kill) + process.on("SIGINT", () => { + void (async () => { + await Cleanup(); + process.exit(0); + })(); + }); + process.on("SIGTERM", () => { + void (async () => { + await Cleanup(); + process.exit(0); + })(); + }); + process.on("beforeExit", () => void Cleanup()); + + await Server.connect(transport); + // eslint-disable-next-line no-console + console.error("babylonjs-gltf MCP server running on stdio"); +} + +try { + await Main(); +} catch (err) { + // eslint-disable-next-line no-console + console.error("Fatal error:", err); + process.exit(1); +} diff --git a/packages/tools/gltf-mcp-server/src/previewServer.ts b/packages/tools/gltf-mcp-server/src/previewServer.ts new file mode 100644 index 00000000000..b97d39d44b4 --- /dev/null +++ b/packages/tools/gltf-mcp-server/src/previewServer.ts @@ -0,0 +1,480 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/** + * PreviewServer — built-in HTTP server that serves a glTF document from + * the MCP server's in-memory state via the Babylon.js Sandbox. + * + * Design: + * ─────── + * 1. Uses Node's built-in `http` module (zero external dependencies). + * 2. Regenerates the GLB on every request — the preview always reflects + * the latest in-memory state without needing a restart. + * 3. Singleton — only one preview server runs at a time. + * 4. Non-blocking — runs in the background alongside the MCP stdio transport. + * + * The server exposes several routes: + * - `GET /model.glb` — serves the active document as a GLB binary + * - `GET /model.gltf` — serves the active document as glTF JSON + * - `GET /api/info` — returns a JSON summary of the active document + * - `GET /` — redirects to the Sandbox URL + */ + +import * as http from "http"; +import { type GltfManager } from "./gltfManager.js"; + +// ═══════════════════════════════════════════════════════════════════════════ +// State +// ═══════════════════════════════════════════════════════════════════════════ + +let _server: http.Server | null = null; +let _port: number = 0; +let _docName: string = ""; +let _manager: GltfManager | null = null; +let _version: number = 0; + +const SANDBOX_BASE = "https://sandbox.babylonjs.com"; + +// ═══════════════════════════════════════════════════════════════════════════ +// Built-in viewer HTML +// ═══════════════════════════════════════════════════════════════════════════ + +function getViewerHtml(serverUrl: string, sandboxUrl: string): string { + return ` + + + +glTF Preview — ${_docName} + + + +
+ + Open in Sandbox ↗ +
+ +
+
+ + + + + +
+ + + 1.0x +
+
+
+ + 0.00 / 0.00s +
+
+